From 3ba857c88a02a8bbc5fe0afd5d20f951480e6dc4 Mon Sep 17 00:00:00 2001 From: Maksim Zdobnikau <43750648+DelevoXDG@users.noreply.github.com> Date: Mon, 8 Apr 2024 12:45:13 +0200 Subject: [PATCH] Add `StarknetByteArray`; Support revision 1 `string` in `StarknetTypedData` (#175) --- Sources/Starknet/Data/ByteArray.swift | 60 +++++++++++++++++++ .../Data/Numbers/NumAsHexProtocol.swift | 10 ---- .../Data/TypedData/StarknetTypedData.swift | 14 ++++- Sources/Starknet/Extensions/String.swift | 17 ++++++ Tests/StarknetTests/Data/ByteArrayTests.swift | 33 ++++++++++ Tests/StarknetTests/Data/TypedDataTests.swift | 16 +++++ .../typed_data_rev_1_basic_types_example.json | 41 +++++++++++++ 7 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 Sources/Starknet/Data/ByteArray.swift create mode 100644 Sources/Starknet/Extensions/String.swift create mode 100644 Tests/StarknetTests/Data/ByteArrayTests.swift create mode 100644 Tests/StarknetTests/Resources/TypedData/typed_data_rev_1_basic_types_example.json diff --git a/Sources/Starknet/Data/ByteArray.swift b/Sources/Starknet/Data/ByteArray.swift new file mode 100644 index 000000000..8123d0c1a --- /dev/null +++ b/Sources/Starknet/Data/ByteArray.swift @@ -0,0 +1,60 @@ +import Foundation + +private let shortStringMaxLen = 31 + +/// Represents a ByteArray struct from Cairo. +/// +/// The ByteArray struct is used to represent a string in Cairo. +/// +/// - Parameters: +/// - data: list of 31-byte chunks of the byte array +/// - pendingWord: the last chunk of the byte array, which consists of at most 30 bytes +/// - pendingWordLen: the number of bytes in `pendingWord` +public struct StarknetByteArray: Equatable, Hashable, ExpressibleByStringLiteral { + let data: [Felt] + let pendingWord: Felt + let pendingWordLen: Int + + public init?(data: [Felt], pendingWord: Felt, pendingWordLen: Int) { + self.data = data + self.pendingWord = pendingWord + self.pendingWordLen = pendingWordLen + + guard self.data.allSatisfy({ $0.byteWidth == shortStringMaxLen }), + self.pendingWordLen >= 0, + self.pendingWordLen < shortStringMaxLen, + self.pendingWord.byteWidth == self.pendingWordLen + else { + return nil + } + } + + public init(fromString string: String) { + let shortStrings = string.splitToShortStrings() + let encodedShortStrings = shortStrings.map { Felt.fromShortString($0)! } + + if shortStrings.isEmpty || shortStrings.last!.count == shortStringMaxLen { + self.data = encodedShortStrings + self.pendingWord = .zero + self.pendingWordLen = 0 + } else { + self.data = encodedShortStrings.dropLast() + self.pendingWord = encodedShortStrings.last! + self.pendingWordLen = shortStrings.last!.count + } + } +} + +public extension StarknetByteArray { + typealias StringLiteralType = String + + init(stringLiteral value: String) { + self.init(fromString: value) + } +} + +private extension Felt { + var byteWidth: Int { + (value.bitWidth + 7) / 8 + } +} diff --git a/Sources/Starknet/Data/Numbers/NumAsHexProtocol.swift b/Sources/Starknet/Data/Numbers/NumAsHexProtocol.swift index 61f19ed9c..791ba319f 100644 --- a/Sources/Starknet/Data/Numbers/NumAsHexProtocol.swift +++ b/Sources/Starknet/Data/Numbers/NumAsHexProtocol.swift @@ -110,16 +110,6 @@ public extension NumAsHexProtocol { } } -extension String { - func components(withMaxLength length: Int) -> [String] { - stride(from: 0, to: self.count, by: length).map { - let start = self.index(self.startIndex, offsetBy: $0) - let end = self.index(start, offsetBy: length, limitedBy: self.endIndex) ?? self.endIndex - return String(self[start ..< end]) - } - } -} - public enum HexStringDecodingError: Error { case invalidStringFormat } diff --git a/Sources/Starknet/Data/TypedData/StarknetTypedData.swift b/Sources/Starknet/Data/TypedData/StarknetTypedData.swift index fb8c0bcec..2e892befc 100644 --- a/Sources/Starknet/Data/TypedData/StarknetTypedData.swift +++ b/Sources/Starknet/Data/TypedData/StarknetTypedData.swift @@ -230,7 +230,7 @@ public struct StarknetTypedData: Codable, Equatable, Hashable { case ("bool", _): return try unwrapBool(from: element) case ("string", .v1): - fatalError("This function is not yet implemented") + return try hashArray(unwrapLongString(from: element)) case ("selector", _): return try unwrapSelector(from: element) case ("merkletree", _): @@ -493,6 +493,18 @@ extension StarknetTypedData { } } + func unwrapLongString(from element: Element) throws -> [Felt] { + guard case let .string(string) = element else { + throw StarknetTypedDataError.decodingError + } + + let byteArray = StarknetByteArray(fromString: string) + + return [Felt(byteArray.data.count)!] + + byteArray.data + + [byteArray.pendingWord, Felt(byteArray.pendingWordLen)!] + } + func unwrapBool(from element: Element) throws -> Felt { switch element { case let .felt(felt): diff --git a/Sources/Starknet/Extensions/String.swift b/Sources/Starknet/Extensions/String.swift new file mode 100644 index 000000000..e3b4a0993 --- /dev/null +++ b/Sources/Starknet/Extensions/String.swift @@ -0,0 +1,17 @@ +import Foundation + +public extension String { + func splitToShortStrings() -> [String] { + self.components(withMaxLength: 31) + } +} + +extension String { + func components(withMaxLength length: Int) -> [String] { + stride(from: 0, to: self.count, by: length).map { + let start = self.index(self.startIndex, offsetBy: $0) + let end = self.index(start, offsetBy: length, limitedBy: self.endIndex) ?? self.endIndex + return String(self[start ..< end]) + } + } +} diff --git a/Tests/StarknetTests/Data/ByteArrayTests.swift b/Tests/StarknetTests/Data/ByteArrayTests.swift new file mode 100644 index 000000000..92e36be13 --- /dev/null +++ b/Tests/StarknetTests/Data/ByteArrayTests.swift @@ -0,0 +1,33 @@ +import XCTest + +@testable import Starknet + +final class ByteArrayTests: XCTestCase { + static let cases: [(String, StarknetByteArray)] = [ + ("hello", .init(data: [], pendingWord: "0x68656c6c6f", pendingWordLen: 5)!), + ("Long string, more than 31 characters.", .init(data: ["0x4c6f6e6720737472696e672c206d6f7265207468616e203331206368617261"], pendingWord: "0x63746572732e", pendingWordLen: 6)!), + ("ABCDEFGHIJKLMNOPQRSTUVWXYZ12345AAADEFGHIJKLMNOPQRSTUVWXYZ12345A", .init(data: ["0x4142434445464748494a4b4c4d4e4f505152535455565758595a3132333435", "0x4141414445464748494a4b4c4d4e4f505152535455565758595a3132333435"], pendingWord: "0x41", pendingWordLen: 1)!), + ("ABCDEFGHIJKLMNOPQRSTUVWXYZ12345", .init(data: ["0x4142434445464748494a4b4c4d4e4f505152535455565758595a3132333435"], pendingWord: .zero, pendingWordLen: 0)!), + ("ABCDEFGHIJKLMNOPQRSTUVWXYZ1234", .init(data: [], pendingWord: "0x4142434445464748494a4b4c4d4e4f505152535455565758595a31323334", pendingWordLen: 30)!), + ("", .init(data: [], pendingWord: .zero, pendingWordLen: 0)!), + ] + + func testByteArrayFromString() { + for (string, expected) in ByteArrayTests.cases { + print(string) + let actual = StarknetByteArray(fromString: string) + XCTAssertEqual(actual, expected) + } + } + + func testExpressibleByStringLiteral() { + let byteArray: StarknetByteArray = "hello" + XCTAssertEqual(byteArray, StarknetByteArray(data: [], pendingWord: "0x68656c6c6f", pendingWordLen: 5)) + } + + func testInvalidByteArray() { + XCTAssertNil(StarknetByteArray(data: [], pendingWord: "0x68656c6c6f", pendingWordLen: 31)) // pendingWordLen too big + XCTAssertNil(StarknetByteArray(data: [], pendingWord: "0x68656c6c6f6", pendingWordLen: 4)) // pendingWordLen is not equal to the length of pendingWord + XCTAssertNil(StarknetByteArray(data: ["0x4142434445464748494a4b4c4d4e4f505152535455565758595a3132333435", "0x68656c6c6f"], pendingWord: .zero, pendingWordLen: 0)) // Not all data elements have length 31 + } +} diff --git a/Tests/StarknetTests/Data/TypedDataTests.swift b/Tests/StarknetTests/Data/TypedDataTests.swift index f248b929c..854b38590 100644 --- a/Tests/StarknetTests/Data/TypedDataTests.swift +++ b/Tests/StarknetTests/Data/TypedDataTests.swift @@ -21,6 +21,7 @@ final class TypedDataTests: XCTestCase { enum CasesRev1 { static let td = try! loadTypedDataFromFile(name: "typed_data_rev_1_example") static let tdFeltMerkleTree = try! loadTypedDataFromFile(name: "typed_data_rev_1_felt_merkletree_example") + static let tdBasicTypes = try! loadTypedDataFromFile(name: "typed_data_rev_1_basic_types_example") } static let domainTypeV0 = ( @@ -242,6 +243,9 @@ final class TypedDataTests: XCTestCase { (CasesRev1.td, "Mail", """ "Mail"("from":"Person","to":"Person","contents":"felt")"Person"("name":"felt","wallet":"felt") """), + (CasesRev1.tdBasicTypes, "Example", """ + "Example"("n0":"felt","n1":"bool","n2":"string","n3":"selector","n4":"u128","n5":"i128","n6":"ContractAddress","n7":"ClassHash","n8":"timestamp","n9":"shortstring") + """), (CasesRev1.tdFeltMerkleTree, "Example", """ "Example"("value":"felt","root":"merkletree") """), @@ -269,6 +273,7 @@ final class TypedDataTests: XCTestCase { (Self.CasesRev1.td, "StarknetDomain", "0x1ff2f602e42168014d405a94f75e8a93d640751d71d16311266e140d8b0a210"), (Self.CasesRev1.td, "Person", "0x30f7aa21b8d67cb04c30f962dd29b95ab320cb929c07d1605f5ace304dadf34"), (Self.CasesRev1.td, "Mail", "0x560430bf7a02939edd1a5c104e7b7a55bbab9f35928b1cf5c7c97de3a907bd"), + (Self.CasesRev1.tdBasicTypes, "Example", "0x1f94cd0be8b4097a41486170fdf09a4cd23aefbc74bb2344718562994c2c111"), (Self.CasesRev1.tdFeltMerkleTree, "Example", "0x160b9c0e8a7c561f9c5d9e3cc2990a1b4d26e94aa319e9eb53e163cd06c71be"), ] @@ -324,6 +329,12 @@ final class TypedDataTests: XCTestCase { "domain", "0x555f72e550b308e50c1a4f8611483a174026c982a9893a05c185eeb85399657" ), + ( + Self.CasesRev1.tdBasicTypes, + "Example", + "message", + "0x391d09a51a31dd17f7270aaa9904688fbeeb9c56a7e2d15c5a6af32e981c730" + ), ( Self.CasesRev1.tdFeltMerkleTree, "Example", @@ -380,6 +391,11 @@ final class TypedDataTests: XCTestCase { "0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826", "0x7f6e8c3d8965b5535f5cc68f837c04e3bbe568535b71aa6c621ddfb188932b8" ), + ( + Self.CasesRev1.tdBasicTypes, + "0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826", + "0x2d80b87b8bc32068247c779b2ef0f15f65c9c449325e44a9df480fb01eb43ec" + ), ( Self.CasesRev1.tdFeltMerkleTree, "0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826", diff --git a/Tests/StarknetTests/Resources/TypedData/typed_data_rev_1_basic_types_example.json b/Tests/StarknetTests/Resources/TypedData/typed_data_rev_1_basic_types_example.json new file mode 100644 index 000000000..ed0cea61a --- /dev/null +++ b/Tests/StarknetTests/Resources/TypedData/typed_data_rev_1_basic_types_example.json @@ -0,0 +1,41 @@ +{ + "types": { + "StarknetDomain": [ + { "name": "name", "type": "shortstring" }, + { "name": "version", "type": "shortstring" }, + { "name": "chainId", "type": "shortstring" }, + { "name": "revision", "type": "shortstring" } + ], + "Example": [ + { "name": "n0", "type": "felt" }, + { "name": "n1", "type": "bool" }, + { "name": "n2", "type": "string" }, + { "name": "n3", "type": "selector" }, + { "name": "n4", "type": "u128" }, + { "name": "n5", "type": "i128" }, + { "name": "n6", "type": "ContractAddress" }, + { "name": "n7", "type": "ClassHash" }, + { "name": "n8", "type": "timestamp" }, + { "name": "n9", "type": "shortstring" } + ] + }, + "primaryType": "Example", + "domain": { + "name": "StarkNet Mail", + "version": "1", + "chainId": "1", + "revision": "1" + }, + "message": { + "n0": "0x3e8", + "n1": true, + "n2": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + "n3": "transfer", + "n4": "0x3e8", + "n5": "-170141183460469231731687303715884105727", + "n6": "0x3e8", + "n7": "0x3e8", + "n8": 1000, + "n9": "transfer" + } +}