diff --git a/Cargo.lock b/Cargo.lock index 0423237..dde71c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -985,7 +985,7 @@ dependencies = [ [[package]] name = "bitkitcore" -version = "0.8.0-rc2" +version = "0.8.0-rc6" dependencies = [ "alloy-primitives", "alloy-rlp", diff --git a/Cargo.toml b/Cargo.toml index 39b0879..59de750 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitkitcore" -version = "0.8.0-rc2" +version = "0.8.0-rc6" edition = "2021" rust-version = "1.90" diff --git a/Package.swift b/Package.swift index e570a77..8155b76 100644 --- a/Package.swift +++ b/Package.swift @@ -4,8 +4,8 @@ import PackageDescription import Foundation -let tag = "v0.8.0-rc2" -let checksum = "e064d0edafecfa64e5291d2aaab6b00052afbe6951fb26b3ea46b727b2df577f" +let tag = "v0.8.0-rc6" +let checksum = "9be4149fe3d6e5c36e0ef562d73692f8fefe1cde2836f975c15e711292d67f95" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/bindings/android/gradle.properties b/bindings/android/gradle.properties index 5c450f4..a468256 100644 --- a/bindings/android/gradle.properties +++ b/bindings/android/gradle.properties @@ -3,4 +3,4 @@ android.useAndroidX=true android.enableJetifier=true kotlin.code.style=official group=com.synonym -version=0.8.0-rc2 +version=0.8.0-rc6 diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index f71108b..a498451 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -3027,6 +3027,12 @@ public protocol UsdtWalletProtocol: AnyObject, Sendable { */ func orchestraDestinations() async throws -> [UsdtDestination] + /** + * Quotes the available balance less a conservative fee reserve. Final fees may leave dust. + * Returns a normal, funded quote; approval and sending use the same checks as any transfer. + */ + func quoteMaxTransfer(recipient: String, destination: UsdtDestination) async throws -> UsdtQuote + func quoteTransfer(recipient: String, amount: UInt64, destination: UsdtDestination) async throws -> UsdtQuote func receiveAddress() -> String @@ -3051,6 +3057,14 @@ public protocol UsdtWalletProtocol: AnyObject, Sendable { */ func send(quoteId: String, mnemonic: String, passphrase: String?) async throws -> UsdtTransfer + /** + * Submits only while the approved deadline is valid, including automatic recovery retries. + * This limits submission, not chain inclusion. An already submitted payment may still settle. + * Repeating a quote ID retains its original deadline and returns its stored outcome. + * The optional deadline is an RFC 3339 timestamp with up to nanosecond precision. + */ + func sendWithDeadline(quoteId: String, mnemonic: String, passphrase: String?, submissionDeadline: String?) async throws -> UsdtTransfer + /** * Saves resumable history progress; returns true when caught up and false when more work remains. * Call between send flows. The soft budget permits an in-flight receipt to finish before yielding. @@ -3232,6 +3246,27 @@ open func orchestraDestinations()async throws -> [UsdtDestination] { ) } + /** + * Quotes the available balance less a conservative fee reserve. Final fees may leave dust. + * Returns a normal, funded quote; approval and sending use the same checks as any transfer. + */ +open func quoteMaxTransfer(recipient: String, destination: UsdtDestination)async throws -> UsdtQuote { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtwallet_quote_max_transfer( + self.uniffiClonePointer(), + FfiConverterString.lower(recipient),FfiConverterTypeUsdtDestination_lower(destination) + ) + }, + 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: FfiConverterTypeUsdtQuote_lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + open func quoteTransfer(recipient: String, amount: UInt64, destination: UsdtDestination)async throws -> UsdtQuote { return try await uniffiRustCallAsync( @@ -3326,6 +3361,29 @@ open func send(quoteId: String, mnemonic: String, passphrase: String?)async thro ) } + /** + * Submits only while the approved deadline is valid, including automatic recovery retries. + * This limits submission, not chain inclusion. An already submitted payment may still settle. + * Repeating a quote ID retains its original deadline and returns its stored outcome. + * The optional deadline is an RFC 3339 timestamp with up to nanosecond precision. + */ +open func sendWithDeadline(quoteId: String, mnemonic: String, passphrase: String?, submissionDeadline: String?)async throws -> UsdtTransfer { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtwallet_send_with_deadline( + self.uniffiClonePointer(), + FfiConverterString.lower(quoteId),FfiConverterString.lower(mnemonic),FfiConverterOptionString.lower(passphrase),FfiConverterOptionString.lower(submissionDeadline) + ) + }, + 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: FfiConverterTypeUsdtTransfer_lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + /** * Saves resumable history progress; returns true when caught up and false when more work remains. * Call between send flows. The soft budget permits an in-flight receipt to finish before yielding. @@ -17654,6 +17712,11 @@ public func FfiConverterTypeUsdtQuote_lower(_ value: UsdtQuote) -> RustBuffer { public struct UsdtTransfer { + /** + * Source address on Arbitrum, from the token event or this wallet's signed operation. + * Absent in older history. A bridge contract address does not identify the original depositor. + */ + public var sender: String? public var id: String /** * Source transaction hash, absent until execution is observed. @@ -17673,10 +17736,15 @@ public struct UsdtTransfer { // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, + public init( + /** + * Source address on Arbitrum, from the token event or this wallet's signed operation. + * Absent in older history. A bridge contract address does not identify the original depositor. + */sender: String?, id: String, /** * Source transaction hash, absent until execution is observed. */txHash: String?, userOperationHash: String?, bridgeGuid: String?, orchestra: UsdtOrchestraTransfer?, recipient: String, destination: UsdtDestination, amount: UInt64, receivedAmount: UInt64, fee: UInt64?, isIncoming: Bool, status: UsdtTransferStatus, timestamp: UInt64) { + self.sender = sender self.id = id self.txHash = txHash self.userOperationHash = userOperationHash @@ -17700,6 +17768,9 @@ extension UsdtTransfer: Sendable {} extension UsdtTransfer: Equatable, Hashable { public static func ==(lhs: UsdtTransfer, rhs: UsdtTransfer) -> Bool { + if lhs.sender != rhs.sender { + return false + } if lhs.id != rhs.id { return false } @@ -17743,6 +17814,7 @@ extension UsdtTransfer: Equatable, Hashable { } public func hash(into hasher: inout Hasher) { + hasher.combine(sender) hasher.combine(id) hasher.combine(txHash) hasher.combine(userOperationHash) @@ -17770,6 +17842,7 @@ public struct FfiConverterTypeUsdtTransfer: FfiConverterRustBuffer { public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtTransfer { return try UsdtTransfer( + sender: FfiConverterOptionString.read(from: &buf), id: FfiConverterString.read(from: &buf), txHash: FfiConverterOptionString.read(from: &buf), userOperationHash: FfiConverterOptionString.read(from: &buf), @@ -17787,6 +17860,7 @@ public struct FfiConverterTypeUsdtTransfer: FfiConverterRustBuffer { } public static func write(_ value: UsdtTransfer, into buf: inout [UInt8]) { + FfiConverterOptionString.write(value.sender, into: &buf) FfiConverterString.write(value.id, into: &buf) FfiConverterOptionString.write(value.txHash, into: &buf) FfiConverterOptionString.write(value.userOperationHash, into: &buf) @@ -25361,6 +25435,7 @@ public enum UsdtError: Swift.Error { case InvalidBackup case InvalidPaymentProof case InvalidAmount + case BridgeAmountTooSmall case InvalidAddress case WrongNetwork case InvalidCredentials @@ -25368,6 +25443,8 @@ public enum UsdtError: Swift.Error { case UnsupportedDelegation case InsufficientBalance case QuoteExpired + case SubmissionDeadlineExpired + case InvalidSubmissionDeadline case PendingTransfer case UnsupportedRoute case DepositNeedsAttention @@ -25404,33 +25481,36 @@ public struct FfiConverterTypeUsdtError: FfiConverterRustBuffer { case 2: return .InvalidBackup case 3: return .InvalidPaymentProof case 4: return .InvalidAmount - case 5: return .InvalidAddress - case 6: return .WrongNetwork - case 7: return .InvalidCredentials - case 8: return .ClockSkew - case 9: return .UnsupportedDelegation - case 10: return .InsufficientBalance - case 11: return .QuoteExpired - case 12: return .PendingTransfer - case 13: return .UnsupportedRoute - case 14: return .DepositNeedsAttention - case 15: return .DepositNotFound - case 16: return .DepositAuthorizationRejected - case 17: return .DepositAmountOutOfRange( + case 5: return .BridgeAmountTooSmall + case 6: return .InvalidAddress + case 7: return .WrongNetwork + case 8: return .InvalidCredentials + case 9: return .ClockSkew + case 10: return .UnsupportedDelegation + case 11: return .InsufficientBalance + case 12: return .QuoteExpired + case 13: return .SubmissionDeadlineExpired + case 14: return .InvalidSubmissionDeadline + case 15: return .PendingTransfer + case 16: return .UnsupportedRoute + case 17: return .DepositNeedsAttention + case 18: return .DepositNotFound + case 19: return .DepositAuthorizationRejected + case 20: return .DepositAmountOutOfRange( minUsdCents: try FfiConverterOptionString.read(from: &buf), maxUsdCents: try FfiConverterOptionString.read(from: &buf) ) - case 18: return .NotConfigured - case 19: return .NetworkUnavailable - case 20: return .RateLimited - case 21: return .LogRangeTooLarge - case 22: return .TransactionRejected( + case 21: return .NotConfigured + case 22: return .NetworkUnavailable + case 23: return .RateLimited + case 24: return .LogRangeTooLarge + case 25: return .TransactionRejected( reason: try FfiConverterString.read(from: &buf) ) - case 23: return .Storage( + case 26: return .Storage( reason: try FfiConverterString.read(from: &buf) ) - case 24: return .InvalidResponse + case 27: return .InvalidResponse default: throw UniffiInternalError.unexpectedEnumCase } @@ -25459,88 +25539,100 @@ public struct FfiConverterTypeUsdtError: FfiConverterRustBuffer { writeInt(&buf, Int32(4)) - case .InvalidAddress: + case .BridgeAmountTooSmall: writeInt(&buf, Int32(5)) - case .WrongNetwork: + case .InvalidAddress: writeInt(&buf, Int32(6)) - case .InvalidCredentials: + case .WrongNetwork: writeInt(&buf, Int32(7)) - case .ClockSkew: + case .InvalidCredentials: writeInt(&buf, Int32(8)) - case .UnsupportedDelegation: + case .ClockSkew: writeInt(&buf, Int32(9)) - case .InsufficientBalance: + case .UnsupportedDelegation: writeInt(&buf, Int32(10)) - case .QuoteExpired: + case .InsufficientBalance: writeInt(&buf, Int32(11)) - case .PendingTransfer: + case .QuoteExpired: writeInt(&buf, Int32(12)) - case .UnsupportedRoute: + case .SubmissionDeadlineExpired: writeInt(&buf, Int32(13)) - case .DepositNeedsAttention: + case .InvalidSubmissionDeadline: writeInt(&buf, Int32(14)) - case .DepositNotFound: + case .PendingTransfer: writeInt(&buf, Int32(15)) - case .DepositAuthorizationRejected: + case .UnsupportedRoute: writeInt(&buf, Int32(16)) - case let .DepositAmountOutOfRange(minUsdCents,maxUsdCents): + case .DepositNeedsAttention: writeInt(&buf, Int32(17)) + + + case .DepositNotFound: + writeInt(&buf, Int32(18)) + + + case .DepositAuthorizationRejected: + writeInt(&buf, Int32(19)) + + + case let .DepositAmountOutOfRange(minUsdCents,maxUsdCents): + writeInt(&buf, Int32(20)) FfiConverterOptionString.write(minUsdCents, into: &buf) FfiConverterOptionString.write(maxUsdCents, into: &buf) case .NotConfigured: - writeInt(&buf, Int32(18)) + writeInt(&buf, Int32(21)) case .NetworkUnavailable: - writeInt(&buf, Int32(19)) + writeInt(&buf, Int32(22)) case .RateLimited: - writeInt(&buf, Int32(20)) + writeInt(&buf, Int32(23)) case .LogRangeTooLarge: - writeInt(&buf, Int32(21)) + writeInt(&buf, Int32(24)) case let .TransactionRejected(reason): - writeInt(&buf, Int32(22)) + writeInt(&buf, Int32(25)) FfiConverterString.write(reason, into: &buf) case let .Storage(reason): - writeInt(&buf, Int32(23)) + writeInt(&buf, Int32(26)) FfiConverterString.write(reason, into: &buf) case .InvalidResponse: - writeInt(&buf, Int32(24)) + writeInt(&buf, Int32(27)) } } @@ -32133,6 +32225,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_usdtwallet_orchestra_destinations() != 39605) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_method_usdtwallet_quote_max_transfer() != 40699) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_method_usdtwallet_quote_transfer() != 3732) { return InitializationResult.apiChecksumMismatch } @@ -32151,6 +32246,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_usdtwallet_send() != 60030) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_method_usdtwallet_send_with_deadline() != 23330) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_method_usdtwallet_sync_history() != 25445) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index 75e5368..3d4d583 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -779,6 +779,11 @@ RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_history(void*_Nonnull ptr, Rus uint64_t uniffi_bitkitcore_fn_method_usdtwallet_orchestra_destinations(void*_Nonnull ptr ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_QUOTE_MAX_TRANSFER +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_QUOTE_MAX_TRANSFER +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_quote_max_transfer(void*_Nonnull ptr, RustBuffer recipient, RustBuffer destination +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_QUOTE_TRANSFER #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_QUOTE_TRANSFER uint64_t uniffi_bitkitcore_fn_method_usdtwallet_quote_transfer(void*_Nonnull ptr, RustBuffer recipient, uint64_t amount, RustBuffer destination @@ -809,6 +814,11 @@ uint64_t uniffi_bitkitcore_fn_method_usdtwallet_restore_backup(void*_Nonnull ptr uint64_t uniffi_bitkitcore_fn_method_usdtwallet_send(void*_Nonnull ptr, RustBuffer quote_id, RustBuffer mnemonic, RustBuffer passphrase ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_SEND_WITH_DEADLINE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_SEND_WITH_DEADLINE +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_send_with_deadline(void*_Nonnull ptr, RustBuffer quote_id, RustBuffer mnemonic, RustBuffer passphrase, RustBuffer submission_deadline +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_SYNC_HISTORY #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_SYNC_HISTORY uint64_t uniffi_bitkitcore_fn_method_usdtwallet_sync_history(void*_Nonnull ptr @@ -3648,6 +3658,12 @@ uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_history(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_ORCHESTRA_DESTINATIONS uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_orchestra_destinations(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_QUOTE_MAX_TRANSFER +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_QUOTE_MAX_TRANSFER +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_quote_max_transfer(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_QUOTE_TRANSFER @@ -3684,6 +3700,12 @@ uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_restore_backup(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_SEND uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_send(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_SEND_WITH_DEADLINE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_SEND_WITH_DEADLINE +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_send_with_deadline(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_SYNC_HISTORY diff --git a/src/modules/usdt/README.md b/src/modules/usdt/README.md index 0797607..7b6c458 100644 --- a/src/modules/usdt/README.md +++ b/src/modules/usdt/README.md @@ -16,6 +16,10 @@ Owned mnemonic/passphrase/seed buffers are zeroized and signing keys are erased `quote_transfer` takes a raw recipient, positive atomic amount and destination; it never receives signing credentials. Local quotes last at most 120 seconds and newly quoted paymaster terms must expire within 15 minutes. `send` validates the owner, nonces, balance, gas estimates, the current slow gas-price recommendation and deadlines before signing the stored plan. Quotes use the fast gas-price recommendation; a modest price increase does not invalidate a quote that still covers the current slow recommendation. Changes beyond the approved bounds require a new review; signing cannot raise the approved fee. +`BridgeAmountTooSmall` means live cross-chain costs exceed the quoted output. It carries no fixed minimum; applications should ask for a larger amount and request a fresh quote or deposit estimate. Provider-reported deposit limits are separate bounds and do not guarantee that an amount covers current bridge costs. + +`quote_max_transfer` reserves a conservative maximum fee from the available balance and returns the same reviewed quote as `quote_transfer`. It uses a read-only token-balance override only to estimate stub gas, then obtains signed paymaster terms against the real balance. Fee changes have a bounded retry budget; an unaffordable quote is never returned. Actual fees can leave a small balance. MAX requires the companion service’s restricted estimation-override support. + The pinned ERC-20 paymaster collects USDT. Its finite approval includes a 5% margin; the displayed maximum fee comes from signed gas limits and paymaster terms, not the allowance. Call/pre-verification estimates receive 10% execution/L1-data headroom; the charged pre-verification margin is included in the maximum. A residual paymaster allowance can remain and is reset to a finite amount on the next payment. `usdt_parse_payment_request` accepts raw addresses and chain-qualified ERC-681 `transfer` requests for the pinned token, with exact atomic/scientific amounts. Native-asset requests and ambiguous or unsupported parameters are rejected. The returned `chain_id` preserves explicit network restrictions; bare addresses leave it unset. Callers must honor it and review the parsed amount before requesting a quote. @@ -34,6 +38,8 @@ A matching event in a canonical receipt settles the payment. Discovery logs alon Seed restoration recovers deposits and outgoing activity from genesis, including transfers before delegation and sends through another wallet. Supported direct EntryPoint calls and paymaster modes recover payment/fee attribution; unknown wrappers or payment modes preserve raw token transfers instead of guessing their intent. Failed payments retain attempted amounts but have no delivered amount. Transaction hashes are absent until execution is observed; callers derive explorer links from the source transaction hash rather than storing a second copy of it. +`UsdtTransfer.sender` records the Arbitrum token-transfer source when known. Incoming transfers use the event’s `from` address; locally signed payments use this account. Older records can omit it. A bridge contract’s address is not evidence of the original depositor. + `sync_history` returns `true` when caught up and `false` when more work remains. It uses adaptive log ranges and a 20-second soft budget between persisted receipts; an in-flight receipt may finish later. Learned range limits survive budget exits. A single-block log overflow falls back to that block's individual receipts. Complete receipt enrichment and fallback scans are retained by canonical block hash within the revisit window; log-only incoming observations and successful bridge receipts missing tracking or fee evidence remain eligible for later enrichment. Incomplete bridge metadata does not keep an executed source payment pending. Zero/self transfers are discarded before enrichment. Network failures preserve completed work and never silently skip a block. Callers should defer catch-up during payment review/submission so historical work does not delay sends. `check_recent_execution` checks one recent direct Arbitrum payment with a five-second request budget. It requires the expected operation outcome and token transfer in a matching canonical receipt and does not scan history, rebroadcast, expire payments or reconcile nonces. It can confirm execution at the current L2 tip; this is provisional sequencer execution, not parent-chain finality. Native send screens may call it approximately once per second during a short foreground window, with cancellation and rate-limit backoff between checks. Missing evidence leaves Pending intact. If another wallet operation owns the local lock, the check returns stored activity immediately. Normal recovery handles older payments outside its 64-block lookup window. diff --git a/src/modules/usdt/backup.rs b/src/modules/usdt/backup.rs index fabde90..5a645b7 100644 --- a/src/modules/usdt/backup.rs +++ b/src/modules/usdt/backup.rs @@ -99,6 +99,13 @@ impl Store { let mut hashes = HashSet::new(); for item in &mut backup.transfers { let transfer = &mut item.transfer; + if let Some(sender) = &mut transfer.sender { + let address: Address = sender.parse().map_err(|_| UsdtError::InvalidBackup)?; + if !transfer.is_incoming && address != owner { + return Err(UsdtError::InvalidBackup); + } + *sender = address.to_checksum(None); + } let refund = transfer .orchestra .as_mut() @@ -176,28 +183,34 @@ impl Store { .user_operation_hash .clone() .unwrap_or_else(|| item.transfer.id.clone()); - let existing: Option = tx + let existing: Option<(String, Option)> = tx .query_row( - "SELECT hash FROM usdt_transfers WHERE id=?1", + "SELECT hash,raw FROM usdt_transfers WHERE id=?1", [&item.transfer.id], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?)), ) .optional()?; - if let Some(existing) = existing { + if let Some((existing, raw)) = existing { if existing != hash { return Err(UsdtError::InvalidBackup); } + if raw.is_some() { + tx.execute( + "UPDATE usdt_transfers SET raw=?1 WHERE id=?2", + params![merge_plan(raw, item.plan.as_ref())?, item.transfer.id], + )?; + } continue; } // Seed recovery can discover a payment before its application ID is restored. - let discovered: Option<(String, String)> = tx + let discovered: Option<(String, String, Option)> = tx .query_row( - "SELECT id,data FROM usdt_transfers WHERE hash=?1", + "SELECT id,data,raw FROM usdt_transfers WHERE hash=?1", [&hash], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional()?; - if let Some((id, data)) = discovered { + if let Some((id, data, raw)) = discovered { if id != hash { return Err(UsdtError::InvalidBackup); } @@ -233,14 +246,12 @@ impl Store { } require_refund_owner(&tx, &id, &transfer)?; tx.execute( - "UPDATE usdt_transfers SET id=?1,data=?2,raw=COALESCE(raw,?4) WHERE id=?3", + "UPDATE usdt_transfers SET id=?1,data=?2,raw=?4 WHERE id=?3", params![ transfer.id, serde_json::to_string(&transfer)?, id, - item.plan - .map(|plan| serde_json::to_string(&plan)) - .transpose()? + merge_plan(raw, item.plan.as_ref())? ], )?; continue; @@ -279,6 +290,24 @@ impl Store { } } +fn merge_plan(raw: Option, restored: Option<&Plan>) -> Result, UsdtError> { + let Some(raw) = raw else { + return restored + .map(serde_json::to_string) + .transpose() + .map_err(Into::into); + }; + let Some(deadline) = restored.and_then(|plan| plan.submission_deadline) else { + return Ok(Some(raw)); + }; + let mut plan: Plan = serde_json::from_str(&raw)?; + plan.submission_deadline = Some( + plan.submission_deadline + .map_or(deadline, |local| local.min(deadline)), + ); + Ok(Some(serde_json::to_string(&plan)?)) +} + fn require_refund_owner( connection: &rusqlite::Connection, id: &str, diff --git a/src/modules/usdt/deposits.rs b/src/modules/usdt/deposits.rs index 9869fd3..a8f6de4 100644 --- a/src/modules/usdt/deposits.rs +++ b/src/modules/usdt/deposits.rs @@ -294,6 +294,7 @@ impl UsdtDepositClient { if !status.is_success() { let error = value.unwrap_or_default(); return Err(match error["error"].as_str() { + Some("xchain_amount_too_small") => UsdtError::BridgeAmountTooSmall, Some("not_configured") => UsdtError::NotConfigured, Some("provider_unavailable") => UsdtError::NetworkUnavailable, Some("invalid_authorization") => UsdtError::DepositAuthorizationRejected, diff --git a/src/modules/usdt/deposits/tests.rs b/src/modules/usdt/deposits/tests.rs index 550830d..25c2d92 100644 --- a/src/modules/usdt/deposits/tests.rs +++ b/src/modules/usdt/deposits/tests.rs @@ -278,6 +278,7 @@ async fn supported_networks_receive_with_pinned_token_uris() { async fn service_errors_preserve_recovery_actions() { for (code, expected) in [ ("not_found", UsdtError::DepositNotFound), + ("xchain_amount_too_small", UsdtError::BridgeAmountTooSmall), ( "invalid_authorization", UsdtError::DepositAuthorizationRejected, @@ -435,3 +436,24 @@ async fn invalid_receive_terms_and_nonadvancing_pages_are_rejected() { )); server.await.unwrap(); } + +#[tokio::test] +async fn outbound_quotes_preserve_bridge_cost_errors() { + let (url, server) = service(vec![(409, json!({"error":"xchain_amount_too_small"}))]).await; + let client = super::super::orchestra::Orchestra::new(url).unwrap(); + let owner = super::super::usdt_address(PHRASE.into(), None).unwrap(); + assert!(matches!( + client + .quote( + owner.parse().unwrap(), + "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8".into(), + 3_500_000, + super::super::UsdtDestination::Tron, + ) + .await, + Err(UsdtError::BridgeAmountTooSmall) + )); + let requests = server.await.unwrap(); + assert_eq!(requests[0]["action"], "quote"); + assert_eq!(requests[0]["amount"], "3500000"); +} diff --git a/src/modules/usdt/errors.rs b/src/modules/usdt/errors.rs index 225f160..2be6000 100644 --- a/src/modules/usdt/errors.rs +++ b/src/modules/usdt/errors.rs @@ -10,6 +10,8 @@ pub enum UsdtError { InvalidPaymentProof, #[error("Enter a valid USDT amount with at most six decimal places")] InvalidAmount, + #[error("Amount too small to cover bridge costs. Try a larger amount.")] + BridgeAmountTooSmall, #[error("Enter a valid address for the selected network")] InvalidAddress, #[error("The network or token does not match this USDT account")] @@ -24,6 +26,10 @@ pub enum UsdtError { InsufficientBalance, #[error("The fee quote has expired or changed. Review a new quote")] QuoteExpired, + #[error("The approved payment submission deadline has passed")] + SubmissionDeadlineExpired, + #[error("The payment submission deadline must be a valid RFC 3339 timestamp")] + InvalidSubmissionDeadline, #[error("A USDT transaction is pending. Wait for confirmation before sending again")] PendingTransfer, #[error("The selected USDT payment route is unavailable")] diff --git a/src/modules/usdt/history.rs b/src/modules/usdt/history.rs index 963e795..6acaa8e 100644 --- a/src/modules/usdt/history.rs +++ b/src/modules/usdt/history.rs @@ -295,6 +295,7 @@ impl UsdtWallet { } let index: U256 = serde_json::from_value(log["logIndex"].clone())?; result.push(UsdtTransfer { + sender: Some(event.from.to_checksum(None)), id: format!("{hash}:{index}"), tx_hash: Some(hash.into()), user_operation_hash: None, @@ -380,6 +381,7 @@ impl UsdtWallet { ) }; let mut transfer = UsdtTransfer { + sender: Some(self.receive_address()), id: operation_hash.clone(), tx_hash: Some(hash.into()), user_operation_hash: Some(operation_hash), diff --git a/src/modules/usdt/orchestra.rs b/src/modules/usdt/orchestra.rs index af7d1b2..9c096f2 100644 --- a/src/modules/usdt/orchestra.rs +++ b/src/modules/usdt/orchestra.rs @@ -165,6 +165,7 @@ impl Orchestra { let value = bounded_json(response, 16384, UsdtError::InvalidResponse).await?; if !status.is_success() { return Err(match value["error"].as_str() { + Some("xchain_amount_too_small") => UsdtError::BridgeAmountTooSmall, Some("amount_too_small" | "amount_too_large" | "amount_exceeds_liquidity") => { UsdtError::InvalidAmount } diff --git a/src/modules/usdt/paymaster.rs b/src/modules/usdt/paymaster.rs index ea1c292..06136ff 100644 --- a/src/modules/usdt/paymaster.rs +++ b/src/modules/usdt/paymaster.rs @@ -6,7 +6,7 @@ use super::{ user_operation::{Authorization, UserOperation}, UsdtError, }; -use alloy_primitives::{address, Address, Bytes, U256}; +use alloy_primitives::{address, keccak256, Address, Bytes, U256}; use alloy_sol_types::SolCall; use serde::Deserialize; use serde_json::json; @@ -36,6 +36,7 @@ struct TokenQuote { token: Address, post_op_gas: U256, exchange_rate: U256, + balance_slot: Option, } #[derive(Deserialize)] @@ -103,18 +104,82 @@ impl Pimlico { calls: &[(Address, Bytes)], timestamp: u64, ) -> Result<(UserOperation, u64, u64), UsdtError> { + let (mut op, estimate_terms) = self + .prepare_estimate(address, nonce, authorization, calls, false) + .await?; + let mut allowance = with_margin(estimate_terms.maximum_token_cost(&op)?, 5)?; + // Provider data can change gas limits and token charges. Refine both before signing. + for _ in 0..3 { + op.call_data = with_approval(calls, allowance); + self.apply_paymaster(&mut op, "pm_getPaymasterData").await?; + let terms = Terms::decode(&op.paymaster_data)?; + let estimate: GasEstimate = self + .rpc + .call("eth_estimateUserOperationGas", json!([op, ENTRY_POINT])) + .await?; + let gas_changed = estimate.apply(&mut op)?; + let required = terms.maximum_token_cost(&op)?; + if gas_changed || required > allowance { + allowance = allowance.max(with_margin(required, 5)?); + continue; + } + if terms.valid_until == 0 + || terms.valid_until > timestamp.saturating_add(MAX_PAYMASTER_VALIDITY_SECONDS) + { + return Err(UsdtError::InvalidResponse); + } + if terms.valid_after > timestamp + || terms.valid_until <= timestamp.saturating_add(MIN_PAYMASTER_VALIDITY_SECONDS) + { + return Err(UsdtError::QuoteExpired); + } + return Ok(( + op, + required + .try_into() + .map_err(|_| UsdtError::InvalidResponse)?, + terms.valid_until, + )); + } + Err(UsdtError::QuoteExpired) + } + + /// Discovers a fee budget without asking the paymaster to sign an unaffordable payment. + pub async fn estimate_maximum_fee( + &self, + address: Address, + nonce: U256, + authorization: Authorization, + calls: &[(Address, Bytes)], + ) -> Result { + let (op, terms) = self + .prepare_estimate(address, nonce, authorization, calls, true) + .await?; + with_margin(terms.maximum_token_cost(&op)?, 5)? + .try_into() + .map_err(|_| UsdtError::InvalidResponse) + } + + async fn prepare_estimate( + &self, + address: Address, + nonce: U256, + authorization: Authorization, + calls: &[(Address, Bytes)], + balance_override: bool, + ) -> Result<(UserOperation, Terms), UsdtError> { self.rpc.verify_chain().await?; #[derive(Deserialize)] struct Quotes { quotes: Vec, } - let quotes: Quotes = self - .rpc - .call( + let (quotes, prices) = tokio::try_join!( + self.rpc.call::( "pimlico_getTokenQuotes", json!([{ "tokens": [USDT0] }, ENTRY_POINT, U256::from(ARBITRUM_CHAIN_ID)]), - ) - .await?; + ), + self.gas_prices() + )?; let quote = quotes .quotes .into_iter() @@ -124,7 +189,7 @@ impl Pimlico { && !quote.exchange_rate.is_zero() }) .ok_or(UsdtError::UnsupportedRoute)?; - let price = self.gas_prices().await?.fast; + let price = prices.fast; let dummy_signature = Bytes::from_static(&alloy_primitives::hex!("fffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c")); let mut op = UserOperation { sender: address, @@ -146,53 +211,40 @@ impl Pimlico { }; self.apply_paymaster(&mut op, "pm_getPaymasterStubData") .await?; + let params = if balance_override { + // USDT0 uses the legacy USDT balance mapping. Never override code or authorization. + let slot = quote.balance_slot.ok_or(UsdtError::UnsupportedRoute)?; + if slot != U256::from(51) { + return Err(UsdtError::UnsupportedRoute); + } + let key = keccak256( + [ + address.into_word().as_slice(), + slot.to_be_bytes::<32>().as_slice(), + ] + .concat(), + ); + json!([op, ENTRY_POINT, {USDT0.to_string(): {"stateDiff": {key.to_string(): format!("{:#066x}", u128::MAX)}}}]) + } else { + json!([op, ENTRY_POINT]) + }; let estimate: GasEstimate = self .rpc - .call("eth_estimateUserOperationGas", json!([op, ENTRY_POINT])) + .call("eth_estimateUserOperationGas", params) .await?; estimate.apply(&mut op)?; let estimate_terms = Terms { exchange_rate: quote.exchange_rate, post_op_gas: quote.post_op_gas, - constant_fee: U256::ZERO, + constant_fee: if balance_override { + Terms::decode(&op.paymaster_data)?.constant_fee + } else { + U256::ZERO + }, valid_until: 0, valid_after: 0, }; - let mut allowance = with_margin(estimate_terms.maximum_token_cost(&op)?, 5)?; - // Provider data can change gas limits and token charges. Refine both before signing. - for _ in 0..3 { - op.call_data = with_approval(calls, allowance); - self.apply_paymaster(&mut op, "pm_getPaymasterData").await?; - let terms = Terms::decode(&op.paymaster_data)?; - let estimate: GasEstimate = self - .rpc - .call("eth_estimateUserOperationGas", json!([op, ENTRY_POINT])) - .await?; - let gas_changed = estimate.apply(&mut op)?; - let required = terms.maximum_token_cost(&op)?; - if gas_changed || required > allowance { - allowance = allowance.max(with_margin(required, 5)?); - continue; - } - if terms.valid_until == 0 - || terms.valid_until > timestamp.saturating_add(MAX_PAYMASTER_VALIDITY_SECONDS) - { - return Err(UsdtError::InvalidResponse); - } - if terms.valid_after > timestamp - || terms.valid_until <= timestamp.saturating_add(MIN_PAYMASTER_VALIDITY_SECONDS) - { - return Err(UsdtError::QuoteExpired); - } - return Ok(( - op, - required - .try_into() - .map_err(|_| UsdtError::InvalidResponse)?, - terms.valid_until, - )); - } - Err(UsdtError::QuoteExpired) + Ok((op, estimate_terms)) } async fn apply_paymaster(&self, op: &mut UserOperation, method: &str) -> Result<(), UsdtError> { diff --git a/src/modules/usdt/rpc.rs b/src/modules/usdt/rpc.rs index d11ca3e..1e18731 100644 --- a/src/modules/usdt/rpc.rs +++ b/src/modules/usdt/rpc.rs @@ -3,7 +3,10 @@ use alloy_primitives::{Address, Bytes, B256, U256}; use alloy_sol_types::SolCall; use serde::{de::DeserializeOwned, Deserialize}; use serde_json::{json, Value}; -use std::{sync::Arc, time::Duration}; +use std::{ + sync::Arc, + time::{Duration, SystemTime}, +}; use tokio::{sync::Mutex, time::Instant}; const REQUEST_INTERVAL: Duration = Duration::from_millis(750); @@ -29,6 +32,8 @@ pub(super) struct Rpc { url: String, chain_id: u64, next_request: Arc>, + #[cfg(test)] + pub(super) clock: Arc>>, } #[derive(Deserialize)] @@ -50,12 +55,18 @@ impl Rpc { url, chain_id, next_request: Arc::new(Mutex::new(Instant::now())), + #[cfg(test)] + clock: Arc::default(), }) } pub fn with_url(&self, url: String) -> Result { let mut rpc = Self::new(url, self.chain_id)?; rpc.next_request = self.next_request.clone(); + #[cfg(test)] + { + rpc.clock = self.clock.clone(); + } Ok(rpc) } @@ -71,12 +82,23 @@ impl Rpc { &self, method: &str, params: Value, + ) -> Result { + self.call_with_deadline(method, params, None).await + } + + pub async fn call_with_deadline( + &self, + method: &str, + params: Value, + submission_deadline: Option, ) -> Result { self.reserve_request().await; - let response = self + let request = self .client .post(&self.url) - .json(&json!({"jsonrpc":"2.0","id":1,"method":method,"params":params})) + .json(&json!({"jsonrpc":"2.0","id":1,"method":method,"params":params})); + self.check_submission_deadline(submission_deadline)?; + let response = request .send() .await .map_err(|_| UsdtError::NetworkUnavailable)?; @@ -172,6 +194,16 @@ impl Rpc { serde_json::from_value(response.result.unwrap_or(Value::Null)).map_err(Into::into) } + pub fn check_submission_deadline(&self, deadline: Option) -> Result<(), UsdtError> { + let now = SystemTime::now(); + #[cfg(test)] + let now = self.clock.lock().unwrap().unwrap_or(now); + if deadline.is_some_and(|deadline| now > deadline) { + return Err(UsdtError::SubmissionDeadlineExpired); + } + Ok(()) + } + async fn reserve_request(&self) { let mut next = self.next_request.lock().await; let scheduled = (*next).max(Instant::now()); @@ -313,6 +345,34 @@ pub(super) async fn bounded_json( mod tests { use super::*; + #[tokio::test(start_paused = true)] + async fn submission_deadline_is_checked_after_rate_limit_wait() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let rpc = Rpc::new(format!("http://{}", listener.local_addr().unwrap()), 42161).unwrap(); + let deadline = std::time::UNIX_EPOCH + Duration::new(2_000_000_000, 123_456_789); + let clock = rpc.clock.clone(); + *clock.lock().unwrap() = Some(deadline); + assert!(rpc.check_submission_deadline(Some(deadline)).is_ok()); + *rpc.next_request.lock().await = Instant::now() + BURST_WINDOW + Duration::from_secs(10); + let submission = tokio::spawn(async move { + rpc.call_with_deadline::("eth_sendUserOperation", json!([]), Some(deadline)) + .await + }); + tokio::task::yield_now().await; + assert!(!submission.is_finished()); + *clock.lock().unwrap() = Some(deadline + Duration::from_nanos(1)); + tokio::time::advance(Duration::from_secs(10)).await; + assert!(matches!( + submission.await.unwrap(), + Err(UsdtError::SubmissionDeadlineExpired) + )); + assert!( + tokio::time::timeout(Duration::from_millis(1), listener.accept()) + .await + .is_err() + ); + } + #[test] fn endpoints_reject_embedded_credentials_and_insecure_remote_hosts() { for url in [ diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index 677c3a9..979646e 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -435,6 +435,9 @@ struct ChainState { paymaster_valid_until: Option, pre_verification_estimates: std::collections::VecDeque, gas_price: u64, + gas_prices: std::collections::VecDeque, + estimation_overrides: Vec, + enforce_simulation_balance: bool, external_outgoing: bool, nonce: u64, balance: alloy_primitives::U256, @@ -500,6 +503,9 @@ impl MockChain { paymaster_valid_until: None, pre_verification_estimates: Default::default(), gas_price: 50_000_000, + gas_prices: Default::default(), + estimation_overrides: vec![], + enforce_simulation_balance: false, external_outgoing: false, nonce: 0, balance: U256::from(10_000_000), @@ -714,6 +720,21 @@ impl ChainState { return json!({"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"Invalid EIP-7702 authorization"}}); } } + if matches!( + body["method"].as_str(), + Some("eth_estimateUserOperationGas" | "pm_getPaymasterData") + ) { + let op: user_operation::UserOperation = + serde_json::from_value(body["params"][0].clone()).unwrap(); + if body["method"] == "eth_estimateUserOperationGas" && !body["params"][2].is_null() { + self.estimation_overrides.push(body["params"][2].clone()); + } else if self.enforce_simulation_balance { + let amount = history::decode_payment(&op.call_data, op.sender).unwrap().1; + if U256::from(amount) + U256::from(50_000) > self.balance { + return json!({"jsonrpc":"2.0","id":1,"error":{"code":-32500,"message":"AA50 postOp reverted"}}); + } + } + } let result = match body["method"].as_str().unwrap() { "bitkit_getBridgeMessages" => self .bridge_messages @@ -837,9 +858,12 @@ impl ChainState { json!(Bytes::from(encoded)) } "pimlico_getTokenQuotes" => { - json!({"quotes":[{"token":types::TOKEN,"paymaster":self.paymaster,"postOpGas":"0xc350","exchangeRate":U256::from(3_000_000_000u64)}]}) + json!({"quotes":[{"token":types::TOKEN,"paymaster":self.paymaster,"postOpGas":"0xc350","balanceSlot":"0x33","exchangeRate":U256::from(3_000_000_000u64)}]}) } "pimlico_getUserOperationGasPrice" => { + if let Some(price) = self.gas_prices.pop_front() { + self.gas_price = price; + } json!({"slow":{"maxFeePerGas":U256::from(self.gas_price * 9 / 10),"maxPriorityFeePerGas":U256::from(100000)},"fast":{"maxFeePerGas":U256::from(self.gas_price),"maxPriorityFeePerGas":U256::from(100000)}}) } "pm_getPaymasterData" | "pm_getPaymasterStubData" => { @@ -1872,6 +1896,7 @@ fn stored_activity_is_complete_and_sorted_newest_first() { .unwrap(); let transfers: Vec<_> = (0..501) .map(|index| UsdtTransfer { + sender: None, id: format!("receipt-{index}"), tx_hash: Some(format!("tx-{index}")), user_operation_hash: None, @@ -2000,6 +2025,16 @@ async fn interrupted_history_resumes_without_repeating_completed_work() { let restored = chain.wallet(&restored_dir); sync_history_to_tip(&restored).await; assert_eq!(restored.history().unwrap().len(), incoming_count); + assert!(restored + .history() + .unwrap() + .iter() + .all(|transfer| transfer.sender.as_deref() + == Some( + alloy_primitives::Address::repeat_byte(8) + .to_checksum(None) + .as_str() + ))); assert_eq!(chain.state.lock().unwrap().block_reads, incoming_count + 1); sync_history_to_tip(&restored).await; assert_eq!(restored.history().unwrap().len(), incoming_count); @@ -2240,6 +2275,7 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending chain.state.lock().unwrap().bridge_delay = Duration::from_secs(25); let bridges: Vec<_> = (0..5) .map(|index| UsdtTransfer { + sender: None, id: format!("bridge-{index}"), tx_hash: Some(format!("{:#x}", alloy_primitives::B256::repeat_byte(index))), user_operation_hash: None, @@ -3909,10 +3945,12 @@ fn orphaned_payments_reopen_in_nonce_order_and_keep_sends_blocked() { operation: serde_json::from_value(vector["operation"].clone()).unwrap(), created_block: 19999, expires_at: wallet::now() + 600, + submission_deadline: None, }; plan.operation.nonce = U256::from(nonce); let (hash, raw) = plan.sign(&key).unwrap(); let mut transfer = UsdtTransfer { + sender: None, id: format!("payment-{nonce}"), tx_hash: None, user_operation_hash: Some(format!("{hash:#x}")), @@ -4664,6 +4702,7 @@ async fn orchestra_refund_receipts_are_exclusive_across_restart_and_pruning() { plan.operation.nonce = U256::from(nonce); let (hash, raw) = plan.sign(&key).unwrap(); let transfer = UsdtTransfer { + sender: None, id: format!("payment-{nonce}"), tx_hash: Some(B256::repeat_byte(nonce + 1).to_string()), user_operation_hash: Some(hash.to_string()), @@ -4811,3 +4850,114 @@ async fn orchestra_refund_receipts_are_exclusive_across_restart_and_pruning() { )); assert_eq!(wallet.export_backup().unwrap(), snapshot); } + +#[tokio::test] +async fn maximum_quote_reserves_fees_and_uses_overrides_only_for_discovery() { + use alloy_primitives::{keccak256, U256}; + let chain = MockChain::start().await; + chain.state.lock().unwrap().enforce_simulation_balance = true; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_max_transfer(RECIPIENT.into(), UsdtDestination::Arbitrum) + .await + .unwrap(); + assert_eq!(quote.amount + quote.maximum_fee, 10_000_000); + assert!(quote.amount > 0 && quote.maximum_fee > 0); + let stored = wallet.store.quote("e.id).unwrap(); + assert_eq!( + history::decode_payment(&stored.plan.operation.call_data, wallet.address) + .unwrap() + .1, + quote.amount + ); + let state = chain.state.lock().unwrap(); + let key = keccak256( + [ + wallet.address.into_word().as_slice(), + U256::from(51).to_be_bytes::<32>().as_slice(), + ] + .concat(), + ); + assert_eq!( + state.estimation_overrides[0], + serde_json::json!({types::TOKEN.to_string(): {"stateDiff": {key.to_string(): format!("{:#066x}", u128::MAX)}}}) + ); + assert!(state.operations.is_empty()); +} + +#[tokio::test] +async fn maximum_quote_reprices_a_changed_fee_and_stops_when_balance_cannot_cover_fees() { + use alloy_primitives::U256; + let chain = MockChain::start().await; + chain.state.lock().unwrap().gas_prices = [50_000_000, 100_000_000, 100_000_000].into(); + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_max_transfer(RECIPIENT.into(), UsdtDestination::Arbitrum) + .await + .unwrap(); + assert_eq!(quote.amount + quote.maximum_fee, 10_000_000); + assert!(quote.maximum_fee > 200_000); + assert_eq!(chain.state.lock().unwrap().estimation_overrides.len(), 2); + chain.state.lock().unwrap().balance = U256::from(1_000); + assert!(matches!( + wallet + .quote_max_transfer(RECIPIENT.into(), UsdtDestination::Arbitrum) + .await, + Err(UsdtError::InsufficientBalance) + )); + assert!(chain.state.lock().unwrap().operations.is_empty()); +} + +#[tokio::test] +async fn maximum_quote_bounds_unstable_fee_retries_without_saving_a_quote() { + let chain = MockChain::start().await; + chain.state.lock().unwrap().gas_prices = [ + 50_000_000, + 100_000_000, + 100_000_000, + 200_000_000, + 200_000_000, + 400_000_000, + ] + .into(); + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + assert!(wallet + .quote_max_transfer(RECIPIENT.into(), UsdtDestination::Arbitrum) + .await + .is_err()); + assert_eq!(chain.state.lock().unwrap().estimation_overrides.len(), 3); + let count: u64 = wallet + .store + .connection() + .unwrap() + .query_row("SELECT COUNT(*) FROM usdt_quotes", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 0); + assert!(wallet.history().unwrap().is_empty()); +} + +#[tokio::test] +async fn maximum_bridge_quote_includes_source_fees_and_compares_complete_route_budgets() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = UsdtWallet::new( + usdt_address(TEST_PHRASE.into(), None).unwrap(), + dir.path().join("usdt.sqlite").to_string_lossy().into(), + chain.url.clone(), + chain.url.clone(), + Some(chain.url.clone()), + std::sync::Arc::new(TestBackup), + ) + .unwrap(); + let quote = wallet + .quote_max_transfer(RECIPIENT.into(), UsdtDestination::Polygon) + .await + .unwrap(); + assert_eq!(quote.amount + quote.maximum_fee, 10_000_000); + assert_eq!(quote.bridge_provider, Some(UsdtBridgeProvider::Usdt0)); + assert!(quote.maximum_fee > 300_000); + assert!(chain.state.lock().unwrap().operations.is_empty()); +} diff --git a/src/modules/usdt/tests/backup.rs b/src/modules/usdt/tests/backup.rs index 6292764..7ffcf1c 100644 --- a/src/modules/usdt/tests/backup.rs +++ b/src/modules/usdt/tests/backup.rs @@ -10,6 +10,7 @@ struct RemoteBackup { unavailable: AtomicBool, writes: AtomicUsize, snapshot: Mutex>, + after_persist: Mutex>>, } #[async_trait::async_trait] @@ -20,10 +21,230 @@ impl UsdtBackup for RemoteBackup { } *self.snapshot.lock().unwrap() = Some(snapshot); self.writes.fetch_add(1, Ordering::SeqCst); + if let Some(after_persist) = self.after_persist.lock().unwrap().as_ref() { + after_persist(); + } Ok(()) } } +#[tokio::test] +async fn expired_submission_deadline_does_not_create_payment() { + let chain = MockChain::start().await; + let directory = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&directory); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let deadline = std::time::SystemTime::now(); + for invalid in ["not-a-timestamp", "2026-10-12T00:00:60Z"] { + assert!(matches!( + wallet + .send_with_deadline( + quote.id.clone(), + TEST_PHRASE.into(), + None, + Some(invalid.into()) + ) + .await, + Err(UsdtError::InvalidSubmissionDeadline) + )); + } + *wallet.rpc.clock.lock().unwrap() = Some(deadline + std::time::Duration::from_nanos(1)); + assert!(matches!( + wallet + .send_with_deadline( + quote.id, + TEST_PHRASE.into(), + None, + Some(chrono::DateTime::::from(deadline).to_rfc3339()) + ) + .await, + Err(UsdtError::SubmissionDeadlineExpired) + )); + assert!(wallet.history().unwrap().is_empty()); + assert!(chain.state.lock().unwrap().operations.is_empty()); +} + +#[tokio::test] +async fn submission_deadline_prevents_first_broadcast_after_backup_failure() { + let chain = MockChain::start().await; + let directory = tempfile::tempdir().unwrap(); + let backup = Arc::new(RemoteBackup::default()); + backup.unavailable.store(true, Ordering::SeqCst); + let wallet = backed_up_wallet(&chain, &directory, backup.clone()); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let deadline = std::time::SystemTime::now(); + *wallet.rpc.clock.lock().unwrap() = Some(deadline); + assert!(matches!( + wallet + .send_with_deadline( + quote.id, + TEST_PHRASE.into(), + None, + Some(chrono::DateTime::::from(deadline).to_rfc3339()) + ) + .await, + Err(UsdtError::BackupUnavailable) + )); + drop(wallet); + let reopened = backed_up_wallet(&chain, &directory, backup.clone()); + *reopened.rpc.clock.lock().unwrap() = Some(deadline + std::time::Duration::from_nanos(1)); + backup.unavailable.store(false, Ordering::SeqCst); + chain.state.lock().unwrap().tip += 3; + assert_eq!( + reopened.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Pending + ); + assert!(chain.state.lock().unwrap().operations.is_empty()); +} + +#[tokio::test] +async fn submission_deadline_passing_during_backup_prevents_dispatch() { + let chain = MockChain::start().await; + let directory = tempfile::tempdir().unwrap(); + let backup = Arc::new(RemoteBackup::default()); + let wallet = backed_up_wallet(&chain, &directory, backup.clone()); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let deadline = std::time::SystemTime::now(); + let clock = wallet.rpc.clock.clone(); + *clock.lock().unwrap() = Some(deadline); + *backup.after_persist.lock().unwrap() = Some(Box::new(move || { + *clock.lock().unwrap() = Some(deadline + std::time::Duration::from_nanos(1)); + })); + assert!(matches!( + wallet + .send_with_deadline( + quote.id, + TEST_PHRASE.into(), + None, + Some(chrono::DateTime::::from(deadline).to_rfc3339()) + ) + .await, + Err(UsdtError::SubmissionDeadlineExpired) + )); + assert_eq!(backup.writes.load(Ordering::SeqCst), 1); + assert!(chain.state.lock().unwrap().operations.is_empty()); + assert_eq!( + wallet.history().unwrap()[0].status, + UsdtTransferStatus::Pending + ); + let snapshot: serde_json::Value = + serde_json::from_str(&backup.snapshot.lock().unwrap().clone().unwrap()).unwrap(); + assert_eq!( + snapshot["transfers"][0]["plan"]["submission_deadline"], + serde_json::to_value(deadline).unwrap() + ); +} + +#[tokio::test] +async fn restored_submission_deadline_blocks_rebroadcast_but_allows_settlement() { + let chain = MockChain::start().await; + let directory = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&directory); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let deadline = std::time::SystemTime::now(); + *wallet.rpc.clock.lock().unwrap() = Some(deadline); + wallet + .send_with_deadline( + quote.id.clone(), + TEST_PHRASE.into(), + None, + Some(chrono::DateTime::::from(deadline).to_rfc3339()), + ) + .await + .unwrap(); + let snapshot = wallet.export_backup().unwrap(); + wallet + .send_with_deadline(quote.id.clone(), TEST_PHRASE.into(), None, None) + .await + .unwrap(); + assert_eq!(wallet.export_backup().unwrap(), snapshot); + drop(wallet); + + let reopened = chain.wallet(&directory); + *reopened.rpc.clock.lock().unwrap() = Some(deadline + std::time::Duration::from_nanos(1)); + chain.state.lock().unwrap().tip += 3; + assert_eq!( + reopened.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Pending + ); + assert_eq!(chain.state.lock().unwrap().operations.len(), 1); + + let restored_directory = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restored_directory); + restored.restore_backup(snapshot).await.unwrap(); + *restored.rpc.clock.lock().unwrap() = Some(deadline + std::time::Duration::from_nanos(1)); + assert_eq!( + restored.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Pending + ); + assert_eq!(chain.state.lock().unwrap().operations.len(), 1); + chain.state.lock().unwrap().mined = true; + assert_eq!( + restored.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Confirmed + ); + assert_eq!(chain.state.lock().unwrap().operations.len(), 1); +} + +#[tokio::test] +async fn backup_merge_preserves_the_earliest_submission_deadline() { + let chain = MockChain::start().await; + let directory = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&directory); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let deadline = std::time::SystemTime::now(); + *wallet.rpc.clock.lock().unwrap() = Some(deadline); + wallet + .send_with_deadline( + quote.id.clone(), + TEST_PHRASE.into(), + None, + Some(chrono::DateTime::::from(deadline).to_rfc3339()), + ) + .await + .unwrap(); + let original: serde_json::Value = + serde_json::from_str(&wallet.export_backup().unwrap()).unwrap(); + let earlier = deadline - std::time::Duration::from_nanos(1); + for (incoming, expected) in [ + ( + Some(deadline + std::time::Duration::from_secs(60)), + deadline, + ), + (None, deadline), + (Some(earlier), earlier), + ] { + let mut snapshot = original.clone(); + snapshot["transfers"][0]["plan"]["submission_deadline"] = + serde_json::to_value(incoming).unwrap(); + wallet.restore_backup(snapshot.to_string()).await.unwrap(); + assert_eq!( + wallet + .store + .pending_plan("e.id) + .unwrap() + .unwrap() + .submission_deadline, + Some(expected) + ); + } +} + fn backed_up_wallet( chain: &MockChain, dir: &tempfile::TempDir, @@ -376,3 +597,43 @@ async fn orchestra_backup_restores_funded_delivery_after_chain_history() { restored.refresh_transfers().await.unwrap(); assert_eq!(chain.state.lock().unwrap().operations.len(), 1); } + +#[tokio::test] +async fn transfer_sender_survives_backup_and_old_snapshots_remain_unknown() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + assert_eq!( + sent.sender.as_deref(), + Some(wallet.receive_address().as_str()) + ); + let snapshot = wallet.export_backup().unwrap(); + let restored_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restored_dir); + restored.restore_backup(snapshot.clone()).await.unwrap(); + assert_eq!(restored.history().unwrap()[0].sender, sent.sender); + let mut old: serde_json::Value = serde_json::from_str(&snapshot).unwrap(); + old["transfers"][0]["transfer"] + .as_object_mut() + .unwrap() + .remove("sender"); + let old_dir = tempfile::tempdir().unwrap(); + let old_wallet = chain.wallet(&old_dir); + old_wallet.restore_backup(old.to_string()).await.unwrap(); + assert_eq!(old_wallet.history().unwrap()[0].sender, None); + restored.restore_backup(old.to_string()).await.unwrap(); + assert_eq!(restored.history().unwrap()[0].sender, sent.sender); + old["transfers"][0]["transfer"]["sender"] = json!(RECIPIENT); + assert!(matches!( + old_wallet.restore_backup(old.to_string()).await, + Err(UsdtError::InvalidBackup) + )); +} diff --git a/src/modules/usdt/transaction.rs b/src/modules/usdt/transaction.rs index 030c280..36cb513 100644 --- a/src/modules/usdt/transaction.rs +++ b/src/modules/usdt/transaction.rs @@ -116,6 +116,8 @@ pub(super) struct Plan { pub operation: UserOperation, pub created_block: u64, pub expires_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub submission_deadline: Option, } impl Plan { diff --git a/src/modules/usdt/types.rs b/src/modules/usdt/types.rs index cfe6647..84ac792 100644 --- a/src/modules/usdt/types.rs +++ b/src/modules/usdt/types.rs @@ -136,6 +136,10 @@ pub struct UsdtOrchestraTransfer { #[derive(Clone, Debug, Serialize, Deserialize, uniffi::Record)] pub struct UsdtTransfer { + /// Source address on Arbitrum, from the token event or this wallet's signed operation. + /// Absent in older history. A bridge contract address does not identify the original depositor. + #[serde(default)] + pub sender: Option, pub id: String, /// Source transaction hash, absent until execution is observed. pub tx_hash: Option, diff --git a/src/modules/usdt/wallet.rs b/src/modules/usdt/wallet.rs index 9e24336..e6b91b7 100644 --- a/src/modules/usdt/wallet.rs +++ b/src/modules/usdt/wallet.rs @@ -21,6 +21,7 @@ use std::sync::{ atomic::{AtomicU64, AtomicUsize, Ordering}, Arc, }; +use std::time::SystemTime; use tokio::{sync::Mutex, time::Instant}; const RECENT_EXECUTION_BLOCKS: u64 = 64; @@ -31,6 +32,13 @@ const QUOTE_LIFETIME_SECONDS: u64 = 120; const BRIDGE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); const BRIDGE_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(60); +struct PreparedCalls { + calls: Vec<(Address, Bytes)>, + received_amount: u64, + bridge_fee: u64, + orchestra: Option, +} + #[derive(uniffi::Object)] pub struct UsdtWallet { pub(super) address: Address, @@ -107,62 +115,23 @@ impl UsdtWallet { amount: u64, destination: UsdtDestination, ) -> Result { - if amount == 0 { - return Err(UsdtError::InvalidAmount); - } - let recipient = super::orchestra::validate_recipient(recipient.trim(), destination)?; - if recipient == self.receive_address() { - return Err(UsdtError::InvalidAddress); - } - self.store.require_no_pending()?; - self.rpc.verify_chain().await?; - self.require_balance(amount, 0).await?; - let direct = self.prepare_quote(&recipient, amount, destination, false); - let routed = async { - if destination == UsdtDestination::Arbitrum - || destination == UsdtDestination::Stable - || self.orchestra.is_none() - { - return Err(UsdtError::UnsupportedRoute); - } - self.prepare_quote(&recipient, amount, destination, true) - .await - }; - let (direct, routed) = tokio::join!(direct, routed); - let fresh = |candidate: Result| { - candidate.and_then(|data| { - if data.quote.expires_at <= now() + 5 { - Err(UsdtError::QuoteExpired) - } else { - Ok(data) - } - }) - }; - let selected = match (fresh(direct), fresh(routed)) { - (Ok(a), Ok(b)) => { - if better_quote(&b.quote, &a.quote)? { - b - } else { - a - } - } - (Ok(a), Err(_)) | (Err(_), Ok(a)) => a, - (Err(a), Err(b)) => { - return Err( - if destination.endpoint().is_some() || destination == UsdtDestination::Arbitrum - { - a - } else { - b - }, - ) - } - }; - if selected.quote.expires_at <= now() + 5 { - return Err(UsdtError::QuoteExpired); + self.quote_payment(recipient, amount, destination, false) + .await + } + + /// Quotes the available balance less a conservative fee reserve. Final fees may leave dust. + /// Returns a normal, funded quote; approval and sending use the same checks as any transfer. + pub async fn quote_max_transfer( + &self, + recipient: String, + destination: UsdtDestination, + ) -> Result { + let balance = self.balance().await?; + if balance == 0 { + return Err(UsdtError::InsufficientBalance); } - self.store.save_quote(&selected)?; - Ok(selected.quote) + self.quote_payment(recipient, balance, destination, true) + .await } /// Available Orchestra destinations. USDT0 destinations retain the app's existing configuration. @@ -180,16 +149,42 @@ impl UsdtWallet { quote_id: String, mnemonic: String, passphrase: Option, + ) -> Result { + self.send_with_deadline(quote_id, mnemonic, passphrase, None) + .await + } + + /// Submits only while the approved deadline is valid, including automatic recovery retries. + /// This limits submission, not chain inclusion. An already submitted payment may still settle. + /// Repeating a quote ID retains its original deadline and returns its stored outcome. + /// The optional deadline is an RFC 3339 timestamp with up to nanosecond precision. + pub async fn send_with_deadline( + &self, + quote_id: String, + mnemonic: String, + passphrase: Option, + submission_deadline: Option, ) -> Result { let mnemonic = zeroize::Zeroizing::new(mnemonic); let passphrase = passphrase.map(zeroize::Zeroizing::new); + let submission_deadline = submission_deadline + .map(|value| { + chrono::DateTime::parse_from_rfc3339(&value) + .ok() + .filter(|value| value.timestamp_subsec_nanos() < 1_000_000_000) + .map(SystemTime::from) + .ok_or(UsdtError::InvalidSubmissionDeadline) + }) + .transpose()?; let _guard = self.operation.lock().await; if let Some(existing) = self.store.transfer("e_id)? { derive_owner_key(mnemonic, passphrase, self.address)?; return Ok(existing); } + self.rpc.check_submission_deadline(submission_deadline)?; self.store.require_no_pending()?; let mut data = self.store.quote("e_id)?; + data.plan.submission_deadline = submission_deadline; if data.quote.expires_at <= now() + 5 { return Err(UsdtError::QuoteExpired); } @@ -221,6 +216,7 @@ impl UsdtWallet { let (hash, raw) = data.plan.sign(&key)?; drop(key); let mut transfer = UsdtTransfer { + sender: Some(self.receive_address()), id: quote_id, tx_hash: None, user_operation_hash: Some(format!("{hash:#x}")), @@ -239,6 +235,9 @@ impl UsdtWallet { self.persist_backup().await?; // After persistence a lost response is indeterminate. Retry only the identical signed operation. if let Err(error) = self.broadcast(&data.plan, hash).await { + if matches!(error, UsdtError::SubmissionDeadlineExpired) { + return Err(error); + } // These errors occur before submission; later retries may already be queued. if matches!( error, @@ -330,6 +329,138 @@ impl UsdtWallet { } impl UsdtWallet { + async fn quote_payment( + &self, + recipient: String, + amount: u64, + destination: UsdtDestination, + maximum: bool, + ) -> Result { + if amount == 0 { + return Err(UsdtError::InvalidAmount); + } + let recipient = super::orchestra::validate_recipient(recipient.trim(), destination)?; + if recipient == self.receive_address() { + return Err(UsdtError::InvalidAddress); + } + self.store.require_no_pending()?; + self.rpc.verify_chain().await?; + self.require_balance(amount, 0).await?; + let recipient = recipient.as_str(); + let candidate = |use_orchestra| async move { + if maximum { + self.prepare_max_quote(recipient, amount, destination, use_orchestra) + .await + } else { + self.prepare_quote(recipient, amount, destination, use_orchestra) + .await + } + }; + let direct = candidate(false); + let routed = async { + if destination == UsdtDestination::Arbitrum + || destination == UsdtDestination::Stable + || self.orchestra.is_none() + { + return Err(UsdtError::UnsupportedRoute); + } + candidate(true).await + }; + let (direct, routed) = tokio::join!(direct, routed); + let fresh = |candidate: Result| { + candidate.and_then(|data| { + if data.quote.expires_at <= now() + 5 { + Err(UsdtError::QuoteExpired) + } else { + Ok(data) + } + }) + }; + let selected = match (fresh(direct), fresh(routed)) { + (Ok(a), Ok(b)) => { + if better_quote(&b.quote, &a.quote)? { + b + } else { + a + } + } + (Ok(a), Err(_)) | (Err(_), Ok(a)) => a, + (Err(a), Err(b)) => { + return Err( + if destination.endpoint().is_some() || destination == UsdtDestination::Arbitrum + { + a + } else { + b + }, + ) + } + }; + if selected.quote.expires_at <= now() + 5 { + return Err(UsdtError::QuoteExpired); + } + self.store.save_quote(&selected)?; + Ok(selected.quote) + } + + async fn prepare_max_quote( + &self, + recipient: &str, + balance: u64, + destination: UsdtDestination, + use_orchestra: bool, + ) -> Result { + let mut reserve = 0; + let mut last_error = UsdtError::QuoteExpired; + for _ in 0..3 { + let amount = balance + .checked_sub(reserve) + .filter(|amount| *amount > 0) + .ok_or(UsdtError::InsufficientBalance)?; + let payment = self + .prepare_calls(recipient, amount, destination, use_orchestra) + .await?; + let (nonce, authorization) = + tokio::try_join!(self.nonce("latest"), self.authorization())?; + let fee = self + .paymaster + .estimate_maximum_fee(self.address, nonce, authorization, &payment.calls) + .await? + .checked_add(payment.bridge_fee) + .ok_or(UsdtError::InvalidAmount)?; + reserve = reserve.max(fee); + let amount = balance + .checked_sub(reserve) + .filter(|amount| *amount > 0) + .ok_or(UsdtError::InsufficientBalance)?; + match self + .prepare_quote(recipient, amount, destination, use_orchestra) + .await + { + Ok(mut data) => { + if data.quote.maximum_fee > reserve { + reserve = data.quote.maximum_fee; + continue; + } + // The reserve is an approval ceiling, never a fee prediction or a promise of zero dust. + data.quote.maximum_fee = reserve; + self.require_balance(amount, reserve).await?; + return Ok(data); + } + Err(error) + if matches!( + error, + UsdtError::InsufficientBalance | UsdtError::QuoteExpired + ) || matches!(&error, UsdtError::TransactionRejected { reason } if reason.contains("AA50") || reason == "Paymaster fee collection failed") => + { + last_error = error; + } + Err(error) => return Err(error), + } + } + Err(last_error) + } + async fn persist_backup(&self) -> Result<(), UsdtError> { let snapshot = self.export_backup()?; let hash = keccak256(snapshot.as_bytes()); @@ -341,13 +472,13 @@ impl UsdtWallet { Ok(()) } - async fn prepare_quote( + async fn prepare_calls( &self, recipient: &str, amount: u64, destination: UsdtDestination, use_orchestra: bool, - ) -> Result { + ) -> Result { let orchestra = if use_orchestra { Some( self.orchestra @@ -380,9 +511,34 @@ impl UsdtWallet { self.transfer_calls(parse_address(recipient)?, amount, destination) .await? }; - let nonce = self.nonce("latest").await?; - let authorization = self.authorization().await?; - let created_block = self.block_number().await?; + Ok(PreparedCalls { + calls, + received_amount, + bridge_fee, + orchestra, + }) + } + + async fn prepare_quote( + &self, + recipient: &str, + amount: u64, + destination: UsdtDestination, + use_orchestra: bool, + ) -> Result { + let PreparedCalls { + calls, + received_amount, + bridge_fee, + orchestra, + } = self + .prepare_calls(recipient, amount, destination, use_orchestra) + .await?; + let (nonce, authorization, created_block) = tokio::try_join!( + self.nonce("latest"), + self.authorization(), + self.block_number() + )?; let timestamp = self.block_timestamp(created_block).await?; let (operation, gas_fee, operation_expires_at) = self .paymaster @@ -427,6 +583,7 @@ impl UsdtWallet { orchestra, created_block, expires_at: operation_expires_at, + submission_deadline: None, }, }) } @@ -864,9 +1021,10 @@ impl UsdtWallet { let result: Result = self .paymaster .rpc - .call( + .call_with_deadline( "eth_sendUserOperation", json!([plan.operation, ENTRY_POINT]), + plan.submission_deadline, ) .await; match result {