From 799bcd588c3d97d98115aa8ad0354fdd3038de44 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 7 Oct 2026 15:43:29 -0500 Subject: [PATCH 1/2] chore: prepare 0.8.0-rc2 with USDT recovery APIs --- Cargo.lock | 2 +- Cargo.toml | 2 +- Package.swift | 4 +- bindings/android/gradle.properties | 2 +- bindings/ios/bitkitcore.swift | 458 ++++++++++++++++++++++++++--- bindings/ios/bitkitcoreFFI.h | 64 +++- src/lib.rs | 2 +- src/modules/usdt/README.md | 4 + src/modules/usdt/backup.rs | 266 +++++++++++++++++ src/modules/usdt/errors.rs | 4 + src/modules/usdt/history.rs | 5 +- src/modules/usdt/mod.rs | 2 + src/modules/usdt/store.rs | 2 +- src/modules/usdt/tests.rs | 23 +- src/modules/usdt/tests/backup.rs | 287 ++++++++++++++++++ src/modules/usdt/wallet.rs | 5 + 16 files changed, 1075 insertions(+), 57 deletions(-) create mode 100644 src/modules/usdt/backup.rs create mode 100644 src/modules/usdt/tests/backup.rs diff --git a/Cargo.lock b/Cargo.lock index 7addf37..0423237 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -985,7 +985,7 @@ dependencies = [ [[package]] name = "bitkitcore" -version = "0.7.0" +version = "0.8.0-rc2" dependencies = [ "alloy-primitives", "alloy-rlp", diff --git a/Cargo.toml b/Cargo.toml index e6a049a..39b0879 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitkitcore" -version = "0.7.0" +version = "0.8.0-rc2" edition = "2021" rust-version = "1.90" diff --git a/Package.swift b/Package.swift index ae7c231..9751902 100644 --- a/Package.swift +++ b/Package.swift @@ -4,8 +4,8 @@ import PackageDescription import Foundation -let tag = "v0.7.0" -let checksum = "1acafd28aadd7098274f80b402f230346c3265a77f21917583814403c05347cd" +let tag = "v0.8.0-rc2" +let checksum = "ce4b1ad2b32bdfa33fe16f79cc5b522cd1fbd4409208b57fdb8340b0618c6e4e" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" diff --git a/bindings/android/gradle.properties b/bindings/android/gradle.properties index 7f17241..5c450f4 100644 --- a/bindings/android/gradle.properties +++ b/bindings/android/gradle.properties @@ -3,4 +3,4 @@ android.useAndroidX=true android.enableJetifier=true kotlin.code.style=official group=com.synonym -version=0.7.0 +version=0.8.0-rc2 diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index aac46a9..14b5967 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -2563,6 +2563,218 @@ public func FfiConverterTypeUrDecoder_lower(_ value: UrDecoder) -> UnsafeMutable +/** + * Saves recovery data before a signed payment can be submitted, including automatic retries. + * 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. + */ +public protocol UsdtBackup: AnyObject, Sendable { + + func persist(snapshot: String) async throws + +} +/** + * Saves recovery data before a signed payment can be submitted, including automatic retries. + * 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. + */ +open class UsdtBackupImpl: UsdtBackup, @unchecked Sendable { + fileprivate let pointer: UnsafeMutableRawPointer! + + /// Used to instantiate a [FFIObject] without an actual pointer, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoPointer { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + self.pointer = pointer + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noPointer: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing [Pointer] the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noPointer: NoPointer) { + self.pointer = nil + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiClonePointer() -> UnsafeMutableRawPointer { + return try! rustCall { uniffi_bitkitcore_fn_clone_usdtbackup(self.pointer, $0) } + } + // No primary constructor declared for this class. + + deinit { + guard let pointer = pointer else { + return + } + + try! rustCall { uniffi_bitkitcore_fn_free_usdtbackup(pointer, $0) } + } + + + + +open func persist(snapshot: String)async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtbackup_persist( + self.uniffiClonePointer(), + FfiConverterString.lower(snapshot) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_void, + completeFunc: ffi_bitkitcore_rust_future_complete_void, + freeFunc: ffi_bitkitcore_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + + +} + + +// Put the implementation in a struct so we don't pollute the top-level namespace +fileprivate struct UniffiCallbackInterfaceUsdtBackup { + + // Create the VTable using a series of closures. + // Swift automatically converts these into C callback functions. + // + // This creates 1-element array, since this seems to be the only way to construct a const + // pointer that we can pass to the Rust code. + static let vtable: [UniffiVTableCallbackInterfaceUsdtBackup] = [UniffiVTableCallbackInterfaceUsdtBackup( + persist: { ( + uniffiHandle: UInt64, + snapshot: RustBuffer, + uniffiFutureCallback: @escaping UniffiForeignFutureCompleteVoid, + uniffiCallbackData: UInt64, + uniffiOutReturn: UnsafeMutablePointer + ) in + let makeCall = { + () async throws -> () in + guard let uniffiObj = try? FfiConverterTypeUsdtBackup.handleMap.get(handle: uniffiHandle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return try await uniffiObj.persist( + snapshot: try FfiConverterString.lift(snapshot) + ) + } + + let uniffiHandleSuccess = { (returnValue: ()) in + uniffiFutureCallback( + uniffiCallbackData, + UniffiForeignFutureStructVoid( + callStatus: RustCallStatus() + ) + ) + } + let uniffiHandleError = { (statusCode, errorBuf) in + uniffiFutureCallback( + uniffiCallbackData, + UniffiForeignFutureStructVoid( + callStatus: RustCallStatus(code: statusCode, errorBuf: errorBuf) + ) + ) + } + let uniffiForeignFuture = uniffiTraitInterfaceCallAsyncWithError( + makeCall: makeCall, + handleSuccess: uniffiHandleSuccess, + handleError: uniffiHandleError, + lowerError: FfiConverterTypeUsdtError_lower + ) + uniffiOutReturn.pointee = uniffiForeignFuture + }, + uniffiFree: { (uniffiHandle: UInt64) -> () in + let result = try? FfiConverterTypeUsdtBackup.handleMap.remove(handle: uniffiHandle) + if result == nil { + print("Uniffi callback interface UsdtBackup: handle missing in uniffiFree") + } + } + )] +} + +private func uniffiCallbackInitUsdtBackup() { + uniffi_bitkitcore_fn_init_callback_vtable_usdtbackup(UniffiCallbackInterfaceUsdtBackup.vtable) +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtBackup: FfiConverter { + fileprivate static let handleMap = UniffiHandleMap() + + typealias FfiType = UnsafeMutableRawPointer + typealias SwiftType = UsdtBackup + + public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> UsdtBackup { + return UsdtBackupImpl(unsafeFromRawPointer: pointer) + } + + public static func lower(_ value: UsdtBackup) -> UnsafeMutableRawPointer { + guard let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: handleMap.insert(obj: value))) else { + fatalError("Cast to UnsafeMutableRawPointer failed") + } + return ptr + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtBackup { + let v: UInt64 = try readInt(&buf) + // The Rust code won't compile if a pointer won't fit in a UInt64. + // We have to go via `UInt` because that's the thing that's the size of a pointer. + let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: v)) + if (ptr == nil) { + throw UniffiInternalError.unexpectedNullPointer + } + return try lift(ptr!) + } + + public static func write(_ value: UsdtBackup, into buf: inout [UInt8]) { + // This fiddling is because `Int` is the thing that's the same size as a pointer. + // The Rust code won't compile if a pointer won't fit in a `UInt64`. + writeInt(&buf, UInt64(bitPattern: Int64(Int(bitPattern: lower(value))))) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtBackup_lift(_ pointer: UnsafeMutableRawPointer) throws -> UsdtBackup { + return try FfiConverterTypeUsdtBackup.lift(pointer) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtBackup_lower(_ value: UsdtBackup) -> UnsafeMutableRawPointer { + return FfiConverterTypeUsdtBackup.lower(value) +} + + + + + + public protocol UsdtDepositClientProtocol: AnyObject, Sendable { func detail(depositId: String, offset: UInt32, mnemonic: String, passphrase: String?) async throws -> UsdtDepositDetail @@ -2798,6 +3010,12 @@ public protocol UsdtWalletProtocol: AnyObject, Sendable { */ func createPaymentProof(transferId: String, binding: UsdtPaymentProofBinding, mnemonic: String, passphrase: String?) async throws -> UsdtPaymentProof? + /** + * Portable recovery data without keys, fee quotes or rebuildable history caches. + * Contains signed operations: store only inside an authenticated, encrypted backup. + */ + func exportBackup() throws -> String + func history() throws -> [UsdtTransfer] /** @@ -2816,6 +3034,13 @@ public protocol UsdtWalletProtocol: AnyObject, Sendable { */ func refreshTransfers() async throws -> [UsdtTransfer] + /** + * Atomically merges recovery data for this account. Existing local outcomes take precedence. + * Restored signed operations remain pending until chain reconciliation proves their outcome; + * recovery may resubmit only the original signed payload through the backup callback. + */ + func restoreBackup(snapshot: String) async throws + /** * 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. @@ -2878,7 +3103,7 @@ open class UsdtWallet: UsdtWalletProtocol, @unchecked Sendable { /** * 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, bridgeUrl: String? = nil)throws { +public convenience init(address: String, storagePath: String, rpcUrl: String, bundlerUrl: String, bridgeUrl: String? = nil, backup: UsdtBackup)throws { let pointer = try rustCallWithError(FfiConverterTypeUsdtError_lift) { uniffi_bitkitcore_fn_constructor_usdtwallet_new( @@ -2886,7 +3111,8 @@ public convenience init(address: String, storagePath: String, rpcUrl: String, bu FfiConverterString.lower(storagePath), FfiConverterString.lower(rpcUrl), FfiConverterString.lower(bundlerUrl), - FfiConverterOptionString.lower(bridgeUrl),$0 + FfiConverterOptionString.lower(bridgeUrl), + FfiConverterTypeUsdtBackup_lower(backup),$0 ) } self.init(unsafeFromRawPointer: pointer) @@ -2964,6 +3190,17 @@ open func createPaymentProof(transferId: String, binding: UsdtPaymentProofBindin ) } + /** + * Portable recovery data without keys, fee quotes or rebuildable history caches. + * Contains signed operations: store only inside an authenticated, encrypted backup. + */ +open func exportBackup()throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeUsdtError_lift) { + uniffi_bitkitcore_fn_method_usdtwallet_export_backup(self.uniffiClonePointer(),$0 + ) +}) +} + open func history()throws -> [UsdtTransfer] { return try FfiConverterSequenceTypeUsdtTransfer.lift(try rustCallWithError(FfiConverterTypeUsdtError_lift) { uniffi_bitkitcore_fn_method_usdtwallet_history(self.uniffiClonePointer(),$0 @@ -3042,6 +3279,28 @@ open func refreshTransfers()async throws -> [UsdtTransfer] { ) } + /** + * Atomically merges recovery data for this account. Existing local outcomes take precedence. + * Restored signed operations remain pending until chain reconciliation proves their outcome; + * recovery may resubmit only the original signed payload through the backup callback. + */ +open func restoreBackup(snapshot: String)async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtwallet_restore_backup( + self.uniffiClonePointer(), + FfiConverterString.lower(snapshot) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_void, + completeFunc: ffi_bitkitcore_rust_future_complete_void, + freeFunc: ffi_bitkitcore_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + /** * 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. @@ -25094,6 +25353,8 @@ public enum UsdtError: Swift.Error { + case BackupUnavailable + case InvalidBackup case InvalidPaymentProof case InvalidAmount case InvalidAddress @@ -25135,35 +25396,37 @@ public struct FfiConverterTypeUsdtError: FfiConverterRustBuffer { - case 1: return .InvalidPaymentProof - case 2: return .InvalidAmount - case 3: return .InvalidAddress - case 4: return .WrongNetwork - case 5: return .InvalidCredentials - case 6: return .ClockSkew - case 7: return .UnsupportedDelegation - case 8: return .InsufficientBalance - case 9: return .QuoteExpired - case 10: return .PendingTransfer - case 11: return .UnsupportedRoute - case 12: return .DepositNeedsAttention - case 13: return .DepositNotFound - case 14: return .DepositAuthorizationRejected - case 15: return .DepositAmountOutOfRange( + case 1: return .BackupUnavailable + case 2: return .InvalidBackup + case 3: return .InvalidPaymentProof + case 4: return .InvalidAmount + case 5: return .InvalidAddress + case 6: return .WrongNetwork + case 7: return .InvalidCredentials + case 8: return .ClockSkew + case 9: return .UnsupportedDelegation + case 10: return .InsufficientBalance + case 11: return .QuoteExpired + case 12: return .PendingTransfer + case 13: return .UnsupportedRoute + case 14: return .DepositNeedsAttention + case 15: return .DepositNotFound + case 16: return .DepositAuthorizationRejected + case 17: return .DepositAmountOutOfRange( minUsdCents: try FfiConverterOptionString.read(from: &buf), maxUsdCents: try FfiConverterOptionString.read(from: &buf) ) - case 16: return .NotConfigured - case 17: return .NetworkUnavailable - case 18: return .RateLimited - case 19: return .LogRangeTooLarge - case 20: return .TransactionRejected( + case 18: return .NotConfigured + case 19: return .NetworkUnavailable + case 20: return .RateLimited + case 21: return .LogRangeTooLarge + case 22: return .TransactionRejected( reason: try FfiConverterString.read(from: &buf) ) - case 21: return .Storage( + case 23: return .Storage( reason: try FfiConverterString.read(from: &buf) ) - case 22: return .InvalidResponse + case 24: return .InvalidResponse default: throw UniffiInternalError.unexpectedEnumCase } @@ -25176,96 +25439,104 @@ public struct FfiConverterTypeUsdtError: FfiConverterRustBuffer { - case .InvalidPaymentProof: + case .BackupUnavailable: writeInt(&buf, Int32(1)) - case .InvalidAmount: + case .InvalidBackup: writeInt(&buf, Int32(2)) - case .InvalidAddress: + case .InvalidPaymentProof: writeInt(&buf, Int32(3)) - case .WrongNetwork: + case .InvalidAmount: writeInt(&buf, Int32(4)) - case .InvalidCredentials: + case .InvalidAddress: writeInt(&buf, Int32(5)) - case .ClockSkew: + case .WrongNetwork: writeInt(&buf, Int32(6)) - case .UnsupportedDelegation: + case .InvalidCredentials: writeInt(&buf, Int32(7)) - case .InsufficientBalance: + case .ClockSkew: writeInt(&buf, Int32(8)) - case .QuoteExpired: + case .UnsupportedDelegation: writeInt(&buf, Int32(9)) - case .PendingTransfer: + case .InsufficientBalance: writeInt(&buf, Int32(10)) - case .UnsupportedRoute: + case .QuoteExpired: writeInt(&buf, Int32(11)) - case .DepositNeedsAttention: + case .PendingTransfer: writeInt(&buf, Int32(12)) - case .DepositNotFound: + case .UnsupportedRoute: writeInt(&buf, Int32(13)) - case .DepositAuthorizationRejected: + case .DepositNeedsAttention: writeInt(&buf, Int32(14)) - case let .DepositAmountOutOfRange(minUsdCents,maxUsdCents): + case .DepositNotFound: writeInt(&buf, Int32(15)) + + + case .DepositAuthorizationRejected: + writeInt(&buf, Int32(16)) + + + case let .DepositAmountOutOfRange(minUsdCents,maxUsdCents): + writeInt(&buf, Int32(17)) FfiConverterOptionString.write(minUsdCents, into: &buf) FfiConverterOptionString.write(maxUsdCents, into: &buf) case .NotConfigured: - writeInt(&buf, Int32(16)) + writeInt(&buf, Int32(18)) case .NetworkUnavailable: - writeInt(&buf, Int32(17)) + writeInt(&buf, Int32(19)) case .RateLimited: - writeInt(&buf, Int32(18)) + writeInt(&buf, Int32(20)) case .LogRangeTooLarge: - writeInt(&buf, Int32(19)) + writeInt(&buf, Int32(21)) case let .TransactionRejected(reason): - writeInt(&buf, Int32(20)) + writeInt(&buf, Int32(22)) FfiConverterString.write(reason, into: &buf) case let .Storage(reason): - writeInt(&buf, Int32(21)) + writeInt(&buf, Int32(23)) FfiConverterString.write(reason, into: &buf) case .InvalidResponse: - writeInt(&buf, Int32(22)) + writeInt(&buf, Int32(24)) } } @@ -28360,6 +28631,89 @@ fileprivate func uniffiFutureContinuationCallback(handle: UInt64, pollResult: In print("uniffiFutureContinuationCallback invalid handle") } } +private func uniffiTraitInterfaceCallAsync( + makeCall: @escaping () async throws -> T, + handleSuccess: @escaping (T) -> (), + handleError: @escaping (Int8, RustBuffer) -> () +) -> UniffiForeignFuture { + let task = Task { + // Note: it's important we call either `handleSuccess` or `handleError` exactly once. Each + // call consumes an Arc reference, which means there should be no possibility of a double + // call. The following code is structured so that will will never call both `handleSuccess` + // and `handleError`, even in the face of weird errors. + // + // On platforms that need extra machinery to make C-ABI calls, like JNA or ctypes, it's + // possible that we fail to make either call. However, it doesn't seem like this is + // possible on Swift since swift can just make the C call directly. + var callResult: T + do { + callResult = try await makeCall() + } catch { + handleError(CALL_UNEXPECTED_ERROR, FfiConverterString.lower(String(describing: error))) + return + } + handleSuccess(callResult) + } + let handle = UNIFFI_FOREIGN_FUTURE_HANDLE_MAP.insert(obj: task) + return UniffiForeignFuture(handle: handle, free: uniffiForeignFutureFree) + +} + +private func uniffiTraitInterfaceCallAsyncWithError( + makeCall: @escaping () async throws -> T, + handleSuccess: @escaping (T) -> (), + handleError: @escaping (Int8, RustBuffer) -> (), + lowerError: @escaping (E) -> RustBuffer +) -> UniffiForeignFuture { + let task = Task { + // See the note in uniffiTraitInterfaceCallAsync for details on `handleSuccess` and + // `handleError`. + var callResult: T + do { + callResult = try await makeCall() + } catch let error as E { + handleError(CALL_ERROR, lowerError(error)) + return + } catch { + handleError(CALL_UNEXPECTED_ERROR, FfiConverterString.lower(String(describing: error))) + return + } + handleSuccess(callResult) + } + let handle = UNIFFI_FOREIGN_FUTURE_HANDLE_MAP.insert(obj: task) + return UniffiForeignFuture(handle: handle, free: uniffiForeignFutureFree) +} + +// Borrow the callback handle map implementation to store foreign future handles +// TODO: consolidate the handle-map code (https://github.com/mozilla/uniffi-rs/pull/1823) +fileprivate let UNIFFI_FOREIGN_FUTURE_HANDLE_MAP = UniffiHandleMap() + +// Protocol for tasks that handle foreign futures. +// +// Defining a protocol allows all tasks to be stored in the same handle map. This can't be done +// with the task object itself, since has generic parameters. +fileprivate protocol UniffiForeignFutureTask { + func cancel() +} + +extension Task: UniffiForeignFutureTask {} + +private func uniffiForeignFutureFree(handle: UInt64) { + do { + let task = try UNIFFI_FOREIGN_FUTURE_HANDLE_MAP.remove(handle: handle) + // Set the cancellation flag on the task. If it's still running, the code can check the + // cancellation flag or call `Task.checkCancellation()`. If the task has completed, this is + // a no-op. + task.cancel() + } catch { + print("uniffiForeignFutureFree: handle missing from handlemap") + } +} + +// For testing +public func uniffiForeignFutureHandleCountBitkitcore() -> Int { + UNIFFI_FOREIGN_FUTURE_HANDLE_MAP.count +} /** * Decode activities from Core's canonical backup JSON, defaulting a * missing/empty wallet id to [`DEFAULT_WALLET_ID`]. @@ -31739,6 +32093,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_urdecoder_reset() != 6027) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_method_usdtbackup_persist() != 15054) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_method_usdtdepositclient_detail() != 63177) { return InitializationResult.apiChecksumMismatch } @@ -31763,6 +32120,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_usdtwallet_create_payment_proof() != 39351) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_method_usdtwallet_export_backup() != 55033) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_method_usdtwallet_history() != 4617) { return InitializationResult.apiChecksumMismatch } @@ -31781,6 +32141,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfers() != 34299) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_method_usdtwallet_restore_backup() != 26015) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_method_usdtwallet_send() != 60030) { return InitializationResult.apiChecksumMismatch } @@ -31796,7 +32159,7 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_constructor_usdtdepositclient_new() != 44626) { return InitializationResult.apiChecksumMismatch } - if (uniffi_bitkitcore_checksum_constructor_usdtwallet_new() != 14616) { + if (uniffi_bitkitcore_checksum_constructor_usdtwallet_new() != 7754) { return InitializationResult.apiChecksumMismatch } @@ -31805,6 +32168,7 @@ private let initializationResult: InitializationResult = { uniffiCallbackInitJadeTransportCallback() uniffiCallbackInitTrezorTransportCallback() uniffiCallbackInitTrezorUiCallback() + uniffiCallbackInitUsdtBackup() return InitializationResult.ok }() diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index e7f9521..75e5368 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -397,6 +397,12 @@ typedef void (*UniffiCallbackInterfaceTrezorUiCallbackMethod1)(uint64_t, int8_t, RustCallStatus *_Nonnull uniffiCallStatus ); +#endif +#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_USDT_BACKUP_METHOD0 +#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_USDT_BACKUP_METHOD0 +typedef void (*UniffiCallbackInterfaceUsdtBackupMethod0)(uint64_t, RustBuffer, UniffiForeignFutureCompleteVoid _Nonnull, uint64_t, UniffiForeignFuture* _Nonnull + ); + #endif #ifndef UNIFFI_FFIDEF_V_TABLE_CALLBACK_INTERFACE_BOLTZ_EVENT_LISTENER #define UNIFFI_FFIDEF_V_TABLE_CALLBACK_INTERFACE_BOLTZ_EVENT_LISTENER @@ -453,6 +459,14 @@ typedef struct UniffiVTableCallbackInterfaceTrezorUiCallback { UniffiCallbackInterfaceFree _Nonnull uniffiFree; } UniffiVTableCallbackInterfaceTrezorUiCallback; +#endif +#ifndef UNIFFI_FFIDEF_V_TABLE_CALLBACK_INTERFACE_USDT_BACKUP +#define UNIFFI_FFIDEF_V_TABLE_CALLBACK_INTERFACE_USDT_BACKUP +typedef struct UniffiVTableCallbackInterfaceUsdtBackup { + UniffiCallbackInterfaceUsdtBackupMethod0 _Nonnull persist; + UniffiCallbackInterfaceFree _Nonnull uniffiFree; +} UniffiVTableCallbackInterfaceUsdtBackup; + #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CLONE_BOLTZEVENTLISTENER #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CLONE_BOLTZEVENTLISTENER @@ -660,6 +674,26 @@ RustBuffer uniffi_bitkitcore_fn_method_urdecoder_receive(void*_Nonnull ptr, Rust void uniffi_bitkitcore_fn_method_urdecoder_reset(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CLONE_USDTBACKUP +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CLONE_USDTBACKUP +void*_Nonnull uniffi_bitkitcore_fn_clone_usdtbackup(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FREE_USDTBACKUP +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FREE_USDTBACKUP +void uniffi_bitkitcore_fn_free_usdtbackup(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_INIT_CALLBACK_VTABLE_USDTBACKUP +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_INIT_CALLBACK_VTABLE_USDTBACKUP +void uniffi_bitkitcore_fn_init_callback_vtable_usdtbackup(const UniffiVTableCallbackInterfaceUsdtBackup* _Nonnull vtable +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTBACKUP_PERSIST +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTBACKUP_PERSIST +uint64_t uniffi_bitkitcore_fn_method_usdtbackup_persist(void*_Nonnull ptr, RustBuffer snapshot +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CLONE_USDTDEPOSITCLIENT #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CLONE_USDTDEPOSITCLIENT void*_Nonnull uniffi_bitkitcore_fn_clone_usdtdepositclient(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status @@ -712,7 +746,7 @@ void uniffi_bitkitcore_fn_free_usdtwallet(void*_Nonnull ptr, RustCallStatus *_No #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CONSTRUCTOR_USDTWALLET_NEW #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CONSTRUCTOR_USDTWALLET_NEW -void*_Nonnull uniffi_bitkitcore_fn_constructor_usdtwallet_new(RustBuffer address, RustBuffer storage_path, RustBuffer rpc_url, RustBuffer bundler_url, RustBuffer bridge_url, RustCallStatus *_Nonnull out_status +void*_Nonnull uniffi_bitkitcore_fn_constructor_usdtwallet_new(RustBuffer address, RustBuffer storage_path, RustBuffer rpc_url, RustBuffer bundler_url, RustBuffer bridge_url, void*_Nonnull backup, RustCallStatus *_Nonnull out_status ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_BALANCE @@ -730,6 +764,11 @@ uint64_t uniffi_bitkitcore_fn_method_usdtwallet_check_recent_execution(void*_Non uint64_t uniffi_bitkitcore_fn_method_usdtwallet_create_payment_proof(void*_Nonnull ptr, RustBuffer transfer_id, RustBuffer binding, RustBuffer mnemonic, RustBuffer passphrase ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_EXPORT_BACKUP +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_EXPORT_BACKUP +RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_export_backup(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#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 @@ -760,6 +799,11 @@ RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_receive_uri(void*_Nonnull ptr, uint64_t uniffi_bitkitcore_fn_method_usdtwallet_refresh_transfers(void*_Nonnull ptr ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_RESTORE_BACKUP +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_RESTORE_BACKUP +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_restore_backup(void*_Nonnull ptr, RustBuffer snapshot +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_SEND #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_SEND uint64_t uniffi_bitkitcore_fn_method_usdtwallet_send(void*_Nonnull ptr, RustBuffer quote_id, RustBuffer mnemonic, RustBuffer passphrase @@ -3532,6 +3576,12 @@ uint16_t uniffi_bitkitcore_checksum_method_urdecoder_receive(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_URDECODER_RESET uint16_t uniffi_bitkitcore_checksum_method_urdecoder_reset(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTBACKUP_PERSIST +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTBACKUP_PERSIST +uint16_t uniffi_bitkitcore_checksum_method_usdtbackup_persist(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTDEPOSITCLIENT_DETAIL @@ -3580,6 +3630,12 @@ uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_check_recent_execution(voi #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_CREATE_PAYMENT_PROOF uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_create_payment_proof(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_EXPORT_BACKUP +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_EXPORT_BACKUP +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_export_backup(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_HISTORY @@ -3616,6 +3672,12 @@ uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_receive_uri(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_REFRESH_TRANSFERS uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfers(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_RESTORE_BACKUP +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_RESTORE_BACKUP +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_restore_backup(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_SEND diff --git a/src/lib.rs b/src/lib.rs index e9bc085..b78220c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -92,7 +92,7 @@ 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, - usdt_validate_recipient, UsdtBridgeProvider, UsdtDeposit, UsdtDepositAddress, + usdt_validate_recipient, UsdtBackup, UsdtBridgeProvider, UsdtDeposit, UsdtDepositAddress, UsdtDepositClient, UsdtDepositDetail, UsdtDepositNetwork, UsdtDepositOrder, UsdtDepositPage, UsdtDestination, UsdtError, UsdtOrchestraTransfer, UsdtPaymentProof, UsdtPaymentProofBinding, UsdtPaymentRequest, UsdtQuote, UsdtTransfer, UsdtTransferStatus, UsdtVerifiedPayment, diff --git a/src/modules/usdt/README.md b/src/modules/usdt/README.md index df4bc8b..f085cd0 100644 --- a/src/modules/usdt/README.md +++ b/src/modules/usdt/README.md @@ -24,6 +24,10 @@ 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. + +`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. + The persisted recovery floor includes the 4096-block history revisit margin below the send-time head, allowing recovery across head retreats within that margin. 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. diff --git a/src/modules/usdt/backup.rs b/src/modules/usdt/backup.rs new file mode 100644 index 0000000..4b2ade0 --- /dev/null +++ b/src/modules/usdt/backup.rs @@ -0,0 +1,266 @@ +use super::{ + history::decode_payment, keys::parse_address, store::Store, transaction::Plan, types::CHAIN_ID, + UsdtError, UsdtTransfer, UsdtTransferStatus, UsdtWallet, +}; +use alloy_primitives::{Address, B256}; +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. +/// 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. +#[uniffi::export(with_foreign)] +#[async_trait::async_trait] +pub trait UsdtBackup: Send + Sync { + async fn persist(&self, snapshot: String) -> Result<(), UsdtError>; +} + +#[derive(Serialize, Deserialize)] +struct Backup { + identity: String, + transfers: Vec, +} + +#[derive(Serialize, Deserialize)] +struct TransferBackup { + transfer: UsdtTransfer, + plan: Option, + block: Option<(u64, B256)>, +} + +#[uniffi::export(async_runtime = "tokio")] +impl UsdtWallet { + /// Portable recovery data without keys, fee quotes or rebuildable history caches. + /// Contains signed operations: store only inside an authenticated, encrypted backup. + pub fn export_backup(&self) -> Result { + self.store.export_backup() + } + + /// Atomically merges recovery data for this account. Existing local outcomes take precedence. + /// Restored signed operations remain pending until chain reconciliation proves their outcome; + /// recovery may resubmit only the original signed payload through the backup callback. + pub async fn restore_backup(&self, snapshot: String) -> Result<(), UsdtError> { + let _guard = self.operation.lock().await; + self.store.restore_backup(&snapshot, self.address) + } +} + +impl Store { + pub fn export_backup(&self) -> Result { + let connection = self.connection()?; + let identity = + connection.query_row("SELECT identity FROM usdt_identity WHERE id=1", [], |row| { + row.get(0) + })?; + let mut statement = connection + .prepare("SELECT data,raw,block_number,block_hash FROM usdt_transfers ORDER BY id")?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) + })?; + let transfers = rows + .map(|row| { + let (data, raw, number, hash) = row?; + let block = match (number, hash) { + (Some(number), Some(hash)) => { + Some((number, hash.parse().map_err(|_| UsdtError::InvalidBackup)?)) + } + (None, None) => None, + _ => return Err(UsdtError::InvalidBackup), + }; + Ok(TransferBackup { + transfer: serde_json::from_str(&data)?, + plan: raw.map(|raw| serde_json::from_str(&raw)).transpose()?, + block, + }) + }) + .collect::>()?; + Ok(serde_json::to_string(&Backup { + identity, + transfers, + })?) + } + + fn restore_backup(&self, snapshot: &str, owner: Address) -> Result<(), UsdtError> { + let 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; + let hash = transfer + .user_operation_hash + .as_deref() + .unwrap_or(&transfer.id); + if transfer.id.is_empty() + || !ids.insert(&transfer.id) + || !hashes.insert(hash) + || super::orchestra::validate_recipient(&transfer.recipient, transfer.destination) + .is_err() + || (transfer.status == UsdtTransferStatus::Pending + || transfer.orchestra.is_some() + && matches!( + transfer.status, + UsdtTransferStatus::Bridging | UsdtTransferStatus::BridgeNeedsAttention + )) + && item.plan.is_none() + { + return Err(UsdtError::InvalidBackup); + } + if let Some(plan) = &item.plan { + let expected = transfer + .user_operation_hash + .as_deref() + .and_then(|hash| hash.parse::().ok()); + let payment = decode_payment(&plan.operation.call_data, owner); + if transfer.is_incoming + || plan.operation.sender != owner + || plan.operation.hash(CHAIN_ID).ok() != expected + || expected.is_none() + || payment.map(|(recipient, amount, destination, _)| { + if let Some(route) = &plan.orchestra { + recipient == parse_address(&route.funding_address).unwrap_or_default() + && amount == route.amount + && amount == transfer.amount + && destination == super::UsdtDestination::Arbitrum + && route.recipient == transfer.recipient + && route.destination == transfer.destination + && transfer.orchestra.as_ref().is_some_and(|bridge| { + bridge.quote_id == route.quote_id + && bridge.funding_address == route.funding_address + }) + && !route.ticket.is_empty() + && route.ticket.len() <= 4096 + } else { + transfer.orchestra.is_none() + && recipient + == parse_address(&transfer.recipient).unwrap_or_default() + && amount == transfer.amount + && destination == transfer.destination + } + }) != Some(true) + { + return Err(UsdtError::InvalidBackup); + } + } + } + let mut connection = self.connection()?; + let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + for mut item in backup.transfers { + if let Some(plan) = item.plan.as_mut() { + plan.refund_block = None; + } + let hash = item + .transfer + .user_operation_hash + .clone() + .unwrap_or_else(|| item.transfer.id.clone()); + let existing: Option = tx + .query_row( + "SELECT hash FROM usdt_transfers WHERE id=?1", + [&item.transfer.id], + |row| row.get(0), + ) + .optional()?; + if let Some(existing) = existing { + if existing != hash { + return Err(UsdtError::InvalidBackup); + } + continue; + } + // Seed recovery can discover a payment before its application ID is restored. + let discovered: Option<(String, String)> = tx + .query_row( + "SELECT id,data FROM usdt_transfers WHERE hash=?1", + [&hash], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((id, data)) = discovered { + if id != hash { + return Err(UsdtError::InvalidBackup); + } + let mut transfer: UsdtTransfer = serde_json::from_str(&data)?; + transfer.id = item.transfer.id; + if item.transfer.orchestra.is_some() { + let bridge = item + .transfer + .orchestra + .as_ref() + .ok_or(UsdtError::InvalidBackup)?; + if transfer.destination != super::UsdtDestination::Arbitrum + || transfer.recipient != bridge.funding_address + || transfer.amount != item.transfer.amount + { + return Err(UsdtError::InvalidBackup); + } + transfer.destination = item.transfer.destination; + transfer.recipient = item.transfer.recipient; + transfer.orchestra = item.transfer.orchestra; + if transfer.status == UsdtTransferStatus::Confirmed { + if let Some(route) = + item.plan.as_ref().and_then(|plan| plan.orchestra.as_ref()) + { + transfer.status = UsdtTransferStatus::Bridging; + transfer.orchestra = Some(route.transfer()); + transfer.received_amount = route.received_amount; + } else { + transfer.status = item.transfer.status; + transfer.received_amount = item.transfer.received_amount; + } + } + } + tx.execute( + "UPDATE usdt_transfers SET id=?1,data=?2,raw=COALESCE(raw,?4) WHERE id=?3", + params![ + transfer.id, + serde_json::to_string(&transfer)?, + id, + item.plan + .map(|plan| serde_json::to_string(&plan)) + .transpose()? + ], + )?; + continue; + } + if let Some(plan) = &item.plan { + item.block = None; + item.transfer.status = UsdtTransferStatus::Pending; + item.transfer.tx_hash = None; + item.transfer.bridge_guid = None; + item.transfer.orchestra = plan.orchestra.as_ref().map(|route| route.transfer()); + item.transfer.fee = None; + item.transfer.received_amount = + if item.transfer.destination == super::UsdtDestination::Arbitrum { + item.transfer.amount + } else { + plan.bridge_received_amount()? + }; + } + tx.execute( + "INSERT INTO usdt_transfers (id,hash,data,raw,block_number,block_hash) VALUES (?1,?2,?3,?4,?5,?6)", + params![ + item.transfer.id, + hash, + serde_json::to_string(&item.transfer)?, + item.plan + .map(|plan| serde_json::to_string(&plan)) + .transpose()?, + item.block.map(|(number, _)| number), + item.block.map(|(_, hash)| hash.to_string()), + ], + )?; + } + tx.commit()?; + Ok(()) + } +} diff --git a/src/modules/usdt/errors.rs b/src/modules/usdt/errors.rs index 9ad6834..94cee1f 100644 --- a/src/modules/usdt/errors.rs +++ b/src/modules/usdt/errors.rs @@ -2,6 +2,10 @@ use thiserror::Error; #[derive(Debug, Error, uniffi::Error)] pub enum UsdtError { + #[error("The payment recovery backup could not be saved. Retry when backup is available")] + BackupUnavailable, + #[error("The USDT recovery backup is invalid or conflicts with local payments")] + InvalidBackup, #[error("The payment proof does not match this request or a successful USDT payment")] InvalidPaymentProof, #[error("Enter a valid USDT amount with at most six decimal places")] diff --git a/src/modules/usdt/history.rs b/src/modules/usdt/history.rs index 78e76cb..963e795 100644 --- a/src/modules/usdt/history.rs +++ b/src/modules/usdt/history.rs @@ -410,7 +410,10 @@ impl UsdtWallet { } } -fn decode_payment(data: &[u8], sender: Address) -> Option<(Address, u64, UsdtDestination, u64)> { +pub(super) fn decode_payment( + data: &[u8], + sender: Address, +) -> Option<(Address, u64, UsdtDestination, u64)> { let mut payment = None; for (target, data) in decode_calls(data).ok()? { let next = if target == TOKEN { diff --git a/src/modules/usdt/mod.rs b/src/modules/usdt/mod.rs index 2a558fc..d3e6c75 100644 --- a/src/modules/usdt/mod.rs +++ b/src/modules/usdt/mod.rs @@ -1,5 +1,6 @@ mod account; mod amount; +mod backup; mod deposits; mod errors; mod history; @@ -16,6 +17,7 @@ mod user_operation; mod wallet; pub use amount::{usdt_format_amount, usdt_parse_amount}; +pub use backup::UsdtBackup; pub use deposits::*; pub use errors::UsdtError; pub use keys::usdt_address; diff --git a/src/modules/usdt/store.rs b/src/modules/usdt/store.rs index 2a1fd06..4e5247e 100644 --- a/src/modules/usdt/store.rs +++ b/src/modules/usdt/store.rs @@ -52,7 +52,7 @@ impl Store { Ok(Self(Mutex::new(connection))) } - fn connection(&self) -> Result, UsdtError> { + pub(super) fn connection(&self) -> Result, UsdtError> { self.0.lock().map_err(|_| UsdtError::Storage { reason: "USDT storage lock unavailable".into(), }) diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs index d499a46..5b1e231 100644 --- a/src/modules/usdt/tests.rs +++ b/src/modules/usdt/tests.rs @@ -1,3 +1,4 @@ +mod backup; use super::*; #[test] @@ -105,6 +106,7 @@ fn wallet_requires_both_provider_endpoints() { rpc.into(), bundler.into(), None, + std::sync::Arc::new(TestBackup), ), Err(UsdtError::NotConfigured) )); @@ -135,6 +137,7 @@ fn payment_request_round_trips_receive_uri_and_rejects_wrong_asset_or_network() "https://provider.example".into(), "https://bundler.example".into(), None, + std::sync::Arc::new(TestBackup), ) .unwrap(); let request = usdt_parse_payment_request(wallet.receive_uri()).unwrap(); @@ -220,6 +223,15 @@ fn payment_requests_preserve_exact_token_amounts_and_reject_ambiguous_terms() { const TEST_PHRASE: &str = "test test test test test test test test test test test junk"; const RECIPIENT: &str = "0x1111111111111111111111111111111111111111"; +struct TestBackup; + +#[async_trait::async_trait] +impl UsdtBackup for TestBackup { + async fn persist(&self, _snapshot: String) -> Result<(), UsdtError> { + Ok(()) + } +} + #[tokio::test] async fn payment_proof_binds_execution_to_request_and_receiver() { use alloy_primitives::{B256, U256}; @@ -238,6 +250,7 @@ async fn payment_proof_binds_execution_to_request_and_receiver() { format!("{}/chain", chain.url), format!("{}/bundler", chain.url), None, + std::sync::Arc::new(TestBackup), ) .unwrap(); let binding = UsdtPaymentProofBinding { @@ -660,6 +673,7 @@ impl MockChain { format!("{}/chain", self.url), format!("{}/bundler", self.url), enabled.then(|| format!("{}/bridges", self.url)), + std::sync::Arc::new(TestBackup), ) .unwrap() } @@ -1576,6 +1590,7 @@ async fn deployed_contracts_collect_usdt_fees_and_revert_failed_bridges_atomical 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()), None, + std::sync::Arc::new(TestBackup), ) .unwrap(); assert_eq!(rpc.balance(wallet.address).await.unwrap(), U256::ZERO); @@ -2255,7 +2270,13 @@ async fn stalled_bridge_status_checks_leave_time_for_source_recovery_and_sending } wallet .store - .save_history_receipt(&bridges, "bridges", 1000, "block", true) + .save_history_receipt( + &bridges, + "bridges", + 1000, + &chain.state.lock().unwrap().block_hash(1000).to_string(), + true, + ) .unwrap(); // A signed operation with no nonce consumption must still resolve once expired. let quote = wallet diff --git a/src/modules/usdt/tests/backup.rs b/src/modules/usdt/tests/backup.rs new file mode 100644 index 0000000..0daa4d3 --- /dev/null +++ b/src/modules/usdt/tests/backup.rs @@ -0,0 +1,287 @@ +use super::*; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; + +#[derive(Default)] +struct RemoteBackup { + unavailable: AtomicBool, + snapshot: Mutex>, +} + +#[async_trait::async_trait] +impl UsdtBackup for RemoteBackup { + async fn persist(&self, snapshot: String) -> Result<(), UsdtError> { + if self.unavailable.load(Ordering::SeqCst) { + return Err(UsdtError::BackupUnavailable); + } + *self.snapshot.lock().unwrap() = Some(snapshot); + Ok(()) + } +} + +fn backed_up_wallet( + chain: &MockChain, + dir: &tempfile::TempDir, + backup: Arc, +) -> Arc { + UsdtWallet::new( + usdt_address(TEST_PHRASE.into(), None).unwrap(), + dir.path().join("usdt.sqlite").to_string_lossy().into(), + format!("{}/chain", chain.url), + format!("{}/bundler", chain.url), + None, + backup, + ) + .unwrap() +} + +#[tokio::test] +async fn submission_and_recovery_wait_for_remote_backup() { + let chain = MockChain::start().await; + let directory = tempfile::tempdir().unwrap(); + let backup = Arc::new(RemoteBackup::default()); + backup.unavailable.store(true, Ordering::SeqCst); + let wallet = backed_up_wallet(&chain, &directory, backup.clone()); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Arbitrum) + .await + .unwrap(); + assert!(matches!( + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::BackupUnavailable) + )); + assert_eq!(wallet.history().unwrap()[0].id, quote.id); + assert!(chain.state.lock().unwrap().operations.is_empty()); + chain.state.lock().unwrap().tip += 3; + assert!(matches!( + wallet.refresh_transfers().await, + Err(UsdtError::BackupUnavailable) + )); + assert!(chain.state.lock().unwrap().operations.is_empty()); + backup.unavailable.store(false, Ordering::SeqCst); + wallet.refresh_transfers().await.unwrap(); + 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(); + drop(wallet); + let restored_directory = tempfile::tempdir().unwrap(); + let restored = backed_up_wallet(&chain, &restored_directory, backup); + restored.restore_backup(snapshot.clone()).await.unwrap(); + restored.restore_backup(snapshot).await.unwrap(); + assert_eq!(restored.history().unwrap().len(), 1); + assert!(matches!( + restored + .quote_transfer(RECIPIENT.into(), 1, UsdtDestination::Arbitrum) + .await, + Err(UsdtError::PendingTransfer) + )); + restored.refresh_transfers().await.unwrap(); + assert_eq!( + serde_json::to_value(&chain.state.lock().unwrap().operations[1]).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); +} + +#[tokio::test] +async fn restore_is_atomic_and_rejects_other_accounts_and_conflicting_payments() { + 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 target_directory = tempfile::tempdir().unwrap(); + let target = chain.wallet(&target_directory); + let mut invalid: serde_json::Value = serde_json::from_str(&snapshot).unwrap(); + invalid["identity"] = serde_json::json!("42161:another-wallet"); + assert!(matches!( + target.restore_backup(invalid.to_string()).await, + Err(UsdtError::InvalidCredentials) + )); + invalid = serde_json::from_str(&snapshot).unwrap(); + invalid["transfers"][0]["transfer"]["amount"] = serde_json::json!(2); + assert!(matches!( + target.restore_backup(invalid.to_string()).await, + Err(UsdtError::InvalidBackup) + )); + assert!(target.history().unwrap().is_empty()); + target.restore_backup(snapshot.clone()).await.unwrap(); + let mut conflict: serde_json::Value = serde_json::from_str(&snapshot).unwrap(); + let mut extra = conflict["transfers"][0].clone(); + extra["transfer"]["id"] = serde_json::json!("another-id"); + conflict["transfers"] + .as_array_mut() + .unwrap() + .insert(0, extra); + assert!(matches!( + target.restore_backup(conflict.to_string()).await, + Err(UsdtError::InvalidBackup) + )); + assert_eq!(target.export_backup().unwrap(), snapshot); +} + +#[tokio::test] +async fn stale_backups_preserve_newer_outcomes_and_recover_application_ids() { + 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.clone(), TEST_PHRASE.into(), None) + .await + .unwrap(); + let pending_backup = wallet.export_backup().unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + } + wallet.refresh_transfers().await.unwrap(); + let settled_backup = wallet.export_backup().unwrap(); + wallet.restore_backup(pending_backup.clone()).await.unwrap(); + assert_eq!( + wallet.history().unwrap()[0].status, + UsdtTransferStatus::Confirmed + ); + let recovered_directory = tempfile::tempdir().unwrap(); + let recovered = chain.wallet(&recovered_directory); + recovered.sync_history().await.unwrap(); + recovered.restore_backup(pending_backup).await.unwrap(); + let history = recovered.history().unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].id, quote.id); + assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); + let empty_directory = tempfile::tempdir().unwrap(); + let empty = chain.wallet(&empty_directory); + empty.restore_backup(settled_backup).await.unwrap(); + assert_eq!( + empty.history().unwrap()[0].status, + UsdtTransferStatus::Pending + ); + { + let mut state = chain.state.lock().unwrap(); + state.tip += history::HISTORY_REVISIT_BLOCKS + 1; + state.hide_logs = true; + } + sync_history_to_tip(&empty).await; + chain.state.lock().unwrap().hide_logs = false; + assert_eq!( + empty.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Confirmed + ); +} + +#[tokio::test] +async fn restored_receipts_reconcile_with_the_canonical_chain() { + use alloy_primitives::B256; + 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 mut state = chain.state.lock().unwrap(); + state.incoming_count = 1; + state.tip += 3; + } + let history_directory = tempfile::tempdir().unwrap(); + let history_wallet = chain.wallet(&history_directory); + sync_history_to_tip(&history_wallet).await; + let history = history_wallet.history().unwrap(); + assert_eq!(history.len(), 1); + assert!(history[0].is_incoming); + assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); + let snapshot = history_wallet.export_backup().unwrap(); + let restored_directory = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restored_directory); + restored.restore_backup(snapshot).await.unwrap(); + chain.state.lock().unwrap().incoming_count = 0; + sync_history_to_tip(&restored).await; + assert_eq!(restored.history().unwrap()[0].id, history[0].id); + { + let mut state = chain.state.lock().unwrap(); + state.block_hashes.insert(20001, B256::repeat_byte(0xaa)); + } + sync_history_to_tip(&restored).await; + assert!(restored.history().unwrap().is_empty()); +} + +#[tokio::test] +async fn orchestra_backup_restores_funded_delivery_after_chain_history() { + let chain = MockChain::start().await; + let source = tempfile::tempdir().unwrap(); + let wallet = chain.wallet_with_bridges(&source, true); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000, UsdtDestination::Base) + .await + .unwrap(); + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + } + wallet.refresh_transfers().await.unwrap(); + let mut refunded = wallet.store.transfer("e.id).unwrap().unwrap(); + refunded.status = UsdtTransferStatus::BridgeRefunded; + refunded.received_amount = 0; + let bridge = refunded.orchestra.as_mut().unwrap(); + bridge.refund_tx = Some(alloy_primitives::B256::repeat_byte(9).to_string()); + bridge.refund_amount = Some(900_000); + wallet + .store + .update_delivery( + &refunded, + Some((20001, alloy_primitives::B256::repeat_byte(8))), + ) + .unwrap(); + let snapshot = wallet.export_backup().unwrap(); + let target = tempfile::tempdir().unwrap(); + let restored = chain.wallet_with_bridges(&target, true); + sync_history_to_tip(&restored).await; + restored.restore_backup(snapshot.clone()).await.unwrap(); + restored.restore_backup(snapshot).await.unwrap(); + let payment = restored + .history() + .unwrap() + .into_iter() + .find(|transfer| transfer.id == quote.id) + .unwrap(); + assert_eq!(payment.destination, UsdtDestination::Base); + assert_eq!(payment.recipient, RECIPIENT); + assert_eq!(payment.status, UsdtTransferStatus::Bridging); + assert_eq!(payment.received_amount, quote.received_amount); + assert!(payment.orchestra.unwrap().refund_tx.is_none()); + assert_eq!( + restored.store.orchestra_plan("e.id).unwrap().ticket, + "quote-ticket" + ); + restored.refresh_transfers().await.unwrap(); + assert_eq!(chain.state.lock().unwrap().operations.len(), 1); +} diff --git a/src/modules/usdt/wallet.rs b/src/modules/usdt/wallet.rs index a90933e..37f9f61 100644 --- a/src/modules/usdt/wallet.rs +++ b/src/modules/usdt/wallet.rs @@ -37,6 +37,7 @@ pub struct UsdtWallet { pub(super) rpc: Rpc, pub(super) paymaster: Pimlico, pub(super) store: Store, + backup: Arc, orchestra: Option, pub(super) operation: Mutex<()>, bridge_poll_offset: AtomicUsize, @@ -54,6 +55,7 @@ impl UsdtWallet { rpc_url: String, bundler_url: String, bridge_url: Option, + backup: Arc, ) -> Result, UsdtError> { if rpc_url.is_empty() || bundler_url.is_empty() { return Err(UsdtError::NotConfigured); @@ -69,6 +71,7 @@ impl UsdtWallet { rpc, paymaster, store, + backup, orchestra: bridge_url .map(super::orchestra::Orchestra::new) .transpose()?, @@ -231,6 +234,7 @@ impl UsdtWallet { timestamp: now(), }; self.store.record_signed(&transfer, &raw)?; + self.backup.persist(self.export_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. @@ -470,6 +474,7 @@ impl UsdtWallet { &format!("{:#x}", block.hash), )?; } else if self.validate_bridge(plan).await.is_ok() { + self.backup.persist(self.export_backup()?).await?; let _ = self.broadcast(plan, hash).await; } return Ok(()); From 6aab8e5d29998ccaee6302fbb28640b6bfc8ff3c Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 7 Oct 2026 16:37:04 -0500 Subject: [PATCH 2/2] test(usdt): exercise atomic restore rollback after a write --- src/modules/usdt/tests/backup.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/modules/usdt/tests/backup.rs b/src/modules/usdt/tests/backup.rs index 0daa4d3..a9f71e5 100644 --- a/src/modules/usdt/tests/backup.rs +++ b/src/modules/usdt/tests/backup.rs @@ -121,12 +121,23 @@ async fn restore_is_atomic_and_rejects_other_accounts_and_conflicting_payments() assert!(target.history().unwrap().is_empty()); target.restore_backup(snapshot.clone()).await.unwrap(); let mut conflict: serde_json::Value = serde_json::from_str(&snapshot).unwrap(); + conflict["transfers"][0]["plan"] = serde_json::Value::Null; + conflict["transfers"][0]["transfer"]["status"] = serde_json::json!("Failed"); + conflict["transfers"][0]["transfer"]["user_operation_hash"] = + serde_json::json!(alloy_primitives::B256::repeat_byte(1).to_string()); let mut extra = conflict["transfers"][0].clone(); extra["transfer"]["id"] = serde_json::json!("another-id"); + extra["transfer"]["user_operation_hash"] = + serde_json::json!(alloy_primitives::B256::repeat_byte(2).to_string()); + // Insert a distinct payment before the later ID conflict must roll it back. conflict["transfers"] .as_array_mut() .unwrap() .insert(0, extra); + let empty_directory = tempfile::tempdir().unwrap(); + let empty = chain.wallet(&empty_directory); + empty.restore_backup(conflict.to_string()).await.unwrap(); + assert_eq!(empty.history().unwrap().len(), 2); assert!(matches!( target.restore_backup(conflict.to_string()).await, Err(UsdtError::InvalidBackup)