Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import PackageDescription
import Foundation

let tag = "v0.8.0-rc2"
let checksum = "ce4b1ad2b32bdfa33fe16f79cc5b522cd1fbd4409208b57fdb8340b0618c6e4e"
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"
Expand Down
16 changes: 10 additions & 6 deletions bindings/ios/bitkitcore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2564,21 +2564,25 @@ 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 {

func persist(snapshot: String) async throws

}
/**
* 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!
Expand Down
2 changes: 1 addition & 1 deletion src/modules/usdt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
43 changes: 37 additions & 6 deletions src/modules/usdt/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -88,15 +90,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::<B256>()
.map_err(|_| UsdtError::InvalidBackup)?
.to_string();
}
let hash = transfer
.user_operation_hash
.as_deref()
Expand Down Expand Up @@ -219,6 +231,7 @@ impl Store {
}
}
}
require_refund_owner(&tx, &id, &transfer)?;
Comment thread
ben-kaufman marked this conversation as resolved.
tx.execute(
"UPDATE usdt_transfers SET id=?1,data=?2,raw=COALESCE(raw,?4) WHERE id=?3",
params![
Expand Down Expand Up @@ -246,6 +259,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![
Expand All @@ -264,3 +278,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(())
}
6 changes: 6 additions & 0 deletions src/modules/usdt/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,9 @@ impl From<serde_json::Error> for UsdtError {
Self::InvalidResponse
}
}

impl From<uniffi::UnexpectedUniFFICallbackError> for UsdtError {
fn from(_: uniffi::UnexpectedUniFFICallbackError) -> Self {
Self::BackupUnavailable
}
}
23 changes: 16 additions & 7 deletions src/modules/usdt/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -444,6 +438,21 @@ impl Store {
}
}

pub(super) fn refund_claimed(
connection: &Connection,
id: &str,
refund: &str,
) -> Result<bool, UsdtError> {
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')) IN (?2, '0x' || ?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()
Expand Down
89 changes: 76 additions & 13 deletions src/modules/usdt/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -4693,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
Expand All @@ -4717,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))))
Expand Down Expand Up @@ -4747,4 +4753,61 @@ 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());
}

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);
}
Loading
Loading