From 82b79e76802f008c4fa55aea3ee4459e1cf57b18 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 17:12:04 -0500 Subject: [PATCH 1/2] feat: add Orchestra USDT deposit addresses and recovery --- bindings/ios/bitkitcore.swift | 1149 +++++++++++++++-- bindings/ios/bitkitcoreFFI.h | 76 ++ src/lib.rs | 7 +- src/modules/usdt/README.md | 16 + src/modules/usdt/deposits.rs | 834 ++++++++++++ src/modules/usdt/errors.rs | 13 + .../usdt/fixtures/deposit-signature.json | 4 + src/modules/usdt/mod.rs | 2 + 8 files changed, 2003 insertions(+), 98 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 09309b8..7cd3d2f 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 @@ -15908,21 +16125,564 @@ extension TxOutput: Codable {} 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) + 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 + /** + * Observed source amount in millionths of USDT; finer BSC dust is rounded down. + */ + 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, + /** + * Observed source amount in millionths of USDT; finer BSC dust is rounded down. + */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 + /** + * Expected input in millionths of USDT, including for 18-decimal BSC USDT. + */ + 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, + /** + * Expected input in millionths of USDT, including for 18-decimal BSC USDT. + */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 + /** + * Batch input in millionths of USDT, not necessarily this deposit alone. + */ + 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, + /** + * Batch input in millionths of USDT, not necessarily this deposit alone. + */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 UsdtDepositOrder: Equatable, Hashable { + public static func ==(lhs: UsdtDepositOrder, rhs: UsdtDepositOrder) -> Bool { + if lhs.status != rhs.status { + return false + } + if lhs.amountIn != rhs.amountIn { + return false + } + if lhs.amountOut != rhs.amountOut { + return false + } + if lhs.destinationTx != rhs.destinationTx { + return false + } + 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(status) + hasher.combine(amountIn) + hasher.combine(amountOut) + hasher.combine(destinationTx) + hasher.combine(refundTx) + hasher.combine(code) + } +} + +extension UsdtDepositOrder: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtDepositOrder: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtDepositOrder { + return + 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) } } @@ -15930,100 +16690,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 { - return false - } - if lhs.fragmentCount != rhs.fragmentCount { +extension UsdtDepositPage: Equatable, Hashable { + public static func ==(lhs: UsdtDepositPage, rhs: UsdtDepositPage) -> Bool { + if lhs.deposits != rhs.deposits { 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) } } @@ -16031,15 +16762,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) } @@ -23523,6 +24254,106 @@ 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 + case solana + case polygon + case bsc + case base +} + + +#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 + + case 3: return .solana + + case 4: return .polygon + + case 5: return .bsc + + case 6: return .base + + 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)) + + + case .solana: + writeInt(&buf, Int32(3)) + + + case .polygon: + writeInt(&buf, Int32(4)) + + + case .bsc: + writeInt(&buf, Int32(5)) + + + case .base: + writeInt(&buf, Int32(6)) + + } + } +} + + +#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. @@ -23625,11 +24456,17 @@ 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 DepositNotFound + case DepositAuthorizationRejected + case DepositAmountOutOfRange(minUsdCents: String?, maxUsdCents: String? + ) case NotConfigured case NetworkUnavailable case RateLimited @@ -23659,22 +24496,30 @@ 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 .DepositNotFound + case 13: return .DepositAuthorizationRejected + 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 + case 18: return .LogRangeTooLarge + case 19: return .TransactionRejected( reason: try FfiConverterString.read(from: &buf) ) - case 15: return .Storage( + case 20: return .Storage( reason: try FfiConverterString.read(from: &buf) ) - case 16: return .InvalidResponse + case 21: return .InvalidResponse default: throw UniffiInternalError.unexpectedEnumCase } @@ -23703,54 +24548,76 @@ 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 .DepositNotFound: writeInt(&buf, Int32(12)) - case .LogRangeTooLarge: + case .DepositAuthorizationRejected: writeInt(&buf, Int32(13)) - case let .TransactionRejected(reason): + 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)) + + + 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(15)) + writeInt(&buf, Int32(20)) FfiConverterString.write(reason, into: &buf) case .InvalidResponse: - writeInt(&buf, Int32(16)) + writeInt(&buf, Int32(21)) } } @@ -25040,6 +25907,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 @@ -26419,6 +27310,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 @@ -26569,6 +27485,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 @@ -30005,6 +30946,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 } @@ -30035,6 +30991,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() != 62148) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index 7ebda6a..9df5bb9 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 @@ -3466,6 +3506,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 @@ -3526,6 +3596,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 fd356d8..8564c82 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 c91e8b3..a842c8c 100644 --- a/src/modules/usdt/README.md +++ b/src/modules/usdt/README.md @@ -85,3 +85,19 @@ Build iOS and Android sequentially with the repository scripts; Android temporar - [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). + +## Incoming deposits + +`UsdtDepositClient` authenticates reusable deposit-address registration, indicative quotes, history, detail and refund requests with the wallet account. Its credential-free service URL is separate from the chain and bundler URLs. The companion service holds the Orchestra server key and fixes the destination to this wallet's Arbitrum One USDT0 account. + +Supported source networks are Ethereum, Tron, Solana, Polygon, Base and BNB Smart Chain (`bsc`). Arbitrum receives directly through the wallet. The service advertises only explicitly enabled, currently supported routes. The sender transfers USDT to the returned address and pays the source-network fee. Orchestra prices each deposit when processing it; receiving estimates are not guarantees or spendable balance. The wallet credits funds only through its Arbitrum history. + +Orchestra's general swap catalog is broader than its standing-address coverage. Optimism, TON, Plasma and HyperEVM are not offered because the approved account does not currently return standing addresses for them. Do not reuse an EVM deposit address on an unlisted network. Additional source networks require provider address support and funded acceptance. + +All client amounts use millionths of USDT. The service scales BSC USDT's 18-decimal source units when quoting and normalizes observed BSC deposit/batch amounts to six decimals, rounding only sub-millionth source dust down. Arbitrum output amounts are exact. EVM receive QR payloads pin the token contract and chain; Tron and Solana use the validated address. + +History returns at most 50 rows per page. Start with `offset = 0`, then use `next_offset` until it is absent. Retain each row's page offset for detail and refund requests; if new arrivals move it out of the inspected pages, refresh history. Deduplicate deposits by ID. Linked order amounts can include multiple deposits batched together and must be labelled as batch totals. + +Refunds require a user-approved address on the source network and provider eligibility; an acknowledgment means queued, not paid. Never infer a refund address from the original sender, which may be an exchange hot wallet. Unconverted Tron refunds require provider assistance. Missing optional metadata must not hide pending deposits. Address recovery depends on the same Orchestra partner account and immutable account-derived reference; provider availability and address control remain trust dependencies. + +Use a matching [bitkit-usdt-service](https://github.com/synonymdev/bitkit-usdt-service) deployment with `ORCHESTRA_API_KEY` and `ORCHESTRA_DEPOSIT_NETWORKS`. Enable each source only after funded delivery and provider recovery acceptance. See [Orchestra deposit addresses](https://docs.flashnet.xyz/orchestra/deposit-addresses). diff --git a/src/modules/usdt/deposits.rs b/src/modules/usdt/deposits.rs new file mode 100644 index 0000000..7a3fbc9 --- /dev/null +++ b/src/modules/usdt/deposits.rs @@ -0,0 +1,834 @@ +use super::{ + keys::{derive_owner_key, 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; + +const SOLANA_USDT: &str = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"; +const TRON_USDT: &str = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +#[serde(rename_all = "lowercase")] +pub enum UsdtDepositNetwork { + Ethereum, + Tron, + Solana, + Polygon, + Bsc, + Base, +} + +#[derive(Clone, Debug, Deserialize, uniffi::Record)] +pub struct UsdtDepositAddress { + pub network: UsdtDepositNetwork, + pub address: String, + pub recipient: String, + #[serde(deserialize_with = "number")] + /// Expected input in millionths of USDT, including for 18-decimal BSC USDT. + 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)] + pub uri: String, +} + +#[derive(Clone, Debug, Deserialize, uniffi::Record)] +pub struct UsdtDeposit { + pub id: String, + pub network: String, + pub asset: String, + #[serde(default, deserialize_with = "optional_number")] + /// Observed source amount in millionths of USDT; finer BSC dust is rounded down. + 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(default, deserialize_with = "optional_number")] + /// Batch input in millionths of USDT, not necessarily this deposit alone. + pub amount_in: Option, + #[serde(default, 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 + .into_iter() + .filter_map(|network| match network.as_str() { + "ethereum" => Some(UsdtDepositNetwork::Ethereum), + "tron" => Some(UsdtDepositNetwork::Tron), + "solana" => Some(UsdtDepositNetwork::Solana), + "polygon" => Some(UsdtDepositNetwork::Polygon), + "bsc" => Some(UsdtDepositNetwork::Bsc), + "base" => Some(UsdtDepositNetwork::Base), + _ => None, + }) + .collect()) + } + + 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).map_err(|_| UsdtError::InvalidResponse)? + != self.address + || result.amount != amount + || result.estimated_received == 0 + || result.estimated_received > amount + || result.slippage_bps != 50 + { + return Err(UsdtError::InvalidResponse); + } + validate_source_address(&result.address, network) + .map_err(|_| UsdtError::InvalidResponse)?; + if let Some((chain, token)) = network.evm_token() { + if parse_address(&result.address).map_err(|_| UsdtError::InvalidResponse)? + == self.address + { + return Err(UsdtError::InvalidResponse); + } + result.uri = format!( + "ethereum:{token}@{chain}/transfer?address={}", + result.address + ); + } else { + if network == UsdtDepositNetwork::Tron { + let decoded = bitcoin::base58::decode_check(&result.address) + .map_err(|_| UsdtError::InvalidResponse)?; + if Address::from_slice(&decoded[1..]) == self.address { + return Err(UsdtError::InvalidResponse); + } + } + result.uri = result.address.clone(); + } + Ok(result) + } + + pub async fn history( + &self, + offset: u32, + mnemonic: String, + passphrase: Option, + ) -> Result { + let page: UsdtDepositPage = self + .call( + json!({"action":"history","offset":offset}), + mnemonic.into(), + passphrase.map(Into::into), + ) + .await?; + if page.deposits.len() > 50 + || page + .next_offset + .is_some_and(|next| next <= offset || next > 100_000) + { + return Err(UsdtError::InvalidResponse); + } + Ok(page) + } + + 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); + 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}), + 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_owner_key(mnemonic, passphrase, self.address)?; + 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("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 { + 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, + Some( + "refund_not_available" + | "instruction_conflict" + | "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, + }); + } + serde_json::from_value(value?).map_err(Into::into) + } +} + +impl UsdtDepositNetwork { + fn evm_token(self) -> Option<(u64, &'static str)> { + Some(match self { + Self::Ethereum => (1, "0xdac17f958d2ee523a2206206994597c13d831ec7"), + Self::Polygon => (137, "0xc2132d05d31c914a87c6611c10748aeb04b58e8f"), + Self::Bsc => (56, "0x55d398326f99059ff775485246999027b3197955"), + Self::Base => (8453, "0xfde4c96c8593536e31f229ea8f37b2ada2699bb2"), + Self::Solana | Self::Tron => return None, + }) + } +} + +fn validate_source_address(value: &str, network: UsdtDepositNetwork) -> Result<(), UsdtError> { + if network == UsdtDepositNetwork::Tron { + if value.len() != 34 || !value.starts_with('T') || !value.is_ascii() || value == TRON_USDT { + return Err(UsdtError::InvalidAddress); + } + let payload = + bitcoin::base58::decode_check(value).map_err(|_| UsdtError::InvalidAddress)?; + if payload.len() != 21 || payload[0] != 0x41 || payload[1..].iter().all(|b| *b == 0) { + return Err(UsdtError::InvalidAddress); + } + return Ok(()); + } + if let Some((_, token)) = network.evm_token() { + let address = parse_address(value)?; + if address == parse_address(token)? { + return Err(UsdtError::InvalidAddress); + } + } else { + if !(32..=44).contains(&value.len()) || value == SOLANA_USDT { + return Err(UsdtError::InvalidAddress); + } + let bytes = bitcoin::base58::decode(value).map_err(|_| UsdtError::InvalidAddress)?; + if bytes.len() != 32 || bytes.iter().all(|b| *b == 0) { + return Err(UsdtError::InvalidAddress); + } + } + Ok(()) +} + +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 { + 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_viem_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 = "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8"; + for bad in [ + TRON_USDT, + "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb", + &"T".repeat(10000), + ] { + assert!(validate_source_address(bad, UsdtDepositNetwork::Tron).is_err()); + } + assert!(validate_source_address( + &super::super::UsdtDestination::Ethereum + .token() + .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( + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6s", + UsdtDepositNetwork::Tron + ) + .is_err()); + let solana = "6G41T4zUUYm47xgYBFoUioUGhigxS98Cj79y7C5nAf1L"; + for network in [ + UsdtDepositNetwork::Ethereum, + UsdtDepositNetwork::Polygon, + UsdtDepositNetwork::Bsc, + UsdtDepositNetwork::Base, + ] { + assert!(validate_source_address(network.evm_token().unwrap().1, network).is_err()); + assert!( + validate_source_address("0x1111111111111111111111111111111111111111", network) + .is_ok() + ); + assert!(validate_source_address(solana, network).is_err()); + } + assert!(validate_source_address(solana, UsdtDepositNetwork::Solana).is_ok()); + for bad in [ + SOLANA_USDT, + "11111111111111111111111111111111", + "0x1111111111111111111111111111111111111111", + ] { + assert!(validate_source_address(bad, UsdtDepositNetwork::Solana).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()); + } + } + + 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(); + assert_ne!(reader.read_line(&mut line).await.unwrap(), 0, "Request ended before its headers"); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + length = value.trim().parse().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) + } + + #[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,"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"})), + ]).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_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); + 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, + "0x2222222222222222222222222222222222222222".into(), + UsdtDepositNetwork::Ethereum, + PHRASE.into(), + None, + ) + .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] + async fn supported_networks_receive_with_pinned_token_uris() { + let owner = super::super::usdt_address(PHRASE.into(), None).unwrap(); + let networks = [ + ( + UsdtDepositNetwork::Ethereum, + "0x1111111111111111111111111111111111111111", + "ethereum:0xdac17f958d2ee523a2206206994597c13d831ec7@1/transfer?address=0x1111111111111111111111111111111111111111", + ), + ( + UsdtDepositNetwork::Tron, + "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", + "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", + ), + ( + UsdtDepositNetwork::Solana, + "6G41T4zUUYm47xgYBFoUioUGhigxS98Cj79y7C5nAf1L", + "6G41T4zUUYm47xgYBFoUioUGhigxS98Cj79y7C5nAf1L", + ), + ( + UsdtDepositNetwork::Polygon, + "0x1111111111111111111111111111111111111111", + "ethereum:0xc2132d05d31c914a87c6611c10748aeb04b58e8f@137/transfer?address=0x1111111111111111111111111111111111111111", + ), + ( + UsdtDepositNetwork::Base, + "0x1111111111111111111111111111111111111111", + "ethereum:0xfde4c96c8593536e31f229ea8f37b2ada2699bb2@8453/transfer?address=0x1111111111111111111111111111111111111111", + ), + ( + UsdtDepositNetwork::Bsc, + "0x1111111111111111111111111111111111111111", + "ethereum:0x55d398326f99059ff775485246999027b3197955@56/transfer?address=0x1111111111111111111111111111111111111111", + ), + ]; + let supported: Vec<_> = networks.iter().map(|(network, _, _)| *network).collect(); + let mut responses = vec![(200, json!({"networks":supported}))]; + responses.extend(networks.iter().map(|(network, address, _)| { + ( + 200, + json!({ + "network":network,"address":address,"recipient":owner,"amount":"100000000", + "estimated_received":"98500000","slippage_bps":50 + }), + ) + })); + let (url, server) = service(responses).await; + let client = UsdtDepositClient::new(owner, url).unwrap(); + assert_eq!(client.networks().await.unwrap(), supported); + for (network, address, uri) in networks { + let received = client + .receive(network, 100_000_000, PHRASE.into(), None) + .await + .unwrap(); + assert_eq!(received.amount, 100_000_000); + assert_eq!(received.estimated_received, 98_500_000); + assert_eq!(received.address, address); + assert_eq!(received.uri, uri); + } + let requests = server.await.unwrap(); + for (request, (network, _, _)) in requests.iter().zip(networks) { + let signed: Value = serde_json::from_str(request["request"].as_str().unwrap()).unwrap(); + assert_eq!( + signed["payload"], + json!({"action":"receive","network":network,"amount":"100000000"}) + ); + } + } + + #[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 { + 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", + UsdtError::DepositNeedsAttention, + ), + ("provider_unavailable", UsdtError::NetworkUnavailable), + ] { + 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(); + assert_eq!( + 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(); + } + } + + #[tokio::test] + 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 mut responses = vec![ + ( + 200, + json!({"network":"ethereum","address":super::super::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..invalid_receives { + 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 3a5cc80..bc6be4e 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,17 @@ pub enum UsdtError { PendingTransfer, #[error("The selected USDT payment route is unavailable")] 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 { + 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/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; From 11dc66eb519ec5335908b8ea14de6c475ace122b Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 19:38:59 -0500 Subject: [PATCH 2/2] test(usdt): separate deposit tests from implementation --- src/modules/usdt/deposits.rs | 441 +---------------------------- src/modules/usdt/deposits/tests.rs | 436 ++++++++++++++++++++++++++++ 2 files changed, 437 insertions(+), 440 deletions(-) create mode 100644 src/modules/usdt/deposits/tests.rs diff --git a/src/modules/usdt/deposits.rs b/src/modules/usdt/deposits.rs index 7a3fbc9..815d5c2 100644 --- a/src/modules/usdt/deposits.rs +++ b/src/modules/usdt/deposits.rs @@ -392,443 +392,4 @@ fn optional_number<'de, D: Deserializer<'de>>(deserializer: D) -> Result, - ) -> (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(); - assert_ne!(reader.read_line(&mut line).await.unwrap(), 0, "Request ended before its headers"); - if line == "\r\n" { - break; - } - if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { - length = value.trim().parse().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) - } - - #[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,"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"})), - ]).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_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); - 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, - "0x2222222222222222222222222222222222222222".into(), - UsdtDepositNetwork::Ethereum, - PHRASE.into(), - None, - ) - .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] - async fn supported_networks_receive_with_pinned_token_uris() { - let owner = super::super::usdt_address(PHRASE.into(), None).unwrap(); - let networks = [ - ( - UsdtDepositNetwork::Ethereum, - "0x1111111111111111111111111111111111111111", - "ethereum:0xdac17f958d2ee523a2206206994597c13d831ec7@1/transfer?address=0x1111111111111111111111111111111111111111", - ), - ( - UsdtDepositNetwork::Tron, - "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", - "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", - ), - ( - UsdtDepositNetwork::Solana, - "6G41T4zUUYm47xgYBFoUioUGhigxS98Cj79y7C5nAf1L", - "6G41T4zUUYm47xgYBFoUioUGhigxS98Cj79y7C5nAf1L", - ), - ( - UsdtDepositNetwork::Polygon, - "0x1111111111111111111111111111111111111111", - "ethereum:0xc2132d05d31c914a87c6611c10748aeb04b58e8f@137/transfer?address=0x1111111111111111111111111111111111111111", - ), - ( - UsdtDepositNetwork::Base, - "0x1111111111111111111111111111111111111111", - "ethereum:0xfde4c96c8593536e31f229ea8f37b2ada2699bb2@8453/transfer?address=0x1111111111111111111111111111111111111111", - ), - ( - UsdtDepositNetwork::Bsc, - "0x1111111111111111111111111111111111111111", - "ethereum:0x55d398326f99059ff775485246999027b3197955@56/transfer?address=0x1111111111111111111111111111111111111111", - ), - ]; - let supported: Vec<_> = networks.iter().map(|(network, _, _)| *network).collect(); - let mut responses = vec![(200, json!({"networks":supported}))]; - responses.extend(networks.iter().map(|(network, address, _)| { - ( - 200, - json!({ - "network":network,"address":address,"recipient":owner,"amount":"100000000", - "estimated_received":"98500000","slippage_bps":50 - }), - ) - })); - let (url, server) = service(responses).await; - let client = UsdtDepositClient::new(owner, url).unwrap(); - assert_eq!(client.networks().await.unwrap(), supported); - for (network, address, uri) in networks { - let received = client - .receive(network, 100_000_000, PHRASE.into(), None) - .await - .unwrap(); - assert_eq!(received.amount, 100_000_000); - assert_eq!(received.estimated_received, 98_500_000); - assert_eq!(received.address, address); - assert_eq!(received.uri, uri); - } - let requests = server.await.unwrap(); - for (request, (network, _, _)) in requests.iter().zip(networks) { - let signed: Value = serde_json::from_str(request["request"].as_str().unwrap()).unwrap(); - assert_eq!( - signed["payload"], - json!({"action":"receive","network":network,"amount":"100000000"}) - ); - } - } - - #[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 { - 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", - UsdtError::DepositNeedsAttention, - ), - ("provider_unavailable", UsdtError::NetworkUnavailable), - ] { - 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(); - assert_eq!( - 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(); - } - } - - #[tokio::test] - 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 mut responses = vec![ - ( - 200, - json!({"network":"ethereum","address":super::super::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..invalid_receives { - 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(); - } -} +mod tests; diff --git a/src/modules/usdt/deposits/tests.rs b/src/modules/usdt/deposits/tests.rs new file mode 100644 index 0000000..22e4164 --- /dev/null +++ b/src/modules/usdt/deposits/tests.rs @@ -0,0 +1,436 @@ +use super::*; +const PHRASE: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +#[test] +fn deposit_authorization_matches_viem_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 = "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8"; + for bad in [ + TRON_USDT, + "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb", + &"T".repeat(10000), + ] { + assert!(validate_source_address(bad, UsdtDepositNetwork::Tron).is_err()); + } + assert!(validate_source_address( + &super::super::UsdtDestination::Ethereum + .token() + .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( + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6s", + UsdtDepositNetwork::Tron + ) + .is_err()); + let solana = "6G41T4zUUYm47xgYBFoUioUGhigxS98Cj79y7C5nAf1L"; + for network in [ + UsdtDepositNetwork::Ethereum, + UsdtDepositNetwork::Polygon, + UsdtDepositNetwork::Bsc, + UsdtDepositNetwork::Base, + ] { + assert!(validate_source_address(network.evm_token().unwrap().1, network).is_err()); + assert!( + validate_source_address("0x1111111111111111111111111111111111111111", network).is_ok() + ); + assert!(validate_source_address(solana, network).is_err()); + } + assert!(validate_source_address(solana, UsdtDepositNetwork::Solana).is_ok()); + for bad in [ + SOLANA_USDT, + "11111111111111111111111111111111", + "0x1111111111111111111111111111111111111111", + ] { + assert!(validate_source_address(bad, UsdtDepositNetwork::Solana).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()); + } +} + +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(); + assert_ne!(reader.read_line(&mut line).await.unwrap(), 0, "Request ended before its headers"); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + length = value.trim().parse().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) +} + +#[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,"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"})), + ]).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_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); + 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, + "0x2222222222222222222222222222222222222222".into(), + UsdtDepositNetwork::Ethereum, + PHRASE.into(), + None, + ) + .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] +async fn supported_networks_receive_with_pinned_token_uris() { + let owner = super::super::usdt_address(PHRASE.into(), None).unwrap(); + let networks = [ + ( + UsdtDepositNetwork::Ethereum, + "0x1111111111111111111111111111111111111111", + "ethereum:0xdac17f958d2ee523a2206206994597c13d831ec7@1/transfer?address=0x1111111111111111111111111111111111111111", + ), + ( + UsdtDepositNetwork::Tron, + "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", + "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", + ), + ( + UsdtDepositNetwork::Solana, + "6G41T4zUUYm47xgYBFoUioUGhigxS98Cj79y7C5nAf1L", + "6G41T4zUUYm47xgYBFoUioUGhigxS98Cj79y7C5nAf1L", + ), + ( + UsdtDepositNetwork::Polygon, + "0x1111111111111111111111111111111111111111", + "ethereum:0xc2132d05d31c914a87c6611c10748aeb04b58e8f@137/transfer?address=0x1111111111111111111111111111111111111111", + ), + ( + UsdtDepositNetwork::Base, + "0x1111111111111111111111111111111111111111", + "ethereum:0xfde4c96c8593536e31f229ea8f37b2ada2699bb2@8453/transfer?address=0x1111111111111111111111111111111111111111", + ), + ( + UsdtDepositNetwork::Bsc, + "0x1111111111111111111111111111111111111111", + "ethereum:0x55d398326f99059ff775485246999027b3197955@56/transfer?address=0x1111111111111111111111111111111111111111", + ), + ]; + let supported: Vec<_> = networks.iter().map(|(network, _, _)| *network).collect(); + let mut responses = vec![(200, json!({"networks":supported}))]; + responses.extend(networks.iter().map(|(network, address, _)| { + ( + 200, + json!({ + "network":network,"address":address,"recipient":owner,"amount":"100000000", + "estimated_received":"98500000","slippage_bps":50 + }), + ) + })); + let (url, server) = service(responses).await; + let client = UsdtDepositClient::new(owner, url).unwrap(); + assert_eq!(client.networks().await.unwrap(), supported); + for (network, address, uri) in networks { + let received = client + .receive(network, 100_000_000, PHRASE.into(), None) + .await + .unwrap(); + assert_eq!(received.amount, 100_000_000); + assert_eq!(received.estimated_received, 98_500_000); + assert_eq!(received.address, address); + assert_eq!(received.uri, uri); + } + let requests = server.await.unwrap(); + for (request, (network, _, _)) in requests.iter().zip(networks) { + let signed: Value = serde_json::from_str(request["request"].as_str().unwrap()).unwrap(); + assert_eq!( + signed["payload"], + json!({"action":"receive","network":network,"amount":"100000000"}) + ); + } +} + +#[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 { + 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", + UsdtError::DepositNeedsAttention, + ), + ("provider_unavailable", UsdtError::NetworkUnavailable), + ] { + 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(); + assert_eq!( + 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(); + } +} + +#[tokio::test] +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 mut responses = vec![ + ( + 200, + json!({"network":"ethereum","address":super::super::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..invalid_receives { + 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(); +}