diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 7cd3d2f..6f8bc29 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -2792,6 +2792,12 @@ public protocol UsdtWalletProtocol: AnyObject, Sendable { */ func checkRecentExecution(id: String) async throws -> UsdtTransfer? + /** + * Signs the executed direct payment using the Paykit ERC-20 EIP-712 profile. + * Persist the binding and payment ID before send. Retry this after execution; it never sends. + */ + func createPaymentProof(transferId: String, binding: UsdtPaymentProofBinding, mnemonic: String, passphrase: String?) async throws -> UsdtPaymentProof? + func history() throws -> [UsdtTransfer] func quoteTransfer(recipient: String, amount: UInt64, destination: UsdtDestination) async throws -> UsdtQuote @@ -2817,6 +2823,13 @@ public protocol UsdtWalletProtocol: AnyObject, Sendable { */ func syncHistory() async throws -> Bool + /** + * Verifies a successful canonical ERC-20 transfer to this wallet and its request signature. + * Ordinary EOA transfers are supported. None means evidence is not yet available. + * The caller verifies accepted terms, payment-time deadlines and payment_id deduplication. + */ + func verifyPaymentProof(binding: UsdtPaymentProofBinding, proof: UsdtPaymentProof) async throws -> UsdtVerifiedPayment? + } open class UsdtWallet: UsdtWalletProtocol, @unchecked Sendable { fileprivate let pointer: UnsafeMutableRawPointer! @@ -2924,6 +2937,27 @@ open func checkRecentExecution(id: String)async throws -> UsdtTransfer? { ) } + /** + * Signs the executed direct payment using the Paykit ERC-20 EIP-712 profile. + * Persist the binding and payment ID before send. Retry this after execution; it never sends. + */ +open func createPaymentProof(transferId: String, binding: UsdtPaymentProofBinding, mnemonic: String, passphrase: String?)async throws -> UsdtPaymentProof? { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtwallet_create_payment_proof( + self.uniffiClonePointer(), + FfiConverterString.lower(transferId),FfiConverterTypeUsdtPaymentProofBinding_lower(binding),FfiConverterString.lower(mnemonic),FfiConverterOptionString.lower(passphrase) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterOptionTypeUsdtPaymentProof.lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + open func history()throws -> [UsdtTransfer] { return try FfiConverterSequenceTypeUsdtTransfer.lift(try rustCallWithError(FfiConverterTypeUsdtError_lift) { uniffi_bitkitcore_fn_method_usdtwallet_history(self.uniffiClonePointer(),$0 @@ -3024,6 +3058,28 @@ open func syncHistory()async throws -> Bool { ) } + /** + * Verifies a successful canonical ERC-20 transfer to this wallet and its request signature. + * Ordinary EOA transfers are supported. None means evidence is not yet available. + * The caller verifies accepted terms, payment-time deadlines and payment_id deduplication. + */ +open func verifyPaymentProof(binding: UsdtPaymentProofBinding, proof: UsdtPaymentProof)async throws -> UsdtVerifiedPayment? { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtwallet_verify_payment_proof( + self.uniffiClonePointer(), + FfiConverterTypeUsdtPaymentProofBinding_lower(binding),FfiConverterTypeUsdtPaymentProof_lower(proof) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterOptionTypeUsdtVerifiedPayment.lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + } @@ -16774,6 +16830,232 @@ public func FfiConverterTypeUsdtDepositPage_lower(_ value: UsdtDepositPage) -> R } +public struct UsdtPaymentProof { + public var chainId: String + public var transactionHash: String + /** + * Decimal position in the complete receipt logs array, not the block-wide RPC logIndex. + */ + public var receiptLogIndex: String + public var signature: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(chainId: String, transactionHash: String, + /** + * Decimal position in the complete receipt logs array, not the block-wide RPC logIndex. + */receiptLogIndex: String, signature: String) { + self.chainId = chainId + self.transactionHash = transactionHash + self.receiptLogIndex = receiptLogIndex + self.signature = signature + } +} + +#if compiler(>=6) +extension UsdtPaymentProof: Sendable {} +#endif + + +extension UsdtPaymentProof: Equatable, Hashable { + public static func ==(lhs: UsdtPaymentProof, rhs: UsdtPaymentProof) -> Bool { + if lhs.chainId != rhs.chainId { + return false + } + if lhs.transactionHash != rhs.transactionHash { + return false + } + if lhs.receiptLogIndex != rhs.receiptLogIndex { + return false + } + if lhs.signature != rhs.signature { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(chainId) + hasher.combine(transactionHash) + hasher.combine(receiptLogIndex) + hasher.combine(signature) + } +} + +extension UsdtPaymentProof: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtPaymentProof: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtPaymentProof { + return + try UsdtPaymentProof( + chainId: FfiConverterString.read(from: &buf), + transactionHash: FfiConverterString.read(from: &buf), + receiptLogIndex: FfiConverterString.read(from: &buf), + signature: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: UsdtPaymentProof, into buf: inout [UInt8]) { + FfiConverterString.write(value.chainId, into: &buf) + FfiConverterString.write(value.transactionHash, into: &buf) + FfiConverterString.write(value.receiptLogIndex, into: &buf) + FfiConverterString.write(value.signature, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtPaymentProof_lift(_ buf: RustBuffer) throws -> UsdtPaymentProof { + return try FfiConverterTypeUsdtPaymentProof.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtPaymentProof_lower(_ value: UsdtPaymentProof) -> RustBuffer { + return FfiConverterTypeUsdtPaymentProof.lower(value) +} + + +/** + * Immutable Paykit request fields, using the canonical strings from the accepted request/proof. + * Pubky keys are bare z32; absent period and conversion quote fields are empty strings. + */ +public struct UsdtPaymentProofBinding { + public var payer: String + public var payee: String + public var paymentAppId: String + public var paymentRequestId: String + public var paymentReference: String + public var paymentEndpointIdentifier: String + public var periodStartsAt: String + public var periodEndsAt: String + public var conversionQuoteId: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(payer: String, payee: String, paymentAppId: String, paymentRequestId: String, paymentReference: String, paymentEndpointIdentifier: String, periodStartsAt: String, periodEndsAt: String, conversionQuoteId: String) { + self.payer = payer + self.payee = payee + self.paymentAppId = paymentAppId + self.paymentRequestId = paymentRequestId + self.paymentReference = paymentReference + self.paymentEndpointIdentifier = paymentEndpointIdentifier + self.periodStartsAt = periodStartsAt + self.periodEndsAt = periodEndsAt + self.conversionQuoteId = conversionQuoteId + } +} + +#if compiler(>=6) +extension UsdtPaymentProofBinding: Sendable {} +#endif + + +extension UsdtPaymentProofBinding: Equatable, Hashable { + public static func ==(lhs: UsdtPaymentProofBinding, rhs: UsdtPaymentProofBinding) -> Bool { + if lhs.payer != rhs.payer { + return false + } + if lhs.payee != rhs.payee { + return false + } + if lhs.paymentAppId != rhs.paymentAppId { + return false + } + if lhs.paymentRequestId != rhs.paymentRequestId { + return false + } + if lhs.paymentReference != rhs.paymentReference { + return false + } + if lhs.paymentEndpointIdentifier != rhs.paymentEndpointIdentifier { + return false + } + if lhs.periodStartsAt != rhs.periodStartsAt { + return false + } + if lhs.periodEndsAt != rhs.periodEndsAt { + return false + } + if lhs.conversionQuoteId != rhs.conversionQuoteId { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(payer) + hasher.combine(payee) + hasher.combine(paymentAppId) + hasher.combine(paymentRequestId) + hasher.combine(paymentReference) + hasher.combine(paymentEndpointIdentifier) + hasher.combine(periodStartsAt) + hasher.combine(periodEndsAt) + hasher.combine(conversionQuoteId) + } +} + +extension UsdtPaymentProofBinding: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtPaymentProofBinding: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtPaymentProofBinding { + return + try UsdtPaymentProofBinding( + payer: FfiConverterString.read(from: &buf), + payee: FfiConverterString.read(from: &buf), + paymentAppId: FfiConverterString.read(from: &buf), + paymentRequestId: FfiConverterString.read(from: &buf), + paymentReference: FfiConverterString.read(from: &buf), + paymentEndpointIdentifier: FfiConverterString.read(from: &buf), + periodStartsAt: FfiConverterString.read(from: &buf), + periodEndsAt: FfiConverterString.read(from: &buf), + conversionQuoteId: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: UsdtPaymentProofBinding, into buf: inout [UInt8]) { + FfiConverterString.write(value.payer, into: &buf) + FfiConverterString.write(value.payee, into: &buf) + FfiConverterString.write(value.paymentAppId, into: &buf) + FfiConverterString.write(value.paymentRequestId, into: &buf) + FfiConverterString.write(value.paymentReference, into: &buf) + FfiConverterString.write(value.paymentEndpointIdentifier, into: &buf) + FfiConverterString.write(value.periodStartsAt, into: &buf) + FfiConverterString.write(value.periodEndsAt, into: &buf) + FfiConverterString.write(value.conversionQuoteId, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtPaymentProofBinding_lift(_ buf: RustBuffer) throws -> UsdtPaymentProofBinding { + return try FfiConverterTypeUsdtPaymentProofBinding.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtPaymentProofBinding_lower(_ value: UsdtPaymentProofBinding) -> RustBuffer { + return FfiConverterTypeUsdtPaymentProofBinding.lower(value) +} + + public struct UsdtPaymentRequest { public var recipient: String public var amount: UInt64? @@ -17130,6 +17412,122 @@ public func FfiConverterTypeUsdtTransfer_lower(_ value: UsdtTransfer) -> RustBuf } +public struct UsdtVerifiedPayment { + /** + * Verified chain, transaction and receipt position; claim at most once across requests/periods. + */ + public var paymentId: String + /** + * Existing incoming activity identity (transaction and block-wide log index). + */ + public var transferId: String + public var sender: String + public var recipient: String + public var amount: UInt64 + public var timestamp: UInt64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * Verified chain, transaction and receipt position; claim at most once across requests/periods. + */paymentId: String, + /** + * Existing incoming activity identity (transaction and block-wide log index). + */transferId: String, sender: String, recipient: String, amount: UInt64, timestamp: UInt64) { + self.paymentId = paymentId + self.transferId = transferId + self.sender = sender + self.recipient = recipient + self.amount = amount + self.timestamp = timestamp + } +} + +#if compiler(>=6) +extension UsdtVerifiedPayment: Sendable {} +#endif + + +extension UsdtVerifiedPayment: Equatable, Hashable { + public static func ==(lhs: UsdtVerifiedPayment, rhs: UsdtVerifiedPayment) -> Bool { + if lhs.paymentId != rhs.paymentId { + return false + } + if lhs.transferId != rhs.transferId { + return false + } + if lhs.sender != rhs.sender { + return false + } + if lhs.recipient != rhs.recipient { + return false + } + if lhs.amount != rhs.amount { + return false + } + if lhs.timestamp != rhs.timestamp { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(paymentId) + hasher.combine(transferId) + hasher.combine(sender) + hasher.combine(recipient) + hasher.combine(amount) + hasher.combine(timestamp) + } +} + +extension UsdtVerifiedPayment: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtVerifiedPayment: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtVerifiedPayment { + return + try UsdtVerifiedPayment( + paymentId: FfiConverterString.read(from: &buf), + transferId: FfiConverterString.read(from: &buf), + sender: FfiConverterString.read(from: &buf), + recipient: FfiConverterString.read(from: &buf), + amount: FfiConverterUInt64.read(from: &buf), + timestamp: FfiConverterUInt64.read(from: &buf) + ) + } + + public static func write(_ value: UsdtVerifiedPayment, into buf: inout [UInt8]) { + FfiConverterString.write(value.paymentId, into: &buf) + FfiConverterString.write(value.transferId, into: &buf) + FfiConverterString.write(value.sender, into: &buf) + FfiConverterString.write(value.recipient, into: &buf) + FfiConverterUInt64.write(value.amount, into: &buf) + FfiConverterUInt64.write(value.timestamp, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtVerifiedPayment_lift(_ buf: RustBuffer) throws -> UsdtVerifiedPayment { + return try FfiConverterTypeUsdtVerifiedPayment.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtVerifiedPayment_lower(_ value: UsdtVerifiedPayment) -> RustBuffer { + return FfiConverterTypeUsdtVerifiedPayment.lower(value) +} + + public struct ValidationResult { public var address: String public var network: NetworkType @@ -24452,6 +24850,7 @@ public enum UsdtError: Swift.Error { + case InvalidPaymentProof case InvalidAmount case InvalidAddress case WrongNetwork @@ -24492,34 +24891,35 @@ public struct FfiConverterTypeUsdtError: FfiConverterRustBuffer { - case 1: return .InvalidAmount - case 2: return .InvalidAddress - case 3: return .WrongNetwork - case 4: return .InvalidCredentials - case 5: return .ClockSkew - case 6: return .UnsupportedDelegation - case 7: return .InsufficientBalance - case 8: return .QuoteExpired - case 9: return .PendingTransfer - case 10: return .UnsupportedRoute - case 11: return .DepositNeedsAttention - case 12: return .DepositNotFound - case 13: return .DepositAuthorizationRejected - case 14: return .DepositAmountOutOfRange( + case 1: return .InvalidPaymentProof + case 2: return .InvalidAmount + case 3: return .InvalidAddress + case 4: return .WrongNetwork + case 5: return .InvalidCredentials + case 6: return .ClockSkew + case 7: return .UnsupportedDelegation + case 8: return .InsufficientBalance + case 9: return .QuoteExpired + case 10: return .PendingTransfer + case 11: return .UnsupportedRoute + case 12: return .DepositNeedsAttention + case 13: return .DepositNotFound + case 14: return .DepositAuthorizationRejected + case 15: return .DepositAmountOutOfRange( minUsdCents: try FfiConverterOptionString.read(from: &buf), maxUsdCents: try FfiConverterOptionString.read(from: &buf) ) - case 15: return .NotConfigured - case 16: return .NetworkUnavailable - case 17: return .RateLimited - case 18: return .LogRangeTooLarge - case 19: return .TransactionRejected( + case 16: return .NotConfigured + case 17: return .NetworkUnavailable + case 18: return .RateLimited + case 19: return .LogRangeTooLarge + case 20: return .TransactionRejected( reason: try FfiConverterString.read(from: &buf) ) - case 20: return .Storage( + case 21: return .Storage( reason: try FfiConverterString.read(from: &buf) ) - case 21: return .InvalidResponse + case 22: return .InvalidResponse default: throw UniffiInternalError.unexpectedEnumCase } @@ -24532,92 +24932,96 @@ public struct FfiConverterTypeUsdtError: FfiConverterRustBuffer { - case .InvalidAmount: + case .InvalidPaymentProof: writeInt(&buf, Int32(1)) - case .InvalidAddress: + case .InvalidAmount: writeInt(&buf, Int32(2)) - case .WrongNetwork: + case .InvalidAddress: writeInt(&buf, Int32(3)) - case .InvalidCredentials: + case .WrongNetwork: writeInt(&buf, Int32(4)) - case .ClockSkew: + case .InvalidCredentials: writeInt(&buf, Int32(5)) - case .UnsupportedDelegation: + case .ClockSkew: writeInt(&buf, Int32(6)) - case .InsufficientBalance: + case .UnsupportedDelegation: writeInt(&buf, Int32(7)) - case .QuoteExpired: + case .InsufficientBalance: writeInt(&buf, Int32(8)) - case .PendingTransfer: + case .QuoteExpired: writeInt(&buf, Int32(9)) - case .UnsupportedRoute: + case .PendingTransfer: writeInt(&buf, Int32(10)) - case .DepositNeedsAttention: + case .UnsupportedRoute: writeInt(&buf, Int32(11)) - case .DepositNotFound: + case .DepositNeedsAttention: writeInt(&buf, Int32(12)) - case .DepositAuthorizationRejected: + case .DepositNotFound: writeInt(&buf, Int32(13)) - case let .DepositAmountOutOfRange(minUsdCents,maxUsdCents): + case .DepositAuthorizationRejected: writeInt(&buf, Int32(14)) + + + case let .DepositAmountOutOfRange(minUsdCents,maxUsdCents): + writeInt(&buf, Int32(15)) FfiConverterOptionString.write(minUsdCents, into: &buf) FfiConverterOptionString.write(maxUsdCents, into: &buf) case .NotConfigured: - writeInt(&buf, Int32(15)) + writeInt(&buf, Int32(16)) case .NetworkUnavailable: - writeInt(&buf, Int32(16)) + writeInt(&buf, Int32(17)) case .RateLimited: - writeInt(&buf, Int32(17)) + writeInt(&buf, Int32(18)) case .LogRangeTooLarge: - writeInt(&buf, Int32(18)) + writeInt(&buf, Int32(19)) case let .TransactionRejected(reason): - writeInt(&buf, Int32(19)) + writeInt(&buf, Int32(20)) FfiConverterString.write(reason, into: &buf) case let .Storage(reason): - writeInt(&buf, Int32(20)) + writeInt(&buf, Int32(21)) FfiConverterString.write(reason, into: &buf) case .InvalidResponse: - writeInt(&buf, Int32(21)) + writeInt(&buf, Int32(22)) } } @@ -25931,6 +26335,30 @@ fileprivate struct FfiConverterOptionTypeUsdtDepositOrder: FfiConverterRustBuffe } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeUsdtPaymentProof: FfiConverterRustBuffer { + typealias SwiftType = UsdtPaymentProof? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeUsdtPaymentProof.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeUsdtPaymentProof.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -25955,6 +26383,30 @@ fileprivate struct FfiConverterOptionTypeUsdtTransfer: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeUsdtVerifiedPayment: FfiConverterRustBuffer { + typealias SwiftType = UsdtVerifiedPayment? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeUsdtVerifiedPayment.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeUsdtVerifiedPayment.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -30967,6 +31419,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_usdtwallet_check_recent_execution() != 56962) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_method_usdtwallet_create_payment_proof() != 39351) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_method_usdtwallet_history() != 4617) { return InitializationResult.apiChecksumMismatch } @@ -30988,6 +31443,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_usdtwallet_sync_history() != 25445) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_method_usdtwallet_verify_payment_proof() != 42574) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_constructor_urdecoder_new() != 23014) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index 9df5bb9..151e8b6 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -725,6 +725,11 @@ uint64_t uniffi_bitkitcore_fn_method_usdtwallet_balance(void*_Nonnull ptr uint64_t uniffi_bitkitcore_fn_method_usdtwallet_check_recent_execution(void*_Nonnull ptr, RustBuffer id ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_CREATE_PAYMENT_PROOF +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_CREATE_PAYMENT_PROOF +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_create_payment_proof(void*_Nonnull ptr, RustBuffer transfer_id, RustBuffer binding, RustBuffer mnemonic, RustBuffer passphrase +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_HISTORY #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_HISTORY RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_history(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status @@ -760,6 +765,11 @@ uint64_t uniffi_bitkitcore_fn_method_usdtwallet_send(void*_Nonnull ptr, RustBuff uint64_t uniffi_bitkitcore_fn_method_usdtwallet_sync_history(void*_Nonnull ptr ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_VERIFY_PAYMENT_PROOF +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_VERIFY_PAYMENT_PROOF +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_verify_payment_proof(void*_Nonnull ptr, RustBuffer binding, RustBuffer proof +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ACTIVITIES_FROM_JSON #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ACTIVITIES_FROM_JSON RustBuffer uniffi_bitkitcore_fn_func_activities_from_json(RustBuffer json, RustCallStatus *_Nonnull out_status @@ -3548,6 +3558,12 @@ uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_balance(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_CHECK_RECENT_EXECUTION uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_check_recent_execution(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_CREATE_PAYMENT_PROOF +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_CREATE_PAYMENT_PROOF +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_create_payment_proof(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_HISTORY @@ -3590,6 +3606,12 @@ uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_send(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_SYNC_HISTORY uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_sync_history(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_VERIFY_PAYMENT_PROOF +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_VERIFY_PAYMENT_PROOF +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_verify_payment_proof(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_CONSTRUCTOR_URDECODER_NEW diff --git a/src/lib.rs b/src/lib.rs index 8564c82..827751f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -93,8 +93,9 @@ pub use modules::seedqr::{decode_compact_seed_qr, decode_standard_seed_qr, SeedQ pub use modules::usdt::{ usdt_address, usdt_format_amount, usdt_parse_amount, usdt_parse_payment_request, UsdtDeposit, UsdtDepositAddress, UsdtDepositClient, UsdtDepositDetail, UsdtDepositNetwork, UsdtDepositOrder, - UsdtDepositPage, UsdtDestination, UsdtError, UsdtPaymentRequest, UsdtQuote, UsdtTransfer, - UsdtTransferStatus, UsdtWallet, + UsdtDepositPage, UsdtDestination, UsdtError, UsdtPaymentProof, UsdtPaymentProofBinding, + UsdtPaymentRequest, UsdtQuote, UsdtTransfer, UsdtTransferStatus, UsdtVerifiedPayment, + UsdtWallet, }; use bip39::Mnemonic; diff --git a/src/modules/usdt/README.md b/src/modules/usdt/README.md index a842c8c..65a702e 100644 --- a/src/modules/usdt/README.md +++ b/src/modules/usdt/README.md @@ -101,3 +101,11 @@ History returns at most 50 rows per page. Start with `offset = 0`, then use `nex Refunds require a user-approved address on the source network and provider eligibility; an acknowledgment means queued, not paid. Never infer a refund address from the original sender, which may be an exchange hot wallet. Unconverted Tron refunds require provider assistance. Missing optional metadata must not hide pending deposits. Address recovery depends on the same Orchestra partner account and immutable account-derived reference; provider availability and address control remain trust dependencies. Use a matching [bitkit-usdt-service](https://github.com/synonymdev/bitkit-usdt-service) deployment with `ORCHESTRA_API_KEY` and `ORCHESTRA_DEPOSIT_NETWORKS`. Enable each source only after funded delivery and provider recovery acceptance. See [Orchestra deposit addresses](https://docs.flashnet.xyz/orchestra/deposit-addresses). + +## Request-bound payment proofs + +`create_payment_proof` signs an executed direct Arbitrum payment using Paykit's `erc20-transfer-eip712` profile. The binding identifies the authenticated payer and payee, the app owning the accepted endpoint, request, reference, billing period and selected conversion quote. Persist this immutable binding and the quote/payment ID before `send`. Retry proof creation and delivery independently after execution; neither action sends funds. A pending payment returns `None`. + +`verify_payment_proof` checks the exact receipt log, pinned chain/token/recipient, canonical successful receipt and payer signature. Ordinary ERC-20 senders are supported. The service must retain receipt status and each retained log's original `receiptLogIndex` before filtering. The returned `payment_id` must be claimed at most once across requests and billing periods; `transfer_id` remains the activity identity. Applications validate authenticated request terms, endpoint app ownership, quoted amounts and deadlines against the returned amount and block timestamp. + +The proof binding and interoperability vectors follow [Paykit rc59](https://github.com/pubky/paykit-rs/blob/v0.1.0-rc59/specs/erc20-payment-proofs.md). Receipt positions are canonical decimal strings on the wire. diff --git a/src/modules/usdt/errors.rs b/src/modules/usdt/errors.rs index bc6be4e..9ad6834 100644 --- a/src/modules/usdt/errors.rs +++ b/src/modules/usdt/errors.rs @@ -2,6 +2,8 @@ use thiserror::Error; #[derive(Debug, Error, uniffi::Error)] pub enum UsdtError { + #[error("The payment proof does not match this request or a successful USDT payment")] + InvalidPaymentProof, #[error("Enter a valid USDT amount with at most six decimal places")] InvalidAmount, #[error("Enter a valid address for the selected network")] diff --git a/src/modules/usdt/fixtures/erc20-payment-proofs.json b/src/modules/usdt/fixtures/erc20-payment-proofs.json new file mode 100644 index 0000000..c769634 --- /dev/null +++ b/src/modules/usdt/fixtures/erc20-payment-proofs.json @@ -0,0 +1,335 @@ +{ + "chain_mismatch_cases": [ + { + "domain_chain_id": "42161", + "endpoint_chain_id": "42161", + "payload_chain_id": "1", + "valid": false, + "vector": "one_time" + }, + { + "domain_chain_id": "1", + "endpoint_chain_id": "42161", + "payload_chain_id": "42161", + "valid": false, + "vector": "one_time" + }, + { + "domain_chain_id": "1", + "endpoint_chain_id": "1", + "payload_chain_id": "1", + "valid": true, + "vector": "ethereum_one_time" + } + ], + "fixture_key": "secp256k1 scalar 1; public test data only", + "profile": "erc20-transfer-eip712", + "vectors": [ + { + "changed_reference": "another-invoice", + "changed_reference_digest": "0x00ac79f14189f04560dae56fe9975812bca4e6d1a13f6487f36bb86842079ad8", + "changed_reference_valid": false, + "digest": "0x5566888237f1f7eeecf6e70f8498c24b4164c36128b3294c57f11228f1920433", + "domain_separator": "0xc8cfc7bdc9a0662133f571459c83f060b7e7cdbfcdb4acdee999358269fd3668", + "name": "one_time", + "signature": "0xa5c3920571e689f4d40a965ff393fc7fa43decdb80558aee6611cb996b9b86d62ad58b4cab335161d93479796955d2f3b7d910c40b014a07bb6d8e9be0819ea51c", + "signer": "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", + "struct_hash": "0xd87265794b629270c4f12c39968f2e202894fab80d6dfe2065272067c5356aac", + "typed_data": { + "domain": { + "chainId": 42161, + "name": "Paykit ERC20 Payment", + "version": "1" + }, + "message": { + "receiptLogIndex": "2", + "request": { + "conversionQuoteId": "", + "payee": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "payer": "8jsf5bm1ck3r7sn6pfx4q9mgqq5xn8fi6sizw6pxgjc8zs1bt4io", + "paymentAppId": "example", + "paymentEndpointIdentifier": "usdt-arbitrum-address", + "paymentReference": "invoice-1", + "paymentRequestId": "b7f9c2a1-6d43-4b0e-a8d4-0fe2c712ab33", + "periodEndsAt": "", + "periodStartsAt": "" + }, + "transactionHash": "0x1111111111111111111111111111111111111111111111111111111111111111" + }, + "primaryType": "Erc20Payment", + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + } + ], + "Erc20Payment": [ + { + "name": "transactionHash", + "type": "bytes32" + }, + { + "name": "receiptLogIndex", + "type": "uint256" + }, + { + "name": "request", + "type": "RequestBinding" + } + ], + "RequestBinding": [ + { + "name": "payer", + "type": "string" + }, + { + "name": "payee", + "type": "string" + }, + { + "name": "paymentAppId", + "type": "string" + }, + { + "name": "paymentRequestId", + "type": "string" + }, + { + "name": "paymentReference", + "type": "string" + }, + { + "name": "paymentEndpointIdentifier", + "type": "string" + }, + { + "name": "periodStartsAt", + "type": "string" + }, + { + "name": "periodEndsAt", + "type": "string" + }, + { + "name": "conversionQuoteId", + "type": "string" + } + ] + } + } + }, + { + "changed_reference": "another-invoice", + "changed_reference_digest": "0x7e53220e3fd8e818042c47979378417b3e5739b063d899f37f3e5a64f64de390", + "changed_reference_valid": false, + "digest": "0xd9fd7764d7e0548fa03721d4a29a8b6a690e9b02e6b7aabc4a2a5203d2e06e16", + "domain_separator": "0xc8cfc7bdc9a0662133f571459c83f060b7e7cdbfcdb4acdee999358269fd3668", + "name": "quoted_recurring", + "signature": "0x64a6aca70f781a48dcd753b8f821630be3073a8fb0084dcadcef86da5c3b7e570027119615da318de2ff77fde36d6cc80015a3e1794cccb230fe140504623bed1c", + "signer": "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", + "struct_hash": "0x35f7123ce9ee9fa66f718e27dad21fbad067b826c8932fcb5893bd9bfb97d04d", + "typed_data": { + "domain": { + "chainId": 42161, + "name": "Paykit ERC20 Payment", + "version": "1" + }, + "message": { + "receiptLogIndex": "2", + "request": { + "conversionQuoteId": "8a0d8b4c-913f-4e31-9f2c-2a6f5bb4d103", + "payee": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "payer": "8jsf5bm1ck3r7sn6pfx4q9mgqq5xn8fi6sizw6pxgjc8zs1bt4io", + "paymentAppId": "example", + "paymentEndpointIdentifier": "usdt-arbitrum-address", + "paymentReference": "invoice-1", + "paymentRequestId": "b7f9c2a1-6d43-4b0e-a8d4-0fe2c712ab33", + "periodEndsAt": "2026-11-01T00:00:00Z", + "periodStartsAt": "2026-10-01T00:00:00Z" + }, + "transactionHash": "0x1111111111111111111111111111111111111111111111111111111111111111" + }, + "primaryType": "Erc20Payment", + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + } + ], + "Erc20Payment": [ + { + "name": "transactionHash", + "type": "bytes32" + }, + { + "name": "receiptLogIndex", + "type": "uint256" + }, + { + "name": "request", + "type": "RequestBinding" + } + ], + "RequestBinding": [ + { + "name": "payer", + "type": "string" + }, + { + "name": "payee", + "type": "string" + }, + { + "name": "paymentAppId", + "type": "string" + }, + { + "name": "paymentRequestId", + "type": "string" + }, + { + "name": "paymentReference", + "type": "string" + }, + { + "name": "paymentEndpointIdentifier", + "type": "string" + }, + { + "name": "periodStartsAt", + "type": "string" + }, + { + "name": "periodEndsAt", + "type": "string" + }, + { + "name": "conversionQuoteId", + "type": "string" + } + ] + } + } + }, + { + "changed_reference": "another-invoice", + "changed_reference_digest": "0x636c0cc7eb3700fec717a31897e2a5f194f0063fc640eb6302c1d3bc7fd92d56", + "changed_reference_valid": false, + "digest": "0x74edd62e3bb809d8f8b6da51c9523df68ebc891af05bcb6287894bd33f3bd414", + "domain_separator": "0x965075d3cd7ca15131a9ca8aea761b5d7ef7cdab64fcbdf9e6f8426af694abef", + "name": "ethereum_one_time", + "signature": "0x322fce239952a67b5c1c0466c0aca6b241818492ffb58726308a7eb3daecf50d0378abd7c1bf4d6d25f1969654dbee6f0368cf24dc1f54d675a514c2a700542a1b", + "signer": "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", + "struct_hash": "0x01f2a3f522dc76bb5a24ba4114767b515a768cdcecdeb4d905908e704f734a8b", + "typed_data": { + "domain": { + "chainId": 1, + "name": "Paykit ERC20 Payment", + "version": "1" + }, + "message": { + "receiptLogIndex": "2", + "request": { + "conversionQuoteId": "", + "payee": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "payer": "8jsf5bm1ck3r7sn6pfx4q9mgqq5xn8fi6sizw6pxgjc8zs1bt4io", + "paymentAppId": "example", + "paymentEndpointIdentifier": "usdt-ethereum-address", + "paymentReference": "invoice-1", + "paymentRequestId": "b7f9c2a1-6d43-4b0e-a8d4-0fe2c712ab33", + "periodEndsAt": "", + "periodStartsAt": "" + }, + "transactionHash": "0x1111111111111111111111111111111111111111111111111111111111111111" + }, + "primaryType": "Erc20Payment", + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + } + ], + "Erc20Payment": [ + { + "name": "transactionHash", + "type": "bytes32" + }, + { + "name": "receiptLogIndex", + "type": "uint256" + }, + { + "name": "request", + "type": "RequestBinding" + } + ], + "RequestBinding": [ + { + "name": "payer", + "type": "string" + }, + { + "name": "payee", + "type": "string" + }, + { + "name": "paymentAppId", + "type": "string" + }, + { + "name": "paymentRequestId", + "type": "string" + }, + { + "name": "paymentReference", + "type": "string" + }, + { + "name": "paymentEndpointIdentifier", + "type": "string" + }, + { + "name": "periodStartsAt", + "type": "string" + }, + { + "name": "periodEndsAt", + "type": "string" + }, + { + "name": "conversionQuoteId", + "type": "string" + } + ] + } + } + } + ] +} diff --git a/src/modules/usdt/mod.rs b/src/modules/usdt/mod.rs index f324cbf..2e996a4 100644 --- a/src/modules/usdt/mod.rs +++ b/src/modules/usdt/mod.rs @@ -6,6 +6,7 @@ mod history; mod keys; mod paymaster; mod payment_request; +mod proof; mod rpc; mod store; mod transaction; @@ -18,6 +19,7 @@ pub use deposits::*; pub use errors::UsdtError; pub use keys::usdt_address; pub use payment_request::usdt_parse_payment_request; +pub use proof::{UsdtPaymentProof, UsdtPaymentProofBinding, UsdtVerifiedPayment}; pub use types::*; pub use wallet::UsdtWallet; diff --git a/src/modules/usdt/proof.rs b/src/modules/usdt/proof.rs new file mode 100644 index 0000000..b96967e --- /dev/null +++ b/src/modules/usdt/proof.rs @@ -0,0 +1,359 @@ +use super::{ + amount::token_amount, + keys::derive_owner_key, + transaction::{entry_point_event, event_data, operation_logs, Erc20}, + types::{CHAIN_ID, TOKEN}, + user_operation::sign_hash, + UsdtDestination, UsdtError, UsdtTransferStatus, UsdtWallet, +}; +use alloy_primitives::{Address, Bytes, B256, U256}; +use alloy_sol_types::{eip712_domain, sol, SolEvent, SolStruct}; +use bitcoin::secp256k1::{ + ecdsa::{RecoverableSignature, RecoveryId}, + Message, Secp256k1, +}; +use serde_json::{json, Value}; + +sol! { + struct RequestBinding { + string payer; + string payee; + string paymentAppId; + string paymentRequestId; + string paymentReference; + string paymentEndpointIdentifier; + string periodStartsAt; + string periodEndsAt; + string conversionQuoteId; + } + struct Erc20Payment { + bytes32 transactionHash; + uint256 receiptLogIndex; + RequestBinding request; + } +} + +/// Immutable Paykit request fields, using the canonical strings from the accepted request/proof. +/// Pubky keys are bare z32; absent period and conversion quote fields are empty strings. +#[derive(Clone, Debug, uniffi::Record)] +pub struct UsdtPaymentProofBinding { + pub payer: String, + pub payee: String, + pub payment_app_id: String, + pub payment_request_id: String, + pub payment_reference: String, + pub payment_endpoint_identifier: String, + pub period_starts_at: String, + pub period_ends_at: String, + pub conversion_quote_id: String, +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct UsdtPaymentProof { + pub chain_id: String, + pub transaction_hash: String, + /// Decimal position in the complete receipt logs array, not the block-wide RPC logIndex. + pub receipt_log_index: String, + pub signature: String, +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct UsdtVerifiedPayment { + /// Verified chain, transaction and receipt position; claim at most once across requests/periods. + pub payment_id: String, + /// Existing incoming activity identity (transaction and block-wide log index). + pub transfer_id: String, + pub sender: String, + pub recipient: String, + pub amount: u64, + pub timestamp: u64, +} + +#[uniffi::export(async_runtime = "tokio")] +impl UsdtWallet { + /// Signs the executed direct payment using the Paykit ERC-20 EIP-712 profile. + /// Persist the binding and payment ID before send. Retry this after execution; it never sends. + pub async fn create_payment_proof( + &self, + transfer_id: String, + binding: UsdtPaymentProofBinding, + mnemonic: String, + passphrase: Option, + ) -> Result, UsdtError> { + let mnemonic = zeroize::Zeroizing::new(mnemonic); + let passphrase = passphrase.map(zeroize::Zeroizing::new); + validate_binding(&binding)?; + let _guard = self.operation.lock().await; + let transfer = self + .store + .transfer(&transfer_id)? + .ok_or(UsdtError::InvalidPaymentProof)?; + if transfer.destination != UsdtDestination::Arbitrum + || transfer.is_incoming + || matches!( + transfer.status, + UsdtTransferStatus::Failed | UsdtTransferStatus::Replaced + ) + { + return Err(UsdtError::InvalidPaymentProof); + } + let Some(hash) = transfer.tx_hash else { + return Ok(None); + }; + let hash = canonical_hash(&hash)?; + let Some((receipt, _)) = self.payment_receipt(hash).await? else { + return Ok(None); + }; + let operation = transfer + .user_operation_hash + .ok_or(UsdtError::InvalidPaymentProof)? + .parse::() + .map_err(|_| UsdtError::InvalidPaymentProof)?; + let logs = operation_logs(&receipt, operation)?; + let event = logs + .last() + .and_then(|log| entry_point_event(log).ok().flatten()) + .ok_or(UsdtError::InvalidPaymentProof)?; + if !event.success || event.sender != self.address { + return Err(UsdtError::InvalidPaymentProof); + } + let recipient = transfer + .recipient + .parse::
() + .map_err(|_| UsdtError::InvalidPaymentProof)?; + let mut selected = None; + for log in logs { + let Some(event) = token_transfer(log)? else { + continue; + }; + if event.from == self.address + && event.to == recipient + && event.value == U256::from(transfer.amount) + { + if selected.is_some() { + return Err(UsdtError::InvalidPaymentProof); + } + selected = Some(receipt_index(log)?); + } + } + let index = selected.ok_or(UsdtError::InvalidPaymentProof)?; + let digest = proof_digest(CHAIN_ID, hash, index, &binding); + let key = derive_owner_key(mnemonic, passphrase, self.address)?; + let signature = sign_hash(digest, &key)?; + Ok(Some(UsdtPaymentProof { + chain_id: CHAIN_ID.to_string(), + transaction_hash: format!("{hash:#x}"), + receipt_log_index: index.to_string(), + signature: format!("{signature:#x}"), + })) + } + + /// Verifies a successful canonical ERC-20 transfer to this wallet and its request signature. + /// Ordinary EOA transfers are supported. None means evidence is not yet available. + /// The caller verifies accepted terms, payment-time deadlines and payment_id deduplication. + pub async fn verify_payment_proof( + &self, + binding: UsdtPaymentProofBinding, + proof: UsdtPaymentProof, + ) -> Result, UsdtError> { + validate_binding(&binding)?; + if proof.chain_id != CHAIN_ID.to_string() { + return Err(UsdtError::InvalidPaymentProof); + } + let hash = canonical_hash(&proof.transaction_hash)?; + let index = decimal_index(&proof.receipt_log_index)?; + let sender = proof_sender( + proof_digest(CHAIN_ID, hash, index, &binding), + &proof.signature, + )?; + let Some((receipt, timestamp)) = self.payment_receipt(hash).await? else { + return Ok(None); + }; + let log = receipt["logs"] + .as_array() + .ok_or(UsdtError::InvalidResponse)? + .iter() + .find(|log| receipt_index(log).ok() == Some(index)) + .ok_or(UsdtError::InvalidPaymentProof)?; + let transfer = token_transfer(log)?.ok_or(UsdtError::InvalidPaymentProof)?; + if transfer.from != sender || transfer.to != self.address || transfer.value.is_zero() { + return Err(UsdtError::InvalidPaymentProof); + } + let log_index: U256 = serde_json::from_value(log["logIndex"].clone())?; + Ok(Some(UsdtVerifiedPayment { + payment_id: format!("{CHAIN_ID}:{hash:#x}:{index}"), + transfer_id: format!("{hash:#x}:{log_index}"), + sender: sender.to_checksum(None), + recipient: self.receive_address(), + amount: token_amount(transfer.value)?, + timestamp, + })) + } +} + +impl UsdtWallet { + async fn payment_receipt(&self, hash: B256) -> Result, UsdtError> { + self.rpc.verify_chain().await?; + let receipt: Value = self + .rpc + .call("eth_getTransactionReceipt", json!([hash])) + .await?; + if receipt.is_null() { + return Ok(None); + } + if serde_json::from_value::(receipt["transactionHash"].clone())? != hash { + return Err(UsdtError::InvalidResponse); + } + if serde_json::from_value::(receipt["status"].clone())? != U256::from(1) { + return Err(UsdtError::InvalidPaymentProof); + } + let number: U256 = serde_json::from_value(receipt["blockNumber"].clone())?; + let block = self + .rpc + .block(number.try_into().map_err(|_| UsdtError::InvalidResponse)?) + .await?; + if serde_json::from_value::(receipt["blockHash"].clone())? != block.hash { + return Err(UsdtError::NetworkUnavailable); + } + let mut positions = std::collections::HashSet::new(); + for log in receipt["logs"] + .as_array() + .ok_or(UsdtError::InvalidResponse)? + { + if !positions.insert(receipt_index(log)?) + || serde_json::from_value::(log["transactionHash"].clone())? != hash + || serde_json::from_value::(log["blockHash"].clone())? != block.hash + || serde_json::from_value::(log["blockNumber"].clone())? != number + || log["removed"].as_bool() == Some(true) + { + return Err(UsdtError::InvalidResponse); + } + } + Ok(Some((receipt, block.timestamp()?))) + } +} + +fn validate_binding(binding: &UsdtPaymentProofBinding) -> Result<(), UsdtError> { + if binding.payment_endpoint_identifier != "usdt-arbitrum-address" + || [ + &binding.payer, + &binding.payee, + &binding.payment_app_id, + &binding.payment_request_id, + &binding.payment_reference, + ] + .into_iter() + .any(|s| s.is_empty()) + || [ + &binding.payer, + &binding.payee, + &binding.payment_app_id, + &binding.payment_request_id, + &binding.payment_reference, + &binding.period_starts_at, + &binding.period_ends_at, + &binding.conversion_quote_id, + ] + .into_iter() + .any(|s| s.len() > 1024 || s.contains('\0')) + { + return Err(UsdtError::InvalidPaymentProof); + } + Ok(()) +} + +fn proof_digest(chain: u64, hash: B256, index: U256, binding: &UsdtPaymentProofBinding) -> B256 { + Erc20Payment { + transactionHash: hash, + receiptLogIndex: index, + request: RequestBinding { + payer: binding.payer.clone(), + payee: binding.payee.clone(), + paymentAppId: binding.payment_app_id.clone(), + paymentRequestId: binding.payment_request_id.clone(), + paymentReference: binding.payment_reference.clone(), + paymentEndpointIdentifier: binding.payment_endpoint_identifier.clone(), + periodStartsAt: binding.period_starts_at.clone(), + periodEndsAt: binding.period_ends_at.clone(), + conversionQuoteId: binding.conversion_quote_id.clone(), + }, + } + .eip712_signing_hash( + &eip712_domain! { name: "Paykit ERC20 Payment", version: "1", chain_id: chain, }, + ) +} + +fn canonical_hex(value: &str, bytes: usize) -> bool { + value.len() == 2 + bytes * 2 + && value.starts_with("0x") + && value.as_bytes()[2..] + .iter() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(c)) +} +fn canonical_hash(value: &str) -> Result { + if !canonical_hex(value, 32) { + return Err(UsdtError::InvalidPaymentProof); + } + value.parse().map_err(|_| UsdtError::InvalidPaymentProof) +} +fn decimal_index(value: &str) -> Result { + if value.is_empty() + || value.len() > 78 + || (value.len() > 1 && value.starts_with('0')) + || !value.bytes().all(|c| c.is_ascii_digit()) + { + return Err(UsdtError::InvalidPaymentProof); + } + U256::from_str_radix(value, 10).map_err(|_| UsdtError::InvalidPaymentProof) +} +fn receipt_index(log: &Value) -> Result { + // The proxy supplies the original position before projecting the receipt to protocol events. + decimal_index( + log["receiptLogIndex"] + .as_str() + .ok_or(UsdtError::InvalidResponse)?, + ) + .map_err(|_| UsdtError::InvalidResponse) +} +fn token_transfer(log: &Value) -> Result, UsdtError> { + if serde_json::from_value::
(log["address"].clone())? != TOKEN { + return Ok(None); + } + let data = event_data(log)?; + if data.topics().first() != Some(&Erc20::Transfer::SIGNATURE_HASH) { + return Ok(None); + } + Erc20::Transfer::decode_log_data_validate(&data) + .map(Some) + .map_err(|_| UsdtError::InvalidResponse) +} +fn proof_sender(digest: B256, signature: &str) -> Result { + if !canonical_hex(signature, 65) { + return Err(UsdtError::InvalidPaymentProof); + } + let signature = signature + .parse::() + .map_err(|_| UsdtError::InvalidPaymentProof)?; + if !matches!(signature[64], 27 | 28) { + return Err(UsdtError::InvalidPaymentProof); + } + let recovery = RecoveryId::from_i32(i32::from(signature[64] - 27)) + .map_err(|_| UsdtError::InvalidPaymentProof)?; + let signature = RecoverableSignature::from_compact(&signature[..64], recovery) + .map_err(|_| UsdtError::InvalidPaymentProof)?; + let standard = signature.to_standard(); + let mut normalized = standard; + normalized.normalize_s(); + if standard != normalized { + return Err(UsdtError::InvalidPaymentProof); + } + let public = Secp256k1::new() + .recover_ecdsa(&Message::from_digest(digest.0), &signature) + .map_err(|_| UsdtError::InvalidPaymentProof)?; + Ok(Address::from_raw_public_key( + &public.serialize_uncompressed()[1..], + )) +} + +#[cfg(test)] +mod tests; diff --git a/src/modules/usdt/proof/tests.rs b/src/modules/usdt/proof/tests.rs new file mode 100644 index 0000000..e6aa0cb --- /dev/null +++ b/src/modules/usdt/proof/tests.rs @@ -0,0 +1,82 @@ +use super::*; + +fn binding(value: &Value) -> UsdtPaymentProofBinding { + UsdtPaymentProofBinding { + payer: value["payer"].as_str().unwrap().into(), + payee: value["payee"].as_str().unwrap().into(), + payment_app_id: value["paymentAppId"].as_str().unwrap().into(), + payment_request_id: value["paymentRequestId"].as_str().unwrap().into(), + payment_reference: value["paymentReference"].as_str().unwrap().into(), + payment_endpoint_identifier: value["paymentEndpointIdentifier"].as_str().unwrap().into(), + period_starts_at: value["periodStartsAt"].as_str().unwrap().into(), + period_ends_at: value["periodEndsAt"].as_str().unwrap().into(), + conversion_quote_id: value["conversionQuoteId"].as_str().unwrap().into(), + } +} + +#[test] +fn erc20_profile_matches_published_one_time_and_recurring_vectors() { + let fixture: Value = + serde_json::from_str(include_str!("../fixtures/erc20-payment-proofs.json")).unwrap(); + for vector in fixture["vectors"].as_array().unwrap() { + let data = &vector["typed_data"]; + let chain = data["domain"]["chainId"].as_u64().unwrap(); + let hash = canonical_hash(data["message"]["transactionHash"].as_str().unwrap()).unwrap(); + let index = decimal_index(data["message"]["receiptLogIndex"].as_str().unwrap()).unwrap(); + let mut binding = binding(&data["message"]["request"]); + let digest = proof_digest(chain, hash, index, &binding); + assert_eq!(format!("{digest:#x}"), vector["digest"].as_str().unwrap()); + let signature = vector["signature"].as_str().unwrap(); + let signer = proof_sender(digest, signature).unwrap(); + assert_eq!(signer.to_checksum(None), vector["signer"].as_str().unwrap()); + let app = std::mem::replace(&mut binding.payment_app_id, "another-app".into()); + assert_ne!( + proof_sender(proof_digest(chain, hash, index, &binding), signature).unwrap(), + signer + ); + binding.payment_app_id = app; + binding.payment_reference = vector["changed_reference"].as_str().unwrap().into(); + let changed = proof_digest(chain, hash, index, &binding); + assert_eq!( + format!("{changed:#x}"), + vector["changed_reference_digest"].as_str().unwrap() + ); + assert_ne!(proof_sender(changed, signature).unwrap(), signer); + assert_ne!( + proof_sender(proof_digest(chain + 1, hash, index, &binding), signature).unwrap(), + signer + ); + let mut high_s = signature.parse::().unwrap().to_vec(); + let order = U256::from_str_radix( + "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", + 16, + ) + .unwrap(); + let s = U256::from_be_slice(&high_s[32..64]); + high_s[32..64].copy_from_slice(&(order - s).to_be_bytes::<32>()); + high_s[64] = if high_s[64] == 27 { 28 } else { 27 }; + assert!(proof_sender(digest, &format!("{:#x}", Bytes::from(high_s))).is_err()); + } +} + +#[test] +fn proof_identifiers_have_one_canonical_encoding() { + assert_eq!(decimal_index("0").unwrap(), U256::ZERO); + assert_eq!(decimal_index(&U256::MAX.to_string()).unwrap(), U256::MAX); + for value in [ + "", + "00", + "01", + "+1", + "-1", + " 1", + "1 ", + "1e1", + "1", + "0x1", + "115792089237316195423570985008687907853269984665640564039457584007913129639936", + ] { + assert!(decimal_index(value).is_err(), "{value}"); + } + assert!(canonical_hash(&format!("0x{}", "AB".repeat(32))).is_err()); +} diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index 2b24262..7bead8c 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -218,6 +218,195 @@ fn payment_requests_preserve_exact_token_amounts_and_reject_ambiguous_terms() { const TEST_PHRASE: &str = "test test test test test test test test test test test junk"; const RECIPIENT: &str = "0x1111111111111111111111111111111111111111"; +#[tokio::test] +async fn payment_proof_binds_execution_to_request_and_receiver() { + use alloy_primitives::{B256, U256}; + use alloy_sol_types::SolEvent; + let chain = MockChain::start().await; + let sender_dir = tempfile::tempdir().unwrap(); + let receiver_dir = tempfile::tempdir().unwrap(); + let sender = chain.wallet(&sender_dir); + let receiver = UsdtWallet::new( + RECIPIENT.into(), + receiver_dir + .path() + .join("usdt.sqlite") + .to_string_lossy() + .into(), + format!("{}/chain", chain.url), + format!("{}/bundler", chain.url), + ) + .unwrap(); + let binding = UsdtPaymentProofBinding { + payer: "8jsf5bm1ck3r7sn6pfx4q9mgqq5xn8fi6sizw6pxgjc8zs1bt4io".into(), + payee: "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy".into(), + payment_app_id: "example".into(), + payment_request_id: "b7f9c2a1-6d43-4b0e-a8d4-0fe2c712ab33".into(), + payment_reference: "invoice-1".into(), + payment_endpoint_identifier: "usdt-arbitrum-address".into(), + period_starts_at: String::new(), + period_ends_at: String::new(), + conversion_quote_id: String::new(), + }; + let quote = sender + .quote_transfer(RECIPIENT.into(), 50_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + assert!(sender + .create_payment_proof(quote.id.clone(), binding.clone(), TEST_PHRASE.into(), None) + .await + .is_err()); + sender + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await + .unwrap(); + assert!(sender + .create_payment_proof(quote.id.clone(), binding.clone(), TEST_PHRASE.into(), None) + .await + .unwrap() + .is_none()); + chain.state.lock().unwrap().mined = true; + sender + .check_recent_execution(quote.id.clone()) + .await + .unwrap(); + let proof = sender + .create_payment_proof(quote.id.clone(), binding.clone(), TEST_PHRASE.into(), None) + .await + .unwrap() + .unwrap(); + let recreated = sender + .create_payment_proof(quote.id.clone(), binding.clone(), TEST_PHRASE.into(), None) + .await + .unwrap() + .unwrap(); + assert_eq!(proof.signature, recreated.signature); + chain.state.lock().unwrap().hide_receipts = true; + assert!(receiver + .verify_payment_proof(binding.clone(), proof.clone()) + .await + .unwrap() + .is_none()); + chain.state.lock().unwrap().hide_receipts = false; + let verified = receiver + .verify_payment_proof(binding.clone(), proof.clone()) + .await + .unwrap() + .unwrap(); + let hash = format!("{:#x}", B256::repeat_byte(7)); + assert_eq!(verified.payment_id, format!("42161:{hash}:0")); + assert_eq!(verified.transfer_id, format!("{hash}:0")); + assert_eq!(verified.amount, 50_000); + assert_eq!(verified.sender, sender.receive_address()); + // The proxy retains original receipt positions even when unrelated logs are removed. + let mut receipt = chain.state.lock().unwrap().respond(&serde_json::json!({ + "method": "eth_getTransactionReceipt", "params": [hash] + }))["result"] + .clone(); + for (index, log) in receipt["logs"] + .as_array_mut() + .unwrap() + .iter_mut() + .enumerate() + { + log["receiptLogIndex"] = serde_json::json!((index + 3).to_string()); + log["logIndex"] = serde_json::json!(format!("0x{:x}", index + 42)); + } + chain.state.lock().unwrap().receipt_response = Some(receipt.clone()); + let indexed_proof = sender + .create_payment_proof(quote.id, binding.clone(), TEST_PHRASE.into(), None) + .await + .unwrap() + .unwrap(); + assert_eq!(indexed_proof.receipt_log_index, "3"); + let indexed_payment = receiver + .verify_payment_proof(binding.clone(), indexed_proof.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(indexed_payment.payment_id, format!("42161:{hash}:3")); + assert_eq!(indexed_payment.transfer_id, format!("{hash}:42")); + for mutation in ["missing_position", "duplicate_position", "failed", "reorg"] { + let mut invalid = receipt.clone(); + match mutation { + "missing_position" => { + invalid["logs"][0] + .as_object_mut() + .unwrap() + .remove("receiptLogIndex"); + } + "duplicate_position" => { + invalid["logs"][1]["receiptLogIndex"] = + invalid["logs"][0]["receiptLogIndex"].clone() + } + "failed" => invalid["status"] = serde_json::json!("0x0"), + "reorg" => invalid["blockHash"] = serde_json::json!(B256::repeat_byte(99)), + _ => unreachable!(), + } + chain.state.lock().unwrap().receipt_response = Some(invalid); + assert!( + receiver + .verify_payment_proof(binding.clone(), indexed_proof.clone()) + .await + .is_err(), + "{mutation}" + ); + } + chain.state.lock().unwrap().receipt_response = None; + let mut other_request = binding.clone(); + other_request.payment_reference = "other-invoice".into(); + assert!(receiver + .verify_payment_proof(other_request, proof.clone()) + .await + .is_err()); + assert!(sender + .verify_payment_proof(binding.clone(), proof.clone()) + .await + .is_err()); + let logs = chain.state.lock().unwrap().event_logs(); + // Verification supports an ordinary ERC-20 transfer, without an EntryPoint operation. + chain.state.lock().unwrap().receipt_logs = Some(vec![logs[0].clone()]); + assert!(receiver + .verify_payment_proof(binding.clone(), proof.clone()) + .await + .unwrap() + .is_some()); + let mut wrong_token = logs[0].clone(); + wrong_token["address"] = serde_json::json!(account::DELEGATE); + chain.state.lock().unwrap().receipt_logs = Some(vec![wrong_token]); + assert!(receiver + .verify_payment_proof(binding.clone(), proof.clone()) + .await + .is_err()); + let mut wrong_recipient = logs[0].clone(); + let event = transaction::Erc20::Transfer { + from: sender.address, + to: account::DELEGATE, + value: U256::from(50_000), + } + .encode_log_data(); + wrong_recipient["topics"] = serde_json::json!(event.topics()); + wrong_recipient["data"] = serde_json::json!(event.data); + chain.state.lock().unwrap().receipt_logs = Some(vec![wrong_recipient]); + assert!(receiver + .verify_payment_proof(binding.clone(), proof.clone()) + .await + .is_err()); + chain.state.lock().unwrap().receipt_logs = Some(vec![logs[0].clone()]); + let mut wrong_index = proof.clone(); + wrong_index.receipt_log_index = "1".into(); + assert!(receiver + .verify_payment_proof(binding.clone(), wrong_index) + .await + .is_err()); + let mut wrong_chain = proof.clone(); + wrong_chain.chain_id = "1".into(); + assert!(receiver + .verify_payment_proof(binding, wrong_chain) + .await + .is_err()); +} + struct MockChain { url: String, state: std::sync::Arc>, @@ -759,14 +948,17 @@ impl ChainState { { return json!({"jsonrpc":"2.0","id":1,"result":null}); } - let logs = self.receipt_logs.clone().unwrap_or_else(|| { + let mut logs = self.receipt_logs.clone().unwrap_or_else(|| { if body["params"][0] == json!(alloy_primitives::B256::repeat_byte(7)) { self.event_logs() } else { vec![] } }); - json!({"transactionHash":body["params"][0],"blockHash":self.block_hash(20000),"blockNumber":"0x4e20","logs":logs,"padding":" ".repeat(self.receipt_padding)}) + for (index, log) in logs.iter_mut().enumerate() { + log["receiptLogIndex"] = json!(index.to_string()); + } + json!({"status":"0x1","transactionHash":body["params"][0],"blockHash":self.block_hash(20000),"blockNumber":"0x4e20","logs":logs,"padding":" ".repeat(self.receipt_padding)}) } "eth_getTransactionByHash" => { let op = &self.operations[0]; @@ -864,6 +1056,9 @@ impl ChainState { .encode_log_data(); logs.push(json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":format!("0x{:x}", logs.len())})); } + for log in &mut logs { + log["blockHash"] = json!(self.block_hash(20000)); + } logs } } diff --git a/src/modules/usdt/wallet.rs b/src/modules/usdt/wallet.rs index bc2fb44..1a10c7f 100644 --- a/src/modules/usdt/wallet.rs +++ b/src/modules/usdt/wallet.rs @@ -37,7 +37,7 @@ pub struct UsdtWallet { pub(super) rpc: Rpc, pub(super) paymaster: Pimlico, pub(super) store: Store, - operation: Mutex<()>, + pub(super) operation: Mutex<()>, bridge_poll_offset: AtomicUsize, bridge_retry_after: Mutex>, pub(super) history_range_limit: AtomicU64,