diff --git a/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/AnyToken2022ExtensionState.swift b/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/AnyToken2022ExtensionState.swift new file mode 100644 index 000000000..e1fd11a21 --- /dev/null +++ b/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/AnyToken2022ExtensionState.swift @@ -0,0 +1,69 @@ +import Foundation + +public struct AnyToken2022ExtensionState: BorshCodable, Codable, Equatable, Hashable { + // MARK: - Hashable + + public func hash(into hasher: inout Hasher) { + type.hash(into: &hasher) + state.hash(into: &hasher) + } + + // MARK: - Equatable + + public static func == (lhs: AnyToken2022ExtensionState, rhs: AnyToken2022ExtensionState) -> Bool { + lhs.type == rhs.type && + lhs.state.jsonString == rhs.state.jsonString + } + + // MARK: - Properties + + public let type: Token2022ExtensionType + public let state: any Token2022ExtensionState + + // MARK: - Codable + + enum CodingKeys: String, CodingKey { + case type + case state + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = try container.decode(Token2022ExtensionType.self, forKey: .type) + + switch type { + case .transferFeeConfig: + state = try container.decode(TransferFeeConfigExtensionState.self, forKey: .state) + default: + state = try container.decode(UnparsedExtensionState.self, forKey: .state) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encode(state, forKey: .state) + } + + // MARK: - BorshCodable + + public init(from reader: inout BinaryReader) throws { + guard let type = try Token2022ExtensionType(rawValue: UInt16(from: &reader)) else { + throw BinaryReaderError.dataMismatch + } + self.type = type + switch type { + case .transferFeeConfig: + state = try TransferFeeConfigExtensionState(from: &reader) + case .interestBearingConfig: + state = try InterestBearingConfigExtensionState(from: &reader) + default: + state = try UnparsedExtensionState(from: &reader) + } + } + + public func serialize(to data: inout Data) throws { + try type.rawValue.serialize(to: &data) + try state.serialize(to: &data) + } +} diff --git a/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/Extensions/InterestBearingConfigExtensionState.swift b/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/Extensions/InterestBearingConfigExtensionState.swift new file mode 100644 index 000000000..3170e2690 --- /dev/null +++ b/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/Extensions/InterestBearingConfigExtensionState.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct InterestBearingConfigExtensionState: Token2022ExtensionState { + public var length: UInt16 + + public let rateAuthority: PublicKey + public let initializationTimestamp: Int64 + public let preUpdateAverageRate: Int16 + public let lastUpdateTimestamp: Int64 + public let currentRate: Int16 + + public init(from reader: inout BinaryReader) throws { + length = try UInt16(from: &reader) + rateAuthority = try PublicKey(from: &reader) + initializationTimestamp = try Int64(from: &reader) + preUpdateAverageRate = try Int16(from: &reader) + lastUpdateTimestamp = try Int64(from: &reader) + currentRate = try Int16(from: &reader) + } + + public func serialize(to data: inout Data) throws { + try length.serialize(to: &data) + try rateAuthority.serialize(to: &data) + try initializationTimestamp.serialize(to: &data) + try preUpdateAverageRate.serialize(to: &data) + try lastUpdateTimestamp.serialize(to: &data) + try currentRate.serialize(to: &data) + } +} diff --git a/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/Extensions/TransferFeeConfigExtensionState.swift b/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/Extensions/TransferFeeConfigExtensionState.swift new file mode 100644 index 000000000..4ac0537d1 --- /dev/null +++ b/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/Extensions/TransferFeeConfigExtensionState.swift @@ -0,0 +1,52 @@ +import Foundation + +public struct TransferFeeConfigExtensionState: Token2022ExtensionState { + public struct TransferFee: BorshCodable, Codable, Equatable, Hashable { + public let epoch: UInt64 + public let maximumFee: UInt64 + public let transferFeeBasisPoints: UInt16 + + public init(from reader: inout BinaryReader) throws { + epoch = try UInt64(from: &reader) + maximumFee = try UInt64(from: &reader) + transferFeeBasisPoints = try UInt16(from: &reader) + } + + public func serialize(to data: inout Data) throws { + try epoch.serialize(to: &data) + try maximumFee.serialize(to: &data) + try transferFeeBasisPoints.serialize(to: &data) + } + } + + public let length: UInt16 + /// Optional authority to set the fee + public let transferFeeConfigAuthority: PublicKey + /// Withdraw from mint instructions must be signed by this key + public let withdrawWithHeldAuthority: PublicKey + /// Withheld transfer fee tokens that have been moved to the mint for + /// withdrawal + public let withheldAmount: UInt64 + /// Older transfer fee, used if the current epoch < new_transfer_fee.epoch + public let olderTransferFee: TransferFee + /// Newer transfer fee, used if the current epoch >= new_transfer_fee.epoch + public let newerTransferFee: TransferFee + + public init(from reader: inout BinaryReader) throws { + length = try UInt16(from: &reader) + transferFeeConfigAuthority = try PublicKey(from: &reader) + withdrawWithHeldAuthority = try PublicKey(from: &reader) + withheldAmount = try UInt64(from: &reader) + olderTransferFee = try TransferFee(from: &reader) + newerTransferFee = try TransferFee(from: &reader) + } + + public func serialize(to data: inout Data) throws { + try length.serialize(to: &data) + try transferFeeConfigAuthority.serialize(to: &data) + try withdrawWithHeldAuthority.serialize(to: &data) + try withheldAmount.serialize(to: &data) + try olderTransferFee.serialize(to: &data) + try newerTransferFee.serialize(to: &data) + } +} diff --git a/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/Extensions/UnparsedExtensionState.swift b/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/Extensions/UnparsedExtensionState.swift new file mode 100644 index 000000000..90cb23a72 --- /dev/null +++ b/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/Extensions/UnparsedExtensionState.swift @@ -0,0 +1,15 @@ +import Foundation + +struct UnparsedExtensionState: Token2022ExtensionState { + let length: UInt16 + let data: Data + init(from reader: inout BinaryReader) throws { + length = try UInt16(from: &reader) + data = try Data(reader.read(count: Int(length))) + } + + func serialize(to data: inout Data) throws { + try length.serialize(to: &data) + try data.serialize(to: &data) + } +} diff --git a/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/Token2022ExtensionState.swift b/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/Token2022ExtensionState.swift new file mode 100644 index 000000000..393c4e540 --- /dev/null +++ b/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/Token2022ExtensionState.swift @@ -0,0 +1,5 @@ +import Foundation + +public protocol Token2022ExtensionState: BorshCodable, Codable, Equatable, Hashable { + var length: UInt16 { get } +} diff --git a/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/Token2022ExtensionType.swift b/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/Token2022ExtensionType.swift new file mode 100644 index 000000000..b42cba601 --- /dev/null +++ b/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/ExtensionReader/Token2022ExtensionType.swift @@ -0,0 +1,72 @@ +import Foundation + +public enum Token2022ExtensionType: UInt16, Codable, Hashable { + /// Used as padding if the account size would otherwise be 355, same as a + /// multisig + case uninitialized + /// Includes transfer fee rate info and accompanying authorities to withdraw + /// and set the fee + case transferFeeConfig + /// Includes withheld transfer fees + case transferFeeAmount + /// Includes an optional mint close authority + case mintCloseAuthority + /// Auditor configuration for confidential transfers + case confidentialTransferMint + /// State for confidential transfers + case confidentialTransferAccount + /// Specifies the default Account::state for new Accounts + case defaultAccountState + /// Indicates that the Account owner authority cannot be changed + case immutableOwner + /// Require inbound transfers to have memo + case memoTransfer + /// Indicates that the tokens from this mint can't be transferred + case nonTransferable + /// Tokens accrue interest over time, + case interestBearingConfig + /// Locks privileged token operations from happening via CPI + case cpiGuard + /// Includes an optional permanent delegate + case permanentDelegate + /// Indicates that the tokens in this account belong to a non-transferable + /// mint + case nonTransferableAccount + /// Mint requires a CPI to a program implementing the "transfer hook" + /// interface + case transferHook + /// Indicates that the tokens in this account belong to a mint with a + /// transfer hook + case transferHookAccount + /// Includes encrypted withheld fees and the encryption public that they are + /// encrypted under + case confidentialTransferFeeConfig + /// Includes confidential withheld transfer fees + case confidentialTransferFeeAmount + /// Mint contains a pointer to another account (or the same account) that + /// holds metadata + case metadataPointer + /// Mint contains token-metadata + case tokenMetadata + /// Mint contains a pointer to another account (or the same account) that + /// holds group configurations + case groupPointer + /// Mint contains token group configurations + case tokenGroup + /// Mint contains a pointer to another account (or the same account) that + /// holds group member configurations + case groupMemberPointer + /// Mint contains token group member configurations + case tokenGroupMember + + // MARK: - Test only + + // /// Test variable-length mint extension + // case variableLenMintTest = UInt16.max - 2, + // /// Padding extension used to make an account exactly Multisig::LEN, used + // /// for testing + // case accountPaddingTest = UInt16.max - 1 + // /// Padding extension used to make a mint exactly Multisig::LEN, used for + // /// testing + // case mintPaddingTest = UInt16.max +} diff --git a/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/Token2022AccountState.swift b/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/Token2022AccountState.swift index e582555a8..d81d33169 100644 --- a/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/Token2022AccountState.swift +++ b/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/Token2022AccountState.swift @@ -17,6 +17,8 @@ public struct Token2022AccountState: TokenAccountState { public let closeAuthorityOption: UInt32 public var closeAuthority: PublicKey? + public var extensions: [AnyToken2022ExtensionState] + public init( mint: PublicKey, owner: PublicKey, @@ -32,7 +34,8 @@ public struct Token2022AccountState: TokenAccountState { isNative: Bool, delegatedAmount: UInt64, closeAuthorityOption: UInt32, - closeAuthority: PublicKey? = nil + closeAuthority: PublicKey? = nil, + extensions: [AnyToken2022ExtensionState] = [] ) { self.mint = mint self.owner = owner @@ -49,13 +52,16 @@ public struct Token2022AccountState: TokenAccountState { self.delegatedAmount = delegatedAmount self.closeAuthorityOption = closeAuthorityOption self.closeAuthority = closeAuthority + self.extensions = extensions } } extension Token2022AccountState: BorshCodable { public func serialize(to writer: inout Data) throws { try serializeCommonProperties(to: &writer) - // TODO: - Serialize token-2022 extensions here + for ext in extensions { + try ext.serialize(to: &writer) + } } public init(from reader: inout BinaryReader) throws { @@ -75,5 +81,20 @@ extension Token2022AccountState: BorshCodable { delegatedAmount = oldTokenProgramData.delegatedAmount closeAuthorityOption = oldTokenProgramData.closeAuthorityOption closeAuthority = oldTokenProgramData.closeAuthority + + guard reader.cursor < reader.bytes.count else { + extensions = [] + return + } + + _ = try reader.read(count: 1) // account type + + var extensions = [AnyToken2022ExtensionState]() + repeat { + let ext = try AnyToken2022ExtensionState(from: &reader) + extensions.append(ext) + } while reader.cursor < reader.bytes.count + + self.extensions = extensions } } diff --git a/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/Token2022MintState.swift b/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/Token2022MintState.swift index d5b95c6f3..9204a70c3 100644 --- a/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/Token2022MintState.swift +++ b/Sources/SolanaSwift/Programs/TokenPrograms/Token2022Program/Token2022MintState.swift @@ -8,6 +8,13 @@ public struct Token2022MintState: TokenMintState { public let isInitialized: Bool public let freezeAuthorityOption: UInt32 public let freezeAuthority: PublicKey? + + public var extensions: [AnyToken2022ExtensionState] + + public func getParsedExtension(ofType _: T.Type) -> T? { + assert(T.self != UnparsedExtensionState.self) + return extensions.first(where: { $0.state is T })?.state as? T + } } extension Token2022MintState: BorshCodable { @@ -20,10 +27,28 @@ extension Token2022MintState: BorshCodable { isInitialized = oldTokenMintState.isInitialized freezeAuthorityOption = oldTokenMintState.freezeAuthorityOption freezeAuthority = oldTokenMintState.freezeAuthority + + guard reader.cursor < reader.bytes.count else { + extensions = [] + return + } + + _ = try reader.read(count: 83) // padding + _ = try reader.read(count: 1) // mint type + + var extensions = [AnyToken2022ExtensionState]() + repeat { + let ext = try AnyToken2022ExtensionState(from: &reader) + extensions.append(ext) + } while reader.cursor < reader.bytes.count + + self.extensions = extensions } public func serialize(to writer: inout Data) throws { try serializeCommonProperties(to: &writer) - // TODO: - Serialize token-2022 extensions here + for ext in extensions { + try ext.serialize(to: &writer) + } } } diff --git a/Tests/SolanaSwiftUnitTests/Other/BufferLayout/BufferLayoutTests.swift b/Tests/SolanaSwiftUnitTests/Other/BufferLayout/BufferLayoutTests.swift index 8c337433e..8e7a76ee7 100644 --- a/Tests/SolanaSwiftUnitTests/Other/BufferLayout/BufferLayoutTests.swift +++ b/Tests/SolanaSwiftUnitTests/Other/BufferLayout/BufferLayoutTests.swift @@ -48,7 +48,6 @@ class BufferLayoutTests: XCTestCase { // MARK: - Account info - func testDecodingAccountInfo() throws { XCTAssertEqual(SPLTokenAccountState.BUFFER_LENGTH, 165) @@ -168,4 +167,63 @@ class BufferLayoutTests: XCTestCase { var binaryReader = BinaryReader(bytes: data.bytes) let _ = try EmptyInfo(from: &binaryReader) } + + // MARK: - Token2022 + + func testDecodingToken2022MintState() throws { + let string = + "AAAAAAT3LznRbp1toHmr0Mjv1bBjc6oSrtihgQu/PG0Sunz6XUTVg3ktAAAFAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQEAbAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAT3LznRbp1toHmr0Mjv1bBjc6oSrtihgQu/PG0Sunz6N5bilgAAAAASAgAAAAAAAAAgPYh5LQAALAESAgAAAAAAAAAgPYh5LQAALAE=" + let data = Data(base64Encoded: string)! + var binaryReader = BinaryReader(bytes: data.bytes) + let state = try Token2022MintState(from: &binaryReader) + + XCTAssertEqual(state.extensions.count, 1) + } + + func testDecodingToken2022MintState2() throws { + // Mint FZYEgCWzzedxcmxYvGXSkMrj7TaA3bXoaEv6XMnwtLKh + let string = + "AAAAABdZNqd8UPqRoeBHXdhoEwzZNLf6UnDQ1UDsr4oXimfhquOLA1BVIXECAQAAAAAXWTanfFD6kaHgR13YaBMM2TS3+lJw0NVA7K+KF4pn4QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQEAbAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALoDKJKHBCRLpAQAAAAAAAACQI15ZrVt7LAHpAQAAAAAAAACQI15ZrVt7LAEKADQAF1k2p3xQ+pGh4Edd2GgTDNk0t/pScNDVQOyviheKZ+EN9NlkAAAAAAAADfTZZAAAAAAAAAYAAQAB" + let data = Data(base64Encoded: string)! + var binaryReader = BinaryReader(bytes: data.bytes) + let state = try Token2022MintState(from: &binaryReader) + + XCTAssertEqual(state.extensions.count, 3) + + let transferConfig = state.getParsedExtension( + ofType: TransferFeeConfigExtensionState.self + ) + + XCTAssertEqual(transferConfig?.length, 108) + XCTAssertEqual(transferConfig?.transferFeeConfigAuthority, "11111111111111111111111111111111") + XCTAssertEqual(transferConfig?.withdrawWithHeldAuthority, "11111111111111111111111111111111") + XCTAssertEqual(transferConfig?.withheldAmount, 1_299_782_865_324_245_038) + XCTAssertEqual(transferConfig?.olderTransferFee.epoch, 489) + XCTAssertEqual(transferConfig?.olderTransferFee.maximumFee, 8_888_888_888_888_889_344) + XCTAssertEqual(transferConfig?.olderTransferFee.transferFeeBasisPoints, 300) + XCTAssertEqual(transferConfig?.newerTransferFee.epoch, 489) + XCTAssertEqual(transferConfig?.newerTransferFee.maximumFee, 8_888_888_888_888_889_344) + XCTAssertEqual(transferConfig?.newerTransferFee.transferFeeBasisPoints, 300) + + let interestBearingConfig = state.getParsedExtension( + ofType: InterestBearingConfigExtensionState.self + ) + + XCTAssertEqual(interestBearingConfig?.length, 52) + XCTAssertEqual(interestBearingConfig?.rateAuthority, "2a9H7uNfUxt7YdS5yH3ZEijdPqpeBtyq7JPtVyi6XKtk") + XCTAssertEqual(interestBearingConfig?.initializationTimestamp, 1_692_005_389) + XCTAssertEqual(interestBearingConfig?.preUpdateAverageRate, 0) + XCTAssertEqual(interestBearingConfig?.lastUpdateTimestamp, 1_692_005_389) + XCTAssertEqual(interestBearingConfig?.currentRate, 0) + } + + func testDecodingToken2022AccountState() throws { + let string = + "c8d675Tc8/enuGEbVogbaWoW6iY9JFkJIswLnf/gvCXDAcw04n4gWtOj5P12Rb7RAxY9RRwFQOwFWCWPS3OnJgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgcAAAA=" + let data = Data(base64Encoded: string)! + var binaryReader = BinaryReader(bytes: data.bytes) + let state = try Token2022AccountState(from: &binaryReader) + + XCTAssertEqual(state.extensions.count, 1) + } }