From 57f96f72d34dc94014cbbbdb7ffb4b1611cee864 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 8 Oct 2026 10:03:10 -0500 Subject: [PATCH 1/3] Fix USDT backup identity and acknowledgement handling --- Package.swift | 2 +- src/modules/usdt/README.md | 2 +- src/modules/usdt/backup.rs | 35 +++++++++++-- src/modules/usdt/errors.rs | 6 +++ src/modules/usdt/store.rs | 21 +++++--- src/modules/usdt/tests.rs | 40 +++++++++++++-- src/modules/usdt/tests/backup.rs | 85 ++++++++++++++++++++++++++++++-- src/modules/usdt/wallet.rs | 32 +++++++++--- 8 files changed, 197 insertions(+), 26 deletions(-) diff --git a/Package.swift b/Package.swift index 9751902..bd43825 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription import Foundation let tag = "v0.8.0-rc2" -let checksum = "ce4b1ad2b32bdfa33fe16f79cc5b522cd1fbd4409208b57fdb8340b0618c6e4e" +let checksum = "d0a681066f67a8d8149e0be3178564b38c8eb9533671f7089b4895566cbbb5ae" 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 f085cd0..0797607 100644 --- a/src/modules/usdt/README.md +++ b/src/modules/usdt/README.md @@ -24,7 +24,7 @@ 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. New sends require no pending source-chain payment. A reorg can reopen multiple signed payments; recovery processes them in nonce order before another send is allowed. -The required `UsdtBackup` callback must acknowledge an encrypted remote backup before initial submission or rebroadcast. Applications save the snapshot with their payment/request associations and must not call mutating wallet methods from the callback. Backup failure keeps the signed payment pending locally and prevents submission. +The required `UsdtBackup` callback must acknowledge an encrypted remote backup before initial submission or rebroadcast. An unchanged snapshot already acknowledged in the current wallet session is not uploaded again; reopening the wallet requires a fresh acknowledgement. Applications save the snapshot with their payment/request associations, back up subsequent application-only changes themselves, and must not call mutating wallet methods from the callback. Backup failure keeps the signed payment pending locally and prevents submission. Unexpected Swift or Kotlin callback errors become `BackupUnavailable`. `export_backup` includes payment records, retained signed operations and bridge tracking data, without private keys or replaceable fee quotes. `restore_backup` atomically merges a snapshot for the same account, preserves newer local outcomes and reconnects recovered on-chain history to application payment IDs. Restored signed operations remain pending until chain reconciliation determines their outcome. Snapshots contain signed payment data and require authenticated encryption. diff --git a/src/modules/usdt/backup.rs b/src/modules/usdt/backup.rs index 4b2ade0..78a6fb5 100644 --- a/src/modules/usdt/backup.rs +++ b/src/modules/usdt/backup.rs @@ -88,15 +88,25 @@ impl Store { } fn restore_backup(&self, snapshot: &str, owner: Address) -> Result<(), UsdtError> { - let backup: Backup = + let mut backup: Backup = serde_json::from_str(snapshot).map_err(|_| UsdtError::InvalidBackup)?; if backup.identity != format!("{CHAIN_ID}:{owner}") { return Err(UsdtError::InvalidCredentials); } let mut ids = HashSet::new(); let mut hashes = HashSet::new(); - for item in &backup.transfers { - let transfer = &item.transfer; + for item in &mut backup.transfers { + let transfer = &mut item.transfer; + let refund = transfer + .orchestra + .as_mut() + .and_then(|bridge| bridge.refund_tx.as_mut()); + for hash in transfer.user_operation_hash.iter_mut().chain(refund) { + *hash = hash + .parse::() + .map_err(|_| UsdtError::InvalidBackup)? + .to_string(); + } let hash = transfer .user_operation_hash .as_deref() @@ -219,6 +229,7 @@ impl Store { } } } + require_refund_owner(&tx, &id, &transfer)?; tx.execute( "UPDATE usdt_transfers SET id=?1,data=?2,raw=COALESCE(raw,?4) WHERE id=?3", params![ @@ -246,6 +257,7 @@ impl Store { plan.bridge_received_amount()? }; } + require_refund_owner(&tx, &item.transfer.id, &item.transfer)?; tx.execute( "INSERT INTO usdt_transfers (id,hash,data,raw,block_number,block_hash) VALUES (?1,?2,?3,?4,?5,?6)", params![ @@ -264,3 +276,20 @@ impl Store { Ok(()) } } + +fn require_refund_owner( + connection: &rusqlite::Connection, + id: &str, + transfer: &UsdtTransfer, +) -> Result<(), UsdtError> { + if let Some(refund) = transfer + .orchestra + .as_ref() + .and_then(|bridge| bridge.refund_tx.as_deref()) + { + if super::store::refund_claimed(connection, id, refund)? { + return Err(UsdtError::InvalidBackup); + } + } + Ok(()) +} diff --git a/src/modules/usdt/errors.rs b/src/modules/usdt/errors.rs index 94cee1f..225f160 100644 --- a/src/modules/usdt/errors.rs +++ b/src/modules/usdt/errors.rs @@ -68,3 +68,9 @@ impl From for UsdtError { Self::InvalidResponse } } + +impl From for UsdtError { + fn from(_: uniffi::UnexpectedUniFFICallbackError) -> Self { + Self::BackupUnavailable + } +} diff --git a/src/modules/usdt/store.rs b/src/modules/usdt/store.rs index 4e5247e..c2e90eb 100644 --- a/src/modules/usdt/store.rs +++ b/src/modules/usdt/store.rs @@ -150,13 +150,7 @@ impl Store { .as_ref() .and_then(|bridge| bridge.refund_tx.as_deref()) .ok_or(UsdtError::InvalidResponse)?; - // Keep the claim in transfer history after its reorg evidence is pruned. - let used: bool = tx.query_row( - "SELECT EXISTS(SELECT 1 FROM usdt_transfers WHERE id!=?1 AND lower(json_extract(data, '$.orchestra.refund_tx'))=lower(?2))", - params![transfer.id, refund], - |row| row.get(0), - )?; - if used { + if refund_claimed(&tx, &transfer.id, refund)? { return Ok(false); } tx.execute( @@ -444,6 +438,19 @@ impl Store { } } +pub(super) fn refund_claimed( + connection: &Connection, + id: &str, + refund: &str, +) -> Result { + // Keep the claim in transfer history after its reorg evidence is pruned. + Ok(connection.query_row( + "SELECT EXISTS(SELECT 1 FROM usdt_transfers WHERE id!=?1 AND lower(json_extract(data, '$.orchestra.refund_tx'))=lower(?2))", + params![id, refund], + |row| row.get(0), + )?) +} + fn write_transfer(connection: &Connection, transfer: &UsdtTransfer) -> Result<(), UsdtError> { let settled = transfer.status != UsdtTransferStatus::Pending; let keep_delivery = transfer.orchestra.is_some() diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index 5b1e231..45cd50f 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -4533,7 +4533,7 @@ async fn orchestra_refunds_require_a_matching_confirmed_usdt_receipt() { state.tip += 3; } wallet.refresh_transfers().await.unwrap(); - let refund_hash = B256::repeat_byte(9); + let refund_hash = B256::repeat_byte(0xab); let event = transaction::Erc20::Transfer { from: Address::repeat_byte(2), to: wallet.address, @@ -4542,7 +4542,7 @@ async fn orchestra_refunds_require_a_matching_confirmed_usdt_receipt() { .encode_log_data(); let receipt = json!({"transactionHash":refund_hash,"blockHash":chain.state.lock().unwrap().block_hash(21000),"blockNumber":"0x5208","status":"0x1","logs":[{"address":types::TOKEN,"topics":event.topics(),"data":event.data}]}); chain.state.lock().unwrap().tip = 21002; - chain.state.lock().unwrap().orchestra_delivery = json!({"status":"refunded", "received_amount":null,"destination_tx":null,"refund_tx":refund_hash,"refund_amount":"900000"}); + chain.state.lock().unwrap().orchestra_delivery = json!({"status":"refunded", "received_amount":null,"destination_tx":null,"refund_tx":"AB".repeat(32),"refund_amount":"900000"}); let owner = wallet.address; drop(wallet); // Provider reports cannot substitute for a successful canonical refund to this wallet. @@ -4593,7 +4593,9 @@ async fn orchestra_refunds_require_a_matching_confirmed_usdt_receipt() { ); if valid { assert_eq!(result.received_amount, 0); - assert_eq!(result.orchestra.unwrap().refund_amount, Some(900_000)); + let bridge = result.orchestra.unwrap(); + assert_eq!(bridge.refund_amount, Some(900_000)); + assert_eq!(bridge.refund_tx, Some(refund_hash.to_string())); } } let wallet = chain.wallet_with_bridges(&directory, true); @@ -4647,6 +4649,7 @@ async fn orchestra_refunds_require_a_matching_confirmed_usdt_receipt() { #[tokio::test] async fn orchestra_refund_receipts_are_exclusive_across_restart_and_pruning() { use alloy_primitives::{B256, U256}; + use serde_json::{json, Value}; let chain = MockChain::start().await; let directory = tempfile::tempdir().unwrap(); let wallet = chain.wallet_with_bridges(&directory, true); @@ -4747,4 +4750,35 @@ async fn orchestra_refund_receipts_are_exclusive_across_restart_and_pruning() { .status, UsdtTransferStatus::BridgeRefunded ); + + let snapshot = wallet.export_backup().unwrap(); + let mut backup: Value = serde_json::from_str(&snapshot).unwrap(); + let refunded = backup["transfers"] + .as_array() + .unwrap() + .iter() + .find(|item| item["transfer"]["status"] == "BridgeRefunded") + .unwrap() + .clone(); + assert!(refunded["plan"].is_null()); + let mut duplicate = refunded.clone(); + duplicate["transfer"]["id"] = json!("another-refund"); + duplicate["transfer"]["user_operation_hash"] = json!(B256::repeat_byte(0xcd).to_string()); + for refund in [format!("0x{}", "AB".repeat(32)), "AB".repeat(32)] { + duplicate["transfer"]["orchestra"]["refund_tx"] = json!(refund); + backup["transfers"] = json!([duplicate.clone()]); + assert!(matches!( + wallet.restore_backup(backup.to_string()).await, + Err(UsdtError::InvalidBackup) + )); + assert_eq!(wallet.export_backup().unwrap(), snapshot); + backup["transfers"] = json!([refunded.clone(), duplicate.clone()]); + let empty_directory = tempfile::tempdir().unwrap(); + let empty = chain.wallet_with_bridges(&empty_directory, true); + assert!(matches!( + empty.restore_backup(backup.to_string()).await, + Err(UsdtError::InvalidBackup) + )); + assert!(empty.history().unwrap().is_empty()); + } } diff --git a/src/modules/usdt/tests/backup.rs b/src/modules/usdt/tests/backup.rs index a9f71e5..7f23b49 100644 --- a/src/modules/usdt/tests/backup.rs +++ b/src/modules/usdt/tests/backup.rs @@ -1,12 +1,14 @@ use super::*; +use serde_json::json; use std::sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, Arc, Mutex, }; #[derive(Default)] struct RemoteBackup { unavailable: AtomicBool, + writes: AtomicUsize, snapshot: Mutex>, } @@ -17,6 +19,7 @@ impl UsdtBackup for RemoteBackup { return Err(UsdtError::BackupUnavailable); } *self.snapshot.lock().unwrap() = Some(snapshot); + self.writes.fetch_add(1, Ordering::SeqCst); Ok(()) } } @@ -67,9 +70,12 @@ async fn submission_and_recovery_wait_for_remote_backup() { let snapshot = backup.snapshot.lock().unwrap().clone().unwrap(); assert!(!snapshot.contains(TEST_PHRASE)); let original = serde_json::to_value(&chain.state.lock().unwrap().operations[0]).unwrap(); + backup.unavailable.store(true, Ordering::SeqCst); + wallet.refresh_transfers().await.unwrap(); + assert_eq!(backup.writes.load(Ordering::SeqCst), 1); drop(wallet); let restored_directory = tempfile::tempdir().unwrap(); - let restored = backed_up_wallet(&chain, &restored_directory, backup); + let restored = backed_up_wallet(&chain, &restored_directory, backup.clone()); restored.restore_backup(snapshot.clone()).await.unwrap(); restored.restore_backup(snapshot).await.unwrap(); assert_eq!(restored.history().unwrap().len(), 1); @@ -79,15 +85,88 @@ async fn submission_and_recovery_wait_for_remote_backup() { .await, Err(UsdtError::PendingTransfer) )); + let submitted = chain.state.lock().unwrap().operations.len(); + assert!(matches!( + restored.refresh_transfers().await, + Err(UsdtError::BackupUnavailable) + )); + assert_eq!(chain.state.lock().unwrap().operations.len(), submitted); + backup.unavailable.store(false, Ordering::SeqCst); restored.refresh_transfers().await.unwrap(); + assert_eq!(backup.writes.load(Ordering::SeqCst), 2); assert_eq!( - serde_json::to_value(&chain.state.lock().unwrap().operations[1]).unwrap(), + serde_json::to_value(chain.state.lock().unwrap().operations.last().unwrap()).unwrap(), original ); chain.state.lock().unwrap().mined = true; let history = restored.refresh_transfers().await.unwrap(); assert_eq!(history[0].id, quote.id); assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); + chain.state.lock().unwrap().nonce = 1; + let next = restored + .quote_transfer(RECIPIENT.into(), 2_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + backup.unavailable.store(true, Ordering::SeqCst); + let submitted = chain.state.lock().unwrap().operations.len(); + assert!(matches!( + restored.send(next.id, TEST_PHRASE.into(), None).await, + Err(UsdtError::BackupUnavailable) + )); + assert_eq!(chain.state.lock().unwrap().operations.len(), submitted); +} + +#[test] +fn foreign_backup_errors_are_recoverable() { + let result = as uniffi::LiftReturn>::handle_callback_unexpected_error( + uniffi::UnexpectedUniFFICallbackError { reason: "remote storage unavailable".into() }, + ); + assert!(matches!(result, Err(UsdtError::BackupUnavailable))); +} + +#[tokio::test] +async fn restore_matches_operation_hashes_independently_of_hex_case() { + let chain = MockChain::start().await; + let directory = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&directory); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let snapshot = wallet.export_backup().unwrap(); + let mut backup: serde_json::Value = serde_json::from_str(&snapshot).unwrap(); + let mut duplicate = backup["transfers"][0].clone(); + let hash = duplicate["transfer"]["user_operation_hash"] + .as_str() + .unwrap() + .to_owned(); + duplicate["transfer"]["user_operation_hash"] = json!(format!("0x{}", hash[2..].to_uppercase())); + backup["transfers"] = json!([duplicate.clone()]); + wallet.restore_backup(backup.to_string()).await.unwrap(); + assert_eq!(wallet.export_backup().unwrap(), snapshot); + duplicate["transfer"]["id"] = json!("another-payment"); + backup = serde_json::from_str(&snapshot).unwrap(); + backup["transfers"] + .as_array_mut() + .unwrap() + .push(duplicate.clone()); + let empty_directory = tempfile::tempdir().unwrap(); + let empty = chain.wallet(&empty_directory); + assert!(matches!( + empty.restore_backup(backup.to_string()).await, + Err(UsdtError::InvalidBackup) + )); + assert!(empty.history().unwrap().is_empty()); + backup["transfers"] = json!([duplicate]); + assert!(matches!( + wallet.restore_backup(backup.to_string()).await, + Err(UsdtError::InvalidBackup) + )); + assert_eq!(wallet.export_backup().unwrap(), snapshot); } #[tokio::test] diff --git a/src/modules/usdt/wallet.rs b/src/modules/usdt/wallet.rs index 37f9f61..9e24336 100644 --- a/src/modules/usdt/wallet.rs +++ b/src/modules/usdt/wallet.rs @@ -13,7 +13,7 @@ use super::{ user_operation::Authorization, UsdtDestination, UsdtError, UsdtQuote, UsdtTransfer, UsdtTransferStatus, }; -use alloy_primitives::{Address, Bytes, B256, U256}; +use alloy_primitives::{keccak256, Address, Bytes, B256, U256}; use alloy_sol_types::{SolCall, SolEvent}; use serde_json::{json, Value}; use std::collections::HashMap; @@ -38,6 +38,7 @@ pub struct UsdtWallet { pub(super) paymaster: Pimlico, pub(super) store: Store, backup: Arc, + acknowledged_backup: Mutex>, orchestra: Option, pub(super) operation: Mutex<()>, bridge_poll_offset: AtomicUsize, @@ -72,6 +73,7 @@ impl UsdtWallet { paymaster, store, backup, + acknowledged_backup: Mutex::new(None), orchestra: bridge_url .map(super::orchestra::Orchestra::new) .transpose()?, @@ -234,7 +236,7 @@ impl UsdtWallet { timestamp: now(), }; self.store.record_signed(&transfer, &raw)?; - self.backup.persist(self.export_backup()?).await?; + self.persist_backup().await?; // After persistence a lost response is indeterminate. Retry only the identical signed operation. if let Err(error) = self.broadcast(&data.plan, hash).await { // These errors occur before submission; later retries may already be queued. @@ -328,6 +330,17 @@ impl UsdtWallet { } impl UsdtWallet { + async fn persist_backup(&self) -> Result<(), UsdtError> { + let snapshot = self.export_backup()?; + let hash = keccak256(snapshot.as_bytes()); + let mut acknowledged = self.acknowledged_backup.lock().await; + if *acknowledged != Some(hash) { + self.backup.persist(snapshot).await?; + *acknowledged = Some(hash); + } + Ok(()) + } + async fn prepare_quote( &self, recipient: &str, @@ -474,7 +487,7 @@ impl UsdtWallet { &format!("{:#x}", block.hash), )?; } else if self.validate_bridge(plan).await.is_ok() { - self.backup.persist(self.export_backup()?).await?; + self.persist_backup().await?; let _ = self.broadcast(plan, hash).await; } return Ok(()); @@ -721,9 +734,13 @@ impl UsdtWallet { .refund_amount .filter(|amount| *amount > 0 && *amount <= transfer.amount) .ok_or(UsdtError::InvalidResponse)?; - let hash = delivery.refund_tx.ok_or(UsdtError::InvalidResponse)?; - refund_block = Some(self.verify_refund(&hash, amount).await?); - bridge.refund_tx = Some(hash); + let hash = delivery + .refund_tx + .ok_or(UsdtError::InvalidResponse)? + .parse::() + .map_err(|_| UsdtError::InvalidResponse)?; + refund_block = Some(self.verify_refund(hash, amount).await?); + bridge.refund_tx = Some(hash.to_string()); bridge.refund_amount = Some(amount); updated.received_amount = 0; UsdtTransferStatus::BridgeRefunded @@ -733,8 +750,7 @@ impl UsdtWallet { Ok((updated, refund_block)) } - async fn verify_refund(&self, hash: &str, amount: u64) -> Result<(u64, B256), UsdtError> { - let hash: B256 = hash.parse().map_err(|_| UsdtError::InvalidResponse)?; + async fn verify_refund(&self, hash: B256, amount: u64) -> Result<(u64, B256), UsdtError> { let receipt: Value = self .rpc .call("eth_getTransactionReceipt", json!([hash])) From 9a6913254405eb5bdf25f71c1a1cfdc20ca2169a Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 8 Oct 2026 12:37:19 -0500 Subject: [PATCH 2/3] test: cover refund conflicts after seed recovery --- src/modules/usdt/tests.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index 45cd50f..fb1f98c 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -4781,4 +4781,30 @@ async fn orchestra_refund_receipts_are_exclusive_across_restart_and_pruning() { )); assert!(empty.history().unwrap().is_empty()); } + + let mut discovered: UsdtTransfer = + serde_json::from_value(duplicate["transfer"].clone()).unwrap(); + discovered.id = discovered.user_operation_hash.clone().unwrap(); + discovered.recipient = plan.orchestra.as_ref().unwrap().funding_address.clone(); + discovered.destination = UsdtDestination::Arbitrum; + discovered.orchestra = None; + discovered.status = UsdtTransferStatus::Confirmed; + discovered.received_amount = discovered.amount; + wallet + .store + .save_history_receipt( + &[discovered], + &B256::repeat_byte(4).to_string(), + 27000, + &B256::repeat_byte(5).to_string(), + true, + ) + .unwrap(); + let snapshot = wallet.export_backup().unwrap(); + backup["transfers"] = json!([duplicate]); + assert!(matches!( + wallet.restore_backup(backup.to_string()).await, + Err(UsdtError::InvalidBackup) + )); + assert_eq!(wallet.export_backup().unwrap(), snapshot); } From 9c273fa08c3ab8b07490fe1eeb9ec04cd3bf2f7c Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 8 Oct 2026 14:59:25 -0500 Subject: [PATCH 3/3] fix: match refund claims across hash encodings --- Package.swift | 2 +- bindings/ios/bitkitcore.swift | 16 ++++++++++------ src/modules/usdt/backup.rs | 8 +++++--- src/modules/usdt/store.rs | 4 +++- src/modules/usdt/tests.rs | 23 +++++++++++++---------- src/modules/usdt/tests/backup.rs | 1 + 6 files changed, 33 insertions(+), 21 deletions(-) diff --git a/Package.swift b/Package.swift index bd43825..e570a77 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription import Foundation let tag = "v0.8.0-rc2" -let checksum = "d0a681066f67a8d8149e0be3178564b38c8eb9533671f7089b4895566cbbb5ae" +let checksum = "e064d0edafecfa64e5291d2aaab6b00052afbe6951fb26b3ea46b727b2df577f" 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 14b5967..f71108b 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -2564,10 +2564,12 @@ public func FfiConverterTypeUrDecoder_lower(_ value: UrDecoder) -> UnsafeMutable /** - * Saves recovery data before a signed payment can be submitted, including automatic retries. + * Saves recovery data before a signed payment can be submitted. * Implementations must encrypt the snapshot, persist it remotely with its application payment - * associations, and return only after acknowledgement. Do not log the snapshot or call mutating - * wallet methods from this callback. A failed backup leaves the payment pending locally. + * associations, and return only after acknowledgement. Automatic retries reuse an unchanged + * acknowledged snapshot within the same wallet session. Applications must separately back up + * association-only changes. Do not log the snapshot or call mutating wallet methods from this + * callback. A failed backup leaves the payment pending locally. */ public protocol UsdtBackup: AnyObject, Sendable { @@ -2575,10 +2577,12 @@ public protocol UsdtBackup: AnyObject, Sendable { } /** - * Saves recovery data before a signed payment can be submitted, including automatic retries. + * Saves recovery data before a signed payment can be submitted. * Implementations must encrypt the snapshot, persist it remotely with its application payment - * associations, and return only after acknowledgement. Do not log the snapshot or call mutating - * wallet methods from this callback. A failed backup leaves the payment pending locally. + * associations, and return only after acknowledgement. Automatic retries reuse an unchanged + * acknowledged snapshot within the same wallet session. Applications must separately back up + * association-only changes. Do not log the snapshot or call mutating wallet methods from this + * callback. A failed backup leaves the payment pending locally. */ open class UsdtBackupImpl: UsdtBackup, @unchecked Sendable { fileprivate let pointer: UnsafeMutableRawPointer! diff --git a/src/modules/usdt/backup.rs b/src/modules/usdt/backup.rs index 78a6fb5..fabde90 100644 --- a/src/modules/usdt/backup.rs +++ b/src/modules/usdt/backup.rs @@ -7,10 +7,12 @@ use rusqlite::{params, OptionalExtension}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; -/// Saves recovery data before a signed payment can be submitted, including automatic retries. +/// Saves recovery data before a signed payment can be submitted. /// Implementations must encrypt the snapshot, persist it remotely with its application payment -/// associations, and return only after acknowledgement. Do not log the snapshot or call mutating -/// wallet methods from this callback. A failed backup leaves the payment pending locally. +/// associations, and return only after acknowledgement. Automatic retries reuse an unchanged +/// acknowledged snapshot within the same wallet session. Applications must separately back up +/// association-only changes. Do not log the snapshot or call mutating wallet methods from this +/// callback. A failed backup leaves the payment pending locally. #[uniffi::export(with_foreign)] #[async_trait::async_trait] pub trait UsdtBackup: Send + Sync { diff --git a/src/modules/usdt/store.rs b/src/modules/usdt/store.rs index c2e90eb..dacf63f 100644 --- a/src/modules/usdt/store.rs +++ b/src/modules/usdt/store.rs @@ -443,9 +443,11 @@ pub(super) fn refund_claimed( id: &str, refund: &str, ) -> Result { + let refund = refund.to_ascii_lowercase(); + let refund = refund.strip_prefix("0x").unwrap_or(&refund); // Keep the claim in transfer history after its reorg evidence is pruned. Ok(connection.query_row( - "SELECT EXISTS(SELECT 1 FROM usdt_transfers WHERE id!=?1 AND lower(json_extract(data, '$.orchestra.refund_tx'))=lower(?2))", + "SELECT EXISTS(SELECT 1 FROM usdt_transfers WHERE id!=?1 AND lower(json_extract(data, '$.orchestra.refund_tx')) IN (?2, '0x' || ?2))", params![id, refund], |row| row.get(0), )?) diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index fb1f98c..677c3a9 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -4696,16 +4696,18 @@ async fn orchestra_refund_receipts_are_exclusive_across_restart_and_pruning() { .store .update_delivery(&payments[0], Some(block)) .unwrap()); - // Retrying the same association is idempotent, including transaction-hash casing. - payments[0].orchestra.as_mut().unwrap().refund_tx = Some(format!("0x{}", "AB".repeat(32))); - assert!(wallet - .store - .update_delivery(&payments[0], Some(block)) - .unwrap()); - assert!(!wallet - .store - .update_delivery(&payments[1], Some(block)) - .unwrap()); + // Receipt identity is independent of hash casing and the optional hex prefix. + for refund in [format!("0x{}", "AB".repeat(32)), "AB".repeat(32)] { + payments[0].orchestra.as_mut().unwrap().refund_tx = Some(refund); + assert!(wallet + .store + .update_delivery(&payments[0], Some(block)) + .unwrap()); + assert!(!wallet + .store + .update_delivery(&payments[1], Some(block)) + .unwrap()); + } assert_eq!( wallet .store @@ -4720,6 +4722,7 @@ async fn orchestra_refund_receipts_are_exclusive_across_restart_and_pruning() { .store .reconcile_block(block.0, &B256::repeat_byte(3).to_string()) .unwrap(); + payments[1].orchestra.as_mut().unwrap().refund_tx = Some("AB".repeat(32)); assert!(wallet .store .update_delivery(&payments[1], Some((block.0, B256::repeat_byte(3)))) diff --git a/src/modules/usdt/tests/backup.rs b/src/modules/usdt/tests/backup.rs index 7f23b49..6292764 100644 --- a/src/modules/usdt/tests/backup.rs +++ b/src/modules/usdt/tests/backup.rs @@ -72,6 +72,7 @@ async fn submission_and_recovery_wait_for_remote_backup() { let original = serde_json::to_value(&chain.state.lock().unwrap().operations[0]).unwrap(); backup.unavailable.store(true, Ordering::SeqCst); wallet.refresh_transfers().await.unwrap(); + assert_eq!(chain.state.lock().unwrap().operations.len(), 2); assert_eq!(backup.writes.load(Ordering::SeqCst), 1); drop(wallet); let restored_directory = tempfile::tempdir().unwrap();