From 5e3cb0e68d8bb488ca33354b5c170d30ce9de8ac Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 24 Sep 2026 19:17:49 +0300 Subject: [PATCH 1/6] feat: add orchestra inbound usdt deposits --- bindings/ios/bitkitcore.swift | 1111 +++++++++++++++-- bindings/ios/bitkitcoreFFI.h | 76 ++ src/lib.rs | 7 +- src/modules/usdt/README.md | 2 + src/modules/usdt/deposits.rs | 359 ++++++ src/modules/usdt/errors.rs | 4 + .../usdt/fixtures/deposit-signature.json | 4 + src/modules/usdt/mod.rs | 2 + src/modules/usdt/tests.rs | 25 + 9 files changed, 1476 insertions(+), 114 deletions(-) create mode 100644 src/modules/usdt/deposits.rs create mode 100644 src/modules/usdt/fixtures/deposit-signature.json diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 76ccf5c..c07f0bd 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -2563,6 +2563,223 @@ public func FfiConverterTypeUrDecoder_lower(_ value: UrDecoder) -> UnsafeMutable +public protocol UsdtDepositClientProtocol: AnyObject, Sendable { + + func detail(depositId: String, offset: UInt32, mnemonic: String, passphrase: String?) async throws -> UsdtDepositDetail + + func history(offset: UInt32, mnemonic: String, passphrase: String?) async throws -> UsdtDepositPage + + func networks() async throws -> [UsdtDepositNetwork] + + func receive(network: UsdtDepositNetwork, amount: UInt64, mnemonic: String, passphrase: String?) async throws -> UsdtDepositAddress + + func requestRefund(depositId: String, offset: UInt32, refundAddress: String, network: UsdtDepositNetwork, mnemonic: String, passphrase: String?) async throws + +} +open class UsdtDepositClient: UsdtDepositClientProtocol, @unchecked Sendable { + fileprivate let pointer: UnsafeMutableRawPointer! + + /// Used to instantiate a [FFIObject] without an actual pointer, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoPointer { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + self.pointer = pointer + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noPointer: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing [Pointer] the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noPointer: NoPointer) { + self.pointer = nil + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiClonePointer() -> UnsafeMutableRawPointer { + return try! rustCall { uniffi_bitkitcore_fn_clone_usdtdepositclient(self.pointer, $0) } + } +public convenience init(address: String, serviceUrl: String)throws { + let pointer = + try rustCallWithError(FfiConverterTypeUsdtError_lift) { + uniffi_bitkitcore_fn_constructor_usdtdepositclient_new( + FfiConverterString.lower(address), + FfiConverterString.lower(serviceUrl),$0 + ) +} + self.init(unsafeFromRawPointer: pointer) +} + + deinit { + guard let pointer = pointer else { + return + } + + try! rustCall { uniffi_bitkitcore_fn_free_usdtdepositclient(pointer, $0) } + } + + + + +open func detail(depositId: String, offset: UInt32, mnemonic: String, passphrase: String?)async throws -> UsdtDepositDetail { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtdepositclient_detail( + self.uniffiClonePointer(), + FfiConverterString.lower(depositId),FfiConverterUInt32.lower(offset),FfiConverterString.lower(mnemonic),FfiConverterOptionString.lower(passphrase) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeUsdtDepositDetail_lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + +open func history(offset: UInt32, mnemonic: String, passphrase: String?)async throws -> UsdtDepositPage { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtdepositclient_history( + self.uniffiClonePointer(), + FfiConverterUInt32.lower(offset),FfiConverterString.lower(mnemonic),FfiConverterOptionString.lower(passphrase) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeUsdtDepositPage_lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + +open func networks()async throws -> [UsdtDepositNetwork] { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtdepositclient_networks( + 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: FfiConverterSequenceTypeUsdtDepositNetwork.lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + +open func receive(network: UsdtDepositNetwork, amount: UInt64, mnemonic: String, passphrase: String?)async throws -> UsdtDepositAddress { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtdepositclient_receive( + self.uniffiClonePointer(), + FfiConverterTypeUsdtDepositNetwork_lower(network),FfiConverterUInt64.lower(amount),FfiConverterString.lower(mnemonic),FfiConverterOptionString.lower(passphrase) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeUsdtDepositAddress_lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + +open func requestRefund(depositId: String, offset: UInt32, refundAddress: String, network: UsdtDepositNetwork, mnemonic: String, passphrase: String?)async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtdepositclient_request_refund( + self.uniffiClonePointer(), + FfiConverterString.lower(depositId),FfiConverterUInt32.lower(offset),FfiConverterString.lower(refundAddress),FfiConverterTypeUsdtDepositNetwork_lower(network),FfiConverterString.lower(mnemonic),FfiConverterOptionString.lower(passphrase) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_void, + completeFunc: ffi_bitkitcore_rust_future_complete_void, + freeFunc: ffi_bitkitcore_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + + +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtDepositClient: FfiConverter { + + typealias FfiType = UnsafeMutableRawPointer + typealias SwiftType = UsdtDepositClient + + public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> UsdtDepositClient { + return UsdtDepositClient(unsafeFromRawPointer: pointer) + } + + public static func lower(_ value: UsdtDepositClient) -> UnsafeMutableRawPointer { + return value.uniffiClonePointer() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtDepositClient { + let v: UInt64 = try readInt(&buf) + // The Rust code won't compile if a pointer won't fit in a UInt64. + // We have to go via `UInt` because that's the thing that's the size of a pointer. + let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: v)) + if (ptr == nil) { + throw UniffiInternalError.unexpectedNullPointer + } + return try lift(ptr!) + } + + public static func write(_ value: UsdtDepositClient, into buf: inout [UInt8]) { + // This fiddling is because `Int` is the thing that's the same size as a pointer. + // The Rust code won't compile if a pointer won't fit in a `UInt64`. + writeInt(&buf, UInt64(bitPattern: Int64(Int(bitPattern: lower(value))))) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtDepositClient_lift(_ pointer: UnsafeMutableRawPointer) throws -> UsdtDepositClient { + return try FfiConverterTypeUsdtDepositClient.lift(pointer) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtDepositClient_lower(_ value: UsdtDepositClient) -> UnsafeMutableRawPointer { + return FfiConverterTypeUsdtDepositClient.lower(value) +} + + + + + + public protocol UsdtWalletProtocol: AnyObject, Sendable { func balance() async throws -> UInt64 @@ -15725,64 +15942,589 @@ public struct TxOutput { } #if compiler(>=6) -extension TxOutput: Sendable {} +extension TxOutput: Sendable {} +#endif + + +extension TxOutput: Equatable, Hashable { + public static func ==(lhs: TxOutput, rhs: TxOutput) -> Bool { + if lhs.scriptpubkey != rhs.scriptpubkey { + return false + } + if lhs.scriptpubkeyType != rhs.scriptpubkeyType { + return false + } + if lhs.scriptpubkeyAddress != rhs.scriptpubkeyAddress { + return false + } + if lhs.value != rhs.value { + return false + } + if lhs.n != rhs.n { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(scriptpubkey) + hasher.combine(scriptpubkeyType) + hasher.combine(scriptpubkeyAddress) + hasher.combine(value) + hasher.combine(n) + } +} + +extension TxOutput: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeTxOutput: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TxOutput { + return + try TxOutput( + scriptpubkey: FfiConverterString.read(from: &buf), + scriptpubkeyType: FfiConverterOptionString.read(from: &buf), + scriptpubkeyAddress: FfiConverterOptionString.read(from: &buf), + value: FfiConverterInt64.read(from: &buf), + n: FfiConverterUInt32.read(from: &buf) + ) + } + + public static func write(_ value: TxOutput, into buf: inout [UInt8]) { + FfiConverterString.write(value.scriptpubkey, into: &buf) + FfiConverterOptionString.write(value.scriptpubkeyType, into: &buf) + FfiConverterOptionString.write(value.scriptpubkeyAddress, into: &buf) + FfiConverterInt64.write(value.value, into: &buf) + FfiConverterUInt32.write(value.n, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTxOutput_lift(_ buf: RustBuffer) throws -> TxOutput { + return try FfiConverterTypeTxOutput.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTxOutput_lower(_ value: TxOutput) -> RustBuffer { + return FfiConverterTypeTxOutput.lower(value) +} + + +/** + * Current state after accepting a scanned UR frame. + */ +public struct UrDecoderStatus { + /** + * Estimated completion from 0.0 through 1.0. + */ + public var progress: Double + /** + * Fountain source-fragment count, or 1 for a single-part UR. + */ + public var fragmentCount: UInt32 + /** + * Present once the complete message has been decoded. + */ + public var payload: UrPayload? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * Estimated completion from 0.0 through 1.0. + */progress: Double, + /** + * Fountain source-fragment count, or 1 for a single-part UR. + */fragmentCount: UInt32, + /** + * Present once the complete message has been decoded. + */payload: UrPayload?) { + self.progress = progress + self.fragmentCount = fragmentCount + self.payload = payload + } +} + +#if compiler(>=6) +extension UrDecoderStatus: Sendable {} +#endif + + +extension UrDecoderStatus: Equatable, Hashable { + public static func ==(lhs: UrDecoderStatus, rhs: UrDecoderStatus) -> Bool { + if lhs.progress != rhs.progress { + return false + } + if lhs.fragmentCount != rhs.fragmentCount { + return false + } + if lhs.payload != rhs.payload { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(progress) + hasher.combine(fragmentCount) + hasher.combine(payload) + } +} + +extension UrDecoderStatus: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUrDecoderStatus: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UrDecoderStatus { + return + try UrDecoderStatus( + progress: FfiConverterDouble.read(from: &buf), + fragmentCount: FfiConverterUInt32.read(from: &buf), + payload: FfiConverterOptionTypeUrPayload.read(from: &buf) + ) + } + + public static func write(_ value: UrDecoderStatus, into buf: inout [UInt8]) { + FfiConverterDouble.write(value.progress, into: &buf) + FfiConverterUInt32.write(value.fragmentCount, into: &buf) + FfiConverterOptionTypeUrPayload.write(value.payload, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUrDecoderStatus_lift(_ buf: RustBuffer) throws -> UrDecoderStatus { + return try FfiConverterTypeUrDecoderStatus.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUrDecoderStatus_lower(_ value: UrDecoderStatus) -> RustBuffer { + return FfiConverterTypeUrDecoderStatus.lower(value) +} + + +public struct UsdtDeposit { + public var id: String + public var network: String + public var asset: String + public var amount: UInt64? + public var sourceTx: String + public var status: String + public var code: String? + public var refundTx: String? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: String, network: String, asset: String, amount: UInt64?, sourceTx: String, status: String, code: String?, refundTx: String?) { + self.id = id + self.network = network + self.asset = asset + self.amount = amount + self.sourceTx = sourceTx + self.status = status + self.code = code + self.refundTx = refundTx + } +} + +#if compiler(>=6) +extension UsdtDeposit: Sendable {} +#endif + + +extension UsdtDeposit: Equatable, Hashable { + public static func ==(lhs: UsdtDeposit, rhs: UsdtDeposit) -> Bool { + if lhs.id != rhs.id { + return false + } + if lhs.network != rhs.network { + return false + } + if lhs.asset != rhs.asset { + return false + } + if lhs.amount != rhs.amount { + return false + } + if lhs.sourceTx != rhs.sourceTx { + return false + } + if lhs.status != rhs.status { + return false + } + if lhs.code != rhs.code { + return false + } + if lhs.refundTx != rhs.refundTx { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(network) + hasher.combine(asset) + hasher.combine(amount) + hasher.combine(sourceTx) + hasher.combine(status) + hasher.combine(code) + hasher.combine(refundTx) + } +} + +extension UsdtDeposit: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtDeposit: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtDeposit { + return + try UsdtDeposit( + id: FfiConverterString.read(from: &buf), + network: FfiConverterString.read(from: &buf), + asset: FfiConverterString.read(from: &buf), + amount: FfiConverterOptionUInt64.read(from: &buf), + sourceTx: FfiConverterString.read(from: &buf), + status: FfiConverterString.read(from: &buf), + code: FfiConverterOptionString.read(from: &buf), + refundTx: FfiConverterOptionString.read(from: &buf) + ) + } + + public static func write(_ value: UsdtDeposit, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.network, into: &buf) + FfiConverterString.write(value.asset, into: &buf) + FfiConverterOptionUInt64.write(value.amount, into: &buf) + FfiConverterString.write(value.sourceTx, into: &buf) + FfiConverterString.write(value.status, into: &buf) + FfiConverterOptionString.write(value.code, into: &buf) + FfiConverterOptionString.write(value.refundTx, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtDeposit_lift(_ buf: RustBuffer) throws -> UsdtDeposit { + return try FfiConverterTypeUsdtDeposit.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtDeposit_lower(_ value: UsdtDeposit) -> RustBuffer { + return FfiConverterTypeUsdtDeposit.lower(value) +} + + +public struct UsdtDepositAddress { + public var network: UsdtDepositNetwork + public var address: String + public var recipient: String + public var amount: UInt64 + public var estimatedReceived: UInt64 + public var minUsdCents: String? + public var maxUsdCents: String? + public var slippageBps: UInt32 + public var uri: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(network: UsdtDepositNetwork, address: String, recipient: String, amount: UInt64, estimatedReceived: UInt64, minUsdCents: String?, maxUsdCents: String?, slippageBps: UInt32, uri: String) { + self.network = network + self.address = address + self.recipient = recipient + self.amount = amount + self.estimatedReceived = estimatedReceived + self.minUsdCents = minUsdCents + self.maxUsdCents = maxUsdCents + self.slippageBps = slippageBps + self.uri = uri + } +} + +#if compiler(>=6) +extension UsdtDepositAddress: Sendable {} +#endif + + +extension UsdtDepositAddress: Equatable, Hashable { + public static func ==(lhs: UsdtDepositAddress, rhs: UsdtDepositAddress) -> Bool { + if lhs.network != rhs.network { + return false + } + if lhs.address != rhs.address { + return false + } + if lhs.recipient != rhs.recipient { + return false + } + if lhs.amount != rhs.amount { + return false + } + if lhs.estimatedReceived != rhs.estimatedReceived { + return false + } + if lhs.minUsdCents != rhs.minUsdCents { + return false + } + if lhs.maxUsdCents != rhs.maxUsdCents { + return false + } + if lhs.slippageBps != rhs.slippageBps { + return false + } + if lhs.uri != rhs.uri { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(network) + hasher.combine(address) + hasher.combine(recipient) + hasher.combine(amount) + hasher.combine(estimatedReceived) + hasher.combine(minUsdCents) + hasher.combine(maxUsdCents) + hasher.combine(slippageBps) + hasher.combine(uri) + } +} + +extension UsdtDepositAddress: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtDepositAddress: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtDepositAddress { + return + try UsdtDepositAddress( + network: FfiConverterTypeUsdtDepositNetwork.read(from: &buf), + address: FfiConverterString.read(from: &buf), + recipient: FfiConverterString.read(from: &buf), + amount: FfiConverterUInt64.read(from: &buf), + estimatedReceived: FfiConverterUInt64.read(from: &buf), + minUsdCents: FfiConverterOptionString.read(from: &buf), + maxUsdCents: FfiConverterOptionString.read(from: &buf), + slippageBps: FfiConverterUInt32.read(from: &buf), + uri: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: UsdtDepositAddress, into buf: inout [UInt8]) { + FfiConverterTypeUsdtDepositNetwork.write(value.network, into: &buf) + FfiConverterString.write(value.address, into: &buf) + FfiConverterString.write(value.recipient, into: &buf) + FfiConverterUInt64.write(value.amount, into: &buf) + FfiConverterUInt64.write(value.estimatedReceived, into: &buf) + FfiConverterOptionString.write(value.minUsdCents, into: &buf) + FfiConverterOptionString.write(value.maxUsdCents, into: &buf) + FfiConverterUInt32.write(value.slippageBps, into: &buf) + FfiConverterString.write(value.uri, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtDepositAddress_lift(_ buf: RustBuffer) throws -> UsdtDepositAddress { + return try FfiConverterTypeUsdtDepositAddress.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtDepositAddress_lower(_ value: UsdtDepositAddress) -> RustBuffer { + return FfiConverterTypeUsdtDepositAddress.lower(value) +} + + +public struct UsdtDepositDetail { + public var deposit: UsdtDeposit + public var order: UsdtDepositOrder? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(deposit: UsdtDeposit, order: UsdtDepositOrder?) { + self.deposit = deposit + self.order = order + } +} + +#if compiler(>=6) +extension UsdtDepositDetail: Sendable {} +#endif + + +extension UsdtDepositDetail: Equatable, Hashable { + public static func ==(lhs: UsdtDepositDetail, rhs: UsdtDepositDetail) -> Bool { + if lhs.deposit != rhs.deposit { + return false + } + if lhs.order != rhs.order { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(deposit) + hasher.combine(order) + } +} + +extension UsdtDepositDetail: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtDepositDetail: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtDepositDetail { + return + try UsdtDepositDetail( + deposit: FfiConverterTypeUsdtDeposit.read(from: &buf), + order: FfiConverterOptionTypeUsdtDepositOrder.read(from: &buf) + ) + } + + public static func write(_ value: UsdtDepositDetail, into buf: inout [UInt8]) { + FfiConverterTypeUsdtDeposit.write(value.deposit, into: &buf) + FfiConverterOptionTypeUsdtDepositOrder.write(value.order, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtDepositDetail_lift(_ buf: RustBuffer) throws -> UsdtDepositDetail { + return try FfiConverterTypeUsdtDepositDetail.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtDepositDetail_lower(_ value: UsdtDepositDetail) -> RustBuffer { + return FfiConverterTypeUsdtDepositDetail.lower(value) +} + + +public struct UsdtDepositOrder { + public var status: String + public var amountIn: UInt64? + public var amountOut: UInt64? + public var destinationTx: String? + public var refundTx: String? + public var code: String? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(status: String, amountIn: UInt64?, amountOut: UInt64?, destinationTx: String?, refundTx: String?, code: String?) { + self.status = status + self.amountIn = amountIn + self.amountOut = amountOut + self.destinationTx = destinationTx + self.refundTx = refundTx + self.code = code + } +} + +#if compiler(>=6) +extension UsdtDepositOrder: Sendable {} #endif -extension TxOutput: Equatable, Hashable { - public static func ==(lhs: TxOutput, rhs: TxOutput) -> Bool { - if lhs.scriptpubkey != rhs.scriptpubkey { +extension UsdtDepositOrder: Equatable, Hashable { + public static func ==(lhs: UsdtDepositOrder, rhs: UsdtDepositOrder) -> Bool { + if lhs.status != rhs.status { return false } - if lhs.scriptpubkeyType != rhs.scriptpubkeyType { + if lhs.amountIn != rhs.amountIn { return false } - if lhs.scriptpubkeyAddress != rhs.scriptpubkeyAddress { + if lhs.amountOut != rhs.amountOut { return false } - if lhs.value != rhs.value { + if lhs.destinationTx != rhs.destinationTx { return false } - if lhs.n != rhs.n { + if lhs.refundTx != rhs.refundTx { + return false + } + if lhs.code != rhs.code { return false } return true } public func hash(into hasher: inout Hasher) { - hasher.combine(scriptpubkey) - hasher.combine(scriptpubkeyType) - hasher.combine(scriptpubkeyAddress) - hasher.combine(value) - hasher.combine(n) + hasher.combine(status) + hasher.combine(amountIn) + hasher.combine(amountOut) + hasher.combine(destinationTx) + hasher.combine(refundTx) + hasher.combine(code) } } -extension TxOutput: Codable {} +extension UsdtDepositOrder: Codable {} #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeTxOutput: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TxOutput { +public struct FfiConverterTypeUsdtDepositOrder: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtDepositOrder { return - try TxOutput( - scriptpubkey: FfiConverterString.read(from: &buf), - scriptpubkeyType: FfiConverterOptionString.read(from: &buf), - scriptpubkeyAddress: FfiConverterOptionString.read(from: &buf), - value: FfiConverterInt64.read(from: &buf), - n: FfiConverterUInt32.read(from: &buf) + try UsdtDepositOrder( + status: FfiConverterString.read(from: &buf), + amountIn: FfiConverterOptionUInt64.read(from: &buf), + amountOut: FfiConverterOptionUInt64.read(from: &buf), + destinationTx: FfiConverterOptionString.read(from: &buf), + refundTx: FfiConverterOptionString.read(from: &buf), + code: FfiConverterOptionString.read(from: &buf) ) } - public static func write(_ value: TxOutput, into buf: inout [UInt8]) { - FfiConverterString.write(value.scriptpubkey, into: &buf) - FfiConverterOptionString.write(value.scriptpubkeyType, into: &buf) - FfiConverterOptionString.write(value.scriptpubkeyAddress, into: &buf) - FfiConverterInt64.write(value.value, into: &buf) - FfiConverterUInt32.write(value.n, into: &buf) + public static func write(_ value: UsdtDepositOrder, into buf: inout [UInt8]) { + FfiConverterString.write(value.status, into: &buf) + FfiConverterOptionUInt64.write(value.amountIn, into: &buf) + FfiConverterOptionUInt64.write(value.amountOut, into: &buf) + FfiConverterOptionString.write(value.destinationTx, into: &buf) + FfiConverterOptionString.write(value.refundTx, into: &buf) + FfiConverterOptionString.write(value.code, into: &buf) } } @@ -15790,100 +16532,71 @@ public struct FfiConverterTypeTxOutput: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeTxOutput_lift(_ buf: RustBuffer) throws -> TxOutput { - return try FfiConverterTypeTxOutput.lift(buf) +public func FfiConverterTypeUsdtDepositOrder_lift(_ buf: RustBuffer) throws -> UsdtDepositOrder { + return try FfiConverterTypeUsdtDepositOrder.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeTxOutput_lower(_ value: TxOutput) -> RustBuffer { - return FfiConverterTypeTxOutput.lower(value) +public func FfiConverterTypeUsdtDepositOrder_lower(_ value: UsdtDepositOrder) -> RustBuffer { + return FfiConverterTypeUsdtDepositOrder.lower(value) } -/** - * Current state after accepting a scanned UR frame. - */ -public struct UrDecoderStatus { - /** - * Estimated completion from 0.0 through 1.0. - */ - public var progress: Double - /** - * Fountain source-fragment count, or 1 for a single-part UR. - */ - public var fragmentCount: UInt32 - /** - * Present once the complete message has been decoded. - */ - public var payload: UrPayload? +public struct UsdtDepositPage { + public var deposits: [UsdtDeposit] + public var nextOffset: UInt32? // Default memberwise initializers are never public by default, so we // declare one manually. - public init( - /** - * Estimated completion from 0.0 through 1.0. - */progress: Double, - /** - * Fountain source-fragment count, or 1 for a single-part UR. - */fragmentCount: UInt32, - /** - * Present once the complete message has been decoded. - */payload: UrPayload?) { - self.progress = progress - self.fragmentCount = fragmentCount - self.payload = payload + public init(deposits: [UsdtDeposit], nextOffset: UInt32?) { + self.deposits = deposits + self.nextOffset = nextOffset } } #if compiler(>=6) -extension UrDecoderStatus: Sendable {} +extension UsdtDepositPage: Sendable {} #endif -extension UrDecoderStatus: Equatable, Hashable { - public static func ==(lhs: UrDecoderStatus, rhs: UrDecoderStatus) -> Bool { - if lhs.progress != rhs.progress { +extension UsdtDepositPage: Equatable, Hashable { + public static func ==(lhs: UsdtDepositPage, rhs: UsdtDepositPage) -> Bool { + if lhs.deposits != rhs.deposits { return false } - if lhs.fragmentCount != rhs.fragmentCount { - return false - } - if lhs.payload != rhs.payload { + if lhs.nextOffset != rhs.nextOffset { return false } return true } public func hash(into hasher: inout Hasher) { - hasher.combine(progress) - hasher.combine(fragmentCount) - hasher.combine(payload) + hasher.combine(deposits) + hasher.combine(nextOffset) } } -extension UrDecoderStatus: Codable {} +extension UsdtDepositPage: Codable {} #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeUrDecoderStatus: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UrDecoderStatus { +public struct FfiConverterTypeUsdtDepositPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtDepositPage { return - try UrDecoderStatus( - progress: FfiConverterDouble.read(from: &buf), - fragmentCount: FfiConverterUInt32.read(from: &buf), - payload: FfiConverterOptionTypeUrPayload.read(from: &buf) + try UsdtDepositPage( + deposits: FfiConverterSequenceTypeUsdtDeposit.read(from: &buf), + nextOffset: FfiConverterOptionUInt32.read(from: &buf) ) } - public static func write(_ value: UrDecoderStatus, into buf: inout [UInt8]) { - FfiConverterDouble.write(value.progress, into: &buf) - FfiConverterUInt32.write(value.fragmentCount, into: &buf) - FfiConverterOptionTypeUrPayload.write(value.payload, into: &buf) + public static func write(_ value: UsdtDepositPage, into buf: inout [UInt8]) { + FfiConverterSequenceTypeUsdtDeposit.write(value.deposits, into: &buf) + FfiConverterOptionUInt32.write(value.nextOffset, into: &buf) } } @@ -15891,15 +16604,15 @@ public struct FfiConverterTypeUrDecoderStatus: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeUrDecoderStatus_lift(_ buf: RustBuffer) throws -> UrDecoderStatus { - return try FfiConverterTypeUrDecoderStatus.lift(buf) +public func FfiConverterTypeUsdtDepositPage_lift(_ buf: RustBuffer) throws -> UsdtDepositPage { + return try FfiConverterTypeUsdtDepositPage.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeUrDecoderStatus_lower(_ value: UrDecoderStatus) -> RustBuffer { - return FfiConverterTypeUrDecoderStatus.lower(value) +public func FfiConverterTypeUsdtDepositPage_lower(_ value: UsdtDepositPage) -> RustBuffer { + return FfiConverterTypeUsdtDepositPage.lower(value) } @@ -23371,6 +24084,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 UsdtDepositNetwork { + + case ethereum + case tron +} + + +#if compiler(>=6) +extension UsdtDepositNetwork: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtDepositNetwork: FfiConverterRustBuffer { + typealias SwiftType = UsdtDepositNetwork + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtDepositNetwork { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .ethereum + + case 2: return .tron + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: UsdtDepositNetwork, into buf: inout [UInt8]) { + switch value { + + + case .ethereum: + writeInt(&buf, Int32(1)) + + + case .tron: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtDepositNetwork_lift(_ buf: RustBuffer) throws -> UsdtDepositNetwork { + return try FfiConverterTypeUsdtDepositNetwork.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtDepositNetwork_lower(_ value: UsdtDepositNetwork) -> RustBuffer { + return FfiConverterTypeUsdtDepositNetwork.lower(value) +} + + +extension UsdtDepositNetwork: Equatable, Hashable {} + +extension UsdtDepositNetwork: Codable {} + + + + + + // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. @@ -23473,11 +24258,13 @@ public enum UsdtError: Swift.Error { case InvalidAddress case WrongNetwork case InvalidCredentials + case ClockSkew case UnsupportedDelegation case InsufficientBalance case QuoteExpired case PendingTransfer case UnsupportedRoute + case DepositNeedsAttention case NotConfigured case NetworkUnavailable case RateLimited @@ -23507,22 +24294,24 @@ public struct FfiConverterTypeUsdtError: FfiConverterRustBuffer { case 2: return .InvalidAddress case 3: return .WrongNetwork case 4: return .InvalidCredentials - case 5: return .UnsupportedDelegation - case 6: return .InsufficientBalance - case 7: return .QuoteExpired - case 8: return .PendingTransfer - case 9: return .UnsupportedRoute - case 10: return .NotConfigured - case 11: return .NetworkUnavailable - case 12: return .RateLimited - case 13: return .LogRangeTooLarge - case 14: return .TransactionRejected( + case 5: return .ClockSkew + case 6: return .UnsupportedDelegation + case 7: return .InsufficientBalance + case 8: return .QuoteExpired + case 9: return .PendingTransfer + case 10: return .UnsupportedRoute + case 11: return .DepositNeedsAttention + case 12: return .NotConfigured + case 13: return .NetworkUnavailable + case 14: return .RateLimited + case 15: return .LogRangeTooLarge + case 16: return .TransactionRejected( reason: try FfiConverterString.read(from: &buf) ) - case 15: return .Storage( + case 17: return .Storage( reason: try FfiConverterString.read(from: &buf) ) - case 16: return .InvalidResponse + case 18: return .InvalidResponse default: throw UniffiInternalError.unexpectedEnumCase } @@ -23551,54 +24340,62 @@ public struct FfiConverterTypeUsdtError: FfiConverterRustBuffer { writeInt(&buf, Int32(4)) - case .UnsupportedDelegation: + case .ClockSkew: writeInt(&buf, Int32(5)) - case .InsufficientBalance: + case .UnsupportedDelegation: writeInt(&buf, Int32(6)) - case .QuoteExpired: + case .InsufficientBalance: writeInt(&buf, Int32(7)) - case .PendingTransfer: + case .QuoteExpired: writeInt(&buf, Int32(8)) - case .UnsupportedRoute: + case .PendingTransfer: writeInt(&buf, Int32(9)) - case .NotConfigured: + case .UnsupportedRoute: writeInt(&buf, Int32(10)) - case .NetworkUnavailable: + case .DepositNeedsAttention: writeInt(&buf, Int32(11)) - case .RateLimited: + case .NotConfigured: writeInt(&buf, Int32(12)) - case .LogRangeTooLarge: + case .NetworkUnavailable: writeInt(&buf, Int32(13)) - case let .TransactionRejected(reason): + case .RateLimited: writeInt(&buf, Int32(14)) + + + case .LogRangeTooLarge: + writeInt(&buf, Int32(15)) + + + case let .TransactionRejected(reason): + writeInt(&buf, Int32(16)) FfiConverterString.write(reason, into: &buf) case let .Storage(reason): - writeInt(&buf, Int32(15)) + writeInt(&buf, Int32(17)) FfiConverterString.write(reason, into: &buf) case .InvalidResponse: - writeInt(&buf, Int32(16)) + writeInt(&buf, Int32(18)) } } @@ -24860,6 +25657,30 @@ fileprivate struct FfiConverterOptionTypeTrezorFeatures: FfiConverterRustBuffer } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeUsdtDepositOrder: FfiConverterRustBuffer { + typealias SwiftType = UsdtDepositOrder? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeUsdtDepositOrder.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 FfiConverterTypeUsdtDepositOrder.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -26215,6 +27036,31 @@ fileprivate struct FfiConverterSequenceTypeTxOutput: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeUsdtDeposit: FfiConverterRustBuffer { + typealias SwiftType = [UsdtDeposit] + + public static func write(_ value: [UsdtDeposit], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeUsdtDeposit.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [UsdtDeposit] { + let len: Int32 = try readInt(&buf) + var seq = [UsdtDeposit]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeUsdtDeposit.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -26365,6 +27211,31 @@ fileprivate struct FfiConverterSequenceTypeHardwareWalletTransport: FfiConverter } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeUsdtDepositNetwork: FfiConverterRustBuffer { + typealias SwiftType = [UsdtDepositNetwork] + + public static func write(_ value: [UsdtDepositNetwork], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeUsdtDepositNetwork.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [UsdtDepositNetwork] { + let len: Int32 = try readInt(&buf) + var seq = [UsdtDepositNetwork]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeUsdtDepositNetwork.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -29796,6 +30667,21 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_urdecoder_reset() != 6027) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_method_usdtdepositclient_detail() != 63177) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_method_usdtdepositclient_history() != 10976) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_method_usdtdepositclient_networks() != 27890) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_method_usdtdepositclient_receive() != 27375) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_method_usdtdepositclient_request_refund() != 10045) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_method_usdtwallet_balance() != 12328) { return InitializationResult.apiChecksumMismatch } @@ -29823,6 +30709,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_constructor_urdecoder_new() != 23014) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_constructor_usdtdepositclient_new() != 44626) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_constructor_usdtwallet_new() != 63633) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index fe19f0f..24fb403 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -660,6 +660,46 @@ RustBuffer uniffi_bitkitcore_fn_method_urdecoder_receive(void*_Nonnull ptr, Rust void uniffi_bitkitcore_fn_method_urdecoder_reset(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CLONE_USDTDEPOSITCLIENT +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CLONE_USDTDEPOSITCLIENT +void*_Nonnull uniffi_bitkitcore_fn_clone_usdtdepositclient(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FREE_USDTDEPOSITCLIENT +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FREE_USDTDEPOSITCLIENT +void uniffi_bitkitcore_fn_free_usdtdepositclient(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CONSTRUCTOR_USDTDEPOSITCLIENT_NEW +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CONSTRUCTOR_USDTDEPOSITCLIENT_NEW +void*_Nonnull uniffi_bitkitcore_fn_constructor_usdtdepositclient_new(RustBuffer address, RustBuffer service_url, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTDEPOSITCLIENT_DETAIL +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTDEPOSITCLIENT_DETAIL +uint64_t uniffi_bitkitcore_fn_method_usdtdepositclient_detail(void*_Nonnull ptr, RustBuffer deposit_id, uint32_t offset, RustBuffer mnemonic, RustBuffer passphrase +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTDEPOSITCLIENT_HISTORY +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTDEPOSITCLIENT_HISTORY +uint64_t uniffi_bitkitcore_fn_method_usdtdepositclient_history(void*_Nonnull ptr, uint32_t offset, RustBuffer mnemonic, RustBuffer passphrase +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTDEPOSITCLIENT_NETWORKS +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTDEPOSITCLIENT_NETWORKS +uint64_t uniffi_bitkitcore_fn_method_usdtdepositclient_networks(void*_Nonnull ptr +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTDEPOSITCLIENT_RECEIVE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTDEPOSITCLIENT_RECEIVE +uint64_t uniffi_bitkitcore_fn_method_usdtdepositclient_receive(void*_Nonnull ptr, RustBuffer network, uint64_t amount, RustBuffer mnemonic, RustBuffer passphrase +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTDEPOSITCLIENT_REQUEST_REFUND +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTDEPOSITCLIENT_REQUEST_REFUND +uint64_t uniffi_bitkitcore_fn_method_usdtdepositclient_request_refund(void*_Nonnull ptr, RustBuffer deposit_id, uint32_t offset, RustBuffer refund_address, RustBuffer network, RustBuffer mnemonic, RustBuffer passphrase +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CLONE_USDTWALLET #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CLONE_USDTWALLET void*_Nonnull uniffi_bitkitcore_fn_clone_usdtwallet(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status @@ -3461,6 +3501,36 @@ uint16_t uniffi_bitkitcore_checksum_method_urdecoder_receive(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_URDECODER_RESET uint16_t uniffi_bitkitcore_checksum_method_urdecoder_reset(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTDEPOSITCLIENT_DETAIL +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTDEPOSITCLIENT_DETAIL +uint16_t uniffi_bitkitcore_checksum_method_usdtdepositclient_detail(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTDEPOSITCLIENT_HISTORY +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTDEPOSITCLIENT_HISTORY +uint16_t uniffi_bitkitcore_checksum_method_usdtdepositclient_history(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTDEPOSITCLIENT_NETWORKS +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTDEPOSITCLIENT_NETWORKS +uint16_t uniffi_bitkitcore_checksum_method_usdtdepositclient_networks(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTDEPOSITCLIENT_RECEIVE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTDEPOSITCLIENT_RECEIVE +uint16_t uniffi_bitkitcore_checksum_method_usdtdepositclient_receive(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTDEPOSITCLIENT_REQUEST_REFUND +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTDEPOSITCLIENT_REQUEST_REFUND +uint16_t uniffi_bitkitcore_checksum_method_usdtdepositclient_request_refund(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_BALANCE @@ -3515,6 +3585,12 @@ uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_sync_history(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_CONSTRUCTOR_URDECODER_NEW uint16_t uniffi_bitkitcore_checksum_constructor_urdecoder_new(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_CONSTRUCTOR_USDTDEPOSITCLIENT_NEW +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_CONSTRUCTOR_USDTDEPOSITCLIENT_NEW +uint16_t uniffi_bitkitcore_checksum_constructor_usdtdepositclient_new(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_CONSTRUCTOR_USDTWALLET_NEW diff --git a/src/lib.rs b/src/lib.rs index 985a20e..9266c44 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, - UsdtDestination, UsdtError, UsdtPaymentRequest, UsdtQuote, UsdtTransfer, UsdtTransferStatus, - UsdtWallet, + usdt_address, usdt_format_amount, usdt_parse_amount, usdt_parse_payment_request, UsdtDeposit, + UsdtDepositAddress, UsdtDepositClient, UsdtDepositDetail, UsdtDepositNetwork, UsdtDepositOrder, + UsdtDepositPage, UsdtDestination, UsdtError, UsdtPaymentRequest, UsdtQuote, UsdtTransfer, + UsdtTransferStatus, UsdtWallet, }; use bip39::Mnemonic; diff --git a/src/modules/usdt/README.md b/src/modules/usdt/README.md index 79017f4..9e9e5b1 100644 --- a/src/modules/usdt/README.md +++ b/src/modules/usdt/README.md @@ -40,6 +40,8 @@ Storage is wallet-specific and owned by the `UsdtWallet` object. Drop it before 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. +`UsdtDepositClient` signs Orchestra deposit registration, history, detail and explicit refund requests for the derived account. It uses a separate optional service endpoint; estimates do not imply delivery. A clock-skew error requires correcting the device clock. Source-network fees are paid by the sender. Partner provisioning, delivered deposits and refund acceptance are separate release checks. + 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. 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. LayerZero status must match the operation GUID/pathway before confirmation; blocked delivery remains visible and never triggers an automatic paid retry. RPC providers see queried addresses; LayerZero Scan sees bridge transaction hashes. diff --git a/src/modules/usdt/deposits.rs b/src/modules/usdt/deposits.rs new file mode 100644 index 0000000..12c4114 --- /dev/null +++ b/src/modules/usdt/deposits.rs @@ -0,0 +1,359 @@ +use super::{ + keys::{derive_key, key_address, parse_address}, + rpc::{bounded_json, endpoint_client}, + user_operation::sign_hash, + UsdtError, +}; +use alloy_primitives::{eip191_hash_message, Address}; +use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize}; +use serde_json::{json, Value}; +use std::{sync::Arc, time::Duration}; +use zeroize::Zeroizing; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +#[serde(rename_all = "lowercase")] +pub enum UsdtDepositNetwork { + Ethereum, + Tron, +} + +#[derive(Clone, Debug, Deserialize, uniffi::Record)] +pub struct UsdtDepositAddress { + pub network: UsdtDepositNetwork, + pub address: String, + pub recipient: String, + #[serde(deserialize_with = "number")] + pub amount: u64, + #[serde(deserialize_with = "number")] + pub estimated_received: u64, + pub min_usd_cents: Option, + pub max_usd_cents: Option, + pub slippage_bps: u32, + #[serde(default)] + pub uri: String, +} + +#[derive(Clone, Debug, Deserialize, uniffi::Record)] +pub struct UsdtDeposit { + pub id: String, + pub network: String, + pub asset: String, + #[serde(deserialize_with = "optional_number")] + pub amount: Option, + pub source_tx: String, + pub status: String, + pub code: Option, + pub refund_tx: Option, +} + +#[derive(Clone, Debug, Deserialize, uniffi::Record)] +pub struct UsdtDepositPage { + pub deposits: Vec, + pub next_offset: Option, +} + +#[derive(Clone, Debug, Deserialize, uniffi::Record)] +pub struct UsdtDepositOrder { + pub status: String, + #[serde(deserialize_with = "optional_number")] + pub amount_in: Option, + #[serde(deserialize_with = "optional_number")] + pub amount_out: Option, + pub destination_tx: Option, + pub refund_tx: Option, + pub code: Option, +} + +#[derive(Clone, Debug, Deserialize, uniffi::Record)] +pub struct UsdtDepositDetail { + pub deposit: UsdtDeposit, + pub order: Option, +} + +#[derive(uniffi::Object)] +pub struct UsdtDepositClient { + address: Address, + client: reqwest::Client, + url: String, +} + +#[uniffi::export(async_runtime = "tokio")] +impl UsdtDepositClient { + #[uniffi::constructor] + pub fn new(address: String, service_url: String) -> Result, UsdtError> { + let client = endpoint_client(&service_url, Duration::from_secs(22))?; + Ok(Arc::new(Self { + address: parse_address(&address)?, + client, + url: service_url, + })) + } + + pub async fn networks(&self) -> Result, UsdtError> { + #[derive(Deserialize)] + struct Networks { + networks: Vec, + } + Ok(self + .response::(self.client.get(&self.url)) + .await? + .networks) + } + + pub async fn receive( + &self, + network: UsdtDepositNetwork, + amount: u64, + mnemonic: String, + passphrase: Option, + ) -> Result { + let mnemonic = Zeroizing::new(mnemonic); + let passphrase = passphrase.map(Zeroizing::new); + if amount == 0 { + return Err(UsdtError::InvalidAmount); + } + let mut result: UsdtDepositAddress = self + .call( + json!({"action":"receive", "network":network, + "amount":amount.to_string()}), + mnemonic, + passphrase, + ) + .await?; + if result.network != network + || parse_address(&result.recipient)? != self.address + || result.amount != amount + || result.estimated_received == 0 + || result.slippage_bps != 50 + { + return Err(UsdtError::InvalidResponse); + } + validate_source_address(&result.address, network)?; + result.uri = match network { + UsdtDepositNetwork::Ethereum => format!( + "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@1/transfer?address={}", + result.address + ), + UsdtDepositNetwork::Tron => result.address.clone(), + }; + Ok(result) + } + + pub async fn history( + &self, + offset: u32, + mnemonic: String, + passphrase: Option, + ) -> Result { + self.call( + json!({"action":"history","offset":offset}), + mnemonic.into(), + passphrase.map(Into::into), + ) + .await + } + + pub async fn detail( + &self, + deposit_id: String, + offset: u32, + mnemonic: String, + passphrase: Option, + ) -> Result { + let result: UsdtDepositDetail = self + .call( + json!({"action":"detail","depositId":deposit_id,"offset":offset}), + mnemonic.into(), + passphrase.map(Into::into), + ) + .await?; + if result.deposit.id != deposit_id { + return Err(UsdtError::InvalidResponse); + } + Ok(result) + } + + pub async fn request_refund( + &self, + deposit_id: String, + offset: u32, + refund_address: String, + network: UsdtDepositNetwork, + mnemonic: String, + passphrase: Option, + ) -> Result<(), UsdtError> { + let mnemonic = Zeroizing::new(mnemonic); + let passphrase = passphrase.map(Zeroizing::new); + validate_source_address(refund_address.trim(), network)?; + let result: Value = self + .call( + json!({"action":"refund","depositId":deposit_id,"offset":offset, + "refundAddress":refund_address.trim()}), + mnemonic, + passphrase, + ) + .await?; + if result["status"] != "refund_requested" { + return Err(UsdtError::InvalidResponse); + } + Ok(()) + } +} + +impl UsdtDepositClient { + async fn call( + &self, + payload: Value, + mnemonic: Zeroizing, + passphrase: Option>, + ) -> Result { + let signed = self.authorize(payload, mnemonic, passphrase, super::wallet::now())?; + self.response(self.client.post(&self.url).json(&signed)) + .await + } + + fn authorize( + &self, + payload: Value, + mnemonic: Zeroizing, + passphrase: Option>, + timestamp: u64, + ) -> Result { + let key = derive_key(mnemonic, passphrase)?; + if key_address(&key) != self.address { + return Err(UsdtError::InvalidCredentials); + } + let request = + json!({"owner":self.address.to_checksum(None),"timestamp":timestamp,"payload":payload}) + .to_string(); + let message = format!("Bitkit USDT deposits v1\n{request}"); + let signature = sign_hash(eip191_hash_message(message), &key)?; + drop(key); + Ok(json!({"request":request,"signature":signature})) + } + + 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, 262144, UsdtError::InvalidResponse).await; + if !status.is_success() { + let error = value.unwrap_or_default(); + return Err(match error["error"].as_str() { + Some("not_configured") => UsdtError::NotConfigured, + Some("invalid_authorization") => UsdtError::InvalidCredentials, + Some("clock_skew") => UsdtError::ClockSkew, + Some("amount_too_small" | "amount_too_large" | "amount_exceeds_liquidity") => { + UsdtError::InvalidAmount + } + Some("invalid_refund_address") => UsdtError::InvalidAddress, + Some("route_unavailable") => UsdtError::UnsupportedRoute, + Some( + "refund_not_available" + | "instruction_conflict" + | "not_found" + | "operator_required" + | "standing_tron_refund_requires_operator", + ) => UsdtError::DepositNeedsAttention, + _ => UsdtError::NetworkUnavailable, + }); + } + serde_json::from_value(value?).map_err(Into::into) + } +} + +fn validate_source_address(value: &str, network: UsdtDepositNetwork) -> Result<(), UsdtError> { + match network { + UsdtDepositNetwork::Ethereum => { + parse_address(value)?; + } + UsdtDepositNetwork::Tron => { + let payload = + bitcoin::base58::decode_check(value).map_err(|_| UsdtError::InvalidAddress)?; + if payload.len() != 21 || payload[0] != 0x41 { + return Err(UsdtError::InvalidAddress); + } + } + } + Ok(()) +} + +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> { + Option::::deserialize(deserializer)? + .map(|v| v.parse().map_err(serde::de::Error::custom)) + .transpose() +} + +#[cfg(test)] +mod tests { + use super::*; + const PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + #[test] + fn deposit_authorization_matches_ethers_and_rejects_another_wallet() { + let vector: Value = + serde_json::from_str(include_str!("fixtures/deposit-signature.json")).unwrap(); + let request: Value = serde_json::from_str(vector["request"].as_str().unwrap()).unwrap(); + let client = UsdtDepositClient::new( + request["owner"].as_str().unwrap().into(), + "https://example.com/v1/usdt/deposits".into(), + ) + .unwrap(); + assert_eq!( + client + .authorize( + request["payload"].clone(), + PHRASE.to_owned().into(), + None, + 1_800_000_000 + ) + .unwrap(), + vector + ); + assert!(matches!( + client.authorize( + request["payload"].clone(), + PHRASE.to_owned().into(), + Some("other".to_owned().into()), + 1_800_000_000 + ), + Err(UsdtError::InvalidCredentials) + )); + } + + #[test] + fn deposit_addresses_and_transport_reject_wrong_networks() { + let tron = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + assert!(validate_source_address(tron, UsdtDepositNetwork::Tron).is_ok()); + assert!(validate_source_address(tron, UsdtDepositNetwork::Ethereum).is_err()); + assert!(validate_source_address( + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6s", + UsdtDepositNetwork::Tron + ) + .is_err()); + for url in [ + "http://example.com", + "https://user:password@example.com", + "https://example.com?key=secret", + ] { + assert!(UsdtDepositClient::new( + "0x1111111111111111111111111111111111111111".into(), + url.into() + ) + .is_err()); + } + } +} diff --git a/src/modules/usdt/errors.rs b/src/modules/usdt/errors.rs index 059f2c2..8b15cc2 100644 --- a/src/modules/usdt/errors.rs +++ b/src/modules/usdt/errors.rs @@ -10,6 +10,8 @@ pub enum UsdtError { WrongNetwork, #[error("Wallet credentials do not match this USDT account")] InvalidCredentials, + #[error("Set your device date and time automatically, then try again")] + ClockSkew, #[error("This account uses another wallet's smart account. Restore its delegation before sending with Bitkit")] UnsupportedDelegation, #[error("The USDT balance does not cover the amount and maximum fee")] @@ -20,6 +22,8 @@ pub enum UsdtError { PendingTransfer, #[error("The selected USDT0 route is unavailable")] UnsupportedRoute, + #[error("This deposit needs provider assistance. Check its recovery status")] + DepositNeedsAttention, #[error("USDT payments are not configured for this app build")] NotConfigured, #[error("The network could not be reached. Try again")] diff --git a/src/modules/usdt/fixtures/deposit-signature.json b/src/modules/usdt/fixtures/deposit-signature.json new file mode 100644 index 0000000..66fa60b --- /dev/null +++ b/src/modules/usdt/fixtures/deposit-signature.json @@ -0,0 +1,4 @@ +{ + "request": "{\"owner\":\"0x9858EfFD232B4033E47d90003D41EC34EcaEda94\",\"payload\":{\"action\":\"receive\",\"amount\":\"100000000\",\"network\":\"ethereum\"},\"timestamp\":1800000000}", + "signature": "0xe4a2d8340e92339d4f0627a6bf6a9bdaaae55498726ed75155a1d891761cd8cc23d6555526f69ce111c584dad43fbd974669dd9778f69c75c1b869065e05a0381b" +} diff --git a/src/modules/usdt/mod.rs b/src/modules/usdt/mod.rs index 9a56f06..f324cbf 100644 --- a/src/modules/usdt/mod.rs +++ b/src/modules/usdt/mod.rs @@ -1,5 +1,6 @@ mod account; mod amount; +mod deposits; mod errors; mod history; mod keys; @@ -13,6 +14,7 @@ mod user_operation; mod wallet; pub use amount::{usdt_format_amount, usdt_parse_amount}; +pub use deposits::*; pub use errors::UsdtError; pub use keys::usdt_address; pub use payment_request::usdt_parse_payment_request; diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index 31f0710..b614ef7 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -1600,6 +1600,31 @@ async fn sync_history_to_tip(wallet: &UsdtWallet) { .expect("History must make progress within successive bounded scans"); } +#[tokio::test] +async fn deposit_http_throttling_is_distinct_from_network_failure() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 4096]; + assert!(socket.read(&mut request).await.unwrap() > 0); + socket + .write_all( + b"HTTP/1.1 429 Too Many Requests\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + }); + let client = + UsdtDepositClient::new(usdt_address(TEST_PHRASE.into(), None).unwrap(), url).unwrap(); + assert!(matches!( + client.networks().await, + Err(UsdtError::RateLimited) + )); + server.await.unwrap(); +} + #[tokio::test] async fn invalid_chain_data_and_stored_json_have_distinct_errors() { let chain = MockChain::start().await; From af7bcd60caf453bcc995affb496b9a929beb5090 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 24 Sep 2026 22:31:31 +0300 Subject: [PATCH 2/6] fix: harden USDT bridging and deposit contracts --- AGENTS.md | 4 +- Package.swift | 2 +- bindings/ios/bitkitcore.swift | 46 +++- src/modules/usdt/README.md | 10 +- src/modules/usdt/deposits.rs | 225 +++++++++++++-- src/modules/usdt/errors.rs | 6 + src/modules/usdt/history.rs | 41 +-- src/modules/usdt/paymaster.rs | 38 +-- src/modules/usdt/rpc.rs | 34 ++- src/modules/usdt/store.rs | 48 +++- src/modules/usdt/tests.rs | 499 +++++++++++++++++++++++++++++++++- src/modules/usdt/types.rs | 16 ++ src/modules/usdt/wallet.rs | 196 ++++++++++--- tests/usdt-fork/provider.mjs | 10 + 14 files changed, 1031 insertions(+), 144 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c182e83..743a64e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Android bindings are built and published by `.github/workflows/gradle-publish.ym ```bash cargo test # All tests -cargo test modules:: # Single module (scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky) +cargo test modules:: # Single module (scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky, usdt) ``` ## Lint & Format @@ -35,7 +35,7 @@ Android bindings use ktlint via Gradle plugin (`org.jlleitschuh.gradle.ktlint`), ## Architecture - `src/lib.rs` — UniFFI exports and module re-exports -- `src/modules/`: core modules: scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky +- `src/modules/`: core modules: scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky, usdt - `bindings/` — Platform-specific binding outputs (ios/, android/, python/) - `build.sh`, `build_ios.sh`, `build_android.sh`, `build_python.sh` — Build scripts diff --git a/Package.swift b/Package.swift index 2ea8a85..7891a15 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription import Foundation let tag = "v0.6.0" -let checksum = "eed89e4a6d060e064bc91f8a66115ffff90fff6edd659c42024077c49a8982b2" +let checksum = "5e472658a387fd19a4fdcc82a1a6e88e4afd3caf90cfd0ef49eb962e06572c15" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index c07f0bd..24aeca6 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -24265,6 +24265,9 @@ public enum UsdtError: Swift.Error { case PendingTransfer case UnsupportedRoute case DepositNeedsAttention + case DepositNotFound + case DepositAuthorizationRejected + case DepositAmountOutOfRange case NotConfigured case NetworkUnavailable case RateLimited @@ -24301,17 +24304,20 @@ public struct FfiConverterTypeUsdtError: FfiConverterRustBuffer { case 9: return .PendingTransfer case 10: return .UnsupportedRoute case 11: return .DepositNeedsAttention - case 12: return .NotConfigured - case 13: return .NetworkUnavailable - case 14: return .RateLimited - case 15: return .LogRangeTooLarge - case 16: return .TransactionRejected( + case 12: return .DepositNotFound + case 13: return .DepositAuthorizationRejected + case 14: return .DepositAmountOutOfRange + case 15: return .NotConfigured + case 16: return .NetworkUnavailable + case 17: return .RateLimited + case 18: return .LogRangeTooLarge + case 19: return .TransactionRejected( reason: try FfiConverterString.read(from: &buf) ) - case 17: return .Storage( + case 20: return .Storage( reason: try FfiConverterString.read(from: &buf) ) - case 18: return .InvalidResponse + case 21: return .InvalidResponse default: throw UniffiInternalError.unexpectedEnumCase } @@ -24368,34 +24374,46 @@ public struct FfiConverterTypeUsdtError: FfiConverterRustBuffer { writeInt(&buf, Int32(11)) - case .NotConfigured: + case .DepositNotFound: writeInt(&buf, Int32(12)) - case .NetworkUnavailable: + case .DepositAuthorizationRejected: writeInt(&buf, Int32(13)) - case .RateLimited: + case .DepositAmountOutOfRange: writeInt(&buf, Int32(14)) - case .LogRangeTooLarge: + case .NotConfigured: writeInt(&buf, Int32(15)) - case let .TransactionRejected(reason): + case .NetworkUnavailable: writeInt(&buf, Int32(16)) + + + case .RateLimited: + writeInt(&buf, Int32(17)) + + + case .LogRangeTooLarge: + writeInt(&buf, Int32(18)) + + + case let .TransactionRejected(reason): + writeInt(&buf, Int32(19)) FfiConverterString.write(reason, into: &buf) case let .Storage(reason): - writeInt(&buf, Int32(17)) + writeInt(&buf, Int32(20)) FfiConverterString.write(reason, into: &buf) case .InvalidResponse: - writeInt(&buf, Int32(18)) + writeInt(&buf, Int32(21)) } } diff --git a/src/modules/usdt/README.md b/src/modules/usdt/README.md index 9e9e5b1..f42611a 100644 --- a/src/modules/usdt/README.md +++ b/src/modules/usdt/README.md @@ -14,7 +14,7 @@ Owned mnemonic/passphrase/seed buffers are zeroized and signing keys are erased ## Quotes and fees -`quote_transfer` takes a raw recipient, positive atomic amount and destination; it never receives signing credentials. Local quotes last at most 120 seconds and newly quoted paymaster terms must expire within 15 minutes. `send` validates the owner, nonces, balance, gas estimates, current gas prices and deadlines before signing the stored plan. Changed terms require a new review; signing cannot raise the approved fee. +`quote_transfer` takes a raw recipient, positive atomic amount and destination; it never receives signing credentials. Local quotes last at most 120 seconds and newly quoted paymaster terms must expire within 15 minutes. `send` validates the owner, nonces, balance, gas estimates, the current slow gas-price recommendation and deadlines before signing the stored plan. Quotes use the fast gas-price recommendation; a modest price increase does not invalidate a quote that still covers the current slow recommendation. Changes beyond the approved bounds require a new review; signing cannot raise the approved fee. The pinned ERC-20 paymaster collects USDT. Its finite approval includes a 5% margin; the displayed maximum fee comes from signed gas limits and paymaster terms, not the allowance. Call/pre-verification estimates receive 10% execution/L1-data headroom; the charged pre-verification margin is included in the maximum. A residual paymaster allowance can remain and is reset to a finite amount on the next payment. @@ -28,7 +28,7 @@ A matching operation event settles the payment. Expired signed paymaster terms a Seed restoration recovers deposits and outgoing activity from genesis, including transfers before delegation and sends through another wallet. Supported direct EntryPoint calls recover payment/fee attribution; unknown wrappers preserve raw token transfers instead of guessing their intent. Failed payments retain attempted amounts but have no delivered amount. -`sync_history` returns `true` when caught up and `false` when more work remains. It uses adaptive log ranges and a 20-second soft budget between persisted receipts; an in-flight receipt may finish later. A single-block log overflow falls back to that block's individual receipts. Zero/self transfers are discarded before enrichment. Network failures preserve completed work and never silently skip a block. +`sync_history` returns `true` when caught up and `false` when more work remains. It uses adaptive log ranges and a 20-second soft budget between persisted receipts; an in-flight receipt may finish later. A single-block log overflow falls back to that block's individual receipts. Completed fallback scans are retained by canonical block hash within the revisit window. Zero/self transfers are discarded before enrichment. Network failures preserve completed work and never silently skip a block. Scans trail the reported tip by two blocks and revisit 4096 blocks for delayed indexing. This is not reorg rollback: previously recorded orphaned activity is not retracted. Providers must supply complete filtered logs, canonical blocks/receipts and historical state. @@ -44,13 +44,15 @@ Both chain and bundler endpoints must be controlled, credential-free HTTPS URLs; 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. +Bridge quotes include 10% native messaging-fee headroom and 20% token-conversion headroom, both within the displayed maximum USDT fee. Before signing, the stored native fee, helper liquidity and token approval are checked against current requirements without raising approved limits. Delivery checks process up to three transfers concurrently outside the send lock, with a ten-second request budget; failed lookups retain the last known status. + 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. LayerZero status must match the operation GUID/pathway before confirmation; blocked delivery remains visible and never triggers an automatic paid retry. RPC providers see queried addresses; LayerZero Scan sees bridge transaction hashes. ## Validation and bindings Run `cargo test --locked --lib modules::usdt`; CI runs these deterministic tests. They cover independent signing/address vectors, fee bounds, uncertain submission, nonce recovery and restored history. Fixtures use public test credentials. -For the ignored deployed-contract test, start a fresh Arbitrum Anvil fork on port 18545 and `tests/usdt-fork/provider.mjs` on 18546 after installing its pinned dependencies. Run `cargo test deployed_contracts_collect_usdt_fees_and_revert_failed_bridges_atomically -- --ignored`. The fixture requires Anvil, sets local balances/signing terms and checks deployed bytecode; it does not establish real provider pricing or destination delivery. +For the ignored deployed-contract test, start a fresh Arbitrum Anvil fork on port 18545 and `tests/usdt-fork/provider.mjs` on 18546 after installing its pinned dependencies. Run `cargo test deployed_contracts_collect_usdt_fees_and_revert_failed_bridges_atomically -- --ignored`. The fixture requires Anvil, sets local balances/signing terms and executes deployed contracts; it does not establish real provider pricing or destination delivery. To include the service, start it with `USDT_BRIDGE_NETWORKS=ethereum,polygon,plasma,stable NODE_ENV=test ARBITRUM_RPC_URL=http://127.0.0.1:18545 LOCAL_PROVIDER_URL=http://127.0.0.1:18546`, then pass `USDT_FORK_RPC_URL=http://127.0.0.1:3100/v1/usdt/chain-rpc` and `USDT_FORK_BUNDLER_URL=http://127.0.0.1:3100/v1/usdt/rpc` to the ignored test. @@ -68,3 +70,5 @@ Build iOS and Android sequentially with the repository scripts; Android temporar - [Transaction helper audit](https://www.openzeppelin.com/news/usdt0-transaction-helper-audit) - [Verified deployed helper](https://arbitrum.blockscout.com/api/v2/smart-contracts/0xa90f03c856d01f698e7071b393387cd75a8a319a) - [LayerZero message statuses](https://docs.layerzero.network/v2/tools/layerzeroscan/mainnet/messages/get-messagesstatus) + +Destination token addresses follow the official USDT0 ecosystem listings for [Polygon](https://usdt0.to/ecosystem/polygon), [Plasma](https://usdt0.to/ecosystem/plasma) and [Stable](https://usdt0.to/ecosystem/stable). diff --git a/src/modules/usdt/deposits.rs b/src/modules/usdt/deposits.rs index 12c4114..7b476b8 100644 --- a/src/modules/usdt/deposits.rs +++ b/src/modules/usdt/deposits.rs @@ -4,12 +4,15 @@ use super::{ user_operation::sign_hash, UsdtError, }; -use alloy_primitives::{eip191_hash_message, Address}; +use alloy_primitives::{address, eip191_hash_message, Address}; use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize}; use serde_json::{json, Value}; use std::{sync::Arc, time::Duration}; use zeroize::Zeroizing; +const ETHEREUM_USDT: Address = address!("dAC17F958D2ee523a2206206994597C13D831ec7"); +const TRON_USDT: &str = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] #[serde(rename_all = "lowercase")] pub enum UsdtDepositNetwork { @@ -29,7 +32,7 @@ pub struct UsdtDepositAddress { pub min_usd_cents: Option, pub max_usd_cents: Option, pub slippage_bps: u32, - #[serde(default)] + #[serde(skip)] pub uri: String, } @@ -38,7 +41,7 @@ pub struct UsdtDeposit { pub id: String, pub network: String, pub asset: String, - #[serde(deserialize_with = "optional_number")] + #[serde(default, deserialize_with = "optional_number")] pub amount: Option, pub source_tx: String, pub status: String, @@ -55,9 +58,9 @@ pub struct UsdtDepositPage { #[derive(Clone, Debug, Deserialize, uniffi::Record)] pub struct UsdtDepositOrder { pub status: String, - #[serde(deserialize_with = "optional_number")] + #[serde(default, deserialize_with = "optional_number")] pub amount_in: Option, - #[serde(deserialize_with = "optional_number")] + #[serde(default, deserialize_with = "optional_number")] pub amount_out: Option, pub destination_tx: Option, pub refund_tx: Option, @@ -92,12 +95,19 @@ impl UsdtDepositClient { pub async fn networks(&self) -> Result, UsdtError> { #[derive(Deserialize)] struct Networks { - networks: Vec, + networks: Vec, } Ok(self .response::(self.client.get(&self.url)) .await? - .networks) + .networks + .into_iter() + .filter_map(|network| match network.as_str() { + "ethereum" => Some(UsdtDepositNetwork::Ethereum), + "tron" => Some(UsdtDepositNetwork::Tron), + _ => None, + }) + .collect()) } pub async fn receive( @@ -121,17 +131,25 @@ impl UsdtDepositClient { ) .await?; if result.network != network - || parse_address(&result.recipient)? != self.address + || parse_address(&result.recipient).map_err(|_| UsdtError::InvalidResponse)? + != self.address || result.amount != amount || result.estimated_received == 0 || result.slippage_bps != 50 { return Err(UsdtError::InvalidResponse); } - validate_source_address(&result.address, network)?; + validate_source_address(&result.address, network) + .map_err(|_| UsdtError::InvalidResponse)?; + if network == UsdtDepositNetwork::Ethereum + && parse_address(&result.address)? == self.address + { + return Err(UsdtError::InvalidResponse); + } result.uri = match network { UsdtDepositNetwork::Ethereum => format!( - "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@1/transfer?address={}", + "ethereum:{}@1/transfer?address={}", + ETHEREUM_USDT.to_checksum(None), result.address ), UsdtDepositNetwork::Tron => result.address.clone(), @@ -145,12 +163,17 @@ impl UsdtDepositClient { mnemonic: String, passphrase: Option, ) -> Result { - self.call( - json!({"action":"history","offset":offset}), - mnemonic.into(), - passphrase.map(Into::into), - ) - .await + let page: UsdtDepositPage = self + .call( + json!({"action":"history","offset":offset}), + mnemonic.into(), + passphrase.map(Into::into), + ) + .await?; + if page.next_offset.is_some_and(|next| next <= offset) { + return Err(UsdtError::InvalidResponse); + } + Ok(page) } pub async fn detail( @@ -184,11 +207,12 @@ impl UsdtDepositClient { ) -> Result<(), UsdtError> { let mnemonic = Zeroizing::new(mnemonic); let passphrase = passphrase.map(Zeroizing::new); - validate_source_address(refund_address.trim(), network)?; + let refund_address = refund_address.trim(); + validate_source_address(refund_address, network)?; let result: Value = self .call( json!({"action":"refund","depositId":deposit_id,"offset":offset, - "refundAddress":refund_address.trim()}), + "refundAddress":refund_address}), mnemonic, passphrase, ) @@ -249,17 +273,16 @@ impl UsdtDepositClient { let error = value.unwrap_or_default(); return Err(match error["error"].as_str() { Some("not_configured") => UsdtError::NotConfigured, - Some("invalid_authorization") => UsdtError::InvalidCredentials, + Some("invalid_authorization") => UsdtError::DepositAuthorizationRejected, + Some("not_found") => UsdtError::DepositNotFound, Some("clock_skew") => UsdtError::ClockSkew, - Some("amount_too_small" | "amount_too_large" | "amount_exceeds_liquidity") => { - UsdtError::InvalidAmount - } + Some("amount_too_small" | "amount_too_large") => UsdtError::DepositAmountOutOfRange, + Some("amount_exceeds_liquidity") => UsdtError::UnsupportedRoute, Some("invalid_refund_address") => UsdtError::InvalidAddress, Some("route_unavailable") => UsdtError::UnsupportedRoute, Some( "refund_not_available" | "instruction_conflict" - | "not_found" | "operator_required" | "standing_tron_refund_requires_operator", ) => UsdtError::DepositNeedsAttention, @@ -273,12 +296,18 @@ impl UsdtDepositClient { fn validate_source_address(value: &str, network: UsdtDepositNetwork) -> Result<(), UsdtError> { match network { UsdtDepositNetwork::Ethereum => { - parse_address(value)?; + if parse_address(value)? == ETHEREUM_USDT { + return Err(UsdtError::InvalidAddress); + } } UsdtDepositNetwork::Tron => { let payload = bitcoin::base58::decode_check(value).map_err(|_| UsdtError::InvalidAddress)?; - if payload.len() != 21 || payload[0] != 0x41 { + if payload.len() != 21 + || payload[0] != 0x41 + || payload[1..].iter().all(|byte| *byte == 0) + || value == TRON_USDT + { return Err(UsdtError::InvalidAddress); } } @@ -336,7 +365,15 @@ mod tests { #[test] fn deposit_addresses_and_transport_reject_wrong_networks() { - let tron = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + let tron = "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8"; + for bad in [TRON_USDT, "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"] { + assert!(validate_source_address(bad, UsdtDepositNetwork::Tron).is_err()); + } + assert!(validate_source_address( + ÐEREUM_USDT.to_checksum(None), + UsdtDepositNetwork::Ethereum + ) + .is_err()); assert!(validate_source_address(tron, UsdtDepositNetwork::Tron).is_ok()); assert!(validate_source_address(tron, UsdtDepositNetwork::Ethereum).is_err()); assert!(validate_source_address( @@ -356,4 +393,140 @@ mod tests { .is_err()); } } + + async fn service(responses: Vec<(u16, Value)>) -> (String, tokio::task::JoinHandle<()>) { + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/v1/usdt/deposits", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + for (status, value) in responses { + let (socket, _) = listener.accept().await.unwrap(); + let mut reader = BufReader::new(socket); + let mut length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + length = value.trim().parse().unwrap(); + } + } + reader.read_exact(&mut vec![0; length]).await.unwrap(); + let body = value.to_string(); + reader.get_mut().write_all(format!("HTTP/1.1 {status} Response\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + } + }); + (url, task) + } + + #[tokio::test] + async fn service_responses_cover_receive_history_detail_and_refund() { + let owner = super::super::usdt_address(PHRASE.into(), None).unwrap(); + let address = "0x1111111111111111111111111111111111111111"; + let deposit = json!({"id":"dep_one","network":"ethereum","asset":"USDT","source_tx":"0xsource","status":"held","code":null,"refund_tx":null}); + let (url, server) = service(vec![ + (200, json!({"networks":["ethereum","tron","bitcoin"]})), + (200, json!({"network":"ethereum","address":address,"recipient":owner,"amount":"100000000","estimated_received":"98500000","slippage_bps":50,"uri":123})), + (200, json!({"deposits":[deposit],"next_offset":50})), + (200, json!({"deposit":deposit,"order":{"status":"held"}})), + (202, json!({"status":"refund_requested"})), + ]).await; + let client = UsdtDepositClient::new(owner, url).unwrap(); + assert_eq!( + client.networks().await.unwrap(), + vec![UsdtDepositNetwork::Ethereum, UsdtDepositNetwork::Tron] + ); + let received = client + .receive( + UsdtDepositNetwork::Ethereum, + 100_000_000, + PHRASE.into(), + None, + ) + .await + .unwrap(); + assert_eq!(received.estimated_received, 98_500_000); + assert!(received.uri.contains(address)); + let page = client.history(0, PHRASE.into(), None).await.unwrap(); + assert_eq!(page.deposits[0].amount, None); + assert_eq!(page.next_offset, Some(50)); + let detail = client + .detail("dep_one".into(), 0, PHRASE.into(), None) + .await + .unwrap(); + assert_eq!(detail.order.unwrap().amount_out, None); + client + .request_refund( + "dep_one".into(), + 0, + address.into(), + UsdtDepositNetwork::Ethereum, + PHRASE.into(), + None, + ) + .await + .unwrap(); + server.await.unwrap(); + } + + #[tokio::test] + async fn service_errors_preserve_recovery_actions() { + for (code, expected) in [ + ("not_found", UsdtError::DepositNotFound), + ( + "invalid_authorization", + UsdtError::DepositAuthorizationRejected, + ), + ("clock_skew", UsdtError::ClockSkew), + ("amount_too_small", UsdtError::DepositAmountOutOfRange), + ("amount_too_large", UsdtError::DepositAmountOutOfRange), + ("amount_exceeds_liquidity", UsdtError::UnsupportedRoute), + ( + "standing_tron_refund_requires_operator", + UsdtError::DepositNeedsAttention, + ), + ("provider_unavailable", UsdtError::NetworkUnavailable), + ] { + let (url, server) = service(vec![(400, json!({"error":code}))]).await; + let owner = super::super::usdt_address(PHRASE.into(), None).unwrap(); + let client = UsdtDepositClient::new(owner, url).unwrap(); + let error = client.history(0, PHRASE.into(), None).await.unwrap_err(); + assert_eq!( + std::mem::discriminant(&error), + std::mem::discriminant(&expected) + ); + server.await.unwrap(); + } + } + + #[tokio::test] + async fn invalid_service_addresses_and_nonadvancing_pages_are_rejected() { + let owner = super::super::usdt_address(PHRASE.into(), None).unwrap(); + let (url, server) = service(vec![ + (200, json!({"network":"ethereum","address":ETHEREUM_USDT,"recipient":owner,"amount":"100000000","estimated_received":"98500000","slippage_bps":50})), + (200, json!({"network":"ethereum","address":"0x1111111111111111111111111111111111111111","recipient":"invalid","amount":"100000000","estimated_received":"98500000","slippage_bps":50})), + (200, json!({"deposits":[],"next_offset":0})), + ]).await; + let client = UsdtDepositClient::new(owner, url).unwrap(); + for _ in 0..2 { + assert!(matches!( + client + .receive( + UsdtDepositNetwork::Ethereum, + 100_000_000, + PHRASE.into(), + None + ) + .await, + Err(UsdtError::InvalidResponse) + )); + } + assert!(matches!( + client.history(0, PHRASE.into(), None).await, + Err(UsdtError::InvalidResponse) + )); + server.await.unwrap(); + } } diff --git a/src/modules/usdt/errors.rs b/src/modules/usdt/errors.rs index 8b15cc2..9af564d 100644 --- a/src/modules/usdt/errors.rs +++ b/src/modules/usdt/errors.rs @@ -24,6 +24,12 @@ pub enum UsdtError { UnsupportedRoute, #[error("This deposit needs provider assistance. Check its recovery status")] DepositNeedsAttention, + #[error("Deposit details changed. Refresh the deposit history and select it again")] + DepositNotFound, + #[error("The deposit service could not verify this request. Try again")] + DepositAuthorizationRejected, + #[error("The amount is outside this deposit route's limits. Review the minimum and maximum")] + DepositAmountOutOfRange, #[error("USDT payments are not configured for this app build")] NotConfigured, #[error("The network could not be reached. Try again")] diff --git a/src/modules/usdt/history.rs b/src/modules/usdt/history.rs index 274ab58..83ff6c9 100644 --- a/src/modules/usdt/history.rs +++ b/src/modules/usdt/history.rs @@ -25,6 +25,7 @@ impl UsdtWallet { if start > tip { return Err(UsdtError::NetworkUnavailable); } + let mut ceiling = MAX_LOG_RANGE; let mut next = start; let mut width = self .history_range_limit @@ -45,7 +46,7 @@ impl UsdtWallet { Err(_) => { self.history_range_limit .store((width / 2).max(1), Ordering::Relaxed); - return Ok(false); + return Err(UsdtError::NetworkUnavailable); } }; match result { @@ -92,6 +93,7 @@ impl UsdtWallet { } Err(UsdtError::LogRangeTooLarge) if next < end => { width = (width / 2).max(1); + ceiling = width; self.history_range_limit.store(width, Ordering::Relaxed); continue; } @@ -113,7 +115,7 @@ impl UsdtWallet { } next = end + 1; self.store.save_history_progress(next)?; - width = (width * 2).min(MAX_LOG_RANGE); + width = (width * 2).min(ceiling); self.history_range_limit.store(width, Ordering::Relaxed); width = width.min(tip - next + 1); } @@ -126,6 +128,10 @@ impl UsdtWallet { deadline: tokio::time::Instant, ) -> Result { let block = self.rpc.block(number).await?; + let block_hash = format!("{:#x}", block.hash); + if self.store.begin_history_block(number, &block_hash)? { + return Ok(true); + } let timestamp = u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; if self.store.history_progress()? != Some(number) { self.store.save_history_progress(number)?; @@ -141,6 +147,10 @@ impl UsdtWallet { let receipt = self.rpc.block_receipt(*hash, &block, number).await?; self.save_receipt_history(&id, timestamp, &receipt).await?; } + if self.rpc.block(number).await?.hash != block.hash { + return Err(UsdtError::NetworkUnavailable); + } + self.store.complete_history_block(number, &block_hash)?; Ok(true) } @@ -265,6 +275,10 @@ impl UsdtWallet { None }; for (event, saved) in owned_operations { + // Unknown fee collection keeps its raw debits and refunds intact. + if event.paymaster != super::paymaster::PAYMASTER { + continue; + } let operation_hash = format!("{:#x}", event.userOpHash); let (recipient, amount, destination) = if let Some(saved) = saved { (saved.recipient, saved.amount, saved.destination) @@ -324,11 +338,10 @@ fn decode_payment(data: &[u8]) -> Result if target == TOKEN { if let Ok(call) = Erc20::transferCall::abi_decode(&data) { payment_count += 1; - payment = Some(( - call.recipient, - token_amount(call.amount)?, - UsdtDestination::Arbitrum, - )); + let Ok(amount) = token_amount(call.amount) else { + return Ok(None); + }; + payment = Some((call.recipient, amount, UsdtDestination::Arbitrum)); } else if Erc20::approveCall::abi_decode(&data).is_err() { supported = false; } @@ -338,21 +351,17 @@ fn decode_payment(data: &[u8]) -> Result supported = false; continue; } - let Some(destination) = [ - UsdtDestination::Ethereum, - UsdtDestination::Polygon, - UsdtDestination::Plasma, - UsdtDestination::Stable, - ] - .into_iter() - .find(|d| d.endpoint() == Some(call.param.dstEid)) else { + let Some(destination) = UsdtDestination::from_endpoint(call.param.dstEid) else { supported = false; continue; }; payment_count += 1; payment = Some(( Address::from_word(call.param.to), - token_amount(call.param.amountLD)?, + match token_amount(call.param.amountLD) { + Ok(amount) => amount, + Err(_) => return Ok(None), + }, destination, )); } else { diff --git a/src/modules/usdt/paymaster.rs b/src/modules/usdt/paymaster.rs index 528af37..cef65e7 100644 --- a/src/modules/usdt/paymaster.rs +++ b/src/modules/usdt/paymaster.rs @@ -20,6 +20,12 @@ struct GasPrice { max_priority_fee_per_gas: U256, } +#[derive(Deserialize)] +struct GasPrices { + slow: GasPrice, + fast: GasPrice, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct TokenQuote { @@ -118,7 +124,7 @@ impl Pimlico { && !quote.exchange_rate.is_zero() }) .ok_or(UsdtError::UnsupportedRoute)?; - let price = self.gas_price().await?; + let price = self.gas_prices().await?.fast; let dummy_signature = Bytes::from_static(&alloy_primitives::hex!("fffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c")); let mut op = UserOperation { sender: address, @@ -209,7 +215,7 @@ impl Pimlico { } pub async fn validate_gas(&self, op: &UserOperation) -> Result<(), UsdtError> { - let price = self.gas_price().await?; + let price = self.gas_prices().await?.slow; if price.max_fee_per_gas > op.max_fee_per_gas || price.max_priority_fee_per_gas > op.max_priority_fee_per_gas { @@ -234,21 +240,24 @@ impl Pimlico { Ok(()) } - async fn gas_price(&self) -> Result { - #[derive(Deserialize)] - struct Prices { - fast: GasPrice, - } - let price: Prices = self + async fn gas_prices(&self) -> Result { + let prices: GasPrices = self .rpc .call("pimlico_getUserOperationGasPrice", json!([])) .await?; - if price.fast.max_fee_per_gas.is_zero() - || price.fast.max_priority_fee_per_gas > price.fast.max_fee_per_gas + for price in [&prices.slow, &prices.fast] { + if price.max_fee_per_gas.is_zero() + || price.max_priority_fee_per_gas > price.max_fee_per_gas + { + return Err(UsdtError::InvalidResponse); + } + } + if prices.slow.max_fee_per_gas > prices.fast.max_fee_per_gas + || prices.slow.max_priority_fee_per_gas > prices.fast.max_priority_fee_per_gas { return Err(UsdtError::InvalidResponse); } - Ok(price.fast) + Ok(prices) } } @@ -273,13 +282,6 @@ fn approval_margin(value: U256) -> Result { .ok_or(UsdtError::InvalidResponse) } -pub(super) fn with_margin(value: U256) -> Result { - value - .checked_add(value / U256::from(5)) - .and_then(|value| value.checked_add(U256::from(1))) - .ok_or(UsdtError::InvalidResponse) -} - struct Terms { exchange_rate: U256, post_op_gas: U256, diff --git a/src/modules/usdt/rpc.rs b/src/modules/usdt/rpc.rs index 35a3cee..0d6af05 100644 --- a/src/modules/usdt/rpc.rs +++ b/src/modules/usdt/rpc.rs @@ -91,6 +91,9 @@ impl Rpc { } else { 2_097_152 }; + if status.is_server_error() { + return Err(UsdtError::NetworkUnavailable); + } let body = bounded_json(response, limit, overflow).await; let response: Response = match body.and_then(|value| serde_json::from_value(value).map_err(Into::into)) { @@ -120,8 +123,20 @@ impl Rpc { if error.code == -32002 { return Err(UsdtError::NetworkUnavailable); } - if message.contains("insufficient funds") || message.contains("insufficient balance") { - return Err(UsdtError::InsufficientBalance); + if matches!( + method, + "eth_chainId" + | "eth_blockNumber" + | "eth_getCode" + | "eth_getBalance" + | "eth_getTransactionCount" + | "eth_call" + | "eth_getLogs" + | "eth_getBlockByNumber" + | "eth_getTransactionReceipt" + | "eth_getTransactionByHash" + ) { + return Err(UsdtError::NetworkUnavailable); } return Err(UsdtError::TransactionRejected { reason: error.message.chars().take(200).collect(), @@ -148,16 +163,10 @@ impl Rpc { if transfer.bridge_guid.is_none() { return Ok(transfer.status); } - let url = format!( - "https://scan.layerzero-api.com/v1/messages/tx/{}", - transfer.tx_hash - ); + let base = "https://scan.layerzero-api.com"; #[cfg(test)] - let url = self - .bridge_status_url - .as_ref() - .map(|base| format!("{base}/{}", transfer.tx_hash)) - .unwrap_or(url); + let base = self.bridge_status_url.as_deref().unwrap_or(base); + let url = format!("{base}/v1/messages/tx/{}", transfer.tx_hash); let response = self .client .get(url) @@ -218,6 +227,9 @@ impl Rpc { let receipt: Value = self .call("eth_getTransactionReceipt", json!([hash])) .await?; + if receipt.is_null() { + return Err(UsdtError::NetworkUnavailable); + } if serde_json::from_value::(receipt["transactionHash"].clone())? != hash || serde_json::from_value::(receipt["blockHash"].clone())? != block.hash || serde_json::from_value::(receipt["blockNumber"].clone())? != U256::from(number) diff --git a/src/modules/usdt/store.rs b/src/modules/usdt/store.rs index a124d32..75212fc 100644 --- a/src/modules/usdt/store.rs +++ b/src/modules/usdt/store.rs @@ -28,6 +28,7 @@ impl Store { CREATE TABLE IF NOT EXISTS usdt_sync (id INTEGER PRIMARY KEY CHECK(id=1), newest INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS usdt_history_progress (id INTEGER PRIMARY KEY CHECK(id=1), next INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS usdt_history_receipts (hash TEXT PRIMARY KEY); + CREATE TABLE IF NOT EXISTS usdt_history_blocks (number INTEGER PRIMARY KEY, hash TEXT NOT NULL, complete INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS usdt_quotes (id TEXT PRIMARY KEY, data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS usdt_nonce_recovery (id TEXT PRIMARY KEY, block_hash TEXT NOT NULL, next_transaction INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS usdt_transfers (id TEXT PRIMARY KEY, hash TEXT NOT NULL, raw TEXT, data TEXT NOT NULL);")?; @@ -191,6 +192,10 @@ impl Store { let tx = connection.transaction()?; tx.execute("DELETE FROM usdt_history_progress", [])?; tx.execute("DELETE FROM usdt_history_receipts", [])?; + tx.execute( + "DELETE FROM usdt_history_blocks WHERE number < ?1", + [newest.saturating_sub(4096)], + )?; tx.execute("INSERT INTO usdt_sync (id,newest) VALUES (1,?1) ON CONFLICT(id) DO UPDATE SET newest=excluded.newest", [newest])?; tx.commit()?; Ok(()) @@ -210,6 +215,10 @@ impl Store { pub fn save_history_progress(&self, next: u64) -> Result<(), UsdtError> { let mut connection = self.connection()?; let tx = connection.transaction()?; + tx.execute( + "DELETE FROM usdt_history_blocks WHERE number < ?1", + [next.saturating_sub(4096)], + )?; tx.execute("INSERT INTO usdt_history_progress VALUES (1,?1) ON CONFLICT(id) DO UPDATE SET next=excluded.next", [next])?; tx.execute("DELETE FROM usdt_history_receipts", [])?; tx.commit()?; @@ -218,11 +227,40 @@ impl Store { pub fn transaction_timestamp(&self, hash: &str) -> Result, UsdtError> { Ok(self.connection()?.query_row( - "SELECT json_extract(data, '$.timestamp') FROM usdt_transfers WHERE json_extract(data, '$.tx_hash')=?1 LIMIT 1", + "SELECT json_extract(data, '$.timestamp') FROM usdt_transfers WHERE json_extract(data, '$.tx_hash')=?1 AND json_extract(data, '$.status') != 'Pending' LIMIT 1", [hash], |row| row.get(0), ).optional()?) } + pub fn begin_history_block(&self, number: u64, hash: &str) -> Result { + let mut connection = self.connection()?; + let tx = connection.transaction()?; + let saved: Option<(String, bool)> = tx + .query_row( + "SELECT hash, complete FROM usdt_history_blocks WHERE number=?1", + [number], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((saved_hash, complete)) = saved { + if saved_hash == hash { + return Ok(complete); + } + tx.execute("DELETE FROM usdt_history_receipts", [])?; + } + tx.execute("INSERT INTO usdt_history_blocks VALUES (?1,?2,0) ON CONFLICT(number) DO UPDATE SET hash=excluded.hash,complete=0", params![number, hash])?; + tx.commit()?; + Ok(false) + } + + pub fn complete_history_block(&self, number: u64, hash: &str) -> Result<(), UsdtError> { + self.connection()?.execute( + "UPDATE usdt_history_blocks SET complete=1 WHERE number=?1 AND hash=?2", + params![number, hash], + )?; + Ok(()) + } + pub fn has_history_receipt(&self, hash: &str) -> Result { Ok(self.connection()?.query_row( "SELECT EXISTS(SELECT 1 FROM usdt_history_receipts WHERE hash=?1)", @@ -264,8 +302,12 @@ impl Store { if let Some(data) = existing { let saved: UsdtTransfer = decode(&data)?; transfer.id = saved.id; - if saved.tx_hash == transfer.tx_hash - && saved.bridge_guid == transfer.bridge_guid + if saved.tx_hash.eq_ignore_ascii_case(&transfer.tx_hash) + && saved + .bridge_guid + .as_ref() + .zip(transfer.bridge_guid.as_ref()) + .is_some_and(|(a, b)| a.eq_ignore_ascii_case(b)) && transfer.status == UsdtTransferStatus::Bridging && matches!( saved.status, diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index b614ef7..b9838ae 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -92,6 +92,11 @@ fn wallet_requires_both_provider_endpoints() { for (rpc, bundler) in [ ("", "https://provider.example"), ("https://provider.example", ""), + ("not a url", "https://provider.example"), + ( + "https://provider.example", + "https://example.com?api-key=secret", + ), ] { assert!(matches!( UsdtWallet::new( @@ -231,6 +236,8 @@ struct ChainState { delay_gas_estimate: bool, paymaster: alloy_primitives::Address, helper_balance: alloy_primitives::U256, + native_message_fee: u64, + helper_token_fee: u64, history_input: Option, history_target: Option, receipt_logs: Option>, @@ -250,6 +257,9 @@ struct ChainState { incoming_count: u64, block_reads: usize, fail_block_read_at: Option, + authorization_change_on_block_read: bool, + receipt_reads: usize, + receipt_response: Option, } impl Drop for MockChain { fn drop(&mut self) { @@ -280,6 +290,8 @@ impl MockChain { delay_gas_estimate: false, paymaster: paymaster::PAYMASTER, helper_balance: U256::from(1_000_000_000_000_000u64), + native_message_fee: 10_000_000_000, + helper_token_fee: 300_000, history_input: None, history_target: Some(account::ENTRY_POINT), receipt_logs: None, @@ -299,6 +311,9 @@ impl MockChain { incoming_count: 0, block_reads: 0, fail_block_read_at: None, + authorization_change_on_block_read: false, + receipt_reads: 0, + receipt_response: None, })); let server_state = state.clone(); let task = tokio::spawn(async move { @@ -336,6 +351,33 @@ impl MockChain { } 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 bundler = matches!( + method, + "pimlico_getTokenQuotes" + | "pimlico_getUserOperationGasPrice" + | "pm_getPaymasterData" + | "pm_getPaymasterStubData" + | "eth_estimateUserOperationGas" + | "eth_sendUserOperation" + ); + if path == "/chain" { + assert!(!bundler, "Bundler method on chain endpoint"); + } + if path == "/bundler" { + assert!( + bundler || method == "eth_chainId", + "Chain method on bundler endpoint" + ); + } let delay = body["method"] == "eth_estimateUserOperationGas" && std::mem::take(&mut server_state.lock().unwrap().delay_gas_estimate); if delay { @@ -352,8 +394,8 @@ impl MockChain { UsdtWallet::new( usdt_address(TEST_PHRASE.into(), None).unwrap(), dir.path().join("usdt.sqlite").to_string_lossy().into(), - self.url.clone(), - self.url.clone(), + format!("{}/chain", self.url), + format!("{}/bundler", self.url), ) .unwrap() } @@ -383,6 +425,9 @@ impl ChainState { "eth_blockNumber" => json!(U256::from(self.tip)), "eth_getBlockByNumber" => { self.block_reads += 1; + if std::mem::take(&mut self.authorization_change_on_block_read) { + self.authorization_nonce += 1; + } if self.fail_block_read_at == Some(self.block_reads) { self.fail_block_read_at = None; return json!({"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"temporarily unavailable"}}); @@ -429,7 +474,7 @@ impl ChainState { .abi_encode_params() } else if data.starts_with(&Oft::quoteSendCall::SELECTOR) { MessagingFee { - nativeFee: U256::from(10_000_000_000u64), + nativeFee: U256::from(self.native_message_fee), lzTokenFee: U256::ZERO, } .abi_encode() @@ -439,7 +484,7 @@ impl ChainState { let param = BridgeHelper::quoteSendCall::abi_decode(&data) .unwrap() .param; - (param.amountLD + U256::from(300_000)).abi_encode() + (param.amountLD + U256::from(self.helper_token_fee)).abi_encode() } else { self.balance.abi_encode() }; @@ -449,7 +494,7 @@ impl ChainState { json!({"quotes":[{"token":types::TOKEN,"paymaster":self.paymaster,"postOpGas":"0xc350","exchangeRate":U256::from(3_000_000_000u64)}]}) } "pimlico_getUserOperationGasPrice" => { - json!({"fast":{"maxFeePerGas":U256::from(self.gas_price),"maxPriorityFeePerGas":U256::from(100000)}}) + json!({"slow":{"maxFeePerGas":U256::from(self.gas_price * 9 / 10),"maxPriorityFeePerGas":U256::from(100000)},"fast":{"maxFeePerGas":U256::from(self.gas_price),"maxPriorityFeePerGas":U256::from(100000)}}) } "pm_getPaymasterData" | "pm_getPaymasterStubData" => { let mut data = vec![0; 118]; @@ -582,6 +627,10 @@ impl ChainState { } } "eth_getTransactionReceipt" => { + self.receipt_reads += 1; + if let Some(response) = &self.receipt_response { + return json!({"jsonrpc":"2.0","id":1,"result":response}); + } if self.hide_receipts || self .receipt_failure @@ -737,7 +786,12 @@ async fn signed_operation_survives_uncertain_broadcast_and_restart() { .iter() .all(|item| serde_json::to_value(item).unwrap() == serde_json::to_value(&op).unwrap())); chain.state.lock().unwrap().mined = true; + chain.state.lock().unwrap().timestamp += alloy_primitives::U256::from(60); let history = wallet.refresh_transfers().await.unwrap(); + assert_eq!( + history[0].timestamp, + u64::try_from(chain.state.lock().unwrap().timestamp).unwrap() + ); assert_eq!(history.len(), 1); assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); assert_eq!(history[0].fee, Some(123)); @@ -931,7 +985,7 @@ async fn bridge_payment_bounds_token_fees_and_revokes_helper_approval() { .into_word() ); assert_eq!(send.param.minAmountLD, U256::from(1_000_000)); - assert_eq!(send.fee.nativeFee, U256::from(10_000_000_000u64)); + assert_eq!(send.fee.nativeFee, U256::from(11_000_000_001u64)); let revoke = transaction::Erc20::approveCall::abi_decode(&calls[3].1).unwrap(); assert_eq!(revoke.spender, types::BRIDGE_HELPER); assert!(revoke.amount.is_zero()); @@ -939,7 +993,30 @@ async fn bridge_payment_bounds_token_fees_and_revokes_helper_approval() { assert_eq!(bridge_fee, 360_001); assert!(paymaster.amount > U256::from(quote.maximum_fee - bridge_fee)); assert_eq!(quote.received_amount, 1_000_000); + chain.state.lock().unwrap().native_message_fee = 12_000_000_000; + assert!(matches!( + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::QuoteExpired) + )); + chain.state.lock().unwrap().native_message_fee = 10_500_000_000; + chain.state.lock().unwrap().helper_token_fee = 400_000; + assert!(matches!( + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::QuoteExpired) + )); + chain.state.lock().unwrap().helper_token_fee = 300_000; chain.state.lock().unwrap().helper_balance = U256::ZERO; + assert!(matches!( + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::UnsupportedRoute) + )); + assert!(chain.state.lock().unwrap().operations.is_empty()); assert!(matches!( wallet .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) @@ -1025,6 +1102,10 @@ async fn bundled_operations_cannot_contribute_another_payments_bridge_status_or_ log(paymaster::PAYMASTER, transaction::Paymaster::UserOperationSponsored { userOpHash:own_hash, user:wallet.address, paymasterMode:1, token:types::TOKEN, tokenAmountPaid:U256::from(123), exchangeRate:U256::from(1) }.encode_log_data()), log(account::ENTRY_POINT, event(own_hash)), ]}); + assert_eq!( + transaction::operation_logs(&receipt, own_hash).unwrap(), + &receipt["logs"].as_array().unwrap()[3..] + ); wallet.settle(&mut transfer, &receipt, false).unwrap(); assert_eq!(transfer.status, UsdtTransferStatus::Failed); assert_eq!(transfer.fee, Some(123)); @@ -1091,6 +1172,25 @@ async fn deployed_contracts_collect_usdt_fees_and_revert_failed_bridges_atomical .call("anvil_setBalance", json!([types::BRIDGE_HELPER, "0x0"])) .await .unwrap(); + assert!(matches!( + wallet + .send(bridge.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::UnsupportedRoute) + )); + let _: serde_json::Value = rpc + .call( + "anvil_setBalance", + json!([types::BRIDGE_HELPER, helper_balance]), + ) + .await + .unwrap(); + // The helper can lose liquidity after preflight but before execution. + let fixture = rpc::Rpc::new("http://127.0.0.1:18546".into(), types::CHAIN_ID).unwrap(); + let _: bool = fixture + .call("test_drainHelperBeforeNextBroadcast", json!([])) + .await + .unwrap(); let before = wallet.balance().await.unwrap(); let sent = wallet .send(bridge.id, TEST_PHRASE.into(), None) @@ -1160,6 +1260,15 @@ async fn history_preserves_receipts_with_external_account_call_shapes() { .await .unwrap(); let original = chain.state.lock().unwrap().operations[0].call_data.clone(); + let oversized = account::batch(&[( + types::TOKEN, + transaction::Erc20::transferCall { + recipient: RECIPIENT.parse().unwrap(), + amount: U256::MAX, + } + .abi_encode() + .into(), + )]); let single = account::SimpleAccount::executeCall { target: types::TOKEN, value: U256::ZERO, @@ -1175,6 +1284,7 @@ async fn history_preserves_receipts_with_external_account_call_shapes() { for (call_data, outer, expected_outgoing) in [ (original.clone(), None, true), (single, None, true), + (oversized, None, false), (Bytes::from_static(&[1, 2, 3, 4]), None, false), (original, Some(Bytes::from_static(&[5, 6, 7, 8])), false), ] { @@ -1427,7 +1537,7 @@ async fn interrupted_history_resumes_without_repeating_completed_work() { }) .await .unwrap(); - assert!(matches!(error, UsdtError::TransactionRejected { .. })); + assert!(matches!(error, UsdtError::NetworkUnavailable)); assert_eq!(restored.history().unwrap().len(), 24); assert!(restored.store.history_progress().unwrap().is_some()); drop(restored); @@ -1514,7 +1624,7 @@ async fn history_distinguishes_rate_limits_from_log_range_limits() { } let error = wallet.sync_history().await.unwrap_err(); if code == -32000 { - assert!(matches!(error, UsdtError::TransactionRejected { .. })); + assert!(matches!(error, UsdtError::NetworkUnavailable)); } else { assert!(matches!(error, UsdtError::RateLimited)); } @@ -1682,7 +1792,7 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending let mut reader = BufReader::new(socket); let mut line = String::new(); reader.read_line(&mut line).await.unwrap(); - let hash = line.split_whitespace().nth(1).unwrap().trim_start_matches('/').to_string(); + let hash = line.split_whitespace().nth(1).unwrap().strip_prefix("/v1/messages/tx/").unwrap().to_string(); loop { line.clear(); if reader.read_line(&mut line).await.unwrap() == 0 || line == "\r\n" { break; } @@ -1742,7 +1852,7 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending .await .unwrap() .unwrap(); - assert_eq!(attempts.lock().unwrap().len(), 1); + assert_eq!(attempts.lock().unwrap().len(), 3); assert_eq!( history .iter() @@ -1763,23 +1873,26 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); - // Hold the refresh lock before send, while all bridge status connections stall again. + // Replenish the shared RPC burst so this checks polling, not rate-limit waiting. + tokio::time::sleep(Duration::from_secs(15)).await; + // Destination polling must not hold the mutation lock needed to send. let refresh_wallet = wallet.clone(); let refresh = tokio::spawn(async move { refresh_wallet.refresh_transfers().await }); tokio::time::timeout(Duration::from_secs(3), async { - while attempts.lock().unwrap().len() < 2 { + while attempts.lock().unwrap().len() < 6 { tokio::time::sleep(Duration::from_millis(10)).await; } }) .await .unwrap(); let result = tokio::time::timeout( - Duration::from_secs(15), + Duration::from_secs(8), wallet.send(quote.id, TEST_PHRASE.into(), None), ) .await; - refresh.await.unwrap().unwrap(); assert_eq!(result.unwrap().unwrap().status, UsdtTransferStatus::Pending); + assert!(!refresh.is_finished()); + refresh.await.unwrap().unwrap(); { let attempts = attempts.lock().unwrap(); assert_ne!(attempts[0], attempts[1]); @@ -2149,6 +2262,11 @@ async fn dense_block_history_recovers_large_receipts_without_skipping_after_rest assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); assert_eq!(history[0].fee, Some(123)); assert_eq!(restored.store.synced_block().unwrap(), Some(20001)); + let reads = chain.state.lock().unwrap().receipt_reads; + drop(restored); + let restored = chain.wallet(&dir); + sync_history_to_tip(&restored).await; + assert_eq!(chain.state.lock().unwrap().receipt_reads, reads); } #[tokio::test] @@ -2176,3 +2294,356 @@ async fn zero_value_transfers_do_not_require_receipts_or_timestamps() { assert!(wallet.sync_history().await.unwrap()); assert!(wallet.history().unwrap().is_empty()); } + +#[tokio::test] +async fn first_submission_precheck_releases_an_operation_that_was_never_sent() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + chain + .state + .lock() + .unwrap() + .authorization_change_on_block_read = true; + assert!(matches!( + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::QuoteExpired) + )); + assert!(chain.state.lock().unwrap().operations.is_empty()); + assert!(wallet.store.pending_plan("e.id).unwrap().is_none()); + let failed = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + assert_eq!(failed.status, UsdtTransferStatus::Failed); + assert_eq!(failed.received_amount, 0); + assert_eq!(failed.fee, Some(0)); + wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); +} + +#[tokio::test] +async fn moderate_gas_price_movement_preserves_the_approved_fee() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let plan = wallet.store.quote("e.id).unwrap().plan; + chain.state.lock().unwrap().gas_price = 52_000_000; + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + assert_eq!( + chain.state.lock().unwrap().operations[0].max_fee_per_gas, + plan.operation.max_fee_per_gas + ); +} + +#[tokio::test] +async fn unknown_paymaster_history_preserves_principal_fee_and_refund() { + use alloy_primitives::{Address, B256, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let payer = Address::repeat_byte(0xab); + let movement = |from, to, value, index| { + let event = transaction::Erc20::Transfer { + from, + to, + value: U256::from(value), + } + .encode_log_data(); + json!({"address": types::TOKEN, "topics":event.topics(), "data":event.data, "logIndex": U256::from(index)}) + }; + { + let mut state = chain.state.lock().unwrap(); + let op = &state.operations[0]; + let event = transaction::EntryPoint::UserOperationEvent { + userOpHash: op.hash(types::CHAIN_ID).unwrap(), + sender: wallet.address, + paymaster: payer, + nonce: U256::ZERO, + success: true, + actualGasCost: U256::from(1), + actualGasUsed: U256::from(1), + } + .encode_log_data(); + let log = json!({"address":account::ENTRY_POINT,"topics":event.topics(),"data":event.data,"transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":"0x3"}); + state.receipt_logs = Some(vec![ + movement(wallet.address, payer, 200u64, 0u64), + movement( + wallet.address, + RECIPIENT.parse().unwrap(), + 1_000_000u64, + 1u64, + ), + movement(payer, wallet.address, 50u64, 2u64), + log.clone(), + ]); + state.log_response = Some(vec![log]); + state.tip += 3; + } + let restored_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restored_dir); + sync_history_to_tip(&restored).await; + let history = restored.history().unwrap(); + assert_eq!(history.len(), 3); + assert_eq!( + history + .iter() + .filter(|t| !t.is_incoming) + .map(|t| t.amount) + .sum::(), + 1_000_200 + ); + assert_eq!( + history + .iter() + .filter(|t| t.is_incoming) + .map(|t| t.amount) + .sum::(), + 50 + ); +} + +#[tokio::test] +async fn settlement_requires_matching_canonical_receipts() { + use alloy_primitives::B256; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let receipt = { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + state + .respond(&json!({"method":"eth_getTransactionReceipt","params":[B256::repeat_byte(7)]})) + ["result"] + .clone() + }; + for (field, value) in [ + ("transactionHash", json!(B256::ZERO)), + ("blockHash", json!(B256::ZERO)), + ("blockNumber", json!("0x1")), + ] { + let mut invalid = receipt.clone(); + invalid[field] = value; + chain.state.lock().unwrap().receipt_response = Some(invalid); + assert!(matches!( + wallet.refresh_transfers().await, + Err(UsdtError::InvalidResponse) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + } + chain.state.lock().unwrap().receipt_response = Some(serde_json::Value::Null); + assert!(matches!( + wallet.refresh_transfers().await, + Err(UsdtError::NetworkUnavailable) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + chain.state.lock().unwrap().receipt_response = None; + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Confirmed + ); +} + +#[tokio::test] +async fn bridge_settlement_recovers_guid_fees_and_preserves_delivery_on_rescan() { + use alloy_primitives::{B256, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let mut wallet = chain.wallet(&dir); + let guid = B256::repeat_byte(0xab); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + std::sync::Arc::get_mut(&mut wallet) + .unwrap() + .rpc + .bridge_status_url = Some(format!("http://{}", listener.local_addr().unwrap())); + let message = json!({"guid":guid,"pathway":{"srcEid":30110,"dstEid":30109,"sender":{"address":types::OFT}},"source":{"tx":{"txHash":B256::repeat_byte(7)}},"status":{"name":"DELIVERED"}}); + let mut responses = Vec::new(); + for (pointer, value) in [ + ("/guid", json!(B256::ZERO)), + ("/pathway/srcEid", json!(30101)), + ("/pathway/dstEid", json!(30101)), + ("/pathway/sender/address", json!(RECIPIENT)), + ("/source/tx/txHash", json!(B256::ZERO)), + ("/status/name", json!("NEW_PROVIDER_STATUS")), + ("/status/name", json!("FAILED")), + ] { + let mut changed = message.clone(); + *changed.pointer_mut(pointer).unwrap() = value; + responses.push((200, json!({"data":[changed]}))); + } + responses.extend([ + (429, json!({})), + (200, json!({"data":null})), + (200, json!({"data":[message]})), + ]); + let server = tokio::spawn(async move { + for (status, body) in responses { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0; 4096]; + let size = socket.read(&mut request).await.unwrap(); + assert!(String::from_utf8_lossy(&request[..size]).starts_with("GET /v1/messages/tx/0x")); + let body = body.to_string(); + socket.write_all(format!("HTTP/1.1 {status} OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + } + }); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + let mut logs = state.event_logs(); + let log = |address, event: alloy_primitives::LogData, index| json!({"address":address,"topics":event.topics(),"data":event.data,"logIndex":U256::from(index)}); + logs.insert( + 1, + log( + types::OFT, + transaction::Oft::OFTSent { + guid, + dstEid: 30109, + fromAddress: types::BRIDGE_HELPER, + amountSentLD: U256::from(1_000_000), + amountReceivedLD: U256::from(999_999), + } + .encode_log_data(), + 1u64, + ), + ); + logs.insert( + 2, + log( + types::BRIDGE_HELPER, + transaction::BridgeHelper::LogSend { + sender: wallet.address, + oft: types::OFT, + amountLD: U256::from(1_000_000), + nativeFee: U256::from(10_000_000_000u64), + feeInToken: U256::from(300_000), + totalAmount: U256::from(1_300_000), + } + .encode_log_data(), + 2u64, + ), + ); + logs[3]["logIndex"] = json!("0x3"); + state.receipt_logs = Some(logs); + } + sync_history_to_tip(&wallet).await; + let pending = wallet.history().unwrap().remove(0); + assert_eq!(pending.status, UsdtTransferStatus::Bridging); + assert_eq!(pending.bridge_guid, Some(format!("{guid:#x}"))); + assert_eq!(pending.received_amount, 999_999); + assert_eq!(pending.fee, Some(300_123)); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); + for _ in 0..6 { + assert_eq!( + wallet.rpc.bridge_status(&pending).await.unwrap(), + UsdtTransferStatus::Bridging + ); + } + assert_eq!( + wallet.rpc.bridge_status(&pending).await.unwrap(), + UsdtTransferStatus::BridgeNeedsAttention + ); + assert!(matches!( + wallet.rpc.bridge_status(&pending).await, + Err(UsdtError::NetworkUnavailable) + )); + assert!(matches!( + wallet.rpc.bridge_status(&pending).await, + Err(UsdtError::InvalidResponse) + )); + chain.state.lock().unwrap().chain = 1; + let mut delivered = wallet.refresh_transfers().await.unwrap().remove(0); + assert_eq!(delivered.status, UsdtTransferStatus::Confirmed); + server.await.unwrap(); + delivered.bridge_guid = delivered.bridge_guid.map(|guid| guid.to_uppercase()); + delivered.tx_hash = delivered.tx_hash.to_uppercase(); + wallet.store.update_transfer(&delivered).unwrap(); + chain.state.lock().unwrap().chain = 42161; + sync_history_to_tip(&wallet).await; + assert_eq!( + wallet.history().unwrap()[0].status, + UsdtTransferStatus::Confirmed + ); + drop(wallet); + let restored_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restored_dir); + sync_history_to_tip(&restored).await; + let recovered = restored.history().unwrap().remove(0); + assert_eq!(recovered.destination, UsdtDestination::Polygon); + assert_eq!(recovered.bridge_guid, Some(format!("{guid:#x}"))); + assert_eq!(recovered.fee, Some(300_123)); +} + +#[tokio::test] +async fn destination_tokens_are_not_payment_recipients() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + for destination in [ + UsdtDestination::Arbitrum, + UsdtDestination::Ethereum, + UsdtDestination::Polygon, + UsdtDestination::Plasma, + UsdtDestination::Stable, + ] { + assert!(matches!( + wallet + .quote_transfer( + destination.token().to_checksum(None), + 1_000_000, + destination + ) + .await, + Err(UsdtError::InvalidAddress) + )); + if let Some(eid) = destination.endpoint() { + assert_eq!(UsdtDestination::from_endpoint(eid), Some(destination)); + } + } +} diff --git a/src/modules/usdt/types.rs b/src/modules/usdt/types.rs index 45245f3..3ca70c1 100644 --- a/src/modules/usdt/types.rs +++ b/src/modules/usdt/types.rs @@ -17,6 +17,22 @@ pub enum UsdtDestination { } impl UsdtDestination { + pub(super) fn token(self) -> Address { + match self { + Self::Arbitrum => TOKEN, + Self::Ethereum => address!("dAC17F958D2ee523a2206206994597C13D831ec7"), + Self::Polygon => address!("c2132D05D31c914a87C6611C10748AEb04B58e8F"), + Self::Plasma => address!("B8CE59FC3717ada4C02eaDF9682A9e934F625ebb"), + Self::Stable => address!("779Ded0c9e1022225f8E0630b35a9b54bE713736"), + } + } + + pub(super) fn from_endpoint(eid: u32) -> Option { + [Self::Ethereum, Self::Polygon, Self::Plasma, Self::Stable] + .into_iter() + .find(|d| d.endpoint() == Some(eid)) + } + pub(super) fn endpoint(self) -> Option { match self { Self::Stable => Some(30396), diff --git a/src/modules/usdt/wallet.rs b/src/modules/usdt/wallet.rs index 64ec4ab..d456a5e 100644 --- a/src/modules/usdt/wallet.rs +++ b/src/modules/usdt/wallet.rs @@ -2,7 +2,7 @@ use super::{ account::{validate_delegation, ENTRY_POINT}, amount::token_amount, keys::{derive_key, parse_address}, - paymaster::{with_margin, Pimlico, PAYMASTER}, + paymaster::{Pimlico, PAYMASTER}, rpc::Rpc, store::{QuoteData, Store}, transaction::{ @@ -45,11 +45,11 @@ impl UsdtWallet { return Err(UsdtError::NotConfigured); } let address = parse_address(&address)?; - let store = Store::open(&storage_path, &format!("{CHAIN_ID}:{}", address))?; let rpc = Rpc::new(rpc_url, CHAIN_ID)?; let paymaster = Pimlico { rpc: rpc.with_url(bundler_url)?, }; + let store = Store::open(&storage_path, &format!("{CHAIN_ID}:{}", address))?; Ok(Arc::new(Self { address, rpc, @@ -89,7 +89,16 @@ impl UsdtWallet { } let recipient = parse_address(recipient.trim())?; if recipient == self.address - || (destination == UsdtDestination::Arbitrum && recipient == TOKEN) + || recipient == destination.token() + || (destination == UsdtDestination::Arbitrum + && [ + ENTRY_POINT, + PAYMASTER, + super::account::DELEGATE, + OFT, + BRIDGE_HELPER, + ] + .contains(&recipient)) { return Err(UsdtError::InvalidAddress); } @@ -98,9 +107,6 @@ impl UsdtWallet { self.require_balance(amount, 0).await?; let (calls, received_amount, bridge_fee) = self.transfer_calls(recipient, amount, destination).await?; - if bridge_fee > 0 { - self.require_balance(amount, bridge_fee).await?; - } let nonce = self.nonce("latest").await?; let authorization = self.authorization().await?; let created_block = self.block_number().await?; @@ -166,6 +172,7 @@ impl UsdtWallet { } self.require_balance(data.quote.amount, data.quote.maximum_fee) .await?; + self.validate_bridge(&data.plan).await?; self.paymaster.validate_gas(&data.plan.operation).await?; if self .block_timestamp(self.block_number().await?) @@ -184,7 +191,7 @@ impl UsdtWallet { } let (hash, raw) = data.plan.sign(&key)?; drop(key); - let transfer = UsdtTransfer { + let mut transfer = UsdtTransfer { id: quote_id, tx_hash: String::new(), user_operation_hash: Some(format!("{hash:#x}")), @@ -201,7 +208,19 @@ impl UsdtWallet { }; self.store.record_signed(&transfer, &raw)?; // After persistence a lost response is indeterminate. Retry only the identical signed operation. - let _ = self.broadcast(&data.plan, hash).await; + if let Err(error) = self.broadcast(&data.plan, hash).await { + // These errors occur before submission; later retries may already be queued. + if matches!( + error, + UsdtError::QuoteExpired | UsdtError::UnsupportedDelegation + ) { + transfer.status = UsdtTransferStatus::Failed; + transfer.received_amount = 0; + transfer.fee = Some(0); + self.store.update_transfer(&transfer)?; + return Err(error); + } + } Ok(transfer) } @@ -221,8 +240,12 @@ impl UsdtWallet { if transfers.is_empty() { return self.history(); } - self.rpc.verify_chain().await?; - self.refresh_bridges(&transfers).await?; + if transfers + .iter() + .any(|transfer| transfer.status == UsdtTransferStatus::Pending) + { + self.rpc.verify_chain().await?; + } for mut transfer in transfers { if matches!( transfer.status, @@ -323,6 +346,8 @@ impl UsdtWallet { } } } + drop(_guard); + self.refresh_bridges(&self.store.unsettled()?).await?; self.history() } } @@ -372,6 +397,8 @@ impl UsdtWallet { transfer.received_amount = 0; transfer.fee = Some(0); } + transfer.timestamp = + u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; return self.store.update_transfer(transfer); } self.store @@ -391,28 +418,50 @@ impl UsdtWallet { let mut bridges: Vec<_> = transfers .iter() .filter(|transfer| { - matches!( - transfer.status, - UsdtTransferStatus::Bridging | UsdtTransferStatus::BridgeNeedsAttention - ) + transfer.bridge_guid.is_some() + && matches!( + transfer.status, + UsdtTransferStatus::Bridging | UsdtTransferStatus::BridgeNeedsAttention + ) }) .collect(); if bridges.is_empty() { return Ok(()); } - let offset = self.bridge_poll_offset.fetch_add(1, Ordering::Relaxed) % bridges.len(); + let offset = self.bridge_poll_offset.fetch_add(3, Ordering::Relaxed) % bridges.len(); bridges.rotate_left(offset); - // Rotate the first check so stalled bridges cannot starve later status checks or sends. - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); - for transfer in bridges { - match tokio::time::timeout_at(deadline, self.rpc.bridge_status(transfer)).await { - Ok(Ok(status)) => { - let mut transfer = transfer.clone(); - transfer.status = status; - self.store.update_transfer(&transfer)?; + let bridges = &bridges; + let check = |index: usize| async move { + let transfer = bridges.get(index).copied()?; + Some(( + transfer, + tokio::time::timeout( + std::time::Duration::from_secs(10), + self.rpc.bridge_status(transfer), + ) + .await, + )) + }; + let (first, second, third) = tokio::join!(check(0), check(1), check(2)); + for (previous, result) in [first, second, third].into_iter().flatten() { + match result { + Ok(Ok(status)) if status != previous.status => { + let _guard = self.operation.lock().await; + let Some(mut current) = self.store.transfer(&previous.id)? else { + continue; + }; + if current.tx_hash == previous.tx_hash + && current.bridge_guid == previous.bridge_guid + && current.status == previous.status + { + current.status = status; + self.store.update_transfer(¤t)?; + } } - Ok(Err(_)) => {} - Err(_) => break, + Ok(Ok(_)) => {} + _ => log::warn!( + "USDT bridge delivery lookup unavailable; retaining last known status" + ), } } Ok(()) @@ -470,18 +519,25 @@ impl UsdtWallet { if self.authorization().await?.nonce != plan.operation.eip7702_auth.nonce { return Err(UsdtError::QuoteExpired); } - let hash: B256 = self + let result: Result = self .paymaster .rpc .call( "eth_sendUserOperation", json!([plan.operation, ENTRY_POINT]), ) - .await?; - if hash != expected_hash { - return Err(UsdtError::InvalidResponse); + .await; + match result { + Ok(hash) if hash == expected_hash => Ok(()), + Ok(_) => { + log::warn!("USDT submission returned an unexpected operation hash; retaining pending payment"); + Err(UsdtError::InvalidResponse) + } + Err(error) => { + log::warn!("USDT submission could not be confirmed; retaining pending payment"); + Err(error) + } } - Ok(()) } async fn settle_from_log( &self, @@ -494,11 +550,17 @@ impl UsdtWallet { } transfer.tx_hash = serde_json::from_value(log["transactionHash"].clone())?; transfer.explorer_url = format!("{EXPLORER}/tx/{}", transfer.tx_hash); - let receipt = self - .rpc - .call("eth_getTransactionReceipt", json!([transfer.tx_hash])) - .await?; + let number = u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) + .map_err(|_| UsdtError::InvalidResponse)?; + let block = self.rpc.block(number).await?; + let hash = transfer + .tx_hash + .parse() + .map_err(|_| UsdtError::InvalidResponse)?; + let receipt = self.rpc.block_receipt(hash, &block, number).await?; self.settle(transfer, &receipt, event.success)?; + transfer.timestamp = + u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; self.store.update_transfer(transfer) } @@ -598,6 +660,51 @@ impl UsdtWallet { }; Ok(()) } + async fn validate_bridge(&self, plan: &Plan) -> Result<(), UsdtError> { + let calls = super::history::decode_calls(&plan.operation.call_data)?; + let Some((_, data)) = calls.iter().find(|(target, _)| *target == BRIDGE_HELPER) else { + return Ok(()); + }; + let send = + BridgeHelper::sendCall::abi_decode(data).map_err(|_| UsdtError::InvalidResponse)?; + let required = self + .rpc + .contract( + OFT, + Oft::quoteSendCall { + param: send.param.clone(), + payInLzToken: false, + }, + ) + .await?; + if !required.lzTokenFee.is_zero() || required.nativeFee > send.fee.nativeFee { + return Err(UsdtError::QuoteExpired); + } + if self.rpc.balance(BRIDGE_HELPER).await? < send.fee.nativeFee { + return Err(UsdtError::UnsupportedRoute); + } + let total = self + .rpc + .contract( + BRIDGE_HELPER, + BridgeHelper::quoteSendCall { + param: send.param, + fee: send.fee, + }, + ) + .await?; + let allowance = calls + .iter() + .filter(|(target, _)| *target == TOKEN) + .filter_map(|(_, data)| Erc20::approveCall::abi_decode(data).ok()) + .find(|call| call.spender == BRIDGE_HELPER) + .ok_or(UsdtError::InvalidResponse)?; + if total > allowance.amount { + return Err(UsdtError::QuoteExpired); + } + Ok(()) + } + async fn transfer_calls( &self, recipient: Address, @@ -628,6 +735,9 @@ impl UsdtWallet { if token != TOKEN || helper_token != TOKEN || peer.is_zero() { return Err(UsdtError::UnsupportedRoute); } + if recipient.into_word() == peer { + return Err(UsdtError::InvalidAddress); + } let mut param = SendParam { dstEid: eid, to: recipient.into_word(), @@ -655,7 +765,7 @@ impl UsdtWallet { return Err(UsdtError::InvalidAmount); } param.minAmountLD = oft.receipt.amountReceivedLD; - let fee = self + let mut fee = self .rpc .contract( OFT, @@ -665,6 +775,8 @@ impl UsdtWallet { }, ) .await?; + // Native headroom is quoted into the approved USDT maximum. + fee.nativeFee = with_margin(fee.nativeFee, 10)?; let maximum_native = self .rpc .contract(BRIDGE_HELPER, BridgeHelper::maxGasCall {}) @@ -692,7 +804,7 @@ impl UsdtWallet { let token_fee = total .checked_sub(U256::from(amount)) .ok_or(UsdtError::InvalidResponse)?; - let token_fee = with_margin(token_fee)?; + let token_fee = with_margin(token_fee, 20)?; let approval = U256::from(amount) .checked_add(token_fee) .ok_or(UsdtError::InvalidResponse)?; @@ -736,3 +848,15 @@ impl UsdtWallet { pub(super) fn now() -> u64 { chrono::Utc::now().timestamp().max(0) as u64 } + +fn with_margin(value: U256, percent: u8) -> Result { + value + .checked_add( + value + .checked_mul(U256::from(percent)) + .ok_or(UsdtError::InvalidResponse)? + / U256::from(100), + ) + .and_then(|value| value.checked_add(U256::from(1))) + .ok_or(UsdtError::InvalidResponse) +} diff --git a/tests/usdt-fork/provider.mjs b/tests/usdt-fork/provider.mjs index e4650a3..43453d6 100644 --- a/tests/usdt-fork/provider.mjs +++ b/tests/usdt-fork/provider.mjs @@ -109,7 +109,12 @@ function pack(op) { signature: op.signature, }; } +let drainHelperBeforeBroadcast = false; async function dispatch(method, params) { + if (method === 'test_drainHelperBeforeNextBroadcast') { + drainHelperBeforeBroadcast = true; + return true; + } if (method === 'eth_estimateUserOperationGas' || method === 'eth_sendUserOperation') { const op = params[0]; assert.equal(op.factory, '0x7702'); @@ -138,6 +143,7 @@ async function dispatch(method, params) { }; if (method === 'pimlico_getUserOperationGasPrice') return { + slow: { maxFeePerGas: toBeHex(90_000_000), maxPriorityFeePerGas: toBeHex(1_000_000) }, fast: { maxFeePerGas: toBeHex(100_000_000), maxPriorityFeePerGas: toBeHex(1_000_000) }, }; if (method === 'eth_estimateUserOperationGas') return gas; @@ -160,6 +166,10 @@ async function dispatch(method, params) { return { paymaster: pmAddress, paymasterData: concat([unsigned, signature]), ...limits }; } if (method === 'eth_sendUserOperation') { + if (drainHelperBeforeBroadcast) { + drainHelperBeforeBroadcast = false; + await rpc.send('anvil_setBalance', ['0xa90f03c856d01f698e7071b393387cd75a8a319a', '0x0']); + } const op = pack(params[0]); const hash = await rpc.send('eth_call', [ { to: entryAddress, data: entry.interface.encodeFunctionData('getUserOpHash', [op]) }, From fee0bb955d0eec5933689b8b3757bd98dfd7d1ad Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 25 Sep 2026 09:13:12 +0300 Subject: [PATCH 3/6] fix: validate usdt deposit and recovery contracts --- Package.swift | 2 +- bindings/ios/bitkitcore.swift | 32 ++- src/modules/usdt/README.md | 10 +- src/modules/usdt/deposits.rs | 161 ++++++++++-- src/modules/usdt/errors.rs | 7 +- src/modules/usdt/history.rs | 150 ++++++----- src/modules/usdt/paymaster.rs | 22 +- src/modules/usdt/payment_request.rs | 15 +- src/modules/usdt/rpc.rs | 29 ++- src/modules/usdt/store.rs | 7 - src/modules/usdt/tests.rs | 386 ++++++++++++++++++++++------ src/modules/usdt/types.rs | 2 + src/modules/usdt/wallet.rs | 102 +++++--- tests/usdt-fork/provider.mjs | 7 +- 14 files changed, 682 insertions(+), 250 deletions(-) diff --git a/Package.swift b/Package.swift index 7891a15..95e162e 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription import Foundation let tag = "v0.6.0" -let checksum = "5e472658a387fd19a4fdcc82a1a6e88e4afd3caf90cfd0ef49eb962e06572c15" +let checksum = "f2b45736cef10e536d3c015385ab4f9bf9cb76fdf9b753a582c51a4df2ded99c" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 24aeca6..10490d8 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -16619,12 +16619,20 @@ public func FfiConverterTypeUsdtDepositPage_lower(_ value: UsdtDepositPage) -> R public struct UsdtPaymentRequest { public var recipient: String public var amount: UInt64? + /** + * An explicit network in the payment URI; bare addresses have no restriction. + */ + public var chainId: UInt64? // Default memberwise initializers are never public by default, so we // declare one manually. - public init(recipient: String, amount: UInt64?) { + public init(recipient: String, amount: UInt64?, + /** + * An explicit network in the payment URI; bare addresses have no restriction. + */chainId: UInt64?) { self.recipient = recipient self.amount = amount + self.chainId = chainId } } @@ -16641,12 +16649,16 @@ extension UsdtPaymentRequest: Equatable, Hashable { if lhs.amount != rhs.amount { return false } + if lhs.chainId != rhs.chainId { + return false + } return true } public func hash(into hasher: inout Hasher) { hasher.combine(recipient) hasher.combine(amount) + hasher.combine(chainId) } } @@ -16662,13 +16674,15 @@ public struct FfiConverterTypeUsdtPaymentRequest: FfiConverterRustBuffer { return try UsdtPaymentRequest( recipient: FfiConverterString.read(from: &buf), - amount: FfiConverterOptionUInt64.read(from: &buf) + amount: FfiConverterOptionUInt64.read(from: &buf), + chainId: FfiConverterOptionUInt64.read(from: &buf) ) } public static func write(_ value: UsdtPaymentRequest, into buf: inout [UInt8]) { FfiConverterString.write(value.recipient, into: &buf) FfiConverterOptionUInt64.write(value.amount, into: &buf) + FfiConverterOptionUInt64.write(value.chainId, into: &buf) } } @@ -24267,7 +24281,8 @@ public enum UsdtError: Swift.Error { case DepositNeedsAttention case DepositNotFound case DepositAuthorizationRejected - case DepositAmountOutOfRange + case DepositAmountOutOfRange(minUsdCents: String?, maxUsdCents: String? + ) case NotConfigured case NetworkUnavailable case RateLimited @@ -24306,7 +24321,10 @@ public struct FfiConverterTypeUsdtError: FfiConverterRustBuffer { case 11: return .DepositNeedsAttention case 12: return .DepositNotFound case 13: return .DepositAuthorizationRejected - case 14: return .DepositAmountOutOfRange + case 14: return .DepositAmountOutOfRange( + minUsdCents: try FfiConverterOptionString.read(from: &buf), + maxUsdCents: try FfiConverterOptionString.read(from: &buf) + ) case 15: return .NotConfigured case 16: return .NetworkUnavailable case 17: return .RateLimited @@ -24382,9 +24400,11 @@ public struct FfiConverterTypeUsdtError: FfiConverterRustBuffer { writeInt(&buf, Int32(13)) - case .DepositAmountOutOfRange: + case let .DepositAmountOutOfRange(minUsdCents,maxUsdCents): writeInt(&buf, Int32(14)) - + FfiConverterOptionString.write(minUsdCents, into: &buf) + FfiConverterOptionString.write(maxUsdCents, into: &buf) + case .NotConfigured: writeInt(&buf, Int32(15)) diff --git a/src/modules/usdt/README.md b/src/modules/usdt/README.md index f42611a..024ed44 100644 --- a/src/modules/usdt/README.md +++ b/src/modules/usdt/README.md @@ -18,15 +18,15 @@ Owned mnemonic/passphrase/seed buffers are zeroized and signing keys are erased The pinned ERC-20 paymaster collects USDT. Its finite approval includes a 5% margin; the displayed maximum fee comes from signed gas limits and paymaster terms, not the allowance. Call/pre-verification estimates receive 10% execution/L1-data headroom; the charged pre-verification margin is included in the maximum. A residual paymaster allowance can remain and is reset to a finite amount on the next payment. -`usdt_parse_payment_request` accepts raw addresses and chain-qualified ERC-681 requests for the pinned token, with exact atomic/scientific amounts. Ambiguous or unsupported parameters are rejected. The caller reviews the parsed amount before requesting a quote. +`usdt_parse_payment_request` accepts raw addresses and chain-qualified ERC-681 requests for the pinned token, with exact atomic/scientific amounts. Ambiguous or unsupported parameters are rejected. The returned `chain_id` preserves explicit network restrictions; bare addresses leave it unset. Callers must honor it and review the parsed amount before requesting a quote. ## Persistence and recovery Signed operations persist atomically before submission. Lost or rejected submission responses do not prove nonexecution: recovery retries only the identical signed operation. A quote ID cannot authorize a second payment. One source-chain payment remains pending at a time. -A matching operation event settles the payment. Expired signed paymaster terms and an unchanged confirmed EntryPoint nonce release an unmined operation; the shorter quote deadline does not. With an advanced nonce and missing indexed events, recovery checks every receipt in the consuming block. A matching event settles/replaces the payment; complete absence proves external nonce consumption. Missing receipts preserve the pending operation. Progress is stored by payment and block hash so interruption does not restart the proof or carry it onto another block. +A matching event in a canonical receipt settles the payment. Discovery logs alone never decide the outcome. Expired signed paymaster terms and a confirmed EntryPoint nonce that has not passed the signed nonce release an unmined operation; the shorter quote deadline does not. With an advanced nonce and missing indexed events, recovery checks every receipt in the consuming block. A matching event settles/replaces the payment; complete absence proves external nonce consumption. Missing receipts preserve the pending operation. Progress is stored by payment and block hash so interruption does not restart the proof or carry it onto another block. -Seed restoration recovers deposits and outgoing activity from genesis, including transfers before delegation and sends through another wallet. Supported direct EntryPoint calls recover payment/fee attribution; unknown wrappers preserve raw token transfers instead of guessing their intent. Failed payments retain attempted amounts but have no delivered amount. +Seed restoration recovers deposits and outgoing activity from genesis, including transfers before delegation and sends through another wallet. Supported direct EntryPoint calls and paymaster modes recover payment/fee attribution; unknown wrappers or payment modes preserve raw token transfers instead of guessing their intent. Failed payments retain attempted amounts but have no delivered amount. `sync_history` returns `true` when caught up and `false` when more work remains. It uses adaptive log ranges and a 20-second soft budget between persisted receipts; an in-flight receipt may finish later. A single-block log overflow falls back to that block's individual receipts. Completed fallback scans are retained by canonical block hash within the revisit window. Zero/self transfers are discarded before enrichment. Network failures preserve completed work and never silently skip a block. @@ -40,11 +40,11 @@ Storage is wallet-specific and owned by the `UsdtWallet` object. Drop it before 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. -`UsdtDepositClient` signs Orchestra deposit registration, history, detail and explicit refund requests for the derived account. It uses a separate optional service endpoint; estimates do not imply delivery. A clock-skew error requires correcting the device clock. Source-network fees are paid by the sender. Partner provisioning, delivered deposits and refund acceptance are separate release checks. +`UsdtDepositClient` signs Orchestra deposit registration, history, detail and explicit refund requests for the derived account. It uses a separate optional service endpoint; estimates do not imply delivery. A clock-skew error requires correcting the device clock. Amount-limit errors carry the provider’s known USD limits so callers can explain rejected amounts. Source-network fees are paid by the sender. Partner provisioning, delivered deposits and refund acceptance are separate release checks. 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. -Bridge quotes include 10% native messaging-fee headroom and 20% token-conversion headroom, both within the displayed maximum USDT fee. Before signing, the stored native fee, helper liquidity and token approval are checked against current requirements without raising approved limits. Delivery checks process up to three transfers concurrently outside the send lock, with a ten-second request budget; failed lookups retain the last known status. +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. 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. 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. LayerZero status must match the operation GUID/pathway before confirmation; blocked delivery remains visible and never triggers an automatic paid retry. RPC providers see queried addresses; LayerZero Scan sees bridge transaction hashes. diff --git a/src/modules/usdt/deposits.rs b/src/modules/usdt/deposits.rs index 7b476b8..43923ad 100644 --- a/src/modules/usdt/deposits.rs +++ b/src/modules/usdt/deposits.rs @@ -2,15 +2,14 @@ use super::{ keys::{derive_key, key_address, parse_address}, rpc::{bounded_json, endpoint_client}, user_operation::sign_hash, - UsdtError, + UsdtDestination, UsdtError, }; -use alloy_primitives::{address, eip191_hash_message, Address}; +use alloy_primitives::{eip191_hash_message, Address}; use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize}; use serde_json::{json, Value}; use std::{sync::Arc, time::Duration}; use zeroize::Zeroizing; -const ETHEREUM_USDT: Address = address!("dAC17F958D2ee523a2206206994597C13D831ec7"); const TRON_USDT: &str = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] @@ -139,17 +138,16 @@ impl UsdtDepositClient { { return Err(UsdtError::InvalidResponse); } - validate_source_address(&result.address, network) - .map_err(|_| UsdtError::InvalidResponse)?; - if network == UsdtDepositNetwork::Ethereum - && parse_address(&result.address)? == self.address + if validate_source_address(&result.address, network) + .map_err(|_| UsdtError::InvalidResponse)? + == self.address { return Err(UsdtError::InvalidResponse); } result.uri = match network { UsdtDepositNetwork::Ethereum => format!( "ethereum:{}@1/transfer?address={}", - ETHEREUM_USDT.to_checksum(None), + UsdtDestination::Ethereum.token().to_checksum(None), result.address ), UsdtDepositNetwork::Tron => result.address.clone(), @@ -273,10 +271,16 @@ impl UsdtDepositClient { let error = value.unwrap_or_default(); return Err(match error["error"].as_str() { Some("not_configured") => UsdtError::NotConfigured, + Some("provider_unavailable") => UsdtError::NetworkUnavailable, Some("invalid_authorization") => UsdtError::DepositAuthorizationRejected, Some("not_found") => UsdtError::DepositNotFound, Some("clock_skew") => UsdtError::ClockSkew, - Some("amount_too_small" | "amount_too_large") => UsdtError::DepositAmountOutOfRange, + Some("amount_too_small" | "amount_too_large") => { + UsdtError::DepositAmountOutOfRange { + min_usd_cents: deposit_limit(&error["min_usd_cents"])?, + max_usd_cents: deposit_limit(&error["max_usd_cents"])?, + } + } Some("amount_exceeds_liquidity") => UsdtError::UnsupportedRoute, Some("invalid_refund_address") => UsdtError::InvalidAddress, Some("route_unavailable") => UsdtError::UnsupportedRoute, @@ -286,6 +290,14 @@ impl UsdtDepositClient { | "operator_required" | "standing_tron_refund_requires_operator", ) => UsdtError::DepositNeedsAttention, + _ if status == reqwest::StatusCode::UNAUTHORIZED + || status == reqwest::StatusCode::FORBIDDEN => + { + UsdtError::DepositAuthorizationRejected + } + _ if status.is_client_error() || status.is_redirection() => { + UsdtError::InvalidResponse + } _ => UsdtError::NetworkUnavailable, }); } @@ -293,14 +305,19 @@ impl UsdtDepositClient { } } -fn validate_source_address(value: &str, network: UsdtDepositNetwork) -> Result<(), UsdtError> { +fn validate_source_address(value: &str, network: UsdtDepositNetwork) -> Result { match network { UsdtDepositNetwork::Ethereum => { - if parse_address(value)? == ETHEREUM_USDT { + let address = parse_address(value)?; + if address == UsdtDestination::Ethereum.token() { return Err(UsdtError::InvalidAddress); } + Ok(address) } UsdtDepositNetwork::Tron => { + if value.len() != 34 || !value.starts_with('T') || !value.is_ascii() { + return Err(UsdtError::InvalidAddress); + } let payload = bitcoin::base58::decode_check(value).map_err(|_| UsdtError::InvalidAddress)?; if payload.len() != 21 @@ -310,9 +327,20 @@ fn validate_source_address(value: &str, network: UsdtDepositNetwork) -> Result<( { return Err(UsdtError::InvalidAddress); } + Ok(Address::from_slice(&payload[1..])) } } - Ok(()) +} + +fn deposit_limit(value: &Value) -> Result, UsdtError> { + if value.is_null() { + return Ok(None); + } + let value = value.as_str().ok_or(UsdtError::InvalidResponse)?; + if value.is_empty() || value.len() > 40 || !value.bytes().all(|c| c.is_ascii_digit()) { + return Err(UsdtError::InvalidResponse); + } + Ok(Some(value.into())) } fn number<'de, D: Deserializer<'de>>(deserializer: D) -> Result { @@ -366,11 +394,15 @@ mod tests { #[test] fn deposit_addresses_and_transport_reject_wrong_networks() { let tron = "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8"; - for bad in [TRON_USDT, "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"] { + for bad in [ + TRON_USDT, + "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb", + &"T".repeat(10000), + ] { assert!(validate_source_address(bad, UsdtDepositNetwork::Tron).is_err()); } assert!(validate_source_address( - ÐEREUM_USDT.to_checksum(None), + &UsdtDestination::Ethereum.token().to_checksum(None), UsdtDepositNetwork::Ethereum ) .is_err()); @@ -394,18 +426,25 @@ mod tests { } } - async fn service(responses: Vec<(u16, Value)>) -> (String, tokio::task::JoinHandle<()>) { + async fn service( + responses: Vec<(u16, Value)>, + ) -> (String, tokio::task::JoinHandle>) { use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}/v1/usdt/deposits", listener.local_addr().unwrap()); let task = tokio::spawn(async move { + tokio::time::timeout(Duration::from_secs(10), async move { + let mut requests = Vec::new(); for (status, value) in responses { let (socket, _) = listener.accept().await.unwrap(); let mut reader = BufReader::new(socket); + let mut first_line = String::new(); + reader.read_line(&mut first_line).await.unwrap(); + assert!(first_line == "GET /v1/usdt/deposits HTTP/1.1\r\n" || first_line == "POST /v1/usdt/deposits HTTP/1.1\r\n"); let mut length = 0; loop { let mut line = String::new(); - reader.read_line(&mut line).await.unwrap(); + assert_ne!(reader.read_line(&mut line).await.unwrap(), 0, "Request ended before its headers"); if line == "\r\n" { break; } @@ -413,10 +452,16 @@ mod tests { length = value.trim().parse().unwrap(); } } - reader.read_exact(&mut vec![0; length]).await.unwrap(); + let mut bytes = vec![0; length]; + reader.read_exact(&mut bytes).await.unwrap(); + if first_line.starts_with("POST") { + requests.push(serde_json::from_slice::(&bytes).unwrap()); + } let body = value.to_string(); reader.get_mut().write_all(format!("HTTP/1.1 {status} Response\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); } + requests + }).await.expect("Deposit fixture requests must complete") }); (url, task) } @@ -428,7 +473,7 @@ mod tests { let deposit = json!({"id":"dep_one","network":"ethereum","asset":"USDT","source_tx":"0xsource","status":"held","code":null,"refund_tx":null}); let (url, server) = service(vec![ (200, json!({"networks":["ethereum","tron","bitcoin"]})), - (200, json!({"network":"ethereum","address":address,"recipient":owner,"amount":"100000000","estimated_received":"98500000","slippage_bps":50,"uri":123})), + (200, json!({"network":"ethereum","address":address,"recipient":owner,"amount":"100000000","estimated_received":"98500000","slippage_bps":50,"min_usd_cents":"200","max_usd_cents":"10000","uri":123})), (200, json!({"deposits":[deposit],"next_offset":50})), (200, json!({"deposit":deposit,"order":{"status":"held"}})), (202, json!({"status":"refund_requested"})), @@ -448,6 +493,8 @@ mod tests { .await .unwrap(); assert_eq!(received.estimated_received, 98_500_000); + assert_eq!(received.min_usd_cents.as_deref(), Some("200")); + assert_eq!(received.max_usd_cents.as_deref(), Some("10000")); assert!(received.uri.contains(address)); let page = client.history(0, PHRASE.into(), None).await.unwrap(); assert_eq!(page.deposits[0].amount, None); @@ -461,14 +508,36 @@ mod tests { .request_refund( "dep_one".into(), 0, - address.into(), + "0x2222222222222222222222222222222222222222".into(), UsdtDepositNetwork::Ethereum, PHRASE.into(), None, ) .await .unwrap(); - server.await.unwrap(); + let requests = server.await.unwrap(); + let expected = [ + json!({"action":"receive","network":"ethereum","amount":"100000000"}), + json!({"action":"history","offset":0}), + json!({"action":"detail","depositId":"dep_one","offset":0}), + json!({"action":"refund","depositId":"dep_one","offset":0,"refundAddress":"0x2222222222222222222222222222222222222222"}), + ]; + assert_eq!(requests.len(), expected.len()); + for (signed, payload) in requests.into_iter().zip(expected) { + let request: Value = serde_json::from_str(signed["request"].as_str().unwrap()).unwrap(); + assert_eq!(request["payload"], payload); + assert_eq!( + signed, + client + .authorize( + payload, + PHRASE.to_string().into(), + None, + request["timestamp"].as_u64().unwrap() + ) + .unwrap() + ); + } } #[tokio::test] @@ -480,8 +549,20 @@ mod tests { UsdtError::DepositAuthorizationRejected, ), ("clock_skew", UsdtError::ClockSkew), - ("amount_too_small", UsdtError::DepositAmountOutOfRange), - ("amount_too_large", UsdtError::DepositAmountOutOfRange), + ( + "amount_too_small", + UsdtError::DepositAmountOutOfRange { + min_usd_cents: None, + max_usd_cents: None, + }, + ), + ( + "amount_too_large", + UsdtError::DepositAmountOutOfRange { + min_usd_cents: None, + max_usd_cents: None, + }, + ), ("amount_exceeds_liquidity", UsdtError::UnsupportedRoute), ( "standing_tron_refund_requires_operator", @@ -489,7 +570,11 @@ mod tests { ), ("provider_unavailable", UsdtError::NetworkUnavailable), ] { - let (url, server) = service(vec![(400, json!({"error":code}))]).await; + let (url, server) = service(vec![( + 400, + json!({"error":code,"min_usd_cents":"200","max_usd_cents":"10000"}), + )]) + .await; let owner = super::super::usdt_address(PHRASE.into(), None).unwrap(); let client = UsdtDepositClient::new(owner, url).unwrap(); let error = client.history(0, PHRASE.into(), None).await.unwrap_err(); @@ -497,6 +582,34 @@ mod tests { std::mem::discriminant(&error), std::mem::discriminant(&expected) ); + if let UsdtError::DepositAmountOutOfRange { + min_usd_cents, + max_usd_cents, + } = error + { + assert_eq!(min_usd_cents.as_deref(), Some("200")); + assert_eq!(max_usd_cents.as_deref(), Some("10000")); + } + server.await.unwrap(); + } + for (status, expected) in [ + (429, UsdtError::RateLimited), + (401, UsdtError::DepositAuthorizationRejected), + (403, UsdtError::DepositAuthorizationRejected), + (404, UsdtError::InvalidResponse), + (502, UsdtError::NetworkUnavailable), + ] { + let (url, server) = service(vec![(status, json!({}))]).await; + let client = UsdtDepositClient::new( + super::super::usdt_address(PHRASE.into(), None).unwrap(), + url, + ) + .unwrap(); + let error = client.networks().await.unwrap_err(); + assert_eq!( + std::mem::discriminant(&error), + std::mem::discriminant(&expected) + ); server.await.unwrap(); } } @@ -505,7 +618,7 @@ mod tests { async fn invalid_service_addresses_and_nonadvancing_pages_are_rejected() { let owner = super::super::usdt_address(PHRASE.into(), None).unwrap(); let (url, server) = service(vec![ - (200, json!({"network":"ethereum","address":ETHEREUM_USDT,"recipient":owner,"amount":"100000000","estimated_received":"98500000","slippage_bps":50})), + (200, json!({"network":"ethereum","address":UsdtDestination::Ethereum.token(),"recipient":owner,"amount":"100000000","estimated_received":"98500000","slippage_bps":50})), (200, json!({"network":"ethereum","address":"0x1111111111111111111111111111111111111111","recipient":"invalid","amount":"100000000","estimated_received":"98500000","slippage_bps":50})), (200, json!({"deposits":[],"next_offset":0})), ]).await; diff --git a/src/modules/usdt/errors.rs b/src/modules/usdt/errors.rs index 9af564d..e098708 100644 --- a/src/modules/usdt/errors.rs +++ b/src/modules/usdt/errors.rs @@ -20,7 +20,7 @@ pub enum UsdtError { QuoteExpired, #[error("A USDT transaction is pending. Wait for confirmation before sending again")] PendingTransfer, - #[error("The selected USDT0 route is unavailable")] + #[error("The selected USDT payment route is unavailable")] UnsupportedRoute, #[error("This deposit needs provider assistance. Check its recovery status")] DepositNeedsAttention, @@ -29,7 +29,10 @@ pub enum UsdtError { #[error("The deposit service could not verify this request. Try again")] DepositAuthorizationRejected, #[error("The amount is outside this deposit route's limits. Review the minimum and maximum")] - DepositAmountOutOfRange, + DepositAmountOutOfRange { + min_usd_cents: Option, + max_usd_cents: Option, + }, #[error("USDT payments are not configured for this app build")] NotConfigured, #[error("The network could not be reached. Try again")] diff --git a/src/modules/usdt/history.rs b/src/modules/usdt/history.rs index 83ff6c9..9ea6a98 100644 --- a/src/modules/usdt/history.rs +++ b/src/modules/usdt/history.rs @@ -5,7 +5,7 @@ use super::{ types::{BRIDGE_HELPER, EXPLORER, OFT, TOKEN}, UsdtDestination, UsdtError, UsdtTransfer, UsdtTransferStatus, UsdtWallet, }; -use alloy_primitives::{Address, Bytes, U256}; +use alloy_primitives::{Address, Bytes, B256, U256}; use alloy_sol_types::{SolCall, SolEvent}; use serde_json::{json, Value}; use std::{collections::BTreeMap, sync::atomic::Ordering}; @@ -25,14 +25,14 @@ impl UsdtWallet { if start > tip { return Err(UsdtError::NetworkUnavailable); } - let mut ceiling = MAX_LOG_RANGE; + let initial_limit = self.history_range_limit.load(Ordering::Relaxed); + let mut ceiling = initial_limit; let mut next = start; - let mut width = self - .history_range_limit - .load(Ordering::Relaxed) - .min(tip - start + 1); + let mut width = initial_limit.min(tip - start + 1); while next <= tip { if tokio::time::Instant::now() >= deadline { + self.history_range_limit + .store((ceiling * 2).min(MAX_LOG_RANGE), Ordering::Relaxed); return Ok(false); } let end = next.saturating_add(width - 1).min(tip); @@ -46,7 +46,11 @@ impl UsdtWallet { Err(_) => { self.history_range_limit .store((width / 2).max(1), Ordering::Relaxed); - return Err(UsdtError::NetworkUnavailable); + return if next > start { + Ok(false) + } else { + Err(UsdtError::NetworkUnavailable) + }; } }; match result { @@ -54,7 +58,7 @@ impl UsdtWallet { if self.store.history_progress()? != Some(next) { self.store.save_history_progress(next)?; } - let mut timestamps = BTreeMap::new(); + let mut blocks = BTreeMap::new(); for ((block, hash), logs) in transactions { if self.store.has_history_receipt(&hash)? { continue; @@ -70,23 +74,32 @@ impl UsdtWallet { .and_then(|data| Erc20::Transfer::decode_log_data(&data).ok()) .is_some_and(|event| event.from == self.address) }); + let canonical = match blocks.entry(block) { + std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(), + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(self.rpc.block(block).await?) + } + }; + for log in &logs { + if serde_json::from_value::(log["blockHash"].clone())? + != canonical.hash + { + return Err(UsdtError::NetworkUnavailable); + } + } let receipt = if needs_receipt { self.rpc - .call("eth_getTransactionReceipt", json!([hash])) + .block_receipt( + hash.parse().map_err(|_| UsdtError::InvalidResponse)?, + canonical.hash, + block, + ) .await? } else { json!({"logs": logs}) }; - let timestamp = - if let Some(timestamp) = self.store.transaction_timestamp(&hash)? { - timestamp - } else if let Some(timestamp) = timestamps.get(&block) { - *timestamp - } else { - let timestamp = self.block_timestamp(block).await?; - timestamps.insert(block, timestamp); - timestamp - }; + let timestamp = u64::try_from(canonical.timestamp) + .map_err(|_| UsdtError::InvalidResponse)?; self.save_receipt_history(&hash, timestamp, &receipt) .await?; } @@ -101,16 +114,26 @@ impl UsdtWallet { if !self.scan_block(next, deadline).await? { return Ok(false); } + // A dense block is not a range limit for the blocks after it. + ceiling = MAX_LOG_RANGE; + width = MAX_LOG_RANGE; } Err(UsdtError::NetworkUnavailable) => { self.history_range_limit .store((width / 2).max(1), Ordering::Relaxed); - return Err(UsdtError::NetworkUnavailable); + return if next > start { + Ok(false) + } else { + Err(UsdtError::NetworkUnavailable) + }; } Err(error) => return Err(error), } if end == tip { self.store.complete_history(tip)?; + // Probe for a higher provider limit only after completing a scan. + self.history_range_limit + .store((ceiling * 2).min(MAX_LOG_RANGE), Ordering::Relaxed); return Ok(true); } next = end + 1; @@ -144,7 +167,7 @@ impl UsdtWallet { if tokio::time::Instant::now() >= deadline { return Ok(false); } - let receipt = self.rpc.block_receipt(*hash, &block, number).await?; + let receipt = self.rpc.block_receipt(*hash, block.hash, number).await?; self.save_receipt_history(&id, timestamp, &receipt).await?; } if self.rpc.block(number).await?.hash != block.hash { @@ -191,7 +214,8 @@ impl UsdtWallet { continue; } } - let hash: String = serde_json::from_value(log["transactionHash"].clone())?; + let hash: B256 = serde_json::from_value(log["transactionHash"].clone())?; + let hash = format!("{hash:#x}"); let block = u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) .map_err(|_| UsdtError::InvalidResponse)?; if !(start..=end).contains(&block) { @@ -263,6 +287,16 @@ impl UsdtWallet { .rpc .call("eth_getTransactionByHash", json!([hash])) .await?; + if tx.is_null() { + return Err(UsdtError::NetworkUnavailable); + } + if serde_json::from_value::(tx["hash"].clone())? + != hash + .parse::() + .map_err(|_| UsdtError::InvalidResponse)? + { + return Err(UsdtError::InvalidResponse); + } let input: Bytes = serde_json::from_value(tx["input"].clone())?; let target: Option
= serde_json::from_value(tx["to"].clone())?; // A wrapper's outer calldata need not describe the operation it executes. @@ -291,7 +325,10 @@ impl UsdtWallet { }) else { continue; }; - let Some((recipient, amount, destination)) = decode_payment(&op.callData)? else { + if !super::paymaster::supported_payment(&op.paymasterAndData) { + continue; + } + let Some((recipient, amount, destination)) = decode_payment(&op.callData) else { continue; }; (recipient.to_checksum(None), amount, destination) @@ -311,7 +348,7 @@ impl UsdtWallet { timestamp, explorer_url: format!("{EXPLORER}/tx/{hash}"), }; - self.settle(&mut transfer, receipt, event.success)?; + self.settle(&mut transfer, receipt)?; let operation_logs = super::transaction::operation_logs(receipt, event.userOpHash)?; let outgoing_ids: Vec<_> = operation_logs .iter() @@ -327,54 +364,39 @@ impl UsdtWallet { } } -fn decode_payment(data: &[u8]) -> Result, UsdtError> { - let Ok(calls) = decode_calls(data) else { - return Ok(None); - }; +fn decode_payment(data: &[u8]) -> Option<(Address, u64, UsdtDestination)> { let mut payment = None; - let mut payment_count = 0; - let mut supported = true; - for (target, data) in calls { - if target == TOKEN { + for (target, data) in decode_calls(data).ok()? { + let next = if target == TOKEN { if let Ok(call) = Erc20::transferCall::abi_decode(&data) { - payment_count += 1; - let Ok(amount) = token_amount(call.amount) else { - return Ok(None); - }; - payment = Some((call.recipient, amount, UsdtDestination::Arbitrum)); - } else if Erc20::approveCall::abi_decode(&data).is_err() { - supported = false; + ( + call.recipient, + token_amount(call.amount).ok()?, + UsdtDestination::Arbitrum, + ) + } else if Erc20::approveCall::abi_decode(&data).is_ok() { + continue; + } else { + return None; } } else if target == BRIDGE_HELPER { - if let Ok(call) = BridgeHelper::sendCall::abi_decode(&data) { - if call.oft != OFT { - supported = false; - continue; - } - let Some(destination) = UsdtDestination::from_endpoint(call.param.dstEid) else { - supported = false; - continue; - }; - payment_count += 1; - payment = Some(( - Address::from_word(call.param.to), - match token_amount(call.param.amountLD) { - Ok(amount) => amount, - Err(_) => return Ok(None), - }, - destination, - )); - } else { - supported = false; + let call = BridgeHelper::sendCall::abi_decode(&data).ok()?; + if call.oft != OFT { + return None; } + ( + Address::from_word(call.param.to), + token_amount(call.param.amountLD).ok()?, + UsdtDestination::from_endpoint(call.param.dstEid)?, + ) } else { - supported = false; + return None; + }; + if payment.replace(next).is_some() { + return None; } } - if !supported || payment_count != 1 { - return Ok(None); - } - Ok(payment) + payment } pub(super) fn decode_calls(data: &[u8]) -> Result, UsdtError> { diff --git a/src/modules/usdt/paymaster.rs b/src/modules/usdt/paymaster.rs index cef65e7..f680706 100644 --- a/src/modules/usdt/paymaster.rs +++ b/src/modules/usdt/paymaster.rs @@ -205,11 +205,14 @@ impl Pimlico { return Err(UsdtError::InvalidResponse); } op.paymaster_data = data.paymaster_data; - if let Some(gas) = data.paymaster_verification_gas_limit { - op.paymaster_verification_gas_limit = gas; - } - if let Some(gas) = data.paymaster_post_op_gas_limit { - op.paymaster_post_op_gas_limit = gas; + // Final data signs the estimated limits; only stub data supplies gas estimates. + if method == "pm_getPaymasterStubData" { + if let Some(gas) = data.paymaster_verification_gas_limit { + op.paymaster_verification_gas_limit = gas; + } + if let Some(gas) = data.paymaster_post_op_gas_limit { + op.paymaster_post_op_gas_limit = gas; + } } Ok(()) } @@ -347,6 +350,15 @@ impl Terms { } } +// Packed EntryPoint paymaster data starts after the address and two 16-byte gas limits. +pub(super) fn supported_payment(data: &[u8]) -> bool { + data.get(..20) + .is_some_and(|address| address == PAYMASTER.as_slice()) + && data + .get(52..) + .is_some_and(|terms| Terms::decode(terms).is_ok()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/modules/usdt/payment_request.rs b/src/modules/usdt/payment_request.rs index d28a174..d509f35 100644 --- a/src/modules/usdt/payment_request.rs +++ b/src/modules/usdt/payment_request.rs @@ -7,20 +7,21 @@ use alloy_primitives::Address; #[uniffi::export] pub fn usdt_parse_payment_request(value: String) -> Result { - let (recipient, amount) = parse_request(&value)?; + let (recipient, amount, chain_id) = parse_request(&value)?; Ok(UsdtPaymentRequest { recipient: recipient.to_checksum(None), amount, + chain_id, }) } -fn parse_request(value: &str) -> Result<(Address, Option), UsdtError> { +fn parse_request(value: &str) -> Result<(Address, Option, Option), UsdtError> { let value = value.trim(); if value.len() > 2048 { return Err(UsdtError::InvalidAddress); } let Some((scheme, uri)) = value.split_once(':') else { - return Ok((parse_address(value)?, None)); + return Ok((parse_address(value)?, None, None)); }; if !scheme.eq_ignore_ascii_case("ethereum") { return Err(UsdtError::InvalidAddress); @@ -35,7 +36,7 @@ fn parse_request(value: &str) -> Result<(Address, Option), UsdtError> { if chain != CHAIN_ID.to_string() || !query.is_empty() { return Err(UsdtError::WrongNetwork); } - return Ok((parse_address(address)?, None)); + return Ok((parse_address(address)?, None, Some(CHAIN_ID))); }; if chain != CHAIN_ID.to_string() || parse_address(address)? != TOKEN { return Err(UsdtError::WrongNetwork); @@ -53,5 +54,9 @@ fn parse_request(value: &str) -> Result<(Address, Option), UsdtError> { _ => return Err(UsdtError::InvalidAddress), } } - Ok((recipient.ok_or(UsdtError::InvalidAddress)?, amount)) + Ok(( + recipient.ok_or(UsdtError::InvalidAddress)?, + amount, + Some(CHAIN_ID), + )) } diff --git a/src/modules/usdt/rpc.rs b/src/modules/usdt/rpc.rs index 0d6af05..27e6b65 100644 --- a/src/modules/usdt/rpc.rs +++ b/src/modules/usdt/rpc.rs @@ -110,6 +110,7 @@ impl Rpc { "credit", "too many requests", "requests per", + "request rate", "compute units", ] .iter() @@ -117,10 +118,21 @@ impl Rpc { { return Err(UsdtError::RateLimited); } - if method == "eth_getLogs" && error.code == -32005 { + if method == "eth_getLogs" + && (error.code == -32005 + || [ + "block range too", + "block range exceeds", + "query returned too many", + "response size exceeded", + "log query limit", + ] + .iter() + .any(|term| message.contains(term))) + { return Err(UsdtError::LogRangeTooLarge); } - if error.code == -32002 { + if matches!(error.code, -32002 | -32603) { return Err(UsdtError::NetworkUnavailable); } if matches!( @@ -214,14 +226,15 @@ impl Rpc { } pub async fn block(&self, number: u64) -> Result { - self.call("eth_getBlockByNumber", json!([U256::from(number), false])) - .await + self.call::>("eth_getBlockByNumber", json!([U256::from(number), false])) + .await? + .ok_or(UsdtError::NetworkUnavailable) } pub async fn block_receipt( &self, hash: B256, - block: &Block, + block_hash: B256, number: u64, ) -> Result { let receipt: Value = self @@ -231,7 +244,7 @@ impl Rpc { return Err(UsdtError::NetworkUnavailable); } if serde_json::from_value::(receipt["transactionHash"].clone())? != hash - || serde_json::from_value::(receipt["blockHash"].clone())? != block.hash + || serde_json::from_value::(receipt["blockHash"].clone())? != block_hash || serde_json::from_value::(receipt["blockNumber"].clone())? != U256::from(number) { return Err(UsdtError::InvalidResponse); @@ -322,6 +335,10 @@ mod tests { 502, r#"{"error":{"code":-32002,"message":"Provider unavailable"}}"#, ), + ( + 200, + r#"{"error":{"code":-32603,"message":"Internal server error"}}"#, + ), (503, "Service unavailable"), ] { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/src/modules/usdt/store.rs b/src/modules/usdt/store.rs index 75212fc..3cd29e6 100644 --- a/src/modules/usdt/store.rs +++ b/src/modules/usdt/store.rs @@ -225,13 +225,6 @@ impl Store { Ok(()) } - pub fn transaction_timestamp(&self, hash: &str) -> Result, UsdtError> { - Ok(self.connection()?.query_row( - "SELECT json_extract(data, '$.timestamp') FROM usdt_transfers WHERE json_extract(data, '$.tx_hash')=?1 AND json_extract(data, '$.status') != 'Pending' LIMIT 1", - [hash], |row| row.get(0), - ).optional()?) - } - pub fn begin_history_block(&self, number: u64, hash: &str) -> Result { let mut connection = self.connection()?; let tx = connection.transaction()?; diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index b9838ae..d42098d 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -141,6 +141,16 @@ fn payment_request_rejects_wrong_chain_and_malformed_checksum() { usdt_parse_payment_request(request).unwrap().recipient, address ); + assert_eq!( + usdt_parse_payment_request(address.into()).unwrap().chain_id, + None + ); + assert_eq!( + usdt_parse_payment_request(format!("ethereum:{address}@42161")) + .unwrap() + .chain_id, + Some(42161) + ); for invalid in [ format!("ethereum:{address}@42161/transfer?address={address}"), format!("ethereum:{address}@1"), @@ -383,7 +393,20 @@ impl MockChain { if delay { tokio::time::sleep(std::time::Duration::from_secs(6)).await; } - let response = server_state.lock().unwrap().respond(&body).to_string(); + let mut response = server_state.lock().unwrap().respond(&body); + if body["method"] == "eth_getLogs" { + if let Some(logs) = response["result"].as_array_mut() { + for log in logs { + log.as_object_mut().unwrap().entry("blockHash").or_insert( + serde_json::json!(alloy_primitives::B256::repeat_byte(9)), + ); + } + } + } + if body["method"] == "eth_getTransactionByHash" && !response["result"].is_null() { + response["result"]["hash"] = body["params"][0].clone(); + } + let response = response.to_string(); let response=format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}",response.len()); let _ = socket.write_all(response.as_bytes()).await; } @@ -441,6 +464,30 @@ impl ChainState { let data: Bytes = serde_json::from_value(body["params"][0]["data"].clone()).unwrap(); use transaction::{BridgeHelper, MessagingFee, OFTLimit, OFTReceipt, Oft}; + let target: alloy_primitives::Address = + serde_json::from_value(body["params"][0]["to"].clone()).unwrap(); + if data.starts_with(&Oft::tokenCall::SELECTOR) { + assert!([types::OFT, types::BRIDGE_HELPER].contains(&target)); + } + if [ + Oft::peersCall::SELECTOR, + Oft::quoteOFTCall::SELECTOR, + Oft::quoteSendCall::SELECTOR, + ] + .iter() + .any(|selector| data.starts_with(selector)) + { + assert_eq!(target, types::OFT); + } + if [ + BridgeHelper::maxGasCall::SELECTOR, + BridgeHelper::quoteSendCall::SELECTOR, + ] + .iter() + .any(|selector| data.starts_with(selector)) + { + assert_eq!(target, types::BRIDGE_HELPER); + } let encoded = if data.starts_with(&transaction::EntryPoint::getNonceCall::SELECTOR) { let block = serde_json::from_value::(body["params"][1].clone()).ok(); @@ -515,6 +562,8 @@ impl ChainState { json!({"paymaster":self.paymaster,"paymasterData":Bytes::from(data)}); if body["method"] == "pm_getPaymasterStubData" { response["paymasterPostOpGasLimit"] = json!("0x186a0"); + } else { + response["paymasterVerificationGasLimit"] = json!("0xc350"); } response } @@ -906,50 +955,54 @@ async fn wrong_network_owner_nonce_balance_and_paymaster_cannot_sign() { #[tokio::test] async fn expired_unmined_operation_releases_nonce_for_a_new_approval() { - let chain = MockChain::start().await; - let dir = tempfile::tempdir().unwrap(); - let wallet = chain.wallet(&dir); - let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) - .await - .unwrap(); - wallet - .send(quote.id, TEST_PHRASE.into(), None) - .await - .unwrap(); - { - let mut state = chain.state.lock().unwrap(); - state.timestamp += alloy_primitives::U256::from(180); - state.tip += 3; - state.max_log_range = Some(1); - } - assert_eq!( - wallet.refresh_transfers().await.unwrap()[0].status, - UsdtTransferStatus::Pending - ); - assert!(matches!( - wallet + for nonce in [0, 1] { + let chain = MockChain::start().await; + chain.state.lock().unwrap().nonce = nonce; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) - .await, - Err(UsdtError::PendingTransfer) - )); - chain.state.lock().unwrap().timestamp += alloy_primitives::U256::from(421); - assert_eq!( - wallet.refresh_transfers().await.unwrap()[0].status, - UsdtTransferStatus::Failed - ); - let next = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) - .await - .unwrap(); - assert_eq!( + .await + .unwrap(); wallet - .send(next.id, TEST_PHRASE.into(), None) + .send(quote.id, TEST_PHRASE.into(), None) .await - .unwrap() - .status, - UsdtTransferStatus::Pending - ); + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.nonce = 0; + state.timestamp += alloy_primitives::U256::from(180); + state.tip += 3; + state.max_log_range = Some(1); + } + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Pending + ); + assert!(matches!( + wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await, + Err(UsdtError::PendingTransfer) + )); + chain.state.lock().unwrap().timestamp += alloy_primitives::U256::from(421); + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Failed + ); + let next = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + assert_eq!( + wallet + .send(next.id, TEST_PHRASE.into(), None) + .await + .unwrap() + .status, + UsdtTransferStatus::Pending + ); + } } #[tokio::test] @@ -1023,6 +1076,16 @@ async fn bridge_payment_bounds_token_fees_and_revokes_helper_approval() { .await, Err(UsdtError::UnsupportedRoute) )); + chain.state.lock().unwrap().helper_balance = U256::from(1_000_000_000_000_000u64); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + chain.state.lock().unwrap().helper_balance = U256::ZERO; + chain.state.lock().unwrap().tip += 3; + wallet.refresh_transfers().await.unwrap(); + assert_eq!(chain.state.lock().unwrap().operations.len(), 1); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); } #[tokio::test] @@ -1082,13 +1145,13 @@ async fn bundled_operations_cannot_contribute_another_payments_bridge_status_or_ .unwrap(); let other_hash = B256::repeat_byte(2); let guid = B256::repeat_byte(3); - let event = |hash| { + let event = |hash, success| { transaction::EntryPoint::UserOperationEvent { userOpHash: hash, sender: wallet.address, paymaster: paymaster::PAYMASTER, nonce: U256::ZERO, - success: true, + success, actualGasCost: U256::from(1), actualGasUsed: U256::from(1), } @@ -1098,22 +1161,24 @@ async fn bundled_operations_cannot_contribute_another_payments_bridge_status_or_ let receipt = json!({"logs":[ log(types::OFT, transaction::Oft::OFTSent { guid, dstEid:30109, fromAddress:types::BRIDGE_HELPER, amountSentLD:U256::from(1_000_000), amountReceivedLD:U256::from(1_000_000) }.encode_log_data()), log(types::BRIDGE_HELPER, transaction::BridgeHelper::LogSend { sender:wallet.address, oft:types::OFT, amountLD:U256::from(1_000_000), nativeFee:U256::from(100), feeInToken:U256::from(500), totalAmount:U256::from(1_000_500) }.encode_log_data()), - log(account::ENTRY_POINT, event(other_hash)), + log(account::ENTRY_POINT, event(other_hash, true)), log(paymaster::PAYMASTER, transaction::Paymaster::UserOperationSponsored { userOpHash:own_hash, user:wallet.address, paymasterMode:1, token:types::TOKEN, tokenAmountPaid:U256::from(123), exchangeRate:U256::from(1) }.encode_log_data()), - log(account::ENTRY_POINT, event(own_hash)), + log(account::ENTRY_POINT, event(own_hash, true)), ]}); assert_eq!( transaction::operation_logs(&receipt, own_hash).unwrap(), &receipt["logs"].as_array().unwrap()[3..] ); - wallet.settle(&mut transfer, &receipt, false).unwrap(); + let mut failed = receipt.clone(); + failed["logs"][4] = log(account::ENTRY_POINT, event(own_hash, false)); + wallet.settle(&mut transfer, &failed).unwrap(); assert_eq!(transfer.status, UsdtTransferStatus::Failed); assert_eq!(transfer.fee, Some(123)); assert_eq!(transfer.bridge_guid, None); - wallet.settle(&mut transfer, &receipt, true).unwrap(); + wallet.settle(&mut transfer, &receipt).unwrap(); assert_eq!(transfer.status, UsdtTransferStatus::BridgeNeedsAttention); assert_eq!(transfer.bridge_guid, None); - assert_eq!(transfer.fee, Some(123)); + assert_eq!(transfer.fee, None); let mut receipt = receipt; let bridge_log = receipt["logs"][1].clone(); @@ -1121,7 +1186,7 @@ async fn bundled_operations_cannot_contribute_another_payments_bridge_status_or_ .as_array_mut() .unwrap() .insert(4, bridge_log); - wallet.settle(&mut transfer, &receipt, true).unwrap(); + wallet.settle(&mut transfer, &receipt).unwrap(); assert_eq!(transfer.fee, Some(623)); } @@ -1134,13 +1199,17 @@ async fn deployed_contracts_collect_usdt_fees_and_revert_failed_bridges_atomical let client: String = rpc.call("web3_clientVersion", json!([])).await.unwrap(); assert!(client.to_lowercase().contains("anvil")); let dir = tempfile::tempdir().unwrap(); - let wallet = UsdtWallet::new( + let mut wallet = UsdtWallet::new( usdt_address(TEST_PHRASE.into(), None).unwrap(), dir.path().join("usdt.sqlite").to_string_lossy().into(), std::env::var("USDT_FORK_RPC_URL").unwrap_or_else(|_| "http://127.0.0.1:18545".into()), std::env::var("USDT_FORK_BUNDLER_URL").unwrap_or_else(|_| "http://127.0.0.1:18546".into()), ) .unwrap(); + std::sync::Arc::get_mut(&mut wallet) + .unwrap() + .rpc + .bridge_status_url = Some("http://127.0.0.1:18546".into()); assert_eq!(rpc.balance(wallet.address).await.unwrap(), U256::ZERO); let initial = wallet.balance().await.unwrap(); // Only locally mined transactions belong to this fixture's history. @@ -1588,7 +1657,17 @@ async fn replacement_after_expiry_recovers_pending_send_after_restart() { UsdtTransferStatus::Pending ); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); - chain.state.lock().unwrap().hide_logs = false; + { + let mut state = chain.state.lock().unwrap(); + state.hide_logs = false; + // The discovery log alone is insufficient; recovery verifies the consuming block. + state.hide_receipts = false; + state.receipt_response = Some(serde_json::json!({ + "transactionHash":alloy_primitives::B256::repeat_byte(7), + "blockHash":alloy_primitives::B256::repeat_byte(9), + "blockNumber":alloy_primitives::U256::from(507_000_000),"logs":[] + })); + } assert_eq!( wallet.refresh_transfers().await.unwrap()[0].status, UsdtTransferStatus::Replaced @@ -1613,6 +1692,7 @@ async fn history_distinguishes_rate_limits_from_log_range_limits() { for (code, message) in [ (-32005, "Rate limit exceeded"), (-32016, "Provider throttled"), + (-32000, "Request rate exceeded"), (-32000, "Invalid request"), ] { let chain = MockChain::start().await; @@ -1623,7 +1703,7 @@ async fn history_distinguishes_rate_limits_from_log_range_limits() { state.log_error = Some((code, message.into())); } let error = wallet.sync_history().await.unwrap_err(); - if code == -32000 { + if message == "Invalid request" { assert!(matches!(error, UsdtError::NetworkUnavailable)); } else { assert!(matches!(error, UsdtError::RateLimited)); @@ -1656,6 +1736,7 @@ async fn history_distinguishes_rate_limits_from_log_range_limits() { wallet .history_range_limit .store(1, std::sync::atomic::Ordering::Relaxed); + wallet.store.save_history_progress(19_990).unwrap(); sync_history_to_tip(&wallet).await; assert!( wallet @@ -1710,31 +1791,6 @@ async fn sync_history_to_tip(wallet: &UsdtWallet) { .expect("History must make progress within successive bounded scans"); } -#[tokio::test] -async fn deposit_http_throttling_is_distinct_from_network_failure() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let server = tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut request = [0u8; 4096]; - assert!(socket.read(&mut request).await.unwrap() > 0); - socket - .write_all( - b"HTTP/1.1 429 Too Many Requests\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .await - .unwrap(); - }); - let client = - UsdtDepositClient::new(usdt_address(TEST_PHRASE.into(), None).unwrap(), url).unwrap(); - assert!(matches!( - client.networks().await, - Err(UsdtError::RateLimited) - )); - server.await.unwrap(); -} - #[tokio::test] async fn invalid_chain_data_and_stored_json_have_distinct_errors() { let chain = MockChain::start().await; @@ -1848,7 +1904,7 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending state.timestamp += alloy_primitives::U256::from(601); } // Completion includes bridge polling and the shared chain/bundler request budget. - let history = tokio::time::timeout(Duration::from_secs(15), wallet.refresh_transfers()) + let history = tokio::time::timeout(Duration::from_secs(25), wallet.refresh_transfers()) .await .unwrap() .unwrap(); @@ -1895,15 +1951,23 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending refresh.await.unwrap().unwrap(); { let attempts = attempts.lock().unwrap(); - assert_ne!(attempts[0], attempts[1]); + assert_eq!( + attempts[..6] + .iter() + .collect::>() + .len(), + 5 + ); } // Healthy responses slower than an equal share of the budget must still settle. stalled.store(false, Ordering::SeqCst); + chain.state.lock().unwrap().tip += 3; + chain.state.lock().unwrap().log_error = Some((-32603, "provider unavailable".into())); for _ in 0..2 { - tokio::time::timeout(Duration::from_secs(15), wallet.refresh_transfers()) + tokio::time::timeout(Duration::from_secs(25), wallet.refresh_transfers()) .await .unwrap() - .unwrap(); + .unwrap_err(); } let history = wallet.history().unwrap(); assert!(bridges.iter().all(|bridge| history.iter().any( @@ -2465,6 +2529,11 @@ async fn settlement_requires_matching_canonical_receipts() { Err(UsdtError::InvalidResponse) )); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + assert!(matches!( + wallet.sync_history().await, + Err(UsdtError::InvalidResponse) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); } chain.state.lock().unwrap().receipt_response = Some(serde_json::Value::Null); assert!(matches!( @@ -2472,6 +2541,10 @@ async fn settlement_requires_matching_canonical_receipts() { Err(UsdtError::NetworkUnavailable) )); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + assert!(matches!( + wallet.sync_history().await, + Err(UsdtError::NetworkUnavailable) + )); chain.state.lock().unwrap().receipt_response = None; assert_eq!( wallet.refresh_transfers().await.unwrap()[0].status, @@ -2625,6 +2698,16 @@ async fn destination_tokens_are_not_payment_recipients() { let chain = MockChain::start().await; let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); + assert!(matches!( + wallet + .quote_transfer( + account::ENTRY_POINT.to_checksum(None), + 1_000_000, + UsdtDestination::Ethereum + ) + .await, + Err(UsdtError::InvalidAddress) + )); for destination in [ UsdtDestination::Arbitrum, UsdtDestination::Ethereum, @@ -2647,3 +2730,142 @@ async fn destination_tokens_are_not_payment_recipients() { } } } + +#[tokio::test] +async fn settlement_uses_the_canonical_operation_outcome() { + use alloy_primitives::{B256, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + for success in [false, true] { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + let op = &state.operations[0]; + let event = |success| { + transaction::EntryPoint::UserOperationEvent { + userOpHash: op.hash(types::CHAIN_ID).unwrap(), + sender: op.sender, + paymaster: paymaster::PAYMASTER, + nonce: op.nonce, + success, + actualGasCost: U256::from(1), + actualGasUsed: U256::from(1), + } + .encode_log_data() + }; + let receipt_event = event(success); + let log_event = event(!success); + let mut logs = state.event_logs(); + logs[1]["data"] = json!(log_event.data); + state.log_response = Some(vec![logs[1].clone()]); + let mut receipt = state.respond( + &json!({"method":"eth_getTransactionReceipt","params":[B256::repeat_byte(7)]}), + )["result"] + .clone(); + receipt["logs"][1]["data"] = json!(receipt_event.data); + state.receipt_response = Some(receipt); + } + let expected = if success { + UsdtTransferStatus::Confirmed + } else { + UsdtTransferStatus::Failed + }; + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + expected + ); + assert!(wallet.sync_history().await.unwrap()); + assert_eq!(wallet.history().unwrap()[0].status, expected); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); + } +} + +#[tokio::test] +async fn external_paymaster_modes_preserve_raw_debits_and_refunds() { + use alloy_primitives::{Address, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let movement = |from, to, value, index| { + let event = transaction::Erc20::Transfer { + from, + to, + value: U256::from(value), + } + .encode_log_data(); + json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"logIndex":U256::from(index)}) + }; + for flags in [2u8, 4u8] { + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip = 20003; + let mut data = state.operations[0].paymaster_data.to_vec(); + data[1] = flags; + state.operations[0].paymaster_data = data.into(); + let mut logs = state.event_logs(); + logs.insert( + 0, + movement(wallet.address, paymaster::PAYMASTER, 200u64, 2u64), + ); + logs.insert( + 1, + movement( + wallet.address, + RECIPIENT.parse::
().unwrap(), + 1_000_000u64, + 3u64, + ), + ); + logs.insert( + 2, + movement(paymaster::PAYMASTER, wallet.address, 50u64, 4u64), + ); + state.receipt_logs = Some(logs); + } + let restored_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restored_dir); + assert!(restored.sync_history().await.unwrap()); + let history = restored.history().unwrap(); + assert_eq!(history.len(), 3); + assert!(history.iter().all(|row| row.user_operation_hash.is_none())); + assert_eq!( + history + .iter() + .filter(|row| row.is_incoming) + .map(|row| row.amount) + .sum::(), + 50 + ); + assert_eq!( + history + .iter() + .filter(|row| !row.is_incoming) + .map(|row| row.amount) + .sum::(), + 1_000_200 + ); + } +} diff --git a/src/modules/usdt/types.rs b/src/modules/usdt/types.rs index 3ca70c1..4903348 100644 --- a/src/modules/usdt/types.rs +++ b/src/modules/usdt/types.rs @@ -48,6 +48,8 @@ impl UsdtDestination { pub struct UsdtPaymentRequest { pub recipient: String, pub amount: Option, + /// An explicit network in the payment URI; bare addresses have no restriction. + pub chain_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize, uniffi::Record)] diff --git a/src/modules/usdt/wallet.rs b/src/modules/usdt/wallet.rs index d456a5e..7874d9d 100644 --- a/src/modules/usdt/wallet.rs +++ b/src/modules/usdt/wallet.rs @@ -5,9 +5,7 @@ use super::{ paymaster::{Pimlico, PAYMASTER}, rpc::Rpc, store::{QuoteData, Store}, - transaction::{ - event_data, BridgeHelper, EntryPoint, Erc20, MessagingFee, Oft, Paymaster, Plan, SendParam, - }, + transaction::{event_data, BridgeHelper, EntryPoint, Erc20, Oft, Paymaster, Plan, SendParam}, types::{BRIDGE_HELPER, CHAIN_ID, EXPLORER, OFT, TOKEN}, user_operation::Authorization, UsdtDestination, UsdtError, UsdtQuote, UsdtTransfer, UsdtTransferStatus, @@ -90,6 +88,7 @@ impl UsdtWallet { let recipient = parse_address(recipient.trim())?; if recipient == self.address || recipient == destination.token() + || (destination == UsdtDestination::Ethereum && recipient == ENTRY_POINT) || (destination == UsdtDestination::Arbitrum && [ ENTRY_POINT, @@ -235,10 +234,19 @@ impl UsdtWallet { } pub async fn refresh_transfers(&self) -> Result, UsdtError> { + let pending = self.refresh_pending_transfers().await; + self.refresh_bridges(&self.store.unsettled()?).await?; + pending?; + self.history() + } +} + +impl UsdtWallet { + async fn refresh_pending_transfers(&self) -> Result<(), UsdtError> { let _guard = self.operation.lock().await; let transfers = self.store.unsettled()?; if transfers.is_empty() { - return self.history(); + return Ok(()); } if transfers .iter() @@ -321,10 +329,8 @@ impl UsdtWallet { if event.userOpHash == hash { self.settle_from_log(&mut transfer, log, event).await?; } else { - transfer.status = UsdtTransferStatus::Replaced; - transfer.received_amount = 0; - transfer.fee = Some(0); - self.store.update_transfer(&transfer)?; + self.reconcile_consumed_nonce(&mut transfer, &plan, block) + .await?; } matched = true; break; @@ -335,24 +341,22 @@ impl UsdtWallet { } } else { let expired = self.block_timestamp(confirmed_tip).await? > plan.expires_at; - if nonce == plan.operation.nonce && expired { + if expired { transfer.status = UsdtTransferStatus::Failed; transfer.received_amount = 0; transfer.fee = Some(0); self.store.update_transfer(&transfer)?; - } else if !expired { - let _ = self.broadcast(&plan, hash).await; + } else { + if self.validate_bridge(&plan).await.is_ok() { + let _ = self.broadcast(&plan, hash).await; + } } } } } - drop(_guard); - self.refresh_bridges(&self.store.unsettled()?).await?; - self.history() + Ok(()) } -} -impl UsdtWallet { async fn reconcile_consumed_nonce( &self, transfer: &mut UsdtTransfer, @@ -370,7 +374,7 @@ impl UsdtWallet { if tokio::time::Instant::now() >= deadline { return Ok(()); } - let receipt = self.rpc.block_receipt(*hash, &block, number).await?; + let receipt = self.rpc.block_receipt(*hash, block.hash, number).await?; for log in receipt["logs"] .as_array() .ok_or(UsdtError::InvalidResponse)? @@ -391,7 +395,7 @@ impl UsdtWallet { } transfer.tx_hash = format!("{hash:#x}"); transfer.explorer_url = format!("{EXPLORER}/tx/{hash:#x}"); - self.settle(transfer, &receipt, event.success)?; + self.settle(transfer, &receipt)?; } else { transfer.status = UsdtTransferStatus::Replaced; transfer.received_amount = 0; @@ -411,6 +415,8 @@ impl UsdtWallet { transfer.status = UsdtTransferStatus::Replaced; transfer.received_amount = 0; transfer.fee = Some(0); + transfer.timestamp = + u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; self.store.update_transfer(transfer) } @@ -428,7 +434,11 @@ impl UsdtWallet { if bridges.is_empty() { return Ok(()); } - let offset = self.bridge_poll_offset.fetch_add(3, Ordering::Relaxed) % bridges.len(); + let batch = [0, 1, 2]; + let offset = self + .bridge_poll_offset + .fetch_add(batch.len(), Ordering::Relaxed) + % bridges.len(); bridges.rotate_left(offset); let bridges = &bridges; let check = |index: usize| async move { @@ -442,7 +452,8 @@ impl UsdtWallet { .await, )) }; - let (first, second, third) = tokio::join!(check(0), check(1), check(2)); + 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() { match result { Ok(Ok(status)) if status != previous.status => { @@ -450,7 +461,7 @@ impl UsdtWallet { let Some(mut current) = self.store.transfer(&previous.id)? else { continue; }; - if current.tx_hash == previous.tx_hash + if current.tx_hash.eq_ignore_ascii_case(&previous.tx_hash) && current.bridge_guid == previous.bridge_guid && current.status == previous.status { @@ -472,11 +483,7 @@ impl UsdtWallet { .map_err(|_| UsdtError::InvalidResponse) } pub(super) async fn block_timestamp(&self, number: u64) -> Result { - let block: Value = self - .rpc - .call("eth_getBlockByNumber", json!([U256::from(number), false])) - .await?; - u64::try_from(serde_json::from_value::(block["timestamp"].clone())?) + u64::try_from(self.rpc.block(number).await?.timestamp) .map_err(|_| UsdtError::InvalidResponse) } async fn token_balance(&self) -> Result { @@ -548,17 +555,17 @@ impl UsdtWallet { if event.sender != self.address || event.paymaster != PAYMASTER { return Err(UsdtError::InvalidResponse); } - transfer.tx_hash = serde_json::from_value(log["transactionHash"].clone())?; + let hash: B256 = serde_json::from_value(log["transactionHash"].clone())?; + transfer.tx_hash = format!("{hash:#x}"); transfer.explorer_url = format!("{EXPLORER}/tx/{}", transfer.tx_hash); let number = u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) .map_err(|_| UsdtError::InvalidResponse)?; let block = self.rpc.block(number).await?; - let hash = transfer - .tx_hash - .parse() - .map_err(|_| UsdtError::InvalidResponse)?; - let receipt = self.rpc.block_receipt(hash, &block, number).await?; - self.settle(transfer, &receipt, event.success)?; + if serde_json::from_value::(log["blockHash"].clone())? != block.hash { + return Err(UsdtError::NetworkUnavailable); + } + let receipt = self.rpc.block_receipt(hash, block.hash, number).await?; + self.settle(transfer, &receipt)?; transfer.timestamp = u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; self.store.update_transfer(transfer) @@ -600,7 +607,6 @@ impl UsdtWallet { &self, transfer: &mut UsdtTransfer, receipt: &Value, - success: bool, ) -> Result<(), UsdtError> { let operation_hash: B256 = transfer .user_operation_hash @@ -609,6 +615,16 @@ impl UsdtWallet { .parse() .map_err(|_| UsdtError::InvalidResponse)?; let logs = super::transaction::operation_logs(receipt, operation_hash)?; + let event = EntryPoint::UserOperationEvent::decode_log_data(&event_data( + logs.last().ok_or(UsdtError::InvalidResponse)?, + )?) + .map_err(|_| UsdtError::InvalidResponse)?; + if event.userOpHash != operation_hash + || event.sender != self.address + || event.paymaster != PAYMASTER + { + return Err(UsdtError::InvalidResponse); + } let mut gas_fee = None; let mut bridge_fee = None; for log in logs { @@ -625,7 +641,7 @@ impl UsdtWallet { } } } - if success && address == BRIDGE_HELPER { + if event.success && address == BRIDGE_HELPER { if let Ok(event) = BridgeHelper::LogSend::decode_log_data(&data) { if event.sender == self.address && event.oft == OFT @@ -635,7 +651,7 @@ impl UsdtWallet { } } } - if success && address == OFT { + if event.success && address == OFT { if let Ok(event) = Oft::OFTSent::decode_log_data(&data) { if event.fromAddress == BRIDGE_HELPER && transfer.destination.endpoint() == Some(event.dstEid) @@ -647,8 +663,14 @@ impl UsdtWallet { } } } - transfer.fee = gas_fee.and_then(|fee| fee.checked_add(bridge_fee.unwrap_or(0))); - transfer.status = if !success { + transfer.fee = if event.success && transfer.destination != UsdtDestination::Arbitrum { + gas_fee + .zip(bridge_fee) + .and_then(|(gas, bridge)| gas.checked_add(bridge)) + } else { + gas_fee + }; + transfer.status = if !event.success { transfer.received_amount = 0; UsdtTransferStatus::Failed } else if transfer.destination == UsdtDestination::Arbitrum { @@ -797,10 +819,6 @@ impl UsdtWallet { }, ) .await?; - let fee = MessagingFee { - nativeFee: fee.nativeFee, - lzTokenFee: U256::ZERO, - }; let token_fee = total .checked_sub(U256::from(amount)) .ok_or(UsdtError::InvalidResponse)?; diff --git a/tests/usdt-fork/provider.mjs b/tests/usdt-fork/provider.mjs index 43453d6..c9bad84 100644 --- a/tests/usdt-fork/provider.mjs +++ b/tests/usdt-fork/provider.mjs @@ -153,7 +153,7 @@ async function dispatch(method, params) { const op = { ...params[0], paymaster: pmAddress, ...limits }; const unsigned = concat([ '0x0300', - toBeHex(Math.floor(Date.now() / 1000) + 600, 6), + toBeHex(BigInt((await rpc.send('eth_getBlockByNumber', ['latest', false])).timestamp) + 600n, 6), toBeHex(0, 6), tokenAddress, toBeHex(50000, 16), @@ -204,6 +204,11 @@ async function dispatch(method, params) { return rpc.send(method, params); } const server = createServer(async (request, response) => { + if (request.method === 'GET' && request.url.startsWith('/v1/messages/tx/')) { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ data: [] })); + return; + } let body = ''; for await (const chunk of request) body += chunk; const call = JSON.parse(body); From d8a003807fd7d3d6b9c366bbf3152e02ddc7a296 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 25 Sep 2026 11:09:14 +0300 Subject: [PATCH 4/6] fix: expose bounded direct USDT execution checks in deposit stack --- Package.swift | 2 +- bindings/ios/bitkitcore.swift | 54 +++++++++++++ bindings/ios/bitkitcoreFFI.h | 11 +++ src/modules/usdt/README.md | 2 + src/modules/usdt/tests.rs | 142 ++++++++++++++++++++++++++++++++-- src/modules/usdt/wallet.rs | 62 +++++++++++++++ 6 files changed, 264 insertions(+), 9 deletions(-) diff --git a/Package.swift b/Package.swift index 95e162e..0d8938a 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription import Foundation let tag = "v0.6.0" -let checksum = "f2b45736cef10e536d3c015385ab4f9bf9cb76fdf9b753a582c51a4df2ded99c" +let checksum = "805517301441e5cf7d26eb1f16a78f2994d65f1344c40ba10fcab728c8bb038d" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 10490d8..18b2504 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -2792,6 +2792,12 @@ public protocol UsdtWalletProtocol: AnyObject, Sendable { func receiveUri() -> String + /** + * Checks recent direct-payment execution at the current tip without scanning history or retrying submission. + * Missing evidence leaves the signed payment pending; confirmation is L2 execution, not parent-chain finality. + */ + func refreshTransfer(id: String) async throws -> UsdtTransfer? + func refreshTransfers() async throws -> [UsdtTransfer] func send(quoteId: String, mnemonic: String, passphrase: String?) async throws -> UsdtTransfer @@ -2917,6 +2923,27 @@ open func receiveUri() -> String { }) } + /** + * Checks recent direct-payment execution at the current tip without scanning history or retrying submission. + * Missing evidence leaves the signed payment pending; confirmation is L2 execution, not parent-chain finality. + */ +open func refreshTransfer(id: String)async throws -> UsdtTransfer? { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtwallet_refresh_transfer( + self.uniffiClonePointer(), + FfiConverterString.lower(id) + ) + }, + 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: FfiConverterOptionTypeUsdtTransfer.lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + open func refreshTransfers()async throws -> [UsdtTransfer] { return try await uniffiRustCallAsync( @@ -25719,6 +25746,30 @@ fileprivate struct FfiConverterOptionTypeUsdtDepositOrder: FfiConverterRustBuffe } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeUsdtTransfer: FfiConverterRustBuffer { + typealias SwiftType = UsdtTransfer? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeUsdtTransfer.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 FfiConverterTypeUsdtTransfer.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -30735,6 +30786,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_usdtwallet_receive_uri() != 33484) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfer() != 58151) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfers() != 32305) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index 24fb403..5b79199 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -740,6 +740,11 @@ RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_receive_address(void*_Nonnull RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_receive_uri(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFER +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFER +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_refresh_transfer(void*_Nonnull ptr, RustBuffer id +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFERS #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFERS uint64_t uniffi_bitkitcore_fn_method_usdtwallet_refresh_transfers(void*_Nonnull ptr @@ -3561,6 +3566,12 @@ uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_receive_address(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_RECEIVE_URI uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_receive_uri(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_REFRESH_TRANSFER +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_REFRESH_TRANSFER +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfer(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_REFRESH_TRANSFERS diff --git a/src/modules/usdt/README.md b/src/modules/usdt/README.md index 024ed44..5cc3a72 100644 --- a/src/modules/usdt/README.md +++ b/src/modules/usdt/README.md @@ -30,6 +30,8 @@ Seed restoration recovers deposits and outgoing activity from genesis, including `sync_history` returns `true` when caught up and `false` when more work remains. It uses adaptive log ranges and a 20-second soft budget between persisted receipts; an in-flight receipt may finish later. A single-block log overflow falls back to that block's individual receipts. Completed fallback scans are retained by canonical block hash within the revisit window. Zero/self transfers are discarded before enrichment. Network failures preserve completed work and never silently skip a block. +`refresh_transfer` checks one recent direct Arbitrum payment with a five-second request budget. It requires the expected operation outcome and token transfer in a matching canonical receipt and does not scan history, rebroadcast, expire payments or reconcile nonces. It can confirm execution at the current L2 tip; this is provisional sequencer execution, not parent-chain finality. Native send screens may call it approximately once per second during a short foreground window, with cancellation and rate-limit backoff between checks. Missing evidence leaves Pending intact. Normal recovery handles older payments outside its 64-block lookup window. + Scans trail the reported tip by two blocks and revisit 4096 blocks for delayed indexing. This is not reorg rollback: previously recorded orphaned activity is not retracted. Providers must supply complete filtered logs, canonical blocks/receipts and historical state. Payment outcomes and expiry decisions trust the configured chain RPC. A malicious RPC can fabricate or suppress evidence and mislead a user into authorizing another payment; these checks are not light-client proofs. diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index d42098d..91d747c 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -670,7 +670,11 @@ impl ChainState { && (filter["address"] == json!(account::ENTRY_POINT) || filter["address"].is_array()) { - json!([self.event_logs()[1]]) + json!([self + .event_logs() + .into_iter() + .find(|log| log["address"] == json!(account::ENTRY_POINT.to_checksum(None))) + .unwrap()]) } else { json!([]) } @@ -717,7 +721,7 @@ impl ChainState { } fn event_logs(&self) -> Vec { use alloy_primitives::{B256, U256}; - use alloy_sol_types::SolEvent; + use alloy_sol_types::{SolCall, SolEvent}; use serde_json::json; let op = self.operations.first().unwrap(); let hash = op.hash(types::CHAIN_ID).unwrap(); @@ -744,6 +748,26 @@ impl ChainState { json!({"address":paymaster::PAYMASTER,"topics":gas.topics(),"data":gas.data,"transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":"0x0"}), json!({"address":account::ENTRY_POINT.to_checksum(None),"topics":event.topics(),"data":event.data,"transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":"0x1"}), ]; + for (target, data) in history::decode_calls(&op.call_data).unwrap_or_default() { + if target == types::TOKEN { + if let Ok(call) = transaction::Erc20::transferCall::abi_decode(&data) { + if call.amount > U256::from(u64::MAX) { + continue; + } + let payment = transaction::Erc20::Transfer { + from: op.sender, + to: call.recipient, + value: call.amount, + } + .encode_log_data(); + logs.insert(0, json!({"address":types::TOKEN,"topics":payment.topics(),"data":payment.data, + "transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":"0x0"})); + for (index, log) in logs.iter_mut().enumerate() { + log["logIndex"] = json!(format!("0x{index:x}")); + } + } + } + } if !self.mined { logs.clear(); } @@ -754,7 +778,7 @@ impl ChainState { value: U256::from(42), } .encode_log_data(); - logs.push(json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"logIndex":"0x2"})); + logs.push(json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"logIndex":format!("0x{:x}", logs.len())})); } if self.external_outgoing { let event = transaction::Erc20::Transfer { @@ -763,7 +787,7 @@ impl ChainState { value: U256::from(77), } .encode_log_data(); - logs.push(json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":"0x3"})); + logs.push(json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":format!("0x{:x}", logs.len())})); } logs } @@ -1355,7 +1379,7 @@ async fn history_preserves_receipts_with_external_account_call_shapes() { (single, None, true), (oversized, None, false), (Bytes::from_static(&[1, 2, 3, 4]), None, false), - (original, Some(Bytes::from_static(&[5, 6, 7, 8])), false), + (original, Some(Bytes::from_static(&[5, 6, 7, 8])), true), ] { { let mut state = chain.state.lock().unwrap(); @@ -1418,6 +1442,7 @@ async fn wrapped_history_preserves_signed_payments_and_restores_token_transfers( .into(), )]); let mut logs = state.event_logs(); + logs.retain(|log| log["address"] != json!(types::TOKEN)); for (recipient, amount) in [ (RECIPIENT.parse().unwrap(), 1_000_000), (paymaster::PAYMASTER, 123), @@ -2173,6 +2198,7 @@ async fn seed_restore_includes_external_token_sends_without_duplicate_operation_ if unsupported_batch { let mut state = chain.state.lock().unwrap(); state.mined = true; + state.external_outgoing = false; state.history_input = None; let mut calls = history::decode_calls(&state.operations[0].call_data).unwrap(); calls.push(( @@ -2524,6 +2550,10 @@ async fn settlement_requires_matching_canonical_receipts() { let mut invalid = receipt.clone(); invalid[field] = value; chain.state.lock().unwrap().receipt_response = Some(invalid); + assert!(matches!( + wallet.refresh_transfer(sent.id.clone()).await, + Err(UsdtError::InvalidResponse) + )); assert!(matches!( wallet.refresh_transfers().await, Err(UsdtError::InvalidResponse) @@ -2768,13 +2798,13 @@ async fn settlement_uses_the_canonical_operation_outcome() { let receipt_event = event(success); let log_event = event(!success); let mut logs = state.event_logs(); - logs[1]["data"] = json!(log_event.data); - state.log_response = Some(vec![logs[1].clone()]); + logs[2]["data"] = json!(log_event.data); + state.log_response = Some(vec![logs[2].clone()]); let mut receipt = state.respond( &json!({"method":"eth_getTransactionReceipt","params":[B256::repeat_byte(7)]}), )["result"] .clone(); - receipt["logs"][1]["data"] = json!(receipt_event.data); + receipt["logs"][2]["data"] = json!(receipt_event.data); state.receipt_response = Some(receipt); } let expected = if success { @@ -2826,6 +2856,7 @@ async fn external_paymaster_modes_preserve_raw_debits_and_refunds() { data[1] = flags; state.operations[0].paymaster_data = data.into(); let mut logs = state.event_logs(); + logs.retain(|log| log["address"] != json!(types::TOKEN)); logs.insert( 0, movement(wallet.address, paymaster::PAYMASTER, 200u64, 2u64), @@ -2869,3 +2900,98 @@ async fn external_paymaster_modes_preserve_raw_debits_and_refunds() { ); } } + +#[tokio::test] +async fn recent_execution_requires_the_expected_token_transfer() { + use alloy_primitives::U256; + use alloy_sol_types::SolEvent; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + chain.state.lock().unwrap().mined = true; + // Normal recovery still waits for its indexing buffer. + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Pending + ); + for (recipient, amount) in [ + (RECIPIENT.parse().unwrap(), 999u64), + (wallet.address, sent.amount), + ] { + let mut logs = chain.state.lock().unwrap().event_logs(); + let token = transaction::Erc20::Transfer { + from: wallet.address, + to: recipient, + value: U256::from(amount), + } + .encode_log_data(); + logs[0]["topics"] = json!(token.topics()); + logs[0]["data"] = json!(token.data); + chain.state.lock().unwrap().receipt_logs = Some(logs); + assert!(matches!( + wallet.refresh_transfer(sent.id.clone()).await, + Err(UsdtError::InvalidResponse) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + } + let mut logs = chain.state.lock().unwrap().event_logs(); + logs.remove(0); + chain.state.lock().unwrap().receipt_logs = Some(logs); + assert!(matches!( + wallet.refresh_transfer(sent.id.clone()).await, + Err(UsdtError::InvalidResponse) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + chain.state.lock().unwrap().receipt_logs = None; + let result = wallet + .refresh_transfer(sent.id.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(result.status, UsdtTransferStatus::Confirmed); + assert_eq!(result.fee, Some(123)); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); +} + +#[tokio::test] +async fn execution_check_preserves_unmined_payments_and_throttling() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.tip += 3; + state.timestamp += alloy_primitives::U256::from(1000); + } + let result = wallet + .refresh_transfer(sent.id.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(result.status, UsdtTransferStatus::Pending); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + assert_eq!(chain.state.lock().unwrap().operations.len(), 1); + chain.state.lock().unwrap().log_error = Some((-32016, "rate limit".into())); + assert!(matches!( + wallet.refresh_transfer(sent.id.clone()).await, + Err(UsdtError::RateLimited) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); +} diff --git a/src/modules/usdt/wallet.rs b/src/modules/usdt/wallet.rs index 7874d9d..3d205a9 100644 --- a/src/modules/usdt/wallet.rs +++ b/src/modules/usdt/wallet.rs @@ -223,6 +223,57 @@ impl UsdtWallet { Ok(transfer) } + /// Checks recent direct-payment execution at the current tip without scanning history or retrying submission. + /// Missing evidence leaves the signed payment pending; confirmation is L2 execution, not parent-chain finality. + pub async fn refresh_transfer(&self, id: String) -> Result, UsdtError> { + let check = async { + let _guard = self.operation.lock().await; + let Some(mut transfer) = self.store.transfer(&id)? else { + return Ok(None); + }; + if transfer.destination != UsdtDestination::Arbitrum { + return Ok(Some(transfer)); + } + let Some(plan) = self.store.pending_plan(&id)? else { + return Ok(Some(transfer)); + }; + self.rpc.verify_chain().await?; + let hash = plan.operation.hash(CHAIN_ID)?; + let tip = self.block_number().await?; + let start = plan.created_block.max(tip.saturating_sub(63)); + if start <= tip { + let logs: Vec = self.rpc.call("eth_getLogs", json!([{ + "address": ENTRY_POINT, "fromBlock": U256::from(start), "toBlock": U256::from(tip), + "topics": [EntryPoint::UserOperationEvent::SIGNATURE_HASH, hash, self.address.into_word()] + }])).await?; + if let Some(log) = logs + .iter() + .find(|log| log["removed"].as_bool() != Some(true)) + { + let event = EntryPoint::UserOperationEvent::decode_log_data(&event_data(log)?) + .map_err(|_| UsdtError::InvalidResponse)?; + let number = + u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) + .map_err(|_| UsdtError::InvalidResponse)?; + if serde_json::from_value::
(log["address"].clone())? != ENTRY_POINT + || event.userOpHash != hash + || event.nonce != plan.operation.nonce + || number < start + || number > tip + { + return Err(UsdtError::InvalidResponse); + } + self.settle_from_log(&mut transfer, log, event).await?; + } + } + Ok(Some(transfer)) + }; + match tokio::time::timeout(std::time::Duration::from_secs(5), check).await { + Ok(result) => result, + Err(_) => Err(UsdtError::NetworkUnavailable), + } + } + pub async fn sync_history(&self) -> Result { let _guard = self.operation.lock().await; self.rpc.verify_chain().await?; @@ -625,11 +676,19 @@ impl UsdtWallet { { return Err(UsdtError::InvalidResponse); } + let mut transfer_proven = false; let mut gas_fee = None; let mut bridge_fee = None; for log in logs { let address: Address = serde_json::from_value(log["address"].clone())?; let data = event_data(log)?; + if 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.value == U256::from(transfer.amount); + } + } if address == PAYMASTER { if let Ok(event) = Paymaster::UserOperationSponsored::decode_log_data(&data) { if event.userOpHash == operation_hash @@ -663,6 +722,9 @@ impl UsdtWallet { } } } + if event.success && transfer.destination == UsdtDestination::Arbitrum && !transfer_proven { + return Err(UsdtError::InvalidResponse); + } transfer.fee = if event.success && transfer.destination != UsdtDestination::Arbitrum { gas_fee .zip(bridge_fee) From 9acd8c250a731dfc372f21693adf839baf11f8a9 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 25 Sep 2026 16:18:58 +0300 Subject: [PATCH 5/6] fix: validate Orchestra receive terms and provider errors --- Package.swift | 2 +- bindings/ios/bitkitcore.swift | 143 +++-- bindings/ios/bitkitcoreFFI.h | 22 +- src/modules/usdt/README.md | 33 +- src/modules/usdt/amount.rs | 37 ++ src/modules/usdt/deposits.rs | 99 ++- src/modules/usdt/errors.rs | 2 +- src/modules/usdt/history.rs | 199 +++--- src/modules/usdt/keys.rs | 12 + src/modules/usdt/paymaster.rs | 27 +- src/modules/usdt/rpc.rs | 115 ++-- src/modules/usdt/store.rs | 167 ++--- src/modules/usdt/tests.rs | 1045 ++++++++++++++++++++++--------- src/modules/usdt/transaction.rs | 24 +- src/modules/usdt/types.rs | 21 +- src/modules/usdt/wallet.rs | 377 ++++++----- tests/usdt-fork/provider.mjs | 6 +- 17 files changed, 1536 insertions(+), 795 deletions(-) diff --git a/Package.swift b/Package.swift index 0d8938a..8bfbb08 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription import Foundation let tag = "v0.6.0" -let checksum = "805517301441e5cf7d26eb1f16a78f2994d65f1344c40ba10fcab728c8bb038d" +let checksum = "69e5c31277c413605fe495fa2bb2b8acd30d793a733cebcc8f9c25200b48357b" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 18b2504..c31a327 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -2784,6 +2784,13 @@ public protocol UsdtWalletProtocol: AnyObject, Sendable { func balance() async throws -> UInt64 + /** + * Checks recent direct-payment execution with a bounded request budget. + * Requires the expected operation and transfer in a canonical receipt; current-tip execution is provisional. + * Does not rebroadcast, expire payments or reconcile nonces. Missing evidence leaves the payment pending. + */ + func checkRecentExecution(id: String) async throws -> UsdtTransfer? + func history() throws -> [UsdtTransfer] func quoteTransfer(recipient: String, amount: UInt64, destination: UsdtDestination) async throws -> UsdtQuote @@ -2793,15 +2800,20 @@ public protocol UsdtWalletProtocol: AnyObject, Sendable { func receiveUri() -> String /** - * Checks recent direct-payment execution at the current tip without scanning history or retrying submission. - * Missing evidence leaves the signed payment pending; confirmation is L2 execution, not parent-chain finality. + * Reconciles pending execution using chain proofs and may rebroadcast the identical signed operation. */ - func refreshTransfer(id: String) async throws -> UsdtTransfer? - func refreshTransfers() async throws -> [UsdtTransfer] + /** + * Repeating a quote ID returns its stored outcome, which may already be failed or replaced. + * A pending outcome is durable and retryable; it does not imply bundler acceptance. + */ func send(quoteId: String, mnemonic: String, passphrase: String?) async throws -> UsdtTransfer + /** + * Saves resumable history progress; returns true when caught up and false when more work remains. + * Call between send flows. The soft budget permits an in-flight receipt to finish before yielding. + */ func syncHistory() async throws -> Bool } @@ -2844,6 +2856,9 @@ open class UsdtWallet: UsdtWalletProtocol, @unchecked Sendable { public func uniffiClonePointer() -> UnsafeMutableRawPointer { return try! rustCall { uniffi_bitkitcore_fn_clone_usdtwallet(self.pointer, $0) } } + /** + * 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 { let pointer = try rustCallWithError(FfiConverterTypeUsdtError_lift) { @@ -2885,6 +2900,28 @@ open func balance()async throws -> UInt64 { ) } + /** + * Checks recent direct-payment execution with a bounded request budget. + * Requires the expected operation and transfer in a canonical receipt; current-tip execution is provisional. + * Does not rebroadcast, expire payments or reconcile nonces. Missing evidence leaves the payment pending. + */ +open func checkRecentExecution(id: String)async throws -> UsdtTransfer? { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtwallet_check_recent_execution( + self.uniffiClonePointer(), + FfiConverterString.lower(id) + ) + }, + 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: FfiConverterOptionTypeUsdtTransfer.lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + open func history()throws -> [UsdtTransfer] { return try FfiConverterSequenceTypeUsdtTransfer.lift(try rustCallWithError(FfiConverterTypeUsdtError_lift) { uniffi_bitkitcore_fn_method_usdtwallet_history(self.uniffiClonePointer(),$0 @@ -2924,26 +2961,8 @@ open func receiveUri() -> String { } /** - * Checks recent direct-payment execution at the current tip without scanning history or retrying submission. - * Missing evidence leaves the signed payment pending; confirmation is L2 execution, not parent-chain finality. + * Reconciles pending execution using chain proofs and may rebroadcast the identical signed operation. */ -open func refreshTransfer(id: String)async throws -> UsdtTransfer? { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_bitkitcore_fn_method_usdtwallet_refresh_transfer( - self.uniffiClonePointer(), - FfiConverterString.lower(id) - ) - }, - 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: FfiConverterOptionTypeUsdtTransfer.lift, - errorHandler: FfiConverterTypeUsdtError_lift - ) -} - open func refreshTransfers()async throws -> [UsdtTransfer] { return try await uniffiRustCallAsync( @@ -2961,6 +2980,10 @@ open func refreshTransfers()async throws -> [UsdtTransfer] { ) } + /** + * Repeating a quote ID returns its stored outcome, which may already be failed or replaced. + * A pending outcome is durable and retryable; it does not imply bundler acceptance. + */ open func send(quoteId: String, mnemonic: String, passphrase: String?)async throws -> UsdtTransfer { return try await uniffiRustCallAsync( @@ -2978,6 +3001,10 @@ open func send(quoteId: String, mnemonic: String, passphrase: String?)async thro ) } + /** + * Saves resumable history progress; returns true when caught up and false when more work remains. + * Call between send flows. The soft budget permits an in-flight receipt to finish before yielding. + */ open func syncHistory()async throws -> Bool { return try await uniffiRustCallAsync( @@ -16843,7 +16870,10 @@ public func FfiConverterTypeUsdtQuote_lower(_ value: UsdtQuote) -> RustBuffer { public struct UsdtTransfer { public var id: String - public var txHash: String + /** + * Source transaction hash, absent until execution is observed. + */ + public var txHash: String? public var userOperationHash: String? public var bridgeGuid: String? public var recipient: String @@ -16854,11 +16884,13 @@ public struct UsdtTransfer { public var isIncoming: Bool public var status: UsdtTransferStatus public var timestamp: UInt64 - public var explorerUrl: String // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, txHash: String, userOperationHash: String?, bridgeGuid: String?, recipient: String, destination: UsdtDestination, amount: UInt64, receivedAmount: UInt64, fee: UInt64?, isIncoming: Bool, status: UsdtTransferStatus, timestamp: UInt64, explorerUrl: String) { + 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) { self.id = id self.txHash = txHash self.userOperationHash = userOperationHash @@ -16871,7 +16903,6 @@ public struct UsdtTransfer { self.isIncoming = isIncoming self.status = status self.timestamp = timestamp - self.explorerUrl = explorerUrl } } @@ -16918,9 +16949,6 @@ extension UsdtTransfer: Equatable, Hashable { if lhs.timestamp != rhs.timestamp { return false } - if lhs.explorerUrl != rhs.explorerUrl { - return false - } return true } @@ -16937,7 +16965,6 @@ extension UsdtTransfer: Equatable, Hashable { hasher.combine(isIncoming) hasher.combine(status) hasher.combine(timestamp) - hasher.combine(explorerUrl) } } @@ -16953,7 +16980,7 @@ public struct FfiConverterTypeUsdtTransfer: FfiConverterRustBuffer { return try UsdtTransfer( id: FfiConverterString.read(from: &buf), - txHash: FfiConverterString.read(from: &buf), + txHash: FfiConverterOptionString.read(from: &buf), userOperationHash: FfiConverterOptionString.read(from: &buf), bridgeGuid: FfiConverterOptionString.read(from: &buf), recipient: FfiConverterString.read(from: &buf), @@ -16963,14 +16990,13 @@ public struct FfiConverterTypeUsdtTransfer: FfiConverterRustBuffer { fee: FfiConverterOptionUInt64.read(from: &buf), isIncoming: FfiConverterBool.read(from: &buf), status: FfiConverterTypeUsdtTransferStatus.read(from: &buf), - timestamp: FfiConverterUInt64.read(from: &buf), - explorerUrl: FfiConverterString.read(from: &buf) + timestamp: FfiConverterUInt64.read(from: &buf) ) } public static func write(_ value: UsdtTransfer, into buf: inout [UInt8]) { FfiConverterString.write(value.id, into: &buf) - FfiConverterString.write(value.txHash, into: &buf) + FfiConverterOptionString.write(value.txHash, into: &buf) FfiConverterOptionString.write(value.userOperationHash, into: &buf) FfiConverterOptionString.write(value.bridgeGuid, into: &buf) FfiConverterString.write(value.recipient, into: &buf) @@ -16981,7 +17007,6 @@ public struct FfiConverterTypeUsdtTransfer: FfiConverterRustBuffer { FfiConverterBool.write(value.isIncoming, into: &buf) FfiConverterTypeUsdtTransferStatus.write(value.status, into: &buf) FfiConverterUInt64.write(value.timestamp, into: &buf) - FfiConverterString.write(value.explorerUrl, into: &buf) } } @@ -24503,11 +24528,33 @@ extension UsdtError: Foundation.LocalizedError { public enum UsdtTransferStatus { + /** + * Signed payment awaiting a conclusive source-chain outcome. + */ case pending + /** + * Payment received on its destination chain. + */ case confirmed + /** + * Source payment failed or was proven not to have executed. + */ case failed + /** + * Source payment executed; destination delivery is pending. + */ case bridging + /** + * Delivery is blocked or its message could not be recovered; it may still complete. + */ case bridgeNeedsAttention + /** + * Delivery was permanently stopped. This does not imply a refund of source funds or fees. + */ + case bridgeFailed + /** + * Another operation consumed the payment nonce. + */ case replaced } @@ -24536,7 +24583,9 @@ public struct FfiConverterTypeUsdtTransferStatus: FfiConverterRustBuffer { case 5: return .bridgeNeedsAttention - case 6: return .replaced + case 6: return .bridgeFailed + + case 7: return .replaced default: throw UniffiInternalError.unexpectedEnumCase } @@ -24566,9 +24615,13 @@ public struct FfiConverterTypeUsdtTransferStatus: FfiConverterRustBuffer { writeInt(&buf, Int32(5)) - case .replaced: + case .bridgeFailed: writeInt(&buf, Int32(6)) + + case .replaced: + writeInt(&buf, Int32(7)) + } } } @@ -30774,6 +30827,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_usdtwallet_balance() != 12328) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_method_usdtwallet_check_recent_execution() != 33172) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_method_usdtwallet_history() != 4617) { return InitializationResult.apiChecksumMismatch } @@ -30786,16 +30842,13 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_usdtwallet_receive_uri() != 33484) { return InitializationResult.apiChecksumMismatch } - if (uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfer() != 58151) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfers() != 32305) { + if (uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfers() != 34299) { return InitializationResult.apiChecksumMismatch } - if (uniffi_bitkitcore_checksum_method_usdtwallet_send() != 10847) { + if (uniffi_bitkitcore_checksum_method_usdtwallet_send() != 60030) { return InitializationResult.apiChecksumMismatch } - if (uniffi_bitkitcore_checksum_method_usdtwallet_sync_history() != 48106) { + if (uniffi_bitkitcore_checksum_method_usdtwallet_sync_history() != 25445) { return InitializationResult.apiChecksumMismatch } if (uniffi_bitkitcore_checksum_constructor_urdecoder_new() != 23014) { @@ -30804,7 +30857,7 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_constructor_usdtdepositclient_new() != 44626) { return InitializationResult.apiChecksumMismatch } - if (uniffi_bitkitcore_checksum_constructor_usdtwallet_new() != 63633) { + if (uniffi_bitkitcore_checksum_constructor_usdtwallet_new() != 62148) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index 5b79199..9df5bb9 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -720,6 +720,11 @@ void*_Nonnull uniffi_bitkitcore_fn_constructor_usdtwallet_new(RustBuffer address uint64_t uniffi_bitkitcore_fn_method_usdtwallet_balance(void*_Nonnull ptr ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_CHECK_RECENT_EXECUTION +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_CHECK_RECENT_EXECUTION +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_check_recent_execution(void*_Nonnull ptr, RustBuffer id +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_HISTORY #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_HISTORY RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_history(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status @@ -740,11 +745,6 @@ RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_receive_address(void*_Nonnull RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_receive_uri(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFER -#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFER -uint64_t uniffi_bitkitcore_fn_method_usdtwallet_refresh_transfer(void*_Nonnull ptr, RustBuffer id -); -#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFERS #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFERS uint64_t uniffi_bitkitcore_fn_method_usdtwallet_refresh_transfers(void*_Nonnull ptr @@ -3542,6 +3542,12 @@ uint16_t uniffi_bitkitcore_checksum_method_usdtdepositclient_request_refund(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_BALANCE uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_balance(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_CHECK_RECENT_EXECUTION +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_CHECK_RECENT_EXECUTION +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_check_recent_execution(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_HISTORY @@ -3566,12 +3572,6 @@ uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_receive_address(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_RECEIVE_URI uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_receive_uri(void -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_REFRESH_TRANSFER -#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_REFRESH_TRANSFER -uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfer(void - ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_REFRESH_TRANSFERS diff --git a/src/modules/usdt/README.md b/src/modules/usdt/README.md index 5cc3a72..b7ed7ce 100644 --- a/src/modules/usdt/README.md +++ b/src/modules/usdt/README.md @@ -24,19 +24,19 @@ The pinned ERC-20 paymaster collects USDT. Its finite approval includes a 5% mar Signed operations persist atomically before submission. Lost or rejected submission responses do not prove nonexecution: recovery retries only the identical signed operation. A quote ID cannot authorize a second payment. One source-chain payment remains pending at a time. -A matching event in a canonical receipt settles the payment. Discovery logs alone never decide the outcome. Expired signed paymaster terms and a confirmed EntryPoint nonce that has not passed the signed nonce release an unmined operation; the shorter quote deadline does not. With an advanced nonce and missing indexed events, recovery checks every receipt in the consuming block. A matching event settles/replaces the payment; complete absence proves external nonce consumption. Missing receipts preserve the pending operation. Progress is stored by payment and block hash so interruption does not restart the proof or carry it onto another block. +A matching event in a canonical receipt settles the payment. Discovery logs alone never decide the outcome; unavailable log queries allow independent nonce/receipt proofs to proceed, while rate limits retain backoff. Expired signed paymaster terms and a confirmed EntryPoint nonce that has not passed the signed nonce release an unmined operation; the shorter quote deadline does not. With an advanced nonce and missing indexed events, recovery checks every receipt in the consuming block. A matching event settles/replaces the payment; complete absence proves external nonce consumption. Missing receipts preserve the pending operation. Progress is stored by payment and block hash so interruption does not restart the proof or carry it onto another block. -Seed restoration recovers deposits and outgoing activity from genesis, including transfers before delegation and sends through another wallet. Supported direct EntryPoint calls and paymaster modes recover payment/fee attribution; unknown wrappers or payment modes preserve raw token transfers instead of guessing their intent. Failed payments retain attempted amounts but have no delivered amount. +Seed restoration recovers deposits and outgoing activity from genesis, including transfers before delegation and sends through another wallet. Supported direct EntryPoint calls and paymaster modes recover payment/fee attribution; unknown wrappers or payment modes preserve raw token transfers instead of guessing their intent. Failed payments retain attempted amounts but have no delivered amount. Transaction hashes are absent until execution is observed; callers derive explorer links from the source transaction hash rather than storing a second copy of it. -`sync_history` returns `true` when caught up and `false` when more work remains. It uses adaptive log ranges and a 20-second soft budget between persisted receipts; an in-flight receipt may finish later. A single-block log overflow falls back to that block's individual receipts. Completed fallback scans are retained by canonical block hash within the revisit window. Zero/self transfers are discarded before enrichment. Network failures preserve completed work and never silently skip a block. +`sync_history` returns `true` when caught up and `false` when more work remains. It uses adaptive log ranges and a 20-second soft budget between persisted receipts; an in-flight receipt may finish later. Learned range limits survive budget exits. A single-block log overflow falls back to that block's individual receipts. Complete receipt enrichment and fallback scans are retained by canonical block hash within the revisit window; log-only incoming observations and successful bridge receipts missing tracking or fee evidence remain eligible for later enrichment. Incomplete bridge metadata does not keep an executed source payment pending. Zero/self transfers are discarded before enrichment. Network failures preserve completed work and never silently skip a block. Callers should defer catch-up during payment review/submission so historical work does not delay sends. -`refresh_transfer` checks one recent direct Arbitrum payment with a five-second request budget. It requires the expected operation outcome and token transfer in a matching canonical receipt and does not scan history, rebroadcast, expire payments or reconcile nonces. It can confirm execution at the current L2 tip; this is provisional sequencer execution, not parent-chain finality. Native send screens may call it approximately once per second during a short foreground window, with cancellation and rate-limit backoff between checks. Missing evidence leaves Pending intact. Normal recovery handles older payments outside its 64-block lookup window. +`check_recent_execution` checks one recent direct Arbitrum payment with a five-second request budget. It requires the expected operation outcome and token transfer in a matching canonical receipt and does not scan history, rebroadcast, expire payments or reconcile nonces. It can confirm execution at the current L2 tip; this is provisional sequencer execution, not parent-chain finality. Native send screens may call it approximately once per second during a short foreground window, with cancellation and rate-limit backoff between checks. Missing evidence leaves Pending intact. Normal recovery handles older payments outside its 64-block lookup window. -Scans trail the reported tip by two blocks and revisit 4096 blocks for delayed indexing. This is not reorg rollback: previously recorded orphaned activity is not retracted. Providers must supply complete filtered logs, canonical blocks/receipts and historical state. +Scans trail the reported tip by two blocks and revisit 4096 blocks for delayed indexing. This is not reorg rollback: previously recorded orphaned activity is not retracted. Providers must supply complete filtered logs, canonical blocks/receipts and historical state. The first scan starts at block zero, including pre-Nitro ranges; providers must answer those queries or return a supported range-limit error so the scan can narrow them. Logs first indexed more than 4096 blocks late can fall outside the revisit window. This is a block-count limit, not a guaranteed time interval or a measured provider indexing guarantee. Payment outcomes and expiry decisions trust the configured chain RPC. A malicious RPC can fabricate or suppress evidence and mislead a user into authorizing another payment; these checks are not light-client proofs. -Storage is wallet-specific and owned by the `UsdtWallet` object. Drop it before deleting its database during an explicit wallet wipe. Async exports use UniFFI's Tokio adapter, preserving cancellation of the polled future; they do not detach sends onto the global runtime used by stateless exports. +Storage is wallet-specific and must have one owning `UsdtWallet` object. Drop it before deleting its database during an explicit wallet wipe. Async exports use UniFFI's Tokio adapter; Kotlin cancellation can drop the polled future, whereas the current Swift bindings may finish an in-flight call after task cancellation. Callers must check cancellation between calls. Sends are not detached onto the global runtime used by stateless exports. ## Transport and cross-network APIs @@ -44,11 +44,25 @@ Both chain and bundler endpoints must be controlled, credential-free HTTPS URLs; `UsdtDepositClient` signs Orchestra deposit registration, history, detail and explicit refund requests for the derived account. It uses a separate optional service endpoint; estimates do not imply delivery. A clock-skew error requires correcting the device clock. Amount-limit errors carry the provider’s known USD limits so callers can explain rejected amounts. Source-network fees are paid by the sender. Partner provisioning, delivered deposits and refund acceptance are separate release checks. -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. +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. -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. 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. +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. LayerZero status must match the operation GUID/pathway before confirmation; blocked delivery remains visible and never triggers an automatic paid retry. RPC providers see queried addresses; LayerZero Scan sees bridge transaction hashes. +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. + +LayerZero status must match the operation GUID/pathway before confirmation; blocked delivery remains visible and never triggers an automatic paid retry. + +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. + +Unavailable, unmatched or unknown delivery responses preserve the last status. Those lookups and retryable problems (`FAILED`, `BLOCKED`, `PAYLOAD_STORED`) wait at least one minute before another automatic attempt in the same wallet session. `APPLICATION_BURNED` and `APPLICATION_SKIPPED` stop polling as `BridgeFailed`; `DELIVERED` stops polling as `Confirmed`. No status triggers an automatic paid retry or refund. + +## Incoming deposit instructions + +Reusable deposit addresses use an immutable 50-bps slippage policy shared by the service and client. The receive response must confirm those exact terms; accepting a lower number alone would not prove that an existing standing instruction changed. Altering this policy requires coordinating new instructions and client/service behavior. Minimum and maximum USD-cent limits are optional display metadata; malformed limits are omitted without hiding a usable address or the amount-limit error. + +Detail and refund calls take the list offset at which the owned deposit was found. The service checks that page and the following page; if new deposits move it further, callers refresh the list and retry with the updated offset. Unknown provider statuses/networks remain visible for support rather than being guessed as successful or refundable. ## Validation and bindings @@ -66,6 +80,7 @@ Build iOS and Android sequentially with the repository scripts; Android temporar - [Alto EIP-7702 request validation](https://github.com/pimlicolabs/alto/blob/96529592b67a69be23c013359cbc9990657af64a/src/rpc/rpcHandler.ts) - [Simple7702Account](https://github.com/eth-infinitism/account-abstraction/blob/releases/v0.8/contracts/accounts/Simple7702Account.sol) - [Pimlico supported tokens](https://docs.pimlico.io/references/paymaster/erc20-paymaster/supported-tokens) +- [Pimlico paymaster deployments](https://docs.pimlico.io/references/paymaster/erc20-paymaster/contract-addresses) - [Pimlico pricing](https://www.pimlico.io/pricing) - [Pimlico public endpoint limits](https://docs.pimlico.io/references/bundler/public-endpoint) - [USDT0 documentation](https://docs.usdt0.to/) diff --git a/src/modules/usdt/amount.rs b/src/modules/usdt/amount.rs index a99cc1c..50aaf68 100644 --- a/src/modules/usdt/amount.rs +++ b/src/modules/usdt/amount.rs @@ -33,6 +33,19 @@ pub fn usdt_format_amount(amount: u64) -> String { .to_string() } +pub(super) fn with_margin(value: U256, percent: u8) -> Result { + let hundred = U256::from(100); + let percent = U256::from(percent); + let margin = (value / hundred) + .checked_mul(percent) + .and_then(|margin| margin.checked_add(value % hundred * percent / hundred)) + .ok_or(UsdtError::InvalidResponse)?; + value + .checked_add(margin) + .and_then(|value| value.checked_add(U256::from(1))) + .ok_or(UsdtError::InvalidResponse) +} + pub(super) fn token_amount(value: U256) -> Result { value.try_into().map_err(|_| UsdtError::InvalidAmount) } @@ -73,3 +86,27 @@ pub(super) fn parse_atomic_amount(value: &str) -> Result { .checked_mul(10u64.checked_pow(power).ok_or(UsdtError::InvalidAmount)?) .ok_or(UsdtError::InvalidAmount) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn margins_preserve_rounding_without_intermediate_overflow() { + for percent in [5, 10, 20] { + for value in [ + U256::ZERO, + U256::from(19), + U256::from(20), + U256::MAX / U256::from(2), + ] { + let expected = value + value / U256::from(100 / percent) + U256::from(1); + assert_eq!(with_margin(value, percent).unwrap(), expected); + } + assert!(matches!( + with_margin(U256::MAX, percent), + Err(UsdtError::InvalidResponse) + )); + } + } +} diff --git a/src/modules/usdt/deposits.rs b/src/modules/usdt/deposits.rs index 43923ad..0ba8950 100644 --- a/src/modules/usdt/deposits.rs +++ b/src/modules/usdt/deposits.rs @@ -1,5 +1,5 @@ use super::{ - keys::{derive_key, key_address, parse_address}, + keys::{derive_owner_key, parse_address}, rpc::{bounded_json, endpoint_client}, user_operation::sign_hash, UsdtDestination, UsdtError, @@ -28,7 +28,9 @@ pub struct UsdtDepositAddress { pub amount: u64, #[serde(deserialize_with = "number")] pub estimated_received: u64, + #[serde(default, deserialize_with = "deposit_limit")] pub min_usd_cents: Option, + #[serde(default, deserialize_with = "deposit_limit")] pub max_usd_cents: Option, pub slippage_bps: u32, #[serde(skip)] @@ -241,10 +243,7 @@ impl UsdtDepositClient { passphrase: Option>, timestamp: u64, ) -> Result { - let key = derive_key(mnemonic, passphrase)?; - if key_address(&key) != self.address { - return Err(UsdtError::InvalidCredentials); - } + let key = derive_owner_key(mnemonic, passphrase, self.address)?; let request = json!({"owner":self.address.to_checksum(None),"timestamp":timestamp,"payload":payload}) .to_string(); @@ -332,15 +331,14 @@ fn validate_source_address(value: &str, network: UsdtDepositNetwork) -> Result Result, UsdtError> { - if value.is_null() { - return Ok(None); - } - let value = value.as_str().ok_or(UsdtError::InvalidResponse)?; - if value.is_empty() || value.len() > 40 || !value.bytes().all(|c| c.is_ascii_digit()) { - return Err(UsdtError::InvalidResponse); - } - Ok(Some(value.into())) +fn deposit_limit<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let value = Value::deserialize(deserializer)?; + Ok(value + .as_str() + .filter(|value| { + !value.is_empty() && value.len() <= 40 && value.bytes().all(|c| c.is_ascii_digit()) + }) + .map(str::to_owned)) } fn number<'de, D: Deserializer<'de>>(deserializer: D) -> Result { @@ -615,15 +613,74 @@ mod tests { } #[tokio::test] - async fn invalid_service_addresses_and_nonadvancing_pages_are_rejected() { + async fn optional_limits_preserve_receive_and_amount_errors() { + for (limit, expected) in [ + (json!("200"), Some("200")), + (Value::Null, None), + (json!("unknown"), None), + (json!(200), None), + (json!(""), None), + (json!("1".repeat(41)), None), + ] { + let owner = super::super::usdt_address(PHRASE.into(), None).unwrap(); + let (url, server) = service(vec![ + (200, json!({"network":"ethereum","address":"0x1111111111111111111111111111111111111111","recipient":owner,"amount":"100000000","estimated_received":"98500000","slippage_bps":50,"min_usd_cents":limit,"max_usd_cents":limit})), + (400, json!({"error":"amount_too_small","min_usd_cents":limit,"max_usd_cents":limit})), + ]).await; + let client = UsdtDepositClient::new(owner, url).unwrap(); + let received = client + .receive( + UsdtDepositNetwork::Ethereum, + 100_000_000, + PHRASE.into(), + None, + ) + .await + .unwrap(); + assert_eq!(received.min_usd_cents.as_deref(), expected); + assert_eq!(received.max_usd_cents.as_deref(), expected); + let UsdtError::DepositAmountOutOfRange { + min_usd_cents, + max_usd_cents, + } = client + .receive( + UsdtDepositNetwork::Ethereum, + 100_000_000, + PHRASE.into(), + None, + ) + .await + .unwrap_err() + else { + panic!("Optional limits must not hide the amount error"); + }; + assert_eq!(min_usd_cents.as_deref(), expected); + assert_eq!(max_usd_cents.as_deref(), expected); + server.await.unwrap(); + } + } + + #[tokio::test] + async fn invalid_receive_terms_and_nonadvancing_pages_are_rejected() { let owner = super::super::usdt_address(PHRASE.into(), None).unwrap(); - let (url, server) = service(vec![ - (200, json!({"network":"ethereum","address":UsdtDestination::Ethereum.token(),"recipient":owner,"amount":"100000000","estimated_received":"98500000","slippage_bps":50})), - (200, json!({"network":"ethereum","address":"0x1111111111111111111111111111111111111111","recipient":"invalid","amount":"100000000","estimated_received":"98500000","slippage_bps":50})), - (200, json!({"deposits":[],"next_offset":0})), - ]).await; + let mut responses = vec![ + ( + 200, + json!({"network":"ethereum","address":UsdtDestination::Ethereum.token(),"recipient":owner,"amount":"100000000","estimated_received":"98500000","slippage_bps":50}), + ), + ( + 200, + json!({"network":"ethereum","address":"0x1111111111111111111111111111111111111111","recipient":"invalid","amount":"100000000","estimated_received":"98500000","slippage_bps":50}), + ), + ]; + for slippage_bps in [0, 49, 51] { + responses.push((200, json!({"network":"ethereum","address":"0x1111111111111111111111111111111111111111","recipient":owner,"amount":"100000000","estimated_received":"98500000","slippage_bps":slippage_bps}))); + } + let invalid_receives = responses.len(); + responses.push((200, json!({"deposits":[],"next_offset":0}))); + let (url, server) = service(responses).await; let client = UsdtDepositClient::new(owner, url).unwrap(); - for _ in 0..2 { + for _ in 0..invalid_receives { assert!(matches!( client .receive( diff --git a/src/modules/usdt/errors.rs b/src/modules/usdt/errors.rs index e098708..bc6be4e 100644 --- a/src/modules/usdt/errors.rs +++ b/src/modules/usdt/errors.rs @@ -6,7 +6,7 @@ pub enum UsdtError { InvalidAmount, #[error("Enter a valid address for the selected network")] InvalidAddress, - #[error("The payment request is for a different network or token")] + #[error("The network or token does not match this USDT account")] WrongNetwork, #[error("Wallet credentials do not match this USDT account")] InvalidCredentials, diff --git a/src/modules/usdt/history.rs b/src/modules/usdt/history.rs index 9ea6a98..37b5422 100644 --- a/src/modules/usdt/history.rs +++ b/src/modules/usdt/history.rs @@ -1,8 +1,8 @@ use super::{ account::{SimpleAccount, ENTRY_POINT}, amount::token_amount, - transaction::{event_data, BridgeHelper, EntryPoint, Erc20}, - types::{BRIDGE_HELPER, EXPLORER, OFT, TOKEN}, + transaction::{entry_point_event, event_data, BridgeHelper, EntryPoint, Erc20}, + types::{BRIDGE_HELPER, OFT, TOKEN}, UsdtDestination, UsdtError, UsdtTransfer, UsdtTransferStatus, UsdtWallet, }; use alloy_primitives::{Address, Bytes, B256, U256}; @@ -11,15 +11,18 @@ use serde_json::{json, Value}; use std::{collections::BTreeMap, sync::atomic::Ordering}; pub(super) const MAX_LOG_RANGE: u64 = 10_000_000; +pub(super) const HISTORY_REVISIT_BLOCKS: u64 = 4096; +const HISTORY_BUDGET: std::time::Duration = std::time::Duration::from_secs(20); +const LOG_QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); impl UsdtWallet { pub(super) async fn scan_history(&self) -> Result { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20); + let deadline = tokio::time::Instant::now() + HISTORY_BUDGET; let tip = self.block_number().await?.saturating_sub(2); let previous = self.store.synced_block()?; let start = self.store.history_progress()?.unwrap_or_else(|| { previous - .map(|block| block.saturating_sub(4096)) + .map(|block| block.saturating_sub(HISTORY_REVISIT_BLOCKS)) .unwrap_or(0) }); if start > tip { @@ -29,79 +32,22 @@ impl UsdtWallet { let mut ceiling = initial_limit; let mut next = start; let mut width = initial_limit.min(tip - start + 1); - while next <= tip { + loop { if tokio::time::Instant::now() >= deadline { - self.history_range_limit - .store((ceiling * 2).min(MAX_LOG_RANGE), Ordering::Relaxed); + self.history_range_limit.store(ceiling, Ordering::Relaxed); return Ok(false); } let end = next.saturating_add(width - 1).min(tip); - let query = tokio::time::timeout( - std::time::Duration::from_secs(10), - self.history_logs(next, end), - ) - .await; - let result = match query { - Ok(result) => result, - Err(_) => { - self.history_range_limit - .store((width / 2).max(1), Ordering::Relaxed); - return if next > start { - Ok(false) - } else { - Err(UsdtError::NetworkUnavailable) - }; - } - }; + let result = tokio::time::timeout(LOG_QUERY_TIMEOUT, self.history_logs(next, end)) + .await + .unwrap_or(Err(UsdtError::NetworkUnavailable)); match result { Ok(transactions) => { if self.store.history_progress()? != Some(next) { self.store.save_history_progress(next)?; } - let mut blocks = BTreeMap::new(); - for ((block, hash), logs) in transactions { - if self.store.has_history_receipt(&hash)? { - continue; - } - if tokio::time::Instant::now() >= deadline { - return Ok(false); - } - let needs_receipt = logs.iter().any(|log| { - serde_json::from_value::
(log["address"].clone()) - .is_ok_and(|address| address == ENTRY_POINT) - || event_data(log) - .ok() - .and_then(|data| Erc20::Transfer::decode_log_data(&data).ok()) - .is_some_and(|event| event.from == self.address) - }); - let canonical = match blocks.entry(block) { - std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(), - std::collections::btree_map::Entry::Vacant(entry) => { - entry.insert(self.rpc.block(block).await?) - } - }; - for log in &logs { - if serde_json::from_value::(log["blockHash"].clone())? - != canonical.hash - { - return Err(UsdtError::NetworkUnavailable); - } - } - let receipt = if needs_receipt { - self.rpc - .block_receipt( - hash.parse().map_err(|_| UsdtError::InvalidResponse)?, - canonical.hash, - block, - ) - .await? - } else { - json!({"logs": logs}) - }; - let timestamp = u64::try_from(canonical.timestamp) - .map_err(|_| UsdtError::InvalidResponse)?; - self.save_receipt_history(&hash, timestamp, &receipt) - .await?; + if !self.scan_history_logs(transactions, end, deadline).await? { + return Ok(false); } } Err(UsdtError::LogRangeTooLarge) if next < end => { @@ -142,6 +88,66 @@ impl UsdtWallet { self.history_range_limit.store(width, Ordering::Relaxed); width = width.min(tip - next + 1); } + } + + async fn scan_history_logs( + &self, + transactions: BTreeMap<(u64, String), Vec>, + end: u64, + deadline: tokio::time::Instant, + ) -> Result { + let mut blocks = BTreeMap::new(); + let mut transactions = transactions.into_iter().peekable(); + while let Some(((block, hash), logs)) = transactions.next() { + if tokio::time::Instant::now() >= deadline { + return Ok(false); + } + let needs_receipt = logs.iter().any(|log| { + serde_json::from_value::
(log["address"].clone()) + .is_ok_and(|address| address == ENTRY_POINT) + || event_data(log) + .ok() + .and_then(|data| Erc20::Transfer::decode_log_data(&data).ok()) + .is_some_and(|event| event.from == self.address) + }); + let canonical = match blocks.entry(block) { + std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(), + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(self.rpc.block(block).await?) + } + }; + for log in &logs { + if serde_json::from_value::(log["blockHash"].clone())? != canonical.hash { + return Err(UsdtError::NetworkUnavailable); + } + } + if !self.store.has_history_receipt( + &hash, + &format!("{:#x}", canonical.hash), + needs_receipt, + )? { + let receipt = if needs_receipt { + self.rpc + .block_receipt( + hash.parse().map_err(|_| UsdtError::InvalidResponse)?, + canonical.hash, + block, + ) + .await? + } else { + json!({"logs": logs}) + }; + self.save_receipt_history(&hash, block, canonical, &receipt, needs_receipt) + .await?; + } + if block < end + && transactions + .peek() + .is_none_or(|((next_block, _), _)| *next_block != block) + { + self.store.save_history_progress(block + 1)?; + } + } Ok(true) } @@ -155,37 +161,58 @@ impl UsdtWallet { if self.store.begin_history_block(number, &block_hash)? { return Ok(true); } - let timestamp = u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; if self.store.history_progress()? != Some(number) { self.store.save_history_progress(number)?; } + let mut complete = true; for hash in &block.transactions { let id = format!("{hash:#x}"); - if self.store.has_history_receipt(&id)? { + if self.store.has_history_receipt(&id, &block_hash, true)? { continue; } if tokio::time::Instant::now() >= deadline { return Ok(false); } let receipt = self.rpc.block_receipt(*hash, block.hash, number).await?; - self.save_receipt_history(&id, timestamp, &receipt).await?; + complete &= self + .save_receipt_history(&id, number, &block, &receipt, true) + .await?; } if self.rpc.block(number).await?.hash != block.hash { return Err(UsdtError::NetworkUnavailable); } - self.store.complete_history_block(number, &block_hash)?; + if complete { + self.store.complete_history_block(number, &block_hash)?; + } Ok(true) } async fn save_receipt_history( &self, hash: &str, - timestamp: u64, + number: u64, + block: &super::rpc::Block, receipt: &Value, - ) -> Result<(), UsdtError> { + complete_receipt: bool, + ) -> Result { // Finish and persist a receipt before yielding the work budget. + let timestamp = block.timestamp()?; let transfers = self.receipt_history(hash, timestamp, receipt).await?; - self.store.save_history_receipt(&transfers, hash) + // Missing bridge evidence remains eligible for enrichment without keeping the source pending. + let complete_receipt = complete_receipt + && transfers.iter().all(|transfer| { + transfer.destination == UsdtDestination::Arbitrum + || transfer.status == UsdtTransferStatus::Failed + || (transfer.bridge_guid.is_some() && transfer.fee.is_some()) + }); + self.store.save_history_receipt( + &transfers, + hash, + number, + &format!("{:#x}", block.hash), + complete_receipt, + )?; + Ok(complete_receipt) } async fn history_logs( @@ -239,16 +266,12 @@ impl UsdtWallet { let mut owned_operations = Vec::new(); for log in logs { let address: Address = serde_json::from_value(log["address"].clone())?; - if address == ENTRY_POINT { - if let Ok(event) = - EntryPoint::UserOperationEvent::decode_log_data(&event_data(log)?) - { - if event.sender == self.address { - let saved = self - .store - .transfer_by_hash(&format!("{:#x}", event.userOpHash))?; - owned_operations.push((event, saved)); - } + if let Some(event) = entry_point_event(log)? { + if event.sender == self.address { + let saved = self + .store + .transfer_by_hash(&format!("{:#x}", event.userOpHash))?; + owned_operations.push((event, saved)); } } if address != TOKEN { @@ -265,7 +288,7 @@ impl UsdtWallet { let index: U256 = serde_json::from_value(log["logIndex"].clone())?; result.push(UsdtTransfer { id: format!("{hash}:{index}"), - tx_hash: hash.into(), + tx_hash: Some(hash.into()), user_operation_hash: None, bridge_guid: None, recipient: event.to.to_checksum(None), @@ -276,7 +299,6 @@ impl UsdtWallet { is_incoming: incoming, status: UsdtTransferStatus::Confirmed, timestamp, - explorer_url: format!("{EXPLORER}/tx/{hash}"), }); } if owned_operations.is_empty() { @@ -335,7 +357,7 @@ impl UsdtWallet { }; let mut transfer = UsdtTransfer { id: operation_hash.clone(), - tx_hash: hash.into(), + tx_hash: Some(hash.into()), user_operation_hash: Some(operation_hash), bridge_guid: None, recipient, @@ -346,7 +368,6 @@ impl UsdtWallet { is_incoming: false, status: UsdtTransferStatus::Pending, timestamp, - explorer_url: format!("{EXPLORER}/tx/{hash}"), }; self.settle(&mut transfer, receipt)?; let operation_logs = super::transaction::operation_logs(receipt, event.userOpHash)?; diff --git a/src/modules/usdt/keys.rs b/src/modules/usdt/keys.rs index f338575..414885f 100644 --- a/src/modules/usdt/keys.rs +++ b/src/modules/usdt/keys.rs @@ -45,6 +45,18 @@ pub(super) fn key_address(key: &SecretKey) -> Address { Address::from_raw_public_key(&public[1..]) } +pub(super) fn derive_owner_key( + mnemonic: Zeroizing, + passphrase: Option>, + owner: Address, +) -> Result { + let key = derive_key(mnemonic, passphrase)?; + if key_address(&key) != owner { + return Err(UsdtError::InvalidCredentials); + } + Ok(key) +} + #[uniffi::export] pub fn usdt_address(mnemonic: String, passphrase: Option) -> Result { Ok(key_address(&*derive_key(mnemonic.into(), passphrase.map(Into::into))?).to_checksum(None)) diff --git a/src/modules/usdt/paymaster.rs b/src/modules/usdt/paymaster.rs index f680706..ea1c292 100644 --- a/src/modules/usdt/paymaster.rs +++ b/src/modules/usdt/paymaster.rs @@ -1,5 +1,6 @@ use super::{ account::ENTRY_POINT, + amount::with_margin, rpc::Rpc, transaction::Erc20, user_operation::{Authorization, UserOperation}, @@ -12,6 +13,8 @@ use serde_json::json; use super::types::{CHAIN_ID as ARBITRUM_CHAIN_ID, TOKEN as USDT0}; pub(super) const PAYMASTER: Address = address!("888888888888Ec68A58AB8094Cc1AD20Ba3D2402"); +const MIN_PAYMASTER_VALIDITY_SECONDS: u64 = 15; +const MAX_PAYMASTER_VALIDITY_SECONDS: u64 = 900; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -63,10 +66,7 @@ impl GasEstimate { (&mut op.pre_verification_gas, self.pre_verification_gas), ] { if estimate > *limit { - *limit = estimate - .checked_add(estimate / U256::from(10)) - .and_then(|value| value.checked_add(U256::from(1))) - .ok_or(UsdtError::InvalidResponse)?; + *limit = with_margin(estimate, 10)?; changed = true; } } @@ -158,7 +158,7 @@ impl Pimlico { valid_until: 0, valid_after: 0, }; - let mut allowance = approval_margin(estimate_terms.maximum_token_cost(&op)?)?; + let mut allowance = with_margin(estimate_terms.maximum_token_cost(&op)?, 5)?; // Provider data can change gas limits and token charges. Refine both before signing. for _ in 0..3 { op.call_data = with_approval(calls, allowance); @@ -171,13 +171,17 @@ impl Pimlico { let gas_changed = estimate.apply(&mut op)?; let required = terms.maximum_token_cost(&op)?; if gas_changed || required > allowance { - allowance = allowance.max(approval_margin(required)?); + allowance = allowance.max(with_margin(required, 5)?); continue; } - if terms.valid_until == 0 || terms.valid_until > timestamp.saturating_add(900) { + if terms.valid_until == 0 + || terms.valid_until > timestamp.saturating_add(MAX_PAYMASTER_VALIDITY_SECONDS) + { return Err(UsdtError::InvalidResponse); } - if terms.valid_after > timestamp || terms.valid_until <= timestamp.saturating_add(15) { + if terms.valid_after > timestamp + || terms.valid_until <= timestamp.saturating_add(MIN_PAYMASTER_VALIDITY_SECONDS) + { return Err(UsdtError::QuoteExpired); } return Ok(( @@ -278,13 +282,6 @@ fn with_approval(calls: &[(Address, Bytes)], amount: U256) -> Bytes { super::account::batch(&batch) } -fn approval_margin(value: U256) -> Result { - value - .checked_add(value / U256::from(20)) - .and_then(|value| value.checked_add(U256::from(1))) - .ok_or(UsdtError::InvalidResponse) -} - struct Terms { exchange_rate: U256, post_op_gas: U256, diff --git a/src/modules/usdt/rpc.rs b/src/modules/usdt/rpc.rs index 27e6b65..d11ca3e 100644 --- a/src/modules/usdt/rpc.rs +++ b/src/modules/usdt/rpc.rs @@ -16,10 +16,16 @@ pub(super) struct Block { pub transactions: Vec, } +impl Block { + pub fn timestamp(&self) -> Result { + self.timestamp + .try_into() + .map_err(|_| UsdtError::InvalidResponse) + } +} + pub(super) struct Rpc { client: reqwest::Client, - #[cfg(test)] - pub(super) bridge_status_url: Option, url: String, chain_id: u64, next_request: Arc>, @@ -41,8 +47,6 @@ impl Rpc { let client = endpoint_client(&url, Duration::from_secs(25))?; Ok(Self { client, - #[cfg(test)] - bridge_status_url: None, url, chain_id, next_request: Arc::new(Mutex::new(Instant::now())), @@ -135,6 +139,13 @@ impl Rpc { if matches!(error.code, -32002 | -32603) { return Err(UsdtError::NetworkUnavailable); } + if method == "eth_call" + && error.code == 3 + && serde_json::from_value::
(params[0]["to"].clone()) + .is_ok_and(|to| [super::types::OFT, super::types::BRIDGE_HELPER].contains(&to)) + { + return Err(UsdtError::UnsupportedRoute); + } if matches!( method, "eth_chainId" @@ -147,6 +158,7 @@ impl Rpc { | "eth_getBlockByNumber" | "eth_getTransactionReceipt" | "eth_getTransactionByHash" + | "bitkit_getBridgeMessages" ) { return Err(UsdtError::NetworkUnavailable); } @@ -172,32 +184,21 @@ impl Rpc { &self, transfer: &UsdtTransfer, ) -> Result { - if transfer.bridge_guid.is_none() { - return Ok(transfer.status); - } - let base = "https://scan.layerzero-api.com"; - #[cfg(test)] - let base = self.bridge_status_url.as_deref().unwrap_or(base); - let url = format!("{base}/v1/messages/tx/{}", transfer.tx_hash); - let response = self - .client - .get(url) - .send() - .await - .map_err(|_| UsdtError::NetworkUnavailable)? - .error_for_status() - .map_err(|_| UsdtError::NetworkUnavailable)?; - let response = bounded_json(response, 2_097_152, UsdtError::InvalidResponse).await?; + let (Some(guid), Some(tx_hash)) = + (transfer.bridge_guid.as_deref(), transfer.tx_hash.as_deref()) + else { + return Err(UsdtError::NetworkUnavailable); + }; + let hash: B256 = tx_hash.parse().map_err(|_| UsdtError::InvalidResponse)?; + let response: Value = self.call("bitkit_getBridgeMessages", json!([hash])).await?; let messages = response["data"] .as_array() .ok_or(UsdtError::InvalidResponse)?; let message = messages.iter().find(|message| { - message["guid"].as_str().is_some_and(|guid| { - transfer - .bridge_guid - .as_ref() - .is_some_and(|expected| guid.eq_ignore_ascii_case(expected)) - }) && message["pathway"]["srcEid"].as_u64() == Some(30110) + message["guid"] + .as_str() + .is_some_and(|value| value.eq_ignore_ascii_case(guid)) + && message["pathway"]["srcEid"].as_u64() == Some(30110) && message["pathway"]["dstEid"].as_u64() == transfer.destination.endpoint().map(u64::from) && message["pathway"]["sender"]["address"] @@ -205,18 +206,16 @@ impl Rpc { .is_some_and(|a| a.eq_ignore_ascii_case(&super::types::OFT.to_string())) && message["source"]["tx"]["txHash"] .as_str() - .is_some_and(|h| h.eq_ignore_ascii_case(&transfer.tx_hash)) + .is_some_and(|h| h.eq_ignore_ascii_case(tx_hash)) }); Ok(match message.and_then(|m| m["status"]["name"].as_str()) { Some("DELIVERED") => UsdtTransferStatus::Confirmed, - Some( - "FAILED" - | "BLOCKED" - | "PAYLOAD_STORED" - | "APPLICATION_BURNED" - | "APPLICATION_SKIPPED", - ) => UsdtTransferStatus::BridgeNeedsAttention, - _ => transfer.status, + Some("INFLIGHT" | "CONFIRMING") => UsdtTransferStatus::Bridging, + Some("FAILED" | "BLOCKED" | "PAYLOAD_STORED") => { + UsdtTransferStatus::BridgeNeedsAttention + } + Some("APPLICATION_BURNED" | "APPLICATION_SKIPPED") => UsdtTransferStatus::BridgeFailed, + _ => return Err(UsdtError::NetworkUnavailable), }) } @@ -253,10 +252,19 @@ impl Rpc { } pub async fn contract(&self, to: Address, call: C) -> Result { + self.contract_at(to, call, "latest").await + } + + pub async fn contract_at( + &self, + to: Address, + call: C, + block: &str, + ) -> Result { let bytes: Bytes = self .call( "eth_call", - json!([{"to":to,"data":Bytes::from(call.abi_encode())},"latest"]), + json!([{"to":to,"data":Bytes::from(call.abi_encode())},block]), ) .await?; C::abi_decode_returns(&bytes).map_err(|_| UsdtError::InvalidResponse) @@ -364,6 +372,41 @@ mod tests { } } + #[tokio::test] + async fn bridge_reverts_are_distinct_from_rpc_failures() { + use super::super::types::{BRIDGE_HELPER, OFT, TOKEN}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + for (target, code, expected_route) in [ + (OFT, 3, true), + (BRIDGE_HELPER, 3, true), + (TOKEN, 3, false), + (BRIDGE_HELPER, -32602, false), + (BRIDGE_HELPER, -32002, false), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0; 4096]; + assert!(socket.read(&mut request).await.unwrap() > 0); + let body = json!({"error":{"code":code,"message":"Provider rejected the request"}}) + .to_string(); + socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + }); + let error = Rpc::new(url, 42161) + .unwrap() + .call::("eth_call", json!([{"to": target, "data":"0x"}, "latest"])) + .await + .unwrap_err(); + if expected_route { + assert!(matches!(error, UsdtError::UnsupportedRoute)); + } else { + assert!(matches!(error, UsdtError::NetworkUnavailable)); + } + server.await.unwrap(); + } + } + #[tokio::test(start_paused = true)] async fn chain_and_bundler_share_bursts_and_sustained_budget() { let chain = Rpc::new("https://chain.example".into(), 42161).unwrap(); diff --git a/src/modules/usdt/store.rs b/src/modules/usdt/store.rs index 3cd29e6..310520b 100644 --- a/src/modules/usdt/store.rs +++ b/src/modules/usdt/store.rs @@ -1,4 +1,7 @@ -use super::{transaction::Plan, UsdtError, UsdtQuote, UsdtTransfer, UsdtTransferStatus}; +use super::{ + history::HISTORY_REVISIT_BLOCKS, transaction::Plan, UsdtError, UsdtQuote, UsdtTransfer, + UsdtTransferStatus, +}; use rusqlite::{params, Connection, OptionalExtension, Transaction}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use std::{ @@ -27,11 +30,12 @@ impl Store { CREATE TABLE IF NOT EXISTS usdt_identity (id INTEGER PRIMARY KEY CHECK(id=1), identity TEXT NOT NULL); CREATE TABLE IF NOT EXISTS usdt_sync (id INTEGER PRIMARY KEY CHECK(id=1), newest INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS usdt_history_progress (id INTEGER PRIMARY KEY CHECK(id=1), next INTEGER NOT NULL); - CREATE TABLE IF NOT EXISTS usdt_history_receipts (hash TEXT PRIMARY KEY); + CREATE TABLE IF NOT EXISTS usdt_history_receipts (hash TEXT PRIMARY KEY, block_number INTEGER NOT NULL, block_hash TEXT NOT NULL, complete_receipt INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS usdt_history_blocks (number INTEGER PRIMARY KEY, hash TEXT NOT NULL, complete INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS usdt_quotes (id TEXT PRIMARY KEY, data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS usdt_nonce_recovery (id TEXT PRIMARY KEY, block_hash TEXT NOT NULL, next_transaction INTEGER NOT NULL); - CREATE TABLE IF NOT EXISTS usdt_transfers (id TEXT PRIMARY KEY, hash TEXT NOT NULL, raw TEXT, data TEXT NOT NULL);")?; + CREATE TABLE IF NOT EXISTS usdt_transfers (id TEXT PRIMARY KEY, hash TEXT NOT NULL, raw TEXT, data TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS usdt_transfers_hash ON usdt_transfers(hash);")?; connection.execute( "INSERT OR IGNORE INTO usdt_identity VALUES (1,?1)", [identity], @@ -88,15 +92,7 @@ impl Store { } pub fn transfer_by_hash(&self, hash: &str) -> Result, UsdtError> { - let data: Option = self - .connection()? - .query_row( - "SELECT data FROM usdt_transfers WHERE hash=?1", - [hash], - |r| r.get(0), - ) - .optional()?; - data.map(|data| decode(&data)).transpose() + find_transfer_by_hash(&*self.connection()?, hash) } pub fn transfers(&self) -> Result, UsdtError> { @@ -131,24 +127,9 @@ impl Store { } pub fn update_transfer(&self, transfer: &UsdtTransfer) -> Result<(), UsdtError> { - let settled = matches!( - transfer.status, - UsdtTransferStatus::Confirmed - | UsdtTransferStatus::Failed - | UsdtTransferStatus::Replaced - | UsdtTransferStatus::Bridging - | UsdtTransferStatus::BridgeNeedsAttention - ); let mut connection = self.connection()?; let tx = connection.transaction()?; - tx.execute("UPDATE usdt_transfers SET data=?1, raw=CASE WHEN ?2 THEN NULL ELSE raw END WHERE id=?3", - params![serde_json::to_string(transfer)?, settled, transfer.id])?; - if settled { - tx.execute( - "DELETE FROM usdt_nonce_recovery WHERE id=?1", - [&transfer.id], - )?; - } + write_transfer(&tx, transfer)?; tx.commit()?; Ok(()) } @@ -191,10 +172,13 @@ impl Store { let mut connection = self.connection()?; let tx = connection.transaction()?; tx.execute("DELETE FROM usdt_history_progress", [])?; - tx.execute("DELETE FROM usdt_history_receipts", [])?; + tx.execute( + "DELETE FROM usdt_history_receipts WHERE complete_receipt=0 OR block_number < ?1", + [newest.saturating_sub(HISTORY_REVISIT_BLOCKS)], + )?; tx.execute( "DELETE FROM usdt_history_blocks WHERE number < ?1", - [newest.saturating_sub(4096)], + [newest.saturating_sub(HISTORY_REVISIT_BLOCKS)], )?; tx.execute("INSERT INTO usdt_sync (id,newest) VALUES (1,?1) ON CONFLICT(id) DO UPDATE SET newest=excluded.newest", [newest])?; tx.commit()?; @@ -217,10 +201,13 @@ impl Store { let tx = connection.transaction()?; tx.execute( "DELETE FROM usdt_history_blocks WHERE number < ?1", - [next.saturating_sub(4096)], + [next.saturating_sub(HISTORY_REVISIT_BLOCKS)], )?; tx.execute("INSERT INTO usdt_history_progress VALUES (1,?1) ON CONFLICT(id) DO UPDATE SET next=excluded.next", [next])?; - tx.execute("DELETE FROM usdt_history_receipts", [])?; + tx.execute( + "DELETE FROM usdt_history_receipts WHERE complete_receipt=0 OR block_number < ?1", + [next.saturating_sub(HISTORY_REVISIT_BLOCKS)], + )?; tx.commit()?; Ok(()) } @@ -239,7 +226,10 @@ impl Store { if saved_hash == hash { return Ok(complete); } - tx.execute("DELETE FROM usdt_history_receipts", [])?; + tx.execute( + "DELETE FROM usdt_history_receipts WHERE block_number=?1", + [number], + )?; } tx.execute("INSERT INTO usdt_history_blocks VALUES (?1,?2,0) ON CONFLICT(number) DO UPDATE SET hash=excluded.hash,complete=0", params![number, hash])?; tx.commit()?; @@ -254,10 +244,15 @@ impl Store { Ok(()) } - pub fn has_history_receipt(&self, hash: &str) -> Result { + pub fn has_history_receipt( + &self, + hash: &str, + block_hash: &str, + require_complete: bool, + ) -> Result { Ok(self.connection()?.query_row( - "SELECT EXISTS(SELECT 1 FROM usdt_history_receipts WHERE hash=?1)", - [hash], + "SELECT EXISTS(SELECT 1 FROM usdt_history_receipts WHERE hash=?1 AND block_hash=?2 AND (complete_receipt=1 OR ?3=0))", + params![hash, block_hash, require_complete], |row| row.get(0), )?) } @@ -266,13 +261,16 @@ impl Store { &self, transfers: &[UsdtTransfer], hash: &str, + block_number: u64, + block_hash: &str, + complete_receipt: bool, ) -> Result<(), UsdtError> { let mut connection = self.connection()?; let tx = connection.transaction()?; Self::merge_history(&tx, transfers)?; tx.execute( - "INSERT OR IGNORE INTO usdt_history_receipts VALUES (?1)", - [hash], + "INSERT INTO usdt_history_receipts VALUES (?1,?2,?3,?4) ON CONFLICT(hash) DO UPDATE SET block_number=excluded.block_number,block_hash=excluded.block_hash,complete_receipt=excluded.complete_receipt", + params![hash, block_number, block_hash, complete_receipt], )?; tx.commit()?; Ok(()) @@ -284,18 +282,15 @@ impl Store { .user_operation_hash .as_deref() .unwrap_or(&transfer.id); - let existing: Option = tx - .query_row( - "SELECT data FROM usdt_transfers WHERE hash=?1", - [hash], - |row| row.get(0), - ) - .optional()?; + let existing = find_transfer_by_hash(tx, hash)?; let mut transfer = transfer.clone(); - if let Some(data) = existing { - let saved: UsdtTransfer = decode(&data)?; + if let Some(saved) = existing { transfer.id = saved.id; - if saved.tx_hash.eq_ignore_ascii_case(&transfer.tx_hash) + if saved + .tx_hash + .as_deref() + .zip(transfer.tx_hash.as_deref()) + .is_some_and(|(a, b)| a.eq_ignore_ascii_case(b)) && saved .bridge_guid .as_ref() @@ -304,19 +299,14 @@ impl Store { && transfer.status == UsdtTransferStatus::Bridging && matches!( saved.status, - UsdtTransferStatus::Confirmed | UsdtTransferStatus::BridgeNeedsAttention + UsdtTransferStatus::Confirmed + | UsdtTransferStatus::BridgeNeedsAttention + | UsdtTransferStatus::BridgeFailed ) { transfer.status = saved.status; } - tx.execute( - "UPDATE usdt_transfers SET data=?1, raw=NULL WHERE id=?2", - params![serde_json::to_string(&transfer)?, transfer.id], - )?; - tx.execute( - "DELETE FROM usdt_nonce_recovery WHERE id=?1", - [&transfer.id], - )?; + write_transfer(tx, &transfer)?; } else { tx.execute( "INSERT INTO usdt_transfers (id,hash,data) VALUES (?1,?2,?3)", @@ -327,23 +317,27 @@ impl Store { Ok(()) } - pub fn unsettled(&self) -> Result, UsdtError> { + pub fn awaiting_delivery(&self) -> Result, UsdtError> { let connection = self.connection()?; - let mut statement = connection.prepare("SELECT data FROM usdt_transfers")?; + 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", + )?; let rows = statement.query_map([], |row| row.get::<_, String>(0))?; - let mut result = Vec::new(); - for row in rows { - let transfer: UsdtTransfer = decode(&row?)?; - if matches!( - transfer.status, - UsdtTransferStatus::Pending - | UsdtTransferStatus::Bridging - | UsdtTransferStatus::BridgeNeedsAttention - ) { - result.push(transfer); - } - } - Ok(result) + rows.map(|row| decode(&row?)).collect() + } + + pub fn pending_operation(&self) -> Result, UsdtError> { + let saved: Option<(String, String)> = self + .connection()? + .query_row( + "SELECT data,raw FROM usdt_transfers WHERE raw IS NOT NULL", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + saved + .map(|(data, raw)| Ok((decode(&data)?, decode(&raw)?))) + .transpose() } pub fn pending_plan(&self, id: &str) -> Result, UsdtError> { @@ -358,6 +352,35 @@ impl Store { } } +fn write_transfer(connection: &Connection, transfer: &UsdtTransfer) -> Result<(), UsdtError> { + let settled = transfer.status != UsdtTransferStatus::Pending; + connection.execute( + "UPDATE usdt_transfers SET data=?1, raw=CASE WHEN ?2 THEN NULL ELSE raw END WHERE id=?3", + params![serde_json::to_string(transfer)?, settled, transfer.id], + )?; + if settled { + connection.execute( + "DELETE FROM usdt_nonce_recovery WHERE id=?1", + [&transfer.id], + )?; + } + Ok(()) +} + +fn find_transfer_by_hash( + connection: &Connection, + hash: &str, +) -> Result, UsdtError> { + let data: Option = connection + .query_row( + "SELECT data FROM usdt_transfers WHERE hash=?1", + [hash], + |row| row.get(0), + ) + .optional()?; + data.map(|data| decode(&data)).transpose() +} + fn decode(data: &str) -> Result { serde_json::from_str(data).map_err(|error| UsdtError::Storage { reason: error.to_string(), diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index 91d747c..594fd53 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -242,17 +242,24 @@ struct ChainState { mined: bool, tip: u64, timestamp: alloy_primitives::U256, + block_hashes: std::collections::BTreeMap, + block_timestamps: std::collections::BTreeMap, reject_broadcast: bool, delay_gas_estimate: bool, + bridge_messages: std::collections::HashMap, + bridge_requests: Vec, + bridge_delay: std::time::Duration, paymaster: alloy_primitives::Address, helper_balance: alloy_primitives::U256, native_message_fee: u64, helper_token_fee: u64, + quote_revert: Option, history_input: Option, history_target: Option, receipt_logs: Option>, incoming: bool, hide_logs: bool, + hide_operation_logs: bool, hide_receipts: bool, receipt_failure: Option, oversized_block: Option, @@ -264,6 +271,7 @@ struct ChainState { max_log_range: Option, oversized_logs: bool, log_requests: usize, + log_ranges: Vec<(u64, u64)>, incoming_count: u64, block_reads: usize, fail_block_read_at: Option, @@ -296,17 +304,24 @@ impl MockChain { mined: false, tip: 20000, timestamp: U256::from(wallet::now()), + block_hashes: Default::default(), + block_timestamps: Default::default(), reject_broadcast: false, delay_gas_estimate: false, + bridge_messages: Default::default(), + bridge_requests: vec![], + bridge_delay: std::time::Duration::ZERO, paymaster: paymaster::PAYMASTER, helper_balance: U256::from(1_000_000_000_000_000u64), native_message_fee: 10_000_000_000, helper_token_fee: 300_000, + quote_revert: None, history_input: None, history_target: Some(account::ENTRY_POINT), receipt_logs: None, incoming: false, hide_logs: false, + hide_operation_logs: false, hide_receipts: false, receipt_failure: None, oversized_block: None, @@ -318,6 +333,7 @@ impl MockChain { max_log_range: None, oversized_logs: false, log_requests: 0, + log_ranges: vec![], incoming_count: 0, block_reads: 0, fail_block_read_at: None, @@ -327,6 +343,7 @@ impl MockChain { })); let server_state = state.clone(); let task = tokio::spawn(async move { + let mut requests = tokio::task::JoinSet::new(); while let Ok((mut socket, _)) = listener.accept().await { let mut request = Vec::new(); let header_end = loop { @@ -393,12 +410,25 @@ impl MockChain { if delay { tokio::time::sleep(std::time::Duration::from_secs(6)).await; } + let bridge_delay = if method == "bitkit_getBridgeMessages" { + let mut state = server_state.lock().unwrap(); + state + .bridge_requests + .push(body["params"][0].as_str().unwrap().into()); + state.bridge_delay + } else { + std::time::Duration::ZERO + }; let mut response = server_state.lock().unwrap().respond(&body); if body["method"] == "eth_getLogs" { if let Some(logs) = response["result"].as_array_mut() { for log in logs { + let number = serde_json::from_value::(log["blockNumber"].clone()) + .ok() + .and_then(|number| u64::try_from(number).ok()) + .unwrap_or(20000); log.as_object_mut().unwrap().entry("blockHash").or_insert( - serde_json::json!(alloy_primitives::B256::repeat_byte(9)), + serde_json::json!(server_state.lock().unwrap().block_hash(number)), ); } } @@ -408,7 +438,11 @@ impl MockChain { } let response = response.to_string(); let response=format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}",response.len()); - let _ = socket.write_all(response.as_bytes()).await; + while requests.try_join_next().is_some() {} + requests.spawn(async move { + tokio::time::sleep(bridge_delay).await; + let _ = socket.write_all(response.as_bytes()).await; + }); } }); Self { url, state, task } @@ -424,6 +458,12 @@ impl MockChain { } } impl ChainState { + fn block_hash(&self, number: u64) -> alloy_primitives::B256 { + self.block_hashes.get(&number).copied().unwrap_or_else(|| { + alloy_primitives::B256::from(alloy_primitives::U256::from(number).to_be_bytes::<32>()) + }) + } + fn respond(&mut self, body: &serde_json::Value) -> serde_json::Value { use alloy_primitives::{Bytes, U256}; use alloy_sol_types::{SolCall, SolValue}; @@ -444,6 +484,11 @@ impl ChainState { } } let result = match body["method"].as_str().unwrap() { + "bitkit_getBridgeMessages" => self + .bridge_messages + .get(&body["params"][0].as_str().unwrap().to_ascii_lowercase()) + .cloned() + .unwrap_or_else(|| json!({"data":[]})), "eth_chainId" => json!(U256::from(self.chain)), "eth_blockNumber" => json!(U256::from(self.tip)), "eth_getBlockByNumber" => { @@ -455,7 +500,17 @@ impl ChainState { self.fail_block_read_at = None; return json!({"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"temporarily unavailable"}}); } - json!({"hash":alloy_primitives::B256::repeat_byte(9),"timestamp":self.timestamp,"transactions":self.block_transactions.clone().unwrap_or_else(|| vec![alloy_primitives::B256::repeat_byte(7)])}) + let number = u64::try_from( + serde_json::from_value::(body["params"][0].clone()).unwrap(), + ) + .unwrap(); + let timestamp = self + .block_timestamps + .range(..=number) + .next_back() + .map(|(_, timestamp)| *timestamp) + .unwrap_or(self.timestamp); + json!({"hash":self.block_hash(number),"timestamp":timestamp,"transactions":self.block_transactions.clone().unwrap_or_else(|| vec![alloy_primitives::B256::repeat_byte(7)])}) } "eth_getCode" => json!(self.account_code), "eth_getTransactionCount" => json!(U256::from(self.authorization_nonce)), @@ -466,6 +521,12 @@ impl ChainState { use transaction::{BridgeHelper, MessagingFee, OFTLimit, OFTReceipt, Oft}; let target: alloy_primitives::Address = serde_json::from_value(body["params"][0]["to"].clone()).unwrap(); + if self.quote_revert == Some(target) + && (data.starts_with(&Oft::quoteSendCall::SELECTOR) + || data.starts_with(&BridgeHelper::quoteSendCall::SELECTOR)) + { + return json!({"jsonrpc":"2.0","id":1,"error":{"code":3,"message":"Bridge contract call reverted"}}); + } if data.starts_with(&Oft::tokenCall::SELECTOR) { assert!([types::OFT, types::BRIDGE_HELPER].contains(&target)); } @@ -599,6 +660,8 @@ impl ChainState { let filter = &body["params"][0]; let from: U256 = serde_json::from_value(filter["fromBlock"].clone()).unwrap(); let to: U256 = serde_json::from_value(filter["toBlock"].clone()).unwrap(); + self.log_ranges + .push((u64::try_from(from).unwrap(), u64::try_from(to).unwrap())); if self .oversized_block .is_some_and(|block| from <= U256::from(block) && to >= U256::from(block)) @@ -665,6 +728,7 @@ impl ChainState { } if self.mined && !self.hide_logs + && !self.hide_operation_logs && from <= U256::from(20000) && to >= U256::from(20000) && (filter["address"] == json!(account::ENTRY_POINT) @@ -691,7 +755,14 @@ impl ChainState { { return json!({"jsonrpc":"2.0","id":1,"result":null}); } - json!({"transactionHash":body["params"][0],"blockHash":alloy_primitives::B256::repeat_byte(9),"blockNumber":"0x4e20","logs":self.receipt_logs.clone().unwrap_or_else(|| self.event_logs()),"padding":" ".repeat(self.receipt_padding)}) + let logs = self.receipt_logs.clone().unwrap_or_else(|| { + if body["params"][0] == json!(alloy_primitives::B256::repeat_byte(7)) { + self.event_logs() + } else { + vec![] + } + }); + json!({"transactionHash":body["params"][0],"blockHash":self.block_hash(20000),"blockNumber":"0x4e20","logs":logs,"padding":" ".repeat(self.receipt_padding)}) } "eth_getTransactionByHash" => { let op = &self.operations[0]; @@ -798,13 +869,10 @@ async fn signed_operation_survives_uncertain_broadcast_and_restart() { let chain = MockChain::start().await; let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); - let quote = tokio::time::timeout( - std::time::Duration::from_secs(3), - wallet.quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum), - ) - .await - .expect("An idle wallet must quote without a fixed per-request delay") - .unwrap(); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); let next = wallet .quote_transfer(RECIPIENT.into(), 2_000_000, UsdtDestination::Arbitrum) .await @@ -815,7 +883,7 @@ async fn signed_operation_survives_uncertain_broadcast_and_restart() { .await .unwrap(); assert_eq!(sent.status, UsdtTransferStatus::Pending); - assert!(sent.tx_hash.is_empty()); + assert!(sent.tx_hash.is_none()); let op = chain.state.lock().unwrap().operations[0].clone(); assert_eq!(op.sender.to_checksum(None), wallet.receive_address()); assert_eq!( @@ -868,7 +936,7 @@ async fn signed_operation_survives_uncertain_broadcast_and_restart() { assert_eq!(history.len(), 1); assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); assert_eq!(history[0].fee, Some(123)); - assert!(!history[0].tx_hash.is_empty()); + assert!(history[0].tx_hash.is_some()); } #[tokio::test] @@ -979,7 +1047,10 @@ async fn wrong_network_owner_nonce_balance_and_paymaster_cannot_sign() { #[tokio::test] async fn expired_unmined_operation_releases_nonce_for_a_new_approval() { - for nonce in [0, 1] { + for (nonce, log_error) in [ + (0, None), + (1, Some((-32002, "Provider unavailable".into()))), + ] { let chain = MockChain::start().await; chain.state.lock().unwrap().nonce = nonce; let dir = tempfile::tempdir().unwrap(); @@ -998,6 +1069,7 @@ async fn expired_unmined_operation_releases_nonce_for_a_new_approval() { state.timestamp += alloy_primitives::U256::from(180); state.tip += 3; state.max_log_range = Some(1); + state.log_error = log_error.clone(); } assert_eq!( wallet.refresh_transfers().await.unwrap()[0].status, @@ -1009,7 +1081,20 @@ async fn expired_unmined_operation_releases_nonce_for_a_new_approval() { .await, Err(UsdtError::PendingTransfer) )); - chain.state.lock().unwrap().timestamp += alloy_primitives::U256::from(421); + { + let mut state = chain.state.lock().unwrap(); + state.timestamp += alloy_primitives::U256::from(421); + state.log_error = Some((-32016, "Provider rate limit exceeded".into())); + } + assert!(matches!( + wallet.refresh_transfers().await, + Err(UsdtError::RateLimited) + )); + assert_eq!( + wallet.history().unwrap()[0].status, + UsdtTransferStatus::Pending + ); + chain.state.lock().unwrap().log_error = log_error; assert_eq!( wallet.refresh_transfers().await.unwrap()[0].status, UsdtTransferStatus::Failed @@ -1070,6 +1155,22 @@ async fn bridge_payment_bounds_token_fees_and_revokes_helper_approval() { assert_eq!(bridge_fee, 360_001); assert!(paymaster.amount > U256::from(quote.maximum_fee - bridge_fee)); assert_eq!(quote.received_amount, 1_000_000); + for target in [types::OFT, types::BRIDGE_HELPER] { + chain.state.lock().unwrap().quote_revert = Some(target); + assert!(matches!( + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::QuoteExpired) + )); + assert!(matches!( + wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) + .await, + Err(UsdtError::UnsupportedRoute) + )); + } + chain.state.lock().unwrap().quote_revert = None; chain.state.lock().unwrap().native_message_fee = 12_000_000_000; assert!(matches!( wallet @@ -1150,68 +1251,84 @@ async fn bundled_operations_cannot_contribute_another_payments_bridge_status_or_ use alloy_primitives::{B256, U256}; use alloy_sol_types::SolEvent; use serde_json::json; - let chain = MockChain::start().await; - let dir = tempfile::tempdir().unwrap(); - let wallet = chain.wallet(&dir); - let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) - .await - .unwrap(); - let mut transfer = wallet - .send(quote.id, TEST_PHRASE.into(), None) - .await - .unwrap(); - let own_hash = transfer - .user_operation_hash - .as_ref() - .unwrap() - .parse() - .unwrap(); - let other_hash = B256::repeat_byte(2); - let guid = B256::repeat_byte(3); - let event = |hash, success| { - transaction::EntryPoint::UserOperationEvent { - userOpHash: hash, - sender: wallet.address, - paymaster: paymaster::PAYMASTER, - nonce: U256::ZERO, - success, - actualGasCost: U256::from(1), - actualGasUsed: U256::from(1), - } - .encode_log_data() - }; - let log = |address, data: alloy_primitives::LogData| json!({"address":address,"topics":data.topics(),"data":data.data}); - let receipt = json!({"logs":[ - log(types::OFT, transaction::Oft::OFTSent { guid, dstEid:30109, fromAddress:types::BRIDGE_HELPER, amountSentLD:U256::from(1_000_000), amountReceivedLD:U256::from(1_000_000) }.encode_log_data()), - log(types::BRIDGE_HELPER, transaction::BridgeHelper::LogSend { sender:wallet.address, oft:types::OFT, amountLD:U256::from(1_000_000), nativeFee:U256::from(100), feeInToken:U256::from(500), totalAmount:U256::from(1_000_500) }.encode_log_data()), - log(account::ENTRY_POINT, event(other_hash, true)), - log(paymaster::PAYMASTER, transaction::Paymaster::UserOperationSponsored { userOpHash:own_hash, user:wallet.address, paymasterMode:1, token:types::TOKEN, tokenAmountPaid:U256::from(123), exchangeRate:U256::from(1) }.encode_log_data()), - log(account::ENTRY_POINT, event(own_hash, true)), - ]}); - assert_eq!( - transaction::operation_logs(&receipt, own_hash).unwrap(), - &receipt["logs"].as_array().unwrap()[3..] - ); - let mut failed = receipt.clone(); - failed["logs"][4] = log(account::ENTRY_POINT, event(own_hash, false)); - wallet.settle(&mut transfer, &failed).unwrap(); - assert_eq!(transfer.status, UsdtTransferStatus::Failed); - assert_eq!(transfer.fee, Some(123)); - assert_eq!(transfer.bridge_guid, None); - wallet.settle(&mut transfer, &receipt).unwrap(); - assert_eq!(transfer.status, UsdtTransferStatus::BridgeNeedsAttention); - assert_eq!(transfer.bridge_guid, None); - assert_eq!(transfer.fee, None); + for (destination, status, fee, fee_with_bridge_log) in [ + ( + UsdtDestination::Arbitrum, + UsdtTransferStatus::Confirmed, + Some(123), + Some(123), + ), + ( + UsdtDestination::Polygon, + UsdtTransferStatus::BridgeNeedsAttention, + None, + Some(623), + ), + ] { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, destination) + .await + .unwrap(); + let mut transfer = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let own_hash = transfer + .user_operation_hash + .as_ref() + .unwrap() + .parse() + .unwrap(); + let other_hash = B256::repeat_byte(2); + let guid = B256::repeat_byte(3); + let event = |hash, success| { + transaction::EntryPoint::UserOperationEvent { + userOpHash: hash, + sender: wallet.address, + paymaster: paymaster::PAYMASTER, + nonce: U256::ZERO, + success, + actualGasCost: U256::from(1), + actualGasUsed: U256::from(1), + } + .encode_log_data() + }; + let log = |address, data: alloy_primitives::LogData| json!({"address":address,"topics":data.topics(),"data":data.data}); + let receipt = json!({"logs":[ + log(types::OFT, transaction::Oft::OFTSent { guid, dstEid:30109, fromAddress:types::BRIDGE_HELPER, amountSentLD:U256::from(1_000_000), amountReceivedLD:U256::from(1_000_000) }.encode_log_data()), + log(types::BRIDGE_HELPER, transaction::BridgeHelper::LogSend { sender:wallet.address, oft:types::OFT, amountLD:U256::from(1_000_000), nativeFee:U256::from(100), feeInToken:U256::from(500), totalAmount:U256::from(1_000_500) }.encode_log_data()), + log(account::ENTRY_POINT, event(other_hash, true)), + log(types::TOKEN, transaction::Erc20::Transfer { from:wallet.address, to:RECIPIENT.parse().unwrap(), value:U256::from(1_000_000) }.encode_log_data()), + log(paymaster::PAYMASTER, transaction::Paymaster::UserOperationSponsored { userOpHash:own_hash, user:wallet.address, paymasterMode:1, token:types::TOKEN, tokenAmountPaid:U256::from(123), exchangeRate:U256::from(1) }.encode_log_data()), + log(account::ENTRY_POINT, event(own_hash, true)), + ]}); + assert_eq!( + transaction::operation_logs(&receipt, own_hash).unwrap(), + &receipt["logs"].as_array().unwrap()[3..] + ); + let mut failed = receipt.clone(); + failed["logs"][5] = log(account::ENTRY_POINT, event(own_hash, false)); + wallet.settle(&mut transfer, &failed).unwrap(); + assert_eq!(transfer.status, UsdtTransferStatus::Failed); + assert_eq!(transfer.fee, Some(123)); + assert_eq!(transfer.bridge_guid, None); + wallet.settle(&mut transfer, &receipt).unwrap(); + assert_eq!(transfer.status, status); + assert_eq!(transfer.bridge_guid, None); + assert_eq!(transfer.fee, fee); - let mut receipt = receipt; - let bridge_log = receipt["logs"][1].clone(); - receipt["logs"] - .as_array_mut() - .unwrap() - .insert(4, bridge_log); - wallet.settle(&mut transfer, &receipt).unwrap(); - assert_eq!(transfer.fee, Some(623)); + let mut receipt = receipt; + let bridge_log = receipt["logs"][1].clone(); + receipt["logs"] + .as_array_mut() + .unwrap() + .insert(5, bridge_log); + wallet.settle(&mut transfer, &receipt).unwrap(); + assert_eq!(transfer.fee, fee_with_bridge_log); + } } #[tokio::test] @@ -1223,17 +1340,13 @@ async fn deployed_contracts_collect_usdt_fees_and_revert_failed_bridges_atomical let client: String = rpc.call("web3_clientVersion", json!([])).await.unwrap(); assert!(client.to_lowercase().contains("anvil")); let dir = tempfile::tempdir().unwrap(); - let mut wallet = UsdtWallet::new( + let wallet = UsdtWallet::new( usdt_address(TEST_PHRASE.into(), None).unwrap(), dir.path().join("usdt.sqlite").to_string_lossy().into(), - std::env::var("USDT_FORK_RPC_URL").unwrap_or_else(|_| "http://127.0.0.1:18545".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()), ) .unwrap(); - std::sync::Arc::get_mut(&mut wallet) - .unwrap() - .rpc - .bridge_status_url = Some("http://127.0.0.1:18546".into()); assert_eq!(rpc.balance(wallet.address).await.unwrap(), U256::ZERO); let initial = wallet.balance().await.unwrap(); // Only locally mined transactions belong to this fixture's history. @@ -1480,7 +1593,7 @@ async fn wrapped_history_preserves_signed_payments_and_restores_token_transfers( assert_eq!(payment.recipient, RECIPIENT); assert_eq!(payment.status, UsdtTransferStatus::Confirmed); assert_eq!(payment.fee, Some(123)); - assert!(!payment.tx_hash.is_empty()); + assert!(payment.tx_hash.is_some()); assert!(reopened.store.pending_plan(&sent.id).unwrap().is_none()); let restored_dir = tempfile::tempdir().unwrap(); @@ -1514,7 +1627,7 @@ fn stored_activity_is_complete_and_sorted_newest_first() { let transfers: Vec<_> = (0..501) .map(|index| UsdtTransfer { id: format!("receipt-{index}"), - tx_hash: format!("tx-{index}"), + tx_hash: Some(format!("tx-{index}")), user_operation_hash: None, bridge_guid: None, recipient: RECIPIENT.into(), @@ -1525,10 +1638,11 @@ fn stored_activity_is_complete_and_sorted_newest_first() { is_incoming: true, status: UsdtTransferStatus::Confirmed, timestamp: index, - explorer_url: String::new(), }) .collect(); - store.save_history_receipt(&transfers, "receipt").unwrap(); + store + .save_history_receipt(&transfers, "receipt", 1000, "block", true) + .unwrap(); store.complete_history(1000).unwrap(); let history = store.transfers().unwrap(); assert_eq!(history.len(), 501); @@ -1610,9 +1724,10 @@ async fn interrupted_history_resumes_without_repeating_completed_work() { .send(quote.id, TEST_PHRASE.into(), None) .await .unwrap(); + let incoming_count = 100; { let mut state = chain.state.lock().unwrap(); - state.incoming_count = 100; + state.incoming_count = incoming_count as u64; state.tip = 508_000_000; state.block_reads = 0; state.fail_block_read_at = Some(25); @@ -1637,16 +1752,20 @@ async fn interrupted_history_resumes_without_repeating_completed_work() { drop(restored); let restored = chain.wallet(&restored_dir); sync_history_to_tip(&restored).await; - assert_eq!(restored.history().unwrap().len(), 100); - assert_eq!(chain.state.lock().unwrap().block_reads, 101); + assert_eq!(restored.history().unwrap().len(), incoming_count); + assert_eq!(chain.state.lock().unwrap().block_reads, incoming_count + 1); sync_history_to_tip(&restored).await; - assert_eq!(restored.history().unwrap().len(), 100); - assert_eq!(chain.state.lock().unwrap().block_reads, 101); + assert_eq!(restored.history().unwrap().len(), incoming_count); + assert_eq!(chain.state.lock().unwrap().block_reads, incoming_count + 1); assert!(restored.store.history_progress().unwrap().is_none()); assert_eq!(restored.store.synced_block().unwrap().unwrap(), 507_999_998); assert!(!restored .store - .has_history_receipt(&restored.history().unwrap()[0].tx_hash) + .has_history_receipt( + restored.history().unwrap()[0].tx_hash.as_deref().unwrap(), + &format!("{:#x}", chain.state.lock().unwrap().block_hash(20100)), + false + ) .unwrap()); } @@ -1665,10 +1784,13 @@ async fn replacement_after_expiry_recovers_pending_send_after_restart() { .await .unwrap(); drop(wallet); + let expiry_block = 22_500; { let mut state = chain.state.lock().unwrap(); state.nonce = 1; state.timestamp += alloy_primitives::U256::from(600); + let expired = state.timestamp + alloy_primitives::U256::from(1); + state.block_timestamps.insert(expiry_block, expired); state.tip = 508_000_000; state.replacement_block = Some(507_000_000); state.hide_logs = true; @@ -1677,6 +1799,11 @@ async fn replacement_after_expiry_recovers_pending_send_after_restart() { } let wallet = chain.wallet(&dir); assert!(wallet.refresh_transfers().await.is_err()); + let log_ranges = chain.state.lock().unwrap().log_ranges.clone(); + assert!(!log_ranges.is_empty()); + assert!(log_ranges + .iter() + .all(|&(start, end)| start == end || end <= expiry_block)); assert_eq!( wallet.history().unwrap()[0].status, UsdtTransferStatus::Pending @@ -1689,7 +1816,7 @@ async fn replacement_after_expiry_recovers_pending_send_after_restart() { state.hide_receipts = false; state.receipt_response = Some(serde_json::json!({ "transactionHash":alloy_primitives::B256::repeat_byte(7), - "blockHash":alloy_primitives::B256::repeat_byte(9), + "blockHash":state.block_hash(507_000_000), "blockNumber":alloy_primitives::U256::from(507_000_000),"logs":[] })); } @@ -1774,14 +1901,25 @@ async fn history_distinguishes_rate_limits_from_log_range_limits() { #[tokio::test] async fn history_budget_returns_incomplete_and_resumes_to_tip() { let chain = MockChain::start().await; - chain.state.lock().unwrap().tip = 508_000_000; + { + let mut state = chain.state.lock().unwrap(); + state.tip = 508_000_000; + state.max_log_range = Some(1_000_000); + } let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); assert!(!wallet.sync_history().await.unwrap()); let next = wallet.store.history_progress().unwrap().unwrap(); assert!(next > 0 && next < 508_000_000); + assert!( + wallet + .history_range_limit + .load(std::sync::atomic::Ordering::Relaxed) + <= 1_000_000 + ); + chain.state.lock().unwrap().tip = next + 1_000_000; sync_history_to_tip(&wallet).await; - assert_eq!(wallet.store.synced_block().unwrap(), Some(507_999_998)); + assert_eq!(wallet.store.synced_block().unwrap(), Some(next + 999_998)); } #[tokio::test] @@ -1847,57 +1985,20 @@ async fn invalid_chain_data_and_stored_json_have_distinct_errors() { #[tokio::test] async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending() { - use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, Mutex, - }; - use tokio::{ - io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, - time::Duration, - }; + use tokio::time::Duration; let chain = MockChain::start().await; let directory = tempfile::tempdir().unwrap(); - let mut wallet = chain.wallet(&directory); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - let attempts = Arc::new(Mutex::new(Vec::new())); - let stalled = Arc::new(AtomicBool::new(true)); - let accepted = attempts.clone(); - let stalled_server = stalled.clone(); - let server = tokio::spawn(async move { - let mut requests = tokio::task::JoinSet::new(); - while let Ok((socket, _)) = listener.accept().await { - let accepted = accepted.clone(); - let stalled = stalled_server.load(Ordering::SeqCst); - requests.spawn(async move { - let mut reader = BufReader::new(socket); - let mut line = String::new(); - reader.read_line(&mut line).await.unwrap(); - let hash = line.split_whitespace().nth(1).unwrap().strip_prefix("/v1/messages/tx/").unwrap().to_string(); - loop { - line.clear(); - if reader.read_line(&mut line).await.unwrap() == 0 || line == "\r\n" { break; } - } - accepted.lock().unwrap().push(hash.clone()); - tokio::time::sleep(if stalled { Duration::from_secs(25) } else { Duration::from_millis(1200) }).await; - let index = hash.strip_prefix("bridge-tx-").unwrap(); - let body = serde_json::json!({"data":[{ - "guid":format!("guid-{index}"), - "pathway":{"srcEid":30110,"dstEid":30109,"sender":{"address":types::OFT}}, - "source":{"tx":{"txHash":hash}},"status":{"name":"DELIVERED"} - }]}).to_string(); - let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body); - let _ = reader.into_inner().write_all(response.as_bytes()).await; - }); - } - }); - Arc::get_mut(&mut wallet).unwrap().rpc.bridge_status_url = Some(format!("http://{address}")); + let wallet = chain.wallet(&directory); + chain.state.lock().unwrap().bridge_delay = Duration::from_secs(25); let bridges: Vec<_> = (0..5) .map(|index| UsdtTransfer { id: format!("bridge-{index}"), - tx_hash: format!("bridge-tx-{index}"), + tx_hash: Some(format!("{:#x}", alloy_primitives::B256::repeat_byte(index))), user_operation_hash: None, - bridge_guid: Some(format!("guid-{index}")), + bridge_guid: Some(format!( + "{:#x}", + alloy_primitives::B256::repeat_byte(index + 10) + )), recipient: RECIPIENT.into(), destination: UsdtDestination::Polygon, amount: 1_000_000, @@ -1906,12 +2007,21 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending is_incoming: false, status: UsdtTransferStatus::Bridging, timestamp: 1, - explorer_url: String::new(), }) .collect(); + for bridge in &bridges { + chain.state.lock().unwrap().bridge_messages.insert( + bridge.tx_hash.clone().unwrap(), + serde_json::json!({"data":[{ + "guid":bridge.bridge_guid, + "pathway":{"srcEid":30110,"dstEid":30109,"sender":{"address":types::OFT}}, + "source":{"tx":{"txHash":bridge.tx_hash}},"status":{"name":"DELIVERED"} + }]}), + ); + } wallet .store - .save_history_receipt(&bridges, "bridges") + .save_history_receipt(&bridges, "bridges", 1000, "block", true) .unwrap(); // A signed operation with no nonce consumption must still resolve once expired. let quote = wallet @@ -1933,7 +2043,7 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending .await .unwrap() .unwrap(); - assert_eq!(attempts.lock().unwrap().len(), 3); + assert_eq!(chain.state.lock().unwrap().bridge_requests.len(), 3); assert_eq!( history .iter() @@ -1960,7 +2070,7 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending let refresh_wallet = wallet.clone(); let refresh = tokio::spawn(async move { refresh_wallet.refresh_transfers().await }); tokio::time::timeout(Duration::from_secs(3), async { - while attempts.lock().unwrap().len() < 6 { + while chain.state.lock().unwrap().bridge_requests.len() < 5 { tokio::time::sleep(Duration::from_millis(10)).await; } }) @@ -1974,21 +2084,31 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending assert_eq!(result.unwrap().unwrap().status, UsdtTransferStatus::Pending); assert!(!refresh.is_finished()); refresh.await.unwrap().unwrap(); - { - let attempts = attempts.lock().unwrap(); - assert_eq!( - attempts[..6] - .iter() - .collect::>() - .len(), - 5 - ); - } - // Healthy responses slower than an equal share of the budget must still settle. - stalled.store(false, Ordering::SeqCst); + assert_eq!( + chain + .state + .lock() + .unwrap() + .bridge_requests + .iter() + .collect::>() + .len(), + 5 + ); + // Failed lookups wait a minute, then recover without delaying source reconciliation. + let attempts = chain.state.lock().unwrap().bridge_requests.len(); + wallet.refresh_transfers().await.unwrap(); + assert_eq!(chain.state.lock().unwrap().bridge_requests.len(), attempts); + tokio::time::pause(); + tokio::time::advance(Duration::from_secs(60)).await; + tokio::time::resume(); + chain.state.lock().unwrap().bridge_delay = Duration::from_millis(1200); chain.state.lock().unwrap().tip += 3; - chain.state.lock().unwrap().log_error = Some((-32603, "provider unavailable".into())); for _ in 0..2 { + { + let mut state = chain.state.lock().unwrap(); + state.fail_block_read_at = Some(state.block_reads + 1); + } tokio::time::timeout(Duration::from_secs(25), wallet.refresh_transfers()) .await .unwrap() @@ -1998,7 +2118,6 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending assert!(bridges.iter().all(|bridge| history.iter().any( |transfer| transfer.id == bridge.id && transfer.status == UsdtTransferStatus::Confirmed ))); - server.abort(); } #[tokio::test] @@ -2222,21 +2341,30 @@ async fn seed_restore_includes_external_token_sends_without_duplicate_operation_ } #[tokio::test] -async fn gas_price_changes_require_a_new_quote_before_signing() { - let chain = MockChain::start().await; - let dir = tempfile::tempdir().unwrap(); - let wallet = chain.wallet(&dir); - let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) - .await - .unwrap(); - chain.state.lock().unwrap().gas_price = 60_000_000; - assert!(matches!( - wallet.send(quote.id, TEST_PHRASE.into(), None).await, - Err(UsdtError::QuoteExpired) - )); - assert!(wallet.history().unwrap().is_empty()); - assert!(chain.state.lock().unwrap().operations.is_empty()); +async fn gas_price_changes_respect_the_approved_fee() { + for (gas_price, accepted) in [(52_000_000, true), (60_000_000, false)] { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let plan = wallet.store.quote("e.id).unwrap().plan; + chain.state.lock().unwrap().gas_price = gas_price; + let result = wallet.send(quote.id, TEST_PHRASE.into(), None).await; + if accepted { + result.unwrap(); + assert_eq!( + chain.state.lock().unwrap().operations[0].max_fee_per_gas, + plan.operation.max_fee_per_gas + ); + } else { + assert!(matches!(result, Err(UsdtError::QuoteExpired))); + assert!(wallet.history().unwrap().is_empty()); + assert!(chain.state.lock().unwrap().operations.is_empty()); + } + } } #[tokio::test] @@ -2263,7 +2391,7 @@ async fn consumed_nonce_recovery_requires_complete_receipts_and_resumes_after_re } assert!(wallet.refresh_transfers().await.is_err()); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); - let block_hash = format!("{:#x}", B256::repeat_byte(9)); + let block_hash = format!("{:#x}", chain.state.lock().unwrap().block_hash(20000)); assert_eq!( wallet.store.nonce_recovery(&sent.id, &block_hash).unwrap(), 1 @@ -2291,6 +2419,40 @@ async fn consumed_nonce_recovery_requires_complete_receipts_and_resumes_after_re #[tokio::test] async fn consuming_block_receipts_recover_a_payment_hidden_from_log_queries() { + for unavailable in [false, true] { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.nonce = 1; + state.tip += 3; + state.hide_operation_logs = true; + state.replacement_block = Some(20000); + if unavailable { + state.log_error = Some((-32002, "Provider unavailable".into())); + } + state.mined = true; + } + let history = wallet.refresh_transfers().await.unwrap(); + assert_eq!(history[0].id, sent.id); + assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); + assert_eq!(history[0].fee, Some(123)); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); + } +} + +#[tokio::test] +async fn consumed_nonce_recovery_preserves_malformed_operation_evidence() { + use serde_json::json; let chain = MockChain::start().await; let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); @@ -2307,13 +2469,36 @@ async fn consuming_block_receipts_recover_a_payment_hidden_from_log_queries() { state.nonce = 1; state.tip += 3; state.hide_logs = true; + state.replacement_block = Some(20000); state.mined = true; + let mut logs = state.event_logs(); + logs.iter_mut() + .find(|log| log["address"] == json!(account::ENTRY_POINT.to_checksum(None))) + .unwrap()["data"] = json!("0x"); + state.receipt_logs = Some(logs); } - let history = wallet.refresh_transfers().await.unwrap(); + assert!(matches!( + wallet.refresh_transfers().await, + Err(UsdtError::InvalidResponse) + )); + assert_eq!( + wallet.history().unwrap()[0].status, + UsdtTransferStatus::Pending + ); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + drop(wallet); + { + let mut state = chain.state.lock().unwrap(); + let mut logs = state.event_logs(); + logs.insert(0, json!({"address":account::ENTRY_POINT,"topics":[alloy_primitives::keccak256("BeforeExecution()")],"data":"0x"})); + state.receipt_logs = Some(logs); + } + let restored = chain.wallet(&dir); + let history = restored.refresh_transfers().await.unwrap(); assert_eq!(history[0].id, sent.id); assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); assert_eq!(history[0].fee, Some(123)); - assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); + assert!(restored.store.pending_plan(&sent.id).unwrap().is_none()); } #[tokio::test] @@ -2357,6 +2542,22 @@ async fn dense_block_history_recovers_large_receipts_without_skipping_after_rest let restored = chain.wallet(&dir); sync_history_to_tip(&restored).await; assert_eq!(chain.state.lock().unwrap().receipt_reads, reads); + drop(restored); + { + let mut state = chain.state.lock().unwrap(); + state + .block_hashes + .insert(20000, alloy_primitives::B256::repeat_byte(0xaa)); + } + let restored = chain.wallet(&dir); + restored.store.save_history_progress(20000).unwrap(); + sync_history_to_tip(&restored).await; + assert_eq!(chain.state.lock().unwrap().receipt_reads, reads + 1); + assert_eq!(restored.history().unwrap()[0].id, sent.id); + assert_eq!( + restored.history().unwrap()[0].status, + UsdtTransferStatus::Confirmed + ); } #[tokio::test] @@ -2420,27 +2621,6 @@ async fn first_submission_precheck_releases_an_operation_that_was_never_sent() { .unwrap(); } -#[tokio::test] -async fn moderate_gas_price_movement_preserves_the_approved_fee() { - let chain = MockChain::start().await; - let dir = tempfile::tempdir().unwrap(); - let wallet = chain.wallet(&dir); - let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) - .await - .unwrap(); - let plan = wallet.store.quote("e.id).unwrap().plan; - chain.state.lock().unwrap().gas_price = 52_000_000; - wallet - .send(quote.id, TEST_PHRASE.into(), None) - .await - .unwrap(); - assert_eq!( - chain.state.lock().unwrap().operations[0].max_fee_per_gas, - plan.operation.max_fee_per_gas - ); -} - #[tokio::test] async fn unknown_paymaster_history_preserves_principal_fee_and_refund() { use alloy_primitives::{Address, B256, U256}; @@ -2551,7 +2731,7 @@ async fn settlement_requires_matching_canonical_receipts() { invalid[field] = value; chain.state.lock().unwrap().receipt_response = Some(invalid); assert!(matches!( - wallet.refresh_transfer(sent.id.clone()).await, + wallet.check_recent_execution(sent.id.clone()).await, Err(UsdtError::InvalidResponse) )); assert!(matches!( @@ -2587,46 +2767,11 @@ async fn bridge_settlement_recovers_guid_fees_and_preserves_delivery_on_rescan() use alloy_primitives::{B256, U256}; use alloy_sol_types::SolEvent; use serde_json::json; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; let chain = MockChain::start().await; let dir = tempfile::tempdir().unwrap(); - let mut wallet = chain.wallet(&dir); + let wallet = chain.wallet(&dir); let guid = B256::repeat_byte(0xab); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - std::sync::Arc::get_mut(&mut wallet) - .unwrap() - .rpc - .bridge_status_url = Some(format!("http://{}", listener.local_addr().unwrap())); let message = json!({"guid":guid,"pathway":{"srcEid":30110,"dstEid":30109,"sender":{"address":types::OFT}},"source":{"tx":{"txHash":B256::repeat_byte(7)}},"status":{"name":"DELIVERED"}}); - let mut responses = Vec::new(); - for (pointer, value) in [ - ("/guid", json!(B256::ZERO)), - ("/pathway/srcEid", json!(30101)), - ("/pathway/dstEid", json!(30101)), - ("/pathway/sender/address", json!(RECIPIENT)), - ("/source/tx/txHash", json!(B256::ZERO)), - ("/status/name", json!("NEW_PROVIDER_STATUS")), - ("/status/name", json!("FAILED")), - ] { - let mut changed = message.clone(); - *changed.pointer_mut(pointer).unwrap() = value; - responses.push((200, json!({"data":[changed]}))); - } - responses.extend([ - (429, json!({})), - (200, json!({"data":null})), - (200, json!({"data":[message]})), - ]); - let server = tokio::spawn(async move { - for (status, body) in responses { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut request = [0; 4096]; - let size = socket.read(&mut request).await.unwrap(); - assert!(String::from_utf8_lossy(&request[..size]).starts_with("GET /v1/messages/tx/0x")); - let body = body.to_string(); - socket.write_all(format!("HTTP/1.1 {status} OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); - } - }); let quote = wallet .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) .await @@ -2682,62 +2827,166 @@ async fn bridge_settlement_recovers_guid_fees_and_preserves_delivery_on_rescan() assert_eq!(pending.received_amount, 999_999); assert_eq!(pending.fee, Some(300_123)); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); - for _ in 0..6 { - assert_eq!( - wallet.rpc.bridge_status(&pending).await.unwrap(), - UsdtTransferStatus::Bridging - ); + for (pointer, value, expected) in [ + ("/guid", json!(B256::ZERO), None), + ("/pathway/srcEid", json!(30101), None), + ("/pathway/dstEid", json!(30101), None), + ("/pathway/sender/address", json!(RECIPIENT), None), + ("/source/tx/txHash", json!(B256::ZERO), None), + ("/status/name", json!("NEW_PROVIDER_STATUS"), None), + ( + "/status/name", + json!("FAILED"), + Some(UsdtTransferStatus::BridgeNeedsAttention), + ), + ( + "/status/name", + json!("BLOCKED"), + Some(UsdtTransferStatus::BridgeNeedsAttention), + ), + ( + "/status/name", + json!("PAYLOAD_STORED"), + Some(UsdtTransferStatus::BridgeNeedsAttention), + ), + ( + "/status/name", + json!("INFLIGHT"), + Some(UsdtTransferStatus::Bridging), + ), + ( + "/status/name", + json!("CONFIRMING"), + Some(UsdtTransferStatus::Bridging), + ), + ( + "/status/name", + json!("APPLICATION_BURNED"), + Some(UsdtTransferStatus::BridgeFailed), + ), + ( + "/status/name", + json!("APPLICATION_SKIPPED"), + Some(UsdtTransferStatus::BridgeFailed), + ), + ] { + let mut changed = message.clone(); + *changed.pointer_mut(pointer).unwrap() = value; + chain + .state + .lock() + .unwrap() + .bridge_messages + .insert(pending.tx_hash.clone().unwrap(), json!({"data":[changed]})); + match expected { + Some(status) => assert_eq!(wallet.rpc.bridge_status(&pending).await.unwrap(), status), + None => assert!(matches!( + wallet.rpc.bridge_status(&pending).await, + Err(UsdtError::NetworkUnavailable) + )), + } } + let mut retryable = message.clone(); + retryable["status"]["name"] = json!("FAILED"); + chain.state.lock().unwrap().bridge_messages.insert( + pending.tx_hash.clone().unwrap(), + json!({"data":[retryable]}), + ); assert_eq!( - wallet.rpc.bridge_status(&pending).await.unwrap(), + wallet.refresh_transfers().await.unwrap()[0].status, UsdtTransferStatus::BridgeNeedsAttention ); - assert!(matches!( - wallet.rpc.bridge_status(&pending).await, - Err(UsdtError::NetworkUnavailable) - )); - assert!(matches!( - wallet.rpc.bridge_status(&pending).await, - Err(UsdtError::InvalidResponse) - )); + let requests = chain.state.lock().unwrap().bridge_requests.len(); + wallet.refresh_transfers().await.unwrap(); + assert_eq!(chain.state.lock().unwrap().bridge_requests.len(), requests); + tokio::time::pause(); + tokio::time::advance(std::time::Duration::from_secs(60)).await; + tokio::time::resume(); + chain.state.lock().unwrap().bridge_messages.insert( + pending.tx_hash.clone().unwrap(), + json!({"data":[message.clone()]}), + ); chain.state.lock().unwrap().chain = 1; let mut delivered = wallet.refresh_transfers().await.unwrap().remove(0); assert_eq!(delivered.status, UsdtTransferStatus::Confirmed); - server.await.unwrap(); delivered.bridge_guid = delivered.bridge_guid.map(|guid| guid.to_uppercase()); - delivered.tx_hash = delivered.tx_hash.to_uppercase(); + delivered.tx_hash = delivered.tx_hash.map(|hash| hash.to_uppercase()); wallet.store.update_transfer(&delivered).unwrap(); - chain.state.lock().unwrap().chain = 42161; + for status in [ + UsdtTransferStatus::Confirmed, + UsdtTransferStatus::BridgeFailed, + ] { + if status == UsdtTransferStatus::BridgeFailed { + delivered.status = UsdtTransferStatus::Bridging; + wallet.store.update_transfer(&delivered).unwrap(); + let mut stopped = message.clone(); + stopped["status"]["name"] = json!("APPLICATION_BURNED"); + chain + .state + .lock() + .unwrap() + .bridge_messages + .insert(pending.tx_hash.clone().unwrap(), json!({"data":[stopped]})); + assert_eq!(wallet.refresh_transfers().await.unwrap()[0].status, status); + } + let reads = { + let mut state = chain.state.lock().unwrap(); + state.chain = 42161; + let current_hash = state.block_hash(20000); + state.block_hashes.insert( + 20000, + if current_hash == B256::repeat_byte(0xac) { + B256::repeat_byte(0xab) + } else { + B256::repeat_byte(0xac) + }, + ); + state.receipt_reads + }; + sync_history_to_tip(&wallet).await; + assert_eq!(chain.state.lock().unwrap().receipt_reads, reads + 1); + assert_eq!(wallet.history().unwrap()[0].status, status); + } + drop(wallet); + let wallet = chain.wallet(&dir); + let requests = chain.state.lock().unwrap().bridge_requests.len(); + let failed = wallet.refresh_transfers().await.unwrap().remove(0); + assert_eq!(failed.status, UsdtTransferStatus::BridgeFailed); + assert_eq!(failed.fee, Some(300_123)); + assert!(failed.tx_hash.is_some() && failed.bridge_guid.is_some()); + assert_eq!(chain.state.lock().unwrap().bridge_requests.len(), requests); + let replacement_guid = B256::repeat_byte(0xad); + { + let mut state = chain.state.lock().unwrap(); + state.block_hashes.insert(20000, B256::repeat_byte(0xae)); + state.receipt_logs.as_mut().unwrap()[1]["topics"][1] = json!(replacement_guid); + } sync_history_to_tip(&wallet).await; + let replacement = wallet.history().unwrap().remove(0); + assert_eq!(replacement.id, delivered.id); assert_eq!( - wallet.history().unwrap()[0].status, - UsdtTransferStatus::Confirmed + replacement.bridge_guid, + Some(format!("{replacement_guid:#x}")) ); + assert_eq!(replacement.status, UsdtTransferStatus::Bridging); drop(wallet); let restored_dir = tempfile::tempdir().unwrap(); let restored = chain.wallet(&restored_dir); sync_history_to_tip(&restored).await; let recovered = restored.history().unwrap().remove(0); assert_eq!(recovered.destination, UsdtDestination::Polygon); - assert_eq!(recovered.bridge_guid, Some(format!("{guid:#x}"))); + assert_eq!( + recovered.bridge_guid, + Some(format!("{replacement_guid:#x}")) + ); assert_eq!(recovered.fee, Some(300_123)); } #[tokio::test] -async fn destination_tokens_are_not_payment_recipients() { +async fn infrastructure_and_token_addresses_are_not_payment_recipients() { let chain = MockChain::start().await; let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); - assert!(matches!( - wallet - .quote_transfer( - account::ENTRY_POINT.to_checksum(None), - 1_000_000, - UsdtDestination::Ethereum - ) - .await, - Err(UsdtError::InvalidAddress) - )); for destination in [ UsdtDestination::Arbitrum, UsdtDestination::Ethereum, @@ -2745,16 +2994,23 @@ async fn destination_tokens_are_not_payment_recipients() { UsdtDestination::Plasma, UsdtDestination::Stable, ] { - assert!(matches!( - wallet - .quote_transfer( - destination.token().to_checksum(None), - 1_000_000, - destination - ) - .await, - Err(UsdtError::InvalidAddress) - )); + let mut recipients = vec![ + destination.token(), + account::ENTRY_POINT, + account::DELEGATE, + paymaster::PAYMASTER, + ]; + if destination == UsdtDestination::Arbitrum { + recipients.extend([types::OFT, types::BRIDGE_HELPER]); + } + for recipient in recipients { + assert!(matches!( + wallet + .quote_transfer(recipient.to_checksum(None), 1_000_000, destination) + .await, + Err(UsdtError::InvalidAddress) + )); + } if let Some(eid) = destination.endpoint() { assert_eq!(UsdtDestination::from_endpoint(eid), Some(destination)); } @@ -2938,7 +3194,7 @@ async fn recent_execution_requires_the_expected_token_transfer() { logs[0]["data"] = json!(token.data); chain.state.lock().unwrap().receipt_logs = Some(logs); assert!(matches!( - wallet.refresh_transfer(sent.id.clone()).await, + wallet.check_recent_execution(sent.id.clone()).await, Err(UsdtError::InvalidResponse) )); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); @@ -2947,13 +3203,13 @@ async fn recent_execution_requires_the_expected_token_transfer() { logs.remove(0); chain.state.lock().unwrap().receipt_logs = Some(logs); assert!(matches!( - wallet.refresh_transfer(sent.id.clone()).await, + wallet.check_recent_execution(sent.id.clone()).await, Err(UsdtError::InvalidResponse) )); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); chain.state.lock().unwrap().receipt_logs = None; let result = wallet - .refresh_transfer(sent.id.clone()) + .check_recent_execution(sent.id.clone()) .await .unwrap() .unwrap(); @@ -2981,7 +3237,7 @@ async fn execution_check_preserves_unmined_payments_and_throttling() { state.timestamp += alloy_primitives::U256::from(1000); } let result = wallet - .refresh_transfer(sent.id.clone()) + .check_recent_execution(sent.id.clone()) .await .unwrap() .unwrap(); @@ -2990,8 +3246,237 @@ async fn execution_check_preserves_unmined_payments_and_throttling() { assert_eq!(chain.state.lock().unwrap().operations.len(), 1); chain.state.lock().unwrap().log_error = Some((-32016, "rate limit".into())); assert!(matches!( - wallet.refresh_transfer(sent.id.clone()).await, + wallet.check_recent_execution(sent.id.clone()).await, Err(UsdtError::RateLimited) )); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); } + +#[tokio::test] +async fn interrupted_nonce_recovery_restarts_on_a_changed_block() { + use alloy_primitives::B256; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.nonce = 1; + state.tip += 3; + state.hide_logs = true; + state.block_transactions = Some(vec![B256::repeat_byte(6), B256::repeat_byte(7)]); + state.receipt_failure = Some(B256::repeat_byte(7)); + } + assert!(wallet.refresh_transfers().await.is_err()); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + drop(wallet); + { + let mut state = chain.state.lock().unwrap(); + state.block_hashes.insert(20000, B256::repeat_byte(0xaa)); + state.block_transactions = Some(vec![B256::repeat_byte(7), B256::repeat_byte(6)]); + state.receipt_failure = None; + state.mined = true; + } + let restored = chain.wallet(&dir); + let history = restored.refresh_transfers().await.unwrap(); + assert_eq!(history[0].id, sent.id); + assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); + assert_eq!(history[0].fee, Some(123)); + assert!(restored.store.pending_plan(&sent.id).unwrap().is_none()); +} + +#[tokio::test] +async fn history_reuses_receipts_only_while_their_block_remains_canonical() { + use alloy_primitives::{B256, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + } + sync_history_to_tip(&wallet).await; + let reads = chain.state.lock().unwrap().receipt_reads; + drop(wallet); + let restored = chain.wallet(&dir); + sync_history_to_tip(&restored).await; + assert_eq!(chain.state.lock().unwrap().receipt_reads, reads); + { + let mut state = chain.state.lock().unwrap(); + let mut logs = state.event_logs(); + let mut event = transaction::Paymaster::UserOperationSponsored::decode_log_data( + &transaction::event_data(&logs[1]).unwrap(), + ) + .unwrap(); + event.tokenAmountPaid = U256::from(456); + logs[1]["data"] = json!(event.encode_log_data().data); + state.receipt_logs = Some(logs); + let mut stale = state.event_logs()[2].clone(); + stale["blockHash"] = json!(state.block_hash(20000)); + state.log_response = Some(vec![stale]); + state.block_hashes.insert(20000, B256::repeat_byte(0xaa)); + } + assert!(matches!( + restored.sync_history().await, + Err(UsdtError::NetworkUnavailable) + )); + assert_eq!(restored.history().unwrap()[0].fee, Some(123)); + chain.state.lock().unwrap().log_response = None; + sync_history_to_tip(&restored).await; + let history = restored.history().unwrap(); + assert_eq!(history[0].id, sent.id); + assert_eq!(history[0].fee, Some(456)); + assert_eq!(chain.state.lock().unwrap().receipt_reads, reads + 1); +} + +#[tokio::test] +async fn incoming_log_progress_does_not_hide_later_receipt_evidence() { + use alloy_primitives::{B256, U256}; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.incoming = true; + state.tip += 3; + let mut incoming = state.event_logs().last().unwrap().clone(); + incoming["transactionHash"] = json!(B256::repeat_byte(7)); + incoming["blockNumber"] = json!(U256::from(20000)); + state.log_response = Some(vec![incoming]); + } + let restore_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restore_dir); + sync_history_to_tip(&restored).await; + assert_eq!(restored.history().unwrap().len(), 1); + assert!(restored.history().unwrap()[0].is_incoming); + assert_eq!(chain.state.lock().unwrap().receipt_reads, 0); + chain.state.lock().unwrap().log_response = None; + sync_history_to_tip(&restored).await; + let history = restored.history().unwrap(); + assert_eq!(history.len(), 2); + let payment = history + .iter() + .find(|transfer| !transfer.is_incoming) + .unwrap(); + assert_eq!(payment.amount, 1_000_000); + assert_eq!(payment.fee, Some(123)); + assert_eq!(chain.state.lock().unwrap().receipt_reads, 1); +} + +#[tokio::test] +async fn bridge_history_retries_incomplete_receipt_enrichment() { + use alloy_primitives::{B256, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + + for (dense, missing) in [ + (false, types::OFT), + (true, types::OFT), + (false, paymaster::PAYMASTER), + ] { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let complete = { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + if dense { + state.oversized_block = Some(20000); + } + let mut logs = state.event_logs(); + let helper = transaction::BridgeHelper::LogSend { + sender: wallet.address, + oft: types::OFT, + amountLD: U256::from(1_000_000), + totalAmount: U256::from(1_000_500), + feeInToken: U256::from(500), + nativeFee: U256::from(1), + } + .encode_log_data(); + let oft = transaction::Oft::OFTSent { + guid: B256::repeat_byte(0x42), + dstEid: UsdtDestination::Polygon.endpoint().unwrap(), + fromAddress: types::BRIDGE_HELPER, + amountSentLD: U256::from(1_000_000), + amountReceivedLD: U256::from(1_000_000), + } + .encode_log_data(); + for (address, data) in [(types::BRIDGE_HELPER, helper), (types::OFT, oft)] { + logs.insert( + 0, + json!({"address":address,"topics":data.topics(),"data":data.data, + "transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20"}), + ); + } + for (index, log) in logs.iter_mut().enumerate() { + log["logIndex"] = json!(format!("0x{index:x}")); + } + state.receipt_logs = Some( + logs.iter() + .filter(|log| { + serde_json::from_value::(log["address"].clone()) + .unwrap() + != missing + }) + .cloned() + .collect(), + ); + logs + }; + sync_history_to_tip(&wallet).await; + let partial = wallet.history().unwrap().remove(0); + assert_eq!(partial.id, sent.id); + assert!(partial.bridge_guid.is_none() || partial.fee.is_none()); + assert_ne!(partial.status, UsdtTransferStatus::Pending); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); + drop(wallet); + chain.state.lock().unwrap().receipt_logs = Some(complete); + let wallet = chain.wallet(&dir); + sync_history_to_tip(&wallet).await; + let enriched = wallet.history().unwrap().remove(0); + assert_eq!(enriched.id, sent.id); + assert_eq!( + enriched.bridge_guid, + Some(format!("{:#x}", B256::repeat_byte(0x42))) + ); + assert_eq!(enriched.fee, Some(623)); + assert_eq!(enriched.status, UsdtTransferStatus::Bridging); + } +} diff --git a/src/modules/usdt/transaction.rs b/src/modules/usdt/transaction.rs index 4d7c8d6..5fe4d58 100644 --- a/src/modules/usdt/transaction.rs +++ b/src/modules/usdt/transaction.rs @@ -73,21 +73,33 @@ sol! { } } +pub(super) fn entry_point_event( + log: &serde_json::Value, +) -> Result, UsdtError> { + use alloy_sol_types::SolEvent; + let address: alloy_primitives::Address = serde_json::from_value(log["address"].clone())?; + if address != super::account::ENTRY_POINT { + return Ok(None); + } + let data = event_data(log)?; + if data.topics().first() != Some(&EntryPoint::UserOperationEvent::SIGNATURE_HASH) { + return Ok(None); + } + EntryPoint::UserOperationEvent::decode_log_data(&data) + .map(Some) + .map_err(|_| UsdtError::InvalidResponse) +} + pub(super) fn operation_logs( receipt: &serde_json::Value, hash: B256, ) -> Result<&[serde_json::Value], UsdtError> { - use alloy_sol_types::SolEvent; let logs = receipt["logs"] .as_array() .ok_or(UsdtError::InvalidResponse)?; let mut start = 0; for (index, log) in logs.iter().enumerate() { - let address: alloy_primitives::Address = serde_json::from_value(log["address"].clone())?; - if address != super::account::ENTRY_POINT { - continue; - } - if let Ok(event) = EntryPoint::UserOperationEvent::decode_log_data(&event_data(log)?) { + if let Some(event) = entry_point_event(log)? { if event.userOpHash == hash { return Ok(&logs[start..=index]); } diff --git a/src/modules/usdt/types.rs b/src/modules/usdt/types.rs index 4903348..ccdafb0 100644 --- a/src/modules/usdt/types.rs +++ b/src/modules/usdt/types.rs @@ -5,7 +5,6 @@ pub(super) const CHAIN_ID: u64 = 42161; pub(super) const TOKEN: Address = address!("Fd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"); pub(super) const OFT: Address = address!("14E4A1B13bf7F943c8ff7C51fb60FA964A298D92"); pub(super) const BRIDGE_HELPER: Address = address!("a90f03c856D01F698E7071B393387cd75a8a319A"); -pub(super) const EXPLORER: &str = "https://arbiscan.io"; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] pub enum UsdtDestination { @@ -65,18 +64,27 @@ pub struct UsdtQuote { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] pub enum UsdtTransferStatus { + /// Signed payment awaiting a conclusive source-chain outcome. Pending, + /// Payment received on its destination chain. Confirmed, + /// Source payment failed or was proven not to have executed. Failed, + /// Source payment executed; destination delivery is pending. Bridging, + /// Delivery is blocked or its message could not be recovered; it may still complete. BridgeNeedsAttention, + /// Delivery was permanently stopped. This does not imply a refund of source funds or fees. + BridgeFailed, + /// Another operation consumed the payment nonce. Replaced, } #[derive(Clone, Debug, Serialize, Deserialize, uniffi::Record)] pub struct UsdtTransfer { pub id: String, - pub tx_hash: String, + /// Source transaction hash, absent until execution is observed. + pub tx_hash: Option, pub user_operation_hash: Option, pub bridge_guid: Option, pub recipient: String, @@ -87,5 +95,12 @@ pub struct UsdtTransfer { pub is_incoming: bool, pub status: UsdtTransferStatus, pub timestamp: u64, - pub explorer_url: String, +} + +impl UsdtTransfer { + pub(super) fn mark_unexecuted(&mut self, status: UsdtTransferStatus) { + self.status = status; + self.received_amount = 0; + self.fee = Some(0); + } } diff --git a/src/modules/usdt/wallet.rs b/src/modules/usdt/wallet.rs index 3d205a9..a9dd7d3 100644 --- a/src/modules/usdt/wallet.rs +++ b/src/modules/usdt/wallet.rs @@ -1,23 +1,34 @@ use super::{ account::{validate_delegation, ENTRY_POINT}, - amount::token_amount, - keys::{derive_key, parse_address}, + amount::{token_amount, with_margin}, + keys::{derive_owner_key, parse_address}, paymaster::{Pimlico, PAYMASTER}, rpc::Rpc, store::{QuoteData, Store}, - transaction::{event_data, BridgeHelper, EntryPoint, Erc20, Oft, Paymaster, Plan, SendParam}, - types::{BRIDGE_HELPER, CHAIN_ID, EXPLORER, OFT, TOKEN}, + transaction::{ + entry_point_event, event_data, BridgeHelper, EntryPoint, Erc20, Oft, Paymaster, Plan, + SendParam, + }, + types::{BRIDGE_HELPER, CHAIN_ID, OFT, TOKEN}, user_operation::Authorization, UsdtDestination, UsdtError, UsdtQuote, UsdtTransfer, UsdtTransferStatus, }; use alloy_primitives::{Address, Bytes, B256, U256}; use alloy_sol_types::{SolCall, SolEvent}; use serde_json::{json, Value}; +use std::collections::HashMap; use std::sync::{ atomic::{AtomicU64, AtomicUsize, Ordering}, Arc, }; -use tokio::sync::Mutex; +use tokio::{sync::Mutex, time::Instant}; + +const RECENT_EXECUTION_BLOCKS: u64 = 64; +const RECENT_EXECUTION_BUDGET: std::time::Duration = std::time::Duration::from_secs(5); +const NONCE_RECOVERY_BUDGET: std::time::Duration = std::time::Duration::from_secs(20); +const EXPIRY_SEARCH_BLOCKS: u64 = 4096; +const QUOTE_LIFETIME_SECONDS: u64 = 120; +const BRIDGE_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(60); #[derive(uniffi::Object)] pub struct UsdtWallet { @@ -27,11 +38,13 @@ pub struct UsdtWallet { pub(super) store: Store, operation: Mutex<()>, bridge_poll_offset: AtomicUsize, + bridge_retry_after: Mutex>, pub(super) history_range_limit: AtomicU64, } #[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] pub fn new( address: String, @@ -55,6 +68,7 @@ impl UsdtWallet { store, operation: Mutex::new(()), bridge_poll_offset: AtomicUsize::new(0), + bridge_retry_after: Mutex::new(HashMap::new()), history_range_limit: AtomicU64::new(super::history::MAX_LOG_RANGE), })) } @@ -88,16 +102,9 @@ impl UsdtWallet { let recipient = parse_address(recipient.trim())?; if recipient == self.address || recipient == destination.token() - || (destination == UsdtDestination::Ethereum && recipient == ENTRY_POINT) + || [ENTRY_POINT, PAYMASTER, super::account::DELEGATE].contains(&recipient) || (destination == UsdtDestination::Arbitrum - && [ - ENTRY_POINT, - PAYMASTER, - super::account::DELEGATE, - OFT, - BRIDGE_HELPER, - ] - .contains(&recipient)) + && [OFT, BRIDGE_HELPER].contains(&recipient)) { return Err(UsdtError::InvalidAddress); } @@ -114,8 +121,11 @@ impl UsdtWallet { .paymaster .prepare(self.address, nonce, authorization, &calls, timestamp) .await?; - let expires_at = - now().saturating_add(operation_expires_at.saturating_sub(timestamp).min(120)); + 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)?; @@ -140,6 +150,8 @@ impl UsdtWallet { Ok(quote) } + /// Repeating a quote ID returns its stored outcome, which may already be failed or replaced. + /// A pending outcome is durable and retryable; it does not imply bundler acceptance. pub async fn send( &self, quote_id: String, @@ -150,10 +162,7 @@ impl UsdtWallet { let passphrase = passphrase.map(zeroize::Zeroizing::new); let _guard = self.operation.lock().await; if let Some(existing) = self.store.transfer("e_id)? { - let key = derive_key(mnemonic, passphrase)?; - if super::keys::key_address(&key) != self.address { - return Err(UsdtError::InvalidCredentials); - } + derive_owner_key(mnemonic, passphrase, self.address)?; return Ok(existing); } self.store.require_no_pending()?; @@ -181,10 +190,7 @@ impl UsdtWallet { { return Err(UsdtError::QuoteExpired); } - let key = derive_key(mnemonic, passphrase)?; - if super::keys::key_address(&key) != self.address { - return Err(UsdtError::InvalidCredentials); - } + let key = derive_owner_key(mnemonic, passphrase, self.address)?; if data.quote.expires_at <= now() + 5 { return Err(UsdtError::QuoteExpired); } @@ -192,7 +198,7 @@ impl UsdtWallet { drop(key); let mut transfer = UsdtTransfer { id: quote_id, - tx_hash: String::new(), + tx_hash: None, user_operation_hash: Some(format!("{hash:#x}")), bridge_guid: None, recipient: data.quote.recipient, @@ -203,7 +209,6 @@ impl UsdtWallet { is_incoming: false, status: UsdtTransferStatus::Pending, timestamp: now(), - explorer_url: String::new(), }; self.store.record_signed(&transfer, &raw)?; // After persistence a lost response is indeterminate. Retry only the identical signed operation. @@ -213,9 +218,7 @@ impl UsdtWallet { error, UsdtError::QuoteExpired | UsdtError::UnsupportedDelegation ) { - transfer.status = UsdtTransferStatus::Failed; - transfer.received_amount = 0; - transfer.fee = Some(0); + transfer.mark_unexecuted(UsdtTransferStatus::Failed); self.store.update_transfer(&transfer)?; return Err(error); } @@ -223,9 +226,13 @@ impl UsdtWallet { Ok(transfer) } - /// Checks recent direct-payment execution at the current tip without scanning history or retrying submission. - /// Missing evidence leaves the signed payment pending; confirmation is L2 execution, not parent-chain finality. - pub async fn refresh_transfer(&self, id: String) -> Result, UsdtError> { + /// Checks recent direct-payment execution with a bounded request budget. + /// Requires the expected operation and transfer in a canonical receipt; current-tip execution is provisional. + /// Does not rebroadcast, expire payments or reconcile nonces. Missing evidence leaves the payment pending. + pub async fn check_recent_execution( + &self, + id: String, + ) -> Result, UsdtError> { let check = async { let _guard = self.operation.lock().await; let Some(mut transfer) = self.store.transfer(&id)? else { @@ -240,23 +247,20 @@ impl UsdtWallet { self.rpc.verify_chain().await?; let hash = plan.operation.hash(CHAIN_ID)?; let tip = self.block_number().await?; - let start = plan.created_block.max(tip.saturating_sub(63)); + let start = plan + .created_block + .max(tip.saturating_sub(RECENT_EXECUTION_BLOCKS - 1)); if start <= tip { - let logs: Vec = self.rpc.call("eth_getLogs", json!([{ - "address": ENTRY_POINT, "fromBlock": U256::from(start), "toBlock": U256::from(tip), - "topics": [EntryPoint::UserOperationEvent::SIGNATURE_HASH, hash, self.address.into_word()] - }])).await?; + let logs: Vec = self.operation_logs_in(start, tip, Some(hash)).await?; if let Some(log) = logs .iter() .find(|log| log["removed"].as_bool() != Some(true)) { - let event = EntryPoint::UserOperationEvent::decode_log_data(&event_data(log)?) - .map_err(|_| UsdtError::InvalidResponse)?; + let event = entry_point_event(log)?.ok_or(UsdtError::InvalidResponse)?; let number = u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) .map_err(|_| UsdtError::InvalidResponse)?; - if serde_json::from_value::
(log["address"].clone())? != ENTRY_POINT - || event.userOpHash != hash + if event.userOpHash != hash || event.nonce != plan.operation.nonce || number < start || number > tip @@ -268,12 +272,14 @@ impl UsdtWallet { } Ok(Some(transfer)) }; - match tokio::time::timeout(std::time::Duration::from_secs(5), check).await { + match tokio::time::timeout(RECENT_EXECUTION_BUDGET, check).await { Ok(result) => result, Err(_) => Err(UsdtError::NetworkUnavailable), } } + /// Saves resumable history progress; returns true when caught up and false when more work remains. + /// Call between send flows. The soft budget permits an in-flight receipt to finish before yielding. pub async fn sync_history(&self) -> Result { let _guard = self.operation.lock().await; self.rpc.verify_chain().await?; @@ -284,9 +290,11 @@ impl UsdtWallet { self.store.transfers() } + /// Reconciles pending execution using chain proofs and may rebroadcast the identical signed operation. pub async fn refresh_transfers(&self) -> Result, UsdtError> { let pending = self.refresh_pending_transfers().await; - self.refresh_bridges(&self.store.unsettled()?).await?; + self.refresh_bridges(&self.store.awaiting_delivery()?) + .await?; pending?; self.history() } @@ -295,117 +303,93 @@ impl UsdtWallet { impl UsdtWallet { async fn refresh_pending_transfers(&self) -> Result<(), UsdtError> { let _guard = self.operation.lock().await; - let transfers = self.store.unsettled()?; - if transfers.is_empty() { + if let Some((mut transfer, plan)) = self.store.pending_operation()? { + self.recover_pending(&mut transfer, &plan).await?; + } + Ok(()) + } + + async fn recover_pending( + &self, + transfer: &mut UsdtTransfer, + plan: &Plan, + ) -> Result<(), UsdtError> { + self.rpc.verify_chain().await?; + let hash = plan.operation.hash(CHAIN_ID)?; + let confirmed_tip = self.block_number().await?.saturating_sub(2); + if confirmed_tip < plan.created_block { return Ok(()); } - if transfers + let end = self.pending_search_end(plan, confirmed_tip).await?; + let logs = match self + .operation_logs_in(plan.created_block, end, Some(hash)) + .await + { + Ok(logs) => logs, + // Discovery can be unavailable while independent nonce/receipt proofs still work. + Err(UsdtError::LogRangeTooLarge | UsdtError::NetworkUnavailable) => Vec::new(), + Err(error) => return Err(error), + }; + if let Some(log) = logs .iter() - .any(|transfer| transfer.status == UsdtTransferStatus::Pending) + .find(|log| log["removed"].as_bool() != Some(true)) { - self.rpc.verify_chain().await?; + let event = entry_point_event(log)?.ok_or(UsdtError::InvalidResponse)?; + if event.userOpHash != hash || event.nonce != plan.operation.nonce { + return Err(UsdtError::InvalidResponse); + } + return self.settle_from_log(transfer, log, event).await; + } + let nonce = self.nonce(&format!("0x{confirmed_tip:x}")).await?; + if nonce <= plan.operation.nonce { + if self.block_timestamp(confirmed_tip).await? > plan.expires_at { + transfer.mark_unexecuted(UsdtTransferStatus::Failed); + self.store.update_transfer(transfer)?; + } else if self.validate_bridge(plan).await.is_ok() { + let _ = self.broadcast(plan, hash).await; + } + return Ok(()); } - for mut transfer in transfers { - if matches!( - transfer.status, - UsdtTransferStatus::Bridging | UsdtTransferStatus::BridgeNeedsAttention - ) { - continue; + // A nonce advance alone cannot distinguish this payment from a replacement. + let block = self.nonce_consumed_block(plan, confirmed_tip).await?; + let candidates = match self.operation_logs_in(block, block, None).await { + Ok(logs) => logs, + Err(UsdtError::LogRangeTooLarge | UsdtError::NetworkUnavailable) => Vec::new(), + Err(error) => return Err(error), + }; + for log in candidates + .iter() + .filter(|log| log["removed"].as_bool() != Some(true)) + { + let event = entry_point_event(log)?.ok_or(UsdtError::InvalidResponse)?; + if u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) + .map_err(|_| UsdtError::InvalidResponse)? + != block + || event.sender != self.address + { + return Err(UsdtError::InvalidResponse); } - let Some(plan) = self.store.pending_plan(&transfer.id)? else { - continue; - }; - let hash = plan.operation.hash(CHAIN_ID)?; - let confirmed_tip = self.block_number().await?.saturating_sub(2); - if confirmed_tip < plan.created_block { + if event.nonce != plan.operation.nonce { continue; } - let end = self.pending_search_end(&plan, confirmed_tip).await?; - let logs: Vec = match self.rpc.call("eth_getLogs", json!([{ - "address": ENTRY_POINT, "fromBlock": U256::from(plan.created_block), "toBlock": U256::from(end), - "topics": [EntryPoint::UserOperationEvent::SIGNATURE_HASH, hash, self.address.into_word()] - }])).await { - Ok(logs) => logs, - // The nonce-based lookup below verifies the consuming event within a single block. - Err(UsdtError::LogRangeTooLarge) => Vec::new(), - Err(error) => return Err(error), - }; - if let Some(log) = logs - .iter() - .find(|log| log["removed"].as_bool() != Some(true)) - { - let event = EntryPoint::UserOperationEvent::decode_log_data(&event_data(log)?) - .map_err(|_| UsdtError::InvalidResponse)?; - if serde_json::from_value::
(log["address"].clone())? != ENTRY_POINT - || event.userOpHash != hash - || event.nonce != plan.operation.nonce - { - return Err(UsdtError::InvalidResponse); - } - self.settle_from_log(&mut transfer, log, event).await?; - } else { - let nonce = self.nonce(&format!("0x{confirmed_tip:x}")).await?; - if nonce > plan.operation.nonce { - // A nonce advance alone cannot distinguish this payment from a replacement. - let block = self.nonce_consumed_block(&plan, confirmed_tip).await?; - let candidates: Vec = match self.rpc.call("eth_getLogs", json!([{ - "address": ENTRY_POINT, "fromBlock": U256::from(block), "toBlock": U256::from(block), - "topics": [EntryPoint::UserOperationEvent::SIGNATURE_HASH, null, self.address.into_word()] - }])).await { - Ok(logs) => logs, - Err(UsdtError::LogRangeTooLarge) => Vec::new(), - Err(error) => return Err(error), - }; - let mut matched = false; - for log in candidates - .iter() - .filter(|log| log["removed"].as_bool() != Some(true)) - { - let event = - EntryPoint::UserOperationEvent::decode_log_data(&event_data(log)?) - .map_err(|_| UsdtError::InvalidResponse)?; - if serde_json::from_value::
(log["address"].clone())? != ENTRY_POINT - || u64::try_from(serde_json::from_value::( - log["blockNumber"].clone(), - )?) - .map_err(|_| UsdtError::InvalidResponse)? - != block - || event.sender != self.address - { - return Err(UsdtError::InvalidResponse); - } - if event.nonce != plan.operation.nonce { - continue; - } - if event.userOpHash == hash { - self.settle_from_log(&mut transfer, log, event).await?; - } else { - self.reconcile_consumed_nonce(&mut transfer, &plan, block) - .await?; - } - matched = true; - break; - } - if !matched { - self.reconcile_consumed_nonce(&mut transfer, &plan, block) - .await?; - } - } else { - let expired = self.block_timestamp(confirmed_tip).await? > plan.expires_at; - if expired { - transfer.status = UsdtTransferStatus::Failed; - transfer.received_amount = 0; - transfer.fee = Some(0); - self.store.update_transfer(&transfer)?; - } else { - if self.validate_bridge(&plan).await.is_ok() { - let _ = self.broadcast(&plan, hash).await; - } - } - } + if event.userOpHash == hash { + return self.settle_from_log(transfer, log, event).await; } + break; } - Ok(()) + self.reconcile_consumed_nonce(transfer, plan, block).await + } + + async fn operation_logs_in( + &self, + start: u64, + end: u64, + hash: Option, + ) -> Result, UsdtError> { + self.rpc.call("eth_getLogs", json!([{ + "address": ENTRY_POINT, "fromBlock": U256::from(start), "toBlock": U256::from(end), + "topics": [EntryPoint::UserOperationEvent::SIGNATURE_HASH, hash, self.address.into_word()] + }])).await } async fn reconcile_consumed_nonce( @@ -414,7 +398,7 @@ impl UsdtWallet { plan: &Plan, number: u64, ) -> Result<(), UsdtError> { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20); + let deadline = tokio::time::Instant::now() + NONCE_RECOVERY_BUDGET; let block = self.rpc.block(number).await?; let block_hash = format!("{:#x}", block.hash); let start = self.store.nonce_recovery(&transfer.id, &block_hash)?; @@ -430,11 +414,7 @@ impl UsdtWallet { .as_array() .ok_or(UsdtError::InvalidResponse)? { - if serde_json::from_value::
(log["address"].clone())? != ENTRY_POINT { - continue; - } - let Ok(event) = EntryPoint::UserOperationEvent::decode_log_data(&event_data(log)?) - else { + let Some(event) = entry_point_event(log)? else { continue; }; if event.sender != self.address || event.nonce != plan.operation.nonce { @@ -444,16 +424,12 @@ impl UsdtWallet { if event.paymaster != PAYMASTER { return Err(UsdtError::InvalidResponse); } - transfer.tx_hash = format!("{hash:#x}"); - transfer.explorer_url = format!("{EXPLORER}/tx/{hash:#x}"); + transfer.tx_hash = Some(format!("{hash:#x}")); self.settle(transfer, &receipt)?; } else { - transfer.status = UsdtTransferStatus::Replaced; - transfer.received_amount = 0; - transfer.fee = Some(0); + transfer.mark_unexecuted(UsdtTransferStatus::Replaced); } - transfer.timestamp = - u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; + transfer.timestamp = block.timestamp()?; return self.store.update_transfer(transfer); } self.store @@ -463,25 +439,19 @@ impl UsdtWallet { if self.rpc.block(number).await?.hash != block.hash { return Err(UsdtError::NetworkUnavailable); } - transfer.status = UsdtTransferStatus::Replaced; - transfer.received_amount = 0; - transfer.fee = Some(0); - transfer.timestamp = - u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; + transfer.mark_unexecuted(UsdtTransferStatus::Replaced); + transfer.timestamp = block.timestamp()?; self.store.update_transfer(transfer) } async fn refresh_bridges(&self, transfers: &[UsdtTransfer]) -> Result<(), UsdtError> { + let mut retry_after = self.bridge_retry_after.lock().await; + retry_after.retain(|_, deadline| *deadline > Instant::now()); let mut bridges: Vec<_> = transfers .iter() - .filter(|transfer| { - transfer.bridge_guid.is_some() - && matches!( - transfer.status, - UsdtTransferStatus::Bridging | UsdtTransferStatus::BridgeNeedsAttention - ) - }) + .filter(|transfer| !retry_after.contains_key(&transfer.id)) .collect(); + drop(retry_after); if bridges.is_empty() { return Ok(()); } @@ -506,13 +476,24 @@ 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() { + if !matches!(result, Ok(Ok(status)) if status != UsdtTransferStatus::BridgeNeedsAttention) + { + self.bridge_retry_after + .lock() + .await + .insert(previous.id.clone(), Instant::now() + BRIDGE_RETRY_DELAY); + } match result { Ok(Ok(status)) if status != previous.status => { let _guard = self.operation.lock().await; let Some(mut current) = self.store.transfer(&previous.id)? else { continue; }; - if current.tx_hash.eq_ignore_ascii_case(&previous.tx_hash) + if current + .tx_hash + .as_deref() + .zip(previous.tx_hash.as_deref()) + .is_some_and(|(a, b)| a.eq_ignore_ascii_case(b)) && current.bridge_guid == previous.bridge_guid && current.status == previous.status { @@ -534,8 +515,7 @@ impl UsdtWallet { .map_err(|_| UsdtError::InvalidResponse) } pub(super) async fn block_timestamp(&self, number: u64) -> Result { - u64::try_from(self.rpc.block(number).await?.timestamp) - .map_err(|_| UsdtError::InvalidResponse) + self.rpc.block(number).await?.timestamp() } async fn token_balance(&self) -> Result { self.rpc @@ -555,8 +535,16 @@ impl UsdtWallet { Ok(()) } async fn nonce(&self, block: &str) -> Result { - let bytes: Bytes = self.rpc.call("eth_call", json!([{"to":ENTRY_POINT,"data":Bytes::from(EntryPoint::getNonceCall { sender:self.address, key:Default::default() }.abi_encode())}, block])).await?; - EntryPoint::getNonceCall::abi_decode_returns(&bytes).map_err(|_| UsdtError::InvalidResponse) + self.rpc + .contract_at( + ENTRY_POINT, + EntryPoint::getNonceCall { + sender: self.address, + key: Default::default(), + }, + block, + ) + .await } async fn authorization(&self) -> Result { let code: Bytes = self @@ -607,8 +595,7 @@ impl UsdtWallet { return Err(UsdtError::InvalidResponse); } let hash: B256 = serde_json::from_value(log["transactionHash"].clone())?; - transfer.tx_hash = format!("{hash:#x}"); - transfer.explorer_url = format!("{EXPLORER}/tx/{}", transfer.tx_hash); + transfer.tx_hash = Some(format!("{hash:#x}")); let number = u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) .map_err(|_| UsdtError::InvalidResponse)?; let block = self.rpc.block(number).await?; @@ -617,8 +604,7 @@ impl UsdtWallet { } let receipt = self.rpc.block_receipt(hash, block.hash, number).await?; self.settle(transfer, &receipt)?; - transfer.timestamp = - u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; + transfer.timestamp = block.timestamp()?; self.store.update_transfer(transfer) } @@ -639,7 +625,7 @@ impl UsdtWallet { async fn pending_search_end(&self, plan: &Plan, tip: u64) -> Result { // The paymaster validity window bounds recovery even after a long absence. - if tip <= plan.created_block + 4096 { + if tip <= plan.created_block + EXPIRY_SEARCH_BLOCKS { return Ok(tip); } let mut low = plan.created_block; @@ -666,14 +652,9 @@ impl UsdtWallet { .parse() .map_err(|_| UsdtError::InvalidResponse)?; let logs = super::transaction::operation_logs(receipt, operation_hash)?; - let event = EntryPoint::UserOperationEvent::decode_log_data(&event_data( - logs.last().ok_or(UsdtError::InvalidResponse)?, - )?) - .map_err(|_| UsdtError::InvalidResponse)?; - if event.userOpHash != operation_hash - || event.sender != self.address - || event.paymaster != PAYMASTER - { + let event = entry_point_event(logs.last().ok_or(UsdtError::InvalidResponse)?)? + .ok_or(UsdtError::InvalidResponse)?; + if event.sender != self.address || event.paymaster != PAYMASTER { return Err(UsdtError::InvalidResponse); } let mut transfer_proven = false; @@ -751,6 +732,10 @@ impl UsdtWallet { }; let send = BridgeHelper::sendCall::abi_decode(data).map_err(|_| UsdtError::InvalidResponse)?; + let requote = |error| match error { + UsdtError::UnsupportedRoute => UsdtError::QuoteExpired, + error => error, + }; let required = self .rpc .contract( @@ -760,7 +745,8 @@ impl UsdtWallet { payInLzToken: false, }, ) - .await?; + .await + .map_err(requote)?; if !required.lzTokenFee.is_zero() || required.nativeFee > send.fee.nativeFee { return Err(UsdtError::QuoteExpired); } @@ -776,7 +762,8 @@ impl UsdtWallet { fee: send.fee, }, ) - .await?; + .await + .map_err(requote)?; let allowance = calls .iter() .filter(|(target, _)| *target == TOKEN) @@ -928,15 +915,3 @@ impl UsdtWallet { pub(super) fn now() -> u64 { chrono::Utc::now().timestamp().max(0) as u64 } - -fn with_margin(value: U256, percent: u8) -> Result { - value - .checked_add( - value - .checked_mul(U256::from(percent)) - .ok_or(UsdtError::InvalidResponse)? - / U256::from(100), - ) - .and_then(|value| value.checked_add(U256::from(1))) - .ok_or(UsdtError::InvalidResponse) -} diff --git a/tests/usdt-fork/provider.mjs b/tests/usdt-fork/provider.mjs index c9bad84..689fbd8 100644 --- a/tests/usdt-fork/provider.mjs +++ b/tests/usdt-fork/provider.mjs @@ -201,14 +201,10 @@ async function dispatch(method, params) { ); return hash; } + if (method === 'bitkit_getBridgeMessages') return { data: [] }; return rpc.send(method, params); } const server = createServer(async (request, response) => { - if (request.method === 'GET' && request.url.startsWith('/v1/messages/tx/')) { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ data: [] })); - return; - } let body = ''; for await (const chunk of request) body += chunk; const call = JSON.parse(body); From d237a7b748b1ba9994ecc0e5c818903f76d58467 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 25 Sep 2026 16:35:02 +0300 Subject: [PATCH 6/6] fix: exclude zero and self transfers from restored payments --- Package.swift | 2 +- src/modules/usdt/history.rs | 9 +++++++-- src/modules/usdt/tests.rs | 34 ++++++++++++++++++++-------------- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/Package.swift b/Package.swift index 8bfbb08..66a316b 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription import Foundation let tag = "v0.6.0" -let checksum = "69e5c31277c413605fe495fa2bb2b8acd30d793a733cebcc8f9c25200b48357b" +let checksum = "29e0b2bae05b5b4f8538cddaa21adac44e2aaf81822939b87e43be224e71bc75" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/src/modules/usdt/history.rs b/src/modules/usdt/history.rs index 37b5422..199d80a 100644 --- a/src/modules/usdt/history.rs +++ b/src/modules/usdt/history.rs @@ -350,7 +350,9 @@ impl UsdtWallet { if !super::paymaster::supported_payment(&op.paymasterAndData) { continue; } - let Some((recipient, amount, destination)) = decode_payment(&op.callData) else { + let Some((recipient, amount, destination)) = + decode_payment(&op.callData, self.address) + else { continue; }; (recipient.to_checksum(None), amount, destination) @@ -385,11 +387,14 @@ impl UsdtWallet { } } -fn decode_payment(data: &[u8]) -> Option<(Address, u64, UsdtDestination)> { +fn decode_payment(data: &[u8], sender: Address) -> Option<(Address, u64, UsdtDestination)> { let mut payment = None; for (target, data) in decode_calls(data).ok()? { let next = if target == TOKEN { if let Ok(call) = Erc20::transferCall::abi_decode(&data) { + if call.amount.is_zero() || call.recipient == sender { + return None; + } ( call.recipient, token_amount(call.amount).ok()?, diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index 594fd53..cdddcc8 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -3079,9 +3079,9 @@ async fn settlement_uses_the_canonical_operation_outcome() { } #[tokio::test] -async fn external_paymaster_modes_preserve_raw_debits_and_refunds() { +async fn unrecognized_operations_preserve_raw_debits_and_refunds() { use alloy_primitives::{Address, U256}; - use alloy_sol_types::SolEvent; + use alloy_sol_types::{SolCall, SolEvent}; use serde_json::json; let chain = MockChain::start().await; let dir = tempfile::tempdir().unwrap(); @@ -3103,7 +3103,13 @@ async fn external_paymaster_modes_preserve_raw_debits_and_refunds() { .encode_log_data(); json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"logIndex":U256::from(index)}) }; - for flags in [2u8, 4u8] { + let recipient = RECIPIENT.parse::
().unwrap(); + for (flags, recipient, amount, expected_outgoing) in [ + (2u8, recipient, 1_000_000u64, 1_000_200), + (4, recipient, 1_000_000, 1_000_200), + (0, recipient, 0, 200), + (0, wallet.address, 1_000_000, 200), + ] { { let mut state = chain.state.lock().unwrap(); state.mined = true; @@ -3111,21 +3117,22 @@ async fn external_paymaster_modes_preserve_raw_debits_and_refunds() { let mut data = state.operations[0].paymaster_data.to_vec(); data[1] = flags; state.operations[0].paymaster_data = data.into(); + state.operations[0].call_data = account::batch(&[( + types::TOKEN, + transaction::Erc20::transferCall { + recipient, + amount: U256::from(amount), + } + .abi_encode() + .into(), + )]); let mut logs = state.event_logs(); logs.retain(|log| log["address"] != json!(types::TOKEN)); logs.insert( 0, movement(wallet.address, paymaster::PAYMASTER, 200u64, 2u64), ); - logs.insert( - 1, - movement( - wallet.address, - RECIPIENT.parse::
().unwrap(), - 1_000_000u64, - 3u64, - ), - ); + logs.insert(1, movement(wallet.address, recipient, amount, 3u64)); logs.insert( 2, movement(paymaster::PAYMASTER, wallet.address, 50u64, 4u64), @@ -3136,7 +3143,6 @@ async fn external_paymaster_modes_preserve_raw_debits_and_refunds() { let restored = chain.wallet(&restored_dir); assert!(restored.sync_history().await.unwrap()); let history = restored.history().unwrap(); - assert_eq!(history.len(), 3); assert!(history.iter().all(|row| row.user_operation_hash.is_none())); assert_eq!( history @@ -3152,7 +3158,7 @@ async fn external_paymaster_modes_preserve_raw_debits_and_refunds() { .filter(|row| !row.is_incoming) .map(|row| row.amount) .sum::(), - 1_000_200 + expected_outgoing ); } }