From c23994ca0458cd4d6c4fd29487d53bac8a2abc76 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 24 Sep 2026 19:12:17 +0300 Subject: [PATCH 1/6] feat: add outbound usdt0 bridging --- bindings/ios/bitkitcore.swift | 155 +++++++++++- bindings/ios/bitkitcoreFFI.h | 2 +- src/lib.rs | 5 +- src/modules/usdt/README.md | 16 +- src/modules/usdt/history.rs | 52 +++- src/modules/usdt/paymaster.rs | 7 + src/modules/usdt/rpc.rs | 69 ++++- src/modules/usdt/store.rs | 19 +- src/modules/usdt/tests.rs | 430 ++++++++++++++++++++++++++++---- src/modules/usdt/transaction.rs | 41 +++ src/modules/usdt/types.rs | 29 +++ src/modules/usdt/wallet.rs | 248 ++++++++++++++++-- 12 files changed, 976 insertions(+), 97 deletions(-) diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 4c409a7..76ccf5c 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -2569,7 +2569,7 @@ public protocol UsdtWalletProtocol: AnyObject, Sendable { func history() throws -> [UsdtTransfer] - func quoteTransfer(recipient: String, amount: UInt64) async throws -> UsdtQuote + func quoteTransfer(recipient: String, amount: UInt64, destination: UsdtDestination) async throws -> UsdtQuote func receiveAddress() -> String @@ -2669,13 +2669,13 @@ open func history()throws -> [UsdtTransfer] { }) } -open func quoteTransfer(recipient: String, amount: UInt64)async throws -> UsdtQuote { +open func quoteTransfer(recipient: String, amount: UInt64, destination: UsdtDestination)async throws -> UsdtQuote { return try await uniffiRustCallAsync( rustFutureFunc: { uniffi_bitkitcore_fn_method_usdtwallet_quote_transfer( self.uniffiClonePointer(), - FfiConverterString.lower(recipient),FfiConverterUInt64.lower(amount) + FfiConverterString.lower(recipient),FfiConverterUInt64.lower(amount),FfiConverterTypeUsdtDestination_lower(destination) ) }, pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, @@ -15978,16 +15978,20 @@ public func FfiConverterTypeUsdtPaymentRequest_lower(_ value: UsdtPaymentRequest public struct UsdtQuote { public var id: String public var recipient: String + public var destination: UsdtDestination public var amount: UInt64 + public var receivedAmount: UInt64 public var maximumFee: UInt64 public var expiresAt: UInt64 // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, recipient: String, amount: UInt64, maximumFee: UInt64, expiresAt: UInt64) { + public init(id: String, recipient: String, destination: UsdtDestination, amount: UInt64, receivedAmount: UInt64, maximumFee: UInt64, expiresAt: UInt64) { self.id = id self.recipient = recipient + self.destination = destination self.amount = amount + self.receivedAmount = receivedAmount self.maximumFee = maximumFee self.expiresAt = expiresAt } @@ -16006,9 +16010,15 @@ extension UsdtQuote: Equatable, Hashable { if lhs.recipient != rhs.recipient { return false } + if lhs.destination != rhs.destination { + return false + } if lhs.amount != rhs.amount { return false } + if lhs.receivedAmount != rhs.receivedAmount { + return false + } if lhs.maximumFee != rhs.maximumFee { return false } @@ -16021,7 +16031,9 @@ extension UsdtQuote: Equatable, Hashable { public func hash(into hasher: inout Hasher) { hasher.combine(id) hasher.combine(recipient) + hasher.combine(destination) hasher.combine(amount) + hasher.combine(receivedAmount) hasher.combine(maximumFee) hasher.combine(expiresAt) } @@ -16040,7 +16052,9 @@ public struct FfiConverterTypeUsdtQuote: FfiConverterRustBuffer { try UsdtQuote( id: FfiConverterString.read(from: &buf), recipient: FfiConverterString.read(from: &buf), + destination: FfiConverterTypeUsdtDestination.read(from: &buf), amount: FfiConverterUInt64.read(from: &buf), + receivedAmount: FfiConverterUInt64.read(from: &buf), maximumFee: FfiConverterUInt64.read(from: &buf), expiresAt: FfiConverterUInt64.read(from: &buf) ) @@ -16049,7 +16063,9 @@ public struct FfiConverterTypeUsdtQuote: FfiConverterRustBuffer { public static func write(_ value: UsdtQuote, into buf: inout [UInt8]) { FfiConverterString.write(value.id, into: &buf) FfiConverterString.write(value.recipient, into: &buf) + FfiConverterTypeUsdtDestination.write(value.destination, into: &buf) FfiConverterUInt64.write(value.amount, into: &buf) + FfiConverterUInt64.write(value.receivedAmount, into: &buf) FfiConverterUInt64.write(value.maximumFee, into: &buf) FfiConverterUInt64.write(value.expiresAt, into: &buf) } @@ -16075,7 +16091,9 @@ public struct UsdtTransfer { public var id: String public var txHash: String public var userOperationHash: String? + public var bridgeGuid: String? public var recipient: String + public var destination: UsdtDestination public var amount: UInt64 public var receivedAmount: UInt64 public var fee: UInt64? @@ -16086,11 +16104,13 @@ public struct UsdtTransfer { // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, txHash: String, userOperationHash: String?, recipient: String, amount: UInt64, receivedAmount: UInt64, fee: UInt64?, isIncoming: Bool, status: UsdtTransferStatus, timestamp: UInt64, explorerUrl: String) { + public init(id: String, txHash: String, userOperationHash: String?, bridgeGuid: String?, recipient: String, destination: UsdtDestination, amount: UInt64, receivedAmount: UInt64, fee: UInt64?, isIncoming: Bool, status: UsdtTransferStatus, timestamp: UInt64, explorerUrl: String) { self.id = id self.txHash = txHash self.userOperationHash = userOperationHash + self.bridgeGuid = bridgeGuid self.recipient = recipient + self.destination = destination self.amount = amount self.receivedAmount = receivedAmount self.fee = fee @@ -16117,9 +16137,15 @@ extension UsdtTransfer: Equatable, Hashable { if lhs.userOperationHash != rhs.userOperationHash { return false } + if lhs.bridgeGuid != rhs.bridgeGuid { + return false + } if lhs.recipient != rhs.recipient { return false } + if lhs.destination != rhs.destination { + return false + } if lhs.amount != rhs.amount { return false } @@ -16148,7 +16174,9 @@ extension UsdtTransfer: Equatable, Hashable { hasher.combine(id) hasher.combine(txHash) hasher.combine(userOperationHash) + hasher.combine(bridgeGuid) hasher.combine(recipient) + hasher.combine(destination) hasher.combine(amount) hasher.combine(receivedAmount) hasher.combine(fee) @@ -16173,7 +16201,9 @@ public struct FfiConverterTypeUsdtTransfer: FfiConverterRustBuffer { id: FfiConverterString.read(from: &buf), txHash: FfiConverterString.read(from: &buf), userOperationHash: FfiConverterOptionString.read(from: &buf), + bridgeGuid: FfiConverterOptionString.read(from: &buf), recipient: FfiConverterString.read(from: &buf), + destination: FfiConverterTypeUsdtDestination.read(from: &buf), amount: FfiConverterUInt64.read(from: &buf), receivedAmount: FfiConverterUInt64.read(from: &buf), fee: FfiConverterOptionUInt64.read(from: &buf), @@ -16188,7 +16218,9 @@ public struct FfiConverterTypeUsdtTransfer: FfiConverterRustBuffer { FfiConverterString.write(value.id, into: &buf) FfiConverterString.write(value.txHash, into: &buf) FfiConverterOptionString.write(value.userOperationHash, into: &buf) + FfiConverterOptionString.write(value.bridgeGuid, into: &buf) FfiConverterString.write(value.recipient, into: &buf) + FfiConverterTypeUsdtDestination.write(value.destination, into: &buf) FfiConverterUInt64.write(value.amount, into: &buf) FfiConverterUInt64.write(value.receivedAmount, into: &buf) FfiConverterOptionUInt64.write(value.fee, into: &buf) @@ -23339,6 +23371,99 @@ 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 UsdtDestination { + + case stable + case ethereum + case arbitrum + case polygon + case plasma +} + + +#if compiler(>=6) +extension UsdtDestination: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtDestination: FfiConverterRustBuffer { + typealias SwiftType = UsdtDestination + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtDestination { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .stable + + case 2: return .ethereum + + case 3: return .arbitrum + + case 4: return .polygon + + case 5: return .plasma + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: UsdtDestination, into buf: inout [UInt8]) { + switch value { + + + case .stable: + writeInt(&buf, Int32(1)) + + + case .ethereum: + writeInt(&buf, Int32(2)) + + + case .arbitrum: + writeInt(&buf, Int32(3)) + + + case .polygon: + writeInt(&buf, Int32(4)) + + + case .plasma: + writeInt(&buf, Int32(5)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtDestination_lift(_ buf: RustBuffer) throws -> UsdtDestination { + return try FfiConverterTypeUsdtDestination.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtDestination_lower(_ value: UsdtDestination) -> RustBuffer { + return FfiConverterTypeUsdtDestination.lower(value) +} + + +extension UsdtDestination: Equatable, Hashable {} + +extension UsdtDestination: Codable {} + + + + + + public enum UsdtError: Swift.Error { @@ -23519,6 +23644,8 @@ public enum UsdtTransferStatus { case pending case confirmed case failed + case bridging + case bridgeNeedsAttention case replaced } @@ -23543,7 +23670,11 @@ public struct FfiConverterTypeUsdtTransferStatus: FfiConverterRustBuffer { case 3: return .failed - case 4: return .replaced + case 4: return .bridging + + case 5: return .bridgeNeedsAttention + + case 6: return .replaced default: throw UniffiInternalError.unexpectedEnumCase } @@ -23565,9 +23696,17 @@ public struct FfiConverterTypeUsdtTransferStatus: FfiConverterRustBuffer { writeInt(&buf, Int32(3)) - case .replaced: + case .bridging: writeInt(&buf, Int32(4)) + + case .bridgeNeedsAttention: + writeInt(&buf, Int32(5)) + + + case .replaced: + writeInt(&buf, Int32(6)) + } } } @@ -29663,7 +29802,7 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_usdtwallet_history() != 4617) { return InitializationResult.apiChecksumMismatch } - if (uniffi_bitkitcore_checksum_method_usdtwallet_quote_transfer() != 56645) { + if (uniffi_bitkitcore_checksum_method_usdtwallet_quote_transfer() != 3732) { return InitializationResult.apiChecksumMismatch } if (uniffi_bitkitcore_checksum_method_usdtwallet_receive_address() != 540) { diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index da7fb99..fe19f0f 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -687,7 +687,7 @@ RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_history(void*_Nonnull ptr, Rus #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 +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_quote_transfer(void*_Nonnull ptr, RustBuffer recipient, uint64_t amount, RustBuffer destination ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_RECEIVE_ADDRESS diff --git a/src/lib.rs b/src/lib.rs index 035fb1d..985a20e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -91,8 +91,9 @@ 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, UsdtError, - UsdtPaymentRequest, UsdtQuote, UsdtTransfer, UsdtTransferStatus, UsdtWallet, + usdt_address, usdt_format_amount, usdt_parse_amount, usdt_parse_payment_request, + 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 3f6f9ab..79017f4 100644 --- a/src/modules/usdt/README.md +++ b/src/modules/usdt/README.md @@ -14,7 +14,7 @@ Owned mnemonic/passphrase/seed buffers are zeroized and signing keys are erased ## Quotes and fees -`quote_transfer` takes a raw recipient, positive atomic amount; it never receives signing credentials. Local quotes last at most 120 seconds and newly quoted paymaster terms must expire within 15 minutes. `send` validates the owner, nonces, balance, gas estimates, current gas prices and deadlines before signing the stored plan. Changed terms require a new review; signing cannot raise the approved fee. +`quote_transfer` takes a raw recipient, positive atomic amount and destination; it never receives signing credentials. Local quotes last at most 120 seconds and newly quoted paymaster terms must expire within 15 minutes. `send` validates the owner, nonces, balance, gas estimates, current gas prices and deadlines before signing the stored plan. Changed terms require a new review; signing cannot raise the approved fee. The pinned ERC-20 paymaster collects USDT. Its finite approval includes a 5% margin; the displayed maximum fee comes from signed gas limits and paymaster terms, not the allowance. Call/pre-verification estimates receive 10% execution/L1-data headroom; the charged pre-verification margin is included in the maximum. A residual paymaster allowance can remain and is reset to a finite amount on the next payment. @@ -36,17 +36,21 @@ Payment outcomes and expiry decisions trust the configured chain RPC. A maliciou Storage is wallet-specific and owned by the `UsdtWallet` object. Drop it before deleting its database during an explicit wallet wipe. Async exports use UniFFI's Tokio adapter, preserving cancellation of the polled future; they do not detach sends onto the global runtime used by stateless exports. -## Transport +## Transport and cross-network APIs Both chain and bundler endpoints must be controlled, credential-free HTTPS URLs; HTTP is accepted only on loopback for fixtures. Provider keys belong on the server. Chain/bundler calls share an 80/minute budget with a burst of 20. Responses are bounded to 2 MiB, except protocol-projected receipts up to 16 MiB. The companion service documents provider requirements, receipt projection and deployment limits. +The outbound bridge API supports Ethereum (30101), Polygon (30109), Plasma (30383) and Stable (30396), alongside direct Arbitrum transfers. Native release flows expose Arbitrum only; bridge routes require explicit service enablement and destination acceptance. Plain deposits on another chain are not automatically forwarded. + +Bridges use the pinned OFT and TransactionValueHelper with zero account ETH, a finite USDT approval covering principal/fee, and atomic helper-allowance revocation. The deployed helper requires native liquidity and retains behaviors noted in its OpenZeppelin audit; its verified runtime is not the audit-remediated implementation. Source success means bridging, not delivered. LayerZero status must match the operation GUID/pathway before confirmation; blocked delivery remains visible and never triggers an automatic paid retry. RPC providers see queried addresses; LayerZero Scan sees bridge transaction hashes. + ## Validation and bindings Run `cargo test --locked --lib modules::usdt`; CI runs these deterministic tests. They cover independent signing/address vectors, fee bounds, uncertain submission, nonce recovery and restored history. Fixtures use public test credentials. -For the ignored deployed-contract test, start a fresh Arbitrum Anvil fork on port 18545 and `tests/usdt-fork/provider.mjs` on 18546 after installing its pinned dependencies. Run `cargo test deployed_contracts_collect_usdt_fees_without_account_eth -- --ignored`. The fixture requires Anvil, sets local balances/signing terms and checks deployed bytecode; it does not establish real provider pricing. +For the ignored deployed-contract test, start a fresh Arbitrum Anvil fork on port 18545 and `tests/usdt-fork/provider.mjs` on 18546 after installing its pinned dependencies. Run `cargo test deployed_contracts_collect_usdt_fees_and_revert_failed_bridges_atomically -- --ignored`. The fixture requires Anvil, sets local balances/signing terms and checks deployed bytecode; it does not establish real provider pricing or destination delivery. -To include the service, start it with `NODE_ENV=test ARBITRUM_RPC_URL=http://127.0.0.1:18545 LOCAL_PROVIDER_URL=http://127.0.0.1:18546`, then pass `USDT_FORK_RPC_URL=http://127.0.0.1:3100/v1/usdt/chain-rpc` and `USDT_FORK_BUNDLER_URL=http://127.0.0.1:3100/v1/usdt/rpc` to the ignored test. +To include the service, start it with `USDT_BRIDGE_NETWORKS=ethereum,polygon,plasma,stable NODE_ENV=test ARBITRUM_RPC_URL=http://127.0.0.1:18545 LOCAL_PROVIDER_URL=http://127.0.0.1:18546`, then pass `USDT_FORK_RPC_URL=http://127.0.0.1:3100/v1/usdt/chain-rpc` and `USDT_FORK_BUNDLER_URL=http://127.0.0.1:3100/v1/usdt/rpc` to the ignored test. Build iOS and Android sequentially with the repository scripts; Android temporarily edits the manifest/example. Generated bindings and native artifacts must use the same source. App configuration and local package overrides belong in each native repository's USDT documentation. @@ -58,3 +62,7 @@ Build iOS and Android sequentially with the repository scripts; Android temporar - [Pimlico supported tokens](https://docs.pimlico.io/references/paymaster/erc20-paymaster/supported-tokens) - [Pimlico pricing](https://www.pimlico.io/pricing) - [Pimlico public endpoint limits](https://docs.pimlico.io/references/bundler/public-endpoint) +- [USDT0 documentation](https://docs.usdt0.to/) +- [Transaction helper audit](https://www.openzeppelin.com/news/usdt0-transaction-helper-audit) +- [Verified deployed helper](https://arbitrum.blockscout.com/api/v2/smart-contracts/0xa90f03c856d01f698e7071b393387cd75a8a319a) +- [LayerZero message statuses](https://docs.layerzero.network/v2/tools/layerzeroscan/mainnet/messages/get-messagesstatus) diff --git a/src/modules/usdt/history.rs b/src/modules/usdt/history.rs index b0c2037..274ab58 100644 --- a/src/modules/usdt/history.rs +++ b/src/modules/usdt/history.rs @@ -1,9 +1,9 @@ use super::{ account::{SimpleAccount, ENTRY_POINT}, amount::token_amount, - transaction::{event_data, EntryPoint, Erc20}, - types::{EXPLORER, TOKEN}, - UsdtError, UsdtTransfer, UsdtTransferStatus, UsdtWallet, + transaction::{event_data, BridgeHelper, EntryPoint, Erc20}, + types::{BRIDGE_HELPER, EXPLORER, OFT, TOKEN}, + UsdtDestination, UsdtError, UsdtTransfer, UsdtTransferStatus, UsdtWallet, }; use alloy_primitives::{Address, Bytes, U256}; use alloy_sol_types::{SolCall, SolEvent}; @@ -233,7 +233,9 @@ impl UsdtWallet { id: format!("{hash}:{index}"), tx_hash: hash.into(), user_operation_hash: None, + bridge_guid: None, recipient: event.to.to_checksum(None), + destination: UsdtDestination::Arbitrum, amount: token_amount(event.value)?, received_amount: token_amount(event.value)?, fee: None, @@ -264,8 +266,8 @@ impl UsdtWallet { }; for (event, saved) in owned_operations { let operation_hash = format!("{:#x}", event.userOpHash); - let (recipient, amount) = if let Some(saved) = saved { - (saved.recipient, saved.amount) + let (recipient, amount, destination) = if let Some(saved) = saved { + (saved.recipient, saved.amount, saved.destination) } else { let Some(op) = batch.as_ref().and_then(|batch| { batch @@ -275,16 +277,18 @@ impl UsdtWallet { }) else { continue; }; - let Some((recipient, amount)) = decode_payment(&op.callData)? else { + let Some((recipient, amount, destination)) = decode_payment(&op.callData)? else { continue; }; - (recipient.to_checksum(None), amount) + (recipient.to_checksum(None), amount, destination) }; let mut transfer = UsdtTransfer { id: operation_hash.clone(), tx_hash: hash.into(), user_operation_hash: Some(operation_hash), + bridge_guid: None, recipient, + destination, amount, received_amount: amount, fee: None, @@ -309,7 +313,7 @@ impl UsdtWallet { } } -fn decode_payment(data: &[u8]) -> Result, UsdtError> { +fn decode_payment(data: &[u8]) -> Result, UsdtError> { let Ok(calls) = decode_calls(data) else { return Ok(None); }; @@ -320,10 +324,40 @@ fn decode_payment(data: &[u8]) -> Result, UsdtError> { if target == TOKEN { if let Ok(call) = Erc20::transferCall::abi_decode(&data) { payment_count += 1; - payment = Some((call.recipient, token_amount(call.amount)?)); + payment = Some(( + call.recipient, + token_amount(call.amount)?, + UsdtDestination::Arbitrum, + )); } else if Erc20::approveCall::abi_decode(&data).is_err() { supported = false; } + } else if target == BRIDGE_HELPER { + if let Ok(call) = BridgeHelper::sendCall::abi_decode(&data) { + if call.oft != OFT { + supported = false; + continue; + } + let Some(destination) = [ + UsdtDestination::Ethereum, + UsdtDestination::Polygon, + UsdtDestination::Plasma, + UsdtDestination::Stable, + ] + .into_iter() + .find(|d| d.endpoint() == Some(call.param.dstEid)) else { + supported = false; + continue; + }; + payment_count += 1; + payment = Some(( + Address::from_word(call.param.to), + token_amount(call.param.amountLD)?, + destination, + )); + } else { + supported = false; + } } else { supported = false; } diff --git a/src/modules/usdt/paymaster.rs b/src/modules/usdt/paymaster.rs index 72fafdb..528af37 100644 --- a/src/modules/usdt/paymaster.rs +++ b/src/modules/usdt/paymaster.rs @@ -273,6 +273,13 @@ fn approval_margin(value: U256) -> Result { .ok_or(UsdtError::InvalidResponse) } +pub(super) fn with_margin(value: U256) -> Result { + value + .checked_add(value / U256::from(5)) + .and_then(|value| value.checked_add(U256::from(1))) + .ok_or(UsdtError::InvalidResponse) +} + struct Terms { exchange_rate: U256, post_op_gas: U256, diff --git a/src/modules/usdt/rpc.rs b/src/modules/usdt/rpc.rs index c24fb09..35a3cee 100644 --- a/src/modules/usdt/rpc.rs +++ b/src/modules/usdt/rpc.rs @@ -1,4 +1,4 @@ -use super::UsdtError; +use super::{UsdtError, UsdtTransfer, UsdtTransferStatus}; use alloy_primitives::{Address, Bytes, B256, U256}; use alloy_sol_types::SolCall; use serde::{de::DeserializeOwned, Deserialize}; @@ -18,6 +18,8 @@ pub(super) struct Block { pub(super) struct Rpc { client: reqwest::Client, + #[cfg(test)] + pub(super) bridge_status_url: Option, url: String, chain_id: u64, next_request: Arc>, @@ -39,6 +41,8 @@ impl Rpc { let client = endpoint_client(&url, Duration::from_secs(25))?; Ok(Self { client, + #[cfg(test)] + bridge_status_url: None, url, chain_id, next_request: Arc::new(Mutex::new(Instant::now())), @@ -137,6 +141,69 @@ impl Rpc { *next = scheduled + REQUEST_INTERVAL; } + pub async fn bridge_status( + &self, + transfer: &UsdtTransfer, + ) -> Result { + if transfer.bridge_guid.is_none() { + return Ok(transfer.status); + } + let url = format!( + "https://scan.layerzero-api.com/v1/messages/tx/{}", + transfer.tx_hash + ); + #[cfg(test)] + let url = self + .bridge_status_url + .as_ref() + .map(|base| format!("{base}/{}", transfer.tx_hash)) + .unwrap_or(url); + let response = self + .client + .get(url) + .send() + .await + .map_err(|_| UsdtError::NetworkUnavailable)? + .error_for_status() + .map_err(|_| UsdtError::NetworkUnavailable)?; + let response = bounded_json(response, 2_097_152, UsdtError::InvalidResponse).await?; + let messages = response["data"] + .as_array() + .ok_or(UsdtError::InvalidResponse)?; + let message = messages.iter().find(|message| { + message["guid"].as_str().is_some_and(|guid| { + transfer + .bridge_guid + .as_ref() + .is_some_and(|expected| guid.eq_ignore_ascii_case(expected)) + }) && message["pathway"]["srcEid"].as_u64() == Some(30110) + && message["pathway"]["dstEid"].as_u64() + == transfer.destination.endpoint().map(u64::from) + && message["pathway"]["sender"]["address"] + .as_str() + .is_some_and(|a| a.eq_ignore_ascii_case(&super::types::OFT.to_string())) + && message["source"]["tx"]["txHash"] + .as_str() + .is_some_and(|h| h.eq_ignore_ascii_case(&transfer.tx_hash)) + }); + Ok(match message.and_then(|m| m["status"]["name"].as_str()) { + Some("DELIVERED") => UsdtTransferStatus::Confirmed, + Some( + "FAILED" + | "BLOCKED" + | "PAYLOAD_STORED" + | "APPLICATION_BURNED" + | "APPLICATION_SKIPPED", + ) => UsdtTransferStatus::BridgeNeedsAttention, + _ => transfer.status, + }) + } + + pub async fn balance(&self, address: Address) -> Result { + self.call("eth_getBalance", json!([address, "pending"])) + .await + } + pub async fn block(&self, number: u64) -> Result { self.call("eth_getBlockByNumber", json!([U256::from(number), false])) .await diff --git a/src/modules/usdt/store.rs b/src/modules/usdt/store.rs index 6c613eb..a124d32 100644 --- a/src/modules/usdt/store.rs +++ b/src/modules/usdt/store.rs @@ -135,6 +135,8 @@ impl Store { UsdtTransferStatus::Confirmed | UsdtTransferStatus::Failed | UsdtTransferStatus::Replaced + | UsdtTransferStatus::Bridging + | UsdtTransferStatus::BridgeNeedsAttention ); let mut connection = self.connection()?; let tx = connection.transaction()?; @@ -262,6 +264,16 @@ impl Store { if let Some(data) = existing { let saved: UsdtTransfer = decode(&data)?; transfer.id = saved.id; + if saved.tx_hash == transfer.tx_hash + && saved.bridge_guid == transfer.bridge_guid + && transfer.status == UsdtTransferStatus::Bridging + && matches!( + saved.status, + UsdtTransferStatus::Confirmed | UsdtTransferStatus::BridgeNeedsAttention + ) + { + transfer.status = saved.status; + } tx.execute( "UPDATE usdt_transfers SET data=?1, raw=NULL WHERE id=?2", params![serde_json::to_string(&transfer)?, transfer.id], @@ -287,7 +299,12 @@ impl Store { let mut result = Vec::new(); for row in rows { let transfer: UsdtTransfer = decode(&row?)?; - if transfer.status == UsdtTransferStatus::Pending { + if matches!( + transfer.status, + UsdtTransferStatus::Pending + | UsdtTransferStatus::Bridging + | UsdtTransferStatus::BridgeNeedsAttention + ) { result.push(transfer); } } diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index 0b2984f..31f0710 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -230,6 +230,7 @@ struct ChainState { reject_broadcast: bool, delay_gas_estimate: bool, paymaster: alloy_primitives::Address, + helper_balance: alloy_primitives::U256, history_input: Option, history_target: Option, receipt_logs: Option>, @@ -278,6 +279,7 @@ impl MockChain { reject_broadcast: false, delay_gas_estimate: false, paymaster: paymaster::PAYMASTER, + helper_balance: U256::from(1_000_000_000_000_000u64), history_input: None, history_target: Some(account::ENTRY_POINT), receipt_logs: None, @@ -389,9 +391,11 @@ impl ChainState { } "eth_getCode" => json!(self.account_code), "eth_getTransactionCount" => json!(U256::from(self.authorization_nonce)), + "eth_getBalance" => json!(self.helper_balance), "eth_call" => { let data: Bytes = serde_json::from_value(body["params"][0]["data"].clone()).unwrap(); + use transaction::{BridgeHelper, MessagingFee, OFTLimit, OFTReceipt, Oft}; let encoded = if data.starts_with(&transaction::EntryPoint::getNonceCall::SELECTOR) { let block = serde_json::from_value::(body["params"][1].clone()).ok(); @@ -405,6 +409,37 @@ impl ChainState { self.nonce }; U256::from(nonce).abi_encode() + } else if data.starts_with(&Oft::tokenCall::SELECTOR) { + types::TOKEN.abi_encode() + } else if data.starts_with(&Oft::peersCall::SELECTOR) { + alloy_primitives::B256::repeat_byte(1).abi_encode() + } else if data.starts_with(&Oft::quoteOFTCall::SELECTOR) { + let param = Oft::quoteOFTCall::abi_decode(&data).unwrap().param; + ( + OFTLimit { + minAmountLD: U256::from(1), + maxAmountLD: U256::MAX, + }, + Vec::::new(), + OFTReceipt { + amountSentLD: param.amountLD, + amountReceivedLD: param.amountLD, + }, + ) + .abi_encode_params() + } else if data.starts_with(&Oft::quoteSendCall::SELECTOR) { + MessagingFee { + nativeFee: U256::from(10_000_000_000u64), + lzTokenFee: U256::ZERO, + } + .abi_encode() + } else if data.starts_with(&BridgeHelper::maxGasCall::SELECTOR) { + U256::from(1_000_000_000_000_000u64).abi_encode() + } else if data.starts_with(&BridgeHelper::quoteSendCall::SELECTOR) { + let param = BridgeHelper::quoteSendCall::abi_decode(&data) + .unwrap() + .param; + (param.amountLD + U256::from(300_000)).abi_encode() } else { self.balance.abi_encode() }; @@ -643,13 +678,13 @@ async fn signed_operation_survives_uncertain_broadcast_and_restart() { let wallet = chain.wallet(&dir); let quote = tokio::time::timeout( std::time::Duration::from_secs(3), - wallet.quote_transfer(RECIPIENT.into(), 1_000_000), + wallet.quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum), ) .await .expect("An idle wallet must quote without a fixed per-request delay") .unwrap(); let next = wallet - .quote_transfer(RECIPIENT.into(), 2_000_000) + .quote_transfer(RECIPIENT.into(), 2_000_000, UsdtDestination::Arbitrum) .await .unwrap(); chain.state.lock().unwrap().reject_broadcast = true; @@ -682,7 +717,9 @@ async fn signed_operation_survives_uncertain_broadcast_and_restart() { // A pending payment is rejected locally even if the provider is now misconfigured. chain.state.lock().unwrap().chain = 1; assert!(matches!( - wallet.quote_transfer(RECIPIENT.into(), 2_000_000).await, + wallet + .quote_transfer(RECIPIENT.into(), 2_000_000, UsdtDestination::Arbitrum) + .await, Err(UsdtError::PendingTransfer) )); assert!(matches!( @@ -716,7 +753,7 @@ async fn fluctuating_gas_estimates_preserve_the_reviewed_operation() { chain.state.lock().unwrap().pre_verification_estimates = [80_000, 80_100, 80_200, 80_300].into(); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); let reviewed = wallet.store.quote("e.id).unwrap().plan.operation; @@ -756,12 +793,14 @@ async fn wrong_network_owner_nonce_balance_and_paymaster_cannot_sign() { types::TOKEN.to_checksum(None) ); assert!(matches!( - wallet.quote_transfer(request, 1_000_000).await, + wallet + .quote_transfer(request, 1_000_000, UsdtDestination::Arbitrum) + .await, Err(UsdtError::InvalidAddress) )); } let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); assert!(matches!( @@ -803,7 +842,9 @@ async fn wrong_network_owner_nonce_balance_and_paymaster_cannot_sign() { state.paymaster = Address::repeat_byte(1); } assert!(matches!( - wallet.quote_transfer(RECIPIENT.into(), 1_000_000).await, + wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await, Err(UsdtError::UnsupportedRoute) )); assert!(chain.state.lock().unwrap().operations.is_empty()); @@ -815,7 +856,7 @@ async fn expired_unmined_operation_releases_nonce_for_a_new_approval() { let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); wallet @@ -833,7 +874,9 @@ async fn expired_unmined_operation_releases_nonce_for_a_new_approval() { UsdtTransferStatus::Pending ); assert!(matches!( - wallet.quote_transfer(RECIPIENT.into(), 1_000_000).await, + wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await, Err(UsdtError::PendingTransfer) )); chain.state.lock().unwrap().timestamp += alloy_primitives::U256::from(421); @@ -842,7 +885,7 @@ async fn expired_unmined_operation_releases_nonce_for_a_new_approval() { UsdtTransferStatus::Failed ); let next = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); assert_eq!( @@ -855,13 +898,63 @@ async fn expired_unmined_operation_releases_nonce_for_a_new_approval() { ); } +#[tokio::test] +async fn bridge_payment_bounds_token_fees_and_revokes_helper_approval() { + use alloy_primitives::U256; + use alloy_sol_types::SolCall; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) + .await + .unwrap(); + let plan = wallet.store.quote("e.id).unwrap().plan; + let calls = history::decode_calls(&plan.operation.call_data).unwrap(); + assert_eq!(calls.len(), 4); + let paymaster = transaction::Erc20::approveCall::abi_decode(&calls[0].1).unwrap(); + assert_eq!(calls[0].0, types::TOKEN); + assert_eq!(paymaster.spender, paymaster::PAYMASTER); + let approval = transaction::Erc20::approveCall::abi_decode(&calls[1].1).unwrap(); + assert_eq!(calls[1].0, types::TOKEN); + assert_eq!(approval.spender, types::BRIDGE_HELPER); + assert_eq!(approval.amount, U256::from(1_360_001)); + let send = transaction::BridgeHelper::sendCall::abi_decode(&calls[2].1).unwrap(); + assert_eq!(calls[2].0, types::BRIDGE_HELPER); + assert_eq!(send.oft, types::OFT); + assert_eq!(send.param.dstEid, 30109); + assert_eq!( + send.param.to, + RECIPIENT + .parse::() + .unwrap() + .into_word() + ); + assert_eq!(send.param.minAmountLD, U256::from(1_000_000)); + assert_eq!(send.fee.nativeFee, U256::from(10_000_000_000u64)); + let revoke = transaction::Erc20::approveCall::abi_decode(&calls[3].1).unwrap(); + assert_eq!(revoke.spender, types::BRIDGE_HELPER); + assert!(revoke.amount.is_zero()); + let bridge_fee = approval.amount.to::() - quote.amount; + assert_eq!(bridge_fee, 360_001); + assert!(paymaster.amount > U256::from(quote.maximum_fee - bridge_fee)); + assert_eq!(quote.received_amount, 1_000_000); + chain.state.lock().unwrap().helper_balance = U256::ZERO; + assert!(matches!( + wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) + .await, + Err(UsdtError::UnsupportedRoute) + )); +} + #[tokio::test] async fn seed_restore_recovers_mined_payments_without_local_submission_data() { 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) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); let sent = wallet @@ -889,7 +982,7 @@ async fn seed_restore_recovers_mined_payments_without_local_submission_data() { } #[tokio::test] -async fn bundled_operations_cannot_contribute_another_payments_fee() { +async fn bundled_operations_cannot_contribute_another_payments_bridge_status_or_fee() { use alloy_primitives::{B256, U256}; use alloy_sol_types::SolEvent; use serde_json::json; @@ -897,7 +990,7 @@ async fn bundled_operations_cannot_contribute_another_payments_fee() { let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) .await .unwrap(); let mut transfer = wallet @@ -911,6 +1004,7 @@ async fn bundled_operations_cannot_contribute_another_payments_fee() { .parse() .unwrap(); let other_hash = B256::repeat_byte(2); + let guid = B256::repeat_byte(3); let event = |hash| { transaction::EntryPoint::UserOperationEvent { userOpHash: hash, @@ -925,7 +1019,8 @@ async fn bundled_operations_cannot_contribute_another_payments_fee() { }; let log = |address, data: alloy_primitives::LogData| json!({"address":address,"topics":data.topics(),"data":data.data}); let receipt = json!({"logs":[ - log(paymaster::PAYMASTER, transaction::Paymaster::UserOperationSponsored { userOpHash:other_hash, user:wallet.address, paymasterMode:1, token:types::TOKEN, tokenAmountPaid:U256::from(500), exchangeRate:U256::from(1) }.encode_log_data()), + log(types::OFT, transaction::Oft::OFTSent { guid, dstEid:30109, fromAddress:types::BRIDGE_HELPER, amountSentLD:U256::from(1_000_000), amountReceivedLD:U256::from(1_000_000) }.encode_log_data()), + log(types::BRIDGE_HELPER, transaction::BridgeHelper::LogSend { sender:wallet.address, oft:types::OFT, amountLD:U256::from(1_000_000), nativeFee:U256::from(100), feeInToken:U256::from(500), totalAmount:U256::from(1_000_500) }.encode_log_data()), log(account::ENTRY_POINT, event(other_hash)), log(paymaster::PAYMASTER, transaction::Paymaster::UserOperationSponsored { userOpHash:own_hash, user:wallet.address, paymasterMode:1, token:types::TOKEN, tokenAmountPaid:U256::from(123), exchangeRate:U256::from(1) }.encode_log_data()), log(account::ENTRY_POINT, event(own_hash)), @@ -933,14 +1028,25 @@ async fn bundled_operations_cannot_contribute_another_payments_fee() { wallet.settle(&mut transfer, &receipt, false).unwrap(); assert_eq!(transfer.status, UsdtTransferStatus::Failed); assert_eq!(transfer.fee, Some(123)); + assert_eq!(transfer.bridge_guid, None); wallet.settle(&mut transfer, &receipt, true).unwrap(); - assert_eq!(transfer.status, UsdtTransferStatus::Confirmed); + assert_eq!(transfer.status, UsdtTransferStatus::BridgeNeedsAttention); + assert_eq!(transfer.bridge_guid, None); assert_eq!(transfer.fee, Some(123)); + + let mut receipt = receipt; + let bridge_log = receipt["logs"][1].clone(); + receipt["logs"] + .as_array_mut() + .unwrap() + .insert(4, bridge_log); + wallet.settle(&mut transfer, &receipt, true).unwrap(); + assert_eq!(transfer.fee, Some(623)); } #[tokio::test] #[ignore = "requires a fresh local Arbitrum fork and tests/usdt-fork/provider.mjs"] -async fn deployed_contracts_collect_usdt_fees_without_account_eth() { +async fn deployed_contracts_collect_usdt_fees_and_revert_failed_bridges_atomically() { use alloy_primitives::U256; use serde_json::json; let rpc = rpc::Rpc::new("http://127.0.0.1:18545".into(), types::CHAIN_ID).unwrap(); @@ -954,12 +1060,7 @@ async fn deployed_contracts_collect_usdt_fees_without_account_eth() { std::env::var("USDT_FORK_BUNDLER_URL").unwrap_or_else(|_| "http://127.0.0.1:18546".into()), ) .unwrap(); - assert_eq!( - rpc.call::("eth_getBalance", json!([wallet.address, "pending"])) - .await - .unwrap(), - U256::ZERO - ); + assert_eq!(rpc.balance(wallet.address).await.unwrap(), U256::ZERO); let initial = wallet.balance().await.unwrap(); // Only locally mined transactions belong to this fixture's history. wallet @@ -967,7 +1068,7 @@ async fn deployed_contracts_collect_usdt_fees_without_account_eth() { .complete_history(wallet.block_number().await.unwrap()) .unwrap(); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); let sent = wallet @@ -980,12 +1081,67 @@ async fn deployed_contracts_collect_usdt_fees_without_account_eth() { let fee = transfer.fee.unwrap(); assert!(fee > 0 && fee <= quote.maximum_fee); assert_eq!(wallet.balance().await.unwrap(), initial - 1_000_000 - fee); + + let bridge = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) + .await + .unwrap(); + let helper_balance = rpc.balance(types::BRIDGE_HELPER).await.unwrap(); + let _: serde_json::Value = rpc + .call("anvil_setBalance", json!([types::BRIDGE_HELPER, "0x0"])) + .await + .unwrap(); + let before = wallet.balance().await.unwrap(); + let sent = wallet + .send(bridge.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let history = wallet.refresh_transfers().await.unwrap(); + let failed = history.iter().find(|t| t.id == sent.id).unwrap(); + assert_eq!(failed.status, UsdtTransferStatus::Failed); + assert!(failed.bridge_guid.is_none()); + assert!(before - wallet.balance().await.unwrap() <= bridge.maximum_fee); + let _: serde_json::Value = rpc + .call( + "anvil_setBalance", + json!([types::BRIDGE_HELPER, helper_balance]), + ) + .await + .unwrap(); + + let bridge = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) + .await + .unwrap(); + let before = wallet.balance().await.unwrap(); + let sent = wallet + .send(bridge.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let history = wallet.refresh_transfers().await.unwrap(); + let bridged = history.iter().find(|t| t.id == sent.id).unwrap(); + assert_eq!(bridged.status, UsdtTransferStatus::Bridging); + assert!(bridged.bridge_guid.is_some()); + assert!(bridged.fee.unwrap() > 0); + assert!(bridged.fee.unwrap() <= bridge.maximum_fee); + sync_history_to_tip(&wallet).await; assert_eq!( - rpc.call::("eth_getBalance", json!([wallet.address, "pending"])) - .await - .unwrap(), - U256::ZERO + wallet.balance().await.unwrap(), + before - 1_000_000 - bridged.fee.unwrap() ); + alloy_sol_types::sol! { function allowance(address owner, address spender) view returns (uint256); } + let allowance = rpc + .contract( + types::TOKEN, + allowanceCall { + owner: wallet.address, + spender: types::BRIDGE_HELPER, + }, + ) + .await + .unwrap(); + assert!(allowance.is_zero()); + assert_eq!(rpc.balance(wallet.address).await.unwrap(), U256::ZERO); } #[tokio::test] @@ -996,7 +1152,7 @@ async fn history_preserves_receipts_with_external_account_call_shapes() { let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); wallet @@ -1059,7 +1215,7 @@ async fn wrapped_history_preserves_signed_payments_and_restores_token_transfers( let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); let sent = wallet @@ -1156,7 +1312,9 @@ fn stored_activity_is_complete_and_sorted_newest_first() { id: format!("receipt-{index}"), tx_hash: format!("tx-{index}"), user_operation_hash: None, + bridge_guid: None, recipient: RECIPIENT.into(), + destination: UsdtDestination::Arbitrum, amount: 1, received_amount: 1, fee: None, @@ -1179,14 +1337,20 @@ async fn nonce_advance_with_delayed_logs_remains_pending_and_history_reconciles( let chain = MockChain::start().await; let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); - for recipient in [wallet.receive_address(), types::TOKEN.to_checksum(None)] { + for (recipient, destination) in [ + (wallet.receive_address(), UsdtDestination::Arbitrum), + (wallet.receive_address(), UsdtDestination::Ethereum), + (types::TOKEN.to_checksum(None), UsdtDestination::Arbitrum), + ] { assert!(matches!( - wallet.quote_transfer(recipient, 1_000_000).await, + wallet + .quote_transfer(recipient, 1_000_000, destination) + .await, Err(UsdtError::InvalidAddress) )); } let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); let sent = wallet @@ -1210,7 +1374,9 @@ async fn nonce_advance_with_delayed_logs_remains_pending_and_history_reconciles( ); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); assert!(matches!( - wallet.quote_transfer(RECIPIENT.into(), 1_000_000).await, + wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await, Err(UsdtError::PendingTransfer) )); { @@ -1233,7 +1399,7 @@ async fn interrupted_history_resumes_without_repeating_completed_work() { let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); wallet @@ -1286,7 +1452,7 @@ async fn replacement_after_expiry_recovers_pending_send_after_restart() { let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); chain.state.lock().unwrap().reject_broadcast = true; @@ -1319,7 +1485,7 @@ async fn replacement_after_expiry_recovers_pending_send_after_restart() { ); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); assert_eq!( @@ -1408,7 +1574,7 @@ async fn quote_expiring_during_validation_is_not_signed() { let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); let mut data = wallet.store.quote("e.id).unwrap(); @@ -1463,6 +1629,151 @@ async fn invalid_chain_data_and_stored_json_have_distinct_errors() { )); } +#[tokio::test] +async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending() { + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }; + use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + time::Duration, + }; + let chain = MockChain::start().await; + let directory = tempfile::tempdir().unwrap(); + let mut wallet = chain.wallet(&directory); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let attempts = Arc::new(Mutex::new(Vec::new())); + let stalled = Arc::new(AtomicBool::new(true)); + let accepted = attempts.clone(); + let stalled_server = stalled.clone(); + let server = tokio::spawn(async move { + let mut requests = tokio::task::JoinSet::new(); + while let Ok((socket, _)) = listener.accept().await { + let accepted = accepted.clone(); + let stalled = stalled_server.load(Ordering::SeqCst); + requests.spawn(async move { + let mut reader = BufReader::new(socket); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + let hash = line.split_whitespace().nth(1).unwrap().trim_start_matches('/').to_string(); + loop { + line.clear(); + if reader.read_line(&mut line).await.unwrap() == 0 || line == "\r\n" { break; } + } + accepted.lock().unwrap().push(hash.clone()); + tokio::time::sleep(if stalled { Duration::from_secs(25) } else { Duration::from_millis(1200) }).await; + let index = hash.strip_prefix("bridge-tx-").unwrap(); + let body = serde_json::json!({"data":[{ + "guid":format!("guid-{index}"), + "pathway":{"srcEid":30110,"dstEid":30109,"sender":{"address":types::OFT}}, + "source":{"tx":{"txHash":hash}},"status":{"name":"DELIVERED"} + }]}).to_string(); + let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body); + let _ = reader.into_inner().write_all(response.as_bytes()).await; + }); + } + }); + Arc::get_mut(&mut wallet).unwrap().rpc.bridge_status_url = Some(format!("http://{address}")); + let bridges: Vec<_> = (0..5) + .map(|index| UsdtTransfer { + id: format!("bridge-{index}"), + tx_hash: format!("bridge-tx-{index}"), + user_operation_hash: None, + bridge_guid: Some(format!("guid-{index}")), + recipient: RECIPIENT.into(), + destination: UsdtDestination::Polygon, + amount: 1_000_000, + received_amount: 1_000_000, + fee: Some(20), + is_incoming: false, + status: UsdtTransferStatus::Bridging, + timestamp: 1, + explorer_url: String::new(), + }) + .collect(); + wallet + .store + .save_history_receipt(&bridges, "bridges") + .unwrap(); + // A signed operation with no nonce consumption must still resolve once expired. + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + chain.state.lock().unwrap().reject_broadcast = true; + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.tip += 3; + state.timestamp += alloy_primitives::U256::from(601); + } + // Completion includes bridge polling and the shared chain/bundler request budget. + let history = tokio::time::timeout(Duration::from_secs(15), wallet.refresh_transfers()) + .await + .unwrap() + .unwrap(); + assert_eq!(attempts.lock().unwrap().len(), 1); + assert_eq!( + history + .iter() + .find(|transfer| transfer.id == sent.id) + .unwrap() + .status, + UsdtTransferStatus::Failed + ); + assert_eq!( + history + .iter() + .filter(|transfer| transfer.status == UsdtTransferStatus::Bridging) + .count(), + 5 + ); + chain.state.lock().unwrap().reject_broadcast = false; + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + // Hold the refresh lock before send, while all bridge status connections stall again. + let refresh_wallet = wallet.clone(); + let refresh = tokio::spawn(async move { refresh_wallet.refresh_transfers().await }); + tokio::time::timeout(Duration::from_secs(3), async { + while attempts.lock().unwrap().len() < 2 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let result = tokio::time::timeout( + Duration::from_secs(15), + wallet.send(quote.id, TEST_PHRASE.into(), None), + ) + .await; + refresh.await.unwrap().unwrap(); + assert_eq!(result.unwrap().unwrap().status, UsdtTransferStatus::Pending); + { + let attempts = attempts.lock().unwrap(); + assert_ne!(attempts[0], attempts[1]); + } + // Healthy responses slower than an equal share of the budget must still settle. + stalled.store(false, Ordering::SeqCst); + for _ in 0..2 { + tokio::time::timeout(Duration::from_secs(15), wallet.refresh_transfers()) + .await + .unwrap() + .unwrap(); + } + let history = wallet.history().unwrap(); + assert!(bridges.iter().all(|bridge| history.iter().any( + |transfer| transfer.id == bridge.id && transfer.status == UsdtTransferStatus::Confirmed + ))); + server.abort(); +} + #[tokio::test] async fn each_payment_authorizes_the_current_nonce_after_delegation() { use alloy_primitives::{Bytes, U256}; @@ -1470,7 +1781,7 @@ async fn each_payment_authorizes_the_current_nonce_after_delegation() { let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); wallet @@ -1491,7 +1802,7 @@ async fn each_payment_authorizes_the_current_nonce_after_delegation() { UsdtTransferStatus::Confirmed ); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); wallet @@ -1514,7 +1825,7 @@ async fn consumed_authorization_preserves_pending_payment_until_signed_expiry() let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); let quote_data = wallet.store.quote("e.id).unwrap(); @@ -1528,7 +1839,7 @@ async fn consumed_authorization_preserves_pending_payment_until_signed_expiry() )); assert!(chain.state.lock().unwrap().operations.is_empty()); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); chain.state.lock().unwrap().reject_broadcast = true; @@ -1562,7 +1873,9 @@ async fn consumed_authorization_preserves_pending_payment_until_signed_expiry() assert_eq!(retained.operation.eip7702_auth, signed.eip7702_auth); assert_eq!(retained.operation.signature, signed.signature); assert!(matches!( - restored.quote_transfer(RECIPIENT.into(), 1).await, + restored + .quote_transfer(RECIPIENT.into(), 1, UsdtDestination::Arbitrum) + .await, Err(UsdtError::PendingTransfer) )); chain.state.lock().unwrap().timestamp = U256::from(retained.expires_at + 1); @@ -1570,7 +1883,10 @@ async fn consumed_authorization_preserves_pending_payment_until_signed_expiry() assert_eq!(history[0].status, UsdtTransferStatus::Failed); assert_eq!(history[0].fee, Some(0)); assert!(restored.store.pending_plan(&sent.id).unwrap().is_none()); - let quote = restored.quote_transfer(RECIPIENT.into(), 1).await.unwrap(); + let quote = restored + .quote_transfer(RECIPIENT.into(), 1, UsdtDestination::Arbitrum) + .await + .unwrap(); assert_eq!( restored .store @@ -1592,7 +1908,9 @@ async fn foreign_delegation_and_unbounded_paymaster_terms_cannot_authorize_payme let wallet = chain.wallet(&dir); chain.state.lock().unwrap().account_code = Bytes::from_static(&[0xef, 0x01, 0x00, 1]); assert!(matches!( - wallet.quote_transfer(RECIPIENT.into(), 1).await, + wallet + .quote_transfer(RECIPIENT.into(), 1, UsdtDestination::Arbitrum) + .await, Err(UsdtError::UnsupportedDelegation) )); assert_eq!(wallet.balance().await.unwrap(), 10_000_000); @@ -1602,12 +1920,17 @@ async fn foreign_delegation_and_unbounded_paymaster_terms_cannot_authorize_payme for expiry in [0, timestamp + 901] { chain.state.lock().unwrap().paymaster_valid_until = Some(expiry); assert!(matches!( - wallet.quote_transfer(RECIPIENT.into(), 1).await, + wallet + .quote_transfer(RECIPIENT.into(), 1, UsdtDestination::Arbitrum) + .await, Err(UsdtError::InvalidResponse) )); } chain.state.lock().unwrap().paymaster_valid_until = Some(timestamp + 900); - let quote = wallet.quote_transfer(RECIPIENT.into(), 1).await.unwrap(); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1, UsdtDestination::Arbitrum) + .await + .unwrap(); chain.state.lock().unwrap().account_code = Bytes::from( [ &[0xef, 0x01, 0x00][..], @@ -1630,7 +1953,10 @@ async fn seed_restore_includes_external_token_sends_without_duplicate_operation_ let chain = MockChain::start().await; let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); - let quote = wallet.quote_transfer(RECIPIENT.into(), 77).await.unwrap(); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 77, UsdtDestination::Arbitrum) + .await + .unwrap(); wallet .send(quote.id, TEST_PHRASE.into(), None) .await @@ -1673,7 +1999,7 @@ async fn gas_price_changes_require_a_new_quote_before_signing() { let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); chain.state.lock().unwrap().gas_price = 60_000_000; @@ -1692,7 +2018,7 @@ async fn consumed_nonce_recovery_requires_complete_receipts_and_resumes_after_re let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); let sent = wallet @@ -1730,7 +2056,7 @@ async fn consumed_nonce_recovery_requires_complete_receipts_and_resumes_after_re 0 ); restored - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); } @@ -1741,7 +2067,7 @@ async fn consuming_block_receipts_recover_a_payment_hidden_from_log_queries() { let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); let sent = wallet @@ -1768,7 +2094,7 @@ async fn dense_block_history_recovers_large_receipts_without_skipping_after_rest let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000) + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); let sent = wallet diff --git a/src/modules/usdt/transaction.rs b/src/modules/usdt/transaction.rs index 6b882b8..4d7c8d6 100644 --- a/src/modules/usdt/transaction.rs +++ b/src/modules/usdt/transaction.rs @@ -16,6 +16,47 @@ sol! { bytes paymasterAndData; bytes signature; } + #[derive(Debug)] + struct SendParam { + uint32 dstEid; + bytes32 to; + uint256 amountLD; + uint256 minAmountLD; + bytes extraOptions; + bytes composeMsg; + bytes oftCmd; + } + #[derive(Debug)] + struct MessagingFee { + uint256 nativeFee; + uint256 lzTokenFee; + } + struct OFTLimit { + uint256 minAmountLD; + uint256 maxAmountLD; + } + struct OFTFeeDetail { + int256 feeAmountLD; + string description; + } + struct OFTReceipt { + uint256 amountSentLD; + uint256 amountReceivedLD; + } + interface Oft { + function token() external view returns (address); + function peers(uint32 eid) external view returns (bytes32); + function quoteOFT(SendParam param) external view returns (OFTLimit limit, OFTFeeDetail[] fees, OFTReceipt receipt); + function quoteSend(SendParam param, bool payInLzToken) external view returns (MessagingFee fee); + event OFTSent(bytes32 indexed guid, uint32 dstEid, address indexed fromAddress, uint256 amountSentLD, uint256 amountReceivedLD); + } + interface BridgeHelper { + function token() external view returns (address); + function maxGas() external view returns (uint256); + function quoteSend(SendParam param, MessagingFee fee) external view returns (uint256 totalAmount); + function send(address oft, SendParam param, MessagingFee fee) external payable; + event LogSend(address indexed sender, address indexed oft, uint256 amountLD, uint256 nativeFee, uint256 feeInToken, uint256 totalAmount); + } interface EntryPoint { function getNonce(address sender, uint192 key) view returns (uint256); function handleOps(PackedOperation[] ops, address beneficiary); diff --git a/src/modules/usdt/types.rs b/src/modules/usdt/types.rs index 744b696..45245f3 100644 --- a/src/modules/usdt/types.rs +++ b/src/modules/usdt/types.rs @@ -3,8 +3,31 @@ use serde::{Deserialize, Serialize}; pub(super) const CHAIN_ID: u64 = 42161; pub(super) const TOKEN: Address = address!("Fd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"); +pub(super) const OFT: Address = address!("14E4A1B13bf7F943c8ff7C51fb60FA964A298D92"); +pub(super) const BRIDGE_HELPER: Address = address!("a90f03c856D01F698E7071B393387cd75a8a319A"); pub(super) const EXPLORER: &str = "https://arbiscan.io"; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +pub enum UsdtDestination { + Stable, + Ethereum, + Arbitrum, + Polygon, + Plasma, +} + +impl UsdtDestination { + pub(super) fn endpoint(self) -> Option { + match self { + Self::Stable => Some(30396), + Self::Ethereum => Some(30101), + Self::Arbitrum => None, + Self::Polygon => Some(30109), + Self::Plasma => Some(30383), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] pub struct UsdtPaymentRequest { pub recipient: String, @@ -15,7 +38,9 @@ pub struct UsdtPaymentRequest { pub struct UsdtQuote { pub id: String, pub recipient: String, + pub destination: UsdtDestination, pub amount: u64, + pub received_amount: u64, pub maximum_fee: u64, pub expires_at: u64, } @@ -25,6 +50,8 @@ pub enum UsdtTransferStatus { Pending, Confirmed, Failed, + Bridging, + BridgeNeedsAttention, Replaced, } @@ -33,7 +60,9 @@ pub struct UsdtTransfer { pub id: String, pub tx_hash: String, pub user_operation_hash: Option, + pub bridge_guid: Option, pub recipient: String, + pub destination: UsdtDestination, pub amount: u64, pub received_amount: u64, pub fee: Option, diff --git a/src/modules/usdt/wallet.rs b/src/modules/usdt/wallet.rs index 1ff5049..64ec4ab 100644 --- a/src/modules/usdt/wallet.rs +++ b/src/modules/usdt/wallet.rs @@ -2,18 +2,23 @@ use super::{ account::{validate_delegation, ENTRY_POINT}, amount::token_amount, keys::{derive_key, parse_address}, - paymaster::{Pimlico, PAYMASTER}, + paymaster::{with_margin, Pimlico, PAYMASTER}, rpc::Rpc, store::{QuoteData, Store}, - transaction::{event_data, EntryPoint, Erc20, Paymaster, Plan}, - types::{CHAIN_ID, EXPLORER, TOKEN}, + transaction::{ + event_data, BridgeHelper, EntryPoint, Erc20, MessagingFee, Oft, Paymaster, Plan, SendParam, + }, + types::{BRIDGE_HELPER, CHAIN_ID, EXPLORER, OFT, TOKEN}, user_operation::Authorization, - UsdtError, UsdtQuote, UsdtTransfer, UsdtTransferStatus, + UsdtDestination, UsdtError, UsdtQuote, UsdtTransfer, UsdtTransferStatus, }; use alloy_primitives::{Address, Bytes, B256, U256}; use alloy_sol_types::{SolCall, SolEvent}; use serde_json::{json, Value}; -use std::sync::{atomic::AtomicU64, Arc}; +use std::sync::{ + atomic::{AtomicU64, AtomicUsize, Ordering}, + Arc, +}; use tokio::sync::Mutex; #[derive(uniffi::Object)] @@ -23,6 +28,7 @@ pub struct UsdtWallet { pub(super) paymaster: Pimlico, pub(super) store: Store, operation: Mutex<()>, + bridge_poll_offset: AtomicUsize, pub(super) history_range_limit: AtomicU64, } @@ -50,6 +56,7 @@ impl UsdtWallet { paymaster, store, operation: Mutex::new(()), + bridge_poll_offset: AtomicUsize::new(0), history_range_limit: AtomicU64::new(super::history::MAX_LOG_RANGE), })) } @@ -75,41 +82,45 @@ impl UsdtWallet { &self, recipient: String, amount: u64, + destination: UsdtDestination, ) -> Result { if amount == 0 { return Err(UsdtError::InvalidAmount); } let recipient = parse_address(recipient.trim())?; - if recipient == self.address || recipient == TOKEN { + if recipient == self.address + || (destination == UsdtDestination::Arbitrum && recipient == TOKEN) + { return Err(UsdtError::InvalidAddress); } self.store.require_no_pending()?; self.rpc.verify_chain().await?; self.require_balance(amount, 0).await?; - let calls = vec![( - TOKEN, - Erc20::transferCall { - recipient, - amount: U256::from(amount), - } - .abi_encode() - .into(), - )]; + let (calls, received_amount, bridge_fee) = + self.transfer_calls(recipient, amount, destination).await?; + if bridge_fee > 0 { + self.require_balance(amount, bridge_fee).await?; + } let nonce = self.nonce("latest").await?; let authorization = self.authorization().await?; let created_block = self.block_number().await?; let timestamp = self.block_timestamp(created_block).await?; - let (operation, maximum_fee, operation_expires_at) = self + let (operation, gas_fee, operation_expires_at) = self .paymaster .prepare(self.address, nonce, authorization, &calls, timestamp) .await?; let expires_at = now().saturating_add(operation_expires_at.saturating_sub(timestamp).min(120)); + let maximum_fee = gas_fee + .checked_add(bridge_fee) + .ok_or(UsdtError::InvalidAmount)?; self.require_balance(amount, maximum_fee).await?; let quote = UsdtQuote { id: uuid::Uuid::new_v4().to_string(), recipient: recipient.to_checksum(None), + destination, amount, + received_amount, maximum_fee, expires_at, }; @@ -177,9 +188,11 @@ impl UsdtWallet { id: quote_id, tx_hash: String::new(), user_operation_hash: Some(format!("{hash:#x}")), + bridge_guid: None, recipient: data.quote.recipient, + destination: data.quote.destination, amount: data.quote.amount, - received_amount: data.quote.amount, + received_amount: data.quote.received_amount, fee: None, is_incoming: false, status: UsdtTransferStatus::Pending, @@ -209,7 +222,14 @@ impl UsdtWallet { return self.history(); } self.rpc.verify_chain().await?; + self.refresh_bridges(&transfers).await?; for mut transfer in transfers { + if matches!( + transfer.status, + UsdtTransferStatus::Bridging | UsdtTransferStatus::BridgeNeedsAttention + ) { + continue; + } let Some(plan) = self.store.pending_plan(&transfer.id)? else { continue; }; @@ -367,6 +387,37 @@ impl UsdtWallet { self.store.update_transfer(transfer) } + async fn refresh_bridges(&self, transfers: &[UsdtTransfer]) -> Result<(), UsdtError> { + let mut bridges: Vec<_> = transfers + .iter() + .filter(|transfer| { + matches!( + transfer.status, + UsdtTransferStatus::Bridging | UsdtTransferStatus::BridgeNeedsAttention + ) + }) + .collect(); + if bridges.is_empty() { + return Ok(()); + } + let offset = self.bridge_poll_offset.fetch_add(1, Ordering::Relaxed) % bridges.len(); + bridges.rotate_left(offset); + // Rotate the first check so stalled bridges cannot starve later status checks or sends. + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + for transfer in bridges { + match tokio::time::timeout_at(deadline, self.rpc.bridge_status(transfer)).await { + Ok(Ok(status)) => { + let mut transfer = transfer.clone(); + transfer.status = status; + self.store.update_transfer(&transfer)?; + } + Ok(Err(_)) => {} + Err(_) => break, + } + } + Ok(()) + } + pub(super) async fn block_number(&self) -> Result { u64::try_from(self.rpc.call::("eth_blockNumber", json!([])).await?) .map_err(|_| UsdtError::InvalidResponse) @@ -497,6 +548,7 @@ impl UsdtWallet { .map_err(|_| UsdtError::InvalidResponse)?; let logs = super::transaction::operation_logs(receipt, operation_hash)?; let mut gas_fee = None; + let mut bridge_fee = None; for log in logs { let address: Address = serde_json::from_value(log["address"].clone())?; let data = event_data(log)?; @@ -511,16 +563,174 @@ impl UsdtWallet { } } } + if success && address == BRIDGE_HELPER { + if let Ok(event) = BridgeHelper::LogSend::decode_log_data(&data) { + if event.sender == self.address + && event.oft == OFT + && event.amountLD == U256::from(transfer.amount) + { + bridge_fee = Some(token_amount(event.feeInToken)?); + } + } + } + if success && address == OFT { + if let Ok(event) = Oft::OFTSent::decode_log_data(&data) { + if event.fromAddress == BRIDGE_HELPER + && transfer.destination.endpoint() == Some(event.dstEid) + && event.amountSentLD == U256::from(transfer.amount) + { + transfer.bridge_guid = Some(format!("{:#x}", event.guid)); + transfer.received_amount = token_amount(event.amountReceivedLD)?; + } + } + } } - transfer.fee = gas_fee; + transfer.fee = gas_fee.and_then(|fee| fee.checked_add(bridge_fee.unwrap_or(0))); transfer.status = if !success { transfer.received_amount = 0; UsdtTransferStatus::Failed - } else { + } else if transfer.destination == UsdtDestination::Arbitrum { UsdtTransferStatus::Confirmed + } else if transfer.bridge_guid.is_some() { + UsdtTransferStatus::Bridging + } else { + UsdtTransferStatus::BridgeNeedsAttention }; Ok(()) } + async fn transfer_calls( + &self, + recipient: Address, + amount: u64, + destination: UsdtDestination, + ) -> Result<(Vec<(Address, Bytes)>, u64, u64), UsdtError> { + let Some(eid) = destination.endpoint() else { + return Ok(( + vec![( + TOKEN, + Erc20::transferCall { + recipient, + amount: U256::from(amount), + } + .abi_encode() + .into(), + )], + amount, + 0, + )); + }; + let token = self.rpc.contract(OFT, Oft::tokenCall {}).await?; + let helper_token = self + .rpc + .contract(BRIDGE_HELPER, BridgeHelper::tokenCall {}) + .await?; + let peer = self.rpc.contract(OFT, Oft::peersCall { eid }).await?; + if token != TOKEN || helper_token != TOKEN || peer.is_zero() { + return Err(UsdtError::UnsupportedRoute); + } + let mut param = SendParam { + dstEid: eid, + to: recipient.into_word(), + amountLD: U256::from(amount), + minAmountLD: U256::ZERO, + extraOptions: Bytes::new(), + composeMsg: Bytes::new(), + oftCmd: Bytes::new(), + }; + let oft = self + .rpc + .contract( + OFT, + Oft::quoteOFTCall { + param: param.clone(), + }, + ) + .await?; + if U256::from(amount) < oft.limit.minAmountLD + || U256::from(amount) > oft.limit.maxAmountLD + || oft.receipt.amountSentLD != U256::from(amount) + || oft.receipt.amountReceivedLD.is_zero() + || oft.receipt.amountReceivedLD > U256::from(amount) + { + return Err(UsdtError::InvalidAmount); + } + param.minAmountLD = oft.receipt.amountReceivedLD; + let fee = self + .rpc + .contract( + OFT, + Oft::quoteSendCall { + param: param.clone(), + payInLzToken: false, + }, + ) + .await?; + let maximum_native = self + .rpc + .contract(BRIDGE_HELPER, BridgeHelper::maxGasCall {}) + .await?; + if !fee.lzTokenFee.is_zero() + || fee.nativeFee > maximum_native + || self.rpc.balance(BRIDGE_HELPER).await? < fee.nativeFee + { + return Err(UsdtError::UnsupportedRoute); + } + let total = self + .rpc + .contract( + BRIDGE_HELPER, + BridgeHelper::quoteSendCall { + param: param.clone(), + fee: fee.clone(), + }, + ) + .await?; + let fee = MessagingFee { + nativeFee: fee.nativeFee, + lzTokenFee: U256::ZERO, + }; + let token_fee = total + .checked_sub(U256::from(amount)) + .ok_or(UsdtError::InvalidResponse)?; + let token_fee = with_margin(token_fee)?; + let approval = U256::from(amount) + .checked_add(token_fee) + .ok_or(UsdtError::InvalidResponse)?; + Ok(( + vec![ + ( + TOKEN, + Erc20::approveCall { + spender: BRIDGE_HELPER, + amount: approval, + } + .abi_encode() + .into(), + ), + ( + BRIDGE_HELPER, + BridgeHelper::sendCall { + oft: OFT, + param, + fee, + } + .abi_encode() + .into(), + ), + ( + TOKEN, + Erc20::approveCall { + spender: BRIDGE_HELPER, + amount: U256::ZERO, + } + .abi_encode() + .into(), + ), + ], + token_amount(oft.receipt.amountReceivedLD)?, + token_amount(token_fee)?, + )) + } } pub(super) fn now() -> u64 { From 1a2e61e004c393cd14750d71ce43b0fd1c7eeaab Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 24 Sep 2026 22:35:30 +0300 Subject: [PATCH 2/6] fix: bound USDT0 fees and preserve bridge recovery --- AGENTS.md | 4 +- Package.swift | 2 +- src/modules/usdt/README.md | 10 +- src/modules/usdt/history.rs | 41 +-- src/modules/usdt/paymaster.rs | 38 +-- src/modules/usdt/rpc.rs | 34 ++- src/modules/usdt/store.rs | 48 +++- src/modules/usdt/tests.rs | 499 +++++++++++++++++++++++++++++++++- src/modules/usdt/types.rs | 16 ++ src/modules/usdt/wallet.rs | 196 ++++++++++--- tests/usdt-fork/provider.mjs | 10 + 11 files changed, 794 insertions(+), 104 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c182e83..743a64e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Android bindings are built and published by `.github/workflows/gradle-publish.ym ```bash cargo test # All tests -cargo test modules:: # Single module (scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky) +cargo test modules:: # Single module (scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky, usdt) ``` ## Lint & Format @@ -35,7 +35,7 @@ Android bindings use ktlint via Gradle plugin (`org.jlleitschuh.gradle.ktlint`), ## Architecture - `src/lib.rs` — UniFFI exports and module re-exports -- `src/modules/`: core modules: scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky +- `src/modules/`: core modules: scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky, usdt - `bindings/` — Platform-specific binding outputs (ios/, android/, python/) - `build.sh`, `build_ios.sh`, `build_android.sh`, `build_python.sh` — Build scripts diff --git a/Package.swift b/Package.swift index 2ea8a85..6273238 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription import Foundation let tag = "v0.6.0" -let checksum = "eed89e4a6d060e064bc91f8a66115ffff90fff6edd659c42024077c49a8982b2" +let checksum = "2c9b706e4bbbabc529082c552f37a3bb0f2258a033c2c5aafbd6f0a8392a44d3" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/src/modules/usdt/README.md b/src/modules/usdt/README.md index 79017f4..0d835f1 100644 --- a/src/modules/usdt/README.md +++ b/src/modules/usdt/README.md @@ -14,7 +14,7 @@ Owned mnemonic/passphrase/seed buffers are zeroized and signing keys are erased ## Quotes and fees -`quote_transfer` takes a raw recipient, positive atomic amount and destination; it never receives signing credentials. Local quotes last at most 120 seconds and newly quoted paymaster terms must expire within 15 minutes. `send` validates the owner, nonces, balance, gas estimates, current gas prices and deadlines before signing the stored plan. Changed terms require a new review; signing cannot raise the approved fee. +`quote_transfer` takes a raw recipient, positive atomic amount and destination; it never receives signing credentials. Local quotes last at most 120 seconds and newly quoted paymaster terms must expire within 15 minutes. `send` validates the owner, nonces, balance, gas estimates, the current slow gas-price recommendation and deadlines before signing the stored plan. Quotes use the fast gas-price recommendation; a modest price increase does not invalidate a quote that still covers the current slow recommendation. Changes beyond the approved bounds require a new review; signing cannot raise the approved fee. The pinned ERC-20 paymaster collects USDT. Its finite approval includes a 5% margin; the displayed maximum fee comes from signed gas limits and paymaster terms, not the allowance. Call/pre-verification estimates receive 10% execution/L1-data headroom; the charged pre-verification margin is included in the maximum. A residual paymaster allowance can remain and is reset to a finite amount on the next payment. @@ -28,7 +28,7 @@ A matching operation event settles the payment. Expired signed paymaster terms a Seed restoration recovers deposits and outgoing activity from genesis, including transfers before delegation and sends through another wallet. Supported direct EntryPoint calls recover payment/fee attribution; unknown wrappers preserve raw token transfers instead of guessing their intent. Failed payments retain attempted amounts but have no delivered amount. -`sync_history` returns `true` when caught up and `false` when more work remains. It uses adaptive log ranges and a 20-second soft budget between persisted receipts; an in-flight receipt may finish later. A single-block log overflow falls back to that block's individual receipts. Zero/self transfers are discarded before enrichment. Network failures preserve completed work and never silently skip a block. +`sync_history` returns `true` when caught up and `false` when more work remains. It uses adaptive log ranges and a 20-second soft budget between persisted receipts; an in-flight receipt may finish later. A single-block log overflow falls back to that block's individual receipts. Completed fallback scans are retained by canonical block hash within the revisit window. Zero/self transfers are discarded before enrichment. Network failures preserve completed work and never silently skip a block. Scans trail the reported tip by two blocks and revisit 4096 blocks for delayed indexing. This is not reorg rollback: previously recorded orphaned activity is not retracted. Providers must supply complete filtered logs, canonical blocks/receipts and historical state. @@ -42,13 +42,15 @@ Both chain and bundler endpoints must be controlled, credential-free HTTPS URLs; The outbound bridge API supports Ethereum (30101), Polygon (30109), Plasma (30383) and Stable (30396), alongside direct Arbitrum transfers. Native release flows expose Arbitrum only; bridge routes require explicit service enablement and destination acceptance. Plain deposits on another chain are not automatically forwarded. +Bridge quotes include 10% native messaging-fee headroom and 20% token-conversion headroom, both within the displayed maximum USDT fee. Before signing, the stored native fee, helper liquidity and token approval are checked against current requirements without raising approved limits. Delivery checks process up to three transfers concurrently outside the send lock, with a ten-second request budget; failed lookups retain the last known status. + Bridges use the pinned OFT and TransactionValueHelper with zero account ETH, a finite USDT approval covering principal/fee, and atomic helper-allowance revocation. The deployed helper requires native liquidity and retains behaviors noted in its OpenZeppelin audit; its verified runtime is not the audit-remediated implementation. Source success means bridging, not delivered. LayerZero status must match the operation GUID/pathway before confirmation; blocked delivery remains visible and never triggers an automatic paid retry. RPC providers see queried addresses; LayerZero Scan sees bridge transaction hashes. ## Validation and bindings Run `cargo test --locked --lib modules::usdt`; CI runs these deterministic tests. They cover independent signing/address vectors, fee bounds, uncertain submission, nonce recovery and restored history. Fixtures use public test credentials. -For the ignored deployed-contract test, start a fresh Arbitrum Anvil fork on port 18545 and `tests/usdt-fork/provider.mjs` on 18546 after installing its pinned dependencies. Run `cargo test deployed_contracts_collect_usdt_fees_and_revert_failed_bridges_atomically -- --ignored`. The fixture requires Anvil, sets local balances/signing terms and checks deployed bytecode; it does not establish real provider pricing or destination delivery. +For the ignored deployed-contract test, start a fresh Arbitrum Anvil fork on port 18545 and `tests/usdt-fork/provider.mjs` on 18546 after installing its pinned dependencies. Run `cargo test deployed_contracts_collect_usdt_fees_and_revert_failed_bridges_atomically -- --ignored`. The fixture requires Anvil, sets local balances/signing terms and executes deployed contracts; it does not establish real provider pricing or destination delivery. To include the service, start it with `USDT_BRIDGE_NETWORKS=ethereum,polygon,plasma,stable NODE_ENV=test ARBITRUM_RPC_URL=http://127.0.0.1:18545 LOCAL_PROVIDER_URL=http://127.0.0.1:18546`, then pass `USDT_FORK_RPC_URL=http://127.0.0.1:3100/v1/usdt/chain-rpc` and `USDT_FORK_BUNDLER_URL=http://127.0.0.1:3100/v1/usdt/rpc` to the ignored test. @@ -66,3 +68,5 @@ Build iOS and Android sequentially with the repository scripts; Android temporar - [Transaction helper audit](https://www.openzeppelin.com/news/usdt0-transaction-helper-audit) - [Verified deployed helper](https://arbitrum.blockscout.com/api/v2/smart-contracts/0xa90f03c856d01f698e7071b393387cd75a8a319a) - [LayerZero message statuses](https://docs.layerzero.network/v2/tools/layerzeroscan/mainnet/messages/get-messagesstatus) + +Destination token addresses follow the official USDT0 ecosystem listings for [Polygon](https://usdt0.to/ecosystem/polygon), [Plasma](https://usdt0.to/ecosystem/plasma) and [Stable](https://usdt0.to/ecosystem/stable). diff --git a/src/modules/usdt/history.rs b/src/modules/usdt/history.rs index 274ab58..83ff6c9 100644 --- a/src/modules/usdt/history.rs +++ b/src/modules/usdt/history.rs @@ -25,6 +25,7 @@ impl UsdtWallet { if start > tip { return Err(UsdtError::NetworkUnavailable); } + let mut ceiling = MAX_LOG_RANGE; let mut next = start; let mut width = self .history_range_limit @@ -45,7 +46,7 @@ impl UsdtWallet { Err(_) => { self.history_range_limit .store((width / 2).max(1), Ordering::Relaxed); - return Ok(false); + return Err(UsdtError::NetworkUnavailable); } }; match result { @@ -92,6 +93,7 @@ impl UsdtWallet { } Err(UsdtError::LogRangeTooLarge) if next < end => { width = (width / 2).max(1); + ceiling = width; self.history_range_limit.store(width, Ordering::Relaxed); continue; } @@ -113,7 +115,7 @@ impl UsdtWallet { } next = end + 1; self.store.save_history_progress(next)?; - width = (width * 2).min(MAX_LOG_RANGE); + width = (width * 2).min(ceiling); self.history_range_limit.store(width, Ordering::Relaxed); width = width.min(tip - next + 1); } @@ -126,6 +128,10 @@ impl UsdtWallet { deadline: tokio::time::Instant, ) -> Result { let block = self.rpc.block(number).await?; + let block_hash = format!("{:#x}", block.hash); + if self.store.begin_history_block(number, &block_hash)? { + return Ok(true); + } let timestamp = u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; if self.store.history_progress()? != Some(number) { self.store.save_history_progress(number)?; @@ -141,6 +147,10 @@ impl UsdtWallet { let receipt = self.rpc.block_receipt(*hash, &block, number).await?; self.save_receipt_history(&id, timestamp, &receipt).await?; } + if self.rpc.block(number).await?.hash != block.hash { + return Err(UsdtError::NetworkUnavailable); + } + self.store.complete_history_block(number, &block_hash)?; Ok(true) } @@ -265,6 +275,10 @@ impl UsdtWallet { None }; for (event, saved) in owned_operations { + // Unknown fee collection keeps its raw debits and refunds intact. + if event.paymaster != super::paymaster::PAYMASTER { + continue; + } let operation_hash = format!("{:#x}", event.userOpHash); let (recipient, amount, destination) = if let Some(saved) = saved { (saved.recipient, saved.amount, saved.destination) @@ -324,11 +338,10 @@ fn decode_payment(data: &[u8]) -> Result if target == TOKEN { if let Ok(call) = Erc20::transferCall::abi_decode(&data) { payment_count += 1; - payment = Some(( - call.recipient, - token_amount(call.amount)?, - UsdtDestination::Arbitrum, - )); + let Ok(amount) = token_amount(call.amount) else { + return Ok(None); + }; + payment = Some((call.recipient, amount, UsdtDestination::Arbitrum)); } else if Erc20::approveCall::abi_decode(&data).is_err() { supported = false; } @@ -338,21 +351,17 @@ fn decode_payment(data: &[u8]) -> Result supported = false; continue; } - let Some(destination) = [ - UsdtDestination::Ethereum, - UsdtDestination::Polygon, - UsdtDestination::Plasma, - UsdtDestination::Stable, - ] - .into_iter() - .find(|d| d.endpoint() == Some(call.param.dstEid)) else { + let Some(destination) = UsdtDestination::from_endpoint(call.param.dstEid) else { supported = false; continue; }; payment_count += 1; payment = Some(( Address::from_word(call.param.to), - token_amount(call.param.amountLD)?, + match token_amount(call.param.amountLD) { + Ok(amount) => amount, + Err(_) => return Ok(None), + }, destination, )); } else { diff --git a/src/modules/usdt/paymaster.rs b/src/modules/usdt/paymaster.rs index 528af37..cef65e7 100644 --- a/src/modules/usdt/paymaster.rs +++ b/src/modules/usdt/paymaster.rs @@ -20,6 +20,12 @@ struct GasPrice { max_priority_fee_per_gas: U256, } +#[derive(Deserialize)] +struct GasPrices { + slow: GasPrice, + fast: GasPrice, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct TokenQuote { @@ -118,7 +124,7 @@ impl Pimlico { && !quote.exchange_rate.is_zero() }) .ok_or(UsdtError::UnsupportedRoute)?; - let price = self.gas_price().await?; + let price = self.gas_prices().await?.fast; let dummy_signature = Bytes::from_static(&alloy_primitives::hex!("fffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c")); let mut op = UserOperation { sender: address, @@ -209,7 +215,7 @@ impl Pimlico { } pub async fn validate_gas(&self, op: &UserOperation) -> Result<(), UsdtError> { - let price = self.gas_price().await?; + let price = self.gas_prices().await?.slow; if price.max_fee_per_gas > op.max_fee_per_gas || price.max_priority_fee_per_gas > op.max_priority_fee_per_gas { @@ -234,21 +240,24 @@ impl Pimlico { Ok(()) } - async fn gas_price(&self) -> Result { - #[derive(Deserialize)] - struct Prices { - fast: GasPrice, - } - let price: Prices = self + async fn gas_prices(&self) -> Result { + let prices: GasPrices = self .rpc .call("pimlico_getUserOperationGasPrice", json!([])) .await?; - if price.fast.max_fee_per_gas.is_zero() - || price.fast.max_priority_fee_per_gas > price.fast.max_fee_per_gas + for price in [&prices.slow, &prices.fast] { + if price.max_fee_per_gas.is_zero() + || price.max_priority_fee_per_gas > price.max_fee_per_gas + { + return Err(UsdtError::InvalidResponse); + } + } + if prices.slow.max_fee_per_gas > prices.fast.max_fee_per_gas + || prices.slow.max_priority_fee_per_gas > prices.fast.max_priority_fee_per_gas { return Err(UsdtError::InvalidResponse); } - Ok(price.fast) + Ok(prices) } } @@ -273,13 +282,6 @@ fn approval_margin(value: U256) -> Result { .ok_or(UsdtError::InvalidResponse) } -pub(super) fn with_margin(value: U256) -> Result { - value - .checked_add(value / U256::from(5)) - .and_then(|value| value.checked_add(U256::from(1))) - .ok_or(UsdtError::InvalidResponse) -} - struct Terms { exchange_rate: U256, post_op_gas: U256, diff --git a/src/modules/usdt/rpc.rs b/src/modules/usdt/rpc.rs index 35a3cee..0d6af05 100644 --- a/src/modules/usdt/rpc.rs +++ b/src/modules/usdt/rpc.rs @@ -91,6 +91,9 @@ impl Rpc { } else { 2_097_152 }; + if status.is_server_error() { + return Err(UsdtError::NetworkUnavailable); + } let body = bounded_json(response, limit, overflow).await; let response: Response = match body.and_then(|value| serde_json::from_value(value).map_err(Into::into)) { @@ -120,8 +123,20 @@ impl Rpc { if error.code == -32002 { return Err(UsdtError::NetworkUnavailable); } - if message.contains("insufficient funds") || message.contains("insufficient balance") { - return Err(UsdtError::InsufficientBalance); + if matches!( + method, + "eth_chainId" + | "eth_blockNumber" + | "eth_getCode" + | "eth_getBalance" + | "eth_getTransactionCount" + | "eth_call" + | "eth_getLogs" + | "eth_getBlockByNumber" + | "eth_getTransactionReceipt" + | "eth_getTransactionByHash" + ) { + return Err(UsdtError::NetworkUnavailable); } return Err(UsdtError::TransactionRejected { reason: error.message.chars().take(200).collect(), @@ -148,16 +163,10 @@ impl Rpc { if transfer.bridge_guid.is_none() { return Ok(transfer.status); } - let url = format!( - "https://scan.layerzero-api.com/v1/messages/tx/{}", - transfer.tx_hash - ); + let base = "https://scan.layerzero-api.com"; #[cfg(test)] - let url = self - .bridge_status_url - .as_ref() - .map(|base| format!("{base}/{}", transfer.tx_hash)) - .unwrap_or(url); + let base = self.bridge_status_url.as_deref().unwrap_or(base); + let url = format!("{base}/v1/messages/tx/{}", transfer.tx_hash); let response = self .client .get(url) @@ -218,6 +227,9 @@ impl Rpc { let receipt: Value = self .call("eth_getTransactionReceipt", json!([hash])) .await?; + if receipt.is_null() { + return Err(UsdtError::NetworkUnavailable); + } if serde_json::from_value::(receipt["transactionHash"].clone())? != hash || serde_json::from_value::(receipt["blockHash"].clone())? != block.hash || serde_json::from_value::(receipt["blockNumber"].clone())? != U256::from(number) diff --git a/src/modules/usdt/store.rs b/src/modules/usdt/store.rs index a124d32..75212fc 100644 --- a/src/modules/usdt/store.rs +++ b/src/modules/usdt/store.rs @@ -28,6 +28,7 @@ impl Store { CREATE TABLE IF NOT EXISTS usdt_sync (id INTEGER PRIMARY KEY CHECK(id=1), newest INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS usdt_history_progress (id INTEGER PRIMARY KEY CHECK(id=1), next INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS usdt_history_receipts (hash TEXT PRIMARY KEY); + CREATE TABLE IF NOT EXISTS usdt_history_blocks (number INTEGER PRIMARY KEY, hash TEXT NOT NULL, complete INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS usdt_quotes (id TEXT PRIMARY KEY, data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS usdt_nonce_recovery (id TEXT PRIMARY KEY, block_hash TEXT NOT NULL, next_transaction INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS usdt_transfers (id TEXT PRIMARY KEY, hash TEXT NOT NULL, raw TEXT, data TEXT NOT NULL);")?; @@ -191,6 +192,10 @@ impl Store { let tx = connection.transaction()?; tx.execute("DELETE FROM usdt_history_progress", [])?; tx.execute("DELETE FROM usdt_history_receipts", [])?; + tx.execute( + "DELETE FROM usdt_history_blocks WHERE number < ?1", + [newest.saturating_sub(4096)], + )?; tx.execute("INSERT INTO usdt_sync (id,newest) VALUES (1,?1) ON CONFLICT(id) DO UPDATE SET newest=excluded.newest", [newest])?; tx.commit()?; Ok(()) @@ -210,6 +215,10 @@ impl Store { pub fn save_history_progress(&self, next: u64) -> Result<(), UsdtError> { let mut connection = self.connection()?; let tx = connection.transaction()?; + tx.execute( + "DELETE FROM usdt_history_blocks WHERE number < ?1", + [next.saturating_sub(4096)], + )?; tx.execute("INSERT INTO usdt_history_progress VALUES (1,?1) ON CONFLICT(id) DO UPDATE SET next=excluded.next", [next])?; tx.execute("DELETE FROM usdt_history_receipts", [])?; tx.commit()?; @@ -218,11 +227,40 @@ impl Store { pub fn transaction_timestamp(&self, hash: &str) -> Result, UsdtError> { Ok(self.connection()?.query_row( - "SELECT json_extract(data, '$.timestamp') FROM usdt_transfers WHERE json_extract(data, '$.tx_hash')=?1 LIMIT 1", + "SELECT json_extract(data, '$.timestamp') FROM usdt_transfers WHERE json_extract(data, '$.tx_hash')=?1 AND json_extract(data, '$.status') != 'Pending' LIMIT 1", [hash], |row| row.get(0), ).optional()?) } + pub fn begin_history_block(&self, number: u64, hash: &str) -> Result { + let mut connection = self.connection()?; + let tx = connection.transaction()?; + let saved: Option<(String, bool)> = tx + .query_row( + "SELECT hash, complete FROM usdt_history_blocks WHERE number=?1", + [number], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((saved_hash, complete)) = saved { + if saved_hash == hash { + return Ok(complete); + } + tx.execute("DELETE FROM usdt_history_receipts", [])?; + } + tx.execute("INSERT INTO usdt_history_blocks VALUES (?1,?2,0) ON CONFLICT(number) DO UPDATE SET hash=excluded.hash,complete=0", params![number, hash])?; + tx.commit()?; + Ok(false) + } + + pub fn complete_history_block(&self, number: u64, hash: &str) -> Result<(), UsdtError> { + self.connection()?.execute( + "UPDATE usdt_history_blocks SET complete=1 WHERE number=?1 AND hash=?2", + params![number, hash], + )?; + Ok(()) + } + pub fn has_history_receipt(&self, hash: &str) -> Result { Ok(self.connection()?.query_row( "SELECT EXISTS(SELECT 1 FROM usdt_history_receipts WHERE hash=?1)", @@ -264,8 +302,12 @@ impl Store { if let Some(data) = existing { let saved: UsdtTransfer = decode(&data)?; transfer.id = saved.id; - if saved.tx_hash == transfer.tx_hash - && saved.bridge_guid == transfer.bridge_guid + if saved.tx_hash.eq_ignore_ascii_case(&transfer.tx_hash) + && saved + .bridge_guid + .as_ref() + .zip(transfer.bridge_guid.as_ref()) + .is_some_and(|(a, b)| a.eq_ignore_ascii_case(b)) && transfer.status == UsdtTransferStatus::Bridging && matches!( saved.status, diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index 31f0710..4b0ed52 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -92,6 +92,11 @@ fn wallet_requires_both_provider_endpoints() { for (rpc, bundler) in [ ("", "https://provider.example"), ("https://provider.example", ""), + ("not a url", "https://provider.example"), + ( + "https://provider.example", + "https://example.com?api-key=secret", + ), ] { assert!(matches!( UsdtWallet::new( @@ -231,6 +236,8 @@ struct ChainState { delay_gas_estimate: bool, paymaster: alloy_primitives::Address, helper_balance: alloy_primitives::U256, + native_message_fee: u64, + helper_token_fee: u64, history_input: Option, history_target: Option, receipt_logs: Option>, @@ -250,6 +257,9 @@ struct ChainState { incoming_count: u64, block_reads: usize, fail_block_read_at: Option, + authorization_change_on_block_read: bool, + receipt_reads: usize, + receipt_response: Option, } impl Drop for MockChain { fn drop(&mut self) { @@ -280,6 +290,8 @@ impl MockChain { delay_gas_estimate: false, paymaster: paymaster::PAYMASTER, helper_balance: U256::from(1_000_000_000_000_000u64), + native_message_fee: 10_000_000_000, + helper_token_fee: 300_000, history_input: None, history_target: Some(account::ENTRY_POINT), receipt_logs: None, @@ -299,6 +311,9 @@ impl MockChain { incoming_count: 0, block_reads: 0, fail_block_read_at: None, + authorization_change_on_block_read: false, + receipt_reads: 0, + receipt_response: None, })); let server_state = state.clone(); let task = tokio::spawn(async move { @@ -336,6 +351,33 @@ impl MockChain { } let body: serde_json::Value = serde_json::from_slice(&request[header_end..]).unwrap(); + let path = String::from_utf8_lossy(&request[..header_end]) + .lines() + .next() + .unwrap() + .split_whitespace() + .nth(1) + .unwrap() + .to_owned(); + let method = body["method"].as_str().unwrap(); + let bundler = matches!( + method, + "pimlico_getTokenQuotes" + | "pimlico_getUserOperationGasPrice" + | "pm_getPaymasterData" + | "pm_getPaymasterStubData" + | "eth_estimateUserOperationGas" + | "eth_sendUserOperation" + ); + if path == "/chain" { + assert!(!bundler, "Bundler method on chain endpoint"); + } + if path == "/bundler" { + assert!( + bundler || method == "eth_chainId", + "Chain method on bundler endpoint" + ); + } let delay = body["method"] == "eth_estimateUserOperationGas" && std::mem::take(&mut server_state.lock().unwrap().delay_gas_estimate); if delay { @@ -352,8 +394,8 @@ impl MockChain { UsdtWallet::new( usdt_address(TEST_PHRASE.into(), None).unwrap(), dir.path().join("usdt.sqlite").to_string_lossy().into(), - self.url.clone(), - self.url.clone(), + format!("{}/chain", self.url), + format!("{}/bundler", self.url), ) .unwrap() } @@ -383,6 +425,9 @@ impl ChainState { "eth_blockNumber" => json!(U256::from(self.tip)), "eth_getBlockByNumber" => { self.block_reads += 1; + if std::mem::take(&mut self.authorization_change_on_block_read) { + self.authorization_nonce += 1; + } if self.fail_block_read_at == Some(self.block_reads) { self.fail_block_read_at = None; return json!({"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"temporarily unavailable"}}); @@ -429,7 +474,7 @@ impl ChainState { .abi_encode_params() } else if data.starts_with(&Oft::quoteSendCall::SELECTOR) { MessagingFee { - nativeFee: U256::from(10_000_000_000u64), + nativeFee: U256::from(self.native_message_fee), lzTokenFee: U256::ZERO, } .abi_encode() @@ -439,7 +484,7 @@ impl ChainState { let param = BridgeHelper::quoteSendCall::abi_decode(&data) .unwrap() .param; - (param.amountLD + U256::from(300_000)).abi_encode() + (param.amountLD + U256::from(self.helper_token_fee)).abi_encode() } else { self.balance.abi_encode() }; @@ -449,7 +494,7 @@ impl ChainState { json!({"quotes":[{"token":types::TOKEN,"paymaster":self.paymaster,"postOpGas":"0xc350","exchangeRate":U256::from(3_000_000_000u64)}]}) } "pimlico_getUserOperationGasPrice" => { - json!({"fast":{"maxFeePerGas":U256::from(self.gas_price),"maxPriorityFeePerGas":U256::from(100000)}}) + json!({"slow":{"maxFeePerGas":U256::from(self.gas_price * 9 / 10),"maxPriorityFeePerGas":U256::from(100000)},"fast":{"maxFeePerGas":U256::from(self.gas_price),"maxPriorityFeePerGas":U256::from(100000)}}) } "pm_getPaymasterData" | "pm_getPaymasterStubData" => { let mut data = vec![0; 118]; @@ -582,6 +627,10 @@ impl ChainState { } } "eth_getTransactionReceipt" => { + self.receipt_reads += 1; + if let Some(response) = &self.receipt_response { + return json!({"jsonrpc":"2.0","id":1,"result":response}); + } if self.hide_receipts || self .receipt_failure @@ -737,7 +786,12 @@ async fn signed_operation_survives_uncertain_broadcast_and_restart() { .iter() .all(|item| serde_json::to_value(item).unwrap() == serde_json::to_value(&op).unwrap())); chain.state.lock().unwrap().mined = true; + chain.state.lock().unwrap().timestamp += alloy_primitives::U256::from(60); let history = wallet.refresh_transfers().await.unwrap(); + assert_eq!( + history[0].timestamp, + u64::try_from(chain.state.lock().unwrap().timestamp).unwrap() + ); assert_eq!(history.len(), 1); assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); assert_eq!(history[0].fee, Some(123)); @@ -931,7 +985,7 @@ async fn bridge_payment_bounds_token_fees_and_revokes_helper_approval() { .into_word() ); assert_eq!(send.param.minAmountLD, U256::from(1_000_000)); - assert_eq!(send.fee.nativeFee, U256::from(10_000_000_000u64)); + assert_eq!(send.fee.nativeFee, U256::from(11_000_000_001u64)); let revoke = transaction::Erc20::approveCall::abi_decode(&calls[3].1).unwrap(); assert_eq!(revoke.spender, types::BRIDGE_HELPER); assert!(revoke.amount.is_zero()); @@ -939,7 +993,30 @@ async fn bridge_payment_bounds_token_fees_and_revokes_helper_approval() { assert_eq!(bridge_fee, 360_001); assert!(paymaster.amount > U256::from(quote.maximum_fee - bridge_fee)); assert_eq!(quote.received_amount, 1_000_000); + chain.state.lock().unwrap().native_message_fee = 12_000_000_000; + assert!(matches!( + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::QuoteExpired) + )); + chain.state.lock().unwrap().native_message_fee = 10_500_000_000; + chain.state.lock().unwrap().helper_token_fee = 400_000; + assert!(matches!( + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::QuoteExpired) + )); + chain.state.lock().unwrap().helper_token_fee = 300_000; chain.state.lock().unwrap().helper_balance = U256::ZERO; + assert!(matches!( + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::UnsupportedRoute) + )); + assert!(chain.state.lock().unwrap().operations.is_empty()); assert!(matches!( wallet .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) @@ -1025,6 +1102,10 @@ async fn bundled_operations_cannot_contribute_another_payments_bridge_status_or_ log(paymaster::PAYMASTER, transaction::Paymaster::UserOperationSponsored { userOpHash:own_hash, user:wallet.address, paymasterMode:1, token:types::TOKEN, tokenAmountPaid:U256::from(123), exchangeRate:U256::from(1) }.encode_log_data()), log(account::ENTRY_POINT, event(own_hash)), ]}); + assert_eq!( + transaction::operation_logs(&receipt, own_hash).unwrap(), + &receipt["logs"].as_array().unwrap()[3..] + ); wallet.settle(&mut transfer, &receipt, false).unwrap(); assert_eq!(transfer.status, UsdtTransferStatus::Failed); assert_eq!(transfer.fee, Some(123)); @@ -1091,6 +1172,25 @@ async fn deployed_contracts_collect_usdt_fees_and_revert_failed_bridges_atomical .call("anvil_setBalance", json!([types::BRIDGE_HELPER, "0x0"])) .await .unwrap(); + assert!(matches!( + wallet + .send(bridge.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::UnsupportedRoute) + )); + let _: serde_json::Value = rpc + .call( + "anvil_setBalance", + json!([types::BRIDGE_HELPER, helper_balance]), + ) + .await + .unwrap(); + // The helper can lose liquidity after preflight but before execution. + let fixture = rpc::Rpc::new("http://127.0.0.1:18546".into(), types::CHAIN_ID).unwrap(); + let _: bool = fixture + .call("test_drainHelperBeforeNextBroadcast", json!([])) + .await + .unwrap(); let before = wallet.balance().await.unwrap(); let sent = wallet .send(bridge.id, TEST_PHRASE.into(), None) @@ -1160,6 +1260,15 @@ async fn history_preserves_receipts_with_external_account_call_shapes() { .await .unwrap(); let original = chain.state.lock().unwrap().operations[0].call_data.clone(); + let oversized = account::batch(&[( + types::TOKEN, + transaction::Erc20::transferCall { + recipient: RECIPIENT.parse().unwrap(), + amount: U256::MAX, + } + .abi_encode() + .into(), + )]); let single = account::SimpleAccount::executeCall { target: types::TOKEN, value: U256::ZERO, @@ -1175,6 +1284,7 @@ async fn history_preserves_receipts_with_external_account_call_shapes() { for (call_data, outer, expected_outgoing) in [ (original.clone(), None, true), (single, None, true), + (oversized, None, false), (Bytes::from_static(&[1, 2, 3, 4]), None, false), (original, Some(Bytes::from_static(&[5, 6, 7, 8])), false), ] { @@ -1427,7 +1537,7 @@ async fn interrupted_history_resumes_without_repeating_completed_work() { }) .await .unwrap(); - assert!(matches!(error, UsdtError::TransactionRejected { .. })); + assert!(matches!(error, UsdtError::NetworkUnavailable)); assert_eq!(restored.history().unwrap().len(), 24); assert!(restored.store.history_progress().unwrap().is_some()); drop(restored); @@ -1514,7 +1624,7 @@ async fn history_distinguishes_rate_limits_from_log_range_limits() { } let error = wallet.sync_history().await.unwrap_err(); if code == -32000 { - assert!(matches!(error, UsdtError::TransactionRejected { .. })); + assert!(matches!(error, UsdtError::NetworkUnavailable)); } else { assert!(matches!(error, UsdtError::RateLimited)); } @@ -1657,7 +1767,7 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending let mut reader = BufReader::new(socket); let mut line = String::new(); reader.read_line(&mut line).await.unwrap(); - let hash = line.split_whitespace().nth(1).unwrap().trim_start_matches('/').to_string(); + let hash = line.split_whitespace().nth(1).unwrap().strip_prefix("/v1/messages/tx/").unwrap().to_string(); loop { line.clear(); if reader.read_line(&mut line).await.unwrap() == 0 || line == "\r\n" { break; } @@ -1717,7 +1827,7 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending .await .unwrap() .unwrap(); - assert_eq!(attempts.lock().unwrap().len(), 1); + assert_eq!(attempts.lock().unwrap().len(), 3); assert_eq!( history .iter() @@ -1738,23 +1848,26 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) .await .unwrap(); - // Hold the refresh lock before send, while all bridge status connections stall again. + // Replenish the shared RPC burst so this checks polling, not rate-limit waiting. + tokio::time::sleep(Duration::from_secs(15)).await; + // Destination polling must not hold the mutation lock needed to send. let refresh_wallet = wallet.clone(); let refresh = tokio::spawn(async move { refresh_wallet.refresh_transfers().await }); tokio::time::timeout(Duration::from_secs(3), async { - while attempts.lock().unwrap().len() < 2 { + while attempts.lock().unwrap().len() < 6 { tokio::time::sleep(Duration::from_millis(10)).await; } }) .await .unwrap(); let result = tokio::time::timeout( - Duration::from_secs(15), + Duration::from_secs(8), wallet.send(quote.id, TEST_PHRASE.into(), None), ) .await; - refresh.await.unwrap().unwrap(); assert_eq!(result.unwrap().unwrap().status, UsdtTransferStatus::Pending); + assert!(!refresh.is_finished()); + refresh.await.unwrap().unwrap(); { let attempts = attempts.lock().unwrap(); assert_ne!(attempts[0], attempts[1]); @@ -2124,6 +2237,11 @@ async fn dense_block_history_recovers_large_receipts_without_skipping_after_rest assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); assert_eq!(history[0].fee, Some(123)); assert_eq!(restored.store.synced_block().unwrap(), Some(20001)); + let reads = chain.state.lock().unwrap().receipt_reads; + drop(restored); + let restored = chain.wallet(&dir); + sync_history_to_tip(&restored).await; + assert_eq!(chain.state.lock().unwrap().receipt_reads, reads); } #[tokio::test] @@ -2151,3 +2269,356 @@ async fn zero_value_transfers_do_not_require_receipts_or_timestamps() { assert!(wallet.sync_history().await.unwrap()); assert!(wallet.history().unwrap().is_empty()); } + +#[tokio::test] +async fn first_submission_precheck_releases_an_operation_that_was_never_sent() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + chain + .state + .lock() + .unwrap() + .authorization_change_on_block_read = true; + assert!(matches!( + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::QuoteExpired) + )); + assert!(chain.state.lock().unwrap().operations.is_empty()); + assert!(wallet.store.pending_plan("e.id).unwrap().is_none()); + let failed = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + assert_eq!(failed.status, UsdtTransferStatus::Failed); + assert_eq!(failed.received_amount, 0); + assert_eq!(failed.fee, Some(0)); + wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); +} + +#[tokio::test] +async fn moderate_gas_price_movement_preserves_the_approved_fee() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let plan = wallet.store.quote("e.id).unwrap().plan; + chain.state.lock().unwrap().gas_price = 52_000_000; + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + assert_eq!( + chain.state.lock().unwrap().operations[0].max_fee_per_gas, + plan.operation.max_fee_per_gas + ); +} + +#[tokio::test] +async fn unknown_paymaster_history_preserves_principal_fee_and_refund() { + use alloy_primitives::{Address, B256, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let payer = Address::repeat_byte(0xab); + let movement = |from, to, value, index| { + let event = transaction::Erc20::Transfer { + from, + to, + value: U256::from(value), + } + .encode_log_data(); + json!({"address": types::TOKEN, "topics":event.topics(), "data":event.data, "logIndex": U256::from(index)}) + }; + { + let mut state = chain.state.lock().unwrap(); + let op = &state.operations[0]; + let event = transaction::EntryPoint::UserOperationEvent { + userOpHash: op.hash(types::CHAIN_ID).unwrap(), + sender: wallet.address, + paymaster: payer, + nonce: U256::ZERO, + success: true, + actualGasCost: U256::from(1), + actualGasUsed: U256::from(1), + } + .encode_log_data(); + let log = json!({"address":account::ENTRY_POINT,"topics":event.topics(),"data":event.data,"transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":"0x3"}); + state.receipt_logs = Some(vec![ + movement(wallet.address, payer, 200u64, 0u64), + movement( + wallet.address, + RECIPIENT.parse().unwrap(), + 1_000_000u64, + 1u64, + ), + movement(payer, wallet.address, 50u64, 2u64), + log.clone(), + ]); + state.log_response = Some(vec![log]); + state.tip += 3; + } + let restored_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restored_dir); + sync_history_to_tip(&restored).await; + let history = restored.history().unwrap(); + assert_eq!(history.len(), 3); + assert_eq!( + history + .iter() + .filter(|t| !t.is_incoming) + .map(|t| t.amount) + .sum::(), + 1_000_200 + ); + assert_eq!( + history + .iter() + .filter(|t| t.is_incoming) + .map(|t| t.amount) + .sum::(), + 50 + ); +} + +#[tokio::test] +async fn settlement_requires_matching_canonical_receipts() { + use alloy_primitives::B256; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let receipt = { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + state + .respond(&json!({"method":"eth_getTransactionReceipt","params":[B256::repeat_byte(7)]})) + ["result"] + .clone() + }; + for (field, value) in [ + ("transactionHash", json!(B256::ZERO)), + ("blockHash", json!(B256::ZERO)), + ("blockNumber", json!("0x1")), + ] { + let mut invalid = receipt.clone(); + invalid[field] = value; + chain.state.lock().unwrap().receipt_response = Some(invalid); + assert!(matches!( + wallet.refresh_transfers().await, + Err(UsdtError::InvalidResponse) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + } + chain.state.lock().unwrap().receipt_response = Some(serde_json::Value::Null); + assert!(matches!( + wallet.refresh_transfers().await, + Err(UsdtError::NetworkUnavailable) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + chain.state.lock().unwrap().receipt_response = None; + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Confirmed + ); +} + +#[tokio::test] +async fn bridge_settlement_recovers_guid_fees_and_preserves_delivery_on_rescan() { + use alloy_primitives::{B256, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let mut wallet = chain.wallet(&dir); + let guid = B256::repeat_byte(0xab); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + std::sync::Arc::get_mut(&mut wallet) + .unwrap() + .rpc + .bridge_status_url = Some(format!("http://{}", listener.local_addr().unwrap())); + let message = json!({"guid":guid,"pathway":{"srcEid":30110,"dstEid":30109,"sender":{"address":types::OFT}},"source":{"tx":{"txHash":B256::repeat_byte(7)}},"status":{"name":"DELIVERED"}}); + let mut responses = Vec::new(); + for (pointer, value) in [ + ("/guid", json!(B256::ZERO)), + ("/pathway/srcEid", json!(30101)), + ("/pathway/dstEid", json!(30101)), + ("/pathway/sender/address", json!(RECIPIENT)), + ("/source/tx/txHash", json!(B256::ZERO)), + ("/status/name", json!("NEW_PROVIDER_STATUS")), + ("/status/name", json!("FAILED")), + ] { + let mut changed = message.clone(); + *changed.pointer_mut(pointer).unwrap() = value; + responses.push((200, json!({"data":[changed]}))); + } + responses.extend([ + (429, json!({})), + (200, json!({"data":null})), + (200, json!({"data":[message]})), + ]); + let server = tokio::spawn(async move { + for (status, body) in responses { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0; 4096]; + let size = socket.read(&mut request).await.unwrap(); + assert!(String::from_utf8_lossy(&request[..size]).starts_with("GET /v1/messages/tx/0x")); + let body = body.to_string(); + socket.write_all(format!("HTTP/1.1 {status} OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + } + }); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + let mut logs = state.event_logs(); + let log = |address, event: alloy_primitives::LogData, index| json!({"address":address,"topics":event.topics(),"data":event.data,"logIndex":U256::from(index)}); + logs.insert( + 1, + log( + types::OFT, + transaction::Oft::OFTSent { + guid, + dstEid: 30109, + fromAddress: types::BRIDGE_HELPER, + amountSentLD: U256::from(1_000_000), + amountReceivedLD: U256::from(999_999), + } + .encode_log_data(), + 1u64, + ), + ); + logs.insert( + 2, + log( + types::BRIDGE_HELPER, + transaction::BridgeHelper::LogSend { + sender: wallet.address, + oft: types::OFT, + amountLD: U256::from(1_000_000), + nativeFee: U256::from(10_000_000_000u64), + feeInToken: U256::from(300_000), + totalAmount: U256::from(1_300_000), + } + .encode_log_data(), + 2u64, + ), + ); + logs[3]["logIndex"] = json!("0x3"); + state.receipt_logs = Some(logs); + } + sync_history_to_tip(&wallet).await; + let pending = wallet.history().unwrap().remove(0); + assert_eq!(pending.status, UsdtTransferStatus::Bridging); + assert_eq!(pending.bridge_guid, Some(format!("{guid:#x}"))); + assert_eq!(pending.received_amount, 999_999); + assert_eq!(pending.fee, Some(300_123)); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); + for _ in 0..6 { + assert_eq!( + wallet.rpc.bridge_status(&pending).await.unwrap(), + UsdtTransferStatus::Bridging + ); + } + assert_eq!( + wallet.rpc.bridge_status(&pending).await.unwrap(), + UsdtTransferStatus::BridgeNeedsAttention + ); + assert!(matches!( + wallet.rpc.bridge_status(&pending).await, + Err(UsdtError::NetworkUnavailable) + )); + assert!(matches!( + wallet.rpc.bridge_status(&pending).await, + Err(UsdtError::InvalidResponse) + )); + chain.state.lock().unwrap().chain = 1; + let mut delivered = wallet.refresh_transfers().await.unwrap().remove(0); + assert_eq!(delivered.status, UsdtTransferStatus::Confirmed); + server.await.unwrap(); + delivered.bridge_guid = delivered.bridge_guid.map(|guid| guid.to_uppercase()); + delivered.tx_hash = delivered.tx_hash.to_uppercase(); + wallet.store.update_transfer(&delivered).unwrap(); + chain.state.lock().unwrap().chain = 42161; + sync_history_to_tip(&wallet).await; + assert_eq!( + wallet.history().unwrap()[0].status, + UsdtTransferStatus::Confirmed + ); + drop(wallet); + let restored_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restored_dir); + sync_history_to_tip(&restored).await; + let recovered = restored.history().unwrap().remove(0); + assert_eq!(recovered.destination, UsdtDestination::Polygon); + assert_eq!(recovered.bridge_guid, Some(format!("{guid:#x}"))); + assert_eq!(recovered.fee, Some(300_123)); +} + +#[tokio::test] +async fn destination_tokens_are_not_payment_recipients() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + for destination in [ + UsdtDestination::Arbitrum, + UsdtDestination::Ethereum, + UsdtDestination::Polygon, + UsdtDestination::Plasma, + UsdtDestination::Stable, + ] { + assert!(matches!( + wallet + .quote_transfer( + destination.token().to_checksum(None), + 1_000_000, + destination + ) + .await, + Err(UsdtError::InvalidAddress) + )); + if let Some(eid) = destination.endpoint() { + assert_eq!(UsdtDestination::from_endpoint(eid), Some(destination)); + } + } +} diff --git a/src/modules/usdt/types.rs b/src/modules/usdt/types.rs index 45245f3..3ca70c1 100644 --- a/src/modules/usdt/types.rs +++ b/src/modules/usdt/types.rs @@ -17,6 +17,22 @@ pub enum UsdtDestination { } impl UsdtDestination { + pub(super) fn token(self) -> Address { + match self { + Self::Arbitrum => TOKEN, + Self::Ethereum => address!("dAC17F958D2ee523a2206206994597C13D831ec7"), + Self::Polygon => address!("c2132D05D31c914a87C6611C10748AEb04B58e8F"), + Self::Plasma => address!("B8CE59FC3717ada4C02eaDF9682A9e934F625ebb"), + Self::Stable => address!("779Ded0c9e1022225f8E0630b35a9b54bE713736"), + } + } + + pub(super) fn from_endpoint(eid: u32) -> Option { + [Self::Ethereum, Self::Polygon, Self::Plasma, Self::Stable] + .into_iter() + .find(|d| d.endpoint() == Some(eid)) + } + pub(super) fn endpoint(self) -> Option { match self { Self::Stable => Some(30396), diff --git a/src/modules/usdt/wallet.rs b/src/modules/usdt/wallet.rs index 64ec4ab..d456a5e 100644 --- a/src/modules/usdt/wallet.rs +++ b/src/modules/usdt/wallet.rs @@ -2,7 +2,7 @@ use super::{ account::{validate_delegation, ENTRY_POINT}, amount::token_amount, keys::{derive_key, parse_address}, - paymaster::{with_margin, Pimlico, PAYMASTER}, + paymaster::{Pimlico, PAYMASTER}, rpc::Rpc, store::{QuoteData, Store}, transaction::{ @@ -45,11 +45,11 @@ impl UsdtWallet { return Err(UsdtError::NotConfigured); } let address = parse_address(&address)?; - let store = Store::open(&storage_path, &format!("{CHAIN_ID}:{}", address))?; let rpc = Rpc::new(rpc_url, CHAIN_ID)?; let paymaster = Pimlico { rpc: rpc.with_url(bundler_url)?, }; + let store = Store::open(&storage_path, &format!("{CHAIN_ID}:{}", address))?; Ok(Arc::new(Self { address, rpc, @@ -89,7 +89,16 @@ impl UsdtWallet { } let recipient = parse_address(recipient.trim())?; if recipient == self.address - || (destination == UsdtDestination::Arbitrum && recipient == TOKEN) + || recipient == destination.token() + || (destination == UsdtDestination::Arbitrum + && [ + ENTRY_POINT, + PAYMASTER, + super::account::DELEGATE, + OFT, + BRIDGE_HELPER, + ] + .contains(&recipient)) { return Err(UsdtError::InvalidAddress); } @@ -98,9 +107,6 @@ impl UsdtWallet { self.require_balance(amount, 0).await?; let (calls, received_amount, bridge_fee) = self.transfer_calls(recipient, amount, destination).await?; - if bridge_fee > 0 { - self.require_balance(amount, bridge_fee).await?; - } let nonce = self.nonce("latest").await?; let authorization = self.authorization().await?; let created_block = self.block_number().await?; @@ -166,6 +172,7 @@ impl UsdtWallet { } self.require_balance(data.quote.amount, data.quote.maximum_fee) .await?; + self.validate_bridge(&data.plan).await?; self.paymaster.validate_gas(&data.plan.operation).await?; if self .block_timestamp(self.block_number().await?) @@ -184,7 +191,7 @@ impl UsdtWallet { } let (hash, raw) = data.plan.sign(&key)?; drop(key); - let transfer = UsdtTransfer { + let mut transfer = UsdtTransfer { id: quote_id, tx_hash: String::new(), user_operation_hash: Some(format!("{hash:#x}")), @@ -201,7 +208,19 @@ impl UsdtWallet { }; self.store.record_signed(&transfer, &raw)?; // After persistence a lost response is indeterminate. Retry only the identical signed operation. - let _ = self.broadcast(&data.plan, hash).await; + if let Err(error) = self.broadcast(&data.plan, hash).await { + // These errors occur before submission; later retries may already be queued. + if matches!( + error, + UsdtError::QuoteExpired | UsdtError::UnsupportedDelegation + ) { + transfer.status = UsdtTransferStatus::Failed; + transfer.received_amount = 0; + transfer.fee = Some(0); + self.store.update_transfer(&transfer)?; + return Err(error); + } + } Ok(transfer) } @@ -221,8 +240,12 @@ impl UsdtWallet { if transfers.is_empty() { return self.history(); } - self.rpc.verify_chain().await?; - self.refresh_bridges(&transfers).await?; + if transfers + .iter() + .any(|transfer| transfer.status == UsdtTransferStatus::Pending) + { + self.rpc.verify_chain().await?; + } for mut transfer in transfers { if matches!( transfer.status, @@ -323,6 +346,8 @@ impl UsdtWallet { } } } + drop(_guard); + self.refresh_bridges(&self.store.unsettled()?).await?; self.history() } } @@ -372,6 +397,8 @@ impl UsdtWallet { transfer.received_amount = 0; transfer.fee = Some(0); } + transfer.timestamp = + u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; return self.store.update_transfer(transfer); } self.store @@ -391,28 +418,50 @@ impl UsdtWallet { let mut bridges: Vec<_> = transfers .iter() .filter(|transfer| { - matches!( - transfer.status, - UsdtTransferStatus::Bridging | UsdtTransferStatus::BridgeNeedsAttention - ) + transfer.bridge_guid.is_some() + && matches!( + transfer.status, + UsdtTransferStatus::Bridging | UsdtTransferStatus::BridgeNeedsAttention + ) }) .collect(); if bridges.is_empty() { return Ok(()); } - let offset = self.bridge_poll_offset.fetch_add(1, Ordering::Relaxed) % bridges.len(); + let offset = self.bridge_poll_offset.fetch_add(3, Ordering::Relaxed) % bridges.len(); bridges.rotate_left(offset); - // Rotate the first check so stalled bridges cannot starve later status checks or sends. - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); - for transfer in bridges { - match tokio::time::timeout_at(deadline, self.rpc.bridge_status(transfer)).await { - Ok(Ok(status)) => { - let mut transfer = transfer.clone(); - transfer.status = status; - self.store.update_transfer(&transfer)?; + let bridges = &bridges; + let check = |index: usize| async move { + let transfer = bridges.get(index).copied()?; + Some(( + transfer, + tokio::time::timeout( + std::time::Duration::from_secs(10), + self.rpc.bridge_status(transfer), + ) + .await, + )) + }; + let (first, second, third) = tokio::join!(check(0), check(1), check(2)); + for (previous, result) in [first, second, third].into_iter().flatten() { + match result { + Ok(Ok(status)) if status != previous.status => { + let _guard = self.operation.lock().await; + let Some(mut current) = self.store.transfer(&previous.id)? else { + continue; + }; + if current.tx_hash == previous.tx_hash + && current.bridge_guid == previous.bridge_guid + && current.status == previous.status + { + current.status = status; + self.store.update_transfer(¤t)?; + } } - Ok(Err(_)) => {} - Err(_) => break, + Ok(Ok(_)) => {} + _ => log::warn!( + "USDT bridge delivery lookup unavailable; retaining last known status" + ), } } Ok(()) @@ -470,18 +519,25 @@ impl UsdtWallet { if self.authorization().await?.nonce != plan.operation.eip7702_auth.nonce { return Err(UsdtError::QuoteExpired); } - let hash: B256 = self + let result: Result = self .paymaster .rpc .call( "eth_sendUserOperation", json!([plan.operation, ENTRY_POINT]), ) - .await?; - if hash != expected_hash { - return Err(UsdtError::InvalidResponse); + .await; + match result { + Ok(hash) if hash == expected_hash => Ok(()), + Ok(_) => { + log::warn!("USDT submission returned an unexpected operation hash; retaining pending payment"); + Err(UsdtError::InvalidResponse) + } + Err(error) => { + log::warn!("USDT submission could not be confirmed; retaining pending payment"); + Err(error) + } } - Ok(()) } async fn settle_from_log( &self, @@ -494,11 +550,17 @@ impl UsdtWallet { } transfer.tx_hash = serde_json::from_value(log["transactionHash"].clone())?; transfer.explorer_url = format!("{EXPLORER}/tx/{}", transfer.tx_hash); - let receipt = self - .rpc - .call("eth_getTransactionReceipt", json!([transfer.tx_hash])) - .await?; + let number = u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) + .map_err(|_| UsdtError::InvalidResponse)?; + let block = self.rpc.block(number).await?; + let hash = transfer + .tx_hash + .parse() + .map_err(|_| UsdtError::InvalidResponse)?; + let receipt = self.rpc.block_receipt(hash, &block, number).await?; self.settle(transfer, &receipt, event.success)?; + transfer.timestamp = + u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; self.store.update_transfer(transfer) } @@ -598,6 +660,51 @@ impl UsdtWallet { }; Ok(()) } + async fn validate_bridge(&self, plan: &Plan) -> Result<(), UsdtError> { + let calls = super::history::decode_calls(&plan.operation.call_data)?; + let Some((_, data)) = calls.iter().find(|(target, _)| *target == BRIDGE_HELPER) else { + return Ok(()); + }; + let send = + BridgeHelper::sendCall::abi_decode(data).map_err(|_| UsdtError::InvalidResponse)?; + let required = self + .rpc + .contract( + OFT, + Oft::quoteSendCall { + param: send.param.clone(), + payInLzToken: false, + }, + ) + .await?; + if !required.lzTokenFee.is_zero() || required.nativeFee > send.fee.nativeFee { + return Err(UsdtError::QuoteExpired); + } + if self.rpc.balance(BRIDGE_HELPER).await? < send.fee.nativeFee { + return Err(UsdtError::UnsupportedRoute); + } + let total = self + .rpc + .contract( + BRIDGE_HELPER, + BridgeHelper::quoteSendCall { + param: send.param, + fee: send.fee, + }, + ) + .await?; + let allowance = calls + .iter() + .filter(|(target, _)| *target == TOKEN) + .filter_map(|(_, data)| Erc20::approveCall::abi_decode(data).ok()) + .find(|call| call.spender == BRIDGE_HELPER) + .ok_or(UsdtError::InvalidResponse)?; + if total > allowance.amount { + return Err(UsdtError::QuoteExpired); + } + Ok(()) + } + async fn transfer_calls( &self, recipient: Address, @@ -628,6 +735,9 @@ impl UsdtWallet { if token != TOKEN || helper_token != TOKEN || peer.is_zero() { return Err(UsdtError::UnsupportedRoute); } + if recipient.into_word() == peer { + return Err(UsdtError::InvalidAddress); + } let mut param = SendParam { dstEid: eid, to: recipient.into_word(), @@ -655,7 +765,7 @@ impl UsdtWallet { return Err(UsdtError::InvalidAmount); } param.minAmountLD = oft.receipt.amountReceivedLD; - let fee = self + let mut fee = self .rpc .contract( OFT, @@ -665,6 +775,8 @@ impl UsdtWallet { }, ) .await?; + // Native headroom is quoted into the approved USDT maximum. + fee.nativeFee = with_margin(fee.nativeFee, 10)?; let maximum_native = self .rpc .contract(BRIDGE_HELPER, BridgeHelper::maxGasCall {}) @@ -692,7 +804,7 @@ impl UsdtWallet { let token_fee = total .checked_sub(U256::from(amount)) .ok_or(UsdtError::InvalidResponse)?; - let token_fee = with_margin(token_fee)?; + let token_fee = with_margin(token_fee, 20)?; let approval = U256::from(amount) .checked_add(token_fee) .ok_or(UsdtError::InvalidResponse)?; @@ -736,3 +848,15 @@ impl UsdtWallet { pub(super) fn now() -> u64 { chrono::Utc::now().timestamp().max(0) as u64 } + +fn with_margin(value: U256, percent: u8) -> Result { + value + .checked_add( + value + .checked_mul(U256::from(percent)) + .ok_or(UsdtError::InvalidResponse)? + / U256::from(100), + ) + .and_then(|value| value.checked_add(U256::from(1))) + .ok_or(UsdtError::InvalidResponse) +} diff --git a/tests/usdt-fork/provider.mjs b/tests/usdt-fork/provider.mjs index e4650a3..43453d6 100644 --- a/tests/usdt-fork/provider.mjs +++ b/tests/usdt-fork/provider.mjs @@ -109,7 +109,12 @@ function pack(op) { signature: op.signature, }; } +let drainHelperBeforeBroadcast = false; async function dispatch(method, params) { + if (method === 'test_drainHelperBeforeNextBroadcast') { + drainHelperBeforeBroadcast = true; + return true; + } if (method === 'eth_estimateUserOperationGas' || method === 'eth_sendUserOperation') { const op = params[0]; assert.equal(op.factory, '0x7702'); @@ -138,6 +143,7 @@ async function dispatch(method, params) { }; if (method === 'pimlico_getUserOperationGasPrice') return { + slow: { maxFeePerGas: toBeHex(90_000_000), maxPriorityFeePerGas: toBeHex(1_000_000) }, fast: { maxFeePerGas: toBeHex(100_000_000), maxPriorityFeePerGas: toBeHex(1_000_000) }, }; if (method === 'eth_estimateUserOperationGas') return gas; @@ -160,6 +166,10 @@ async function dispatch(method, params) { return { paymaster: pmAddress, paymasterData: concat([unsigned, signature]), ...limits }; } if (method === 'eth_sendUserOperation') { + if (drainHelperBeforeBroadcast) { + drainHelperBeforeBroadcast = false; + await rpc.send('anvil_setBalance', ['0xa90f03c856d01f698e7071b393387cd75a8a319a', '0x0']); + } const op = pack(params[0]); const hash = await rpc.send('eth_call', [ { to: entryAddress, data: entry.interface.encodeFunctionData('getUserOpHash', [op]) }, From 4108e38f29e68f453d405c512e5a0db890f43cd3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 25 Sep 2026 09:07:47 +0300 Subject: [PATCH 3/6] fix: harden usdt bridge recovery and polling --- Package.swift | 2 +- bindings/ios/bitkitcore.swift | 18 +- src/modules/usdt/README.md | 8 +- src/modules/usdt/errors.rs | 2 +- src/modules/usdt/history.rs | 150 +++++++----- src/modules/usdt/paymaster.rs | 22 +- src/modules/usdt/payment_request.rs | 15 +- src/modules/usdt/rpc.rs | 29 ++- src/modules/usdt/store.rs | 7 - src/modules/usdt/tests.rs | 361 +++++++++++++++++++++++----- src/modules/usdt/types.rs | 2 + src/modules/usdt/wallet.rs | 102 ++++---- tests/usdt-fork/provider.mjs | 7 +- 13 files changed, 530 insertions(+), 195 deletions(-) diff --git a/Package.swift b/Package.swift index 6273238..2f15947 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription import Foundation let tag = "v0.6.0" -let checksum = "2c9b706e4bbbabc529082c552f37a3bb0f2258a033c2c5aafbd6f0a8392a44d3" +let checksum = "994ecf4f6ec42fa8d1482197b3644e58b7f43957dd058e97a9e286d06dd18edd" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 76ccf5c..ba5004c 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -15906,12 +15906,20 @@ public func FfiConverterTypeUrDecoderStatus_lower(_ value: UrDecoderStatus) -> R public struct UsdtPaymentRequest { public var recipient: String public var amount: UInt64? + /** + * An explicit network in the payment URI; bare addresses have no restriction. + */ + public var chainId: UInt64? // Default memberwise initializers are never public by default, so we // declare one manually. - public init(recipient: String, amount: UInt64?) { + public init(recipient: String, amount: UInt64?, + /** + * An explicit network in the payment URI; bare addresses have no restriction. + */chainId: UInt64?) { self.recipient = recipient self.amount = amount + self.chainId = chainId } } @@ -15928,12 +15936,16 @@ extension UsdtPaymentRequest: Equatable, Hashable { if lhs.amount != rhs.amount { return false } + if lhs.chainId != rhs.chainId { + return false + } return true } public func hash(into hasher: inout Hasher) { hasher.combine(recipient) hasher.combine(amount) + hasher.combine(chainId) } } @@ -15949,13 +15961,15 @@ public struct FfiConverterTypeUsdtPaymentRequest: FfiConverterRustBuffer { return try UsdtPaymentRequest( recipient: FfiConverterString.read(from: &buf), - amount: FfiConverterOptionUInt64.read(from: &buf) + amount: FfiConverterOptionUInt64.read(from: &buf), + chainId: FfiConverterOptionUInt64.read(from: &buf) ) } public static func write(_ value: UsdtPaymentRequest, into buf: inout [UInt8]) { FfiConverterString.write(value.recipient, into: &buf) FfiConverterOptionUInt64.write(value.amount, into: &buf) + FfiConverterOptionUInt64.write(value.chainId, into: &buf) } } diff --git a/src/modules/usdt/README.md b/src/modules/usdt/README.md index 0d835f1..9b45fbd 100644 --- a/src/modules/usdt/README.md +++ b/src/modules/usdt/README.md @@ -18,15 +18,15 @@ Owned mnemonic/passphrase/seed buffers are zeroized and signing keys are erased The pinned ERC-20 paymaster collects USDT. Its finite approval includes a 5% margin; the displayed maximum fee comes from signed gas limits and paymaster terms, not the allowance. Call/pre-verification estimates receive 10% execution/L1-data headroom; the charged pre-verification margin is included in the maximum. A residual paymaster allowance can remain and is reset to a finite amount on the next payment. -`usdt_parse_payment_request` accepts raw addresses and chain-qualified ERC-681 requests for the pinned token, with exact atomic/scientific amounts. Ambiguous or unsupported parameters are rejected. The caller reviews the parsed amount before requesting a quote. +`usdt_parse_payment_request` accepts raw addresses and chain-qualified ERC-681 requests for the pinned token, with exact atomic/scientific amounts. Ambiguous or unsupported parameters are rejected. The returned `chain_id` preserves explicit network restrictions; bare addresses leave it unset. Callers must honor it and review the parsed amount before requesting a quote. ## Persistence and recovery Signed operations persist atomically before submission. Lost or rejected submission responses do not prove nonexecution: recovery retries only the identical signed operation. A quote ID cannot authorize a second payment. One source-chain payment remains pending at a time. -A matching operation event settles the payment. Expired signed paymaster terms and an unchanged confirmed EntryPoint nonce release an unmined operation; the shorter quote deadline does not. With an advanced nonce and missing indexed events, recovery checks every receipt in the consuming block. A matching event settles/replaces the payment; complete absence proves external nonce consumption. Missing receipts preserve the pending operation. Progress is stored by payment and block hash so interruption does not restart the proof or carry it onto another block. +A matching event in a canonical receipt settles the payment. Discovery logs alone never decide the outcome. Expired signed paymaster terms and a confirmed EntryPoint nonce that has not passed the signed nonce release an unmined operation; the shorter quote deadline does not. With an advanced nonce and missing indexed events, recovery checks every receipt in the consuming block. A matching event settles/replaces the payment; complete absence proves external nonce consumption. Missing receipts preserve the pending operation. Progress is stored by payment and block hash so interruption does not restart the proof or carry it onto another block. -Seed restoration recovers deposits and outgoing activity from genesis, including transfers before delegation and sends through another wallet. Supported direct EntryPoint calls recover payment/fee attribution; unknown wrappers preserve raw token transfers instead of guessing their intent. Failed payments retain attempted amounts but have no delivered amount. +Seed restoration recovers deposits and outgoing activity from genesis, including transfers before delegation and sends through another wallet. Supported direct EntryPoint calls and paymaster modes recover payment/fee attribution; unknown wrappers or payment modes preserve raw token transfers instead of guessing their intent. Failed payments retain attempted amounts but have no delivered amount. `sync_history` returns `true` when caught up and `false` when more work remains. It uses adaptive log ranges and a 20-second soft budget between persisted receipts; an in-flight receipt may finish later. A single-block log overflow falls back to that block's individual receipts. Completed fallback scans are retained by canonical block hash within the revisit window. Zero/self transfers are discarded before enrichment. Network failures preserve completed work and never silently skip a block. @@ -42,7 +42,7 @@ Both chain and bundler endpoints must be controlled, credential-free HTTPS URLs; The outbound bridge API supports Ethereum (30101), Polygon (30109), Plasma (30383) and Stable (30396), alongside direct Arbitrum transfers. Native release flows expose Arbitrum only; bridge routes require explicit service enablement and destination acceptance. Plain deposits on another chain are not automatically forwarded. -Bridge quotes include 10% native messaging-fee headroom and 20% token-conversion headroom, both within the displayed maximum USDT fee. Before signing, the stored native fee, helper liquidity and token approval are checked against current requirements without raising approved limits. Delivery checks process up to three transfers concurrently outside the send lock, with a ten-second request budget; failed lookups retain the last known status. +Bridge quotes include 10% native messaging-fee headroom and 20% token-conversion headroom, both within the displayed maximum USDT fee. Before signing or rebroadcasting, the stored native fee, helper liquidity and token approval are checked against current requirements without raising approved limits. Delivery checks process up to three transfers concurrently outside the send lock, with a ten-second request budget, even when source recovery fails; failed lookups retain the last known status. Bridges use the pinned OFT and TransactionValueHelper with zero account ETH, a finite USDT approval covering principal/fee, and atomic helper-allowance revocation. The deployed helper requires native liquidity and retains behaviors noted in its OpenZeppelin audit; its verified runtime is not the audit-remediated implementation. Source success means bridging, not delivered. LayerZero status must match the operation GUID/pathway before confirmation; blocked delivery remains visible and never triggers an automatic paid retry. RPC providers see queried addresses; LayerZero Scan sees bridge transaction hashes. diff --git a/src/modules/usdt/errors.rs b/src/modules/usdt/errors.rs index 059f2c2..688d7b8 100644 --- a/src/modules/usdt/errors.rs +++ b/src/modules/usdt/errors.rs @@ -18,7 +18,7 @@ pub enum UsdtError { QuoteExpired, #[error("A USDT transaction is pending. Wait for confirmation before sending again")] PendingTransfer, - #[error("The selected USDT0 route is unavailable")] + #[error("The selected USDT payment route is unavailable")] UnsupportedRoute, #[error("USDT payments are not configured for this app build")] NotConfigured, diff --git a/src/modules/usdt/history.rs b/src/modules/usdt/history.rs index 83ff6c9..9ea6a98 100644 --- a/src/modules/usdt/history.rs +++ b/src/modules/usdt/history.rs @@ -5,7 +5,7 @@ use super::{ types::{BRIDGE_HELPER, EXPLORER, OFT, TOKEN}, UsdtDestination, UsdtError, UsdtTransfer, UsdtTransferStatus, UsdtWallet, }; -use alloy_primitives::{Address, Bytes, U256}; +use alloy_primitives::{Address, Bytes, B256, U256}; use alloy_sol_types::{SolCall, SolEvent}; use serde_json::{json, Value}; use std::{collections::BTreeMap, sync::atomic::Ordering}; @@ -25,14 +25,14 @@ impl UsdtWallet { if start > tip { return Err(UsdtError::NetworkUnavailable); } - let mut ceiling = MAX_LOG_RANGE; + let initial_limit = self.history_range_limit.load(Ordering::Relaxed); + let mut ceiling = initial_limit; let mut next = start; - let mut width = self - .history_range_limit - .load(Ordering::Relaxed) - .min(tip - start + 1); + let mut width = initial_limit.min(tip - start + 1); while next <= tip { if tokio::time::Instant::now() >= deadline { + self.history_range_limit + .store((ceiling * 2).min(MAX_LOG_RANGE), Ordering::Relaxed); return Ok(false); } let end = next.saturating_add(width - 1).min(tip); @@ -46,7 +46,11 @@ impl UsdtWallet { Err(_) => { self.history_range_limit .store((width / 2).max(1), Ordering::Relaxed); - return Err(UsdtError::NetworkUnavailable); + return if next > start { + Ok(false) + } else { + Err(UsdtError::NetworkUnavailable) + }; } }; match result { @@ -54,7 +58,7 @@ impl UsdtWallet { if self.store.history_progress()? != Some(next) { self.store.save_history_progress(next)?; } - let mut timestamps = BTreeMap::new(); + let mut blocks = BTreeMap::new(); for ((block, hash), logs) in transactions { if self.store.has_history_receipt(&hash)? { continue; @@ -70,23 +74,32 @@ impl UsdtWallet { .and_then(|data| Erc20::Transfer::decode_log_data(&data).ok()) .is_some_and(|event| event.from == self.address) }); + let canonical = match blocks.entry(block) { + std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(), + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(self.rpc.block(block).await?) + } + }; + for log in &logs { + if serde_json::from_value::(log["blockHash"].clone())? + != canonical.hash + { + return Err(UsdtError::NetworkUnavailable); + } + } let receipt = if needs_receipt { self.rpc - .call("eth_getTransactionReceipt", json!([hash])) + .block_receipt( + hash.parse().map_err(|_| UsdtError::InvalidResponse)?, + canonical.hash, + block, + ) .await? } else { json!({"logs": logs}) }; - let timestamp = - if let Some(timestamp) = self.store.transaction_timestamp(&hash)? { - timestamp - } else if let Some(timestamp) = timestamps.get(&block) { - *timestamp - } else { - let timestamp = self.block_timestamp(block).await?; - timestamps.insert(block, timestamp); - timestamp - }; + let timestamp = u64::try_from(canonical.timestamp) + .map_err(|_| UsdtError::InvalidResponse)?; self.save_receipt_history(&hash, timestamp, &receipt) .await?; } @@ -101,16 +114,26 @@ impl UsdtWallet { if !self.scan_block(next, deadline).await? { return Ok(false); } + // A dense block is not a range limit for the blocks after it. + ceiling = MAX_LOG_RANGE; + width = MAX_LOG_RANGE; } Err(UsdtError::NetworkUnavailable) => { self.history_range_limit .store((width / 2).max(1), Ordering::Relaxed); - return Err(UsdtError::NetworkUnavailable); + return if next > start { + Ok(false) + } else { + Err(UsdtError::NetworkUnavailable) + }; } Err(error) => return Err(error), } if end == tip { self.store.complete_history(tip)?; + // Probe for a higher provider limit only after completing a scan. + self.history_range_limit + .store((ceiling * 2).min(MAX_LOG_RANGE), Ordering::Relaxed); return Ok(true); } next = end + 1; @@ -144,7 +167,7 @@ impl UsdtWallet { if tokio::time::Instant::now() >= deadline { return Ok(false); } - let receipt = self.rpc.block_receipt(*hash, &block, number).await?; + let receipt = self.rpc.block_receipt(*hash, block.hash, number).await?; self.save_receipt_history(&id, timestamp, &receipt).await?; } if self.rpc.block(number).await?.hash != block.hash { @@ -191,7 +214,8 @@ impl UsdtWallet { continue; } } - let hash: String = serde_json::from_value(log["transactionHash"].clone())?; + let hash: B256 = serde_json::from_value(log["transactionHash"].clone())?; + let hash = format!("{hash:#x}"); let block = u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) .map_err(|_| UsdtError::InvalidResponse)?; if !(start..=end).contains(&block) { @@ -263,6 +287,16 @@ impl UsdtWallet { .rpc .call("eth_getTransactionByHash", json!([hash])) .await?; + if tx.is_null() { + return Err(UsdtError::NetworkUnavailable); + } + if serde_json::from_value::(tx["hash"].clone())? + != hash + .parse::() + .map_err(|_| UsdtError::InvalidResponse)? + { + return Err(UsdtError::InvalidResponse); + } let input: Bytes = serde_json::from_value(tx["input"].clone())?; let target: Option
= serde_json::from_value(tx["to"].clone())?; // A wrapper's outer calldata need not describe the operation it executes. @@ -291,7 +325,10 @@ impl UsdtWallet { }) else { continue; }; - let Some((recipient, amount, destination)) = decode_payment(&op.callData)? else { + if !super::paymaster::supported_payment(&op.paymasterAndData) { + continue; + } + let Some((recipient, amount, destination)) = decode_payment(&op.callData) else { continue; }; (recipient.to_checksum(None), amount, destination) @@ -311,7 +348,7 @@ impl UsdtWallet { timestamp, explorer_url: format!("{EXPLORER}/tx/{hash}"), }; - self.settle(&mut transfer, receipt, event.success)?; + self.settle(&mut transfer, receipt)?; let operation_logs = super::transaction::operation_logs(receipt, event.userOpHash)?; let outgoing_ids: Vec<_> = operation_logs .iter() @@ -327,54 +364,39 @@ impl UsdtWallet { } } -fn decode_payment(data: &[u8]) -> Result, UsdtError> { - let Ok(calls) = decode_calls(data) else { - return Ok(None); - }; +fn decode_payment(data: &[u8]) -> Option<(Address, u64, UsdtDestination)> { let mut payment = None; - let mut payment_count = 0; - let mut supported = true; - for (target, data) in calls { - if target == TOKEN { + for (target, data) in decode_calls(data).ok()? { + let next = if target == TOKEN { if let Ok(call) = Erc20::transferCall::abi_decode(&data) { - payment_count += 1; - let Ok(amount) = token_amount(call.amount) else { - return Ok(None); - }; - payment = Some((call.recipient, amount, UsdtDestination::Arbitrum)); - } else if Erc20::approveCall::abi_decode(&data).is_err() { - supported = false; + ( + call.recipient, + token_amount(call.amount).ok()?, + UsdtDestination::Arbitrum, + ) + } else if Erc20::approveCall::abi_decode(&data).is_ok() { + continue; + } else { + return None; } } else if target == BRIDGE_HELPER { - if let Ok(call) = BridgeHelper::sendCall::abi_decode(&data) { - if call.oft != OFT { - supported = false; - continue; - } - let Some(destination) = UsdtDestination::from_endpoint(call.param.dstEid) else { - supported = false; - continue; - }; - payment_count += 1; - payment = Some(( - Address::from_word(call.param.to), - match token_amount(call.param.amountLD) { - Ok(amount) => amount, - Err(_) => return Ok(None), - }, - destination, - )); - } else { - supported = false; + let call = BridgeHelper::sendCall::abi_decode(&data).ok()?; + if call.oft != OFT { + return None; } + ( + Address::from_word(call.param.to), + token_amount(call.param.amountLD).ok()?, + UsdtDestination::from_endpoint(call.param.dstEid)?, + ) } else { - supported = false; + return None; + }; + if payment.replace(next).is_some() { + return None; } } - if !supported || payment_count != 1 { - return Ok(None); - } - Ok(payment) + payment } pub(super) fn decode_calls(data: &[u8]) -> Result, UsdtError> { diff --git a/src/modules/usdt/paymaster.rs b/src/modules/usdt/paymaster.rs index cef65e7..f680706 100644 --- a/src/modules/usdt/paymaster.rs +++ b/src/modules/usdt/paymaster.rs @@ -205,11 +205,14 @@ impl Pimlico { return Err(UsdtError::InvalidResponse); } op.paymaster_data = data.paymaster_data; - if let Some(gas) = data.paymaster_verification_gas_limit { - op.paymaster_verification_gas_limit = gas; - } - if let Some(gas) = data.paymaster_post_op_gas_limit { - op.paymaster_post_op_gas_limit = gas; + // Final data signs the estimated limits; only stub data supplies gas estimates. + if method == "pm_getPaymasterStubData" { + if let Some(gas) = data.paymaster_verification_gas_limit { + op.paymaster_verification_gas_limit = gas; + } + if let Some(gas) = data.paymaster_post_op_gas_limit { + op.paymaster_post_op_gas_limit = gas; + } } Ok(()) } @@ -347,6 +350,15 @@ impl Terms { } } +// Packed EntryPoint paymaster data starts after the address and two 16-byte gas limits. +pub(super) fn supported_payment(data: &[u8]) -> bool { + data.get(..20) + .is_some_and(|address| address == PAYMASTER.as_slice()) + && data + .get(52..) + .is_some_and(|terms| Terms::decode(terms).is_ok()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/modules/usdt/payment_request.rs b/src/modules/usdt/payment_request.rs index d28a174..d509f35 100644 --- a/src/modules/usdt/payment_request.rs +++ b/src/modules/usdt/payment_request.rs @@ -7,20 +7,21 @@ use alloy_primitives::Address; #[uniffi::export] pub fn usdt_parse_payment_request(value: String) -> Result { - let (recipient, amount) = parse_request(&value)?; + let (recipient, amount, chain_id) = parse_request(&value)?; Ok(UsdtPaymentRequest { recipient: recipient.to_checksum(None), amount, + chain_id, }) } -fn parse_request(value: &str) -> Result<(Address, Option), UsdtError> { +fn parse_request(value: &str) -> Result<(Address, Option, Option), UsdtError> { let value = value.trim(); if value.len() > 2048 { return Err(UsdtError::InvalidAddress); } let Some((scheme, uri)) = value.split_once(':') else { - return Ok((parse_address(value)?, None)); + return Ok((parse_address(value)?, None, None)); }; if !scheme.eq_ignore_ascii_case("ethereum") { return Err(UsdtError::InvalidAddress); @@ -35,7 +36,7 @@ fn parse_request(value: &str) -> Result<(Address, Option), UsdtError> { if chain != CHAIN_ID.to_string() || !query.is_empty() { return Err(UsdtError::WrongNetwork); } - return Ok((parse_address(address)?, None)); + return Ok((parse_address(address)?, None, Some(CHAIN_ID))); }; if chain != CHAIN_ID.to_string() || parse_address(address)? != TOKEN { return Err(UsdtError::WrongNetwork); @@ -53,5 +54,9 @@ fn parse_request(value: &str) -> Result<(Address, Option), UsdtError> { _ => return Err(UsdtError::InvalidAddress), } } - Ok((recipient.ok_or(UsdtError::InvalidAddress)?, amount)) + Ok(( + recipient.ok_or(UsdtError::InvalidAddress)?, + amount, + Some(CHAIN_ID), + )) } diff --git a/src/modules/usdt/rpc.rs b/src/modules/usdt/rpc.rs index 0d6af05..27e6b65 100644 --- a/src/modules/usdt/rpc.rs +++ b/src/modules/usdt/rpc.rs @@ -110,6 +110,7 @@ impl Rpc { "credit", "too many requests", "requests per", + "request rate", "compute units", ] .iter() @@ -117,10 +118,21 @@ impl Rpc { { return Err(UsdtError::RateLimited); } - if method == "eth_getLogs" && error.code == -32005 { + if method == "eth_getLogs" + && (error.code == -32005 + || [ + "block range too", + "block range exceeds", + "query returned too many", + "response size exceeded", + "log query limit", + ] + .iter() + .any(|term| message.contains(term))) + { return Err(UsdtError::LogRangeTooLarge); } - if error.code == -32002 { + if matches!(error.code, -32002 | -32603) { return Err(UsdtError::NetworkUnavailable); } if matches!( @@ -214,14 +226,15 @@ impl Rpc { } pub async fn block(&self, number: u64) -> Result { - self.call("eth_getBlockByNumber", json!([U256::from(number), false])) - .await + self.call::>("eth_getBlockByNumber", json!([U256::from(number), false])) + .await? + .ok_or(UsdtError::NetworkUnavailable) } pub async fn block_receipt( &self, hash: B256, - block: &Block, + block_hash: B256, number: u64, ) -> Result { let receipt: Value = self @@ -231,7 +244,7 @@ impl Rpc { return Err(UsdtError::NetworkUnavailable); } if serde_json::from_value::(receipt["transactionHash"].clone())? != hash - || serde_json::from_value::(receipt["blockHash"].clone())? != block.hash + || serde_json::from_value::(receipt["blockHash"].clone())? != block_hash || serde_json::from_value::(receipt["blockNumber"].clone())? != U256::from(number) { return Err(UsdtError::InvalidResponse); @@ -322,6 +335,10 @@ mod tests { 502, r#"{"error":{"code":-32002,"message":"Provider unavailable"}}"#, ), + ( + 200, + r#"{"error":{"code":-32603,"message":"Internal server error"}}"#, + ), (503, "Service unavailable"), ] { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/src/modules/usdt/store.rs b/src/modules/usdt/store.rs index 75212fc..3cd29e6 100644 --- a/src/modules/usdt/store.rs +++ b/src/modules/usdt/store.rs @@ -225,13 +225,6 @@ impl Store { Ok(()) } - pub fn transaction_timestamp(&self, hash: &str) -> Result, UsdtError> { - Ok(self.connection()?.query_row( - "SELECT json_extract(data, '$.timestamp') FROM usdt_transfers WHERE json_extract(data, '$.tx_hash')=?1 AND json_extract(data, '$.status') != 'Pending' LIMIT 1", - [hash], |row| row.get(0), - ).optional()?) - } - pub fn begin_history_block(&self, number: u64, hash: &str) -> Result { let mut connection = self.connection()?; let tx = connection.transaction()?; diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index 4b0ed52..d42098d 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -141,6 +141,16 @@ fn payment_request_rejects_wrong_chain_and_malformed_checksum() { usdt_parse_payment_request(request).unwrap().recipient, address ); + assert_eq!( + usdt_parse_payment_request(address.into()).unwrap().chain_id, + None + ); + assert_eq!( + usdt_parse_payment_request(format!("ethereum:{address}@42161")) + .unwrap() + .chain_id, + Some(42161) + ); for invalid in [ format!("ethereum:{address}@42161/transfer?address={address}"), format!("ethereum:{address}@1"), @@ -383,7 +393,20 @@ impl MockChain { if delay { tokio::time::sleep(std::time::Duration::from_secs(6)).await; } - let response = server_state.lock().unwrap().respond(&body).to_string(); + let mut response = server_state.lock().unwrap().respond(&body); + if body["method"] == "eth_getLogs" { + if let Some(logs) = response["result"].as_array_mut() { + for log in logs { + log.as_object_mut().unwrap().entry("blockHash").or_insert( + serde_json::json!(alloy_primitives::B256::repeat_byte(9)), + ); + } + } + } + if body["method"] == "eth_getTransactionByHash" && !response["result"].is_null() { + response["result"]["hash"] = body["params"][0].clone(); + } + let response = response.to_string(); let response=format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}",response.len()); let _ = socket.write_all(response.as_bytes()).await; } @@ -441,6 +464,30 @@ impl ChainState { let data: Bytes = serde_json::from_value(body["params"][0]["data"].clone()).unwrap(); use transaction::{BridgeHelper, MessagingFee, OFTLimit, OFTReceipt, Oft}; + let target: alloy_primitives::Address = + serde_json::from_value(body["params"][0]["to"].clone()).unwrap(); + if data.starts_with(&Oft::tokenCall::SELECTOR) { + assert!([types::OFT, types::BRIDGE_HELPER].contains(&target)); + } + if [ + Oft::peersCall::SELECTOR, + Oft::quoteOFTCall::SELECTOR, + Oft::quoteSendCall::SELECTOR, + ] + .iter() + .any(|selector| data.starts_with(selector)) + { + assert_eq!(target, types::OFT); + } + if [ + BridgeHelper::maxGasCall::SELECTOR, + BridgeHelper::quoteSendCall::SELECTOR, + ] + .iter() + .any(|selector| data.starts_with(selector)) + { + assert_eq!(target, types::BRIDGE_HELPER); + } let encoded = if data.starts_with(&transaction::EntryPoint::getNonceCall::SELECTOR) { let block = serde_json::from_value::(body["params"][1].clone()).ok(); @@ -515,6 +562,8 @@ impl ChainState { json!({"paymaster":self.paymaster,"paymasterData":Bytes::from(data)}); if body["method"] == "pm_getPaymasterStubData" { response["paymasterPostOpGasLimit"] = json!("0x186a0"); + } else { + response["paymasterVerificationGasLimit"] = json!("0xc350"); } response } @@ -906,50 +955,54 @@ async fn wrong_network_owner_nonce_balance_and_paymaster_cannot_sign() { #[tokio::test] async fn expired_unmined_operation_releases_nonce_for_a_new_approval() { - let chain = MockChain::start().await; - let dir = tempfile::tempdir().unwrap(); - let wallet = chain.wallet(&dir); - let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) - .await - .unwrap(); - wallet - .send(quote.id, TEST_PHRASE.into(), None) - .await - .unwrap(); - { - let mut state = chain.state.lock().unwrap(); - state.timestamp += alloy_primitives::U256::from(180); - state.tip += 3; - state.max_log_range = Some(1); - } - assert_eq!( - wallet.refresh_transfers().await.unwrap()[0].status, - UsdtTransferStatus::Pending - ); - assert!(matches!( - wallet + for nonce in [0, 1] { + let chain = MockChain::start().await; + chain.state.lock().unwrap().nonce = nonce; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) - .await, - Err(UsdtError::PendingTransfer) - )); - chain.state.lock().unwrap().timestamp += alloy_primitives::U256::from(421); - assert_eq!( - wallet.refresh_transfers().await.unwrap()[0].status, - UsdtTransferStatus::Failed - ); - let next = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) - .await - .unwrap(); - assert_eq!( + .await + .unwrap(); wallet - .send(next.id, TEST_PHRASE.into(), None) + .send(quote.id, TEST_PHRASE.into(), None) .await - .unwrap() - .status, - UsdtTransferStatus::Pending - ); + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.nonce = 0; + state.timestamp += alloy_primitives::U256::from(180); + state.tip += 3; + state.max_log_range = Some(1); + } + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Pending + ); + assert!(matches!( + wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await, + Err(UsdtError::PendingTransfer) + )); + chain.state.lock().unwrap().timestamp += alloy_primitives::U256::from(421); + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Failed + ); + let next = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + assert_eq!( + wallet + .send(next.id, TEST_PHRASE.into(), None) + .await + .unwrap() + .status, + UsdtTransferStatus::Pending + ); + } } #[tokio::test] @@ -1023,6 +1076,16 @@ async fn bridge_payment_bounds_token_fees_and_revokes_helper_approval() { .await, Err(UsdtError::UnsupportedRoute) )); + chain.state.lock().unwrap().helper_balance = U256::from(1_000_000_000_000_000u64); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + chain.state.lock().unwrap().helper_balance = U256::ZERO; + chain.state.lock().unwrap().tip += 3; + wallet.refresh_transfers().await.unwrap(); + assert_eq!(chain.state.lock().unwrap().operations.len(), 1); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); } #[tokio::test] @@ -1082,13 +1145,13 @@ async fn bundled_operations_cannot_contribute_another_payments_bridge_status_or_ .unwrap(); let other_hash = B256::repeat_byte(2); let guid = B256::repeat_byte(3); - let event = |hash| { + let event = |hash, success| { transaction::EntryPoint::UserOperationEvent { userOpHash: hash, sender: wallet.address, paymaster: paymaster::PAYMASTER, nonce: U256::ZERO, - success: true, + success, actualGasCost: U256::from(1), actualGasUsed: U256::from(1), } @@ -1098,22 +1161,24 @@ async fn bundled_operations_cannot_contribute_another_payments_bridge_status_or_ let receipt = json!({"logs":[ log(types::OFT, transaction::Oft::OFTSent { guid, dstEid:30109, fromAddress:types::BRIDGE_HELPER, amountSentLD:U256::from(1_000_000), amountReceivedLD:U256::from(1_000_000) }.encode_log_data()), log(types::BRIDGE_HELPER, transaction::BridgeHelper::LogSend { sender:wallet.address, oft:types::OFT, amountLD:U256::from(1_000_000), nativeFee:U256::from(100), feeInToken:U256::from(500), totalAmount:U256::from(1_000_500) }.encode_log_data()), - log(account::ENTRY_POINT, event(other_hash)), + log(account::ENTRY_POINT, event(other_hash, true)), log(paymaster::PAYMASTER, transaction::Paymaster::UserOperationSponsored { userOpHash:own_hash, user:wallet.address, paymasterMode:1, token:types::TOKEN, tokenAmountPaid:U256::from(123), exchangeRate:U256::from(1) }.encode_log_data()), - log(account::ENTRY_POINT, event(own_hash)), + log(account::ENTRY_POINT, event(own_hash, true)), ]}); assert_eq!( transaction::operation_logs(&receipt, own_hash).unwrap(), &receipt["logs"].as_array().unwrap()[3..] ); - wallet.settle(&mut transfer, &receipt, false).unwrap(); + let mut failed = receipt.clone(); + failed["logs"][4] = log(account::ENTRY_POINT, event(own_hash, false)); + wallet.settle(&mut transfer, &failed).unwrap(); assert_eq!(transfer.status, UsdtTransferStatus::Failed); assert_eq!(transfer.fee, Some(123)); assert_eq!(transfer.bridge_guid, None); - wallet.settle(&mut transfer, &receipt, true).unwrap(); + wallet.settle(&mut transfer, &receipt).unwrap(); assert_eq!(transfer.status, UsdtTransferStatus::BridgeNeedsAttention); assert_eq!(transfer.bridge_guid, None); - assert_eq!(transfer.fee, Some(123)); + assert_eq!(transfer.fee, None); let mut receipt = receipt; let bridge_log = receipt["logs"][1].clone(); @@ -1121,7 +1186,7 @@ async fn bundled_operations_cannot_contribute_another_payments_bridge_status_or_ .as_array_mut() .unwrap() .insert(4, bridge_log); - wallet.settle(&mut transfer, &receipt, true).unwrap(); + wallet.settle(&mut transfer, &receipt).unwrap(); assert_eq!(transfer.fee, Some(623)); } @@ -1134,13 +1199,17 @@ async fn deployed_contracts_collect_usdt_fees_and_revert_failed_bridges_atomical let client: String = rpc.call("web3_clientVersion", json!([])).await.unwrap(); assert!(client.to_lowercase().contains("anvil")); let dir = tempfile::tempdir().unwrap(); - let wallet = UsdtWallet::new( + let mut wallet = UsdtWallet::new( usdt_address(TEST_PHRASE.into(), None).unwrap(), dir.path().join("usdt.sqlite").to_string_lossy().into(), std::env::var("USDT_FORK_RPC_URL").unwrap_or_else(|_| "http://127.0.0.1:18545".into()), std::env::var("USDT_FORK_BUNDLER_URL").unwrap_or_else(|_| "http://127.0.0.1:18546".into()), ) .unwrap(); + std::sync::Arc::get_mut(&mut wallet) + .unwrap() + .rpc + .bridge_status_url = Some("http://127.0.0.1:18546".into()); assert_eq!(rpc.balance(wallet.address).await.unwrap(), U256::ZERO); let initial = wallet.balance().await.unwrap(); // Only locally mined transactions belong to this fixture's history. @@ -1588,7 +1657,17 @@ async fn replacement_after_expiry_recovers_pending_send_after_restart() { UsdtTransferStatus::Pending ); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); - chain.state.lock().unwrap().hide_logs = false; + { + let mut state = chain.state.lock().unwrap(); + state.hide_logs = false; + // The discovery log alone is insufficient; recovery verifies the consuming block. + state.hide_receipts = false; + state.receipt_response = Some(serde_json::json!({ + "transactionHash":alloy_primitives::B256::repeat_byte(7), + "blockHash":alloy_primitives::B256::repeat_byte(9), + "blockNumber":alloy_primitives::U256::from(507_000_000),"logs":[] + })); + } assert_eq!( wallet.refresh_transfers().await.unwrap()[0].status, UsdtTransferStatus::Replaced @@ -1613,6 +1692,7 @@ async fn history_distinguishes_rate_limits_from_log_range_limits() { for (code, message) in [ (-32005, "Rate limit exceeded"), (-32016, "Provider throttled"), + (-32000, "Request rate exceeded"), (-32000, "Invalid request"), ] { let chain = MockChain::start().await; @@ -1623,7 +1703,7 @@ async fn history_distinguishes_rate_limits_from_log_range_limits() { state.log_error = Some((code, message.into())); } let error = wallet.sync_history().await.unwrap_err(); - if code == -32000 { + if message == "Invalid request" { assert!(matches!(error, UsdtError::NetworkUnavailable)); } else { assert!(matches!(error, UsdtError::RateLimited)); @@ -1656,6 +1736,7 @@ async fn history_distinguishes_rate_limits_from_log_range_limits() { wallet .history_range_limit .store(1, std::sync::atomic::Ordering::Relaxed); + wallet.store.save_history_progress(19_990).unwrap(); sync_history_to_tip(&wallet).await; assert!( wallet @@ -1823,7 +1904,7 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending state.timestamp += alloy_primitives::U256::from(601); } // Completion includes bridge polling and the shared chain/bundler request budget. - let history = tokio::time::timeout(Duration::from_secs(15), wallet.refresh_transfers()) + let history = tokio::time::timeout(Duration::from_secs(25), wallet.refresh_transfers()) .await .unwrap() .unwrap(); @@ -1870,15 +1951,23 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending refresh.await.unwrap().unwrap(); { let attempts = attempts.lock().unwrap(); - assert_ne!(attempts[0], attempts[1]); + assert_eq!( + attempts[..6] + .iter() + .collect::>() + .len(), + 5 + ); } // Healthy responses slower than an equal share of the budget must still settle. stalled.store(false, Ordering::SeqCst); + chain.state.lock().unwrap().tip += 3; + chain.state.lock().unwrap().log_error = Some((-32603, "provider unavailable".into())); for _ in 0..2 { - tokio::time::timeout(Duration::from_secs(15), wallet.refresh_transfers()) + tokio::time::timeout(Duration::from_secs(25), wallet.refresh_transfers()) .await .unwrap() - .unwrap(); + .unwrap_err(); } let history = wallet.history().unwrap(); assert!(bridges.iter().all(|bridge| history.iter().any( @@ -2440,6 +2529,11 @@ async fn settlement_requires_matching_canonical_receipts() { Err(UsdtError::InvalidResponse) )); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + assert!(matches!( + wallet.sync_history().await, + Err(UsdtError::InvalidResponse) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); } chain.state.lock().unwrap().receipt_response = Some(serde_json::Value::Null); assert!(matches!( @@ -2447,6 +2541,10 @@ async fn settlement_requires_matching_canonical_receipts() { Err(UsdtError::NetworkUnavailable) )); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + assert!(matches!( + wallet.sync_history().await, + Err(UsdtError::NetworkUnavailable) + )); chain.state.lock().unwrap().receipt_response = None; assert_eq!( wallet.refresh_transfers().await.unwrap()[0].status, @@ -2600,6 +2698,16 @@ async fn destination_tokens_are_not_payment_recipients() { let chain = MockChain::start().await; let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); + assert!(matches!( + wallet + .quote_transfer( + account::ENTRY_POINT.to_checksum(None), + 1_000_000, + UsdtDestination::Ethereum + ) + .await, + Err(UsdtError::InvalidAddress) + )); for destination in [ UsdtDestination::Arbitrum, UsdtDestination::Ethereum, @@ -2622,3 +2730,142 @@ async fn destination_tokens_are_not_payment_recipients() { } } } + +#[tokio::test] +async fn settlement_uses_the_canonical_operation_outcome() { + use alloy_primitives::{B256, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + for success in [false, true] { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + let op = &state.operations[0]; + let event = |success| { + transaction::EntryPoint::UserOperationEvent { + userOpHash: op.hash(types::CHAIN_ID).unwrap(), + sender: op.sender, + paymaster: paymaster::PAYMASTER, + nonce: op.nonce, + success, + actualGasCost: U256::from(1), + actualGasUsed: U256::from(1), + } + .encode_log_data() + }; + let receipt_event = event(success); + let log_event = event(!success); + let mut logs = state.event_logs(); + logs[1]["data"] = json!(log_event.data); + state.log_response = Some(vec![logs[1].clone()]); + let mut receipt = state.respond( + &json!({"method":"eth_getTransactionReceipt","params":[B256::repeat_byte(7)]}), + )["result"] + .clone(); + receipt["logs"][1]["data"] = json!(receipt_event.data); + state.receipt_response = Some(receipt); + } + let expected = if success { + UsdtTransferStatus::Confirmed + } else { + UsdtTransferStatus::Failed + }; + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + expected + ); + assert!(wallet.sync_history().await.unwrap()); + assert_eq!(wallet.history().unwrap()[0].status, expected); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); + } +} + +#[tokio::test] +async fn external_paymaster_modes_preserve_raw_debits_and_refunds() { + use alloy_primitives::{Address, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let movement = |from, to, value, index| { + let event = transaction::Erc20::Transfer { + from, + to, + value: U256::from(value), + } + .encode_log_data(); + json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"logIndex":U256::from(index)}) + }; + for flags in [2u8, 4u8] { + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip = 20003; + let mut data = state.operations[0].paymaster_data.to_vec(); + data[1] = flags; + state.operations[0].paymaster_data = data.into(); + let mut logs = state.event_logs(); + logs.insert( + 0, + movement(wallet.address, paymaster::PAYMASTER, 200u64, 2u64), + ); + logs.insert( + 1, + movement( + wallet.address, + RECIPIENT.parse::
().unwrap(), + 1_000_000u64, + 3u64, + ), + ); + logs.insert( + 2, + movement(paymaster::PAYMASTER, wallet.address, 50u64, 4u64), + ); + state.receipt_logs = Some(logs); + } + let restored_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restored_dir); + assert!(restored.sync_history().await.unwrap()); + let history = restored.history().unwrap(); + assert_eq!(history.len(), 3); + assert!(history.iter().all(|row| row.user_operation_hash.is_none())); + assert_eq!( + history + .iter() + .filter(|row| row.is_incoming) + .map(|row| row.amount) + .sum::(), + 50 + ); + assert_eq!( + history + .iter() + .filter(|row| !row.is_incoming) + .map(|row| row.amount) + .sum::(), + 1_000_200 + ); + } +} diff --git a/src/modules/usdt/types.rs b/src/modules/usdt/types.rs index 3ca70c1..4903348 100644 --- a/src/modules/usdt/types.rs +++ b/src/modules/usdt/types.rs @@ -48,6 +48,8 @@ impl UsdtDestination { pub struct UsdtPaymentRequest { pub recipient: String, pub amount: Option, + /// An explicit network in the payment URI; bare addresses have no restriction. + pub chain_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize, uniffi::Record)] diff --git a/src/modules/usdt/wallet.rs b/src/modules/usdt/wallet.rs index d456a5e..7874d9d 100644 --- a/src/modules/usdt/wallet.rs +++ b/src/modules/usdt/wallet.rs @@ -5,9 +5,7 @@ use super::{ paymaster::{Pimlico, PAYMASTER}, rpc::Rpc, store::{QuoteData, Store}, - transaction::{ - event_data, BridgeHelper, EntryPoint, Erc20, MessagingFee, Oft, Paymaster, Plan, SendParam, - }, + transaction::{event_data, BridgeHelper, EntryPoint, Erc20, Oft, Paymaster, Plan, SendParam}, types::{BRIDGE_HELPER, CHAIN_ID, EXPLORER, OFT, TOKEN}, user_operation::Authorization, UsdtDestination, UsdtError, UsdtQuote, UsdtTransfer, UsdtTransferStatus, @@ -90,6 +88,7 @@ impl UsdtWallet { let recipient = parse_address(recipient.trim())?; if recipient == self.address || recipient == destination.token() + || (destination == UsdtDestination::Ethereum && recipient == ENTRY_POINT) || (destination == UsdtDestination::Arbitrum && [ ENTRY_POINT, @@ -235,10 +234,19 @@ impl UsdtWallet { } pub async fn refresh_transfers(&self) -> Result, UsdtError> { + let pending = self.refresh_pending_transfers().await; + self.refresh_bridges(&self.store.unsettled()?).await?; + pending?; + self.history() + } +} + +impl UsdtWallet { + async fn refresh_pending_transfers(&self) -> Result<(), UsdtError> { let _guard = self.operation.lock().await; let transfers = self.store.unsettled()?; if transfers.is_empty() { - return self.history(); + return Ok(()); } if transfers .iter() @@ -321,10 +329,8 @@ impl UsdtWallet { if event.userOpHash == hash { self.settle_from_log(&mut transfer, log, event).await?; } else { - transfer.status = UsdtTransferStatus::Replaced; - transfer.received_amount = 0; - transfer.fee = Some(0); - self.store.update_transfer(&transfer)?; + self.reconcile_consumed_nonce(&mut transfer, &plan, block) + .await?; } matched = true; break; @@ -335,24 +341,22 @@ impl UsdtWallet { } } else { let expired = self.block_timestamp(confirmed_tip).await? > plan.expires_at; - if nonce == plan.operation.nonce && expired { + if expired { transfer.status = UsdtTransferStatus::Failed; transfer.received_amount = 0; transfer.fee = Some(0); self.store.update_transfer(&transfer)?; - } else if !expired { - let _ = self.broadcast(&plan, hash).await; + } else { + if self.validate_bridge(&plan).await.is_ok() { + let _ = self.broadcast(&plan, hash).await; + } } } } } - drop(_guard); - self.refresh_bridges(&self.store.unsettled()?).await?; - self.history() + Ok(()) } -} -impl UsdtWallet { async fn reconcile_consumed_nonce( &self, transfer: &mut UsdtTransfer, @@ -370,7 +374,7 @@ impl UsdtWallet { if tokio::time::Instant::now() >= deadline { return Ok(()); } - let receipt = self.rpc.block_receipt(*hash, &block, number).await?; + let receipt = self.rpc.block_receipt(*hash, block.hash, number).await?; for log in receipt["logs"] .as_array() .ok_or(UsdtError::InvalidResponse)? @@ -391,7 +395,7 @@ impl UsdtWallet { } transfer.tx_hash = format!("{hash:#x}"); transfer.explorer_url = format!("{EXPLORER}/tx/{hash:#x}"); - self.settle(transfer, &receipt, event.success)?; + self.settle(transfer, &receipt)?; } else { transfer.status = UsdtTransferStatus::Replaced; transfer.received_amount = 0; @@ -411,6 +415,8 @@ impl UsdtWallet { transfer.status = UsdtTransferStatus::Replaced; transfer.received_amount = 0; transfer.fee = Some(0); + transfer.timestamp = + u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; self.store.update_transfer(transfer) } @@ -428,7 +434,11 @@ impl UsdtWallet { if bridges.is_empty() { return Ok(()); } - let offset = self.bridge_poll_offset.fetch_add(3, Ordering::Relaxed) % bridges.len(); + let batch = [0, 1, 2]; + let offset = self + .bridge_poll_offset + .fetch_add(batch.len(), Ordering::Relaxed) + % bridges.len(); bridges.rotate_left(offset); let bridges = &bridges; let check = |index: usize| async move { @@ -442,7 +452,8 @@ impl UsdtWallet { .await, )) }; - let (first, second, third) = tokio::join!(check(0), check(1), check(2)); + let (first, second, third) = + tokio::join!(check(batch[0]), check(batch[1]), check(batch[2])); for (previous, result) in [first, second, third].into_iter().flatten() { match result { Ok(Ok(status)) if status != previous.status => { @@ -450,7 +461,7 @@ impl UsdtWallet { let Some(mut current) = self.store.transfer(&previous.id)? else { continue; }; - if current.tx_hash == previous.tx_hash + if current.tx_hash.eq_ignore_ascii_case(&previous.tx_hash) && current.bridge_guid == previous.bridge_guid && current.status == previous.status { @@ -472,11 +483,7 @@ impl UsdtWallet { .map_err(|_| UsdtError::InvalidResponse) } pub(super) async fn block_timestamp(&self, number: u64) -> Result { - let block: Value = self - .rpc - .call("eth_getBlockByNumber", json!([U256::from(number), false])) - .await?; - u64::try_from(serde_json::from_value::(block["timestamp"].clone())?) + u64::try_from(self.rpc.block(number).await?.timestamp) .map_err(|_| UsdtError::InvalidResponse) } async fn token_balance(&self) -> Result { @@ -548,17 +555,17 @@ impl UsdtWallet { if event.sender != self.address || event.paymaster != PAYMASTER { return Err(UsdtError::InvalidResponse); } - transfer.tx_hash = serde_json::from_value(log["transactionHash"].clone())?; + let hash: B256 = serde_json::from_value(log["transactionHash"].clone())?; + transfer.tx_hash = format!("{hash:#x}"); transfer.explorer_url = format!("{EXPLORER}/tx/{}", transfer.tx_hash); let number = u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) .map_err(|_| UsdtError::InvalidResponse)?; let block = self.rpc.block(number).await?; - let hash = transfer - .tx_hash - .parse() - .map_err(|_| UsdtError::InvalidResponse)?; - let receipt = self.rpc.block_receipt(hash, &block, number).await?; - self.settle(transfer, &receipt, event.success)?; + if serde_json::from_value::(log["blockHash"].clone())? != block.hash { + return Err(UsdtError::NetworkUnavailable); + } + let receipt = self.rpc.block_receipt(hash, block.hash, number).await?; + self.settle(transfer, &receipt)?; transfer.timestamp = u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; self.store.update_transfer(transfer) @@ -600,7 +607,6 @@ impl UsdtWallet { &self, transfer: &mut UsdtTransfer, receipt: &Value, - success: bool, ) -> Result<(), UsdtError> { let operation_hash: B256 = transfer .user_operation_hash @@ -609,6 +615,16 @@ impl UsdtWallet { .parse() .map_err(|_| UsdtError::InvalidResponse)?; let logs = super::transaction::operation_logs(receipt, operation_hash)?; + let event = EntryPoint::UserOperationEvent::decode_log_data(&event_data( + logs.last().ok_or(UsdtError::InvalidResponse)?, + )?) + .map_err(|_| UsdtError::InvalidResponse)?; + if event.userOpHash != operation_hash + || event.sender != self.address + || event.paymaster != PAYMASTER + { + return Err(UsdtError::InvalidResponse); + } let mut gas_fee = None; let mut bridge_fee = None; for log in logs { @@ -625,7 +641,7 @@ impl UsdtWallet { } } } - if success && address == BRIDGE_HELPER { + if event.success && address == BRIDGE_HELPER { if let Ok(event) = BridgeHelper::LogSend::decode_log_data(&data) { if event.sender == self.address && event.oft == OFT @@ -635,7 +651,7 @@ impl UsdtWallet { } } } - if success && address == OFT { + if event.success && address == OFT { if let Ok(event) = Oft::OFTSent::decode_log_data(&data) { if event.fromAddress == BRIDGE_HELPER && transfer.destination.endpoint() == Some(event.dstEid) @@ -647,8 +663,14 @@ impl UsdtWallet { } } } - transfer.fee = gas_fee.and_then(|fee| fee.checked_add(bridge_fee.unwrap_or(0))); - transfer.status = if !success { + transfer.fee = if event.success && transfer.destination != UsdtDestination::Arbitrum { + gas_fee + .zip(bridge_fee) + .and_then(|(gas, bridge)| gas.checked_add(bridge)) + } else { + gas_fee + }; + transfer.status = if !event.success { transfer.received_amount = 0; UsdtTransferStatus::Failed } else if transfer.destination == UsdtDestination::Arbitrum { @@ -797,10 +819,6 @@ impl UsdtWallet { }, ) .await?; - let fee = MessagingFee { - nativeFee: fee.nativeFee, - lzTokenFee: U256::ZERO, - }; let token_fee = total .checked_sub(U256::from(amount)) .ok_or(UsdtError::InvalidResponse)?; diff --git a/tests/usdt-fork/provider.mjs b/tests/usdt-fork/provider.mjs index 43453d6..c9bad84 100644 --- a/tests/usdt-fork/provider.mjs +++ b/tests/usdt-fork/provider.mjs @@ -153,7 +153,7 @@ async function dispatch(method, params) { const op = { ...params[0], paymaster: pmAddress, ...limits }; const unsigned = concat([ '0x0300', - toBeHex(Math.floor(Date.now() / 1000) + 600, 6), + toBeHex(BigInt((await rpc.send('eth_getBlockByNumber', ['latest', false])).timestamp) + 600n, 6), toBeHex(0, 6), tokenAddress, toBeHex(50000, 16), @@ -204,6 +204,11 @@ async function dispatch(method, params) { return rpc.send(method, params); } const server = createServer(async (request, response) => { + if (request.method === 'GET' && request.url.startsWith('/v1/messages/tx/')) { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ data: [] })); + return; + } let body = ''; for await (const chunk of request) body += chunk; const call = JSON.parse(body); From 478587e2493221780407b268b757f7cacfabe828 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 25 Sep 2026 11:08:51 +0300 Subject: [PATCH 4/6] fix: preserve direct execution checks alongside USDT0 bridging --- Package.swift | 2 +- bindings/ios/bitkitcore.swift | 54 +++++++++++++ bindings/ios/bitkitcoreFFI.h | 11 +++ src/modules/usdt/README.md | 2 + src/modules/usdt/tests.rs | 142 ++++++++++++++++++++++++++++++++-- src/modules/usdt/wallet.rs | 62 +++++++++++++++ 6 files changed, 264 insertions(+), 9 deletions(-) diff --git a/Package.swift b/Package.swift index 2f15947..02f8063 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription import Foundation let tag = "v0.6.0" -let checksum = "994ecf4f6ec42fa8d1482197b3644e58b7f43957dd058e97a9e286d06dd18edd" +let checksum = "1da4ecd7e9cae3352a6a03c21b5cf027b68c6d8874cba6cd175e7e11690904ce" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index ba5004c..3c1c0df 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -2575,6 +2575,12 @@ public protocol UsdtWalletProtocol: AnyObject, Sendable { func receiveUri() -> String + /** + * Checks recent direct-payment execution at the current tip without scanning history or retrying submission. + * Missing evidence leaves the signed payment pending; confirmation is L2 execution, not parent-chain finality. + */ + func refreshTransfer(id: String) async throws -> UsdtTransfer? + func refreshTransfers() async throws -> [UsdtTransfer] func send(quoteId: String, mnemonic: String, passphrase: String?) async throws -> UsdtTransfer @@ -2700,6 +2706,27 @@ open func receiveUri() -> String { }) } + /** + * Checks recent direct-payment execution at the current tip without scanning history or retrying submission. + * Missing evidence leaves the signed payment pending; confirmation is L2 execution, not parent-chain finality. + */ +open func refreshTransfer(id: String)async throws -> UsdtTransfer? { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtwallet_refresh_transfer( + self.uniffiClonePointer(), + FfiConverterString.lower(id) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterOptionTypeUsdtTransfer.lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + open func refreshTransfers()async throws -> [UsdtTransfer] { return try await uniffiRustCallAsync( @@ -24874,6 +24901,30 @@ fileprivate struct FfiConverterOptionTypeTrezorFeatures: FfiConverterRustBuffer } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeUsdtTransfer: FfiConverterRustBuffer { + typealias SwiftType = UsdtTransfer? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeUsdtTransfer.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeUsdtTransfer.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -29825,6 +29876,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_usdtwallet_receive_uri() != 33484) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfer() != 58151) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfers() != 32305) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index fe19f0f..6291acd 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -700,6 +700,11 @@ RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_receive_address(void*_Nonnull RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_receive_uri(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFER +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFER +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_refresh_transfer(void*_Nonnull ptr, RustBuffer id +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFERS #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFERS uint64_t uniffi_bitkitcore_fn_method_usdtwallet_refresh_transfers(void*_Nonnull ptr @@ -3491,6 +3496,12 @@ uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_receive_address(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_RECEIVE_URI uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_receive_uri(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_REFRESH_TRANSFER +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_REFRESH_TRANSFER +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfer(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_REFRESH_TRANSFERS diff --git a/src/modules/usdt/README.md b/src/modules/usdt/README.md index 9b45fbd..85851e3 100644 --- a/src/modules/usdt/README.md +++ b/src/modules/usdt/README.md @@ -30,6 +30,8 @@ Seed restoration recovers deposits and outgoing activity from genesis, including `sync_history` returns `true` when caught up and `false` when more work remains. It uses adaptive log ranges and a 20-second soft budget between persisted receipts; an in-flight receipt may finish later. A single-block log overflow falls back to that block's individual receipts. Completed fallback scans are retained by canonical block hash within the revisit window. Zero/self transfers are discarded before enrichment. Network failures preserve completed work and never silently skip a block. +`refresh_transfer` checks one recent direct Arbitrum payment with a five-second request budget. It requires the expected operation outcome and token transfer in a matching canonical receipt and does not scan history, rebroadcast, expire payments or reconcile nonces. It can confirm execution at the current L2 tip; this is provisional sequencer execution, not parent-chain finality. Native send screens may call it approximately once per second during a short foreground window, with cancellation and rate-limit backoff between checks. Missing evidence leaves Pending intact. Normal recovery handles older payments outside its 64-block lookup window. + Scans trail the reported tip by two blocks and revisit 4096 blocks for delayed indexing. This is not reorg rollback: previously recorded orphaned activity is not retracted. Providers must supply complete filtered logs, canonical blocks/receipts and historical state. Payment outcomes and expiry decisions trust the configured chain RPC. A malicious RPC can fabricate or suppress evidence and mislead a user into authorizing another payment; these checks are not light-client proofs. diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index d42098d..91d747c 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -670,7 +670,11 @@ impl ChainState { && (filter["address"] == json!(account::ENTRY_POINT) || filter["address"].is_array()) { - json!([self.event_logs()[1]]) + json!([self + .event_logs() + .into_iter() + .find(|log| log["address"] == json!(account::ENTRY_POINT.to_checksum(None))) + .unwrap()]) } else { json!([]) } @@ -717,7 +721,7 @@ impl ChainState { } fn event_logs(&self) -> Vec { use alloy_primitives::{B256, U256}; - use alloy_sol_types::SolEvent; + use alloy_sol_types::{SolCall, SolEvent}; use serde_json::json; let op = self.operations.first().unwrap(); let hash = op.hash(types::CHAIN_ID).unwrap(); @@ -744,6 +748,26 @@ impl ChainState { json!({"address":paymaster::PAYMASTER,"topics":gas.topics(),"data":gas.data,"transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":"0x0"}), json!({"address":account::ENTRY_POINT.to_checksum(None),"topics":event.topics(),"data":event.data,"transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":"0x1"}), ]; + for (target, data) in history::decode_calls(&op.call_data).unwrap_or_default() { + if target == types::TOKEN { + if let Ok(call) = transaction::Erc20::transferCall::abi_decode(&data) { + if call.amount > U256::from(u64::MAX) { + continue; + } + let payment = transaction::Erc20::Transfer { + from: op.sender, + to: call.recipient, + value: call.amount, + } + .encode_log_data(); + logs.insert(0, json!({"address":types::TOKEN,"topics":payment.topics(),"data":payment.data, + "transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":"0x0"})); + for (index, log) in logs.iter_mut().enumerate() { + log["logIndex"] = json!(format!("0x{index:x}")); + } + } + } + } if !self.mined { logs.clear(); } @@ -754,7 +778,7 @@ impl ChainState { value: U256::from(42), } .encode_log_data(); - logs.push(json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"logIndex":"0x2"})); + logs.push(json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"logIndex":format!("0x{:x}", logs.len())})); } if self.external_outgoing { let event = transaction::Erc20::Transfer { @@ -763,7 +787,7 @@ impl ChainState { value: U256::from(77), } .encode_log_data(); - logs.push(json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":"0x3"})); + logs.push(json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":format!("0x{:x}", logs.len())})); } logs } @@ -1355,7 +1379,7 @@ async fn history_preserves_receipts_with_external_account_call_shapes() { (single, None, true), (oversized, None, false), (Bytes::from_static(&[1, 2, 3, 4]), None, false), - (original, Some(Bytes::from_static(&[5, 6, 7, 8])), false), + (original, Some(Bytes::from_static(&[5, 6, 7, 8])), true), ] { { let mut state = chain.state.lock().unwrap(); @@ -1418,6 +1442,7 @@ async fn wrapped_history_preserves_signed_payments_and_restores_token_transfers( .into(), )]); let mut logs = state.event_logs(); + logs.retain(|log| log["address"] != json!(types::TOKEN)); for (recipient, amount) in [ (RECIPIENT.parse().unwrap(), 1_000_000), (paymaster::PAYMASTER, 123), @@ -2173,6 +2198,7 @@ async fn seed_restore_includes_external_token_sends_without_duplicate_operation_ if unsupported_batch { let mut state = chain.state.lock().unwrap(); state.mined = true; + state.external_outgoing = false; state.history_input = None; let mut calls = history::decode_calls(&state.operations[0].call_data).unwrap(); calls.push(( @@ -2524,6 +2550,10 @@ async fn settlement_requires_matching_canonical_receipts() { let mut invalid = receipt.clone(); invalid[field] = value; chain.state.lock().unwrap().receipt_response = Some(invalid); + assert!(matches!( + wallet.refresh_transfer(sent.id.clone()).await, + Err(UsdtError::InvalidResponse) + )); assert!(matches!( wallet.refresh_transfers().await, Err(UsdtError::InvalidResponse) @@ -2768,13 +2798,13 @@ async fn settlement_uses_the_canonical_operation_outcome() { let receipt_event = event(success); let log_event = event(!success); let mut logs = state.event_logs(); - logs[1]["data"] = json!(log_event.data); - state.log_response = Some(vec![logs[1].clone()]); + logs[2]["data"] = json!(log_event.data); + state.log_response = Some(vec![logs[2].clone()]); let mut receipt = state.respond( &json!({"method":"eth_getTransactionReceipt","params":[B256::repeat_byte(7)]}), )["result"] .clone(); - receipt["logs"][1]["data"] = json!(receipt_event.data); + receipt["logs"][2]["data"] = json!(receipt_event.data); state.receipt_response = Some(receipt); } let expected = if success { @@ -2826,6 +2856,7 @@ async fn external_paymaster_modes_preserve_raw_debits_and_refunds() { data[1] = flags; state.operations[0].paymaster_data = data.into(); let mut logs = state.event_logs(); + logs.retain(|log| log["address"] != json!(types::TOKEN)); logs.insert( 0, movement(wallet.address, paymaster::PAYMASTER, 200u64, 2u64), @@ -2869,3 +2900,98 @@ async fn external_paymaster_modes_preserve_raw_debits_and_refunds() { ); } } + +#[tokio::test] +async fn recent_execution_requires_the_expected_token_transfer() { + use alloy_primitives::U256; + use alloy_sol_types::SolEvent; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + chain.state.lock().unwrap().mined = true; + // Normal recovery still waits for its indexing buffer. + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Pending + ); + for (recipient, amount) in [ + (RECIPIENT.parse().unwrap(), 999u64), + (wallet.address, sent.amount), + ] { + let mut logs = chain.state.lock().unwrap().event_logs(); + let token = transaction::Erc20::Transfer { + from: wallet.address, + to: recipient, + value: U256::from(amount), + } + .encode_log_data(); + logs[0]["topics"] = json!(token.topics()); + logs[0]["data"] = json!(token.data); + chain.state.lock().unwrap().receipt_logs = Some(logs); + assert!(matches!( + wallet.refresh_transfer(sent.id.clone()).await, + Err(UsdtError::InvalidResponse) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + } + let mut logs = chain.state.lock().unwrap().event_logs(); + logs.remove(0); + chain.state.lock().unwrap().receipt_logs = Some(logs); + assert!(matches!( + wallet.refresh_transfer(sent.id.clone()).await, + Err(UsdtError::InvalidResponse) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + chain.state.lock().unwrap().receipt_logs = None; + let result = wallet + .refresh_transfer(sent.id.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(result.status, UsdtTransferStatus::Confirmed); + assert_eq!(result.fee, Some(123)); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); +} + +#[tokio::test] +async fn execution_check_preserves_unmined_payments_and_throttling() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.tip += 3; + state.timestamp += alloy_primitives::U256::from(1000); + } + let result = wallet + .refresh_transfer(sent.id.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(result.status, UsdtTransferStatus::Pending); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + assert_eq!(chain.state.lock().unwrap().operations.len(), 1); + chain.state.lock().unwrap().log_error = Some((-32016, "rate limit".into())); + assert!(matches!( + wallet.refresh_transfer(sent.id.clone()).await, + Err(UsdtError::RateLimited) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); +} diff --git a/src/modules/usdt/wallet.rs b/src/modules/usdt/wallet.rs index 7874d9d..3d205a9 100644 --- a/src/modules/usdt/wallet.rs +++ b/src/modules/usdt/wallet.rs @@ -223,6 +223,57 @@ impl UsdtWallet { Ok(transfer) } + /// Checks recent direct-payment execution at the current tip without scanning history or retrying submission. + /// Missing evidence leaves the signed payment pending; confirmation is L2 execution, not parent-chain finality. + pub async fn refresh_transfer(&self, id: String) -> Result, UsdtError> { + let check = async { + let _guard = self.operation.lock().await; + let Some(mut transfer) = self.store.transfer(&id)? else { + return Ok(None); + }; + if transfer.destination != UsdtDestination::Arbitrum { + return Ok(Some(transfer)); + } + let Some(plan) = self.store.pending_plan(&id)? else { + return Ok(Some(transfer)); + }; + self.rpc.verify_chain().await?; + let hash = plan.operation.hash(CHAIN_ID)?; + let tip = self.block_number().await?; + let start = plan.created_block.max(tip.saturating_sub(63)); + if start <= tip { + let logs: Vec = self.rpc.call("eth_getLogs", json!([{ + "address": ENTRY_POINT, "fromBlock": U256::from(start), "toBlock": U256::from(tip), + "topics": [EntryPoint::UserOperationEvent::SIGNATURE_HASH, hash, self.address.into_word()] + }])).await?; + if let Some(log) = logs + .iter() + .find(|log| log["removed"].as_bool() != Some(true)) + { + let event = EntryPoint::UserOperationEvent::decode_log_data(&event_data(log)?) + .map_err(|_| UsdtError::InvalidResponse)?; + let number = + u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) + .map_err(|_| UsdtError::InvalidResponse)?; + if serde_json::from_value::
(log["address"].clone())? != ENTRY_POINT + || event.userOpHash != hash + || event.nonce != plan.operation.nonce + || number < start + || number > tip + { + return Err(UsdtError::InvalidResponse); + } + self.settle_from_log(&mut transfer, log, event).await?; + } + } + Ok(Some(transfer)) + }; + match tokio::time::timeout(std::time::Duration::from_secs(5), check).await { + Ok(result) => result, + Err(_) => Err(UsdtError::NetworkUnavailable), + } + } + pub async fn sync_history(&self) -> Result { let _guard = self.operation.lock().await; self.rpc.verify_chain().await?; @@ -625,11 +676,19 @@ impl UsdtWallet { { return Err(UsdtError::InvalidResponse); } + let mut transfer_proven = false; let mut gas_fee = None; let mut bridge_fee = None; for log in logs { let address: Address = serde_json::from_value(log["address"].clone())?; let data = event_data(log)?; + if address == TOKEN { + if let Ok(payment) = Erc20::Transfer::decode_log_data(&data) { + transfer_proven |= payment.from == self.address + && payment.to == parse_address(&transfer.recipient)? + && payment.value == U256::from(transfer.amount); + } + } if address == PAYMASTER { if let Ok(event) = Paymaster::UserOperationSponsored::decode_log_data(&data) { if event.userOpHash == operation_hash @@ -663,6 +722,9 @@ impl UsdtWallet { } } } + if event.success && transfer.destination == UsdtDestination::Arbitrum && !transfer_proven { + return Err(UsdtError::InvalidResponse); + } transfer.fee = if event.success && transfer.destination != UsdtDestination::Arbitrum { gas_fee .zip(bridge_fee) From d202ffbe0ff9ef2d0019f01c736ab8072f74fa6e Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 25 Sep 2026 16:18:58 +0300 Subject: [PATCH 5/6] fix: bound USDT bridge delivery tracking --- Package.swift | 2 +- bindings/ios/bitkitcore.swift | 143 +++-- bindings/ios/bitkitcoreFFI.h | 22 +- src/modules/usdt/README.md | 27 +- src/modules/usdt/amount.rs | 37 ++ src/modules/usdt/errors.rs | 2 +- src/modules/usdt/history.rs | 199 +++--- src/modules/usdt/keys.rs | 12 + src/modules/usdt/paymaster.rs | 27 +- src/modules/usdt/rpc.rs | 115 ++-- src/modules/usdt/store.rs | 167 ++--- src/modules/usdt/tests.rs | 1045 ++++++++++++++++++++++--------- src/modules/usdt/transaction.rs | 24 +- src/modules/usdt/types.rs | 21 +- src/modules/usdt/wallet.rs | 377 ++++++----- tests/usdt-fork/provider.mjs | 6 +- 16 files changed, 1452 insertions(+), 774 deletions(-) diff --git a/Package.swift b/Package.swift index 02f8063..ac9e120 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription import Foundation let tag = "v0.6.0" -let checksum = "1da4ecd7e9cae3352a6a03c21b5cf027b68c6d8874cba6cd175e7e11690904ce" +let checksum = "71ba9b4249617a31fb2ddf753a80a221ebdadf1522433b5a9b1b31fb30611906" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 3c1c0df..65c4e4f 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -2567,6 +2567,13 @@ public protocol UsdtWalletProtocol: AnyObject, Sendable { func balance() async throws -> UInt64 + /** + * Checks recent direct-payment execution with a bounded request budget. + * Requires the expected operation and transfer in a canonical receipt; current-tip execution is provisional. + * Does not rebroadcast, expire payments or reconcile nonces. Missing evidence leaves the payment pending. + */ + func checkRecentExecution(id: String) async throws -> UsdtTransfer? + func history() throws -> [UsdtTransfer] func quoteTransfer(recipient: String, amount: UInt64, destination: UsdtDestination) async throws -> UsdtQuote @@ -2576,15 +2583,20 @@ public protocol UsdtWalletProtocol: AnyObject, Sendable { func receiveUri() -> String /** - * Checks recent direct-payment execution at the current tip without scanning history or retrying submission. - * Missing evidence leaves the signed payment pending; confirmation is L2 execution, not parent-chain finality. + * Reconciles pending execution using chain proofs and may rebroadcast the identical signed operation. */ - func refreshTransfer(id: String) async throws -> UsdtTransfer? - func refreshTransfers() async throws -> [UsdtTransfer] + /** + * Repeating a quote ID returns its stored outcome, which may already be failed or replaced. + * A pending outcome is durable and retryable; it does not imply bundler acceptance. + */ func send(quoteId: String, mnemonic: String, passphrase: String?) async throws -> UsdtTransfer + /** + * Saves resumable history progress; returns true when caught up and false when more work remains. + * Call between send flows. The soft budget permits an in-flight receipt to finish before yielding. + */ func syncHistory() async throws -> Bool } @@ -2627,6 +2639,9 @@ open class UsdtWallet: UsdtWalletProtocol, @unchecked Sendable { public func uniffiClonePointer() -> UnsafeMutableRawPointer { return try! rustCall { uniffi_bitkitcore_fn_clone_usdtwallet(self.pointer, $0) } } + /** + * Creates the sole owner of this wallet's database; reuse it for all calls until it is dropped. + */ public convenience init(address: String, storagePath: String, rpcUrl: String, bundlerUrl: String)throws { let pointer = try rustCallWithError(FfiConverterTypeUsdtError_lift) { @@ -2668,6 +2683,28 @@ open func balance()async throws -> UInt64 { ) } + /** + * Checks recent direct-payment execution with a bounded request budget. + * Requires the expected operation and transfer in a canonical receipt; current-tip execution is provisional. + * Does not rebroadcast, expire payments or reconcile nonces. Missing evidence leaves the payment pending. + */ +open func checkRecentExecution(id: String)async throws -> UsdtTransfer? { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtwallet_check_recent_execution( + self.uniffiClonePointer(), + FfiConverterString.lower(id) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterOptionTypeUsdtTransfer.lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + open func history()throws -> [UsdtTransfer] { return try FfiConverterSequenceTypeUsdtTransfer.lift(try rustCallWithError(FfiConverterTypeUsdtError_lift) { uniffi_bitkitcore_fn_method_usdtwallet_history(self.uniffiClonePointer(),$0 @@ -2707,26 +2744,8 @@ open func receiveUri() -> String { } /** - * Checks recent direct-payment execution at the current tip without scanning history or retrying submission. - * Missing evidence leaves the signed payment pending; confirmation is L2 execution, not parent-chain finality. + * Reconciles pending execution using chain proofs and may rebroadcast the identical signed operation. */ -open func refreshTransfer(id: String)async throws -> UsdtTransfer? { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_bitkitcore_fn_method_usdtwallet_refresh_transfer( - self.uniffiClonePointer(), - FfiConverterString.lower(id) - ) - }, - pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, - completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, - freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, - liftFunc: FfiConverterOptionTypeUsdtTransfer.lift, - errorHandler: FfiConverterTypeUsdtError_lift - ) -} - open func refreshTransfers()async throws -> [UsdtTransfer] { return try await uniffiRustCallAsync( @@ -2744,6 +2763,10 @@ open func refreshTransfers()async throws -> [UsdtTransfer] { ) } + /** + * Repeating a quote ID returns its stored outcome, which may already be failed or replaced. + * A pending outcome is durable and retryable; it does not imply bundler acceptance. + */ open func send(quoteId: String, mnemonic: String, passphrase: String?)async throws -> UsdtTransfer { return try await uniffiRustCallAsync( @@ -2761,6 +2784,10 @@ open func send(quoteId: String, mnemonic: String, passphrase: String?)async thro ) } + /** + * Saves resumable history progress; returns true when caught up and false when more work remains. + * Call between send flows. The soft budget permits an in-flight receipt to finish before yielding. + */ open func syncHistory()async throws -> Bool { return try await uniffiRustCallAsync( @@ -16130,7 +16157,10 @@ public func FfiConverterTypeUsdtQuote_lower(_ value: UsdtQuote) -> RustBuffer { public struct UsdtTransfer { public var id: String - public var txHash: String + /** + * Source transaction hash, absent until execution is observed. + */ + public var txHash: String? public var userOperationHash: String? public var bridgeGuid: String? public var recipient: String @@ -16141,11 +16171,13 @@ public struct UsdtTransfer { public var isIncoming: Bool public var status: UsdtTransferStatus public var timestamp: UInt64 - public var explorerUrl: String // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, txHash: String, userOperationHash: String?, bridgeGuid: String?, recipient: String, destination: UsdtDestination, amount: UInt64, receivedAmount: UInt64, fee: UInt64?, isIncoming: Bool, status: UsdtTransferStatus, timestamp: UInt64, explorerUrl: String) { + public init(id: String, + /** + * Source transaction hash, absent until execution is observed. + */txHash: String?, userOperationHash: String?, bridgeGuid: String?, recipient: String, destination: UsdtDestination, amount: UInt64, receivedAmount: UInt64, fee: UInt64?, isIncoming: Bool, status: UsdtTransferStatus, timestamp: UInt64) { self.id = id self.txHash = txHash self.userOperationHash = userOperationHash @@ -16158,7 +16190,6 @@ public struct UsdtTransfer { self.isIncoming = isIncoming self.status = status self.timestamp = timestamp - self.explorerUrl = explorerUrl } } @@ -16205,9 +16236,6 @@ extension UsdtTransfer: Equatable, Hashable { if lhs.timestamp != rhs.timestamp { return false } - if lhs.explorerUrl != rhs.explorerUrl { - return false - } return true } @@ -16224,7 +16252,6 @@ extension UsdtTransfer: Equatable, Hashable { hasher.combine(isIncoming) hasher.combine(status) hasher.combine(timestamp) - hasher.combine(explorerUrl) } } @@ -16240,7 +16267,7 @@ public struct FfiConverterTypeUsdtTransfer: FfiConverterRustBuffer { return try UsdtTransfer( id: FfiConverterString.read(from: &buf), - txHash: FfiConverterString.read(from: &buf), + txHash: FfiConverterOptionString.read(from: &buf), userOperationHash: FfiConverterOptionString.read(from: &buf), bridgeGuid: FfiConverterOptionString.read(from: &buf), recipient: FfiConverterString.read(from: &buf), @@ -16250,14 +16277,13 @@ public struct FfiConverterTypeUsdtTransfer: FfiConverterRustBuffer { fee: FfiConverterOptionUInt64.read(from: &buf), isIncoming: FfiConverterBool.read(from: &buf), status: FfiConverterTypeUsdtTransferStatus.read(from: &buf), - timestamp: FfiConverterUInt64.read(from: &buf), - explorerUrl: FfiConverterString.read(from: &buf) + timestamp: FfiConverterUInt64.read(from: &buf) ) } public static func write(_ value: UsdtTransfer, into buf: inout [UInt8]) { FfiConverterString.write(value.id, into: &buf) - FfiConverterString.write(value.txHash, into: &buf) + FfiConverterOptionString.write(value.txHash, into: &buf) FfiConverterOptionString.write(value.userOperationHash, into: &buf) FfiConverterOptionString.write(value.bridgeGuid, into: &buf) FfiConverterString.write(value.recipient, into: &buf) @@ -16268,7 +16294,6 @@ public struct FfiConverterTypeUsdtTransfer: FfiConverterRustBuffer { FfiConverterBool.write(value.isIncoming, into: &buf) FfiConverterTypeUsdtTransferStatus.write(value.status, into: &buf) FfiConverterUInt64.write(value.timestamp, into: &buf) - FfiConverterString.write(value.explorerUrl, into: &buf) } } @@ -23682,11 +23707,33 @@ extension UsdtError: Foundation.LocalizedError { public enum UsdtTransferStatus { + /** + * Signed payment awaiting a conclusive source-chain outcome. + */ case pending + /** + * Payment received on its destination chain. + */ case confirmed + /** + * Source payment failed or was proven not to have executed. + */ case failed + /** + * Source payment executed; destination delivery is pending. + */ case bridging + /** + * Delivery is blocked or its message could not be recovered; it may still complete. + */ case bridgeNeedsAttention + /** + * Delivery was permanently stopped. This does not imply a refund of source funds or fees. + */ + case bridgeFailed + /** + * Another operation consumed the payment nonce. + */ case replaced } @@ -23715,7 +23762,9 @@ public struct FfiConverterTypeUsdtTransferStatus: FfiConverterRustBuffer { case 5: return .bridgeNeedsAttention - case 6: return .replaced + case 6: return .bridgeFailed + + case 7: return .replaced default: throw UniffiInternalError.unexpectedEnumCase } @@ -23745,9 +23794,13 @@ public struct FfiConverterTypeUsdtTransferStatus: FfiConverterRustBuffer { writeInt(&buf, Int32(5)) - case .replaced: + case .bridgeFailed: writeInt(&buf, Int32(6)) + + case .replaced: + writeInt(&buf, Int32(7)) + } } } @@ -29864,6 +29917,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_usdtwallet_balance() != 12328) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_method_usdtwallet_check_recent_execution() != 33172) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_method_usdtwallet_history() != 4617) { return InitializationResult.apiChecksumMismatch } @@ -29876,22 +29932,19 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_usdtwallet_receive_uri() != 33484) { return InitializationResult.apiChecksumMismatch } - if (uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfer() != 58151) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfers() != 32305) { + if (uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfers() != 34299) { return InitializationResult.apiChecksumMismatch } - if (uniffi_bitkitcore_checksum_method_usdtwallet_send() != 10847) { + if (uniffi_bitkitcore_checksum_method_usdtwallet_send() != 60030) { return InitializationResult.apiChecksumMismatch } - if (uniffi_bitkitcore_checksum_method_usdtwallet_sync_history() != 48106) { + if (uniffi_bitkitcore_checksum_method_usdtwallet_sync_history() != 25445) { return InitializationResult.apiChecksumMismatch } if (uniffi_bitkitcore_checksum_constructor_urdecoder_new() != 23014) { return InitializationResult.apiChecksumMismatch } - if (uniffi_bitkitcore_checksum_constructor_usdtwallet_new() != 63633) { + if (uniffi_bitkitcore_checksum_constructor_usdtwallet_new() != 62148) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index 6291acd..7ebda6a 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -680,6 +680,11 @@ void*_Nonnull uniffi_bitkitcore_fn_constructor_usdtwallet_new(RustBuffer address uint64_t uniffi_bitkitcore_fn_method_usdtwallet_balance(void*_Nonnull ptr ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_CHECK_RECENT_EXECUTION +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_CHECK_RECENT_EXECUTION +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_check_recent_execution(void*_Nonnull ptr, RustBuffer id +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_HISTORY #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_HISTORY RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_history(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status @@ -700,11 +705,6 @@ RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_receive_address(void*_Nonnull RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_receive_uri(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFER -#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFER -uint64_t uniffi_bitkitcore_fn_method_usdtwallet_refresh_transfer(void*_Nonnull ptr, RustBuffer id -); -#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFERS #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFERS uint64_t uniffi_bitkitcore_fn_method_usdtwallet_refresh_transfers(void*_Nonnull ptr @@ -3472,6 +3472,12 @@ uint16_t uniffi_bitkitcore_checksum_method_urdecoder_reset(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_BALANCE uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_balance(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_CHECK_RECENT_EXECUTION +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_CHECK_RECENT_EXECUTION +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_check_recent_execution(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_HISTORY @@ -3496,12 +3502,6 @@ uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_receive_address(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_RECEIVE_URI uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_receive_uri(void -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_REFRESH_TRANSFER -#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_REFRESH_TRANSFER -uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfer(void - ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_REFRESH_TRANSFERS diff --git a/src/modules/usdt/README.md b/src/modules/usdt/README.md index 85851e3..24496d3 100644 --- a/src/modules/usdt/README.md +++ b/src/modules/usdt/README.md @@ -24,29 +24,37 @@ The pinned ERC-20 paymaster collects USDT. Its finite approval includes a 5% mar Signed operations persist atomically before submission. Lost or rejected submission responses do not prove nonexecution: recovery retries only the identical signed operation. A quote ID cannot authorize a second payment. One source-chain payment remains pending at a time. -A matching event in a canonical receipt settles the payment. Discovery logs alone never decide the outcome. Expired signed paymaster terms and a confirmed EntryPoint nonce that has not passed the signed nonce release an unmined operation; the shorter quote deadline does not. With an advanced nonce and missing indexed events, recovery checks every receipt in the consuming block. A matching event settles/replaces the payment; complete absence proves external nonce consumption. Missing receipts preserve the pending operation. Progress is stored by payment and block hash so interruption does not restart the proof or carry it onto another block. +A matching event in a canonical receipt settles the payment. Discovery logs alone never decide the outcome; unavailable log queries allow independent nonce/receipt proofs to proceed, while rate limits retain backoff. Expired signed paymaster terms and a confirmed EntryPoint nonce that has not passed the signed nonce release an unmined operation; the shorter quote deadline does not. With an advanced nonce and missing indexed events, recovery checks every receipt in the consuming block. A matching event settles/replaces the payment; complete absence proves external nonce consumption. Missing receipts preserve the pending operation. Progress is stored by payment and block hash so interruption does not restart the proof or carry it onto another block. -Seed restoration recovers deposits and outgoing activity from genesis, including transfers before delegation and sends through another wallet. Supported direct EntryPoint calls and paymaster modes recover payment/fee attribution; unknown wrappers or payment modes preserve raw token transfers instead of guessing their intent. Failed payments retain attempted amounts but have no delivered amount. +Seed restoration recovers deposits and outgoing activity from genesis, including transfers before delegation and sends through another wallet. Supported direct EntryPoint calls and paymaster modes recover payment/fee attribution; unknown wrappers or payment modes preserve raw token transfers instead of guessing their intent. Failed payments retain attempted amounts but have no delivered amount. Transaction hashes are absent until execution is observed; callers derive explorer links from the source transaction hash rather than storing a second copy of it. -`sync_history` returns `true` when caught up and `false` when more work remains. It uses adaptive log ranges and a 20-second soft budget between persisted receipts; an in-flight receipt may finish later. A single-block log overflow falls back to that block's individual receipts. Completed fallback scans are retained by canonical block hash within the revisit window. Zero/self transfers are discarded before enrichment. Network failures preserve completed work and never silently skip a block. +`sync_history` returns `true` when caught up and `false` when more work remains. It uses adaptive log ranges and a 20-second soft budget between persisted receipts; an in-flight receipt may finish later. Learned range limits survive budget exits. A single-block log overflow falls back to that block's individual receipts. Complete receipt enrichment and fallback scans are retained by canonical block hash within the revisit window; log-only incoming observations and successful bridge receipts missing tracking or fee evidence remain eligible for later enrichment. Incomplete bridge metadata does not keep an executed source payment pending. Zero/self transfers are discarded before enrichment. Network failures preserve completed work and never silently skip a block. Callers should defer catch-up during payment review/submission so historical work does not delay sends. -`refresh_transfer` checks one recent direct Arbitrum payment with a five-second request budget. It requires the expected operation outcome and token transfer in a matching canonical receipt and does not scan history, rebroadcast, expire payments or reconcile nonces. It can confirm execution at the current L2 tip; this is provisional sequencer execution, not parent-chain finality. Native send screens may call it approximately once per second during a short foreground window, with cancellation and rate-limit backoff between checks. Missing evidence leaves Pending intact. Normal recovery handles older payments outside its 64-block lookup window. +`check_recent_execution` checks one recent direct Arbitrum payment with a five-second request budget. It requires the expected operation outcome and token transfer in a matching canonical receipt and does not scan history, rebroadcast, expire payments or reconcile nonces. It can confirm execution at the current L2 tip; this is provisional sequencer execution, not parent-chain finality. Native send screens may call it approximately once per second during a short foreground window, with cancellation and rate-limit backoff between checks. Missing evidence leaves Pending intact. Normal recovery handles older payments outside its 64-block lookup window. -Scans trail the reported tip by two blocks and revisit 4096 blocks for delayed indexing. This is not reorg rollback: previously recorded orphaned activity is not retracted. Providers must supply complete filtered logs, canonical blocks/receipts and historical state. +Scans trail the reported tip by two blocks and revisit 4096 blocks for delayed indexing. This is not reorg rollback: previously recorded orphaned activity is not retracted. Providers must supply complete filtered logs, canonical blocks/receipts and historical state. The first scan starts at block zero, including pre-Nitro ranges; providers must answer those queries or return a supported range-limit error so the scan can narrow them. Logs first indexed more than 4096 blocks late can fall outside the revisit window. This is a block-count limit, not a guaranteed time interval or a measured provider indexing guarantee. Payment outcomes and expiry decisions trust the configured chain RPC. A malicious RPC can fabricate or suppress evidence and mislead a user into authorizing another payment; these checks are not light-client proofs. -Storage is wallet-specific and owned by the `UsdtWallet` object. Drop it before deleting its database during an explicit wallet wipe. Async exports use UniFFI's Tokio adapter, preserving cancellation of the polled future; they do not detach sends onto the global runtime used by stateless exports. +Storage is wallet-specific and must have one owning `UsdtWallet` object. Drop it before deleting its database during an explicit wallet wipe. Async exports use UniFFI's Tokio adapter; Kotlin cancellation can drop the polled future, whereas the current Swift bindings may finish an in-flight call after task cancellation. Callers must check cancellation between calls. Sends are not detached onto the global runtime used by stateless exports. ## Transport and cross-network APIs Both chain and bundler endpoints must be controlled, credential-free HTTPS URLs; HTTP is accepted only on loopback for fixtures. Provider keys belong on the server. Chain/bundler calls share an 80/minute budget with a burst of 20. Responses are bounded to 2 MiB, except protocol-projected receipts up to 16 MiB. The companion service documents provider requirements, receipt projection and deployment limits. -The outbound bridge API supports Ethereum (30101), Polygon (30109), Plasma (30383) and Stable (30396), alongside direct Arbitrum transfers. Native release flows expose Arbitrum only; bridge routes require explicit service enablement and destination acceptance. Plain deposits on another chain are not automatically forwarded. +The outbound bridge API supports Ethereum (30101), Polygon (30109), Plasma (30383) and Stable (30396), alongside direct Arbitrum transfers. Native release flows expose Arbitrum only; bridge routes require explicit service enablement and destination acceptance. Plain deposits on another chain are not automatically forwarded. Recipient validation rejects the destination token and the pinned EntryPoint, paymaster and Simple7702 delegate addresses on every destination. Direct Arbitrum sends also reject the source OFT and helper. -Bridge quotes include 10% native messaging-fee headroom and 20% token-conversion headroom, both within the displayed maximum USDT fee. Before signing or rebroadcasting, the stored native fee, helper liquidity and token approval are checked against current requirements without raising approved limits. Delivery checks process up to three transfers concurrently outside the send lock, with a ten-second request budget, even when source recovery fails; failed lookups retain the last known status. +Bridge quotes include 10% native messaging-fee headroom and 20% token-conversion headroom, both within the displayed maximum USDT fee. Before signing or rebroadcasting, the stored native fee, helper liquidity and token approval are checked against current requirements without raising approved limits. The service reports OFT/helper execution reverts as sanitized RPC code `3`, which core maps to `UnsupportedRoute` during initial quoting. A reverted fee recheck for an already reviewed bridge quote requires a fresh quote (`QuoteExpired`); insufficient helper liquidity remains `UnsupportedRoute`. Provider outages remain retryable network errors. Delivery checks process up to three transfers concurrently outside the send lock, with a ten-second request budget, even when source recovery fails; failed lookups retain the last known status, while an explicit `INFLIGHT` or `CONFIRMING` update clears a previous needs-attention state. -Bridges use the pinned OFT and TransactionValueHelper with zero account ETH, a finite USDT approval covering principal/fee, and atomic helper-allowance revocation. The deployed helper requires native liquidity and retains behaviors noted in its OpenZeppelin audit; its verified runtime is not the audit-remediated implementation. Source success means bridging, not delivered. LayerZero status must match the operation GUID/pathway before confirmation; blocked delivery remains visible and never triggers an automatic paid retry. RPC providers see queried addresses; LayerZero Scan sees bridge transaction hashes. +Bridges use the pinned OFT and TransactionValueHelper with zero account ETH, a finite USDT approval covering principal/fee, and atomic helper-allowance revocation. The deployed helper requires native liquidity and retains behaviors noted in its OpenZeppelin audit; its verified runtime is not the audit-remediated implementation. Source success means bridging, not delivered. + +`Pending` means source execution is unresolved; `Failed` means the source payment failed or was proved unexecuted; `Replaced` means another operation consumed its nonce. `Bridging` means source execution succeeded and destination delivery is unresolved. For bridges, `Confirmed` means delivery was reported. `BridgeNeedsAttention` covers retryable delivery problems or missing message evidence; without a GUID, no delivery lookup is possible. `BridgeFailed` means LayerZero reports a burned or skipped message: delivery polling stops, while source transaction, GUID, amount and fees remain visible. Neither bridge status implies a refund. Terminal delivery states survive restart and source-history rescans for the same transaction and GUID. + +LayerZero status must match the operation GUID/pathway before confirmation; blocked delivery remains visible and never triggers an automatic paid retry. + +RPC providers see queried addresses. Delivery checks use `bitkit_getBridgeMessages([sourceTransactionHash])` on the existing chain-service endpoint. The service queries LayerZero Scan without forwarding device headers, projects only message identity/pathway/status fields, and applies its shared request and response limits. LayerZero sees the service IP and the transaction hash; the service still sees the requesting device. Manually opening LayerZero Scan from transaction details connects the browser directly. No delivery requests are made for Arbitrum-only transfers. + +Unavailable, unmatched or unknown delivery responses preserve the last status. Those lookups and retryable problems (`FAILED`, `BLOCKED`, `PAYLOAD_STORED`) wait at least one minute before another automatic attempt in the same wallet session. `APPLICATION_BURNED` and `APPLICATION_SKIPPED` stop polling as `BridgeFailed`; `DELIVERED` stops polling as `Confirmed`. No status triggers an automatic paid retry or refund. ## Validation and bindings @@ -64,6 +72,7 @@ Build iOS and Android sequentially with the repository scripts; Android temporar - [Alto EIP-7702 request validation](https://github.com/pimlicolabs/alto/blob/96529592b67a69be23c013359cbc9990657af64a/src/rpc/rpcHandler.ts) - [Simple7702Account](https://github.com/eth-infinitism/account-abstraction/blob/releases/v0.8/contracts/accounts/Simple7702Account.sol) - [Pimlico supported tokens](https://docs.pimlico.io/references/paymaster/erc20-paymaster/supported-tokens) +- [Pimlico paymaster deployments](https://docs.pimlico.io/references/paymaster/erc20-paymaster/contract-addresses) - [Pimlico pricing](https://www.pimlico.io/pricing) - [Pimlico public endpoint limits](https://docs.pimlico.io/references/bundler/public-endpoint) - [USDT0 documentation](https://docs.usdt0.to/) diff --git a/src/modules/usdt/amount.rs b/src/modules/usdt/amount.rs index a99cc1c..50aaf68 100644 --- a/src/modules/usdt/amount.rs +++ b/src/modules/usdt/amount.rs @@ -33,6 +33,19 @@ pub fn usdt_format_amount(amount: u64) -> String { .to_string() } +pub(super) fn with_margin(value: U256, percent: u8) -> Result { + let hundred = U256::from(100); + let percent = U256::from(percent); + let margin = (value / hundred) + .checked_mul(percent) + .and_then(|margin| margin.checked_add(value % hundred * percent / hundred)) + .ok_or(UsdtError::InvalidResponse)?; + value + .checked_add(margin) + .and_then(|value| value.checked_add(U256::from(1))) + .ok_or(UsdtError::InvalidResponse) +} + pub(super) fn token_amount(value: U256) -> Result { value.try_into().map_err(|_| UsdtError::InvalidAmount) } @@ -73,3 +86,27 @@ pub(super) fn parse_atomic_amount(value: &str) -> Result { .checked_mul(10u64.checked_pow(power).ok_or(UsdtError::InvalidAmount)?) .ok_or(UsdtError::InvalidAmount) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn margins_preserve_rounding_without_intermediate_overflow() { + for percent in [5, 10, 20] { + for value in [ + U256::ZERO, + U256::from(19), + U256::from(20), + U256::MAX / U256::from(2), + ] { + let expected = value + value / U256::from(100 / percent) + U256::from(1); + assert_eq!(with_margin(value, percent).unwrap(), expected); + } + assert!(matches!( + with_margin(U256::MAX, percent), + Err(UsdtError::InvalidResponse) + )); + } + } +} diff --git a/src/modules/usdt/errors.rs b/src/modules/usdt/errors.rs index 688d7b8..3a5cc80 100644 --- a/src/modules/usdt/errors.rs +++ b/src/modules/usdt/errors.rs @@ -6,7 +6,7 @@ pub enum UsdtError { InvalidAmount, #[error("Enter a valid address for the selected network")] InvalidAddress, - #[error("The payment request is for a different network or token")] + #[error("The network or token does not match this USDT account")] WrongNetwork, #[error("Wallet credentials do not match this USDT account")] InvalidCredentials, diff --git a/src/modules/usdt/history.rs b/src/modules/usdt/history.rs index 9ea6a98..37b5422 100644 --- a/src/modules/usdt/history.rs +++ b/src/modules/usdt/history.rs @@ -1,8 +1,8 @@ use super::{ account::{SimpleAccount, ENTRY_POINT}, amount::token_amount, - transaction::{event_data, BridgeHelper, EntryPoint, Erc20}, - types::{BRIDGE_HELPER, EXPLORER, OFT, TOKEN}, + transaction::{entry_point_event, event_data, BridgeHelper, EntryPoint, Erc20}, + types::{BRIDGE_HELPER, OFT, TOKEN}, UsdtDestination, UsdtError, UsdtTransfer, UsdtTransferStatus, UsdtWallet, }; use alloy_primitives::{Address, Bytes, B256, U256}; @@ -11,15 +11,18 @@ use serde_json::{json, Value}; use std::{collections::BTreeMap, sync::atomic::Ordering}; pub(super) const MAX_LOG_RANGE: u64 = 10_000_000; +pub(super) const HISTORY_REVISIT_BLOCKS: u64 = 4096; +const HISTORY_BUDGET: std::time::Duration = std::time::Duration::from_secs(20); +const LOG_QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); impl UsdtWallet { pub(super) async fn scan_history(&self) -> Result { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20); + let deadline = tokio::time::Instant::now() + HISTORY_BUDGET; let tip = self.block_number().await?.saturating_sub(2); let previous = self.store.synced_block()?; let start = self.store.history_progress()?.unwrap_or_else(|| { previous - .map(|block| block.saturating_sub(4096)) + .map(|block| block.saturating_sub(HISTORY_REVISIT_BLOCKS)) .unwrap_or(0) }); if start > tip { @@ -29,79 +32,22 @@ impl UsdtWallet { let mut ceiling = initial_limit; let mut next = start; let mut width = initial_limit.min(tip - start + 1); - while next <= tip { + loop { if tokio::time::Instant::now() >= deadline { - self.history_range_limit - .store((ceiling * 2).min(MAX_LOG_RANGE), Ordering::Relaxed); + self.history_range_limit.store(ceiling, Ordering::Relaxed); return Ok(false); } let end = next.saturating_add(width - 1).min(tip); - let query = tokio::time::timeout( - std::time::Duration::from_secs(10), - self.history_logs(next, end), - ) - .await; - let result = match query { - Ok(result) => result, - Err(_) => { - self.history_range_limit - .store((width / 2).max(1), Ordering::Relaxed); - return if next > start { - Ok(false) - } else { - Err(UsdtError::NetworkUnavailable) - }; - } - }; + let result = tokio::time::timeout(LOG_QUERY_TIMEOUT, self.history_logs(next, end)) + .await + .unwrap_or(Err(UsdtError::NetworkUnavailable)); match result { Ok(transactions) => { if self.store.history_progress()? != Some(next) { self.store.save_history_progress(next)?; } - let mut blocks = BTreeMap::new(); - for ((block, hash), logs) in transactions { - if self.store.has_history_receipt(&hash)? { - continue; - } - if tokio::time::Instant::now() >= deadline { - return Ok(false); - } - let needs_receipt = logs.iter().any(|log| { - serde_json::from_value::
(log["address"].clone()) - .is_ok_and(|address| address == ENTRY_POINT) - || event_data(log) - .ok() - .and_then(|data| Erc20::Transfer::decode_log_data(&data).ok()) - .is_some_and(|event| event.from == self.address) - }); - let canonical = match blocks.entry(block) { - std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(), - std::collections::btree_map::Entry::Vacant(entry) => { - entry.insert(self.rpc.block(block).await?) - } - }; - for log in &logs { - if serde_json::from_value::(log["blockHash"].clone())? - != canonical.hash - { - return Err(UsdtError::NetworkUnavailable); - } - } - let receipt = if needs_receipt { - self.rpc - .block_receipt( - hash.parse().map_err(|_| UsdtError::InvalidResponse)?, - canonical.hash, - block, - ) - .await? - } else { - json!({"logs": logs}) - }; - let timestamp = u64::try_from(canonical.timestamp) - .map_err(|_| UsdtError::InvalidResponse)?; - self.save_receipt_history(&hash, timestamp, &receipt) - .await?; + if !self.scan_history_logs(transactions, end, deadline).await? { + return Ok(false); } } Err(UsdtError::LogRangeTooLarge) if next < end => { @@ -142,6 +88,66 @@ impl UsdtWallet { self.history_range_limit.store(width, Ordering::Relaxed); width = width.min(tip - next + 1); } + } + + async fn scan_history_logs( + &self, + transactions: BTreeMap<(u64, String), Vec>, + end: u64, + deadline: tokio::time::Instant, + ) -> Result { + let mut blocks = BTreeMap::new(); + let mut transactions = transactions.into_iter().peekable(); + while let Some(((block, hash), logs)) = transactions.next() { + if tokio::time::Instant::now() >= deadline { + return Ok(false); + } + let needs_receipt = logs.iter().any(|log| { + serde_json::from_value::
(log["address"].clone()) + .is_ok_and(|address| address == ENTRY_POINT) + || event_data(log) + .ok() + .and_then(|data| Erc20::Transfer::decode_log_data(&data).ok()) + .is_some_and(|event| event.from == self.address) + }); + let canonical = match blocks.entry(block) { + std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(), + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(self.rpc.block(block).await?) + } + }; + for log in &logs { + if serde_json::from_value::(log["blockHash"].clone())? != canonical.hash { + return Err(UsdtError::NetworkUnavailable); + } + } + if !self.store.has_history_receipt( + &hash, + &format!("{:#x}", canonical.hash), + needs_receipt, + )? { + let receipt = if needs_receipt { + self.rpc + .block_receipt( + hash.parse().map_err(|_| UsdtError::InvalidResponse)?, + canonical.hash, + block, + ) + .await? + } else { + json!({"logs": logs}) + }; + self.save_receipt_history(&hash, block, canonical, &receipt, needs_receipt) + .await?; + } + if block < end + && transactions + .peek() + .is_none_or(|((next_block, _), _)| *next_block != block) + { + self.store.save_history_progress(block + 1)?; + } + } Ok(true) } @@ -155,37 +161,58 @@ impl UsdtWallet { if self.store.begin_history_block(number, &block_hash)? { return Ok(true); } - let timestamp = u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; if self.store.history_progress()? != Some(number) { self.store.save_history_progress(number)?; } + let mut complete = true; for hash in &block.transactions { let id = format!("{hash:#x}"); - if self.store.has_history_receipt(&id)? { + if self.store.has_history_receipt(&id, &block_hash, true)? { continue; } if tokio::time::Instant::now() >= deadline { return Ok(false); } let receipt = self.rpc.block_receipt(*hash, block.hash, number).await?; - self.save_receipt_history(&id, timestamp, &receipt).await?; + complete &= self + .save_receipt_history(&id, number, &block, &receipt, true) + .await?; } if self.rpc.block(number).await?.hash != block.hash { return Err(UsdtError::NetworkUnavailable); } - self.store.complete_history_block(number, &block_hash)?; + if complete { + self.store.complete_history_block(number, &block_hash)?; + } Ok(true) } async fn save_receipt_history( &self, hash: &str, - timestamp: u64, + number: u64, + block: &super::rpc::Block, receipt: &Value, - ) -> Result<(), UsdtError> { + complete_receipt: bool, + ) -> Result { // Finish and persist a receipt before yielding the work budget. + let timestamp = block.timestamp()?; let transfers = self.receipt_history(hash, timestamp, receipt).await?; - self.store.save_history_receipt(&transfers, hash) + // Missing bridge evidence remains eligible for enrichment without keeping the source pending. + let complete_receipt = complete_receipt + && transfers.iter().all(|transfer| { + transfer.destination == UsdtDestination::Arbitrum + || transfer.status == UsdtTransferStatus::Failed + || (transfer.bridge_guid.is_some() && transfer.fee.is_some()) + }); + self.store.save_history_receipt( + &transfers, + hash, + number, + &format!("{:#x}", block.hash), + complete_receipt, + )?; + Ok(complete_receipt) } async fn history_logs( @@ -239,16 +266,12 @@ impl UsdtWallet { let mut owned_operations = Vec::new(); for log in logs { let address: Address = serde_json::from_value(log["address"].clone())?; - if address == ENTRY_POINT { - if let Ok(event) = - EntryPoint::UserOperationEvent::decode_log_data(&event_data(log)?) - { - if event.sender == self.address { - let saved = self - .store - .transfer_by_hash(&format!("{:#x}", event.userOpHash))?; - owned_operations.push((event, saved)); - } + if let Some(event) = entry_point_event(log)? { + if event.sender == self.address { + let saved = self + .store + .transfer_by_hash(&format!("{:#x}", event.userOpHash))?; + owned_operations.push((event, saved)); } } if address != TOKEN { @@ -265,7 +288,7 @@ impl UsdtWallet { let index: U256 = serde_json::from_value(log["logIndex"].clone())?; result.push(UsdtTransfer { id: format!("{hash}:{index}"), - tx_hash: hash.into(), + tx_hash: Some(hash.into()), user_operation_hash: None, bridge_guid: None, recipient: event.to.to_checksum(None), @@ -276,7 +299,6 @@ impl UsdtWallet { is_incoming: incoming, status: UsdtTransferStatus::Confirmed, timestamp, - explorer_url: format!("{EXPLORER}/tx/{hash}"), }); } if owned_operations.is_empty() { @@ -335,7 +357,7 @@ impl UsdtWallet { }; let mut transfer = UsdtTransfer { id: operation_hash.clone(), - tx_hash: hash.into(), + tx_hash: Some(hash.into()), user_operation_hash: Some(operation_hash), bridge_guid: None, recipient, @@ -346,7 +368,6 @@ impl UsdtWallet { is_incoming: false, status: UsdtTransferStatus::Pending, timestamp, - explorer_url: format!("{EXPLORER}/tx/{hash}"), }; self.settle(&mut transfer, receipt)?; let operation_logs = super::transaction::operation_logs(receipt, event.userOpHash)?; diff --git a/src/modules/usdt/keys.rs b/src/modules/usdt/keys.rs index f338575..414885f 100644 --- a/src/modules/usdt/keys.rs +++ b/src/modules/usdt/keys.rs @@ -45,6 +45,18 @@ pub(super) fn key_address(key: &SecretKey) -> Address { Address::from_raw_public_key(&public[1..]) } +pub(super) fn derive_owner_key( + mnemonic: Zeroizing, + passphrase: Option>, + owner: Address, +) -> Result { + let key = derive_key(mnemonic, passphrase)?; + if key_address(&key) != owner { + return Err(UsdtError::InvalidCredentials); + } + Ok(key) +} + #[uniffi::export] pub fn usdt_address(mnemonic: String, passphrase: Option) -> Result { Ok(key_address(&*derive_key(mnemonic.into(), passphrase.map(Into::into))?).to_checksum(None)) diff --git a/src/modules/usdt/paymaster.rs b/src/modules/usdt/paymaster.rs index f680706..ea1c292 100644 --- a/src/modules/usdt/paymaster.rs +++ b/src/modules/usdt/paymaster.rs @@ -1,5 +1,6 @@ use super::{ account::ENTRY_POINT, + amount::with_margin, rpc::Rpc, transaction::Erc20, user_operation::{Authorization, UserOperation}, @@ -12,6 +13,8 @@ use serde_json::json; use super::types::{CHAIN_ID as ARBITRUM_CHAIN_ID, TOKEN as USDT0}; pub(super) const PAYMASTER: Address = address!("888888888888Ec68A58AB8094Cc1AD20Ba3D2402"); +const MIN_PAYMASTER_VALIDITY_SECONDS: u64 = 15; +const MAX_PAYMASTER_VALIDITY_SECONDS: u64 = 900; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -63,10 +66,7 @@ impl GasEstimate { (&mut op.pre_verification_gas, self.pre_verification_gas), ] { if estimate > *limit { - *limit = estimate - .checked_add(estimate / U256::from(10)) - .and_then(|value| value.checked_add(U256::from(1))) - .ok_or(UsdtError::InvalidResponse)?; + *limit = with_margin(estimate, 10)?; changed = true; } } @@ -158,7 +158,7 @@ impl Pimlico { valid_until: 0, valid_after: 0, }; - let mut allowance = approval_margin(estimate_terms.maximum_token_cost(&op)?)?; + let mut allowance = with_margin(estimate_terms.maximum_token_cost(&op)?, 5)?; // Provider data can change gas limits and token charges. Refine both before signing. for _ in 0..3 { op.call_data = with_approval(calls, allowance); @@ -171,13 +171,17 @@ impl Pimlico { let gas_changed = estimate.apply(&mut op)?; let required = terms.maximum_token_cost(&op)?; if gas_changed || required > allowance { - allowance = allowance.max(approval_margin(required)?); + allowance = allowance.max(with_margin(required, 5)?); continue; } - if terms.valid_until == 0 || terms.valid_until > timestamp.saturating_add(900) { + if terms.valid_until == 0 + || terms.valid_until > timestamp.saturating_add(MAX_PAYMASTER_VALIDITY_SECONDS) + { return Err(UsdtError::InvalidResponse); } - if terms.valid_after > timestamp || terms.valid_until <= timestamp.saturating_add(15) { + if terms.valid_after > timestamp + || terms.valid_until <= timestamp.saturating_add(MIN_PAYMASTER_VALIDITY_SECONDS) + { return Err(UsdtError::QuoteExpired); } return Ok(( @@ -278,13 +282,6 @@ fn with_approval(calls: &[(Address, Bytes)], amount: U256) -> Bytes { super::account::batch(&batch) } -fn approval_margin(value: U256) -> Result { - value - .checked_add(value / U256::from(20)) - .and_then(|value| value.checked_add(U256::from(1))) - .ok_or(UsdtError::InvalidResponse) -} - struct Terms { exchange_rate: U256, post_op_gas: U256, diff --git a/src/modules/usdt/rpc.rs b/src/modules/usdt/rpc.rs index 27e6b65..d11ca3e 100644 --- a/src/modules/usdt/rpc.rs +++ b/src/modules/usdt/rpc.rs @@ -16,10 +16,16 @@ pub(super) struct Block { pub transactions: Vec, } +impl Block { + pub fn timestamp(&self) -> Result { + self.timestamp + .try_into() + .map_err(|_| UsdtError::InvalidResponse) + } +} + pub(super) struct Rpc { client: reqwest::Client, - #[cfg(test)] - pub(super) bridge_status_url: Option, url: String, chain_id: u64, next_request: Arc>, @@ -41,8 +47,6 @@ impl Rpc { let client = endpoint_client(&url, Duration::from_secs(25))?; Ok(Self { client, - #[cfg(test)] - bridge_status_url: None, url, chain_id, next_request: Arc::new(Mutex::new(Instant::now())), @@ -135,6 +139,13 @@ impl Rpc { if matches!(error.code, -32002 | -32603) { return Err(UsdtError::NetworkUnavailable); } + if method == "eth_call" + && error.code == 3 + && serde_json::from_value::
(params[0]["to"].clone()) + .is_ok_and(|to| [super::types::OFT, super::types::BRIDGE_HELPER].contains(&to)) + { + return Err(UsdtError::UnsupportedRoute); + } if matches!( method, "eth_chainId" @@ -147,6 +158,7 @@ impl Rpc { | "eth_getBlockByNumber" | "eth_getTransactionReceipt" | "eth_getTransactionByHash" + | "bitkit_getBridgeMessages" ) { return Err(UsdtError::NetworkUnavailable); } @@ -172,32 +184,21 @@ impl Rpc { &self, transfer: &UsdtTransfer, ) -> Result { - if transfer.bridge_guid.is_none() { - return Ok(transfer.status); - } - let base = "https://scan.layerzero-api.com"; - #[cfg(test)] - let base = self.bridge_status_url.as_deref().unwrap_or(base); - let url = format!("{base}/v1/messages/tx/{}", transfer.tx_hash); - let response = self - .client - .get(url) - .send() - .await - .map_err(|_| UsdtError::NetworkUnavailable)? - .error_for_status() - .map_err(|_| UsdtError::NetworkUnavailable)?; - let response = bounded_json(response, 2_097_152, UsdtError::InvalidResponse).await?; + let (Some(guid), Some(tx_hash)) = + (transfer.bridge_guid.as_deref(), transfer.tx_hash.as_deref()) + else { + return Err(UsdtError::NetworkUnavailable); + }; + let hash: B256 = tx_hash.parse().map_err(|_| UsdtError::InvalidResponse)?; + let response: Value = self.call("bitkit_getBridgeMessages", json!([hash])).await?; let messages = response["data"] .as_array() .ok_or(UsdtError::InvalidResponse)?; let message = messages.iter().find(|message| { - message["guid"].as_str().is_some_and(|guid| { - transfer - .bridge_guid - .as_ref() - .is_some_and(|expected| guid.eq_ignore_ascii_case(expected)) - }) && message["pathway"]["srcEid"].as_u64() == Some(30110) + message["guid"] + .as_str() + .is_some_and(|value| value.eq_ignore_ascii_case(guid)) + && message["pathway"]["srcEid"].as_u64() == Some(30110) && message["pathway"]["dstEid"].as_u64() == transfer.destination.endpoint().map(u64::from) && message["pathway"]["sender"]["address"] @@ -205,18 +206,16 @@ impl Rpc { .is_some_and(|a| a.eq_ignore_ascii_case(&super::types::OFT.to_string())) && message["source"]["tx"]["txHash"] .as_str() - .is_some_and(|h| h.eq_ignore_ascii_case(&transfer.tx_hash)) + .is_some_and(|h| h.eq_ignore_ascii_case(tx_hash)) }); Ok(match message.and_then(|m| m["status"]["name"].as_str()) { Some("DELIVERED") => UsdtTransferStatus::Confirmed, - Some( - "FAILED" - | "BLOCKED" - | "PAYLOAD_STORED" - | "APPLICATION_BURNED" - | "APPLICATION_SKIPPED", - ) => UsdtTransferStatus::BridgeNeedsAttention, - _ => transfer.status, + Some("INFLIGHT" | "CONFIRMING") => UsdtTransferStatus::Bridging, + Some("FAILED" | "BLOCKED" | "PAYLOAD_STORED") => { + UsdtTransferStatus::BridgeNeedsAttention + } + Some("APPLICATION_BURNED" | "APPLICATION_SKIPPED") => UsdtTransferStatus::BridgeFailed, + _ => return Err(UsdtError::NetworkUnavailable), }) } @@ -253,10 +252,19 @@ impl Rpc { } pub async fn contract(&self, to: Address, call: C) -> Result { + self.contract_at(to, call, "latest").await + } + + pub async fn contract_at( + &self, + to: Address, + call: C, + block: &str, + ) -> Result { let bytes: Bytes = self .call( "eth_call", - json!([{"to":to,"data":Bytes::from(call.abi_encode())},"latest"]), + json!([{"to":to,"data":Bytes::from(call.abi_encode())},block]), ) .await?; C::abi_decode_returns(&bytes).map_err(|_| UsdtError::InvalidResponse) @@ -364,6 +372,41 @@ mod tests { } } + #[tokio::test] + async fn bridge_reverts_are_distinct_from_rpc_failures() { + use super::super::types::{BRIDGE_HELPER, OFT, TOKEN}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + for (target, code, expected_route) in [ + (OFT, 3, true), + (BRIDGE_HELPER, 3, true), + (TOKEN, 3, false), + (BRIDGE_HELPER, -32602, false), + (BRIDGE_HELPER, -32002, false), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0; 4096]; + assert!(socket.read(&mut request).await.unwrap() > 0); + let body = json!({"error":{"code":code,"message":"Provider rejected the request"}}) + .to_string(); + socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + }); + let error = Rpc::new(url, 42161) + .unwrap() + .call::("eth_call", json!([{"to": target, "data":"0x"}, "latest"])) + .await + .unwrap_err(); + if expected_route { + assert!(matches!(error, UsdtError::UnsupportedRoute)); + } else { + assert!(matches!(error, UsdtError::NetworkUnavailable)); + } + server.await.unwrap(); + } + } + #[tokio::test(start_paused = true)] async fn chain_and_bundler_share_bursts_and_sustained_budget() { let chain = Rpc::new("https://chain.example".into(), 42161).unwrap(); diff --git a/src/modules/usdt/store.rs b/src/modules/usdt/store.rs index 3cd29e6..310520b 100644 --- a/src/modules/usdt/store.rs +++ b/src/modules/usdt/store.rs @@ -1,4 +1,7 @@ -use super::{transaction::Plan, UsdtError, UsdtQuote, UsdtTransfer, UsdtTransferStatus}; +use super::{ + history::HISTORY_REVISIT_BLOCKS, transaction::Plan, UsdtError, UsdtQuote, UsdtTransfer, + UsdtTransferStatus, +}; use rusqlite::{params, Connection, OptionalExtension, Transaction}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use std::{ @@ -27,11 +30,12 @@ impl Store { CREATE TABLE IF NOT EXISTS usdt_identity (id INTEGER PRIMARY KEY CHECK(id=1), identity TEXT NOT NULL); CREATE TABLE IF NOT EXISTS usdt_sync (id INTEGER PRIMARY KEY CHECK(id=1), newest INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS usdt_history_progress (id INTEGER PRIMARY KEY CHECK(id=1), next INTEGER NOT NULL); - CREATE TABLE IF NOT EXISTS usdt_history_receipts (hash TEXT PRIMARY KEY); + CREATE TABLE IF NOT EXISTS usdt_history_receipts (hash TEXT PRIMARY KEY, block_number INTEGER NOT NULL, block_hash TEXT NOT NULL, complete_receipt INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS usdt_history_blocks (number INTEGER PRIMARY KEY, hash TEXT NOT NULL, complete INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS usdt_quotes (id TEXT PRIMARY KEY, data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS usdt_nonce_recovery (id TEXT PRIMARY KEY, block_hash TEXT NOT NULL, next_transaction INTEGER NOT NULL); - CREATE TABLE IF NOT EXISTS usdt_transfers (id TEXT PRIMARY KEY, hash TEXT NOT NULL, raw TEXT, data TEXT NOT NULL);")?; + CREATE TABLE IF NOT EXISTS usdt_transfers (id TEXT PRIMARY KEY, hash TEXT NOT NULL, raw TEXT, data TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS usdt_transfers_hash ON usdt_transfers(hash);")?; connection.execute( "INSERT OR IGNORE INTO usdt_identity VALUES (1,?1)", [identity], @@ -88,15 +92,7 @@ impl Store { } pub fn transfer_by_hash(&self, hash: &str) -> Result, UsdtError> { - let data: Option = self - .connection()? - .query_row( - "SELECT data FROM usdt_transfers WHERE hash=?1", - [hash], - |r| r.get(0), - ) - .optional()?; - data.map(|data| decode(&data)).transpose() + find_transfer_by_hash(&*self.connection()?, hash) } pub fn transfers(&self) -> Result, UsdtError> { @@ -131,24 +127,9 @@ impl Store { } pub fn update_transfer(&self, transfer: &UsdtTransfer) -> Result<(), UsdtError> { - let settled = matches!( - transfer.status, - UsdtTransferStatus::Confirmed - | UsdtTransferStatus::Failed - | UsdtTransferStatus::Replaced - | UsdtTransferStatus::Bridging - | UsdtTransferStatus::BridgeNeedsAttention - ); let mut connection = self.connection()?; let tx = connection.transaction()?; - tx.execute("UPDATE usdt_transfers SET data=?1, raw=CASE WHEN ?2 THEN NULL ELSE raw END WHERE id=?3", - params![serde_json::to_string(transfer)?, settled, transfer.id])?; - if settled { - tx.execute( - "DELETE FROM usdt_nonce_recovery WHERE id=?1", - [&transfer.id], - )?; - } + write_transfer(&tx, transfer)?; tx.commit()?; Ok(()) } @@ -191,10 +172,13 @@ impl Store { let mut connection = self.connection()?; let tx = connection.transaction()?; tx.execute("DELETE FROM usdt_history_progress", [])?; - tx.execute("DELETE FROM usdt_history_receipts", [])?; + tx.execute( + "DELETE FROM usdt_history_receipts WHERE complete_receipt=0 OR block_number < ?1", + [newest.saturating_sub(HISTORY_REVISIT_BLOCKS)], + )?; tx.execute( "DELETE FROM usdt_history_blocks WHERE number < ?1", - [newest.saturating_sub(4096)], + [newest.saturating_sub(HISTORY_REVISIT_BLOCKS)], )?; tx.execute("INSERT INTO usdt_sync (id,newest) VALUES (1,?1) ON CONFLICT(id) DO UPDATE SET newest=excluded.newest", [newest])?; tx.commit()?; @@ -217,10 +201,13 @@ impl Store { let tx = connection.transaction()?; tx.execute( "DELETE FROM usdt_history_blocks WHERE number < ?1", - [next.saturating_sub(4096)], + [next.saturating_sub(HISTORY_REVISIT_BLOCKS)], )?; tx.execute("INSERT INTO usdt_history_progress VALUES (1,?1) ON CONFLICT(id) DO UPDATE SET next=excluded.next", [next])?; - tx.execute("DELETE FROM usdt_history_receipts", [])?; + tx.execute( + "DELETE FROM usdt_history_receipts WHERE complete_receipt=0 OR block_number < ?1", + [next.saturating_sub(HISTORY_REVISIT_BLOCKS)], + )?; tx.commit()?; Ok(()) } @@ -239,7 +226,10 @@ impl Store { if saved_hash == hash { return Ok(complete); } - tx.execute("DELETE FROM usdt_history_receipts", [])?; + tx.execute( + "DELETE FROM usdt_history_receipts WHERE block_number=?1", + [number], + )?; } tx.execute("INSERT INTO usdt_history_blocks VALUES (?1,?2,0) ON CONFLICT(number) DO UPDATE SET hash=excluded.hash,complete=0", params![number, hash])?; tx.commit()?; @@ -254,10 +244,15 @@ impl Store { Ok(()) } - pub fn has_history_receipt(&self, hash: &str) -> Result { + pub fn has_history_receipt( + &self, + hash: &str, + block_hash: &str, + require_complete: bool, + ) -> Result { Ok(self.connection()?.query_row( - "SELECT EXISTS(SELECT 1 FROM usdt_history_receipts WHERE hash=?1)", - [hash], + "SELECT EXISTS(SELECT 1 FROM usdt_history_receipts WHERE hash=?1 AND block_hash=?2 AND (complete_receipt=1 OR ?3=0))", + params![hash, block_hash, require_complete], |row| row.get(0), )?) } @@ -266,13 +261,16 @@ impl Store { &self, transfers: &[UsdtTransfer], hash: &str, + block_number: u64, + block_hash: &str, + complete_receipt: bool, ) -> Result<(), UsdtError> { let mut connection = self.connection()?; let tx = connection.transaction()?; Self::merge_history(&tx, transfers)?; tx.execute( - "INSERT OR IGNORE INTO usdt_history_receipts VALUES (?1)", - [hash], + "INSERT INTO usdt_history_receipts VALUES (?1,?2,?3,?4) ON CONFLICT(hash) DO UPDATE SET block_number=excluded.block_number,block_hash=excluded.block_hash,complete_receipt=excluded.complete_receipt", + params![hash, block_number, block_hash, complete_receipt], )?; tx.commit()?; Ok(()) @@ -284,18 +282,15 @@ impl Store { .user_operation_hash .as_deref() .unwrap_or(&transfer.id); - let existing: Option = tx - .query_row( - "SELECT data FROM usdt_transfers WHERE hash=?1", - [hash], - |row| row.get(0), - ) - .optional()?; + let existing = find_transfer_by_hash(tx, hash)?; let mut transfer = transfer.clone(); - if let Some(data) = existing { - let saved: UsdtTransfer = decode(&data)?; + if let Some(saved) = existing { transfer.id = saved.id; - if saved.tx_hash.eq_ignore_ascii_case(&transfer.tx_hash) + if saved + .tx_hash + .as_deref() + .zip(transfer.tx_hash.as_deref()) + .is_some_and(|(a, b)| a.eq_ignore_ascii_case(b)) && saved .bridge_guid .as_ref() @@ -304,19 +299,14 @@ impl Store { && transfer.status == UsdtTransferStatus::Bridging && matches!( saved.status, - UsdtTransferStatus::Confirmed | UsdtTransferStatus::BridgeNeedsAttention + UsdtTransferStatus::Confirmed + | UsdtTransferStatus::BridgeNeedsAttention + | UsdtTransferStatus::BridgeFailed ) { transfer.status = saved.status; } - tx.execute( - "UPDATE usdt_transfers SET data=?1, raw=NULL WHERE id=?2", - params![serde_json::to_string(&transfer)?, transfer.id], - )?; - tx.execute( - "DELETE FROM usdt_nonce_recovery WHERE id=?1", - [&transfer.id], - )?; + write_transfer(tx, &transfer)?; } else { tx.execute( "INSERT INTO usdt_transfers (id,hash,data) VALUES (?1,?2,?3)", @@ -327,23 +317,27 @@ impl Store { Ok(()) } - pub fn unsettled(&self) -> Result, UsdtError> { + pub fn awaiting_delivery(&self) -> Result, UsdtError> { let connection = self.connection()?; - let mut statement = connection.prepare("SELECT data FROM usdt_transfers")?; + let mut statement = connection.prepare( + "SELECT data FROM usdt_transfers WHERE json_extract(data, '$.status') IN ('Bridging','BridgeNeedsAttention') AND json_extract(data, '$.bridge_guid') IS NOT NULL ORDER BY json_extract(data, '$.timestamp') DESC, id", + )?; let rows = statement.query_map([], |row| row.get::<_, String>(0))?; - let mut result = Vec::new(); - for row in rows { - let transfer: UsdtTransfer = decode(&row?)?; - if matches!( - transfer.status, - UsdtTransferStatus::Pending - | UsdtTransferStatus::Bridging - | UsdtTransferStatus::BridgeNeedsAttention - ) { - result.push(transfer); - } - } - Ok(result) + rows.map(|row| decode(&row?)).collect() + } + + pub fn pending_operation(&self) -> Result, UsdtError> { + let saved: Option<(String, String)> = self + .connection()? + .query_row( + "SELECT data,raw FROM usdt_transfers WHERE raw IS NOT NULL", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + saved + .map(|(data, raw)| Ok((decode(&data)?, decode(&raw)?))) + .transpose() } pub fn pending_plan(&self, id: &str) -> Result, UsdtError> { @@ -358,6 +352,35 @@ impl Store { } } +fn write_transfer(connection: &Connection, transfer: &UsdtTransfer) -> Result<(), UsdtError> { + let settled = transfer.status != UsdtTransferStatus::Pending; + connection.execute( + "UPDATE usdt_transfers SET data=?1, raw=CASE WHEN ?2 THEN NULL ELSE raw END WHERE id=?3", + params![serde_json::to_string(transfer)?, settled, transfer.id], + )?; + if settled { + connection.execute( + "DELETE FROM usdt_nonce_recovery WHERE id=?1", + [&transfer.id], + )?; + } + Ok(()) +} + +fn find_transfer_by_hash( + connection: &Connection, + hash: &str, +) -> Result, UsdtError> { + let data: Option = connection + .query_row( + "SELECT data FROM usdt_transfers WHERE hash=?1", + [hash], + |row| row.get(0), + ) + .optional()?; + data.map(|data| decode(&data)).transpose() +} + fn decode(data: &str) -> Result { serde_json::from_str(data).map_err(|error| UsdtError::Storage { reason: error.to_string(), diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index 91d747c..594fd53 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -242,17 +242,24 @@ struct ChainState { mined: bool, tip: u64, timestamp: alloy_primitives::U256, + block_hashes: std::collections::BTreeMap, + block_timestamps: std::collections::BTreeMap, reject_broadcast: bool, delay_gas_estimate: bool, + bridge_messages: std::collections::HashMap, + bridge_requests: Vec, + bridge_delay: std::time::Duration, paymaster: alloy_primitives::Address, helper_balance: alloy_primitives::U256, native_message_fee: u64, helper_token_fee: u64, + quote_revert: Option, history_input: Option, history_target: Option, receipt_logs: Option>, incoming: bool, hide_logs: bool, + hide_operation_logs: bool, hide_receipts: bool, receipt_failure: Option, oversized_block: Option, @@ -264,6 +271,7 @@ struct ChainState { max_log_range: Option, oversized_logs: bool, log_requests: usize, + log_ranges: Vec<(u64, u64)>, incoming_count: u64, block_reads: usize, fail_block_read_at: Option, @@ -296,17 +304,24 @@ impl MockChain { mined: false, tip: 20000, timestamp: U256::from(wallet::now()), + block_hashes: Default::default(), + block_timestamps: Default::default(), reject_broadcast: false, delay_gas_estimate: false, + bridge_messages: Default::default(), + bridge_requests: vec![], + bridge_delay: std::time::Duration::ZERO, paymaster: paymaster::PAYMASTER, helper_balance: U256::from(1_000_000_000_000_000u64), native_message_fee: 10_000_000_000, helper_token_fee: 300_000, + quote_revert: None, history_input: None, history_target: Some(account::ENTRY_POINT), receipt_logs: None, incoming: false, hide_logs: false, + hide_operation_logs: false, hide_receipts: false, receipt_failure: None, oversized_block: None, @@ -318,6 +333,7 @@ impl MockChain { max_log_range: None, oversized_logs: false, log_requests: 0, + log_ranges: vec![], incoming_count: 0, block_reads: 0, fail_block_read_at: None, @@ -327,6 +343,7 @@ impl MockChain { })); let server_state = state.clone(); let task = tokio::spawn(async move { + let mut requests = tokio::task::JoinSet::new(); while let Ok((mut socket, _)) = listener.accept().await { let mut request = Vec::new(); let header_end = loop { @@ -393,12 +410,25 @@ impl MockChain { if delay { tokio::time::sleep(std::time::Duration::from_secs(6)).await; } + let bridge_delay = if method == "bitkit_getBridgeMessages" { + let mut state = server_state.lock().unwrap(); + state + .bridge_requests + .push(body["params"][0].as_str().unwrap().into()); + state.bridge_delay + } else { + std::time::Duration::ZERO + }; let mut response = server_state.lock().unwrap().respond(&body); if body["method"] == "eth_getLogs" { if let Some(logs) = response["result"].as_array_mut() { for log in logs { + let number = serde_json::from_value::(log["blockNumber"].clone()) + .ok() + .and_then(|number| u64::try_from(number).ok()) + .unwrap_or(20000); log.as_object_mut().unwrap().entry("blockHash").or_insert( - serde_json::json!(alloy_primitives::B256::repeat_byte(9)), + serde_json::json!(server_state.lock().unwrap().block_hash(number)), ); } } @@ -408,7 +438,11 @@ impl MockChain { } let response = response.to_string(); let response=format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}",response.len()); - let _ = socket.write_all(response.as_bytes()).await; + while requests.try_join_next().is_some() {} + requests.spawn(async move { + tokio::time::sleep(bridge_delay).await; + let _ = socket.write_all(response.as_bytes()).await; + }); } }); Self { url, state, task } @@ -424,6 +458,12 @@ impl MockChain { } } impl ChainState { + fn block_hash(&self, number: u64) -> alloy_primitives::B256 { + self.block_hashes.get(&number).copied().unwrap_or_else(|| { + alloy_primitives::B256::from(alloy_primitives::U256::from(number).to_be_bytes::<32>()) + }) + } + fn respond(&mut self, body: &serde_json::Value) -> serde_json::Value { use alloy_primitives::{Bytes, U256}; use alloy_sol_types::{SolCall, SolValue}; @@ -444,6 +484,11 @@ impl ChainState { } } let result = match body["method"].as_str().unwrap() { + "bitkit_getBridgeMessages" => self + .bridge_messages + .get(&body["params"][0].as_str().unwrap().to_ascii_lowercase()) + .cloned() + .unwrap_or_else(|| json!({"data":[]})), "eth_chainId" => json!(U256::from(self.chain)), "eth_blockNumber" => json!(U256::from(self.tip)), "eth_getBlockByNumber" => { @@ -455,7 +500,17 @@ impl ChainState { self.fail_block_read_at = None; return json!({"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"temporarily unavailable"}}); } - json!({"hash":alloy_primitives::B256::repeat_byte(9),"timestamp":self.timestamp,"transactions":self.block_transactions.clone().unwrap_or_else(|| vec![alloy_primitives::B256::repeat_byte(7)])}) + let number = u64::try_from( + serde_json::from_value::(body["params"][0].clone()).unwrap(), + ) + .unwrap(); + let timestamp = self + .block_timestamps + .range(..=number) + .next_back() + .map(|(_, timestamp)| *timestamp) + .unwrap_or(self.timestamp); + json!({"hash":self.block_hash(number),"timestamp":timestamp,"transactions":self.block_transactions.clone().unwrap_or_else(|| vec![alloy_primitives::B256::repeat_byte(7)])}) } "eth_getCode" => json!(self.account_code), "eth_getTransactionCount" => json!(U256::from(self.authorization_nonce)), @@ -466,6 +521,12 @@ impl ChainState { use transaction::{BridgeHelper, MessagingFee, OFTLimit, OFTReceipt, Oft}; let target: alloy_primitives::Address = serde_json::from_value(body["params"][0]["to"].clone()).unwrap(); + if self.quote_revert == Some(target) + && (data.starts_with(&Oft::quoteSendCall::SELECTOR) + || data.starts_with(&BridgeHelper::quoteSendCall::SELECTOR)) + { + return json!({"jsonrpc":"2.0","id":1,"error":{"code":3,"message":"Bridge contract call reverted"}}); + } if data.starts_with(&Oft::tokenCall::SELECTOR) { assert!([types::OFT, types::BRIDGE_HELPER].contains(&target)); } @@ -599,6 +660,8 @@ impl ChainState { let filter = &body["params"][0]; let from: U256 = serde_json::from_value(filter["fromBlock"].clone()).unwrap(); let to: U256 = serde_json::from_value(filter["toBlock"].clone()).unwrap(); + self.log_ranges + .push((u64::try_from(from).unwrap(), u64::try_from(to).unwrap())); if self .oversized_block .is_some_and(|block| from <= U256::from(block) && to >= U256::from(block)) @@ -665,6 +728,7 @@ impl ChainState { } if self.mined && !self.hide_logs + && !self.hide_operation_logs && from <= U256::from(20000) && to >= U256::from(20000) && (filter["address"] == json!(account::ENTRY_POINT) @@ -691,7 +755,14 @@ impl ChainState { { return json!({"jsonrpc":"2.0","id":1,"result":null}); } - json!({"transactionHash":body["params"][0],"blockHash":alloy_primitives::B256::repeat_byte(9),"blockNumber":"0x4e20","logs":self.receipt_logs.clone().unwrap_or_else(|| self.event_logs()),"padding":" ".repeat(self.receipt_padding)}) + let logs = self.receipt_logs.clone().unwrap_or_else(|| { + if body["params"][0] == json!(alloy_primitives::B256::repeat_byte(7)) { + self.event_logs() + } else { + vec![] + } + }); + json!({"transactionHash":body["params"][0],"blockHash":self.block_hash(20000),"blockNumber":"0x4e20","logs":logs,"padding":" ".repeat(self.receipt_padding)}) } "eth_getTransactionByHash" => { let op = &self.operations[0]; @@ -798,13 +869,10 @@ async fn signed_operation_survives_uncertain_broadcast_and_restart() { let chain = MockChain::start().await; let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); - let quote = tokio::time::timeout( - std::time::Duration::from_secs(3), - wallet.quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum), - ) - .await - .expect("An idle wallet must quote without a fixed per-request delay") - .unwrap(); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); let next = wallet .quote_transfer(RECIPIENT.into(), 2_000_000, UsdtDestination::Arbitrum) .await @@ -815,7 +883,7 @@ async fn signed_operation_survives_uncertain_broadcast_and_restart() { .await .unwrap(); assert_eq!(sent.status, UsdtTransferStatus::Pending); - assert!(sent.tx_hash.is_empty()); + assert!(sent.tx_hash.is_none()); let op = chain.state.lock().unwrap().operations[0].clone(); assert_eq!(op.sender.to_checksum(None), wallet.receive_address()); assert_eq!( @@ -868,7 +936,7 @@ async fn signed_operation_survives_uncertain_broadcast_and_restart() { assert_eq!(history.len(), 1); assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); assert_eq!(history[0].fee, Some(123)); - assert!(!history[0].tx_hash.is_empty()); + assert!(history[0].tx_hash.is_some()); } #[tokio::test] @@ -979,7 +1047,10 @@ async fn wrong_network_owner_nonce_balance_and_paymaster_cannot_sign() { #[tokio::test] async fn expired_unmined_operation_releases_nonce_for_a_new_approval() { - for nonce in [0, 1] { + for (nonce, log_error) in [ + (0, None), + (1, Some((-32002, "Provider unavailable".into()))), + ] { let chain = MockChain::start().await; chain.state.lock().unwrap().nonce = nonce; let dir = tempfile::tempdir().unwrap(); @@ -998,6 +1069,7 @@ async fn expired_unmined_operation_releases_nonce_for_a_new_approval() { state.timestamp += alloy_primitives::U256::from(180); state.tip += 3; state.max_log_range = Some(1); + state.log_error = log_error.clone(); } assert_eq!( wallet.refresh_transfers().await.unwrap()[0].status, @@ -1009,7 +1081,20 @@ async fn expired_unmined_operation_releases_nonce_for_a_new_approval() { .await, Err(UsdtError::PendingTransfer) )); - chain.state.lock().unwrap().timestamp += alloy_primitives::U256::from(421); + { + let mut state = chain.state.lock().unwrap(); + state.timestamp += alloy_primitives::U256::from(421); + state.log_error = Some((-32016, "Provider rate limit exceeded".into())); + } + assert!(matches!( + wallet.refresh_transfers().await, + Err(UsdtError::RateLimited) + )); + assert_eq!( + wallet.history().unwrap()[0].status, + UsdtTransferStatus::Pending + ); + chain.state.lock().unwrap().log_error = log_error; assert_eq!( wallet.refresh_transfers().await.unwrap()[0].status, UsdtTransferStatus::Failed @@ -1070,6 +1155,22 @@ async fn bridge_payment_bounds_token_fees_and_revokes_helper_approval() { assert_eq!(bridge_fee, 360_001); assert!(paymaster.amount > U256::from(quote.maximum_fee - bridge_fee)); assert_eq!(quote.received_amount, 1_000_000); + for target in [types::OFT, types::BRIDGE_HELPER] { + chain.state.lock().unwrap().quote_revert = Some(target); + assert!(matches!( + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::QuoteExpired) + )); + assert!(matches!( + wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) + .await, + Err(UsdtError::UnsupportedRoute) + )); + } + chain.state.lock().unwrap().quote_revert = None; chain.state.lock().unwrap().native_message_fee = 12_000_000_000; assert!(matches!( wallet @@ -1150,68 +1251,84 @@ async fn bundled_operations_cannot_contribute_another_payments_bridge_status_or_ use alloy_primitives::{B256, U256}; use alloy_sol_types::SolEvent; use serde_json::json; - let chain = MockChain::start().await; - let dir = tempfile::tempdir().unwrap(); - let wallet = chain.wallet(&dir); - let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) - .await - .unwrap(); - let mut transfer = wallet - .send(quote.id, TEST_PHRASE.into(), None) - .await - .unwrap(); - let own_hash = transfer - .user_operation_hash - .as_ref() - .unwrap() - .parse() - .unwrap(); - let other_hash = B256::repeat_byte(2); - let guid = B256::repeat_byte(3); - let event = |hash, success| { - transaction::EntryPoint::UserOperationEvent { - userOpHash: hash, - sender: wallet.address, - paymaster: paymaster::PAYMASTER, - nonce: U256::ZERO, - success, - actualGasCost: U256::from(1), - actualGasUsed: U256::from(1), - } - .encode_log_data() - }; - let log = |address, data: alloy_primitives::LogData| json!({"address":address,"topics":data.topics(),"data":data.data}); - let receipt = json!({"logs":[ - log(types::OFT, transaction::Oft::OFTSent { guid, dstEid:30109, fromAddress:types::BRIDGE_HELPER, amountSentLD:U256::from(1_000_000), amountReceivedLD:U256::from(1_000_000) }.encode_log_data()), - log(types::BRIDGE_HELPER, transaction::BridgeHelper::LogSend { sender:wallet.address, oft:types::OFT, amountLD:U256::from(1_000_000), nativeFee:U256::from(100), feeInToken:U256::from(500), totalAmount:U256::from(1_000_500) }.encode_log_data()), - log(account::ENTRY_POINT, event(other_hash, true)), - log(paymaster::PAYMASTER, transaction::Paymaster::UserOperationSponsored { userOpHash:own_hash, user:wallet.address, paymasterMode:1, token:types::TOKEN, tokenAmountPaid:U256::from(123), exchangeRate:U256::from(1) }.encode_log_data()), - log(account::ENTRY_POINT, event(own_hash, true)), - ]}); - assert_eq!( - transaction::operation_logs(&receipt, own_hash).unwrap(), - &receipt["logs"].as_array().unwrap()[3..] - ); - let mut failed = receipt.clone(); - failed["logs"][4] = log(account::ENTRY_POINT, event(own_hash, false)); - wallet.settle(&mut transfer, &failed).unwrap(); - assert_eq!(transfer.status, UsdtTransferStatus::Failed); - assert_eq!(transfer.fee, Some(123)); - assert_eq!(transfer.bridge_guid, None); - wallet.settle(&mut transfer, &receipt).unwrap(); - assert_eq!(transfer.status, UsdtTransferStatus::BridgeNeedsAttention); - assert_eq!(transfer.bridge_guid, None); - assert_eq!(transfer.fee, None); + for (destination, status, fee, fee_with_bridge_log) in [ + ( + UsdtDestination::Arbitrum, + UsdtTransferStatus::Confirmed, + Some(123), + Some(123), + ), + ( + UsdtDestination::Polygon, + UsdtTransferStatus::BridgeNeedsAttention, + None, + Some(623), + ), + ] { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, destination) + .await + .unwrap(); + let mut transfer = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let own_hash = transfer + .user_operation_hash + .as_ref() + .unwrap() + .parse() + .unwrap(); + let other_hash = B256::repeat_byte(2); + let guid = B256::repeat_byte(3); + let event = |hash, success| { + transaction::EntryPoint::UserOperationEvent { + userOpHash: hash, + sender: wallet.address, + paymaster: paymaster::PAYMASTER, + nonce: U256::ZERO, + success, + actualGasCost: U256::from(1), + actualGasUsed: U256::from(1), + } + .encode_log_data() + }; + let log = |address, data: alloy_primitives::LogData| json!({"address":address,"topics":data.topics(),"data":data.data}); + let receipt = json!({"logs":[ + log(types::OFT, transaction::Oft::OFTSent { guid, dstEid:30109, fromAddress:types::BRIDGE_HELPER, amountSentLD:U256::from(1_000_000), amountReceivedLD:U256::from(1_000_000) }.encode_log_data()), + log(types::BRIDGE_HELPER, transaction::BridgeHelper::LogSend { sender:wallet.address, oft:types::OFT, amountLD:U256::from(1_000_000), nativeFee:U256::from(100), feeInToken:U256::from(500), totalAmount:U256::from(1_000_500) }.encode_log_data()), + log(account::ENTRY_POINT, event(other_hash, true)), + log(types::TOKEN, transaction::Erc20::Transfer { from:wallet.address, to:RECIPIENT.parse().unwrap(), value:U256::from(1_000_000) }.encode_log_data()), + log(paymaster::PAYMASTER, transaction::Paymaster::UserOperationSponsored { userOpHash:own_hash, user:wallet.address, paymasterMode:1, token:types::TOKEN, tokenAmountPaid:U256::from(123), exchangeRate:U256::from(1) }.encode_log_data()), + log(account::ENTRY_POINT, event(own_hash, true)), + ]}); + assert_eq!( + transaction::operation_logs(&receipt, own_hash).unwrap(), + &receipt["logs"].as_array().unwrap()[3..] + ); + let mut failed = receipt.clone(); + failed["logs"][5] = log(account::ENTRY_POINT, event(own_hash, false)); + wallet.settle(&mut transfer, &failed).unwrap(); + assert_eq!(transfer.status, UsdtTransferStatus::Failed); + assert_eq!(transfer.fee, Some(123)); + assert_eq!(transfer.bridge_guid, None); + wallet.settle(&mut transfer, &receipt).unwrap(); + assert_eq!(transfer.status, status); + assert_eq!(transfer.bridge_guid, None); + assert_eq!(transfer.fee, fee); - let mut receipt = receipt; - let bridge_log = receipt["logs"][1].clone(); - receipt["logs"] - .as_array_mut() - .unwrap() - .insert(4, bridge_log); - wallet.settle(&mut transfer, &receipt).unwrap(); - assert_eq!(transfer.fee, Some(623)); + let mut receipt = receipt; + let bridge_log = receipt["logs"][1].clone(); + receipt["logs"] + .as_array_mut() + .unwrap() + .insert(5, bridge_log); + wallet.settle(&mut transfer, &receipt).unwrap(); + assert_eq!(transfer.fee, fee_with_bridge_log); + } } #[tokio::test] @@ -1223,17 +1340,13 @@ async fn deployed_contracts_collect_usdt_fees_and_revert_failed_bridges_atomical let client: String = rpc.call("web3_clientVersion", json!([])).await.unwrap(); assert!(client.to_lowercase().contains("anvil")); let dir = tempfile::tempdir().unwrap(); - let mut wallet = UsdtWallet::new( + let wallet = UsdtWallet::new( usdt_address(TEST_PHRASE.into(), None).unwrap(), dir.path().join("usdt.sqlite").to_string_lossy().into(), - std::env::var("USDT_FORK_RPC_URL").unwrap_or_else(|_| "http://127.0.0.1:18545".into()), + std::env::var("USDT_FORK_RPC_URL").unwrap_or_else(|_| "http://127.0.0.1:18546".into()), std::env::var("USDT_FORK_BUNDLER_URL").unwrap_or_else(|_| "http://127.0.0.1:18546".into()), ) .unwrap(); - std::sync::Arc::get_mut(&mut wallet) - .unwrap() - .rpc - .bridge_status_url = Some("http://127.0.0.1:18546".into()); assert_eq!(rpc.balance(wallet.address).await.unwrap(), U256::ZERO); let initial = wallet.balance().await.unwrap(); // Only locally mined transactions belong to this fixture's history. @@ -1480,7 +1593,7 @@ async fn wrapped_history_preserves_signed_payments_and_restores_token_transfers( assert_eq!(payment.recipient, RECIPIENT); assert_eq!(payment.status, UsdtTransferStatus::Confirmed); assert_eq!(payment.fee, Some(123)); - assert!(!payment.tx_hash.is_empty()); + assert!(payment.tx_hash.is_some()); assert!(reopened.store.pending_plan(&sent.id).unwrap().is_none()); let restored_dir = tempfile::tempdir().unwrap(); @@ -1514,7 +1627,7 @@ fn stored_activity_is_complete_and_sorted_newest_first() { let transfers: Vec<_> = (0..501) .map(|index| UsdtTransfer { id: format!("receipt-{index}"), - tx_hash: format!("tx-{index}"), + tx_hash: Some(format!("tx-{index}")), user_operation_hash: None, bridge_guid: None, recipient: RECIPIENT.into(), @@ -1525,10 +1638,11 @@ fn stored_activity_is_complete_and_sorted_newest_first() { is_incoming: true, status: UsdtTransferStatus::Confirmed, timestamp: index, - explorer_url: String::new(), }) .collect(); - store.save_history_receipt(&transfers, "receipt").unwrap(); + store + .save_history_receipt(&transfers, "receipt", 1000, "block", true) + .unwrap(); store.complete_history(1000).unwrap(); let history = store.transfers().unwrap(); assert_eq!(history.len(), 501); @@ -1610,9 +1724,10 @@ async fn interrupted_history_resumes_without_repeating_completed_work() { .send(quote.id, TEST_PHRASE.into(), None) .await .unwrap(); + let incoming_count = 100; { let mut state = chain.state.lock().unwrap(); - state.incoming_count = 100; + state.incoming_count = incoming_count as u64; state.tip = 508_000_000; state.block_reads = 0; state.fail_block_read_at = Some(25); @@ -1637,16 +1752,20 @@ async fn interrupted_history_resumes_without_repeating_completed_work() { drop(restored); let restored = chain.wallet(&restored_dir); sync_history_to_tip(&restored).await; - assert_eq!(restored.history().unwrap().len(), 100); - assert_eq!(chain.state.lock().unwrap().block_reads, 101); + assert_eq!(restored.history().unwrap().len(), incoming_count); + assert_eq!(chain.state.lock().unwrap().block_reads, incoming_count + 1); sync_history_to_tip(&restored).await; - assert_eq!(restored.history().unwrap().len(), 100); - assert_eq!(chain.state.lock().unwrap().block_reads, 101); + assert_eq!(restored.history().unwrap().len(), incoming_count); + assert_eq!(chain.state.lock().unwrap().block_reads, incoming_count + 1); assert!(restored.store.history_progress().unwrap().is_none()); assert_eq!(restored.store.synced_block().unwrap().unwrap(), 507_999_998); assert!(!restored .store - .has_history_receipt(&restored.history().unwrap()[0].tx_hash) + .has_history_receipt( + restored.history().unwrap()[0].tx_hash.as_deref().unwrap(), + &format!("{:#x}", chain.state.lock().unwrap().block_hash(20100)), + false + ) .unwrap()); } @@ -1665,10 +1784,13 @@ async fn replacement_after_expiry_recovers_pending_send_after_restart() { .await .unwrap(); drop(wallet); + let expiry_block = 22_500; { let mut state = chain.state.lock().unwrap(); state.nonce = 1; state.timestamp += alloy_primitives::U256::from(600); + let expired = state.timestamp + alloy_primitives::U256::from(1); + state.block_timestamps.insert(expiry_block, expired); state.tip = 508_000_000; state.replacement_block = Some(507_000_000); state.hide_logs = true; @@ -1677,6 +1799,11 @@ async fn replacement_after_expiry_recovers_pending_send_after_restart() { } let wallet = chain.wallet(&dir); assert!(wallet.refresh_transfers().await.is_err()); + let log_ranges = chain.state.lock().unwrap().log_ranges.clone(); + assert!(!log_ranges.is_empty()); + assert!(log_ranges + .iter() + .all(|&(start, end)| start == end || end <= expiry_block)); assert_eq!( wallet.history().unwrap()[0].status, UsdtTransferStatus::Pending @@ -1689,7 +1816,7 @@ async fn replacement_after_expiry_recovers_pending_send_after_restart() { state.hide_receipts = false; state.receipt_response = Some(serde_json::json!({ "transactionHash":alloy_primitives::B256::repeat_byte(7), - "blockHash":alloy_primitives::B256::repeat_byte(9), + "blockHash":state.block_hash(507_000_000), "blockNumber":alloy_primitives::U256::from(507_000_000),"logs":[] })); } @@ -1774,14 +1901,25 @@ async fn history_distinguishes_rate_limits_from_log_range_limits() { #[tokio::test] async fn history_budget_returns_incomplete_and_resumes_to_tip() { let chain = MockChain::start().await; - chain.state.lock().unwrap().tip = 508_000_000; + { + let mut state = chain.state.lock().unwrap(); + state.tip = 508_000_000; + state.max_log_range = Some(1_000_000); + } let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); assert!(!wallet.sync_history().await.unwrap()); let next = wallet.store.history_progress().unwrap().unwrap(); assert!(next > 0 && next < 508_000_000); + assert!( + wallet + .history_range_limit + .load(std::sync::atomic::Ordering::Relaxed) + <= 1_000_000 + ); + chain.state.lock().unwrap().tip = next + 1_000_000; sync_history_to_tip(&wallet).await; - assert_eq!(wallet.store.synced_block().unwrap(), Some(507_999_998)); + assert_eq!(wallet.store.synced_block().unwrap(), Some(next + 999_998)); } #[tokio::test] @@ -1847,57 +1985,20 @@ async fn invalid_chain_data_and_stored_json_have_distinct_errors() { #[tokio::test] async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending() { - use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, Mutex, - }; - use tokio::{ - io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, - time::Duration, - }; + use tokio::time::Duration; let chain = MockChain::start().await; let directory = tempfile::tempdir().unwrap(); - let mut wallet = chain.wallet(&directory); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - let attempts = Arc::new(Mutex::new(Vec::new())); - let stalled = Arc::new(AtomicBool::new(true)); - let accepted = attempts.clone(); - let stalled_server = stalled.clone(); - let server = tokio::spawn(async move { - let mut requests = tokio::task::JoinSet::new(); - while let Ok((socket, _)) = listener.accept().await { - let accepted = accepted.clone(); - let stalled = stalled_server.load(Ordering::SeqCst); - requests.spawn(async move { - let mut reader = BufReader::new(socket); - let mut line = String::new(); - reader.read_line(&mut line).await.unwrap(); - let hash = line.split_whitespace().nth(1).unwrap().strip_prefix("/v1/messages/tx/").unwrap().to_string(); - loop { - line.clear(); - if reader.read_line(&mut line).await.unwrap() == 0 || line == "\r\n" { break; } - } - accepted.lock().unwrap().push(hash.clone()); - tokio::time::sleep(if stalled { Duration::from_secs(25) } else { Duration::from_millis(1200) }).await; - let index = hash.strip_prefix("bridge-tx-").unwrap(); - let body = serde_json::json!({"data":[{ - "guid":format!("guid-{index}"), - "pathway":{"srcEid":30110,"dstEid":30109,"sender":{"address":types::OFT}}, - "source":{"tx":{"txHash":hash}},"status":{"name":"DELIVERED"} - }]}).to_string(); - let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body); - let _ = reader.into_inner().write_all(response.as_bytes()).await; - }); - } - }); - Arc::get_mut(&mut wallet).unwrap().rpc.bridge_status_url = Some(format!("http://{address}")); + let wallet = chain.wallet(&directory); + chain.state.lock().unwrap().bridge_delay = Duration::from_secs(25); let bridges: Vec<_> = (0..5) .map(|index| UsdtTransfer { id: format!("bridge-{index}"), - tx_hash: format!("bridge-tx-{index}"), + tx_hash: Some(format!("{:#x}", alloy_primitives::B256::repeat_byte(index))), user_operation_hash: None, - bridge_guid: Some(format!("guid-{index}")), + bridge_guid: Some(format!( + "{:#x}", + alloy_primitives::B256::repeat_byte(index + 10) + )), recipient: RECIPIENT.into(), destination: UsdtDestination::Polygon, amount: 1_000_000, @@ -1906,12 +2007,21 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending is_incoming: false, status: UsdtTransferStatus::Bridging, timestamp: 1, - explorer_url: String::new(), }) .collect(); + for bridge in &bridges { + chain.state.lock().unwrap().bridge_messages.insert( + bridge.tx_hash.clone().unwrap(), + serde_json::json!({"data":[{ + "guid":bridge.bridge_guid, + "pathway":{"srcEid":30110,"dstEid":30109,"sender":{"address":types::OFT}}, + "source":{"tx":{"txHash":bridge.tx_hash}},"status":{"name":"DELIVERED"} + }]}), + ); + } wallet .store - .save_history_receipt(&bridges, "bridges") + .save_history_receipt(&bridges, "bridges", 1000, "block", true) .unwrap(); // A signed operation with no nonce consumption must still resolve once expired. let quote = wallet @@ -1933,7 +2043,7 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending .await .unwrap() .unwrap(); - assert_eq!(attempts.lock().unwrap().len(), 3); + assert_eq!(chain.state.lock().unwrap().bridge_requests.len(), 3); assert_eq!( history .iter() @@ -1960,7 +2070,7 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending let refresh_wallet = wallet.clone(); let refresh = tokio::spawn(async move { refresh_wallet.refresh_transfers().await }); tokio::time::timeout(Duration::from_secs(3), async { - while attempts.lock().unwrap().len() < 6 { + while chain.state.lock().unwrap().bridge_requests.len() < 5 { tokio::time::sleep(Duration::from_millis(10)).await; } }) @@ -1974,21 +2084,31 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending assert_eq!(result.unwrap().unwrap().status, UsdtTransferStatus::Pending); assert!(!refresh.is_finished()); refresh.await.unwrap().unwrap(); - { - let attempts = attempts.lock().unwrap(); - assert_eq!( - attempts[..6] - .iter() - .collect::>() - .len(), - 5 - ); - } - // Healthy responses slower than an equal share of the budget must still settle. - stalled.store(false, Ordering::SeqCst); + assert_eq!( + chain + .state + .lock() + .unwrap() + .bridge_requests + .iter() + .collect::>() + .len(), + 5 + ); + // Failed lookups wait a minute, then recover without delaying source reconciliation. + let attempts = chain.state.lock().unwrap().bridge_requests.len(); + wallet.refresh_transfers().await.unwrap(); + assert_eq!(chain.state.lock().unwrap().bridge_requests.len(), attempts); + tokio::time::pause(); + tokio::time::advance(Duration::from_secs(60)).await; + tokio::time::resume(); + chain.state.lock().unwrap().bridge_delay = Duration::from_millis(1200); chain.state.lock().unwrap().tip += 3; - chain.state.lock().unwrap().log_error = Some((-32603, "provider unavailable".into())); for _ in 0..2 { + { + let mut state = chain.state.lock().unwrap(); + state.fail_block_read_at = Some(state.block_reads + 1); + } tokio::time::timeout(Duration::from_secs(25), wallet.refresh_transfers()) .await .unwrap() @@ -1998,7 +2118,6 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending assert!(bridges.iter().all(|bridge| history.iter().any( |transfer| transfer.id == bridge.id && transfer.status == UsdtTransferStatus::Confirmed ))); - server.abort(); } #[tokio::test] @@ -2222,21 +2341,30 @@ async fn seed_restore_includes_external_token_sends_without_duplicate_operation_ } #[tokio::test] -async fn gas_price_changes_require_a_new_quote_before_signing() { - let chain = MockChain::start().await; - let dir = tempfile::tempdir().unwrap(); - let wallet = chain.wallet(&dir); - let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) - .await - .unwrap(); - chain.state.lock().unwrap().gas_price = 60_000_000; - assert!(matches!( - wallet.send(quote.id, TEST_PHRASE.into(), None).await, - Err(UsdtError::QuoteExpired) - )); - assert!(wallet.history().unwrap().is_empty()); - assert!(chain.state.lock().unwrap().operations.is_empty()); +async fn gas_price_changes_respect_the_approved_fee() { + for (gas_price, accepted) in [(52_000_000, true), (60_000_000, false)] { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let plan = wallet.store.quote("e.id).unwrap().plan; + chain.state.lock().unwrap().gas_price = gas_price; + let result = wallet.send(quote.id, TEST_PHRASE.into(), None).await; + if accepted { + result.unwrap(); + assert_eq!( + chain.state.lock().unwrap().operations[0].max_fee_per_gas, + plan.operation.max_fee_per_gas + ); + } else { + assert!(matches!(result, Err(UsdtError::QuoteExpired))); + assert!(wallet.history().unwrap().is_empty()); + assert!(chain.state.lock().unwrap().operations.is_empty()); + } + } } #[tokio::test] @@ -2263,7 +2391,7 @@ async fn consumed_nonce_recovery_requires_complete_receipts_and_resumes_after_re } assert!(wallet.refresh_transfers().await.is_err()); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); - let block_hash = format!("{:#x}", B256::repeat_byte(9)); + let block_hash = format!("{:#x}", chain.state.lock().unwrap().block_hash(20000)); assert_eq!( wallet.store.nonce_recovery(&sent.id, &block_hash).unwrap(), 1 @@ -2291,6 +2419,40 @@ async fn consumed_nonce_recovery_requires_complete_receipts_and_resumes_after_re #[tokio::test] async fn consuming_block_receipts_recover_a_payment_hidden_from_log_queries() { + for unavailable in [false, true] { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.nonce = 1; + state.tip += 3; + state.hide_operation_logs = true; + state.replacement_block = Some(20000); + if unavailable { + state.log_error = Some((-32002, "Provider unavailable".into())); + } + state.mined = true; + } + let history = wallet.refresh_transfers().await.unwrap(); + assert_eq!(history[0].id, sent.id); + assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); + assert_eq!(history[0].fee, Some(123)); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); + } +} + +#[tokio::test] +async fn consumed_nonce_recovery_preserves_malformed_operation_evidence() { + use serde_json::json; let chain = MockChain::start().await; let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); @@ -2307,13 +2469,36 @@ async fn consuming_block_receipts_recover_a_payment_hidden_from_log_queries() { state.nonce = 1; state.tip += 3; state.hide_logs = true; + state.replacement_block = Some(20000); state.mined = true; + let mut logs = state.event_logs(); + logs.iter_mut() + .find(|log| log["address"] == json!(account::ENTRY_POINT.to_checksum(None))) + .unwrap()["data"] = json!("0x"); + state.receipt_logs = Some(logs); } - let history = wallet.refresh_transfers().await.unwrap(); + assert!(matches!( + wallet.refresh_transfers().await, + Err(UsdtError::InvalidResponse) + )); + assert_eq!( + wallet.history().unwrap()[0].status, + UsdtTransferStatus::Pending + ); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + drop(wallet); + { + let mut state = chain.state.lock().unwrap(); + let mut logs = state.event_logs(); + logs.insert(0, json!({"address":account::ENTRY_POINT,"topics":[alloy_primitives::keccak256("BeforeExecution()")],"data":"0x"})); + state.receipt_logs = Some(logs); + } + let restored = chain.wallet(&dir); + let history = restored.refresh_transfers().await.unwrap(); assert_eq!(history[0].id, sent.id); assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); assert_eq!(history[0].fee, Some(123)); - assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); + assert!(restored.store.pending_plan(&sent.id).unwrap().is_none()); } #[tokio::test] @@ -2357,6 +2542,22 @@ async fn dense_block_history_recovers_large_receipts_without_skipping_after_rest let restored = chain.wallet(&dir); sync_history_to_tip(&restored).await; assert_eq!(chain.state.lock().unwrap().receipt_reads, reads); + drop(restored); + { + let mut state = chain.state.lock().unwrap(); + state + .block_hashes + .insert(20000, alloy_primitives::B256::repeat_byte(0xaa)); + } + let restored = chain.wallet(&dir); + restored.store.save_history_progress(20000).unwrap(); + sync_history_to_tip(&restored).await; + assert_eq!(chain.state.lock().unwrap().receipt_reads, reads + 1); + assert_eq!(restored.history().unwrap()[0].id, sent.id); + assert_eq!( + restored.history().unwrap()[0].status, + UsdtTransferStatus::Confirmed + ); } #[tokio::test] @@ -2420,27 +2621,6 @@ async fn first_submission_precheck_releases_an_operation_that_was_never_sent() { .unwrap(); } -#[tokio::test] -async fn moderate_gas_price_movement_preserves_the_approved_fee() { - let chain = MockChain::start().await; - let dir = tempfile::tempdir().unwrap(); - let wallet = chain.wallet(&dir); - let quote = wallet - .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) - .await - .unwrap(); - let plan = wallet.store.quote("e.id).unwrap().plan; - chain.state.lock().unwrap().gas_price = 52_000_000; - wallet - .send(quote.id, TEST_PHRASE.into(), None) - .await - .unwrap(); - assert_eq!( - chain.state.lock().unwrap().operations[0].max_fee_per_gas, - plan.operation.max_fee_per_gas - ); -} - #[tokio::test] async fn unknown_paymaster_history_preserves_principal_fee_and_refund() { use alloy_primitives::{Address, B256, U256}; @@ -2551,7 +2731,7 @@ async fn settlement_requires_matching_canonical_receipts() { invalid[field] = value; chain.state.lock().unwrap().receipt_response = Some(invalid); assert!(matches!( - wallet.refresh_transfer(sent.id.clone()).await, + wallet.check_recent_execution(sent.id.clone()).await, Err(UsdtError::InvalidResponse) )); assert!(matches!( @@ -2587,46 +2767,11 @@ async fn bridge_settlement_recovers_guid_fees_and_preserves_delivery_on_rescan() use alloy_primitives::{B256, U256}; use alloy_sol_types::SolEvent; use serde_json::json; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; let chain = MockChain::start().await; let dir = tempfile::tempdir().unwrap(); - let mut wallet = chain.wallet(&dir); + let wallet = chain.wallet(&dir); let guid = B256::repeat_byte(0xab); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - std::sync::Arc::get_mut(&mut wallet) - .unwrap() - .rpc - .bridge_status_url = Some(format!("http://{}", listener.local_addr().unwrap())); let message = json!({"guid":guid,"pathway":{"srcEid":30110,"dstEid":30109,"sender":{"address":types::OFT}},"source":{"tx":{"txHash":B256::repeat_byte(7)}},"status":{"name":"DELIVERED"}}); - let mut responses = Vec::new(); - for (pointer, value) in [ - ("/guid", json!(B256::ZERO)), - ("/pathway/srcEid", json!(30101)), - ("/pathway/dstEid", json!(30101)), - ("/pathway/sender/address", json!(RECIPIENT)), - ("/source/tx/txHash", json!(B256::ZERO)), - ("/status/name", json!("NEW_PROVIDER_STATUS")), - ("/status/name", json!("FAILED")), - ] { - let mut changed = message.clone(); - *changed.pointer_mut(pointer).unwrap() = value; - responses.push((200, json!({"data":[changed]}))); - } - responses.extend([ - (429, json!({})), - (200, json!({"data":null})), - (200, json!({"data":[message]})), - ]); - let server = tokio::spawn(async move { - for (status, body) in responses { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut request = [0; 4096]; - let size = socket.read(&mut request).await.unwrap(); - assert!(String::from_utf8_lossy(&request[..size]).starts_with("GET /v1/messages/tx/0x")); - let body = body.to_string(); - socket.write_all(format!("HTTP/1.1 {status} OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); - } - }); let quote = wallet .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) .await @@ -2682,62 +2827,166 @@ async fn bridge_settlement_recovers_guid_fees_and_preserves_delivery_on_rescan() assert_eq!(pending.received_amount, 999_999); assert_eq!(pending.fee, Some(300_123)); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); - for _ in 0..6 { - assert_eq!( - wallet.rpc.bridge_status(&pending).await.unwrap(), - UsdtTransferStatus::Bridging - ); + for (pointer, value, expected) in [ + ("/guid", json!(B256::ZERO), None), + ("/pathway/srcEid", json!(30101), None), + ("/pathway/dstEid", json!(30101), None), + ("/pathway/sender/address", json!(RECIPIENT), None), + ("/source/tx/txHash", json!(B256::ZERO), None), + ("/status/name", json!("NEW_PROVIDER_STATUS"), None), + ( + "/status/name", + json!("FAILED"), + Some(UsdtTransferStatus::BridgeNeedsAttention), + ), + ( + "/status/name", + json!("BLOCKED"), + Some(UsdtTransferStatus::BridgeNeedsAttention), + ), + ( + "/status/name", + json!("PAYLOAD_STORED"), + Some(UsdtTransferStatus::BridgeNeedsAttention), + ), + ( + "/status/name", + json!("INFLIGHT"), + Some(UsdtTransferStatus::Bridging), + ), + ( + "/status/name", + json!("CONFIRMING"), + Some(UsdtTransferStatus::Bridging), + ), + ( + "/status/name", + json!("APPLICATION_BURNED"), + Some(UsdtTransferStatus::BridgeFailed), + ), + ( + "/status/name", + json!("APPLICATION_SKIPPED"), + Some(UsdtTransferStatus::BridgeFailed), + ), + ] { + let mut changed = message.clone(); + *changed.pointer_mut(pointer).unwrap() = value; + chain + .state + .lock() + .unwrap() + .bridge_messages + .insert(pending.tx_hash.clone().unwrap(), json!({"data":[changed]})); + match expected { + Some(status) => assert_eq!(wallet.rpc.bridge_status(&pending).await.unwrap(), status), + None => assert!(matches!( + wallet.rpc.bridge_status(&pending).await, + Err(UsdtError::NetworkUnavailable) + )), + } } + let mut retryable = message.clone(); + retryable["status"]["name"] = json!("FAILED"); + chain.state.lock().unwrap().bridge_messages.insert( + pending.tx_hash.clone().unwrap(), + json!({"data":[retryable]}), + ); assert_eq!( - wallet.rpc.bridge_status(&pending).await.unwrap(), + wallet.refresh_transfers().await.unwrap()[0].status, UsdtTransferStatus::BridgeNeedsAttention ); - assert!(matches!( - wallet.rpc.bridge_status(&pending).await, - Err(UsdtError::NetworkUnavailable) - )); - assert!(matches!( - wallet.rpc.bridge_status(&pending).await, - Err(UsdtError::InvalidResponse) - )); + let requests = chain.state.lock().unwrap().bridge_requests.len(); + wallet.refresh_transfers().await.unwrap(); + assert_eq!(chain.state.lock().unwrap().bridge_requests.len(), requests); + tokio::time::pause(); + tokio::time::advance(std::time::Duration::from_secs(60)).await; + tokio::time::resume(); + chain.state.lock().unwrap().bridge_messages.insert( + pending.tx_hash.clone().unwrap(), + json!({"data":[message.clone()]}), + ); chain.state.lock().unwrap().chain = 1; let mut delivered = wallet.refresh_transfers().await.unwrap().remove(0); assert_eq!(delivered.status, UsdtTransferStatus::Confirmed); - server.await.unwrap(); delivered.bridge_guid = delivered.bridge_guid.map(|guid| guid.to_uppercase()); - delivered.tx_hash = delivered.tx_hash.to_uppercase(); + delivered.tx_hash = delivered.tx_hash.map(|hash| hash.to_uppercase()); wallet.store.update_transfer(&delivered).unwrap(); - chain.state.lock().unwrap().chain = 42161; + for status in [ + UsdtTransferStatus::Confirmed, + UsdtTransferStatus::BridgeFailed, + ] { + if status == UsdtTransferStatus::BridgeFailed { + delivered.status = UsdtTransferStatus::Bridging; + wallet.store.update_transfer(&delivered).unwrap(); + let mut stopped = message.clone(); + stopped["status"]["name"] = json!("APPLICATION_BURNED"); + chain + .state + .lock() + .unwrap() + .bridge_messages + .insert(pending.tx_hash.clone().unwrap(), json!({"data":[stopped]})); + assert_eq!(wallet.refresh_transfers().await.unwrap()[0].status, status); + } + let reads = { + let mut state = chain.state.lock().unwrap(); + state.chain = 42161; + let current_hash = state.block_hash(20000); + state.block_hashes.insert( + 20000, + if current_hash == B256::repeat_byte(0xac) { + B256::repeat_byte(0xab) + } else { + B256::repeat_byte(0xac) + }, + ); + state.receipt_reads + }; + sync_history_to_tip(&wallet).await; + assert_eq!(chain.state.lock().unwrap().receipt_reads, reads + 1); + assert_eq!(wallet.history().unwrap()[0].status, status); + } + drop(wallet); + let wallet = chain.wallet(&dir); + let requests = chain.state.lock().unwrap().bridge_requests.len(); + let failed = wallet.refresh_transfers().await.unwrap().remove(0); + assert_eq!(failed.status, UsdtTransferStatus::BridgeFailed); + assert_eq!(failed.fee, Some(300_123)); + assert!(failed.tx_hash.is_some() && failed.bridge_guid.is_some()); + assert_eq!(chain.state.lock().unwrap().bridge_requests.len(), requests); + let replacement_guid = B256::repeat_byte(0xad); + { + let mut state = chain.state.lock().unwrap(); + state.block_hashes.insert(20000, B256::repeat_byte(0xae)); + state.receipt_logs.as_mut().unwrap()[1]["topics"][1] = json!(replacement_guid); + } sync_history_to_tip(&wallet).await; + let replacement = wallet.history().unwrap().remove(0); + assert_eq!(replacement.id, delivered.id); assert_eq!( - wallet.history().unwrap()[0].status, - UsdtTransferStatus::Confirmed + replacement.bridge_guid, + Some(format!("{replacement_guid:#x}")) ); + assert_eq!(replacement.status, UsdtTransferStatus::Bridging); drop(wallet); let restored_dir = tempfile::tempdir().unwrap(); let restored = chain.wallet(&restored_dir); sync_history_to_tip(&restored).await; let recovered = restored.history().unwrap().remove(0); assert_eq!(recovered.destination, UsdtDestination::Polygon); - assert_eq!(recovered.bridge_guid, Some(format!("{guid:#x}"))); + assert_eq!( + recovered.bridge_guid, + Some(format!("{replacement_guid:#x}")) + ); assert_eq!(recovered.fee, Some(300_123)); } #[tokio::test] -async fn destination_tokens_are_not_payment_recipients() { +async fn infrastructure_and_token_addresses_are_not_payment_recipients() { let chain = MockChain::start().await; let dir = tempfile::tempdir().unwrap(); let wallet = chain.wallet(&dir); - assert!(matches!( - wallet - .quote_transfer( - account::ENTRY_POINT.to_checksum(None), - 1_000_000, - UsdtDestination::Ethereum - ) - .await, - Err(UsdtError::InvalidAddress) - )); for destination in [ UsdtDestination::Arbitrum, UsdtDestination::Ethereum, @@ -2745,16 +2994,23 @@ async fn destination_tokens_are_not_payment_recipients() { UsdtDestination::Plasma, UsdtDestination::Stable, ] { - assert!(matches!( - wallet - .quote_transfer( - destination.token().to_checksum(None), - 1_000_000, - destination - ) - .await, - Err(UsdtError::InvalidAddress) - )); + let mut recipients = vec![ + destination.token(), + account::ENTRY_POINT, + account::DELEGATE, + paymaster::PAYMASTER, + ]; + if destination == UsdtDestination::Arbitrum { + recipients.extend([types::OFT, types::BRIDGE_HELPER]); + } + for recipient in recipients { + assert!(matches!( + wallet + .quote_transfer(recipient.to_checksum(None), 1_000_000, destination) + .await, + Err(UsdtError::InvalidAddress) + )); + } if let Some(eid) = destination.endpoint() { assert_eq!(UsdtDestination::from_endpoint(eid), Some(destination)); } @@ -2938,7 +3194,7 @@ async fn recent_execution_requires_the_expected_token_transfer() { logs[0]["data"] = json!(token.data); chain.state.lock().unwrap().receipt_logs = Some(logs); assert!(matches!( - wallet.refresh_transfer(sent.id.clone()).await, + wallet.check_recent_execution(sent.id.clone()).await, Err(UsdtError::InvalidResponse) )); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); @@ -2947,13 +3203,13 @@ async fn recent_execution_requires_the_expected_token_transfer() { logs.remove(0); chain.state.lock().unwrap().receipt_logs = Some(logs); assert!(matches!( - wallet.refresh_transfer(sent.id.clone()).await, + wallet.check_recent_execution(sent.id.clone()).await, Err(UsdtError::InvalidResponse) )); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); chain.state.lock().unwrap().receipt_logs = None; let result = wallet - .refresh_transfer(sent.id.clone()) + .check_recent_execution(sent.id.clone()) .await .unwrap() .unwrap(); @@ -2981,7 +3237,7 @@ async fn execution_check_preserves_unmined_payments_and_throttling() { state.timestamp += alloy_primitives::U256::from(1000); } let result = wallet - .refresh_transfer(sent.id.clone()) + .check_recent_execution(sent.id.clone()) .await .unwrap() .unwrap(); @@ -2990,8 +3246,237 @@ async fn execution_check_preserves_unmined_payments_and_throttling() { assert_eq!(chain.state.lock().unwrap().operations.len(), 1); chain.state.lock().unwrap().log_error = Some((-32016, "rate limit".into())); assert!(matches!( - wallet.refresh_transfer(sent.id.clone()).await, + wallet.check_recent_execution(sent.id.clone()).await, Err(UsdtError::RateLimited) )); assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); } + +#[tokio::test] +async fn interrupted_nonce_recovery_restarts_on_a_changed_block() { + use alloy_primitives::B256; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.nonce = 1; + state.tip += 3; + state.hide_logs = true; + state.block_transactions = Some(vec![B256::repeat_byte(6), B256::repeat_byte(7)]); + state.receipt_failure = Some(B256::repeat_byte(7)); + } + assert!(wallet.refresh_transfers().await.is_err()); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + drop(wallet); + { + let mut state = chain.state.lock().unwrap(); + state.block_hashes.insert(20000, B256::repeat_byte(0xaa)); + state.block_transactions = Some(vec![B256::repeat_byte(7), B256::repeat_byte(6)]); + state.receipt_failure = None; + state.mined = true; + } + let restored = chain.wallet(&dir); + let history = restored.refresh_transfers().await.unwrap(); + assert_eq!(history[0].id, sent.id); + assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); + assert_eq!(history[0].fee, Some(123)); + assert!(restored.store.pending_plan(&sent.id).unwrap().is_none()); +} + +#[tokio::test] +async fn history_reuses_receipts_only_while_their_block_remains_canonical() { + use alloy_primitives::{B256, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + } + sync_history_to_tip(&wallet).await; + let reads = chain.state.lock().unwrap().receipt_reads; + drop(wallet); + let restored = chain.wallet(&dir); + sync_history_to_tip(&restored).await; + assert_eq!(chain.state.lock().unwrap().receipt_reads, reads); + { + let mut state = chain.state.lock().unwrap(); + let mut logs = state.event_logs(); + let mut event = transaction::Paymaster::UserOperationSponsored::decode_log_data( + &transaction::event_data(&logs[1]).unwrap(), + ) + .unwrap(); + event.tokenAmountPaid = U256::from(456); + logs[1]["data"] = json!(event.encode_log_data().data); + state.receipt_logs = Some(logs); + let mut stale = state.event_logs()[2].clone(); + stale["blockHash"] = json!(state.block_hash(20000)); + state.log_response = Some(vec![stale]); + state.block_hashes.insert(20000, B256::repeat_byte(0xaa)); + } + assert!(matches!( + restored.sync_history().await, + Err(UsdtError::NetworkUnavailable) + )); + assert_eq!(restored.history().unwrap()[0].fee, Some(123)); + chain.state.lock().unwrap().log_response = None; + sync_history_to_tip(&restored).await; + let history = restored.history().unwrap(); + assert_eq!(history[0].id, sent.id); + assert_eq!(history[0].fee, Some(456)); + assert_eq!(chain.state.lock().unwrap().receipt_reads, reads + 1); +} + +#[tokio::test] +async fn incoming_log_progress_does_not_hide_later_receipt_evidence() { + use alloy_primitives::{B256, U256}; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.incoming = true; + state.tip += 3; + let mut incoming = state.event_logs().last().unwrap().clone(); + incoming["transactionHash"] = json!(B256::repeat_byte(7)); + incoming["blockNumber"] = json!(U256::from(20000)); + state.log_response = Some(vec![incoming]); + } + let restore_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restore_dir); + sync_history_to_tip(&restored).await; + assert_eq!(restored.history().unwrap().len(), 1); + assert!(restored.history().unwrap()[0].is_incoming); + assert_eq!(chain.state.lock().unwrap().receipt_reads, 0); + chain.state.lock().unwrap().log_response = None; + sync_history_to_tip(&restored).await; + let history = restored.history().unwrap(); + assert_eq!(history.len(), 2); + let payment = history + .iter() + .find(|transfer| !transfer.is_incoming) + .unwrap(); + assert_eq!(payment.amount, 1_000_000); + assert_eq!(payment.fee, Some(123)); + assert_eq!(chain.state.lock().unwrap().receipt_reads, 1); +} + +#[tokio::test] +async fn bridge_history_retries_incomplete_receipt_enrichment() { + use alloy_primitives::{B256, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + + for (dense, missing) in [ + (false, types::OFT), + (true, types::OFT), + (false, paymaster::PAYMASTER), + ] { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Polygon) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let complete = { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + if dense { + state.oversized_block = Some(20000); + } + let mut logs = state.event_logs(); + let helper = transaction::BridgeHelper::LogSend { + sender: wallet.address, + oft: types::OFT, + amountLD: U256::from(1_000_000), + totalAmount: U256::from(1_000_500), + feeInToken: U256::from(500), + nativeFee: U256::from(1), + } + .encode_log_data(); + let oft = transaction::Oft::OFTSent { + guid: B256::repeat_byte(0x42), + dstEid: UsdtDestination::Polygon.endpoint().unwrap(), + fromAddress: types::BRIDGE_HELPER, + amountSentLD: U256::from(1_000_000), + amountReceivedLD: U256::from(1_000_000), + } + .encode_log_data(); + for (address, data) in [(types::BRIDGE_HELPER, helper), (types::OFT, oft)] { + logs.insert( + 0, + json!({"address":address,"topics":data.topics(),"data":data.data, + "transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20"}), + ); + } + for (index, log) in logs.iter_mut().enumerate() { + log["logIndex"] = json!(format!("0x{index:x}")); + } + state.receipt_logs = Some( + logs.iter() + .filter(|log| { + serde_json::from_value::(log["address"].clone()) + .unwrap() + != missing + }) + .cloned() + .collect(), + ); + logs + }; + sync_history_to_tip(&wallet).await; + let partial = wallet.history().unwrap().remove(0); + assert_eq!(partial.id, sent.id); + assert!(partial.bridge_guid.is_none() || partial.fee.is_none()); + assert_ne!(partial.status, UsdtTransferStatus::Pending); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); + drop(wallet); + chain.state.lock().unwrap().receipt_logs = Some(complete); + let wallet = chain.wallet(&dir); + sync_history_to_tip(&wallet).await; + let enriched = wallet.history().unwrap().remove(0); + assert_eq!(enriched.id, sent.id); + assert_eq!( + enriched.bridge_guid, + Some(format!("{:#x}", B256::repeat_byte(0x42))) + ); + assert_eq!(enriched.fee, Some(623)); + assert_eq!(enriched.status, UsdtTransferStatus::Bridging); + } +} diff --git a/src/modules/usdt/transaction.rs b/src/modules/usdt/transaction.rs index 4d7c8d6..5fe4d58 100644 --- a/src/modules/usdt/transaction.rs +++ b/src/modules/usdt/transaction.rs @@ -73,21 +73,33 @@ sol! { } } +pub(super) fn entry_point_event( + log: &serde_json::Value, +) -> Result, UsdtError> { + use alloy_sol_types::SolEvent; + let address: alloy_primitives::Address = serde_json::from_value(log["address"].clone())?; + if address != super::account::ENTRY_POINT { + return Ok(None); + } + let data = event_data(log)?; + if data.topics().first() != Some(&EntryPoint::UserOperationEvent::SIGNATURE_HASH) { + return Ok(None); + } + EntryPoint::UserOperationEvent::decode_log_data(&data) + .map(Some) + .map_err(|_| UsdtError::InvalidResponse) +} + pub(super) fn operation_logs( receipt: &serde_json::Value, hash: B256, ) -> Result<&[serde_json::Value], UsdtError> { - use alloy_sol_types::SolEvent; let logs = receipt["logs"] .as_array() .ok_or(UsdtError::InvalidResponse)?; let mut start = 0; for (index, log) in logs.iter().enumerate() { - let address: alloy_primitives::Address = serde_json::from_value(log["address"].clone())?; - if address != super::account::ENTRY_POINT { - continue; - } - if let Ok(event) = EntryPoint::UserOperationEvent::decode_log_data(&event_data(log)?) { + if let Some(event) = entry_point_event(log)? { if event.userOpHash == hash { return Ok(&logs[start..=index]); } diff --git a/src/modules/usdt/types.rs b/src/modules/usdt/types.rs index 4903348..ccdafb0 100644 --- a/src/modules/usdt/types.rs +++ b/src/modules/usdt/types.rs @@ -5,7 +5,6 @@ pub(super) const CHAIN_ID: u64 = 42161; pub(super) const TOKEN: Address = address!("Fd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"); pub(super) const OFT: Address = address!("14E4A1B13bf7F943c8ff7C51fb60FA964A298D92"); pub(super) const BRIDGE_HELPER: Address = address!("a90f03c856D01F698E7071B393387cd75a8a319A"); -pub(super) const EXPLORER: &str = "https://arbiscan.io"; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] pub enum UsdtDestination { @@ -65,18 +64,27 @@ pub struct UsdtQuote { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] pub enum UsdtTransferStatus { + /// Signed payment awaiting a conclusive source-chain outcome. Pending, + /// Payment received on its destination chain. Confirmed, + /// Source payment failed or was proven not to have executed. Failed, + /// Source payment executed; destination delivery is pending. Bridging, + /// Delivery is blocked or its message could not be recovered; it may still complete. BridgeNeedsAttention, + /// Delivery was permanently stopped. This does not imply a refund of source funds or fees. + BridgeFailed, + /// Another operation consumed the payment nonce. Replaced, } #[derive(Clone, Debug, Serialize, Deserialize, uniffi::Record)] pub struct UsdtTransfer { pub id: String, - pub tx_hash: String, + /// Source transaction hash, absent until execution is observed. + pub tx_hash: Option, pub user_operation_hash: Option, pub bridge_guid: Option, pub recipient: String, @@ -87,5 +95,12 @@ pub struct UsdtTransfer { pub is_incoming: bool, pub status: UsdtTransferStatus, pub timestamp: u64, - pub explorer_url: String, +} + +impl UsdtTransfer { + pub(super) fn mark_unexecuted(&mut self, status: UsdtTransferStatus) { + self.status = status; + self.received_amount = 0; + self.fee = Some(0); + } } diff --git a/src/modules/usdt/wallet.rs b/src/modules/usdt/wallet.rs index 3d205a9..a9dd7d3 100644 --- a/src/modules/usdt/wallet.rs +++ b/src/modules/usdt/wallet.rs @@ -1,23 +1,34 @@ use super::{ account::{validate_delegation, ENTRY_POINT}, - amount::token_amount, - keys::{derive_key, parse_address}, + amount::{token_amount, with_margin}, + keys::{derive_owner_key, parse_address}, paymaster::{Pimlico, PAYMASTER}, rpc::Rpc, store::{QuoteData, Store}, - transaction::{event_data, BridgeHelper, EntryPoint, Erc20, Oft, Paymaster, Plan, SendParam}, - types::{BRIDGE_HELPER, CHAIN_ID, EXPLORER, OFT, TOKEN}, + transaction::{ + entry_point_event, event_data, BridgeHelper, EntryPoint, Erc20, Oft, Paymaster, Plan, + SendParam, + }, + types::{BRIDGE_HELPER, CHAIN_ID, OFT, TOKEN}, user_operation::Authorization, UsdtDestination, UsdtError, UsdtQuote, UsdtTransfer, UsdtTransferStatus, }; use alloy_primitives::{Address, Bytes, B256, U256}; use alloy_sol_types::{SolCall, SolEvent}; use serde_json::{json, Value}; +use std::collections::HashMap; use std::sync::{ atomic::{AtomicU64, AtomicUsize, Ordering}, Arc, }; -use tokio::sync::Mutex; +use tokio::{sync::Mutex, time::Instant}; + +const RECENT_EXECUTION_BLOCKS: u64 = 64; +const RECENT_EXECUTION_BUDGET: std::time::Duration = std::time::Duration::from_secs(5); +const NONCE_RECOVERY_BUDGET: std::time::Duration = std::time::Duration::from_secs(20); +const EXPIRY_SEARCH_BLOCKS: u64 = 4096; +const QUOTE_LIFETIME_SECONDS: u64 = 120; +const BRIDGE_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(60); #[derive(uniffi::Object)] pub struct UsdtWallet { @@ -27,11 +38,13 @@ pub struct UsdtWallet { pub(super) store: Store, operation: Mutex<()>, bridge_poll_offset: AtomicUsize, + bridge_retry_after: Mutex>, pub(super) history_range_limit: AtomicU64, } #[uniffi::export(async_runtime = "tokio")] impl UsdtWallet { + /// Creates the sole owner of this wallet's database; reuse it for all calls until it is dropped. #[uniffi::constructor] pub fn new( address: String, @@ -55,6 +68,7 @@ impl UsdtWallet { store, operation: Mutex::new(()), bridge_poll_offset: AtomicUsize::new(0), + bridge_retry_after: Mutex::new(HashMap::new()), history_range_limit: AtomicU64::new(super::history::MAX_LOG_RANGE), })) } @@ -88,16 +102,9 @@ impl UsdtWallet { let recipient = parse_address(recipient.trim())?; if recipient == self.address || recipient == destination.token() - || (destination == UsdtDestination::Ethereum && recipient == ENTRY_POINT) + || [ENTRY_POINT, PAYMASTER, super::account::DELEGATE].contains(&recipient) || (destination == UsdtDestination::Arbitrum - && [ - ENTRY_POINT, - PAYMASTER, - super::account::DELEGATE, - OFT, - BRIDGE_HELPER, - ] - .contains(&recipient)) + && [OFT, BRIDGE_HELPER].contains(&recipient)) { return Err(UsdtError::InvalidAddress); } @@ -114,8 +121,11 @@ impl UsdtWallet { .paymaster .prepare(self.address, nonce, authorization, &calls, timestamp) .await?; - let expires_at = - now().saturating_add(operation_expires_at.saturating_sub(timestamp).min(120)); + let expires_at = now().saturating_add( + operation_expires_at + .saturating_sub(timestamp) + .min(QUOTE_LIFETIME_SECONDS), + ); let maximum_fee = gas_fee .checked_add(bridge_fee) .ok_or(UsdtError::InvalidAmount)?; @@ -140,6 +150,8 @@ impl UsdtWallet { Ok(quote) } + /// Repeating a quote ID returns its stored outcome, which may already be failed or replaced. + /// A pending outcome is durable and retryable; it does not imply bundler acceptance. pub async fn send( &self, quote_id: String, @@ -150,10 +162,7 @@ impl UsdtWallet { let passphrase = passphrase.map(zeroize::Zeroizing::new); let _guard = self.operation.lock().await; if let Some(existing) = self.store.transfer("e_id)? { - let key = derive_key(mnemonic, passphrase)?; - if super::keys::key_address(&key) != self.address { - return Err(UsdtError::InvalidCredentials); - } + derive_owner_key(mnemonic, passphrase, self.address)?; return Ok(existing); } self.store.require_no_pending()?; @@ -181,10 +190,7 @@ impl UsdtWallet { { return Err(UsdtError::QuoteExpired); } - let key = derive_key(mnemonic, passphrase)?; - if super::keys::key_address(&key) != self.address { - return Err(UsdtError::InvalidCredentials); - } + let key = derive_owner_key(mnemonic, passphrase, self.address)?; if data.quote.expires_at <= now() + 5 { return Err(UsdtError::QuoteExpired); } @@ -192,7 +198,7 @@ impl UsdtWallet { drop(key); let mut transfer = UsdtTransfer { id: quote_id, - tx_hash: String::new(), + tx_hash: None, user_operation_hash: Some(format!("{hash:#x}")), bridge_guid: None, recipient: data.quote.recipient, @@ -203,7 +209,6 @@ impl UsdtWallet { is_incoming: false, status: UsdtTransferStatus::Pending, timestamp: now(), - explorer_url: String::new(), }; self.store.record_signed(&transfer, &raw)?; // After persistence a lost response is indeterminate. Retry only the identical signed operation. @@ -213,9 +218,7 @@ impl UsdtWallet { error, UsdtError::QuoteExpired | UsdtError::UnsupportedDelegation ) { - transfer.status = UsdtTransferStatus::Failed; - transfer.received_amount = 0; - transfer.fee = Some(0); + transfer.mark_unexecuted(UsdtTransferStatus::Failed); self.store.update_transfer(&transfer)?; return Err(error); } @@ -223,9 +226,13 @@ impl UsdtWallet { Ok(transfer) } - /// Checks recent direct-payment execution at the current tip without scanning history or retrying submission. - /// Missing evidence leaves the signed payment pending; confirmation is L2 execution, not parent-chain finality. - pub async fn refresh_transfer(&self, id: String) -> Result, UsdtError> { + /// Checks recent direct-payment execution with a bounded request budget. + /// Requires the expected operation and transfer in a canonical receipt; current-tip execution is provisional. + /// Does not rebroadcast, expire payments or reconcile nonces. Missing evidence leaves the payment pending. + pub async fn check_recent_execution( + &self, + id: String, + ) -> Result, UsdtError> { let check = async { let _guard = self.operation.lock().await; let Some(mut transfer) = self.store.transfer(&id)? else { @@ -240,23 +247,20 @@ impl UsdtWallet { self.rpc.verify_chain().await?; let hash = plan.operation.hash(CHAIN_ID)?; let tip = self.block_number().await?; - let start = plan.created_block.max(tip.saturating_sub(63)); + let start = plan + .created_block + .max(tip.saturating_sub(RECENT_EXECUTION_BLOCKS - 1)); if start <= tip { - let logs: Vec = self.rpc.call("eth_getLogs", json!([{ - "address": ENTRY_POINT, "fromBlock": U256::from(start), "toBlock": U256::from(tip), - "topics": [EntryPoint::UserOperationEvent::SIGNATURE_HASH, hash, self.address.into_word()] - }])).await?; + let logs: Vec = self.operation_logs_in(start, tip, Some(hash)).await?; if let Some(log) = logs .iter() .find(|log| log["removed"].as_bool() != Some(true)) { - let event = EntryPoint::UserOperationEvent::decode_log_data(&event_data(log)?) - .map_err(|_| UsdtError::InvalidResponse)?; + let event = entry_point_event(log)?.ok_or(UsdtError::InvalidResponse)?; let number = u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) .map_err(|_| UsdtError::InvalidResponse)?; - if serde_json::from_value::
(log["address"].clone())? != ENTRY_POINT - || event.userOpHash != hash + if event.userOpHash != hash || event.nonce != plan.operation.nonce || number < start || number > tip @@ -268,12 +272,14 @@ impl UsdtWallet { } Ok(Some(transfer)) }; - match tokio::time::timeout(std::time::Duration::from_secs(5), check).await { + match tokio::time::timeout(RECENT_EXECUTION_BUDGET, check).await { Ok(result) => result, Err(_) => Err(UsdtError::NetworkUnavailable), } } + /// Saves resumable history progress; returns true when caught up and false when more work remains. + /// Call between send flows. The soft budget permits an in-flight receipt to finish before yielding. pub async fn sync_history(&self) -> Result { let _guard = self.operation.lock().await; self.rpc.verify_chain().await?; @@ -284,9 +290,11 @@ impl UsdtWallet { self.store.transfers() } + /// Reconciles pending execution using chain proofs and may rebroadcast the identical signed operation. pub async fn refresh_transfers(&self) -> Result, UsdtError> { let pending = self.refresh_pending_transfers().await; - self.refresh_bridges(&self.store.unsettled()?).await?; + self.refresh_bridges(&self.store.awaiting_delivery()?) + .await?; pending?; self.history() } @@ -295,117 +303,93 @@ impl UsdtWallet { impl UsdtWallet { async fn refresh_pending_transfers(&self) -> Result<(), UsdtError> { let _guard = self.operation.lock().await; - let transfers = self.store.unsettled()?; - if transfers.is_empty() { + if let Some((mut transfer, plan)) = self.store.pending_operation()? { + self.recover_pending(&mut transfer, &plan).await?; + } + Ok(()) + } + + async fn recover_pending( + &self, + transfer: &mut UsdtTransfer, + plan: &Plan, + ) -> Result<(), UsdtError> { + self.rpc.verify_chain().await?; + let hash = plan.operation.hash(CHAIN_ID)?; + let confirmed_tip = self.block_number().await?.saturating_sub(2); + if confirmed_tip < plan.created_block { return Ok(()); } - if transfers + let end = self.pending_search_end(plan, confirmed_tip).await?; + let logs = match self + .operation_logs_in(plan.created_block, end, Some(hash)) + .await + { + Ok(logs) => logs, + // Discovery can be unavailable while independent nonce/receipt proofs still work. + Err(UsdtError::LogRangeTooLarge | UsdtError::NetworkUnavailable) => Vec::new(), + Err(error) => return Err(error), + }; + if let Some(log) = logs .iter() - .any(|transfer| transfer.status == UsdtTransferStatus::Pending) + .find(|log| log["removed"].as_bool() != Some(true)) { - self.rpc.verify_chain().await?; + let event = entry_point_event(log)?.ok_or(UsdtError::InvalidResponse)?; + if event.userOpHash != hash || event.nonce != plan.operation.nonce { + return Err(UsdtError::InvalidResponse); + } + return self.settle_from_log(transfer, log, event).await; + } + let nonce = self.nonce(&format!("0x{confirmed_tip:x}")).await?; + if nonce <= plan.operation.nonce { + if self.block_timestamp(confirmed_tip).await? > plan.expires_at { + transfer.mark_unexecuted(UsdtTransferStatus::Failed); + self.store.update_transfer(transfer)?; + } else if self.validate_bridge(plan).await.is_ok() { + let _ = self.broadcast(plan, hash).await; + } + return Ok(()); } - for mut transfer in transfers { - if matches!( - transfer.status, - UsdtTransferStatus::Bridging | UsdtTransferStatus::BridgeNeedsAttention - ) { - continue; + // A nonce advance alone cannot distinguish this payment from a replacement. + let block = self.nonce_consumed_block(plan, confirmed_tip).await?; + let candidates = match self.operation_logs_in(block, block, None).await { + Ok(logs) => logs, + Err(UsdtError::LogRangeTooLarge | UsdtError::NetworkUnavailable) => Vec::new(), + Err(error) => return Err(error), + }; + for log in candidates + .iter() + .filter(|log| log["removed"].as_bool() != Some(true)) + { + let event = entry_point_event(log)?.ok_or(UsdtError::InvalidResponse)?; + if u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) + .map_err(|_| UsdtError::InvalidResponse)? + != block + || event.sender != self.address + { + return Err(UsdtError::InvalidResponse); } - let Some(plan) = self.store.pending_plan(&transfer.id)? else { - continue; - }; - let hash = plan.operation.hash(CHAIN_ID)?; - let confirmed_tip = self.block_number().await?.saturating_sub(2); - if confirmed_tip < plan.created_block { + if event.nonce != plan.operation.nonce { continue; } - let end = self.pending_search_end(&plan, confirmed_tip).await?; - let logs: Vec = match self.rpc.call("eth_getLogs", json!([{ - "address": ENTRY_POINT, "fromBlock": U256::from(plan.created_block), "toBlock": U256::from(end), - "topics": [EntryPoint::UserOperationEvent::SIGNATURE_HASH, hash, self.address.into_word()] - }])).await { - Ok(logs) => logs, - // The nonce-based lookup below verifies the consuming event within a single block. - Err(UsdtError::LogRangeTooLarge) => Vec::new(), - Err(error) => return Err(error), - }; - if let Some(log) = logs - .iter() - .find(|log| log["removed"].as_bool() != Some(true)) - { - let event = EntryPoint::UserOperationEvent::decode_log_data(&event_data(log)?) - .map_err(|_| UsdtError::InvalidResponse)?; - if serde_json::from_value::
(log["address"].clone())? != ENTRY_POINT - || event.userOpHash != hash - || event.nonce != plan.operation.nonce - { - return Err(UsdtError::InvalidResponse); - } - self.settle_from_log(&mut transfer, log, event).await?; - } else { - let nonce = self.nonce(&format!("0x{confirmed_tip:x}")).await?; - if nonce > plan.operation.nonce { - // A nonce advance alone cannot distinguish this payment from a replacement. - let block = self.nonce_consumed_block(&plan, confirmed_tip).await?; - let candidates: Vec = match self.rpc.call("eth_getLogs", json!([{ - "address": ENTRY_POINT, "fromBlock": U256::from(block), "toBlock": U256::from(block), - "topics": [EntryPoint::UserOperationEvent::SIGNATURE_HASH, null, self.address.into_word()] - }])).await { - Ok(logs) => logs, - Err(UsdtError::LogRangeTooLarge) => Vec::new(), - Err(error) => return Err(error), - }; - let mut matched = false; - for log in candidates - .iter() - .filter(|log| log["removed"].as_bool() != Some(true)) - { - let event = - EntryPoint::UserOperationEvent::decode_log_data(&event_data(log)?) - .map_err(|_| UsdtError::InvalidResponse)?; - if serde_json::from_value::
(log["address"].clone())? != ENTRY_POINT - || u64::try_from(serde_json::from_value::( - log["blockNumber"].clone(), - )?) - .map_err(|_| UsdtError::InvalidResponse)? - != block - || event.sender != self.address - { - return Err(UsdtError::InvalidResponse); - } - if event.nonce != plan.operation.nonce { - continue; - } - if event.userOpHash == hash { - self.settle_from_log(&mut transfer, log, event).await?; - } else { - self.reconcile_consumed_nonce(&mut transfer, &plan, block) - .await?; - } - matched = true; - break; - } - if !matched { - self.reconcile_consumed_nonce(&mut transfer, &plan, block) - .await?; - } - } else { - let expired = self.block_timestamp(confirmed_tip).await? > plan.expires_at; - if expired { - transfer.status = UsdtTransferStatus::Failed; - transfer.received_amount = 0; - transfer.fee = Some(0); - self.store.update_transfer(&transfer)?; - } else { - if self.validate_bridge(&plan).await.is_ok() { - let _ = self.broadcast(&plan, hash).await; - } - } - } + if event.userOpHash == hash { + return self.settle_from_log(transfer, log, event).await; } + break; } - Ok(()) + self.reconcile_consumed_nonce(transfer, plan, block).await + } + + async fn operation_logs_in( + &self, + start: u64, + end: u64, + hash: Option, + ) -> Result, UsdtError> { + self.rpc.call("eth_getLogs", json!([{ + "address": ENTRY_POINT, "fromBlock": U256::from(start), "toBlock": U256::from(end), + "topics": [EntryPoint::UserOperationEvent::SIGNATURE_HASH, hash, self.address.into_word()] + }])).await } async fn reconcile_consumed_nonce( @@ -414,7 +398,7 @@ impl UsdtWallet { plan: &Plan, number: u64, ) -> Result<(), UsdtError> { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20); + let deadline = tokio::time::Instant::now() + NONCE_RECOVERY_BUDGET; let block = self.rpc.block(number).await?; let block_hash = format!("{:#x}", block.hash); let start = self.store.nonce_recovery(&transfer.id, &block_hash)?; @@ -430,11 +414,7 @@ impl UsdtWallet { .as_array() .ok_or(UsdtError::InvalidResponse)? { - if serde_json::from_value::
(log["address"].clone())? != ENTRY_POINT { - continue; - } - let Ok(event) = EntryPoint::UserOperationEvent::decode_log_data(&event_data(log)?) - else { + let Some(event) = entry_point_event(log)? else { continue; }; if event.sender != self.address || event.nonce != plan.operation.nonce { @@ -444,16 +424,12 @@ impl UsdtWallet { if event.paymaster != PAYMASTER { return Err(UsdtError::InvalidResponse); } - transfer.tx_hash = format!("{hash:#x}"); - transfer.explorer_url = format!("{EXPLORER}/tx/{hash:#x}"); + transfer.tx_hash = Some(format!("{hash:#x}")); self.settle(transfer, &receipt)?; } else { - transfer.status = UsdtTransferStatus::Replaced; - transfer.received_amount = 0; - transfer.fee = Some(0); + transfer.mark_unexecuted(UsdtTransferStatus::Replaced); } - transfer.timestamp = - u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; + transfer.timestamp = block.timestamp()?; return self.store.update_transfer(transfer); } self.store @@ -463,25 +439,19 @@ impl UsdtWallet { if self.rpc.block(number).await?.hash != block.hash { return Err(UsdtError::NetworkUnavailable); } - transfer.status = UsdtTransferStatus::Replaced; - transfer.received_amount = 0; - transfer.fee = Some(0); - transfer.timestamp = - u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; + transfer.mark_unexecuted(UsdtTransferStatus::Replaced); + transfer.timestamp = block.timestamp()?; self.store.update_transfer(transfer) } async fn refresh_bridges(&self, transfers: &[UsdtTransfer]) -> Result<(), UsdtError> { + let mut retry_after = self.bridge_retry_after.lock().await; + retry_after.retain(|_, deadline| *deadline > Instant::now()); let mut bridges: Vec<_> = transfers .iter() - .filter(|transfer| { - transfer.bridge_guid.is_some() - && matches!( - transfer.status, - UsdtTransferStatus::Bridging | UsdtTransferStatus::BridgeNeedsAttention - ) - }) + .filter(|transfer| !retry_after.contains_key(&transfer.id)) .collect(); + drop(retry_after); if bridges.is_empty() { return Ok(()); } @@ -506,13 +476,24 @@ impl UsdtWallet { let (first, second, third) = tokio::join!(check(batch[0]), check(batch[1]), check(batch[2])); for (previous, result) in [first, second, third].into_iter().flatten() { + if !matches!(result, Ok(Ok(status)) if status != UsdtTransferStatus::BridgeNeedsAttention) + { + self.bridge_retry_after + .lock() + .await + .insert(previous.id.clone(), Instant::now() + BRIDGE_RETRY_DELAY); + } match result { Ok(Ok(status)) if status != previous.status => { let _guard = self.operation.lock().await; let Some(mut current) = self.store.transfer(&previous.id)? else { continue; }; - if current.tx_hash.eq_ignore_ascii_case(&previous.tx_hash) + if current + .tx_hash + .as_deref() + .zip(previous.tx_hash.as_deref()) + .is_some_and(|(a, b)| a.eq_ignore_ascii_case(b)) && current.bridge_guid == previous.bridge_guid && current.status == previous.status { @@ -534,8 +515,7 @@ impl UsdtWallet { .map_err(|_| UsdtError::InvalidResponse) } pub(super) async fn block_timestamp(&self, number: u64) -> Result { - u64::try_from(self.rpc.block(number).await?.timestamp) - .map_err(|_| UsdtError::InvalidResponse) + self.rpc.block(number).await?.timestamp() } async fn token_balance(&self) -> Result { self.rpc @@ -555,8 +535,16 @@ impl UsdtWallet { Ok(()) } async fn nonce(&self, block: &str) -> Result { - let bytes: Bytes = self.rpc.call("eth_call", json!([{"to":ENTRY_POINT,"data":Bytes::from(EntryPoint::getNonceCall { sender:self.address, key:Default::default() }.abi_encode())}, block])).await?; - EntryPoint::getNonceCall::abi_decode_returns(&bytes).map_err(|_| UsdtError::InvalidResponse) + self.rpc + .contract_at( + ENTRY_POINT, + EntryPoint::getNonceCall { + sender: self.address, + key: Default::default(), + }, + block, + ) + .await } async fn authorization(&self) -> Result { let code: Bytes = self @@ -607,8 +595,7 @@ impl UsdtWallet { return Err(UsdtError::InvalidResponse); } let hash: B256 = serde_json::from_value(log["transactionHash"].clone())?; - transfer.tx_hash = format!("{hash:#x}"); - transfer.explorer_url = format!("{EXPLORER}/tx/{}", transfer.tx_hash); + transfer.tx_hash = Some(format!("{hash:#x}")); let number = u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) .map_err(|_| UsdtError::InvalidResponse)?; let block = self.rpc.block(number).await?; @@ -617,8 +604,7 @@ impl UsdtWallet { } let receipt = self.rpc.block_receipt(hash, block.hash, number).await?; self.settle(transfer, &receipt)?; - transfer.timestamp = - u64::try_from(block.timestamp).map_err(|_| UsdtError::InvalidResponse)?; + transfer.timestamp = block.timestamp()?; self.store.update_transfer(transfer) } @@ -639,7 +625,7 @@ impl UsdtWallet { async fn pending_search_end(&self, plan: &Plan, tip: u64) -> Result { // The paymaster validity window bounds recovery even after a long absence. - if tip <= plan.created_block + 4096 { + if tip <= plan.created_block + EXPIRY_SEARCH_BLOCKS { return Ok(tip); } let mut low = plan.created_block; @@ -666,14 +652,9 @@ impl UsdtWallet { .parse() .map_err(|_| UsdtError::InvalidResponse)?; let logs = super::transaction::operation_logs(receipt, operation_hash)?; - let event = EntryPoint::UserOperationEvent::decode_log_data(&event_data( - logs.last().ok_or(UsdtError::InvalidResponse)?, - )?) - .map_err(|_| UsdtError::InvalidResponse)?; - if event.userOpHash != operation_hash - || event.sender != self.address - || event.paymaster != PAYMASTER - { + let event = entry_point_event(logs.last().ok_or(UsdtError::InvalidResponse)?)? + .ok_or(UsdtError::InvalidResponse)?; + if event.sender != self.address || event.paymaster != PAYMASTER { return Err(UsdtError::InvalidResponse); } let mut transfer_proven = false; @@ -751,6 +732,10 @@ impl UsdtWallet { }; let send = BridgeHelper::sendCall::abi_decode(data).map_err(|_| UsdtError::InvalidResponse)?; + let requote = |error| match error { + UsdtError::UnsupportedRoute => UsdtError::QuoteExpired, + error => error, + }; let required = self .rpc .contract( @@ -760,7 +745,8 @@ impl UsdtWallet { payInLzToken: false, }, ) - .await?; + .await + .map_err(requote)?; if !required.lzTokenFee.is_zero() || required.nativeFee > send.fee.nativeFee { return Err(UsdtError::QuoteExpired); } @@ -776,7 +762,8 @@ impl UsdtWallet { fee: send.fee, }, ) - .await?; + .await + .map_err(requote)?; let allowance = calls .iter() .filter(|(target, _)| *target == TOKEN) @@ -928,15 +915,3 @@ impl UsdtWallet { pub(super) fn now() -> u64 { chrono::Utc::now().timestamp().max(0) as u64 } - -fn with_margin(value: U256, percent: u8) -> Result { - value - .checked_add( - value - .checked_mul(U256::from(percent)) - .ok_or(UsdtError::InvalidResponse)? - / U256::from(100), - ) - .and_then(|value| value.checked_add(U256::from(1))) - .ok_or(UsdtError::InvalidResponse) -} diff --git a/tests/usdt-fork/provider.mjs b/tests/usdt-fork/provider.mjs index c9bad84..689fbd8 100644 --- a/tests/usdt-fork/provider.mjs +++ b/tests/usdt-fork/provider.mjs @@ -201,14 +201,10 @@ async function dispatch(method, params) { ); return hash; } + if (method === 'bitkit_getBridgeMessages') return { data: [] }; return rpc.send(method, params); } const server = createServer(async (request, response) => { - if (request.method === 'GET' && request.url.startsWith('/v1/messages/tx/')) { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ data: [] })); - return; - } let body = ''; for await (const chunk of request) body += chunk; const call = JSON.parse(body); From f39ad0dc24c4027fd8a006d214c15c98b1ba9ba3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 25 Sep 2026 16:35:02 +0300 Subject: [PATCH 6/6] fix: exclude zero and self transfers from restored payments --- Package.swift | 2 +- src/modules/usdt/history.rs | 9 +++++++-- src/modules/usdt/tests.rs | 34 ++++++++++++++++++++-------------- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/Package.swift b/Package.swift index ac9e120..0555e2c 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription import Foundation let tag = "v0.6.0" -let checksum = "71ba9b4249617a31fb2ddf753a80a221ebdadf1522433b5a9b1b31fb30611906" +let checksum = "7cb8c8c49221d991f7cbe71c73dcad8e7ceb4d8ad410d68d7a781eb70e04fbdf" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/src/modules/usdt/history.rs b/src/modules/usdt/history.rs index 37b5422..199d80a 100644 --- a/src/modules/usdt/history.rs +++ b/src/modules/usdt/history.rs @@ -350,7 +350,9 @@ impl UsdtWallet { if !super::paymaster::supported_payment(&op.paymasterAndData) { continue; } - let Some((recipient, amount, destination)) = decode_payment(&op.callData) else { + let Some((recipient, amount, destination)) = + decode_payment(&op.callData, self.address) + else { continue; }; (recipient.to_checksum(None), amount, destination) @@ -385,11 +387,14 @@ impl UsdtWallet { } } -fn decode_payment(data: &[u8]) -> Option<(Address, u64, UsdtDestination)> { +fn decode_payment(data: &[u8], sender: Address) -> Option<(Address, u64, UsdtDestination)> { let mut payment = None; for (target, data) in decode_calls(data).ok()? { let next = if target == TOKEN { if let Ok(call) = Erc20::transferCall::abi_decode(&data) { + if call.amount.is_zero() || call.recipient == sender { + return None; + } ( call.recipient, token_amount(call.amount).ok()?, diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index 594fd53..cdddcc8 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -3079,9 +3079,9 @@ async fn settlement_uses_the_canonical_operation_outcome() { } #[tokio::test] -async fn external_paymaster_modes_preserve_raw_debits_and_refunds() { +async fn unrecognized_operations_preserve_raw_debits_and_refunds() { use alloy_primitives::{Address, U256}; - use alloy_sol_types::SolEvent; + use alloy_sol_types::{SolCall, SolEvent}; use serde_json::json; let chain = MockChain::start().await; let dir = tempfile::tempdir().unwrap(); @@ -3103,7 +3103,13 @@ async fn external_paymaster_modes_preserve_raw_debits_and_refunds() { .encode_log_data(); json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"logIndex":U256::from(index)}) }; - for flags in [2u8, 4u8] { + let recipient = RECIPIENT.parse::
().unwrap(); + for (flags, recipient, amount, expected_outgoing) in [ + (2u8, recipient, 1_000_000u64, 1_000_200), + (4, recipient, 1_000_000, 1_000_200), + (0, recipient, 0, 200), + (0, wallet.address, 1_000_000, 200), + ] { { let mut state = chain.state.lock().unwrap(); state.mined = true; @@ -3111,21 +3117,22 @@ async fn external_paymaster_modes_preserve_raw_debits_and_refunds() { let mut data = state.operations[0].paymaster_data.to_vec(); data[1] = flags; state.operations[0].paymaster_data = data.into(); + state.operations[0].call_data = account::batch(&[( + types::TOKEN, + transaction::Erc20::transferCall { + recipient, + amount: U256::from(amount), + } + .abi_encode() + .into(), + )]); let mut logs = state.event_logs(); logs.retain(|log| log["address"] != json!(types::TOKEN)); logs.insert( 0, movement(wallet.address, paymaster::PAYMASTER, 200u64, 2u64), ); - logs.insert( - 1, - movement( - wallet.address, - RECIPIENT.parse::
().unwrap(), - 1_000_000u64, - 3u64, - ), - ); + logs.insert(1, movement(wallet.address, recipient, amount, 3u64)); logs.insert( 2, movement(paymaster::PAYMASTER, wallet.address, 50u64, 4u64), @@ -3136,7 +3143,6 @@ async fn external_paymaster_modes_preserve_raw_debits_and_refunds() { let restored = chain.wallet(&restored_dir); assert!(restored.sync_history().await.unwrap()); let history = restored.history().unwrap(); - assert_eq!(history.len(), 3); assert!(history.iter().all(|row| row.user_operation_hash.is_none())); assert_eq!( history @@ -3152,7 +3158,7 @@ async fn external_paymaster_modes_preserve_raw_debits_and_refunds() { .filter(|row| !row.is_incoming) .map(|row| row.amount) .sum::(), - 1_000_200 + expected_outgoing ); } }