diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 6f8bc29..aac46a9 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -2800,6 +2800,11 @@ public protocol UsdtWalletProtocol: AnyObject, Sendable { func history() throws -> [UsdtTransfer] + /** + * Available Orchestra destinations. USDT0 destinations retain the app's existing configuration. + */ + func orchestraDestinations() async throws -> [UsdtDestination] + func quoteTransfer(recipient: String, amount: UInt64, destination: UsdtDestination) async throws -> UsdtQuote func receiveAddress() -> String @@ -2873,14 +2878,15 @@ open class UsdtWallet: UsdtWalletProtocol, @unchecked Sendable { /** * Creates the sole owner of this wallet's database; reuse it for all calls until it is dropped. */ -public convenience init(address: String, storagePath: String, rpcUrl: String, bundlerUrl: String)throws { +public convenience init(address: String, storagePath: String, rpcUrl: String, bundlerUrl: String, bridgeUrl: String? = nil)throws { let pointer = try rustCallWithError(FfiConverterTypeUsdtError_lift) { uniffi_bitkitcore_fn_constructor_usdtwallet_new( FfiConverterString.lower(address), FfiConverterString.lower(storagePath), FfiConverterString.lower(rpcUrl), - FfiConverterString.lower(bundlerUrl),$0 + FfiConverterString.lower(bundlerUrl), + FfiConverterOptionString.lower(bridgeUrl),$0 ) } self.init(unsafeFromRawPointer: pointer) @@ -2965,6 +2971,26 @@ open func history()throws -> [UsdtTransfer] { }) } + /** + * Available Orchestra destinations. USDT0 destinations retain the app's existing configuration. + */ +open func orchestraDestinations()async throws -> [UsdtDestination] { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtwallet_orchestra_destinations( + self.uniffiClonePointer() + + ) + }, + 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: FfiConverterSequenceTypeUsdtDestination.lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + open func quoteTransfer(recipient: String, amount: UInt64, destination: UsdtDestination)async throws -> UsdtQuote { return try await uniffiRustCallAsync( @@ -16830,6 +16856,102 @@ public func FfiConverterTypeUsdtDepositPage_lower(_ value: UsdtDepositPage) -> R } +public struct UsdtOrchestraTransfer { + public var quoteId: String + public var fundingAddress: String + public var destinationTx: String? + public var refundTx: String? + public var refundAmount: UInt64? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(quoteId: String, fundingAddress: String, destinationTx: String?, refundTx: String?, refundAmount: UInt64?) { + self.quoteId = quoteId + self.fundingAddress = fundingAddress + self.destinationTx = destinationTx + self.refundTx = refundTx + self.refundAmount = refundAmount + } +} + +#if compiler(>=6) +extension UsdtOrchestraTransfer: Sendable {} +#endif + + +extension UsdtOrchestraTransfer: Equatable, Hashable { + public static func ==(lhs: UsdtOrchestraTransfer, rhs: UsdtOrchestraTransfer) -> Bool { + if lhs.quoteId != rhs.quoteId { + return false + } + if lhs.fundingAddress != rhs.fundingAddress { + return false + } + if lhs.destinationTx != rhs.destinationTx { + return false + } + if lhs.refundTx != rhs.refundTx { + return false + } + if lhs.refundAmount != rhs.refundAmount { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(quoteId) + hasher.combine(fundingAddress) + hasher.combine(destinationTx) + hasher.combine(refundTx) + hasher.combine(refundAmount) + } +} + +extension UsdtOrchestraTransfer: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtOrchestraTransfer: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtOrchestraTransfer { + return + try UsdtOrchestraTransfer( + quoteId: FfiConverterString.read(from: &buf), + fundingAddress: FfiConverterString.read(from: &buf), + destinationTx: FfiConverterOptionString.read(from: &buf), + refundTx: FfiConverterOptionString.read(from: &buf), + refundAmount: FfiConverterOptionUInt64.read(from: &buf) + ) + } + + public static func write(_ value: UsdtOrchestraTransfer, into buf: inout [UInt8]) { + FfiConverterString.write(value.quoteId, into: &buf) + FfiConverterString.write(value.fundingAddress, into: &buf) + FfiConverterOptionString.write(value.destinationTx, into: &buf) + FfiConverterOptionString.write(value.refundTx, into: &buf) + FfiConverterOptionUInt64.write(value.refundAmount, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtOrchestraTransfer_lift(_ buf: RustBuffer) throws -> UsdtOrchestraTransfer { + return try FfiConverterTypeUsdtOrchestraTransfer.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtOrchestraTransfer_lower(_ value: UsdtOrchestraTransfer) -> RustBuffer { + return FfiConverterTypeUsdtOrchestraTransfer.lower(value) +} + + public struct UsdtPaymentProof { public var chainId: String public var transactionHash: String @@ -17143,6 +17265,10 @@ public func FfiConverterTypeUsdtPaymentRequest_lower(_ value: UsdtPaymentRequest public struct UsdtQuote { + /** + * Absent for a direct Arbitrum payment. The provider is fixed when this quote is approved. + */ + public var bridgeProvider: UsdtBridgeProvider? public var id: String public var recipient: String public var destination: UsdtDestination @@ -17153,7 +17279,11 @@ public struct UsdtQuote { // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, recipient: String, destination: UsdtDestination, amount: UInt64, receivedAmount: UInt64, maximumFee: UInt64, expiresAt: UInt64) { + public init( + /** + * Absent for a direct Arbitrum payment. The provider is fixed when this quote is approved. + */bridgeProvider: UsdtBridgeProvider?, id: String, recipient: String, destination: UsdtDestination, amount: UInt64, receivedAmount: UInt64, maximumFee: UInt64, expiresAt: UInt64) { + self.bridgeProvider = bridgeProvider self.id = id self.recipient = recipient self.destination = destination @@ -17171,6 +17301,9 @@ extension UsdtQuote: Sendable {} extension UsdtQuote: Equatable, Hashable { public static func ==(lhs: UsdtQuote, rhs: UsdtQuote) -> Bool { + if lhs.bridgeProvider != rhs.bridgeProvider { + return false + } if lhs.id != rhs.id { return false } @@ -17196,6 +17329,7 @@ extension UsdtQuote: Equatable, Hashable { } public func hash(into hasher: inout Hasher) { + hasher.combine(bridgeProvider) hasher.combine(id) hasher.combine(recipient) hasher.combine(destination) @@ -17217,6 +17351,7 @@ public struct FfiConverterTypeUsdtQuote: FfiConverterRustBuffer { public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtQuote { return try UsdtQuote( + bridgeProvider: FfiConverterOptionTypeUsdtBridgeProvider.read(from: &buf), id: FfiConverterString.read(from: &buf), recipient: FfiConverterString.read(from: &buf), destination: FfiConverterTypeUsdtDestination.read(from: &buf), @@ -17228,6 +17363,7 @@ public struct FfiConverterTypeUsdtQuote: FfiConverterRustBuffer { } public static func write(_ value: UsdtQuote, into buf: inout [UInt8]) { + FfiConverterOptionTypeUsdtBridgeProvider.write(value.bridgeProvider, into: &buf) FfiConverterString.write(value.id, into: &buf) FfiConverterString.write(value.recipient, into: &buf) FfiConverterTypeUsdtDestination.write(value.destination, into: &buf) @@ -17262,6 +17398,7 @@ public struct UsdtTransfer { public var txHash: String? public var userOperationHash: String? public var bridgeGuid: String? + public var orchestra: UsdtOrchestraTransfer? public var recipient: String public var destination: UsdtDestination public var amount: UInt64 @@ -17276,11 +17413,12 @@ public struct UsdtTransfer { public init(id: String, /** * Source transaction hash, absent until execution is observed. - */txHash: String?, userOperationHash: String?, bridgeGuid: String?, recipient: String, destination: UsdtDestination, amount: UInt64, receivedAmount: UInt64, fee: UInt64?, isIncoming: Bool, status: UsdtTransferStatus, timestamp: UInt64) { + */txHash: String?, userOperationHash: String?, bridgeGuid: String?, orchestra: UsdtOrchestraTransfer?, recipient: String, destination: UsdtDestination, amount: UInt64, receivedAmount: UInt64, fee: UInt64?, isIncoming: Bool, status: UsdtTransferStatus, timestamp: UInt64) { self.id = id self.txHash = txHash self.userOperationHash = userOperationHash self.bridgeGuid = bridgeGuid + self.orchestra = orchestra self.recipient = recipient self.destination = destination self.amount = amount @@ -17311,6 +17449,9 @@ extension UsdtTransfer: Equatable, Hashable { if lhs.bridgeGuid != rhs.bridgeGuid { return false } + if lhs.orchestra != rhs.orchestra { + return false + } if lhs.recipient != rhs.recipient { return false } @@ -17343,6 +17484,7 @@ extension UsdtTransfer: Equatable, Hashable { hasher.combine(txHash) hasher.combine(userOperationHash) hasher.combine(bridgeGuid) + hasher.combine(orchestra) hasher.combine(recipient) hasher.combine(destination) hasher.combine(amount) @@ -17369,6 +17511,7 @@ public struct FfiConverterTypeUsdtTransfer: FfiConverterRustBuffer { txHash: FfiConverterOptionString.read(from: &buf), userOperationHash: FfiConverterOptionString.read(from: &buf), bridgeGuid: FfiConverterOptionString.read(from: &buf), + orchestra: FfiConverterOptionTypeUsdtOrchestraTransfer.read(from: &buf), recipient: FfiConverterString.read(from: &buf), destination: FfiConverterTypeUsdtDestination.read(from: &buf), amount: FfiConverterUInt64.read(from: &buf), @@ -17385,6 +17528,7 @@ public struct FfiConverterTypeUsdtTransfer: FfiConverterRustBuffer { FfiConverterOptionString.write(value.txHash, into: &buf) FfiConverterOptionString.write(value.userOperationHash, into: &buf) FfiConverterOptionString.write(value.bridgeGuid, into: &buf) + FfiConverterOptionTypeUsdtOrchestraTransfer.write(value.orchestra, into: &buf) FfiConverterString.write(value.recipient, into: &buf) FfiConverterTypeUsdtDestination.write(value.destination, into: &buf) FfiConverterUInt64.write(value.amount, into: &buf) @@ -24652,6 +24796,78 @@ extension UrPayload: Codable {} +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. + +public enum UsdtBridgeProvider { + + case usdt0 + case orchestra +} + + +#if compiler(>=6) +extension UsdtBridgeProvider: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtBridgeProvider: FfiConverterRustBuffer { + typealias SwiftType = UsdtBridgeProvider + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtBridgeProvider { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .usdt0 + + case 2: return .orchestra + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: UsdtBridgeProvider, into buf: inout [UInt8]) { + switch value { + + + case .usdt0: + writeInt(&buf, Int32(1)) + + + case .orchestra: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtBridgeProvider_lift(_ buf: RustBuffer) throws -> UsdtBridgeProvider { + return try FfiConverterTypeUsdtBridgeProvider.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtBridgeProvider_lower(_ value: UsdtBridgeProvider) -> RustBuffer { + return FfiConverterTypeUsdtBridgeProvider.lower(value) +} + + +extension UsdtBridgeProvider: Equatable, Hashable {} + +extension UsdtBridgeProvider: Codable {} + + + + + + // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. @@ -24762,6 +24978,10 @@ public enum UsdtDestination { case arbitrum case polygon case plasma + case base + case bsc + case solana + case tron } @@ -24789,6 +25009,14 @@ public struct FfiConverterTypeUsdtDestination: FfiConverterRustBuffer { case 5: return .plasma + case 6: return .base + + case 7: return .bsc + + case 8: return .solana + + case 9: return .tron + default: throw UniffiInternalError.unexpectedEnumCase } } @@ -24816,6 +25044,22 @@ public struct FfiConverterTypeUsdtDestination: FfiConverterRustBuffer { case .plasma: writeInt(&buf, Int32(5)) + + case .base: + writeInt(&buf, Int32(6)) + + + case .bsc: + writeInt(&buf, Int32(7)) + + + case .solana: + writeInt(&buf, Int32(8)) + + + case .tron: + writeInt(&buf, Int32(9)) + } } } @@ -25088,6 +25332,10 @@ public enum UsdtTransferStatus { * Delivery was permanently stopped. This does not imply a refund of source funds or fees. */ case bridgeFailed + /** + * Source-chain receipt proves USDT was returned to this wallet. + */ + case bridgeRefunded /** * Another operation consumed the payment nonce. */ @@ -25121,7 +25369,9 @@ public struct FfiConverterTypeUsdtTransferStatus: FfiConverterRustBuffer { case 6: return .bridgeFailed - case 7: return .replaced + case 7: return .bridgeRefunded + + case 8: return .replaced default: throw UniffiInternalError.unexpectedEnumCase } @@ -25155,9 +25405,13 @@ public struct FfiConverterTypeUsdtTransferStatus: FfiConverterRustBuffer { writeInt(&buf, Int32(6)) - case .replaced: + case .bridgeRefunded: writeInt(&buf, Int32(7)) + + case .replaced: + writeInt(&buf, Int32(8)) + } } } @@ -26335,6 +26589,30 @@ fileprivate struct FfiConverterOptionTypeUsdtDepositOrder: FfiConverterRustBuffe } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeUsdtOrchestraTransfer: FfiConverterRustBuffer { + typealias SwiftType = UsdtOrchestraTransfer? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeUsdtOrchestraTransfer.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 FfiConverterTypeUsdtOrchestraTransfer.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -26767,6 +27045,30 @@ fileprivate struct FfiConverterOptionTypeUrPayload: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeUsdtBridgeProvider: FfiConverterRustBuffer { + typealias SwiftType = UsdtBridgeProvider? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeUsdtBridgeProvider.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 FfiConverterTypeUsdtBridgeProvider.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -27962,6 +28264,31 @@ fileprivate struct FfiConverterSequenceTypeUsdtDepositNetwork: FfiConverterRustB } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeUsdtDestination: FfiConverterRustBuffer { + typealias SwiftType = [UsdtDestination] + + public static func write(_ value: [UsdtDestination], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeUsdtDestination.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [UsdtDestination] { + let len: Int32 = try readInt(&buf) + var seq = [UsdtDestination]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeUsdtDestination.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -30652,6 +30979,17 @@ public func usdtParsePaymentRequest(value: String)throws -> UsdtPaymentRequest ) }) } +/** + * Validates a recipient for the chosen network; payment URIs remain Arbitrum-only. + */ +public func usdtValidateRecipient(value: String, destination: UsdtDestination)throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeUsdtError_lift) { + uniffi_bitkitcore_fn_func_usdt_validate_recipient( + FfiConverterString.lower(value), + FfiConverterTypeUsdtDestination_lower(destination),$0 + ) +}) +} public func validateBitcoinAddress(address: String)throws -> ValidationResult { return try FfiConverterTypeValidationResult_lift(try rustCallWithError(FfiConverterTypeAddressError_lift) { uniffi_bitkitcore_fn_func_validate_bitcoin_address( @@ -31314,6 +31652,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_func_usdt_parse_payment_request() != 63265) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_func_usdt_validate_recipient() != 17281) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_func_validate_bitcoin_address() != 56003) { return InitializationResult.apiChecksumMismatch } @@ -31425,6 +31766,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_usdtwallet_history() != 4617) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_method_usdtwallet_orchestra_destinations() != 39605) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_method_usdtwallet_quote_transfer() != 3732) { return InitializationResult.apiChecksumMismatch } @@ -31452,7 +31796,7 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_constructor_usdtdepositclient_new() != 44626) { return InitializationResult.apiChecksumMismatch } - if (uniffi_bitkitcore_checksum_constructor_usdtwallet_new() != 62148) { + if (uniffi_bitkitcore_checksum_constructor_usdtwallet_new() != 14616) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index 151e8b6..e7f9521 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -712,7 +712,7 @@ void uniffi_bitkitcore_fn_free_usdtwallet(void*_Nonnull ptr, RustCallStatus *_No #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CONSTRUCTOR_USDTWALLET_NEW #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CONSTRUCTOR_USDTWALLET_NEW -void*_Nonnull uniffi_bitkitcore_fn_constructor_usdtwallet_new(RustBuffer address, RustBuffer storage_path, RustBuffer rpc_url, RustBuffer bundler_url, RustCallStatus *_Nonnull out_status +void*_Nonnull uniffi_bitkitcore_fn_constructor_usdtwallet_new(RustBuffer address, RustBuffer storage_path, RustBuffer rpc_url, RustBuffer bundler_url, RustBuffer bridge_url, RustCallStatus *_Nonnull out_status ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_BALANCE @@ -735,6 +735,11 @@ uint64_t uniffi_bitkitcore_fn_method_usdtwallet_create_payment_proof(void*_Nonnu RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_history(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_ORCHESTRA_DESTINATIONS +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_ORCHESTRA_DESTINATIONS +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_orchestra_destinations(void*_Nonnull ptr +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_QUOTE_TRANSFER #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_QUOTE_TRANSFER uint64_t uniffi_bitkitcore_fn_method_usdtwallet_quote_transfer(void*_Nonnull ptr, RustBuffer recipient, uint64_t amount, RustBuffer destination @@ -1824,6 +1829,11 @@ uint64_t uniffi_bitkitcore_fn_func_usdt_parse_amount(RustBuffer value, RustCallS RustBuffer uniffi_bitkitcore_fn_func_usdt_parse_payment_request(RustBuffer value, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_USDT_VALIDATE_RECIPIENT +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_USDT_VALIDATE_RECIPIENT +RustBuffer uniffi_bitkitcore_fn_func_usdt_validate_recipient(RustBuffer value, RustBuffer destination, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_VALIDATE_BITCOIN_ADDRESS #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_VALIDATE_BITCOIN_ADDRESS RustBuffer uniffi_bitkitcore_fn_func_validate_bitcoin_address(RustBuffer address, RustCallStatus *_Nonnull out_status @@ -3348,6 +3358,12 @@ uint16_t uniffi_bitkitcore_checksum_func_usdt_parse_amount(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_USDT_PARSE_PAYMENT_REQUEST uint16_t uniffi_bitkitcore_checksum_func_usdt_parse_payment_request(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_USDT_VALIDATE_RECIPIENT +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_USDT_VALIDATE_RECIPIENT +uint16_t uniffi_bitkitcore_checksum_func_usdt_validate_recipient(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_VALIDATE_BITCOIN_ADDRESS @@ -3570,6 +3586,12 @@ uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_create_payment_proof(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_HISTORY uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_history(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_ORCHESTRA_DESTINATIONS +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_ORCHESTRA_DESTINATIONS +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_orchestra_destinations(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_QUOTE_TRANSFER diff --git a/src/lib.rs b/src/lib.rs index 827751f..e9bc085 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -91,9 +91,10 @@ pub use modules::onchain; pub use modules::scanner::{DecodingError, LnurlPayData, Scanner}; pub use modules::seedqr::{decode_compact_seed_qr, decode_standard_seed_qr, SeedQrError}; 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, UsdtPaymentProof, UsdtPaymentProofBinding, + usdt_address, usdt_format_amount, usdt_parse_amount, usdt_parse_payment_request, + usdt_validate_recipient, UsdtBridgeProvider, UsdtDeposit, UsdtDepositAddress, + UsdtDepositClient, UsdtDepositDetail, UsdtDepositNetwork, UsdtDepositOrder, UsdtDepositPage, + UsdtDestination, UsdtError, UsdtOrchestraTransfer, UsdtPaymentProof, UsdtPaymentProofBinding, UsdtPaymentRequest, UsdtQuote, UsdtTransfer, UsdtTransferStatus, UsdtVerifiedPayment, UsdtWallet, }; diff --git a/src/modules/usdt/README.md b/src/modules/usdt/README.md index 65a702e..df4bc8b 100644 --- a/src/modules/usdt/README.md +++ b/src/modules/usdt/README.md @@ -44,16 +44,20 @@ Storage is wallet-specific and must have one owning `UsdtWallet` object. Drop it Both chain and bundler endpoints must be controlled, credential-free HTTPS URLs; HTTP is accepted only on loopback for fixtures. Provider keys belong on the server. Chain/bundler calls share an 80/minute budget with a burst of 20. Responses are bounded to 2 MiB, except protocol-projected receipts up to 16 MiB. The companion service documents provider requirements, receipt projection and deployment limits. -The outbound bridge API supports Ethereum (30101), Polygon (30109), Plasma (30383) and Stable (30396), alongside direct Arbitrum transfers. Native release flows expose Arbitrum only; bridge routes require explicit service enablement and destination acceptance. Plain deposits on another chain are not automatically forwarded. Recipient validation rejects the destination token and the pinned EntryPoint, paymaster and Simple7702 delegate addresses on every destination. Direct Arbitrum sends also reject the source OFT and helper. +The USDT0 outbound bridge supports Ethereum (30101), Polygon (30109), Plasma (30383) and Stable (30396), alongside direct Arbitrum transfers. An optional credential-free `bridge_url` enables Orchestra quotes and delivery tracking through the companion service. `orchestra_destinations()` returns enabled, available routes: Ethereum, Polygon, Plasma, Base, BNB Smart Chain, Solana and Tron. Callers combine these with their enabled USDT0 destinations and use `usdt_validate_recipient` for the selected network. Plain deposits on another chain are not automatically forwarded. Recipient validation rejects the destination token and the pinned EntryPoint, paymaster and Simple7702 delegate addresses on every destination. Direct Arbitrum sends also reject the source OFT and helper. -Bridge quotes include 10% native messaging-fee headroom and 20% token-conversion headroom, both within the displayed maximum USDT fee. Before signing or rebroadcasting, the stored native fee, helper liquidity and token approval are checked against current requirements without raising approved limits. The service reports OFT/helper execution reverts as sanitized RPC code `3`, which core maps to `UnsupportedRoute` during initial quoting. A reverted fee recheck for an already reviewed bridge quote requires a fresh quote (`QuoteExpired`); insufficient helper liquidity remains `UnsupportedRoute`. Provider outages remain retryable network errors. Delivery checks process up to three transfers concurrently outside the send lock, with a ten-second request budget, even when source recovery fails; failed lookups retain the last known status, while an explicit `INFLIGHT` or `CONFIRMING` update clears a previous needs-attention state. +USDT0 bridge quotes include 10% native messaging-fee headroom and 20% token-conversion headroom, both within the displayed maximum USDT fee. Before signing or rebroadcasting, the stored native fee, helper liquidity and token approval are checked against current requirements without raising approved limits. The service reports OFT/helper execution reverts as sanitized RPC code `3`, which core maps to `UnsupportedRoute` during initial quoting. A reverted fee recheck for an already reviewed bridge quote requires a fresh quote (`QuoteExpired`); insufficient helper liquidity remains `UnsupportedRoute`. Provider outages remain retryable network errors. Delivery checks process up to three transfers concurrently outside the send lock, with a ten-second request budget, even when source recovery fails; failed lookups retain the last known status, while an explicit `INFLIGHT` or `CONFIRMING` update clears a previous needs-attention state. -Bridges use the pinned OFT and TransactionValueHelper with zero account ETH, a finite USDT approval covering principal/fee, and atomic helper-allowance revocation. The deployed helper requires native liquidity and retains behaviors noted in its OpenZeppelin audit; its verified runtime is not the audit-remediated implementation. Source success means bridging, not delivered. +USDT0 bridges use the pinned OFT and TransactionValueHelper with zero account ETH, a finite USDT approval covering principal/fee, and atomic helper-allowance revocation. The deployed helper requires native liquidity and retains behaviors noted in its OpenZeppelin audit; its verified runtime is not the audit-remediated implementation. Source success means bridging, not delivered. -`Pending` means source execution is unresolved; `Failed` means the source payment failed or was proved unexecuted; `Replaced` means another operation consumed its nonce. `Bridging` means source execution succeeded and destination delivery is unresolved. For bridges, `Confirmed` means delivery was reported. `BridgeNeedsAttention` covers retryable delivery problems or missing message evidence; without a GUID, no delivery lookup is possible. `BridgeFailed` means LayerZero reports a burned or skipped message: delivery polling stops, while source transaction, GUID, amount and fees remain visible. Neither bridge status implies a refund. Terminal delivery states survive restart and source-history rescans for the same transaction and GUID. +`Pending` means source execution is unresolved; `Failed` means the source payment failed or was proved unexecuted; `Replaced` means another operation consumed its nonce. `Bridging` means source execution succeeded and destination delivery is unresolved. For bridges, `Confirmed` means delivery was reported. `BridgeNeedsAttention` covers retryable delivery problems or missing message evidence; without a GUID or an Orchestra tracking plan, no delivery lookup is possible. `BridgeFailed` means LayerZero reports a burned or skipped message: delivery polling stops, while source transaction, GUID, amount and fees remain visible. Neither bridge status implies a refund. Terminal delivery states survive restart and source-history rescans for the same transaction and GUID. LayerZero status must match the operation GUID/pathway before confirmation; blocked delivery remains visible and never triggers an automatic paid retry. +Orchestra delivery and refund attribution trust the configured gateway and provider. Core bounds reported delivery by the sent principal. A reported refund also requires a successful canonical Arbitrum receipt with a matching USDT transfer and at least that amount in net credit to the wallet. Self-transfers and refund transactions already assigned to another payment cannot settle a refund. One refund transaction can settle at most one payment, even if a provider batches several refunds. + +These checks prove receipt of funds, not which Orchestra order caused it. The current gateway contract does not pin a refund sender; an unrelated incoming payment cannot be distinguished from a refund without trusting the order association. `BridgeRefunded` retains that association across restart and receipt pruning. Funding and refund reorgs reopen the affected payment within the history revisit window. + Bridge history preserves the saved receiving amount or recovers the signed `minAmountLD` from calldata until a matching `OFTSent` event supplies the source-confirmed amount. The fallback is a minimum receiving amount, not proof of destination delivery. RPC providers see queried addresses. Delivery checks use `bitkit_getBridgeMessages([sourceTransactionHash])` on the existing chain-service endpoint. The service queries LayerZero Scan without forwarding device headers, projects only message identity/pathway/status fields, and applies its shared request and response limits. LayerZero sees the service IP and the transaction hash; the service still sees the requesting device. Manually opening LayerZero Scan from transaction details connects the browser directly. No delivery requests are made for Arbitrum-only transfers. @@ -102,6 +106,18 @@ Refunds require a user-approved address on the source network and provider eligi 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). +### Orchestra outbound quotes and delivery + +Core compares usable quotes concurrently and selects the highest estimated destination receipt per maximum total source debit, including the USDT paymaster fee. Ties prefer USDT0. An unavailable, expired or unaffordable provider does not disqualify the other. Direct Arbitrum and USDT0-only Stable transfers keep their existing execution paths. Provider selection is frozen in the quote; send and recovery never choose another route. + +The entered amount is the source principal. Orchestra quotes are exact-input: routing costs are deducted from that principal, and the source transaction fee is additional. `received_amount` is an estimate, not a guaranteed exact output. Show the provider, estimated receipt, included routing cost, maximum source fee and maximum total debit before approval. All public amounts remain six-decimal USDT, including BSC's 18-decimal token; destination amounts are rounded down for display. Orchestra can use intermediate assets internally, but Core only signs a USDT transfer on Arbitrum. No destination gas token is requested from the sender. + +The service returns a single-use quote funding address and a signed tracking ticket. Core persists both with the signed operation before broadcast. Successful funding becomes `Bridging`; `Confirmed` requires provider-reported delivery bound to that quote, source transaction and destination. This trusts Orchestra's delivery report, not an independently verified destination-chain proof. The original source transaction, gas fee, recipient and provider remain in activity. Delivery metadata includes the destination transaction or a refund transaction and amount. `BridgeRefunded` requires a successful canonical Arbitrum USDT receipt paying the refund to this account; it is not destination delivery. + +Quotes can expire before an already-submitted operation executes. Never start another payment based on a timeout or expired quote: the original operation may still execute, and Orchestra may need to recover late funding. Tracking tickets remain usable after expiry and route disablement. Pending delivery retains its signed funding plan beyond the source-history revisit window. Seed-only history cannot reconstruct the provider ticket or external destination; app recovery must preserve these application records alongside the seed. The service signing secret must remain available for ticket verification. + +Quoting shares the source account, destination address, network and amount with Orchestra even when USDT0 is ultimately selected. Only the selected quote is funded. No private key or mnemonic leaves Core. + ## 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`. diff --git a/src/modules/usdt/deposits.rs b/src/modules/usdt/deposits.rs index 815d5c2..9869fd3 100644 --- a/src/modules/usdt/deposits.rs +++ b/src/modules/usdt/deposits.rs @@ -341,7 +341,10 @@ impl UsdtDepositNetwork { } } -fn validate_source_address(value: &str, network: UsdtDepositNetwork) -> Result<(), UsdtError> { +pub(super) fn validate_source_address( + value: &str, + network: UsdtDepositNetwork, +) -> Result<(), UsdtError> { if network == UsdtDepositNetwork::Tron { if value.len() != 34 || !value.starts_with('T') || !value.is_ascii() || value == TRON_USDT { return Err(UsdtError::InvalidAddress); @@ -380,12 +383,14 @@ fn deposit_limit<'de, D: Deserializer<'de>>(deserializer: D) -> Result>(deserializer: D) -> Result { +pub(super) fn number<'de, D: Deserializer<'de>>(deserializer: D) -> Result { String::deserialize(deserializer)? .parse() .map_err(serde::de::Error::custom) } -fn optional_number<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { +pub(super) fn optional_number<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { Option::::deserialize(deserializer)? .map(|v| v.parse().map_err(serde::de::Error::custom)) .transpose() diff --git a/src/modules/usdt/deposits/tests.rs b/src/modules/usdt/deposits/tests.rs index 22e4164..550830d 100644 --- a/src/modules/usdt/deposits/tests.rs +++ b/src/modules/usdt/deposits/tests.rs @@ -47,6 +47,7 @@ fn deposit_addresses_and_transport_reject_wrong_networks() { assert!(validate_source_address( &super::super::UsdtDestination::Ethereum .token() + .unwrap() .to_checksum(None), UsdtDepositNetwork::Ethereum ) diff --git a/src/modules/usdt/history.rs b/src/modules/usdt/history.rs index 619ef10..78e76cb 100644 --- a/src/modules/usdt/history.rs +++ b/src/modules/usdt/history.rs @@ -210,7 +210,8 @@ impl UsdtWallet { && transfers.iter().all(|transfer| { transfer.destination == UsdtDestination::Arbitrum || transfer.status == UsdtTransferStatus::Failed - || (transfer.bridge_guid.is_some() && transfer.fee.is_some()) + || ((transfer.bridge_guid.is_some() || transfer.orchestra.is_some()) + && transfer.fee.is_some()) }); self.store.save_history_receipt( &transfers, @@ -298,6 +299,7 @@ impl UsdtWallet { tx_hash: Some(hash.into()), user_operation_hash: None, bridge_guid: None, + orchestra: None, recipient: event.to.to_checksum(None), destination: UsdtDestination::Arbitrum, amount: token_amount(event.value)?, @@ -343,42 +345,46 @@ impl UsdtWallet { continue; } let operation_hash = format!("{:#x}", event.userOpHash); - let (recipient, amount, destination, received_amount) = if let Some(saved) = saved { - ( - saved.recipient, - saved.amount, - saved.destination, - saved.received_amount, - ) - } else { - let Some(op) = batch.as_ref().and_then(|batch| { - batch - .ops - .iter() - .find(|op| op.sender == self.address && op.nonce == event.nonce) - }) else { - continue; - }; - if !super::paymaster::supported_payment(&op.paymasterAndData) { - continue; - } - let Some((recipient, amount, destination, received_amount)) = - decode_payment(&op.callData, self.address) - else { - continue; + let (recipient, amount, destination, received_amount, orchestra) = + if let Some(saved) = saved { + ( + saved.recipient, + saved.amount, + saved.destination, + saved.received_amount, + saved.orchestra, + ) + } else { + let Some(op) = batch.as_ref().and_then(|batch| { + batch + .ops + .iter() + .find(|op| op.sender == self.address && op.nonce == event.nonce) + }) else { + continue; + }; + if !super::paymaster::supported_payment(&op.paymasterAndData) { + continue; + } + let Some((recipient, amount, destination, received_amount)) = + decode_payment(&op.callData, self.address) + else { + continue; + }; + ( + recipient.to_checksum(None), + amount, + destination, + received_amount, + None, + ) }; - ( - recipient.to_checksum(None), - amount, - destination, - received_amount, - ) - }; let mut transfer = UsdtTransfer { id: operation_hash.clone(), tx_hash: Some(hash.into()), user_operation_hash: Some(operation_hash), bridge_guid: None, + orchestra, recipient, destination, amount, diff --git a/src/modules/usdt/mod.rs b/src/modules/usdt/mod.rs index 2e996a4..2a558fc 100644 --- a/src/modules/usdt/mod.rs +++ b/src/modules/usdt/mod.rs @@ -4,6 +4,7 @@ mod deposits; mod errors; mod history; mod keys; +mod orchestra; mod paymaster; mod payment_request; mod proof; @@ -18,6 +19,7 @@ pub use amount::{usdt_format_amount, usdt_parse_amount}; pub use deposits::*; pub use errors::UsdtError; pub use keys::usdt_address; +pub use orchestra::usdt_validate_recipient; pub use payment_request::usdt_parse_payment_request; pub use proof::{UsdtPaymentProof, UsdtPaymentProofBinding, UsdtVerifiedPayment}; pub use types::*; diff --git a/src/modules/usdt/orchestra.rs b/src/modules/usdt/orchestra.rs new file mode 100644 index 0000000..af7d1b2 --- /dev/null +++ b/src/modules/usdt/orchestra.rs @@ -0,0 +1,220 @@ +use super::{ + deposits::{number, optional_number}, + keys::parse_address, + rpc::{bounded_json, endpoint_client}, + UsdtDestination, UsdtError, UsdtOrchestraTransfer, +}; +use alloy_primitives::Address; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::json; +use std::time::Duration; + +pub(super) struct Orchestra { + client: reqwest::Client, + url: String, +} + +// Funding and destination instructions travel together through signing, recovery and backup. +#[derive(Clone, Serialize, Deserialize)] +pub(super) struct OrchestraPlan { + pub quote_id: String, + pub ticket: String, + pub funding_address: String, + pub destination: UsdtDestination, + pub recipient: String, + pub amount: u64, + pub received_amount: u64, + pub expires_at: u64, +} + +impl std::fmt::Debug for OrchestraPlan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OrchestraPlan") + .field("quote_id", &self.quote_id) + .field("destination", &self.destination) + .finish_non_exhaustive() + } +} + +impl OrchestraPlan { + pub fn transfer(&self) -> UsdtOrchestraTransfer { + UsdtOrchestraTransfer { + quote_id: self.quote_id.clone(), + funding_address: self.funding_address.clone(), + destination_tx: None, + refund_tx: None, + refund_amount: None, + } + } +} + +#[derive(Deserialize)] +pub(super) struct Delivery { + pub status: String, + #[serde(deserialize_with = "optional_number")] + pub received_amount: Option, + pub destination_tx: Option, + pub refund_tx: Option, + #[serde(deserialize_with = "optional_number")] + pub refund_amount: Option, +} + +impl Orchestra { + pub fn new(url: String) -> Result { + Ok(Self { + client: endpoint_client(&url, Duration::from_secs(22))?, + url, + }) + } + + pub async fn networks(&self) -> Result, UsdtError> { + #[derive(Deserialize)] + struct Networks { + networks: Vec, + } + let networks: Networks = self.response(self.client.get(&self.url)).await?; + Ok(networks + .networks + .into_iter() + .filter_map(|network| UsdtDestination::from_network(&network)) + .collect()) + } + + pub async fn quote( + &self, + owner: Address, + recipient: String, + amount: u64, + destination: UsdtDestination, + ) -> Result { + #[derive(Deserialize)] + struct Quote { + quote_id: String, + funding_address: String, + #[serde(deserialize_with = "number")] + received_amount: u64, + expires_at: u64, + ticket: String, + } + let quote: Quote = self + .response(self.client.post(&self.url).json(&json!({ + "action":"quote", "request_id":uuid::Uuid::new_v4().to_string(), + "owner":owner.to_checksum(None), "recipient":recipient, "amount":amount.to_string(), + "network":destination.network(), + }))) + .await?; + let funding = + parse_address("e.funding_address).map_err(|_| UsdtError::InvalidResponse)?; + if quote.quote_id.is_empty() + || quote.quote_id.len() > 128 + || quote.ticket.is_empty() + || quote.ticket.len() > 4096 + || quote.received_amount == 0 + || quote.received_amount > amount + || quote.expires_at <= super::wallet::now() + 10 + || [ + owner, + super::types::TOKEN, + super::types::OFT, + super::types::BRIDGE_HELPER, + super::account::ENTRY_POINT, + super::account::DELEGATE, + super::paymaster::PAYMASTER, + ] + .contains(&funding) + { + return Err(UsdtError::InvalidResponse); + } + Ok(OrchestraPlan { + quote_id: quote.quote_id, + ticket: quote.ticket, + funding_address: funding.to_checksum(None), + destination, + recipient, + amount, + received_amount: quote.received_amount, + expires_at: quote.expires_at, + }) + } + + pub async fn status( + &self, + plan: &OrchestraPlan, + source_tx: &str, + ) -> Result { + self.response( + self.client + .post(&self.url) + .json(&json!({"action":"status", "ticket":plan.ticket, "source_tx":source_tx})), + ) + .await + } + + async fn response( + &self, + request: reqwest::RequestBuilder, + ) -> Result { + let response = request + .send() + .await + .map_err(|_| UsdtError::NetworkUnavailable)?; + let status = response.status(); + if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + return Err(UsdtError::RateLimited); + } + let value = bounded_json(response, 16384, UsdtError::InvalidResponse).await?; + if !status.is_success() { + return Err(match value["error"].as_str() { + Some("amount_too_small" | "amount_too_large" | "amount_exceeds_liquidity") => { + UsdtError::InvalidAmount + } + Some("invalid_address") => UsdtError::InvalidAddress, + Some("route_unavailable" | "not_configured") => UsdtError::UnsupportedRoute, + _ => UsdtError::NetworkUnavailable, + }); + } + serde_json::from_value(value).map_err(Into::into) + } +} + +pub(super) fn validate_recipient( + value: &str, + destination: UsdtDestination, +) -> Result { + match destination { + UsdtDestination::Tron | UsdtDestination::Solana => { + let network = if destination == UsdtDestination::Tron { + super::UsdtDepositNetwork::Tron + } else { + super::UsdtDepositNetwork::Solana + }; + super::deposits::validate_source_address(value, network)?; + Ok(value.to_owned()) + } + _ => { + let address = parse_address(value)?; + if destination.token() == Some(address) + || (destination == UsdtDestination::Arbitrum + && [super::types::OFT, super::types::BRIDGE_HELPER].contains(&address)) + || [ + super::account::ENTRY_POINT, + super::account::DELEGATE, + super::paymaster::PAYMASTER, + ] + .contains(&address) + { + return Err(UsdtError::InvalidAddress); + } + Ok(address.to_checksum(None)) + } + } +} + +/// Validates a recipient for the chosen network; payment URIs remain Arbitrum-only. +#[uniffi::export] +pub fn usdt_validate_recipient( + value: String, + destination: UsdtDestination, +) -> Result { + validate_recipient(value.trim(), destination) +} diff --git a/src/modules/usdt/store.rs b/src/modules/usdt/store.rs index 4ee14f4..2a1fd06 100644 --- a/src/modules/usdt/store.rs +++ b/src/modules/usdt/store.rs @@ -2,6 +2,7 @@ use super::{ history::HISTORY_REVISIT_BLOCKS, transaction::Plan, UsdtError, UsdtQuote, UsdtTransfer, UsdtTransferStatus, }; +use alloy_primitives::B256; use rusqlite::{params, Connection, OptionalExtension, Transaction}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use std::{ @@ -135,6 +136,39 @@ impl Store { Ok(()) } + /// Returns false when the refund transaction already belongs to another payment. + pub fn update_delivery( + &self, + transfer: &UsdtTransfer, + refund_block: Option<(u64, B256)>, + ) -> Result { + let mut connection = self.connection()?; + let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + if let Some(block) = refund_block { + let refund = transfer + .orchestra + .as_ref() + .and_then(|bridge| bridge.refund_tx.as_deref()) + .ok_or(UsdtError::InvalidResponse)?; + // Keep the claim in transfer history after its reorg evidence is pruned. + let used: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM usdt_transfers WHERE id!=?1 AND lower(json_extract(data, '$.orchestra.refund_tx'))=lower(?2))", + params![transfer.id, refund], + |row| row.get(0), + )?; + if used { + return Ok(false); + } + tx.execute( + "UPDATE usdt_transfers SET raw=json_set(raw, '$.refund_block', json(?1)) WHERE id=?2", + params![serde_json::to_string(&block)?, transfer.id], + )?; + } + write_transfer(&tx, transfer)?; + tx.commit()?; + Ok(true) + } + pub fn settle_transfer( &self, transfer: &UsdtTransfer, @@ -152,7 +186,7 @@ impl Store { pub fn settlement_blocks(&self, start: u64, end: u64) -> Result, UsdtError> { let connection = self.connection()?; let mut statement = connection.prepare( - "SELECT DISTINCT block_number FROM usdt_transfers WHERE block_number BETWEEN ?1 AND ?2 ORDER BY block_number", + "SELECT block_number FROM usdt_transfers WHERE block_number BETWEEN ?1 AND ?2 UNION SELECT json_extract(raw, '$.refund_block[0]') FROM usdt_transfers WHERE json_extract(raw, '$.refund_block[0]') BETWEEN ?1 AND ?2 ORDER BY 1", )?; let rows = statement.query_map(params![start, end], |row| row.get(0))?; Ok(rows.collect::>()?) @@ -326,20 +360,30 @@ impl Store { .as_deref() .zip(transfer.tx_hash.as_deref()) .is_some_and(|(a, b)| a.eq_ignore_ascii_case(b)) - && saved + && ((saved .bridge_guid .as_ref() .zip(transfer.bridge_guid.as_ref()) - .is_some_and(|(a, b)| a.eq_ignore_ascii_case(b)) + .is_some_and(|(a, b)| a.eq_ignore_ascii_case(b))) + || saved + .orchestra + .as_ref() + .zip(transfer.orchestra.as_ref()) + .is_some_and(|(a, b)| { + a.quote_id == b.quote_id && a.funding_address == b.funding_address + })) && transfer.status == UsdtTransferStatus::Bridging && matches!( saved.status, UsdtTransferStatus::Confirmed | UsdtTransferStatus::BridgeNeedsAttention | UsdtTransferStatus::BridgeFailed + | UsdtTransferStatus::BridgeRefunded ) { transfer.status = saved.status; + transfer.received_amount = saved.received_amount; + transfer.orchestra = saved.orchestra; } write_settlement(tx, &transfer, number, block_hash)?; } else { @@ -355,7 +399,7 @@ impl Store { pub fn awaiting_delivery(&self) -> Result, UsdtError> { let connection = self.connection()?; let mut statement = connection.prepare( - "SELECT data FROM usdt_transfers WHERE json_extract(data, '$.status') IN ('Bridging','BridgeNeedsAttention') AND json_extract(data, '$.bridge_guid') IS NOT NULL ORDER BY json_extract(data, '$.timestamp') DESC, id", + "SELECT data FROM usdt_transfers WHERE json_extract(data, '$.status') IN ('Bridging','BridgeNeedsAttention') AND (json_extract(data, '$.bridge_guid') IS NOT NULL OR json_extract(data, '$.orchestra') IS NOT NULL) ORDER BY json_extract(data, '$.timestamp') DESC, id", )?; let rows = statement.query_map([], |row| row.get::<_, String>(0))?; rows.map(|row| decode(&row?)).collect() @@ -379,6 +423,15 @@ impl Store { Ok(pending) } + pub fn orchestra_plan(&self, id: &str) -> Result { + let raw: Option = self.connection()?.query_row( + "SELECT json_extract(raw, '$.orchestra') FROM usdt_transfers WHERE id=?1", + [id], + |row| row.get(0), + )?; + decode(&raw.ok_or(UsdtError::InvalidResponse)?) + } + pub fn pending_plan(&self, id: &str) -> Result, UsdtError> { let raw: Option = self .connection()? @@ -393,10 +446,15 @@ impl Store { fn write_transfer(connection: &Connection, transfer: &UsdtTransfer) -> Result<(), UsdtError> { let settled = transfer.status != UsdtTransferStatus::Pending; + let keep_delivery = transfer.orchestra.is_some() + && matches!( + transfer.status, + UsdtTransferStatus::Bridging | UsdtTransferStatus::BridgeNeedsAttention + ); // Chain-backed outcomes retain the signed operation until their reorg window passes. connection.execute( - "UPDATE usdt_transfers SET data=?1, raw=CASE WHEN ?2 AND block_number IS NULL THEN NULL ELSE raw END WHERE id=?3", - params![serde_json::to_string(transfer)?, settled, transfer.id], + "UPDATE usdt_transfers SET data=?1, raw=CASE WHEN ?2 AND block_number IS NULL AND json_extract(raw, '$.refund_block') IS NULL THEN NULL ELSE raw END WHERE id=?3", + params![serde_json::to_string(transfer)?, settled && !keep_delivery, transfer.id], )?; if settled { connection.execute( @@ -434,6 +492,13 @@ fn reconcile_block(tx: &Transaction<'_>, number: u64, hash: &str) -> Result<(), transfer.status = UsdtTransferStatus::Pending; transfer.tx_hash = None; transfer.bridge_guid = None; + let mut plan: Plan = decode(&raw)?; + plan.refund_block = None; + tx.execute( + "UPDATE usdt_transfers SET raw=?1 WHERE id=?2", + params![serde_json::to_string(&plan)?, transfer.id], + )?; + transfer.orchestra = plan.orchestra.map(|plan| plan.transfer()); transfer.received_amount = if transfer.destination == super::UsdtDestination::Arbitrum { transfer.amount } else { @@ -449,6 +514,11 @@ fn reconcile_block(tx: &Transaction<'_>, number: u64, hash: &str) -> Result<(), tx.execute("DELETE FROM usdt_transfers WHERE id=?1", [&transfer.id])?; } } + // A refund is a separate transaction: its reorg must not reopen the original funding send. + tx.execute( + "UPDATE usdt_transfers SET data=json_set(data, '$.status', 'Bridging', '$.orchestra.refund_tx', NULL, '$.orchestra.refund_amount', NULL, '$.received_amount', json_extract(raw, '$.orchestra.received_amount')),raw=json_remove(raw, '$.refund_block') WHERE json_extract(raw, '$.refund_block[0]')=?1 AND json_extract(raw, '$.refund_block[1]')!=?2", + params![number, hash], + )?; tx.execute( "DELETE FROM usdt_history_receipts WHERE block_number=?1 AND block_hash!=?2", params![number, hash], @@ -462,7 +532,11 @@ fn reconcile_block(tx: &Transaction<'_>, number: u64, hash: &str) -> Result<(), fn prune_settlement_proofs(connection: &Connection, before: u64) -> Result<(), UsdtError> { connection.execute( - "UPDATE usdt_transfers SET raw=NULL,block_number=NULL,block_hash=NULL WHERE block_number < ?1", + "UPDATE usdt_transfers SET block_number=NULL,block_hash=NULL WHERE block_number < ?1", + [before], + )?; + connection.execute( + "UPDATE usdt_transfers SET raw=NULL WHERE block_number IS NULL AND json_extract(data, '$.status')!='Pending' AND NOT (json_extract(data, '$.orchestra') IS NOT NULL AND json_extract(data, '$.status') IN ('Bridging','BridgeNeedsAttention')) AND COALESCE(json_extract(raw, '$.refund_block[0]'),0) < ?1", [before], )?; Ok(()) diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index 7bead8c..d499a46 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -103,7 +103,8 @@ fn wallet_requires_both_provider_endpoints() { RECIPIENT.into(), path.to_string_lossy().into(), rpc.into(), - bundler.into() + bundler.into(), + None, ), Err(UsdtError::NotConfigured) )); @@ -133,6 +134,7 @@ fn payment_request_round_trips_receive_uri_and_rejects_wrong_asset_or_network() dir.path().join("usdt.sqlite").to_string_lossy().into(), "https://provider.example".into(), "https://bundler.example".into(), + None, ) .unwrap(); let request = usdt_parse_payment_request(wallet.receive_uri()).unwrap(); @@ -235,6 +237,7 @@ async fn payment_proof_binds_execution_to_request_and_receiver() { .into(), format!("{}/chain", chain.url), format!("{}/bundler", chain.url), + None, ) .unwrap(); let binding = UsdtPaymentProofBinding { @@ -433,6 +436,8 @@ struct ChainState { bridge_messages: std::collections::HashMap, bridge_requests: Vec, bridge_delay: std::time::Duration, + orchestra_received: Option, + orchestra_delivery: serde_json::Value, paymaster: alloy_primitives::Address, helper_balance: alloy_primitives::U256, native_message_fee: u64, @@ -496,6 +501,8 @@ impl MockChain { bridge_messages: Default::default(), bridge_requests: vec![], bridge_delay: std::time::Duration::ZERO, + orchestra_received: Some(950_000), + orchestra_delivery: serde_json::json!({"status":"bridging", "received_amount":null,"destination_tx":null,"refund_tx":null,"refund_amount":null}), paymaster: paymaster::PAYMASTER, helper_balance: U256::from(1_000_000_000_000_000u64), native_message_fee: 10_000_000_000, @@ -546,14 +553,18 @@ impl MockChain { if header_end == 0 { continue; } - let length: usize = String::from_utf8_lossy(&request[..header_end]) + let headers = String::from_utf8_lossy(&request[..header_end]); + let mut request_line = headers.lines().next().unwrap().split_whitespace(); + let http_method = request_line.next().unwrap().to_owned(); + let path = request_line.next().unwrap().to_owned(); + let length: usize = headers .lines() .find_map(|line| { line.to_lowercase() .strip_prefix("content-length:") .map(|v| v.trim().parse().unwrap()) }) - .unwrap(); + .unwrap_or(0); while request.len() < header_end + length { let mut chunk = [0u8; 4096]; let n = socket.read(&mut chunk).await.unwrap(); @@ -562,17 +573,15 @@ impl MockChain { } request.extend_from_slice(&chunk[..n]); } - let body: serde_json::Value = - serde_json::from_slice(&request[header_end..]).unwrap(); - let path = String::from_utf8_lossy(&request[..header_end]) - .lines() - .next() - .unwrap() - .split_whitespace() - .nth(1) - .unwrap() - .to_owned(); - let method = body["method"].as_str().unwrap(); + let body: serde_json::Value = if http_method == "GET" { + assert_eq!(path, "/bridges"); + assert_eq!(length, 0); + serde_json::Value::Null + } else { + assert_eq!(http_method, "POST"); + serde_json::from_slice(&request[header_end..]).unwrap() + }; + let method = body["method"].as_str().unwrap_or(""); let bundler = matches!( method, "pimlico_getTokenQuotes" @@ -605,7 +614,11 @@ impl MockChain { } else { std::time::Duration::ZERO }; - let mut response = server_state.lock().unwrap().respond(&body); + let mut response = if http_method == "GET" { + serde_json::json!({"networks":["ethereum", "polygon", "plasma", "base", "bsc", "solana", "tron", "unknown-network"]}) + } else { + server_state.lock().unwrap().respond(&body) + }; if body["method"] == "eth_getLogs" { if let Some(logs) = response["result"].as_array_mut() { for log in logs { @@ -634,11 +647,19 @@ impl MockChain { Self { url, state, task } } fn wallet(&self, dir: &tempfile::TempDir) -> std::sync::Arc { + self.wallet_with_bridges(dir, false) + } + fn wallet_with_bridges( + &self, + dir: &tempfile::TempDir, + enabled: bool, + ) -> std::sync::Arc { UsdtWallet::new( usdt_address(TEST_PHRASE.into(), None).unwrap(), dir.path().join("usdt.sqlite").to_string_lossy().into(), format!("{}/chain", self.url), format!("{}/bundler", self.url), + enabled.then(|| format!("{}/bridges", self.url)), ) .unwrap() } @@ -654,6 +675,16 @@ impl ChainState { use alloy_primitives::{Bytes, U256}; use alloy_sol_types::{SolCall, SolValue}; use serde_json::json; + if body["action"] == "quote" { + return if let Some(amount) = self.orchestra_received { + json!({"quote_id":"orchestra-quote", "funding_address":"0x2222222222222222222222222222222222222222", "received_amount":amount.to_string(),"expires_at":wallet::now()+120,"ticket":"quote-ticket"}) + } else { + json!({"error":"route_unavailable"}) + }; + } + if body["action"] == "status" { + return self.orchestra_delivery.clone(); + } if matches!( body["method"].as_str(), Some("eth_estimateUserOperationGas" | "eth_sendUserOperation") @@ -1544,6 +1575,7 @@ async fn deployed_contracts_collect_usdt_fees_and_revert_failed_bridges_atomical dir.path().join("usdt.sqlite").to_string_lossy().into(), std::env::var("USDT_FORK_RPC_URL").unwrap_or_else(|_| "http://127.0.0.1:18546".into()), std::env::var("USDT_FORK_BUNDLER_URL").unwrap_or_else(|_| "http://127.0.0.1:18546".into()), + None, ) .unwrap(); assert_eq!(rpc.balance(wallet.address).await.unwrap(), U256::ZERO); @@ -1829,6 +1861,7 @@ fn stored_activity_is_complete_and_sorted_newest_first() { tx_hash: Some(format!("tx-{index}")), user_operation_hash: None, bridge_guid: None, + orchestra: None, recipient: RECIPIENT.into(), destination: UsdtDestination::Arbitrum, amount: 1, @@ -2195,6 +2228,7 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending id: format!("bridge-{index}"), tx_hash: Some(format!("{:#x}", alloy_primitives::B256::repeat_byte(index))), user_operation_hash: None, + orchestra: None, bridge_guid: Some(format!( "{:#x}", alloy_primitives::B256::repeat_byte(index + 10) @@ -3270,7 +3304,7 @@ async fn infrastructure_and_token_addresses_are_not_payment_recipients() { UsdtDestination::Stable, ] { let mut recipients = vec![ - destination.token(), + destination.token().unwrap(), account::ENTRY_POINT, account::DELEGATE, paymaster::PAYMASTER, @@ -3849,6 +3883,8 @@ fn orphaned_payments_reopen_in_nonce_order_and_keep_sends_blocked() { // Retry order must be independent of the order rows were stored. for nonce in [1, 0] { let mut plan = transaction::Plan { + refund_block: None, + orchestra: None, operation: serde_json::from_value(vector["operation"].clone()).unwrap(), created_block: 19999, expires_at: wallet::now() + 600, @@ -3860,6 +3896,7 @@ fn orphaned_payments_reopen_in_nonce_order_and_keep_sends_blocked() { tx_hash: None, user_operation_hash: Some(format!("{hash:#x}")), bridge_guid: None, + orchestra: None, destination: UsdtDestination::Arbitrum, recipient: RECIPIENT.into(), amount: 1_000_000, @@ -4181,3 +4218,512 @@ async fn history_regains_query_width_before_finishing_restoration() { .collect(); assert!(widths.contains(&history::MAX_LOG_RANGE)); } + +#[tokio::test] +async fn outbound_quotes_select_the_best_usable_receipt_for_total_debit() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet_with_bridges(&dir, true); + for (received, destination, provider) in [ + ( + Some(950_000), + UsdtDestination::Ethereum, + UsdtBridgeProvider::Orchestra, + ), + ( + Some(500_000), + UsdtDestination::Ethereum, + UsdtBridgeProvider::Usdt0, + ), + (None, UsdtDestination::Ethereum, UsdtBridgeProvider::Usdt0), + ( + Some(950_000), + UsdtDestination::Base, + UsdtBridgeProvider::Orchestra, + ), + ] { + chain.state.lock().unwrap().orchestra_received = received; + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, destination) + .await + .unwrap(); + assert_eq!(quote.bridge_provider, Some(provider)); + assert_eq!(quote.amount, 1_000_000); + let plan = wallet.store.quote("e.id).unwrap().plan; + if provider == UsdtBridgeProvider::Orchestra { + let route = plan.orchestra.unwrap(); + assert_eq!(route.recipient, RECIPIENT); + assert_eq!(route.received_amount, quote.received_amount); + assert_eq!(route.amount, quote.amount); + assert_eq!(route.destination, destination); + } else { + assert!(plan.orchestra.is_none()); + } + } + chain.state.lock().unwrap().orchestra_received = None; + assert!(wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Solana) + .await + .is_err()); +} + +#[tokio::test] +async fn orchestra_destinations_follow_available_gateway_networks() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + assert!(wallet.orchestra_destinations().await.unwrap().is_empty()); + drop(wallet); + let wallet = chain.wallet_with_bridges(&dir, true); + assert_eq!( + wallet.orchestra_destinations().await.unwrap(), + vec![ + UsdtDestination::Ethereum, + UsdtDestination::Polygon, + UsdtDestination::Plasma, + UsdtDestination::Base, + UsdtDestination::Bsc, + UsdtDestination::Solana, + UsdtDestination::Tron, + ] + ); +} + +#[tokio::test] +async fn orchestra_funding_reorg_restores_the_signed_payment_after_restart() { + use alloy_primitives::B256; + for status in [ + UsdtTransferStatus::Confirmed, + UsdtTransferStatus::BridgeRefunded, + ] { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet_with_bridges(&dir, true); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Base) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let original_plan = + serde_json::to_value(wallet.store.pending_plan(&sent.id).unwrap().unwrap()).unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + } + wallet.refresh_transfers().await.unwrap(); + let mut transfer = wallet.store.transfer(&sent.id).unwrap().unwrap(); + assert_eq!(transfer.status, UsdtTransferStatus::Bridging); + assert!(transfer.tx_hash.is_some()); + assert!(transfer.fee.is_some()); + + // Persist each terminal delivery outcome before the source funding is orphaned. + transfer.status = status; + let bridge = transfer.orchestra.as_mut().unwrap(); + let refund_block = if status == UsdtTransferStatus::BridgeRefunded { + bridge.refund_tx = Some(B256::repeat_byte(9).to_string()); + bridge.refund_amount = Some(900_000); + transfer.received_amount = 0; + Some((20001, chain.state.lock().unwrap().block_hash(20001))) + } else { + bridge.destination_tx = Some("destination-transaction".into()); + transfer.received_amount = 949_000; + None + }; + wallet + .store + .update_delivery(&transfer, refund_block) + .unwrap(); + drop(wallet); + { + let mut state = chain.state.lock().unwrap(); + state.mined = false; + state.block_hashes.insert(20000, B256::repeat_byte(0xab)); + } + let wallet = chain.wallet_with_bridges(&dir, true); + sync_history_to_tip(&wallet).await; + drop(wallet); + let wallet = chain.wallet_with_bridges(&dir, true); + let pending = wallet.store.pending_operations().unwrap(); + assert_eq!(pending.len(), 1, "orphaned {status:?} funding must reopen"); + let (reopened, plan) = &pending[0]; + assert_eq!(reopened.status, UsdtTransferStatus::Pending); + assert_eq!(reopened.id, sent.id); + assert_eq!(reopened.user_operation_hash, sent.user_operation_hash); + assert_eq!(reopened.recipient, sent.recipient); + assert_eq!(reopened.destination, sent.destination); + assert_eq!(reopened.received_amount, sent.received_amount); + assert!(reopened.tx_hash.is_none()); + assert!(reopened.fee.is_none()); + assert_eq!(reopened.orchestra, sent.orchestra); + assert_eq!(serde_json::to_value(plan).unwrap(), original_plan); + assert!(matches!( + wallet.store.require_no_pending(), + Err(UsdtError::PendingTransfer) + )); + assert_eq!(chain.state.lock().unwrap().operations.len(), 1); + } +} + +#[tokio::test] +async fn orchestra_funding_keeps_delivery_identity_through_restart_and_history() { + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet_with_bridges(&dir, true); + let recipient = "6G41T4zUUYm47xgYBFoUioUGhigxS98Cj79y7C5nAf1L"; + let quote = wallet + .quote_transfer(recipient.into(), 1_000_000, UsdtDestination::Solana) + .await + .unwrap(); + let sent = wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await + .unwrap(); + assert_eq!(sent.status, UsdtTransferStatus::Pending); + assert_eq!( + sent.orchestra.as_ref().unwrap().funding_address, + "0x2222222222222222222222222222222222222222" + ); + assert_eq!( + wallet.store.orchestra_plan(&sent.id).unwrap().ticket, + "quote-ticket" + ); + drop(wallet); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + } + let wallet = chain.wallet_with_bridges(&dir, true); + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Bridging + ); + wallet.store.require_no_pending().unwrap(); + sync_history_to_tip(&wallet).await; + let payment = wallet.store.transfer(&sent.id).unwrap().unwrap(); + assert_eq!(payment.recipient, recipient); + assert_eq!(payment.destination, UsdtDestination::Solana); + assert_eq!(payment.status, UsdtTransferStatus::Bridging); + assert!(payment.fee.is_some()); + // Delivery tracking outlives the source receipt revisit window. + chain.state.lock().unwrap().tip += 10000; + sync_history_to_tip(&wallet).await; + assert_eq!( + wallet.store.orchestra_plan(&sent.id).unwrap().ticket, + "quote-ticket" + ); + drop(wallet); + for amount in [0, 1_000_001] { + chain.state.lock().unwrap().orchestra_delivery = json!({"status":"completed", "received_amount":amount.to_string(),"destination_tx":"destination-transaction","refund_tx":null,"refund_amount":null}); + let wallet = chain.wallet_with_bridges(&dir, true); + wallet.refresh_transfers().await.unwrap(); + let transfer = wallet.store.transfer(&sent.id).unwrap().unwrap(); + assert_eq!(transfer.status, UsdtTransferStatus::Bridging); + assert_eq!(transfer.received_amount, 950_000); + assert!(transfer.orchestra.unwrap().destination_tx.is_none()); + } + chain.state.lock().unwrap().orchestra_delivery = json!({"status":"completed", "received_amount":"949000","destination_tx":"destination-transaction","refund_tx":null,"refund_amount":null}); + let wallet = chain.wallet_with_bridges(&dir, true); + let delivered = wallet + .refresh_transfers() + .await + .unwrap() + .into_iter() + .find(|tx| tx.id == sent.id) + .unwrap(); + assert_eq!(delivered.status, UsdtTransferStatus::Confirmed); + assert_eq!(delivered.received_amount, 949_000); + assert_eq!( + delivered.orchestra.unwrap().destination_tx.as_deref(), + Some("destination-transaction") + ); + assert_eq!( + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap() + .status, + UsdtTransferStatus::Confirmed + ); + assert_eq!(chain.state.lock().unwrap().operations.len(), 1); +} + +#[test] +fn outbound_recipients_follow_the_selected_chain() { + for (value, network) in [ + (RECIPIENT, UsdtDestination::Base), + (RECIPIENT, UsdtDestination::Bsc), + ("TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", UsdtDestination::Tron), + ( + "6G41T4zUUYm47xgYBFoUioUGhigxS98Cj79y7C5nAf1L", + UsdtDestination::Solana, + ), + ] { + assert_eq!( + usdt_validate_recipient(value.into(), network).unwrap(), + value + ); + } + for address in [types::OFT, types::BRIDGE_HELPER] { + assert!(matches!( + usdt_validate_recipient(address.to_string(), UsdtDestination::Arbitrum), + Err(UsdtError::InvalidAddress) + )); + assert!(usdt_validate_recipient(address.to_string(), UsdtDestination::Base).is_ok()); + } + for (value, network) in [ + (RECIPIENT, UsdtDestination::Tron), + (RECIPIENT, UsdtDestination::Solana), + ("TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", UsdtDestination::Base), + ( + "0x55d398326f99059ff775485246999027b3197955", + UsdtDestination::Bsc, + ), + ("11111111111111111111111111111111", UsdtDestination::Solana), + ] { + assert!(usdt_validate_recipient(value.into(), network).is_err()); + } +} + +#[tokio::test] +async fn orchestra_refunds_require_a_matching_confirmed_usdt_receipt() { + use alloy_primitives::{Address, B256, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + let chain = MockChain::start().await; + let directory = tempfile::tempdir().unwrap(); + let wallet = chain.wallet_with_bridges(&directory, true); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Base) + .await + .unwrap(); + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + } + wallet.refresh_transfers().await.unwrap(); + let refund_hash = B256::repeat_byte(9); + let event = transaction::Erc20::Transfer { + from: Address::repeat_byte(2), + to: wallet.address, + value: U256::from(900_000), + } + .encode_log_data(); + let receipt = json!({"transactionHash":refund_hash,"blockHash":chain.state.lock().unwrap().block_hash(21000),"blockNumber":"0x5208","status":"0x1","logs":[{"address":types::TOKEN,"topics":event.topics(),"data":event.data}]}); + chain.state.lock().unwrap().tip = 21002; + chain.state.lock().unwrap().orchestra_delivery = json!({"status":"refunded", "received_amount":null,"destination_tx":null,"refund_tx":refund_hash,"refund_amount":"900000"}); + let owner = wallet.address; + drop(wallet); + // Provider reports cannot substitute for a successful canonical refund to this wallet. + for case in [ + "reverted", + "wrong_token", + "self_transfer", + "round_trip", + "valid", + ] { + let mut response = receipt.clone(); + match case { + "reverted" => response["status"] = json!("0x0"), + "wrong_token" => response["logs"][0]["address"] = json!(Address::repeat_byte(3)), + "self_transfer" => { + response["logs"][0]["topics"][1] = json!(owner.into_word()); + } + "round_trip" => { + let outgoing = transaction::Erc20::Transfer { + from: owner, + to: Address::repeat_byte(2), + value: U256::from(900_000), + } + .encode_log_data(); + response["logs"].as_array_mut().unwrap().push(json!({ + "address": types::TOKEN, "topics": outgoing.topics(), "data": outgoing.data + })); + } + _ => {} + } + let valid = case == "valid"; + chain.state.lock().unwrap().receipt_response = Some(response); + let wallet = chain.wallet_with_bridges(&directory, true); + let result = wallet + .refresh_transfers() + .await + .unwrap() + .into_iter() + .find(|transfer| transfer.id == quote.id) + .unwrap(); + assert_eq!( + result.status, + if valid { + UsdtTransferStatus::BridgeRefunded + } else { + UsdtTransferStatus::Bridging + } + ); + if valid { + assert_eq!(result.received_amount, 0); + assert_eq!(result.orchestra.unwrap().refund_amount, Some(900_000)); + } + } + let wallet = chain.wallet_with_bridges(&directory, true); + wallet.store.complete_history(24500).unwrap(); + assert_eq!( + wallet.store.settlement_blocks(20000, 25000).unwrap(), + vec![21000] + ); + let new_hash = B256::repeat_byte(0xab); + chain + .state + .lock() + .unwrap() + .block_hashes + .insert(21000, new_hash); + wallet + .store + .reconcile_block(21000, &new_hash.to_string()) + .unwrap(); + drop(wallet); + let wallet = chain.wallet_with_bridges(&directory, true); + let transfer = wallet.store.transfer("e.id).unwrap().unwrap(); + assert_eq!(transfer.status, UsdtTransferStatus::Bridging); + assert!(transfer.orchestra.unwrap().refund_tx.is_none()); + assert!(wallet.store.pending_operations().unwrap().is_empty()); + assert!(wallet.store.orchestra_plan("e.id).is_ok()); + wallet.refresh_transfers().await.unwrap(); + assert_eq!( + wallet.store.transfer("e.id).unwrap().unwrap().status, + UsdtTransferStatus::Bridging + ); + let mut canonical = receipt; + canonical["blockHash"] = json!(new_hash); + chain.state.lock().unwrap().receipt_response = Some(canonical); + drop(wallet); + let wallet = chain.wallet_with_bridges(&directory, true); + wallet.refresh_transfers().await.unwrap(); + assert_eq!( + wallet.store.transfer("e.id).unwrap().unwrap().status, + UsdtTransferStatus::BridgeRefunded + ); + wallet.store.complete_history(26000).unwrap(); + assert!(wallet + .store + .settlement_blocks(20000, 27000) + .unwrap() + .is_empty()); + assert!(wallet.store.orchestra_plan("e.id).is_err()); +} + +#[tokio::test] +async fn orchestra_refund_receipts_are_exclusive_across_restart_and_pruning() { + use alloy_primitives::{B256, U256}; + let chain = MockChain::start().await; + let directory = tempfile::tempdir().unwrap(); + let wallet = chain.wallet_with_bridges(&directory, true); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Base) + .await + .unwrap(); + let mut plan = wallet.store.quote("e.id).unwrap().plan; + let key = keys::derive_key(TEST_PHRASE.to_owned().into(), None).unwrap(); + let mut payments = Vec::new(); + for nonce in 0..2 { + plan.operation.nonce = U256::from(nonce); + let (hash, raw) = plan.sign(&key).unwrap(); + let transfer = UsdtTransfer { + id: format!("payment-{nonce}"), + tx_hash: Some(B256::repeat_byte(nonce + 1).to_string()), + user_operation_hash: Some(hash.to_string()), + bridge_guid: None, + orchestra: Some(plan.orchestra.as_ref().unwrap().transfer()), + recipient: RECIPIENT.into(), + destination: UsdtDestination::Base, + amount: 1_000_000, + received_amount: 950_000, + fee: Some(10_000), + is_incoming: false, + status: UsdtTransferStatus::Bridging, + timestamp: wallet::now(), + }; + wallet.store.record_signed(&transfer, &raw).unwrap(); + wallet + .store + .settle_transfer(&transfer, 20000, &B256::repeat_byte(1).to_string()) + .unwrap(); + let mut refunded = transfer; + refunded.status = UsdtTransferStatus::BridgeRefunded; + refunded.received_amount = 0; + let bridge = refunded.orchestra.as_mut().unwrap(); + bridge.refund_tx = Some(B256::repeat_byte(0xab).to_string()); + bridge.refund_amount = Some(900_000); + payments.push(refunded); + } + let block = (21000, B256::repeat_byte(2)); + assert!(wallet + .store + .update_delivery(&payments[0], Some(block)) + .unwrap()); + // Retrying the same association is idempotent, including transaction-hash casing. + payments[0].orchestra.as_mut().unwrap().refund_tx = Some(format!("0x{}", "AB".repeat(32))); + assert!(wallet + .store + .update_delivery(&payments[0], Some(block)) + .unwrap()); + assert!(!wallet + .store + .update_delivery(&payments[1], Some(block)) + .unwrap()); + assert_eq!( + wallet + .store + .transfer(&payments[1].id) + .unwrap() + .unwrap() + .status, + UsdtTransferStatus::Bridging + ); + // An orphaned refund releases its claim without reopening source funding. + wallet + .store + .reconcile_block(block.0, &B256::repeat_byte(3).to_string()) + .unwrap(); + assert!(wallet + .store + .update_delivery(&payments[1], Some((block.0, B256::repeat_byte(3)))) + .unwrap()); + wallet.store.complete_history(26000).unwrap(); + drop(wallet); + let wallet = chain.wallet_with_bridges(&directory, true); + assert!(wallet.store.orchestra_plan(&payments[1].id).is_err()); + assert!(!wallet + .store + .update_delivery(&payments[0], Some(block)) + .unwrap()); + assert_eq!( + wallet + .store + .transfer(&payments[0].id) + .unwrap() + .unwrap() + .status, + UsdtTransferStatus::Bridging + ); + assert_eq!( + wallet + .store + .transfer(&payments[1].id) + .unwrap() + .unwrap() + .status, + UsdtTransferStatus::BridgeRefunded + ); +} diff --git a/src/modules/usdt/transaction.rs b/src/modules/usdt/transaction.rs index 4be7575..030c280 100644 --- a/src/modules/usdt/transaction.rs +++ b/src/modules/usdt/transaction.rs @@ -111,6 +111,8 @@ pub(super) fn operation_logs( #[derive(Clone, Debug, Serialize, Deserialize)] pub(super) struct Plan { + pub refund_block: Option<(u64, B256)>, + pub orchestra: Option, pub operation: UserOperation, pub created_block: u64, pub expires_at: u64, @@ -118,6 +120,9 @@ pub(super) struct Plan { impl Plan { pub fn bridge_received_amount(&self) -> Result { + if let Some(plan) = &self.orchestra { + return Ok(plan.received_amount); + } let calls = super::history::decode_calls(&self.operation.call_data)?; let (_, data) = calls .iter() diff --git a/src/modules/usdt/types.rs b/src/modules/usdt/types.rs index ccdafb0..cfe6647 100644 --- a/src/modules/usdt/types.rs +++ b/src/modules/usdt/types.rs @@ -13,19 +13,54 @@ pub enum UsdtDestination { Arbitrum, Polygon, Plasma, + Base, + Bsc, + Solana, + Tron, } impl UsdtDestination { - pub(super) fn token(self) -> Address { - match self { + pub(super) fn token(self) -> Option
{ + Some(match self { Self::Arbitrum => TOKEN, Self::Ethereum => address!("dAC17F958D2ee523a2206206994597C13D831ec7"), Self::Polygon => address!("c2132D05D31c914a87C6611C10748AEb04B58e8F"), Self::Plasma => address!("B8CE59FC3717ada4C02eaDF9682A9e934F625ebb"), Self::Stable => address!("779Ded0c9e1022225f8E0630b35a9b54bE713736"), + Self::Base => address!("fde4c96c8593536e31f229ea8f37b2ada2699bb2"), + Self::Bsc => address!("55d398326f99059ff775485246999027b3197955"), + Self::Solana | Self::Tron => return None, + }) + } + + pub(super) fn network(self) -> &'static str { + match self { + Self::Stable => "stable", + Self::Ethereum => "ethereum", + Self::Arbitrum => "arbitrum", + Self::Polygon => "polygon", + Self::Plasma => "plasma", + Self::Base => "base", + Self::Bsc => "bsc", + Self::Solana => "solana", + Self::Tron => "tron", } } + pub(super) fn from_network(network: &str) -> Option { + [ + Self::Ethereum, + Self::Polygon, + Self::Plasma, + Self::Base, + Self::Bsc, + Self::Solana, + Self::Tron, + ] + .into_iter() + .find(|destination| destination.network() == network) + } + pub(super) fn from_endpoint(eid: u32) -> Option { [Self::Ethereum, Self::Polygon, Self::Plasma, Self::Stable] .into_iter() @@ -36,7 +71,7 @@ impl UsdtDestination { match self { Self::Stable => Some(30396), Self::Ethereum => Some(30101), - Self::Arbitrum => None, + Self::Arbitrum | Self::Base | Self::Bsc | Self::Solana | Self::Tron => None, Self::Polygon => Some(30109), Self::Plasma => Some(30383), } @@ -53,6 +88,8 @@ pub struct UsdtPaymentRequest { #[derive(Clone, Debug, Serialize, Deserialize, uniffi::Record)] pub struct UsdtQuote { + /// Absent for a direct Arbitrum payment. The provider is fixed when this quote is approved. + pub bridge_provider: Option, pub id: String, pub recipient: String, pub destination: UsdtDestination, @@ -76,10 +113,27 @@ pub enum UsdtTransferStatus { BridgeNeedsAttention, /// Delivery was permanently stopped. This does not imply a refund of source funds or fees. BridgeFailed, + /// Source-chain receipt proves USDT was returned to this wallet. + BridgeRefunded, /// Another operation consumed the payment nonce. Replaced, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +pub enum UsdtBridgeProvider { + Usdt0, + Orchestra, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct UsdtOrchestraTransfer { + pub quote_id: String, + pub funding_address: String, + pub destination_tx: Option, + pub refund_tx: Option, + pub refund_amount: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, uniffi::Record)] pub struct UsdtTransfer { pub id: String, @@ -87,6 +141,7 @@ pub struct UsdtTransfer { pub tx_hash: Option, pub user_operation_hash: Option, pub bridge_guid: Option, + pub orchestra: Option, pub recipient: String, pub destination: UsdtDestination, pub amount: u64, diff --git a/src/modules/usdt/wallet.rs b/src/modules/usdt/wallet.rs index 1a10c7f..a90933e 100644 --- a/src/modules/usdt/wallet.rs +++ b/src/modules/usdt/wallet.rs @@ -37,6 +37,7 @@ pub struct UsdtWallet { pub(super) rpc: Rpc, pub(super) paymaster: Pimlico, pub(super) store: Store, + orchestra: Option, pub(super) operation: Mutex<()>, bridge_poll_offset: AtomicUsize, bridge_retry_after: Mutex>, @@ -46,12 +47,13 @@ pub struct UsdtWallet { #[uniffi::export(async_runtime = "tokio")] impl UsdtWallet { /// Creates the sole owner of this wallet's database; reuse it for all calls until it is dropped. - #[uniffi::constructor] + #[uniffi::constructor(default(bridge_url = None))] pub fn new( address: String, storage_path: String, rpc_url: String, bundler_url: String, + bridge_url: Option, ) -> Result, UsdtError> { if rpc_url.is_empty() || bundler_url.is_empty() { return Err(UsdtError::NotConfigured); @@ -67,6 +69,9 @@ impl UsdtWallet { rpc, paymaster, store, + orchestra: bridge_url + .map(super::orchestra::Orchestra::new) + .transpose()?, operation: Mutex::new(()), bridge_poll_offset: AtomicUsize::new(0), bridge_retry_after: Mutex::new(HashMap::new()), @@ -100,55 +105,67 @@ impl UsdtWallet { if amount == 0 { return Err(UsdtError::InvalidAmount); } - let recipient = parse_address(recipient.trim())?; - if recipient == self.address - || recipient == destination.token() - || [ENTRY_POINT, PAYMASTER, super::account::DELEGATE].contains(&recipient) - || (destination == UsdtDestination::Arbitrum - && [OFT, BRIDGE_HELPER].contains(&recipient)) - { + let recipient = super::orchestra::validate_recipient(recipient.trim(), destination)?; + if recipient == self.receive_address() { return Err(UsdtError::InvalidAddress); } self.store.require_no_pending()?; self.rpc.verify_chain().await?; self.require_balance(amount, 0).await?; - let (calls, received_amount, bridge_fee) = - self.transfer_calls(recipient, amount, destination).await?; - let nonce = self.nonce("latest").await?; - let authorization = self.authorization().await?; - let created_block = self.block_number().await?; - let timestamp = self.block_timestamp(created_block).await?; - let (operation, gas_fee, operation_expires_at) = self - .paymaster - .prepare(self.address, nonce, authorization, &calls, timestamp) - .await?; - let expires_at = now().saturating_add( - operation_expires_at - .saturating_sub(timestamp) - .min(QUOTE_LIFETIME_SECONDS), - ); - let maximum_fee = gas_fee - .checked_add(bridge_fee) - .ok_or(UsdtError::InvalidAmount)?; - self.require_balance(amount, maximum_fee).await?; - let quote = UsdtQuote { - id: uuid::Uuid::new_v4().to_string(), - recipient: recipient.to_checksum(None), - destination, - amount, - received_amount, - maximum_fee, - expires_at, + let direct = self.prepare_quote(&recipient, amount, destination, false); + let routed = async { + if destination == UsdtDestination::Arbitrum + || destination == UsdtDestination::Stable + || self.orchestra.is_none() + { + return Err(UsdtError::UnsupportedRoute); + } + self.prepare_quote(&recipient, amount, destination, true) + .await }; - self.store.save_quote(&QuoteData { - quote: quote.clone(), - plan: Plan { - operation, - created_block, - expires_at: operation_expires_at, - }, - })?; - Ok(quote) + let (direct, routed) = tokio::join!(direct, routed); + let fresh = |candidate: Result| { + candidate.and_then(|data| { + if data.quote.expires_at <= now() + 5 { + Err(UsdtError::QuoteExpired) + } else { + Ok(data) + } + }) + }; + let selected = match (fresh(direct), fresh(routed)) { + (Ok(a), Ok(b)) => { + if better_quote(&b.quote, &a.quote)? { + b + } else { + a + } + } + (Ok(a), Err(_)) | (Err(_), Ok(a)) => a, + (Err(a), Err(b)) => { + return Err( + if destination.endpoint().is_some() || destination == UsdtDestination::Arbitrum + { + a + } else { + b + }, + ) + } + }; + if selected.quote.expires_at <= now() + 5 { + return Err(UsdtError::QuoteExpired); + } + self.store.save_quote(&selected)?; + Ok(selected.quote) + } + + /// Available Orchestra destinations. USDT0 destinations retain the app's existing configuration. + pub async fn orchestra_destinations(&self) -> Result, UsdtError> { + match &self.orchestra { + Some(client) => client.networks().await, + None => Ok(Vec::new()), + } } /// Repeating a quote ID returns its stored outcome, which may already be failed or replaced. @@ -203,6 +220,7 @@ impl UsdtWallet { tx_hash: None, user_operation_hash: Some(format!("{hash:#x}")), bridge_guid: None, + orchestra: data.plan.orchestra.as_ref().map(|plan| plan.transfer()), recipient: data.quote.recipient, destination: data.quote.destination, amount: data.quote.amount, @@ -306,6 +324,96 @@ impl UsdtWallet { } impl UsdtWallet { + async fn prepare_quote( + &self, + recipient: &str, + amount: u64, + destination: UsdtDestination, + use_orchestra: bool, + ) -> Result { + let orchestra = if use_orchestra { + Some( + self.orchestra + .as_ref() + .ok_or(UsdtError::UnsupportedRoute)? + .quote(self.address, recipient.into(), amount, destination) + .await?, + ) + } else { + None + }; + let (calls, received_amount, bridge_fee) = if let Some(plan) = &orchestra { + ( + vec![( + TOKEN, + Erc20::transferCall { + recipient: parse_address(&plan.funding_address)?, + amount: U256::from(amount), + } + .abi_encode() + .into(), + )], + plan.received_amount, + 0, + ) + } else { + if destination != UsdtDestination::Arbitrum && destination.endpoint().is_none() { + return Err(UsdtError::UnsupportedRoute); + } + self.transfer_calls(parse_address(recipient)?, amount, destination) + .await? + }; + let nonce = self.nonce("latest").await?; + let authorization = self.authorization().await?; + let created_block = self.block_number().await?; + let timestamp = self.block_timestamp(created_block).await?; + let (operation, gas_fee, operation_expires_at) = self + .paymaster + .prepare(self.address, nonce, authorization, &calls, timestamp) + .await?; + let mut expires_at = now().saturating_add( + operation_expires_at + .saturating_sub(timestamp) + .min(QUOTE_LIFETIME_SECONDS), + ); + if let Some(plan) = &orchestra { + expires_at = expires_at.min(plan.expires_at); + } + let maximum_fee = gas_fee + .checked_add(bridge_fee) + .ok_or(UsdtError::InvalidAmount)?; + amount + .checked_add(maximum_fee) + .ok_or(UsdtError::InvalidAmount)?; + self.require_balance(amount, maximum_fee).await?; + let bridge_provider = if orchestra.is_some() { + Some(super::UsdtBridgeProvider::Orchestra) + } else if destination == UsdtDestination::Arbitrum { + None + } else { + Some(super::UsdtBridgeProvider::Usdt0) + }; + Ok(QuoteData { + quote: UsdtQuote { + id: uuid::Uuid::new_v4().to_string(), + recipient: recipient.into(), + destination, + amount, + received_amount, + maximum_fee, + expires_at, + bridge_provider, + }, + plan: Plan { + refund_block: None, + operation, + orchestra, + created_block, + expires_at: operation_expires_at, + }, + }) + } + async fn refresh_pending_transfers(&self) -> Result<(), UsdtError> { let _guard = self.operation.lock().await; for (mut transfer, plan) in self.store.pending_operations()? { @@ -484,7 +592,7 @@ impl UsdtWallet { transfer, tokio::time::timeout( std::time::Duration::from_secs(10), - self.rpc.bridge_status(transfer), + self.bridge_delivery(transfer), ) .await, )) @@ -492,9 +600,20 @@ impl UsdtWallet { let (first, second, third) = tokio::join!(check(batch[0]), check(batch[1]), check(batch[2])); for (previous, result) in [first, second, third].into_iter().flatten() { - let delay = match result { - Ok(Ok(UsdtTransferStatus::Bridging)) => Some(BRIDGE_POLL_INTERVAL), - Ok(Ok(UsdtTransferStatus::Confirmed | UsdtTransferStatus::BridgeFailed)) => None, + let delay = match &result { + Ok(Ok((updated, _))) if updated.status == UsdtTransferStatus::Bridging => { + Some(BRIDGE_POLL_INTERVAL) + } + Ok(Ok((updated, _))) + if matches!( + updated.status, + UsdtTransferStatus::Confirmed + | UsdtTransferStatus::BridgeFailed + | UsdtTransferStatus::BridgeRefunded + ) => + { + None + } _ => Some(BRIDGE_RETRY_DELAY), }; if let Some(delay) = delay { @@ -504,7 +623,14 @@ impl UsdtWallet { .insert(previous.id.clone(), Instant::now() + delay); } match result { - Ok(Ok(status)) if status != previous.status => { + Ok(Ok((updated, refund_block))) => { + if updated.status == previous.status + && updated.orchestra == previous.orchestra + && updated.received_amount == previous.received_amount + && refund_block.is_none() + { + continue; + } let _guard = self.operation.lock().await; let Some(mut current) = self.store.transfer(&previous.id)? else { continue; @@ -515,13 +641,21 @@ impl UsdtWallet { .zip(previous.tx_hash.as_deref()) .is_some_and(|(a, b)| a.eq_ignore_ascii_case(b)) && current.bridge_guid == previous.bridge_guid + && current.orchestra == previous.orchestra && current.status == previous.status { - current.status = status; - self.store.update_transfer(¤t)?; + current.status = updated.status; + current.orchestra = updated.orchestra; + current.received_amount = updated.received_amount; + if !self.store.update_delivery(¤t, refund_block)? { + self.bridge_retry_after + .lock() + .await + .insert(previous.id.clone(), Instant::now() + BRIDGE_RETRY_DELAY); + log::warn!("USDT refund receipt already assigned to another payment"); + } } } - Ok(Ok(_)) => {} _ => log::warn!( "USDT bridge delivery lookup unavailable; retaining last known status" ), @@ -530,6 +664,120 @@ impl UsdtWallet { Ok(()) } + async fn bridge_delivery( + &self, + transfer: &UsdtTransfer, + ) -> Result<(UsdtTransfer, Option<(u64, B256)>), UsdtError> { + let mut updated = transfer.clone(); + let Some(bridge) = updated.orchestra.as_mut() else { + updated.status = self.rpc.bridge_status(transfer).await?; + return Ok((updated, None)); + }; + let plan = self.store.orchestra_plan(&transfer.id)?; + if plan.quote_id != bridge.quote_id + || plan.funding_address != bridge.funding_address + || plan.recipient != transfer.recipient + || plan.destination != transfer.destination + || plan.amount != transfer.amount + { + return Err(UsdtError::InvalidResponse); + } + let delivery = self + .orchestra + .as_ref() + .ok_or(UsdtError::NotConfigured)? + .status( + &plan, + transfer + .tx_hash + .as_deref() + .ok_or(UsdtError::InvalidResponse)?, + ) + .await?; + let mut refund_block = None; + updated.status = match delivery.status.as_str() { + "bridging" => UsdtTransferStatus::Bridging, + "needs_attention" => UsdtTransferStatus::BridgeNeedsAttention, + "completed" => { + let amount = delivery + .received_amount + .filter(|amount| *amount > 0 && *amount <= transfer.amount) + .ok_or(UsdtError::InvalidResponse)?; + let hash = delivery + .destination_tx + .filter(|hash| !hash.is_empty() && hash.len() <= 128) + .ok_or(UsdtError::InvalidResponse)?; + bridge.destination_tx = Some(hash); + updated.received_amount = amount; + UsdtTransferStatus::Confirmed + } + "refunded" => { + let amount = delivery + .refund_amount + .filter(|amount| *amount > 0 && *amount <= transfer.amount) + .ok_or(UsdtError::InvalidResponse)?; + let hash = delivery.refund_tx.ok_or(UsdtError::InvalidResponse)?; + refund_block = Some(self.verify_refund(&hash, amount).await?); + bridge.refund_tx = Some(hash); + bridge.refund_amount = Some(amount); + updated.received_amount = 0; + UsdtTransferStatus::BridgeRefunded + } + _ => return Err(UsdtError::InvalidResponse), + }; + Ok((updated, refund_block)) + } + + async fn verify_refund(&self, hash: &str, amount: u64) -> Result<(u64, B256), UsdtError> { + let hash: B256 = hash.parse().map_err(|_| UsdtError::InvalidResponse)?; + let receipt: Value = self + .rpc + .call("eth_getTransactionReceipt", json!([hash])) + .await?; + let number = token_amount(serde_json::from_value(receipt["blockNumber"].clone())?)?; + if number > self.block_number().await?.saturating_sub(2) { + return Err(UsdtError::NetworkUnavailable); + } + let block = self.rpc.block(number).await?; + let receipt = self.rpc.block_receipt(hash, block.hash, number).await?; + if serde_json::from_value::(receipt["status"].clone())? != U256::from(1) { + return Err(UsdtError::InvalidResponse); + } + let mut incoming = U256::ZERO; + let mut outgoing = U256::ZERO; + let mut matching_transfer = false; + for log in receipt["logs"] + .as_array() + .ok_or(UsdtError::InvalidResponse)? + { + if serde_json::from_value::
(log["address"].clone())? == TOKEN { + if let Ok(event) = Erc20::Transfer::decode_log_data(&event_data(log)?) { + if event.to == self.address { + incoming = incoming + .checked_add(event.value) + .ok_or(UsdtError::InvalidResponse)?; + matching_transfer |= + event.from != self.address && event.value == U256::from(amount); + } + if event.from == self.address { + outgoing = outgoing + .checked_add(event.value) + .ok_or(UsdtError::InvalidResponse)?; + } + } + } + } + if matching_transfer + && incoming + .checked_sub(outgoing) + .is_some_and(|net| net >= U256::from(amount)) + { + Ok((number, block.hash)) + } else { + Err(UsdtError::InvalidResponse) + } + } + pub(super) async fn block_number(&self) -> Result { u64::try_from(self.rpc.call::("eth_blockNumber", json!([])).await?) .map_err(|_| UsdtError::InvalidResponse) @@ -582,6 +830,13 @@ impl UsdtWallet { } async fn broadcast(&self, plan: &Plan, expected_hash: B256) -> Result<(), UsdtError> { + if plan + .orchestra + .as_ref() + .is_some_and(|quote| quote.expires_at <= now() + 5) + { + return Err(UsdtError::QuoteExpired); + } if self.authorization().await?.nonce != plan.operation.eip7702_auth.nonce { return Err(UsdtError::QuoteExpired); } @@ -678,6 +933,15 @@ impl UsdtWallet { if event.sender != self.address || event.paymaster != PAYMASTER { return Err(UsdtError::InvalidResponse); } + let funding_recipient = if !event.success { + Address::ZERO + } else if let Some(orchestra) = &transfer.orchestra { + parse_address(&orchestra.funding_address)? + } else if transfer.destination == UsdtDestination::Arbitrum { + parse_address(&transfer.recipient)? + } else { + Address::ZERO + }; let mut transfer_proven = false; let mut gas_fee = None; let mut bridge_fee = None; @@ -687,7 +951,7 @@ impl UsdtWallet { if event.success && address == TOKEN { if let Ok(payment) = Erc20::Transfer::decode_log_data(&data) { transfer_proven |= payment.from == self.address - && payment.to == parse_address(&transfer.recipient)? + && payment.to == funding_recipient && payment.value == U256::from(transfer.amount); } } @@ -702,7 +966,7 @@ impl UsdtWallet { } } } - if event.success && address == BRIDGE_HELPER { + if event.success && transfer.orchestra.is_none() && address == BRIDGE_HELPER { if let Ok(event) = BridgeHelper::LogSend::decode_log_data(&data) { if event.sender == self.address && event.oft == OFT @@ -712,7 +976,7 @@ impl UsdtWallet { } } } - if event.success && address == OFT { + if event.success && transfer.orchestra.is_none() && address == OFT { if let Ok(event) = Oft::OFTSent::decode_log_data(&data) { if event.fromAddress == BRIDGE_HELPER && transfer.destination.endpoint() == Some(event.dstEid) @@ -724,10 +988,16 @@ impl UsdtWallet { } } } - if event.success && transfer.destination == UsdtDestination::Arbitrum && !transfer_proven { + if event.success + && (transfer.destination == UsdtDestination::Arbitrum || transfer.orchestra.is_some()) + && !transfer_proven + { return Err(UsdtError::InvalidResponse); } - transfer.fee = if event.success && transfer.destination != UsdtDestination::Arbitrum { + transfer.fee = if event.success + && transfer.destination != UsdtDestination::Arbitrum + && transfer.orchestra.is_none() + { gas_fee .zip(bridge_fee) .and_then(|(gas, bridge)| gas.checked_add(bridge)) @@ -739,7 +1009,7 @@ impl UsdtWallet { UsdtTransferStatus::Failed } else if transfer.destination == UsdtDestination::Arbitrum { UsdtTransferStatus::Confirmed - } else if transfer.bridge_guid.is_some() { + } else if transfer.bridge_guid.is_some() || transfer.orchestra.is_some() { UsdtTransferStatus::Bridging } else { UsdtTransferStatus::BridgeNeedsAttention @@ -747,6 +1017,13 @@ impl UsdtWallet { Ok(()) } async fn validate_bridge(&self, plan: &Plan) -> Result<(), UsdtError> { + if plan + .orchestra + .as_ref() + .is_some_and(|quote| quote.expires_at <= now() + 5) + { + return Err(UsdtError::QuoteExpired); + } let calls = super::history::decode_calls(&plan.operation.call_data)?; let Some((_, data)) = calls.iter().find(|(target, _)| *target == BRIDGE_HELPER) else { return Ok(()); @@ -936,3 +1213,20 @@ impl UsdtWallet { pub(super) fn now() -> u64 { chrono::Utc::now().timestamp().max(0) as u64 } + +// Compare the expected receipt per maximum USDT debit without floating-point rates. +// A tie keeps the USDT0 route; only usable, funded-balance candidates reach this comparison. +fn better_quote(candidate: &UsdtQuote, current: &UsdtQuote) -> Result { + let candidate_total = candidate + .amount + .checked_add(candidate.maximum_fee) + .ok_or(UsdtError::InvalidAmount)?; + let current_total = current + .amount + .checked_add(current.maximum_fee) + .ok_or(UsdtError::InvalidAmount)?; + Ok( + u128::from(candidate.received_amount) * u128::from(current_total) + > u128::from(current.received_amount) * u128::from(candidate_total), + ) +}