From da58f178db5d701f4ee798dda8c647115406a26a Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Tue, 6 Oct 2026 13:38:25 +0200 Subject: [PATCH 1/4] feat(truapi)!: follow payment requests by id and hand them to the host --- .changeset/payment-request.md | 5 + docs/rfcs/0006-payments.md | 28 +- .../tests/golden/host-callbacks.ts | 33 ++ .../truapi-codegen/tests/golden/wire_table.rs | 2 +- rust/crates/truapi/RUNTIME.md | 6 + rust/crates/truapi/src/api/payment.rs | 17 +- rust/crates/truapi/src/host_core.rs | 18 +- rust/crates/truapi/src/lib.rs | 10 + rust/crates/truapi/src/native.rs | 2 +- rust/crates/truapi/src/native/callbacks.rs | 29 ++ rust/crates/truapi/src/native/platform.rs | 335 +++++++++++++----- rust/crates/truapi/src/native/runtime.rs | 28 +- rust/crates/truapi/src/platform.rs | 29 +- rust/crates/truapi/src/runtime.rs | 1 - .../src/runtime/capabilities/payment.rs | 52 ++- rust/crates/truapi/src/runtime/services.rs | 14 + rust/crates/truapi/src/runtime/tests.rs | 142 ++++++++ rust/crates/truapi/src/v01/payment.rs | 49 ++- rust/crates/truapi/src/versioned/payment.rs | 2 +- rust/crates/truapi/tests/wire_result_shape.rs | 107 +++--- 20 files changed, 704 insertions(+), 205 deletions(-) create mode 100644 .changeset/payment-request.md diff --git a/.changeset/payment-request.md b/.changeset/payment-request.md new file mode 100644 index 0000000000..e9d94b5d08 --- /dev/null +++ b/.changeset/payment-request.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": major +--- + +`payment.request` takes a caller-chosen 32-byte `id` and answers with nothing, adding `AlreadyExists`; `payment.statusSubscribe` follows that `id` and reports `PartiallyClaimed`. Hosts serve both through `PaymentPlatform` (`set_payment_callbacks` and `notify_payment_status` natively); without one they answer `Unsupported`. diff --git a/docs/rfcs/0006-payments.md b/docs/rfcs/0006-payments.md index 6ba47af668..2daed87da5 100644 --- a/docs/rfcs/0006-payments.md +++ b/docs/rfcs/0006-payments.md @@ -118,23 +118,23 @@ enum PaymentTopUpStatusErr { #### 3. Request Payment -Request a payment from the user's available balance to a destination account. The host should prompt the user to authorize the payment. Returns a `PaymentId` for tracking. +Request a payment from the user's available balance to a destination account. The host should prompt the user to authorize the payment. The caller chooses the `PaymentId` the payment is followed by. ```rust fn host_payment_request( amount: Balance, - destination: AccountId -) -> Result - -type PaymentId = str; - -struct PaymentReceipt { + destination: AccountId, id: PaymentId -} +) -> Result<(), PaymentRequestErr> + +/// Caller-chosen, 32 bytes. Reusing one is refused, which makes a retry safe. +type PaymentId = [u8; 32]; enum PaymentRequestErr { + /// A payment with this id already exists + AlreadyExists, /// User denied the payment request - Denied, + Rejected, /// User's available balance is not sufficient for the requested amount InsufficientBalance, Unknown(GenericErr) @@ -145,11 +145,11 @@ A successful response means the user has authorized the payment and the host has #### 4. Payment Status Subscription -Subscribe to status updates for a previously requested payment. The subscription emits status changes until the payment reaches a terminal state (`Completed` or `Failed`). +Subscribe to status updates for a previously requested payment. The subscription emits status changes until the payment reaches a terminal state (`Completed`, `Failed` or `PartiallyClaimed`), which stays readable after the payment ends. ```rust fn host_payment_status_subscribe( - payment_id: PaymentId, + id: PaymentId, callback: fn(PaymentStatus) ) -> Result @@ -159,7 +159,9 @@ enum PaymentStatus { /// Payment has been settled successfully Completed, /// Payment has failed - Failed(str) + Failed(str), + /// Only this amount reached the destination, less than requested + PartiallyClaimed(Balance) } enum PaymentStatusErr { @@ -177,7 +179,7 @@ enum PaymentStatusErr { 3. **Payment ID scoping**: A `PaymentId` is scoped to the product that created it. A product cannot query or subscribe to payment status for another product's payments. -4. **Terminal status delivery**: Once a payment reaches `Completed` or `Failed`, the host must deliver that status to any active subscriber and may then close the subscription. The host should make a best effort to deliver terminal status even across session restarts. +4. **Terminal status delivery**: Once a payment reaches `Completed`, `Failed` or `PartiallyClaimed`, the host must deliver that status to any active subscriber and may then close the subscription. The host should make a best effort to deliver terminal status even across session restarts. 5. **Top-up idempotency**: If the same top-up is submitted multiple times (e.g. due to a retry), the host should ensure funds are only transferred once where possible. However, this is a best-effort guarantee -- products should implement their own idempotency checks for critical flows. diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 3acabf25b6..da3a02c047 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -39,6 +39,9 @@ import type { HostFundingStatusSubscribeItem, HostLocalStorageChangeItem, HostLocaleSubscribeItem, + HostPaymentRequest, + HostPaymentStatusSubscribeError, + HostPaymentStatusSubscribeItem, HostPaymentTopUpRequest, HostPaymentTopUpStatusSubscribeError, HostPaymentTopUpStatusSubscribeItem, @@ -1559,6 +1562,36 @@ export interface PairingHostAdmin { notifySessionStoreChanged(): void; } +/** + * Host-implemented payment engine: pays from the user's balance to an + * account, once the user approves. Optional: a host that omits it leaves + * payment requests answered `Unsupported`. + * + * The host owns the approval sheet, the transfer and its persistence, and + * scopes ids to `product`. + */ +export interface PaymentPlatform { + /** + * Ask the user to approve `request`. Returns once the host has accepted + * it; the payment's outcome arrives through its status. + */ + requestPayment( + product: ProductContext, + request: HostPaymentRequest, + ): Promise; + + /** + * Emit a payment's current status and every later one, ending after a + * terminal status. + */ + subscribePaymentStatus( + product: ProductContext, + id: Uint8Array, + ): AsyncIterable< + Result + >; +} + /** * Live OS permission state, read without prompting. * diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 8cb0b6206e..5c2d8fdc66 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -42,7 +42,7 @@ pub enum WireKind { /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so /// the debugger refuses to decode a frame whose contract differs from /// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "e9f7964f909918cf"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "f2ebcea64f5e953e"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: MethodIds = MethodIds { diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 203250cb5a..61b6e6e836 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -464,6 +464,12 @@ AutoSigning without approval. Legacy-account signing still asks the user. the new stage by subscribing again. `funding_account_secret` exports an account's raw seed, as getcash does, for a user to take funds back with a wallet. +- `PaymentPlatform`: pay from the user's balance to an account once the user + approves, and stream each payment's status by its caller-chosen id. + Installed with `set_payment_platform`; native hosts use + `set_payment_callbacks` with `notify_payment_status`. The core requires a + session and refuses the funding product. Without it, `request` and + `statusSubscribe` answer `Unsupported`. - `TopUpPlatform`: claim a top-up source's funds into the user's balance and stream each top-up's status. Installed with `set_top_up_platform`. The core requires a session and checks the source keys; the host owns claiming, diff --git a/rust/crates/truapi/src/api/payment.rs b/rust/crates/truapi/src/api/payment.rs index 10cf391fa8..978b3280c9 100644 --- a/rust/crates/truapi/src/api/payment.rs +++ b/rust/crates/truapi/src/api/payment.rs @@ -35,7 +35,8 @@ pub trait Payment: Send + Sync { Subscription::interrupted(CallError::unavailable()) } - /// Request a payment from the user. + /// Request a payment from the user, followed by `id` through + /// `statusSubscribe`. Returns once the host has accepted it. /// /// ```ts /// // Fund the balance first so the request is not rejected for lack of funds. @@ -50,9 +51,10 @@ pub trait Payment: Send + Sync { /// amount: 1000n, /// destination: /// "0x0000000000000000000000000000000000000000000000000000000000000000", + /// id: "0x0000000000000000000000000000000000000000000000000000000000000004", /// }); /// assert(result.isOk(), "request failed:", result); - /// console.log("payment requested:", result.value); + /// console.log("payment requested"); /// ``` #[wire(id = 2)] async fn request( @@ -63,7 +65,8 @@ pub trait Payment: Send + Sync { Err(CallError::unavailable()) } - /// Subscribe to payment lifecycle updates for a specific payment. + /// Subscribe to payment lifecycle updates for a specific payment. Emits + /// the current status first, so a caller that reloads re-attaches. /// /// ```ts /// import { firstValueFrom, from } from "rxjs"; @@ -76,19 +79,17 @@ pub trait Payment: Send + Sync { /// }); /// assert(topUp.isOk(), "topUp failed:", topUp); /// + /// const id = "0x0000000000000000000000000000000000000000000000000000000000000005"; /// const requested = await truapi.payment.request({ /// amount: 1000n, /// destination: /// "0x0000000000000000000000000000000000000000000000000000000000000000", + /// id, /// }); /// assert(requested.isOk(), "request failed:", requested); /// /// const status = await firstValueFrom( - /// from( - /// truapi.payment.statusSubscribe({ - /// request: { paymentId: requested.value.id }, - /// }), - /// ), + /// from(truapi.payment.statusSubscribe({ request: { id } })), /// ); /// console.log("payment status received:", status); /// ``` diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index 60a8527d92..5474a72e2d 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -16,7 +16,7 @@ use std::time::Duration; use crate::platform::{ ChatPlatform, ContactsPlatform, FundingPlatform, PermissionStatusHost, PocketPlatform, - TopUpPlatform, + PaymentPlatform, TopUpPlatform, }; use crate::platform::{ CoreAdmin, PairingHostAdmin, PairingHostConfig, PermissionAuthorizationRequest, @@ -275,6 +275,14 @@ impl PairingHostRuntime { self.services.install_top_up_platform(platform) } + /// Install the host's [`PaymentPlatform`], which pays from the user's + /// balance once the user approves. Set-once; returns whether this call + /// installed it. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.set_payment_platform"))] + pub fn set_payment_platform(&self, platform: Arc) -> bool { + self.services.install_payment_platform(platform) + } + /// Install the host's [`FundingPlatform`], the native funding overlay. /// /// Set-once. Returns whether this call installed it. Call it before @@ -797,6 +805,14 @@ impl SigningHostRuntime { self.services.install_top_up_platform(platform) } + /// Install the host's [`PaymentPlatform`], which pays from the user's + /// balance once the user approves. Set-once; returns whether this call + /// installed it. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.set_payment_platform"))] + pub fn set_payment_platform(&self, platform: Arc) -> bool { + self.services.install_payment_platform(platform) + } + /// Install the host's [`FundingPlatform`], the native funding overlay. /// /// Set-once. Returns whether this call installed it. Call it before diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 7453035e13..977d21d7d0 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -213,6 +213,16 @@ pub mod latest { /// Failure watching a funding session. pub type HostFundingStatusSubscribeError = LatestOf; + /// Payment request. + pub type HostPaymentRequest = LatestOf; + /// Payment request failure. + pub type HostPaymentError = LatestOf; + /// Progress of a payment. + pub type HostPaymentStatusSubscribeItem = + LatestOf; + /// Failure following a payment. + pub type HostPaymentStatusSubscribeError = + LatestOf; /// Payment top-up request. pub type HostPaymentTopUpRequest = LatestOf; /// Payment top-up failure. diff --git a/rust/crates/truapi/src/native.rs b/rust/crates/truapi/src/native.rs index 3ee265faf8..1cc16da79c 100644 --- a/rust/crates/truapi/src/native.rs +++ b/rust/crates/truapi/src/native.rs @@ -30,7 +30,7 @@ pub use crate::host_internal::sso_messages::SsoRequestOutcome; pub use crate::host_logic::dotns::{NavigateDecision, PocketDeeplinkAction}; pub use callbacks::{ HostCallbacks, NativeChatCallbacks, NativeContactsCallbacks, NativeFundingCallbacks, - NativePocketCallbacks, NativePocketRemoval, NativeTopUpCallbacks, + NativePaymentCallbacks, NativePocketCallbacks, NativePocketRemoval, NativeTopUpCallbacks, }; pub use config::{HostRuntimeConfig, NativeRuntimeConfigError, ProductExecutionConfig}; pub use errors::{HostRejection, NativeCoreDatabaseError}; diff --git a/rust/crates/truapi/src/native/callbacks.rs b/rust/crates/truapi/src/native/callbacks.rs index 8191b075a6..21258f4ac5 100644 --- a/rust/crates/truapi/src/native/callbacks.rs +++ b/rust/crates/truapi/src/native/callbacks.rs @@ -332,6 +332,35 @@ pub trait NativeFundingCallbacks: Send + Sync { fn funding_session_changed(&self, intent: String, status: v01::HostFundingStatusSubscribeItem); } +/// Native payment engine, which pays from the user's balance to an account. +/// A host passes an implementation to +/// [`NativeTrUApiHostRuntime::set_payment_callbacks`] and reports each later +/// status with [`NativeTrUApiHostRuntime::notify_payment_status`]. +/// +/// [`NativeTrUApiHostRuntime::set_payment_callbacks`]: super::NativeTrUApiHostRuntime::set_payment_callbacks +/// [`NativeTrUApiHostRuntime::notify_payment_status`]: super::NativeTrUApiHostRuntime::notify_payment_status +#[uniffi::export(rust, foreign)] +#[async_trait::async_trait] +pub trait NativePaymentCallbacks: Send + Sync { + /// Ask the user to approve payment `request` for `product_id`, returning + /// once the user has decided: `Ok` when they authorized it and the host + /// took it on. Ids are scoped to `product_id`. Its amount is a decimal + /// string of CASH units. + async fn request_payment( + &self, + product_id: String, + request: v01::HostPaymentRequest, + ) -> Result<(), v01::HostPaymentError>; + + /// The current status of `product_id`'s payment `id`, or `None` when the + /// host holds no such payment. + fn payment_status( + &self, + product_id: String, + id: crate::Bytes32, + ) -> Result, HostRejection>; +} + /// Native top-up engine, which claims a source's funds into the user's /// balance. A host passes an implementation to /// [`NativeTrUApiHostRuntime::set_top_up_callbacks`] and reports each later diff --git a/rust/crates/truapi/src/native/platform.rs b/rust/crates/truapi/src/native/platform.rs index d1f258e99c..962d79187d 100644 --- a/rust/crates/truapi/src/native/platform.rs +++ b/rust/crates/truapi/src/native/platform.rs @@ -17,7 +17,7 @@ use crate::{DevicePairingObserver, PairedSsoPeer}; use super::callbacks::{ HostCallbacks, NativeChatCallbacks, NativeContactsCallbacks, NativeFundingCallbacks, - NativePocketCallbacks, NativePocketRemoval, NativeTopUpCallbacks, + NativePaymentCallbacks, NativePocketCallbacks, NativePocketRemoval, NativeTopUpCallbacks, }; use super::errors::HostRejection; use super::events::NativeEventBus; @@ -663,39 +663,142 @@ impl crate::platform::FundingPlatform for FundingCallbackPlatform { } } -type TopUpStatus = - Result; - -/// One subscription to a top-up's status. -struct TopUpFollower { - /// Tells this subscription apart from others on the same top-up. +/// One subscription to a host-pushed status. +struct Follower { + /// Tells this subscription apart from others on the same id. subscription: u64, - sender: mpsc::UnboundedSender, + sender: mpsc::UnboundedSender, } -/// Who follows which top-up, per product and id. -#[derive(Default)] -struct TopUpFollowers { +/// Who follows which status, per product and id. +struct Followers { next: u64, - by_top_up: std::collections::HashMap<(String, crate::Bytes32), Vec>, + by_id: std::collections::HashMap<(String, crate::Bytes32), Vec>>, +} + +impl Default for Followers { + fn default() -> Self { + Self { + next: 0, + by_id: std::collections::HashMap::new(), + } + } } -impl TopUpFollowers { +impl Followers { /// Forget followers whose streams were dropped. fn prune(&mut self) { - self.by_top_up.retain(|_, senders| { + self.by_id.retain(|_, senders| { senders.retain(|follower| !follower.sender.is_closed()); !senders.is_empty() }); } } +/// Rank of a terminal status. +const TERMINAL_RANK: u8 = 3; + +/// Statuses a host answers once and pushes after, relayed to every stream +/// following an id: each starts at the host's current status and shows every +/// later one once, never stepping back, and ends at a terminal one. +struct StatusRelay { + followers: Mutex>>, + /// How far along a status is; [`TERMINAL_RANK`] ends a stream. + rank: fn(&T) -> u8, +} + +impl StatusRelay { + fn new(rank: fn(&T) -> u8) -> Self { + Self { + followers: Mutex::new(Followers::default()), + rank, + } + } + + /// Deliver a later `status` of `product_id`'s `id` to everyone following + /// it; a terminal one ends their streams. + fn notify(&self, product_id: String, id: crate::Bytes32, status: T) { + let terminal = (self.rank)(&status) == TERMINAL_RANK; + let mut followers = self.lock_followers(); + let key = (product_id, id); + if let Some(senders) = followers.by_id.get_mut(&key) { + for follower in senders.iter() { + let _ = follower.sender.unbounded_send(Ok(status.clone())); + } + } + if terminal { + followers.by_id.remove(&key); + } + followers.prune(); + } + + /// Follow `product_id`'s `id` from `current`, the host's answer for it + /// now; an error ends the stream there. + fn follow( + &self, + product_id: String, + id: crate::Bytes32, + current: impl FnOnce() -> Result, + ) -> BoxStream<'static, Result> { + let key = (product_id, id); + // Registered before the current status is read, so a status the host + // pushes in between is not lost; the stream below drops it if it + // repeats or precedes the current one. + let (sender, changes) = mpsc::unbounded(); + let follower = { + let mut followers = self.lock_followers(); + followers.prune(); + followers.next += 1; + let follower = followers.next; + followers.by_id.entry(key.clone()).or_default().push(Follower { + subscription: follower, + sender, + }); + follower + }; + let rank = self.rank; + let first = current(); + let last_rank = first.as_ref().map_or(TERMINAL_RANK, rank); + if last_rank == TERMINAL_RANK { + let mut followers = self.lock_followers(); + if let Some(senders) = followers.by_id.get_mut(&key) { + senders.retain(|registered| registered.subscription != follower); + } + followers.prune(); + return stream::iter([first]).boxed(); + } + let later = changes.scan(last_rank, move |shown, status: Result| { + let next = status.as_ref().map_or(TERMINAL_RANK, rank); + let fresh = next > *shown || (next == *shown && next == TERMINAL_RANK); + if fresh { + *shown = next; + } + futures::future::ready(Some(fresh.then_some(status))) + }); + stream::iter([first]) + .chain(later.filter_map(futures::future::ready)) + .boxed() + } + + /// How many ids are followed. + #[cfg(test)] + fn followed(&self) -> usize { + self.lock_followers().by_id.len() + } + + fn lock_followers(&self) -> std::sync::MutexGuard<'_, Followers>> { + self.followers + .lock() + .expect("status followers mutex poisoned") + } +} + /// [`crate::platform::TopUpPlatform`] served by host-provided /// [`NativeTopUpCallbacks`]: the host answers a top-up's current status, and /// pushes each later one through [`Self::notify_status`]. pub struct TopUpCallbackPlatform { top_up: Arc, - followers: Mutex, + statuses: StatusRelay, } impl TopUpCallbackPlatform { @@ -703,7 +806,7 @@ impl TopUpCallbackPlatform { pub fn new(top_up: Arc) -> Self { Self { top_up, - followers: Mutex::new(TopUpFollowers::default()), + statuses: StatusRelay::new(top_up_rank), } } @@ -715,31 +818,10 @@ impl TopUpCallbackPlatform { id: crate::Bytes32, status: v01::HostPaymentTopUpStatusSubscribeItem, ) { - let terminal = top_up_rank(&status) == TERMINAL_RANK; - let mut followers = self.lock_followers(); - let key = (product_id, id); - if let Some(senders) = followers.by_top_up.get_mut(&key) { - for follower in senders.iter() { - let _ = follower.sender.unbounded_send(Ok(status.clone())); - } - } - if terminal { - followers.by_top_up.remove(&key); - } - followers.prune(); - } - - fn lock_followers(&self) -> std::sync::MutexGuard<'_, TopUpFollowers> { - self.followers - .lock() - .expect("top-up followers mutex poisoned") + self.statuses.notify(product_id, id, status); } } -/// Rank of a terminal top-up status: claimed and final, partly claimed, or -/// not claimed. -const TERMINAL_RANK: u8 = 3; - /// How far along a top-up `status` is, so a stream never steps back. fn top_up_rank(status: &v01::HostPaymentTopUpStatusSubscribeItem) -> u8 { match status { @@ -766,62 +848,87 @@ impl crate::platform::TopUpPlatform for TopUpCallbackPlatform { &self, product: &ProductContext, id: crate::Bytes32, - ) -> BoxStream<'static, TopUpStatus> { - let key = (product.product_id.clone(), id); - // Registered before the snapshot is read, so a status the host - // pushes in between is not lost; the stream below drops it if it - // repeats or precedes the snapshot. - let (sender, changes) = mpsc::unbounded(); - let follower = { - let mut followers = self.lock_followers(); - followers.prune(); - followers.next += 1; - let follower = followers.next; - followers - .by_top_up - .entry(key.clone()) - .or_default() - .push(TopUpFollower { - subscription: follower, - sender, - }); - follower - }; - let first = match self.top_up.top_up_status(key.0.clone(), id) { - Ok(Some(status)) => Ok(status), - Ok(None) => Err(v01::HostPaymentTopUpStatusSubscribeError::NotFound), - Err(error) => Err(v01::HostPaymentTopUpStatusSubscribeError::Unknown { - reason: error.to_string(), - }), - }; - let last_rank = match &first { - Ok(status) => top_up_rank(status), - Err(_) => TERMINAL_RANK, - }; - if last_rank == TERMINAL_RANK { - let mut followers = self.lock_followers(); - if let Some(senders) = followers.by_top_up.get_mut(&key) { - senders.retain(|registered| registered.subscription != follower); + ) -> BoxStream<'static, Result> + { + let product_id = product.product_id.clone(); + self.statuses.follow(product_id.clone(), id, || { + match self.top_up.top_up_status(product_id, id) { + Ok(Some(status)) => Ok(status), + Ok(None) => Err(v01::HostPaymentTopUpStatusSubscribeError::NotFound), + Err(error) => Err(v01::HostPaymentTopUpStatusSubscribeError::Unknown { + reason: error.to_string(), + }), } - followers.prune(); - return stream::iter([first]).boxed(); + }) + } +} + +/// [`crate::platform::PaymentPlatform`] served by host-provided +/// [`NativePaymentCallbacks`]: the host answers a payment's current status, +/// and pushes each later one through [`Self::notify_status`]. +pub struct PaymentCallbackPlatform { + payments: Arc, + statuses: StatusRelay, +} + +impl PaymentCallbackPlatform { + /// Serve payments from `payments`. + pub fn new(payments: Arc) -> Self { + Self { + payments, + statuses: StatusRelay::new(payment_rank), } - let later = changes.scan(last_rank, |shown, status| { - let rank = status.as_ref().map_or(TERMINAL_RANK, top_up_rank); - let fresh = rank > *shown || (rank == *shown && rank == TERMINAL_RANK); - if fresh { - *shown = rank; + } + + /// Deliver a later status of `product_id`'s payment `id` to everyone + /// following it; a terminal one ends their streams. + pub fn notify_status(&self, product_id: String, id: crate::Bytes32, status: v01::HostPaymentStatusSubscribeItem) { + self.statuses.notify(product_id, id, status); + } +} + +/// How far along a payment `status` is, so a stream never steps back. +fn payment_rank(status: &v01::HostPaymentStatusSubscribeItem) -> u8 { + match status { + v01::HostPaymentStatusSubscribeItem::Processing => 0, + v01::HostPaymentStatusSubscribeItem::Completed + | v01::HostPaymentStatusSubscribeItem::Failed { .. } + | v01::HostPaymentStatusSubscribeItem::PartiallyClaimed { .. } => TERMINAL_RANK, + } +} + +#[async_trait] +impl crate::platform::PaymentPlatform for PaymentCallbackPlatform { + async fn request_payment( + &self, + product: &ProductContext, + request: v01::HostPaymentRequest, + ) -> Result<(), v01::HostPaymentError> { + self.payments + .request_payment(product.product_id.clone(), request) + .await + } + + fn subscribe_payment_status( + &self, + product: &ProductContext, + id: crate::Bytes32, + ) -> BoxStream<'static, Result> { + let product_id = product.product_id.clone(); + self.statuses.follow(product_id.clone(), id, || { + match self.payments.payment_status(product_id, id) { + Ok(Some(status)) => Ok(status), + Ok(None) => Err(v01::HostPaymentStatusSubscribeError::PaymentNotFound), + Err(error) => Err(v01::HostPaymentStatusSubscribeError::Unknown { + reason: error.to_string(), + }), } - futures::future::ready(Some(fresh.then_some(status))) - }); - stream::iter([first]) - .chain(later.filter_map(futures::future::ready)) - .boxed() + }) } } #[cfg(test)] -mod top_up_tests { +mod status_relay_tests { use super::*; use futures::executor::block_on; @@ -885,7 +992,7 @@ mod top_up_tests { ( block_on(followed.collect::>()), block_on(missing.collect::>()), - platform.followers.lock().expect("followers").by_top_up.len(), + platform.statuses.followed(), ), ( vec![ @@ -958,7 +1065,7 @@ mod top_up_tests { assert_eq!( ( block_on(followed.collect::>()), - platform.followers.lock().expect("followers").by_top_up.len(), + platform.statuses.followed(), ), ( vec![ @@ -969,4 +1076,54 @@ mod top_up_tests { ) ); } + + /// A payment engine holding one status per id, and nothing else. + struct Payments(Mutex>); + + #[async_trait::async_trait] + impl NativePaymentCallbacks for Payments { + async fn request_payment( + &self, + _product_id: String, + _request: v01::HostPaymentRequest, + ) -> Result<(), v01::HostPaymentError> { + Ok(()) + } + + fn payment_status( + &self, + _product_id: String, + id: crate::Bytes32, + ) -> Result, HostRejection> { + Ok(self.0.lock().expect("statuses").get(&id).cloned()) + } + } + + // A payment is followed from the host's current status to the verdict + // it pushes, and one the host does not hold is reported as not found, + // so its caller knows to request it again. + #[test] + fn a_payment_is_followed_to_its_verdict_and_an_unknown_one_is_not_found() { + use crate::platform::PaymentPlatform; + + let payments = Arc::new(Payments(Mutex::new(std::collections::HashMap::from([( + [1; 32], + v01::HostPaymentStatusSubscribeItem::Processing, + )])))); + let platform = PaymentCallbackPlatform::new(payments); + let followed = platform.subscribe_payment_status(&product(), [1; 32]); + let missing = platform.subscribe_payment_status(&product(), [2; 32]); + platform.notify_status("fund.dot".into(), [1; 32], v01::HostPaymentStatusSubscribeItem::Completed); + + assert_eq!( + (block_on(followed.collect::>()), block_on(missing.collect::>())), + ( + vec![ + Ok(v01::HostPaymentStatusSubscribeItem::Processing), + Ok(v01::HostPaymentStatusSubscribeItem::Completed), + ], + vec![Err(v01::HostPaymentStatusSubscribeError::PaymentNotFound)], + ) + ); + } } diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index 4eb0ff1363..5d732e6303 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -26,7 +26,7 @@ use crate::{PairedSsoPeer, ResponderExit, SigningHostRuntime}; use super::callbacks::{ HostCallbacks, NativeChatCallbacks, NativeContactsCallbacks, NativeFundingCallbacks, - NativePocketCallbacks, NativeTopUpCallbacks, + NativePaymentCallbacks, NativePocketCallbacks, NativeTopUpCallbacks, }; use super::config::{ HostRuntimeConfig, NativeResolvedHostRuntimeConfig, NativeRuntimeConfigError, @@ -37,7 +37,7 @@ use super::executor::shared_native_executor; use super::events::NativeEventBus; use super::platform::{ CallbackPlatform, ChatCallbackPlatform, ContactsCallbackPlatform, FundingCallbackPlatform, - PocketCallbackPlatform, TopUpCallbackPlatform, + PaymentCallbackPlatform, PocketCallbackPlatform, TopUpCallbackPlatform, }; #[cfg(doc)] use crate::WorkerTransition; @@ -56,6 +56,8 @@ pub struct NativeTrUApiHostRuntime { /// The host's top-up engine, once installed, which later statuses are /// pushed through. top_up: OnceLock>, + /// The payment engine statuses are pushed to, once installed. + payments: OnceLock>, } impl NativeTrUApiHostRuntime { @@ -122,6 +124,7 @@ impl NativeTrUApiHostRuntime { }))), worker_executions: Mutex::new(HashMap::new()), top_up: OnceLock::new(), + payments: OnceLock::new(), })) } @@ -273,6 +276,27 @@ impl NativeTrUApiHostRuntime { } } + /// Install the host's payment engine. Set-once; answers whether this + /// call installed it. Report each later status with + /// [`Self::notify_payment_status`]. + pub fn set_payment_callbacks(&self, callbacks: Arc) -> bool { + let platform = Arc::new(PaymentCallbackPlatform::new(callbacks)); + self.runtime.set_payment_platform(platform.clone()) && self.payments.set(platform).is_ok() + } + + /// Report a later status of `product_id`'s payment `id` to the products + /// and core following it. + pub fn notify_payment_status( + &self, + product_id: String, + id: crate::Bytes32, + status: v01::HostPaymentStatusSubscribeItem, + ) { + if let Some(platform) = self.payments.get() { + platform.notify_status(product_id, id, status); + } + } + /// Convert funding deposits into CASH on People, where CASH is Asset /// Hub asset `cash_asset_id`, reading every deposit account for the /// `deposit_asset_ids` and the native token. Set-once; answers whether diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index d6bea242d6..3d3ce38a6a 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -33,7 +33,8 @@ use truapi::latest::{ HostChatPostMessageResponse, HostChatRegisterBotError, HostChatRegisterBotRequest, HostChatRegisterBotResponse, HostDevicePermissionRequest, HostFeatureSupportedRequest, HostFeatureSupportedResponse, HostFundingStatusSubscribeItem, HostLocalStorageChangeItem, - HostLocaleSubscribeItem, HostNavigateToError, HostPaymentTopUpError, HostPaymentTopUpRequest, + HostLocaleSubscribeItem, HostNavigateToError, HostPaymentError, HostPaymentRequest, HostPaymentStatusSubscribeError, + HostPaymentStatusSubscribeItem, HostPaymentTopUpError, HostPaymentTopUpRequest, HostPaymentTopUpStatusSubscribeError, HostPaymentTopUpStatusSubscribeItem, HostPlatform, HostPocketListSubscribeItem, HostPocketRemoveCardError, HostPocketRemoveCardRequest, HostPushNotificationRequest, HostPushNotificationResponse, HostSignPayloadRequest, @@ -3295,6 +3296,32 @@ pub trait TopUpPlatform: Send + Sync { >; } +/// Host-implemented payment engine: pays from the user's balance to an +/// account, once the user approves. Optional: a host that omits it leaves +/// payment requests answered `Unsupported`. +/// +/// The host owns the approval sheet, the transfer and its persistence, and +/// scopes ids to `product`. +#[async_trait] +pub trait PaymentPlatform: Send + Sync { + /// Ask the user to approve `request`. Returns once the user has decided: + /// `Ok` when they authorized it and the host took it on; the payment's + /// outcome arrives through its status. + async fn request_payment( + &self, + product: &ProductContext, + request: HostPaymentRequest, + ) -> Result<(), HostPaymentError>; + + /// Emit a payment's current status and every later one, ending after a + /// terminal status. + fn subscribe_payment_status( + &self, + product: &ProductContext, + id: [u8; 32], + ) -> BoxStream<'static, Result>; +} + /// A funding session as the host overlay needs it to open on the right screen. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FundingPresentation { diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 899524e089..485179d803 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -1108,7 +1108,6 @@ fn transaction_call_error( })) } -const PAYMENTS_NOT_IMPLEMENTED: &str = "Payments are not supported in dot.li"; impl ProductRuntimeHost { /// Chat access policy for this connection; see [`chat_platform_for`]. diff --git a/rust/crates/truapi/src/runtime/capabilities/payment.rs b/rust/crates/truapi/src/runtime/capabilities/payment.rs index b7de6cadbd..e86d785a4b 100644 --- a/rust/crates/truapi/src/runtime/capabilities/payment.rs +++ b/rust/crates/truapi/src/runtime/capabilities/payment.rs @@ -30,7 +30,7 @@ use truapi::versioned::payment::{ use truapi::{CallContext, CallError, Subscription, v01}; use crate::host_internal::extrinsic::sr25519_secret_from_bytes; -use crate::runtime::{PAYMENTS_NOT_IMPLEMENTED, ProductRuntimeHost}; +use crate::runtime::ProductRuntimeHost; #[truapi::async_trait] impl CoinPayment for ProductRuntimeHost { @@ -143,27 +143,53 @@ impl Payment for ProductRuntimeHost { async fn request( &self, _cx: &CallContext, - _request: HostPaymentRequest, + request: HostPaymentRequest, ) -> Result> { - Err(CallError::Domain(HostPaymentError::V1( - v01::HostPaymentError::Unknown { - reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), - }, - ))) + let HostPaymentRequest::V1(request) = request; + let platform = self + .services + .payment_platform() + .ok_or(CallError::Unsupported)?; + // Payments the core makes as the funding product, into its funding + // accounts under ids anyone can work out, must not be raced or read + // by a product under that name. + if self.authority.current_session().is_none() + || crate::runtime::is_funding_product(&self.product_id()) + { + return Err(CallError::Denied); + } + platform + .request_payment(&self.product, request) + .await + .map(|()| HostPaymentResponse::V1) + .map_err(|error| CallError::Domain(HostPaymentError::V1(error))) } #[instrument(skip_all, fields(runtime.method = "payment.status_subscribe"))] async fn status_subscribe( &self, _cx: &CallContext, - _request: HostPaymentStatusSubscribeRequest, + request: HostPaymentStatusSubscribeRequest, ) -> Subscription> { - Subscription::interrupted(CallError::Domain(HostPaymentStatusSubscribeError::V1( - v01::HostPaymentStatusSubscribeError::Unknown { - reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), - }, - ))) + let HostPaymentStatusSubscribeRequest::V1(request) = request; + let Some(platform) = self.services.payment_platform() else { + return Subscription::interrupted(CallError::Unsupported); + }; + if self.authority.current_session().is_none() + || crate::runtime::is_funding_product(&self.product_id()) + { + return Subscription::interrupted(CallError::Denied); + } + Subscription::new(Box::pin( + platform + .subscribe_payment_status(&self.product, request.id) + .map(|item| { + item.map(HostPaymentStatusSubscribeItem::V1).map_err(|error| { + CallError::Domain(HostPaymentStatusSubscribeError::V1(error)) + }) + }), + )) } #[instrument(skip_all, fields(runtime.method = "payment.top_up"))] diff --git a/rust/crates/truapi/src/runtime/services.rs b/rust/crates/truapi/src/runtime/services.rs index c814c49ae7..5f20af7a8b 100644 --- a/rust/crates/truapi/src/runtime/services.rs +++ b/rust/crates/truapi/src/runtime/services.rs @@ -49,6 +49,8 @@ pub struct RuntimeServices { /// Host top-up engine, installed once at startup by a host that claims /// funds into the balance. Unset leaves every top-up `Unsupported`. top_up_platform: OnceLock>, + /// The host's payment engine, once installed. + payment_platform: OnceLock>, /// Host contacts adapter, installed once at startup by a host with a /// contact picker. Unset leaves every product contacts call `Unsupported`. contacts_platform: OnceLock>, @@ -124,6 +126,7 @@ impl RuntimeServices { pocket_platform: OnceLock::new(), funding: Default::default(), top_up_platform: OnceLock::new(), + payment_platform: OnceLock::new(), contacts_platform: OnceLock::new(), contact_handles: Default::default(), device_pairing_observer: OnceLock::new(), @@ -222,6 +225,17 @@ impl RuntimeServices { self.top_up_platform.get().cloned() } + /// Install the host's payment engine. Set-once; returns whether this + /// call installed it. + pub fn install_payment_platform(&self, platform: Arc) -> bool { + self.payment_platform.set(platform).is_ok() + } + + /// The host's payment engine, when one is installed. + pub fn payment_platform(&self) -> Option> { + self.payment_platform.get().cloned() + } + /// Funding sessions shared by every product runtime of this host. pub fn funding(&self) -> &Arc { &self.funding diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 22ef8afbb8..7f5da9dafb 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -2834,6 +2834,148 @@ fn a_top_up_needs_a_session() { ); } +#[derive(Default)] +struct RecordingPaymentPlatform { + requested: Mutex>, + followed: Mutex>, +} + +#[truapi::async_trait] +impl crate::platform::PaymentPlatform for RecordingPaymentPlatform { + async fn request_payment( + &self, + product: &ProductContext, + request: truapi::latest::HostPaymentRequest, + ) -> Result<(), truapi::latest::HostPaymentError> { + self.requested + .lock() + .expect("requested mutex poisoned") + .push((product.product_id.clone(), request)); + Ok(()) + } + + fn subscribe_payment_status( + &self, + product: &ProductContext, + id: [u8; 32], + ) -> futures::stream::BoxStream< + 'static, + Result, + > { + self.followed + .lock() + .expect("followed mutex poisoned") + .push((product.product_id.clone(), id)); + Box::pin(futures::stream::iter([ + Ok(v01::HostPaymentStatusSubscribeItem::Processing), + Ok(v01::HostPaymentStatusSubscribeItem::PartiallyClaimed { actual_claimed: 600 }), + ])) + } +} + +fn payment_request() -> v01::HostPaymentRequest { + v01::HostPaymentRequest { + from: None, + amount: 1_000, + destination: [3; 32], + id: [9; 32], + } +} + +fn request_payment( + host: &ProductRuntimeHost, +) -> Result> { + futures::executor::block_on(truapi::api::Payment::request( + host, + &CallContext::default(), + truapi::versioned::payment::HostPaymentRequest::V1(payment_request()), + )) +} + +fn follow_payment( + host: &ProductRuntimeHost, +) -> Vec< + Result< + truapi::versioned::payment::HostPaymentStatusSubscribeItem, + CallError, + >, +> { + futures::executor::block_on( + futures::executor::block_on(truapi::api::Payment::status_subscribe( + host, + &CallContext::default(), + truapi::versioned::payment::HostPaymentStatusSubscribeRequest::V1( + v01::HostPaymentStatusSubscribeRequest { id: [9; 32] }, + ), + )) + .collect::>(), + ) +} + +// The host owns the approval sheet and the transfer; core hands it the +// request as the product made it, and relays the status the host reports, +// a partial payment included. +#[test] +fn a_payment_reaches_the_host_engine_and_its_status_is_relayed() { + let services = funding_services(); + let engine = Arc::new(RecordingPaymentPlatform::default()); + assert!(services.install_payment_platform(engine.clone())); + let host = funding_host(&services, "wallet.dot", true); + + assert_eq!( + ( + request_payment(&host), + follow_payment(&host), + engine.requested.lock().expect("requested mutex poisoned").clone(), + engine.followed.lock().expect("followed mutex poisoned").clone(), + ), + ( + Ok(truapi::versioned::payment::HostPaymentResponse::V1), + vec![ + Ok(truapi::versioned::payment::HostPaymentStatusSubscribeItem::V1( + v01::HostPaymentStatusSubscribeItem::Processing + )), + Ok(truapi::versioned::payment::HostPaymentStatusSubscribeItem::V1( + v01::HostPaymentStatusSubscribeItem::PartiallyClaimed { actual_claimed: 600 } + )), + ], + vec![("wallet.dot".to_string(), payment_request())], + vec![("wallet.dot".to_string(), [9; 32])], + ) + ); +} + +// Payments the core makes as the funding product use ids anyone can work +// out; a product under that name, or one without a session, must not reach +// the host's payments. +#[test] +fn no_product_pays_as_the_funding_product_or_without_a_session() { + let services = funding_services(); + let engine = Arc::new(RecordingPaymentPlatform::default()); + assert!(services.install_payment_platform(engine.clone())); + let funding = funding_host(&services, "fund.dot", true); + let signed_out = funding_host(&services, "wallet.dot", false); + + assert_eq!( + ( + request_payment(&funding), + follow_payment(&funding), + request_payment(&signed_out), + follow_payment(&signed_out), + engine.requested.lock().expect("requested mutex poisoned").len(), + engine.followed.lock().expect("followed mutex poisoned").len(), + ), + ( + Err(CallError::Denied), + vec![Err(CallError::Denied)], + Err(CallError::Denied), + vec![Err(CallError::Denied)], + 0, + 0 + ) + ); +} + #[test] fn chain_follow_ids_are_scoped_per_product_core() { let (host_config, product) = runtime_config("same.dot"); diff --git a/rust/crates/truapi/src/v01/payment.rs b/rust/crates/truapi/src/v01/payment.rs index f331720b36..68e378b18e 100644 --- a/rust/crates/truapi/src/v01/payment.rs +++ b/rust/crates/truapi/src/v01/payment.rs @@ -73,6 +73,10 @@ pub struct HostPaymentTopUpRequest { /// Request to initiate a payment to another account. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct HostPaymentRequest { /// Optional purse selector. `None` means MAIN_PURSE. pub from: Option, @@ -80,38 +84,39 @@ pub struct HostPaymentRequest { pub amount: u128, /// Destination account. pub destination: [u8; 32], -} - -/// Receipt returned after a successful payment request. -/// -/// See [RFC 0006]. -/// -/// [RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94 -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPaymentResponse { - /// The assigned payment identifier. - pub id: String, + /// Caller-chosen id the payment's status is followed by. Reusing one is + /// refused with `AlreadyExists`, which makes a retried call safe. + pub id: [u8; 32], } /// Payment lifecycle status pushed to subscribers. /// -/// Once a terminal state (`Completed` or `Failed`) is reached, the host -/// delivers it and may close the subscription. +/// `Completed`, `Failed` and `PartiallyClaimed` are terminal, and stay +/// readable after the payment ends. /// /// See [RFC 0006]. /// /// [RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94 #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum HostPaymentStatusSubscribeItem { /// Payment is being processed. Processing, - /// Payment has been settled successfully. + /// The full amount reached the destination. Completed, /// Payment has failed. Failed { /// Failure reason. reason: String, }, + /// Less than the requested amount reached the destination. + PartiallyClaimed { + /// Amount that did. + actual_claimed: u128, + }, } /// Error from [`crate::api::Payment::balance_subscribe`]. @@ -163,13 +168,23 @@ pub enum HostPaymentTopUpError { /// See [RFC 0006]. /// /// [RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94 -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::Display)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Error) +)] pub enum HostPaymentError { + /// A payment with this id already exists. + #[display("a payment with this id already exists")] + AlreadyExists, /// User rejected the payment request. + #[display("the payment was declined")] Rejected, /// User's available balance is not sufficient for the requested amount. + #[display("the balance does not cover the payment")] InsufficientBalance, /// Catch-all. + #[display("{reason}")] Unknown { /// Human-readable failure reason. reason: String, @@ -195,8 +210,8 @@ pub enum HostPaymentStatusSubscribeError { /// Request to subscribe to a payment status. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostPaymentStatusSubscribeRequest { - /// Payment identifier to watch. - pub payment_id: String, + /// Id the payment was requested with. + pub id: [u8; 32], } /// Request to follow one top-up. diff --git a/rust/crates/truapi/src/versioned/payment.rs b/rust/crates/truapi/src/versioned/payment.rs index 3e7b15f919..72f77f98df 100644 --- a/rust/crates/truapi/src/versioned/payment.rs +++ b/rust/crates/truapi/src/versioned/payment.rs @@ -13,7 +13,7 @@ truapi_macros::versioned_type! { pub enum HostPaymentTopUpStatusSubscribeItem { V1 => v01::HostPaymentTopUpStatusSubscribeItem } pub enum HostPaymentTopUpStatusSubscribeError { V1 => v01::HostPaymentTopUpStatusSubscribeError } pub enum HostPaymentRequest { V1 => v01::HostPaymentRequest } - pub enum HostPaymentResponse { V1 => v01::HostPaymentResponse } + pub enum HostPaymentResponse { V1 } pub enum HostPaymentError { V1 => v01::HostPaymentError } pub enum HostPaymentStatusSubscribeRequest { V1 => v01::HostPaymentStatusSubscribeRequest } pub enum HostPaymentStatusSubscribeItem { V1 => v01::HostPaymentStatusSubscribeItem } diff --git a/rust/crates/truapi/tests/wire_result_shape.rs b/rust/crates/truapi/tests/wire_result_shape.rs index bf920e8792..2bb81e334a 100644 --- a/rust/crates/truapi/tests/wire_result_shape.rs +++ b/rust/crates/truapi/tests/wire_result_shape.rs @@ -40,8 +40,6 @@ use truapi::frame::{ mod common; use common::{RecordingTransport, WireShapePlatform, test_runtime_config, test_spawner}; -const PAYMENTS_NOT_IMPLEMENTED: &str = "Payments are not supported in dot.li"; - fn dispatch(core: &TrUApiCore, frame: ProtocolMessage) -> ProtocolMessage { let encoded = frame.encode(); let response_bytes = futures::executor::block_on(core.receive_from_product(&encoded)) @@ -200,36 +198,6 @@ fn versioned_interrupt_err_payload(wrapped_error: Wrapper) -> V expected } -fn assert_request_returns_domain_error( - core: &TrUApiCore, - request_id: &str, - method: &str, - value: Vec, - wrapped_error: Wrapper, -) { - let ids = request_ids(method).expect("known request method"); - let response = dispatch( - core, - ProtocolMessage { - request_id: request_id.into(), - payload: Payload { - trait_id: ids.trait_id, - method_id: ids.method_id, - message_type: MESSAGE_TYPE_REQUEST, - value, - }, - }, - ); - assert_eq!(response.request_id, request_id); - assert_eq!(response.payload.trait_id, ids.trait_id); - assert_eq!(response.payload.method_id, ids.method_id); - assert_eq!(response.payload.message_type, MESSAGE_TYPE_RESPONSE); - assert_eq!( - response.payload.value, - versioned_result_err_payload(wrapped_error) - ); -} - fn assert_subscription_start_interrupts_error( core: &TrUApiCore, request_id: &str, @@ -328,28 +296,68 @@ fn foreign_account_proof_encodes_a_domain_refusal() { assert_eq!(response.payload.value, expected); } +/// A host with no payment engine answers `Unsupported`, so a product can tell +/// "this host never pays" from a payment that failed. #[test] -fn deferred_payment_requests_return_dotli_not_implemented_errors() { +fn payment_request_reports_unsupported_without_a_host_engine() { let core = make_core(); let request = v01::HostPaymentRequest { from: None, amount: 1, destination: [0u8; 32], + id: [7; 32], + }; + let ids = request_ids("payment_request").expect("known request method"); + let frame = ProtocolMessage { + request_id: "p:payment".into(), + payload: Payload { + trait_id: ids.trait_id, + method_id: ids.method_id, + message_type: MESSAGE_TYPE_REQUEST, + value: truapi::versioned::payment::HostPaymentRequest::V1(request).encode(), + }, }; + let response = dispatch(&core, frame); + assert_eq!(response.payload.value, vec![0x01u8, 0x02u8]); +} - assert_request_returns_domain_error( - &core, - "p:payment", - "payment_request", - truapi::versioned::payment::HostPaymentRequest::V1(request).encode(), - truapi::versioned::payment::HostPaymentError::V1(v01::HostPaymentError::Unknown { - reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), - }), +/// Following a payment on a host with no payment engine interrupts with +/// `Unsupported`, as requesting one answers. +#[test] +fn payment_status_subscription_interrupts_unsupported_without_a_host_engine() { + let core = make_core(); + let status = v01::HostPaymentStatusSubscribeRequest { id: [7; 32] }; + let ids = subscription_ids("payment_status_subscribe").expect("known subscription method"); + let transport = Arc::new(RecordingTransport::default()); + futures::executor::block_on( + core.dispatch( + ProtocolMessage { + request_id: "p:status".into(), + payload: Payload { + trait_id: ids.trait_id, + method_id: ids.method_id, + message_type: MESSAGE_TYPE_START, + value: truapi::versioned::payment::HostPaymentStatusSubscribeRequest::V1( + status, + ) + .encode(), + }, + }, + transport.clone(), + ), + ); + transport.wait_for(1, std::time::Duration::from_secs(5)); + + let sent = transport.sent.lock().unwrap(); + assert_eq!( + (sent[0].payload.message_type, sent[0].payload.value.clone()), + // [Result::Err=0x01][CallError::Unsupported=0x02], as for a request. + (MESSAGE_TYPE_INTERRUPT, vec![0x01u8, 0x02u8]) ); } #[test] -fn deferred_payment_subscriptions_interrupt_dotli_not_implemented_errors() { +fn payment_balance_subscription_interrupts_permission_denied() { let core = make_core(); let balance = v01::HostPaymentBalanceSubscribeRequest { purse: None }; assert_subscription_start_interrupts_error( @@ -361,21 +369,6 @@ fn deferred_payment_subscriptions_interrupt_dotli_not_implemented_errors() { v01::HostPaymentBalanceSubscribeError::PermissionDenied, ), ); - - let status = v01::HostPaymentStatusSubscribeRequest { - payment_id: "payment-id".to_string(), - }; - assert_subscription_start_interrupts_error( - &core, - "p:status", - "payment_status_subscribe", - truapi::versioned::payment::HostPaymentStatusSubscribeRequest::V1(status).encode(), - truapi::versioned::payment::HostPaymentStatusSubscribeError::V1( - v01::HostPaymentStatusSubscribeError::Unknown { - reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), - }, - ), - ); } #[test] From bda3d569fa2a0047dd9bdccc6df272cb9f3e344d Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Tue, 6 Oct 2026 13:52:40 +0200 Subject: [PATCH 2/4] feat(truapi): pay withdrawals into their account as getcash does --- .changeset/funding-withdraw-payment.md | 5 + .../truapi-host-cli/src/funding_check.rs | 3 +- rust/crates/truapi/RUNTIME.md | 12 + rust/crates/truapi/src/host_core.rs | 32 ++ rust/crates/truapi/src/host_logic/funding.rs | 334 +++++++++++++++++- rust/crates/truapi/src/native/runtime.rs | 14 + rust/crates/truapi/src/runtime/funding.rs | 79 ++++- .../truapi/src/runtime/funding/credit.rs | 15 +- .../truapi/src/runtime/funding/withdraw.rs | 326 +++++++++++++++++ rust/crates/truapi/src/runtime/tests.rs | 98 ++++- 10 files changed, 889 insertions(+), 29 deletions(-) create mode 100644 .changeset/funding-withdraw-payment.md create mode 100644 rust/crates/truapi/src/runtime/funding/withdraw.rs diff --git a/.changeset/funding-withdraw-payment.md b/.changeset/funding-withdraw-payment.md new file mode 100644 index 0000000000..510a32d276 --- /dev/null +++ b/.changeset/funding-withdraw-payment.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": patch +--- + +`assign_funding_withdrawal` gives an outbound funding session its withdrawal account and asks the host for the user's payment into it, as getcash pays its withdrawal key; the watch follows the payment to `Paid`, retries it under a new id, and expires it after 30 minutes untaken (Rust and native API). diff --git a/rust/crates/truapi-host-cli/src/funding_check.rs b/rust/crates/truapi-host-cli/src/funding_check.rs index 51ce34ee83..9fda903286 100644 --- a/rust/crates/truapi-host-cli/src/funding_check.rs +++ b/rust/crates/truapi-host-cli/src/funding_check.rs @@ -375,7 +375,8 @@ async fn follow(runtime: &SigningHostRuntime, intent: &str) -> Result<()> { FundingStage::Open | FundingStage::Converting { .. } | FundingStage::Converted - | FundingStage::Crediting { .. } => {} + | FundingStage::Crediting { .. } + | FundingStage::Paid { .. } => {} } tokio::time::sleep(POLL).await; } diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 61b6e6e836..28c994f29e 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -429,6 +429,18 @@ AutoSigning without approval. Legacy-account signing still asks the user. the account, stays out of the history bound, and `retry_funding` picks it up: the conversion again by its route, or the credit from its last attempt. A product sees the retried session by subscribing again. + For a withdrawal, `assign_funding_withdrawal` gives an outbound session + that names its amount a `wd:eph::` account, the next one + with no CASH on People, and asks the host's `PaymentPlatform` to have the + user pay the amount into it, as the funding product, under getcash's + payment ids (the account, then `blake2(account ‖ attempt)`). The watch + reads the account's CASH and the host's latest word on the current + attempt: CASH there moves the session to `Paid`, a payment under way no + longer expires, a failed or refused one ends the session for + `retry_funding`, which asks again under the next id while the account is + still read, and one never taken expires after 30 minutes. A cancel reads + both first and is refused once the payment is taken. The watch starts + before the request, which resolves only once the user has decided. `cancel_funding` cancels a session while nothing has arrived, as getcash does: it is refused once a deposit was seen or while anything is on the deposit account, which is read first, and nothing is cancelled if that diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index 5474a72e2d..46bde99ae3 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -683,6 +683,38 @@ impl SigningHostRuntime { }) } + /// Give open outbound session `intent` a withdrawal account for + /// `destination_id` and ask the host to have the user pay the session's + /// amount into it, as getcash pays its withdrawal key. Returns the + /// account. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.assign_funding_withdrawal"))] + pub async fn assign_funding_withdrawal( + &self, + intent: &str, + destination_id: &str, + ) -> Result<[u8; 32], v01::GenericError> { + let derive = |number| { + self.signing_host + .derive_funding_account( + crate::host_logic::funding::FundingAccountKind::Withdrawal, + destination_id, + number, + ) + .map_err(|err| v01::GenericError { + reason: err.to_string(), + })? + .ok_or_else(|| v01::GenericError { + reason: "no signing session is active".into(), + }) + }; + self.services + .assign_funding_withdrawal(intent, destination_id, derive) + .await + .map_err(|err| v01::GenericError { + reason: err.to_string(), + }) + } + /// The product's hard-subtree public key, derived from the active session /// root, or `None` while no session is active. /// diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index fc402228f1..8736d4b2b3 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -11,7 +11,9 @@ use std::collections::BTreeMap; use parity_scale_codec::{Decode, Encode}; use tracing::warn; -use truapi::latest::{FundingDirection, FundingFailure, HostFundingStatusSubscribeItem}; +use truapi::latest::{ + FundingDirection, FundingFailure, HostFundingStatusSubscribeItem, HostPaymentStatusSubscribeItem, +}; use crate::host_logic::entropy::{ProductEntropyError, derive_product_entropy}; use crate::host_logic::product_account::{ProductAccountError, derive_root_keypair_from_entropy}; @@ -59,6 +61,9 @@ pub struct FundingSession { /// it has: what crediting claims from, and what stays stranded on the /// account when less is credited. pub landed: Option, + /// Where an outbound session's payment goes, once its destination is + /// known. + pub withdrawal: Option, /// When each step in flight was first reached, in the order reached. /// The session starts at `opened_at_ms` and ends when its stage says. pub stamps: Vec, @@ -67,6 +72,66 @@ pub struct FundingSession { pub acknowledged: bool, } +/// The account an outbound session's payment goes to, and the payment. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct FundingWithdrawal { + /// Where the funds go from Asset Hub, as in the account label. + pub destination_id: String, + /// Account number for `destination_id`. + pub number: u32, + /// Public key of the withdrawal account on People and Asset Hub. + pub account: [u8; 32], + /// Which payment attempt is asked for, from 0; its id is + /// [`funding_attempt_id`] of the account. + pub attempt: u8, + /// When that attempt was asked for, in Unix milliseconds. + pub since_ms: u64, + /// Whether the host reported the payment under way or done, after which + /// the session no longer expires or cancels, as getcash holds a taken + /// payment. + pub taken: bool, +} + +/// The id of attempt `attempt` on `account`, as getcash numbers its top-ups +/// and payments: the account itself first, then +/// `blake2_256(account ‖ u32le attempt)`. +pub fn funding_attempt_id(account: &[u8; 32], attempt: u8) -> [u8; 32] { + if attempt == 0 { + return *account; + } + sp_crypto_hashing::blake2_256(&[account.as_slice(), &u32::from(attempt).to_le_bytes()].concat()) +} + +/// What the host said of a withdrawal's payment, and for which attempt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaymentReading { + /// The attempt whose id was asked about. + pub attempt: u8, + /// The answer. + pub word: PaymentWord, +} + +/// The host's answer about one payment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaymentWord { + /// The host did not answer in time. + Unanswered, + /// The host holds no payment under the id. + NotFound, + /// The payment's status. + Said(HostPaymentStatusSubscribeItem), +} + +/// How long a requested payment may go untaken before the session expires, +/// as getcash's payment window. +pub const PAYMENT_WINDOW_MS: u64 = 30 * 60 * 1_000; +/// Code a session fails with when its payment did not go through. +const PAYMENT_FAILED: &str = "payment_failed"; + /// Where a session is, for the host's progress and history views: the /// stage, with an inbound session's deposit account and submission folded /// in. Each rail's markers are drawn from these. @@ -78,6 +143,11 @@ pub struct FundingSession { pub enum FundingStep { /// Opened; nothing chosen yet. Started, + /// An outbound session asked the user to pay into its withdrawal + /// account. + AwaitingPayment, + /// The user's CASH is on the withdrawal account. + Paid, /// An inbound session has a deposit account and waits for the payment. AwaitingDeposit, /// The deposit arrived. @@ -382,6 +452,12 @@ pub enum FundingStage { /// What the top-ups have claimed so far. progress: CreditProgress, }, + /// Outbound: the user's CASH is on the withdrawal account on People, + /// to be converted and sent on. + Paid { + /// CASH on the account, in payment balance units. + paid: u128, + }, /// Inbound terminal success: the CASH is in the user's balance. Delivered { /// Amount credited, in payment balance units. @@ -411,6 +487,9 @@ pub enum FundingStage { pub enum FundingResume { /// The deposit is still on Asset Hub: convert it again by its route. Conversion, + /// The payment into the withdrawal account did not go through: ask for + /// it again, under the next attempt's id. + Payment, /// The CASH is on People: credit it from `progress`. Credit { /// What the top-ups claimed, and the attempt to go on with. @@ -473,6 +552,7 @@ impl FundingSession { deposit: None, quote: None, landed: None, + withdrawal: None, stamps: Vec::new(), acknowledged: false, } @@ -482,6 +562,8 @@ impl FundingSession { pub fn step(&self) -> FundingStep { match &self.stage { FundingStage::Open if self.deposit.is_some() => FundingStep::AwaitingDeposit, + FundingStage::Open if self.withdrawal.is_some() => FundingStep::AwaitingPayment, + FundingStage::Paid { .. } => FundingStep::Paid, FundingStage::Open => FundingStep::Started, FundingStage::Converting { submission: None, .. @@ -555,7 +637,8 @@ impl FundingSession { FundingStage::Open | FundingStage::Converting { .. } | FundingStage::Converted - | FundingStage::Crediting { .. } => None, + | FundingStage::Crediting { .. } + | FundingStage::Paid { .. } => None, FundingStage::Delivered { settled_at_ms, .. } => Some(settled_at_ms), FundingStage::Failed { settled_at_ms, .. } => Some(settled_at_ms), } @@ -575,7 +658,8 @@ impl FundingSession { ( FundingStage::Converting { .. } | FundingStage::Converted - | FundingStage::Crediting { .. }, + | FundingStage::Crediting { .. } + | FundingStage::Paid { .. }, _, ) => HostFundingStatusSubscribeItem::Converting, (FundingStage::Delivered { credited, .. }, _) => { @@ -615,10 +699,10 @@ impl FundingSession { } /// Whether the expiry sweep ends this session at its deadline: an open - /// one with no deposit account. One with an account is ended by the - /// deposit watch, after a read that shows its deposit did not arrive. + /// one with no deposit or withdrawal account. One with an account is + /// ended by its watch, after a read that shows its funds did not arrive. pub fn expires_by_sweep(&self) -> bool { - self.stage == FundingStage::Open && self.deposit.is_none() + self.stage == FundingStage::Open && self.deposit.is_none() && self.withdrawal.is_none() } /// Expire the session if the sweep owns it and its deadline passed. @@ -685,6 +769,10 @@ impl FundingSession { reason: FundingFailure::Expired, .. } + | FundingStage::Failed { + resume: Some(FundingResume::Payment), + .. + } ); if !cancellable { return Err(CancelRefusal::Underway); @@ -692,6 +780,9 @@ impl FundingSession { if self.deposit.as_ref().is_some_and(|deposit| !deposit.holdings.is_empty()) { return Err(CancelRefusal::FundsArrived); } + if self.withdrawal.as_ref().is_some_and(|withdrawal| withdrawal.taken) { + return Err(CancelRefusal::Underway); + } self.stage = FundingStage::Failed { reason: FundingFailure::Cancelled, settled_at_ms: now_ms, @@ -729,6 +820,21 @@ impl FundingSession { submission: None, } } + FundingResume::Payment => { + // Past the late watch nothing reads the account, so CASH that + // reached it since would be asked for twice. + let watched = self.watched_withdrawal(now_ms).is_some(); + let withdrawal = self + .withdrawal + .as_mut() + .filter(|_| watched) + .ok_or(RetryRefusal::NotResumable)?; + // A reused id would be taken for the payment already made. + withdrawal.attempt = withdrawal.attempt.checked_add(1).ok_or(RetryRefusal::NotResumable)?; + withdrawal.since_ms = now_ms; + withdrawal.taken = false; + FundingStage::Open + } FundingResume::Credit { progress } => FundingStage::Crediting { progress: CreditProgress { claim: progress.claim.map(|claim| Claim { @@ -843,6 +949,118 @@ impl FundingSession { Ok(()) } + /// Give an open outbound session that names its amount its withdrawal + /// account. Returns whether it was given one. + pub fn assign_withdrawal(&mut self, withdrawal: FundingWithdrawal) -> bool { + let assignable = self.awaits_withdrawal(); + if assignable { + self.withdrawal = Some(withdrawal); + } + assignable + } + + /// Whether the session is an open outbound one that names its amount and + /// has no withdrawal account yet. + pub fn awaits_withdrawal(&self) -> bool { + self.direction == FundingDirection::Out + && self.stage == FundingStage::Open + && self.amount.is_some() + && self.withdrawal.is_none() + } + + /// The withdrawal account a session still reads: an open session's, or + /// one that expired, was cancelled or had its payment fail, for + /// [`LATE_WATCH_MS`] after, since CASH that reaches the account is the + /// user's, as getcash completes a payment its key shows from any state + /// before it moves on. + pub fn watched_withdrawal(&self, now_ms: u64) -> Option<&FundingWithdrawal> { + let recent = self + .settled_at_ms() + .is_none_or(|settled_at_ms| now_ms.saturating_sub(settled_at_ms) <= LATE_WATCH_MS); + self.withdrawal.as_ref().filter(|_| self.payment_pending() && recent) + } + + /// Whether a withdrawal's payment can still arrive and move the session + /// on: it is open, or it expired, was cancelled or had its payment fail. + pub fn payment_pending(&self) -> bool { + self.withdrawal.is_some() + && match &self.stage { + FundingStage::Open => true, + FundingStage::Failed { reason, resume, .. } => { + matches!(reason, FundingFailure::Expired | FundingFailure::Cancelled) + || *resume == Some(FundingResume::Payment) + } + _ => false, + } + } + + /// Record a finalized reading of the withdrawal account's CASH on + /// People and the host's word on the payment, taken at `now_ms`, as + /// getcash judges a withdrawal's payment. CASH on the account means it + /// was paid, whatever the host says: the session waits for it even once + /// the host reports the payment complete. Otherwise the host's latest + /// word for the current attempt decides: under way or done, it no longer + /// expires; failed, the session ends for a retry; unknown to the host, + /// it was never taken, and one never taken expires after its window. + /// Returns whether the session changed. + pub fn observe_withdrawal(&mut self, cash: u128, payment: PaymentReading, now_ms: u64) -> bool { + if !self.payment_pending() { + return false; + } + if cash > 0 { + self.stage = FundingStage::Paid { paid: cash }; + return true; + } + if self.stage != FundingStage::Open { + return false; + } + let Some(withdrawal) = self.withdrawal.as_mut() else { + return false; + }; + let word = match payment { + PaymentReading { attempt, word } if attempt == withdrawal.attempt => word, + // The host's word on an earlier attempt says nothing of this one. + PaymentReading { .. } => PaymentWord::Unanswered, + }; + let taken = match word { + PaymentWord::Said(HostPaymentStatusSubscribeItem::Failed { reason }) => { + return self.fail_resumable( + FundingFailure::Other { + code: PAYMENT_FAILED.into(), + message: reason, + }, + Some(FundingResume::Payment), + now_ms, + ); + } + PaymentWord::Said(_) => true, + PaymentWord::NotFound => false, + PaymentWord::Unanswered => withdrawal.taken, + }; + let changed = taken != withdrawal.taken; + withdrawal.taken = taken; + let expired = !taken + && now_ms.saturating_sub(withdrawal.since_ms) > PAYMENT_WINDOW_MS + && self.fail(FundingFailure::Expired, now_ms); + changed || expired + } + + /// End an open outbound session whose payment the host or user refused, + /// for a retry under the next attempt's id. Returns whether it changed. + pub fn refuse_payment(&mut self, message: String, now_ms: u64) -> bool { + if self.stage != FundingStage::Open || self.withdrawal.is_none() { + return false; + } + self.fail_resumable( + FundingFailure::Other { + code: PAYMENT_FAILED.into(), + message, + }, + Some(FundingResume::Payment), + now_ms, + ) + } + /// The deposit an open inbound session is waiting on, if one is assigned. pub fn awaited_deposit(&self) -> Option<&FundingDeposit> { (self.stage == FundingStage::Open) @@ -1539,6 +1757,110 @@ mod tests { ); } + fn withdrawing(taken: bool) -> FundingSession { + let mut session = FundingSession { + amount: Some(1_000), + ..session(FundingDirection::Out) + }; + session.assign_withdrawal(FundingWithdrawal { + destination_id: "dot-assethub".into(), + number: 1, + account: [4; 32], + attempt: 0, + since_ms: NOW, + taken, + }); + session + } + + // getcash judges a withdrawal's payment by the account first: CASH there + // is paid whatever the host says; then by the host's latest word on the + // current attempt: under way, it no longer expires; failed, even after + // it was under way, it is retried; unknown, it was never taken; and one + // never taken expires after its window. A word on an earlier attempt + // says nothing of this one. + #[test] + fn a_withdrawal_payment_is_judged_as_getcash_judges_it() { + let said = |status| PaymentReading { + attempt: 0, + word: PaymentWord::Said(status), + }; + let word = |word| PaymentReading { attempt: 0, word }; + let observe = |mut session: FundingSession, cash, payment, now_ms| { + let changed = session.observe_withdrawal(cash, payment, now_ms); + (changed, session.step(), session.withdrawal.map(|withdrawal| withdrawal.taken)) + }; + let late = NOW + PAYMENT_WINDOW_MS + 1; + let failed = || said(HostPaymentStatusSubscribeItem::Failed { reason: "declined".into() }); + let processing = || said(HostPaymentStatusSubscribeItem::Processing); + let mut expired = withdrawing(false); + expired.observe_withdrawal(0, word(PaymentWord::Unanswered), late); + let stale = PaymentReading { + attempt: 1, + word: PaymentWord::Said(HostPaymentStatusSubscribeItem::Processing), + }; + + assert_eq!( + [ + observe(withdrawing(false), 0, word(PaymentWord::Unanswered), NOW + 1), + observe(withdrawing(false), 0, processing(), NOW + 1), + observe(withdrawing(false), 0, failed(), NOW + 1), + observe(withdrawing(true), 0, failed(), NOW + 1), + observe(withdrawing(true), 0, word(PaymentWord::NotFound), NOW + 1), + observe(withdrawing(false), 0, word(PaymentWord::Unanswered), late), + observe(withdrawing(true), 0, processing(), late), + observe(withdrawing(false), 0, stale, late), + observe(withdrawing(false), 900, word(PaymentWord::Unanswered), NOW + 1), + observe(expired, 900, word(PaymentWord::Unanswered), late + 1), + ], + [ + (false, FundingStep::AwaitingPayment, Some(false)), + (true, FundingStep::AwaitingPayment, Some(true)), + (true, FundingStep::Failed, Some(false)), + (true, FundingStep::Failed, Some(true)), + (true, FundingStep::AwaitingPayment, Some(false)), + (true, FundingStep::Expired, Some(false)), + (false, FundingStep::AwaitingPayment, Some(true)), + (true, FundingStep::Expired, Some(false)), + (true, FundingStep::Paid, Some(false)), + (true, FundingStep::Paid, Some(false)), + ] + ); + } + + // A withdrawal's payment is asked for again under the next attempt's + // id, but not once the account is no longer read, when CASH that reached + // it would be asked for twice; one that failed can be cancelled, one + // under way cannot, and none waits on the 24 h sweep, which would end a + // payment the host is still taking. + #[test] + fn a_withdrawal_retries_under_a_new_id_and_a_taken_one_cannot_be_cancelled() { + let mut refused = withdrawing(false); + refused.refuse_payment("declined".into(), NOW); + let mut too_late = refused.clone(); + let mut cancelled = refused.clone(); + let retried = refused.retry(NOW + 5).map(|()| refused.withdrawal.clone()); + + assert_eq!( + ( + retried.map(|withdrawal| withdrawal.map(|withdrawal| (withdrawal.attempt, withdrawal.since_ms))), + too_late.retry(NOW + LATE_WATCH_MS + 1), + cancelled.cancel(NOW + 1), + withdrawing(true).cancel(NOW), + withdrawing(false).cancel(NOW), + withdrawing(false).expires_by_sweep(), + ), + ( + Ok(Some((1, NOW + 5))), + Err(RetryRefusal::NotResumable), + Ok(()), + Err(CancelRefusal::Underway), + Ok(()), + false + ) + ); + } + // A deposit screen and a provider must see the address Asset Hub // wallets and Chainflip accept: prefix 0, as getcash encodes it. #[test] diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index 5d732e6303..c758c87078 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -369,6 +369,20 @@ impl NativeTrUApiHostRuntime { Ok(self.runtime.quote_funding_deposit(&intent, asset).await?) } + /// Give open outbound session `intent` a withdrawal account for + /// `destination_id` and ask for the user's payment into it. Returns the + /// account. + pub async fn assign_funding_withdrawal( + &self, + intent: String, + destination_id: String, + ) -> Result { + Ok(self + .runtime + .assign_funding_withdrawal(&intent, &destination_id) + .await?) + } + /// Give session `intent` the deposit account its provider pays into. pub async fn assign_funding_deposit( &self, diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs index 8c3d19a45a..4100c5c929 100644 --- a/rust/crates/truapi/src/runtime/funding.rs +++ b/rust/crates/truapi/src/runtime/funding.rs @@ -27,6 +27,7 @@ use truapi::latest::{ mod conversion; mod credit; +mod withdraw; use conversion::{Chains, ConversionChains, ConversionError}; #[cfg(test)] @@ -144,13 +145,15 @@ impl FundingRegistry { } /// Cancel session `intent`, first recording `holdings`, a fresh reading - /// of its deposit account when it has one. + /// of its deposit account when it has one. `confirmed_withdrawal` says + /// its withdrawal account and payment were read fresh just before. pub async fn cancel( &self, storage: &(impl CoreStorage + ?Sized), now_ms: u64, intent: &str, holdings: Option>, + confirmed_withdrawal: bool, ) -> Result<(), CancelFundingError> { let intent = intent.to_string(); self.commit(storage, now_ms, move |sessions| { @@ -159,7 +162,9 @@ impl FundingRegistry { }; // An account assigned since the read, or funds a watch pass // recorded since, are not covered by it. - if holdings.is_none() && session.deposit.is_some() { + if (holdings.is_none() && session.deposit.is_some()) + || (!confirmed_withdrawal && session.payment_pending()) + { return (Err(CancelFundingError::Unconfirmed), Vec::new()); } if session.deposit.as_ref().is_some_and(|deposit| !deposit.holdings.is_empty()) { @@ -484,6 +489,7 @@ impl FundingRegistry { .values() .any(|session| { session.watched_deposit(current_unix_millis()).is_some() + || session.watched_withdrawal(current_unix_millis()).is_some() || session.converting().is_some() || session.crediting().is_some() }) @@ -969,6 +975,13 @@ impl RuntimeServices { pub async fn cancel_funding(self: &Arc, intent: &str) -> Result<(), CancelFundingError> { let registry = self.funding(); let session = registry.get(intent).ok_or(CancelFundingError::NotFound)?; + let confirmed_withdrawal = match session.withdrawal.as_ref().filter(|_| session.payment_pending()) { + Some(withdrawal) => { + self.confirm_payment_untaken(intent, withdrawal).await?; + true + } + None => false, + }; let holdings = match (&session.deposit, registry.conversion.get()) { (None, _) => None, (Some(_), None) => return Err(CancelFundingError::Unconfirmed), @@ -994,7 +1007,13 @@ impl RuntimeServices { } }; registry - .cancel(self.platform.as_ref(), current_unix_millis(), intent, holdings) + .cancel( + self.platform.as_ref(), + current_unix_millis(), + intent, + holdings, + confirmed_withdrawal, + ) .await } @@ -1012,21 +1031,32 @@ impl RuntimeServices { pub async fn retry_funding(self: &Arc, intent: &str) -> Result<(), AssignDepositError> { let intent = intent.to_string(); let now_ms = current_unix_millis(); + let retrying = intent.clone(); self.funding() .commit(self.platform.as_ref(), now_ms, move |sessions| { let retried = sessions - .get_mut(&intent) + .get_mut(&retrying) .ok_or(AssignDepositError::NotFound) .and_then(|session| { session .retry(now_ms) .map_err(|refusal| AssignDepositError::Refused(refusal.to_string())) }); - let changed = if retried.is_ok() { vec![intent] } else { Vec::new() }; + let changed = if retried.is_ok() { vec![retrying] } else { Vec::new() }; (retried, changed) }) .await??; + // A payment asked for again needs the user's approval again. + let asks_payment = self + .funding() + .get(&intent) + .is_some_and(|session| session.stage == FundingStage::Open && session.withdrawal.is_some()); self.watch_funding_deposits(); + if asks_payment { + self.request_withdrawal_payment(&intent) + .await + .map_err(|error| AssignDepositError::Refused(error.to_string()))?; + } Ok(()) } @@ -1155,6 +1185,7 @@ impl RuntimeServices { if let Err(reason) = services.advance_credits().await { tracing::warn!(%reason, "funding credit pass failed"); } + services.advance_withdrawals().await; } })); } @@ -1675,11 +1706,11 @@ mod tests { balance: 3, }; - let refused = block_on(registry.cancel(storage.as_ref(), NOW, "fs_1", Some(vec![stray]))); + let refused = block_on(registry.cancel(storage.as_ref(), NOW, "fs_1", Some(vec![stray]), false)); let holdings = || registry.get("fs_1").and_then(|session| session.deposit).map(|deposit| deposit.holdings); let after_fresh_read = holdings(); // A read taken before the watch recorded the stray must not erase it. - let stale = block_on(registry.cancel(storage.as_ref(), NOW, "fs_1", Some(Vec::new()))); + let stale = block_on(registry.cancel(storage.as_ref(), NOW, "fs_1", Some(Vec::new()), false)); assert_eq!( (refused, after_fresh_read, stale, holdings()), @@ -1721,9 +1752,9 @@ mod tests { assert_eq!( ( - block_on(registry.cancel(storage.as_ref(), NOW, "fs_1", Some(vec![paid]))), + block_on(registry.cancel(storage.as_ref(), NOW, "fs_1", Some(vec![paid]), false)), registry.get("fs_1").map(|session| session.step()), - block_on(registry.cancel(storage.as_ref(), NOW, "fs_2", None)), + block_on(registry.cancel(storage.as_ref(), NOW, "fs_2", None, false)), ), ( Err(CancelFundingError::Refused(CancelRefusal::Underway)), @@ -1733,6 +1764,36 @@ mod tests { ); } + // A withdrawal is cancelled only after its account and payment were read + // fresh, so one assigned after the cancel looked is not cancelled unread. + #[test] + fn a_withdrawal_is_never_cancelled_unread() { + let storage = stub_platform(); + let registry = FundingRegistry::default(); + let mut withdrawing = FundingSession { + direction: FundingDirection::Out, + amount: Some(1_000), + ..session("fs_1", NOW) + }; + withdrawing.assign_withdrawal(crate::host_logic::funding::FundingWithdrawal { + destination_id: "dot-assethub".into(), + number: 1, + account: [4; 32], + attempt: 0, + since_ms: NOW, + taken: false, + }); + insert(®istry, storage.as_ref(), withdrawing); + + assert_eq!( + ( + block_on(registry.cancel(storage.as_ref(), NOW, "fs_1", None, false)), + block_on(registry.cancel(storage.as_ref(), NOW, "fs_1", None, true)), + ), + (Err(CancelFundingError::Unconfirmed), Ok(())) + ); + } + // The host's list shows what is in flight first, then what ended, each // newest first, as getcash lists its requests. #[test] diff --git a/rust/crates/truapi/src/runtime/funding/credit.rs b/rust/crates/truapi/src/runtime/funding/credit.rs index 242d67d713..af346659d5 100644 --- a/rust/crates/truapi/src/runtime/funding/credit.rs +++ b/rust/crates/truapi/src/runtime/funding/credit.rs @@ -17,7 +17,7 @@ use truapi::latest::{ }; use super::FundingSigner; -use crate::host_logic::funding::{CreditProgress, CreditStep, FundingDeposit}; +use crate::host_logic::funding::{CreditProgress, CreditStep, FundingDeposit, funding_attempt_id}; use crate::platform::{ProductContext, TopUpPlatform}; /// Smallest amount a top-up claims, in CASH units: what the account holds is @@ -132,7 +132,7 @@ impl Credit<'_> { source: PaymentTopUpSource::PrivateKey { sr25519_secret_key: keypair.secret.to_bytes(), }, - id: top_up_id(&deposit.account, attempt), + id: funding_attempt_id(&deposit.account, attempt), }; match self.top_up.top_up(self.product, request).await { Ok(()) | Err(HostPaymentTopUpError::AlreadyExists) => { @@ -157,7 +157,7 @@ impl Credit<'_> { { let mut statuses = self .top_up - .subscribe_top_up_status(self.product, top_up_id(&deposit.account, attempt)); + .subscribe_top_up_status(self.product, funding_attempt_id(&deposit.account, attempt)); super::within_timeout(STATUS_TIMEOUT, statuses.next()) .await .ok() @@ -165,15 +165,6 @@ impl Credit<'_> { } } -/// The id of top-up `attempt` from `account`: the account itself first, then -/// `blake2_256(account ‖ attempt)`, as getcash numbers them. -fn top_up_id(account: &[u8; 32], attempt: u8) -> [u8; 32] { - if attempt == 0 { - return *account; - } - sp_crypto_hashing::blake2_256(&[account.as_slice(), &u32::from(attempt).to_le_bytes()].concat()) -} - #[cfg(test)] mod tests { use std::sync::Mutex; diff --git a/rust/crates/truapi/src/runtime/funding/withdraw.rs b/rust/crates/truapi/src/runtime/funding/withdraw.rs new file mode 100644 index 0000000000..b983171f1c --- /dev/null +++ b/rust/crates/truapi/src/runtime/funding/withdraw.rs @@ -0,0 +1,326 @@ +//! Withdrawals: the user's CASH paid into a withdrawal account on People, +//! the way getcash pays its withdrawal key. +//! +//! Each outbound session gets its own account under the funding product. +//! The host asks the user to approve a payment into it, under an id taken +//! from the account and the attempt; the watch then reads the account's +//! CASH and the host's word on the payment until the CASH is there. + +use core::time::Duration; +use std::sync::Arc; + +use futures::StreamExt; +use truapi::latest::{ + GenericError, HostPaymentError, HostPaymentRequest, HostPaymentStatusSubscribeError, +}; + +use super::conversion::ConversionChains; +use super::{ + CANCEL_CONFIRM, CancelFundingError, FundingRegistry, MAX_USED_ACCOUNTS, within_chain_timeout, + within_timeout, +}; +use crate::host_logic::funding::{ + FundingSessionError, FundingWithdrawal, PaymentReading, PaymentWord, funding_attempt_id, +}; +use crate::platform::{CoreStorage, ProductContext}; +use crate::runtime::services::RuntimeServices; +use crate::unix_time::current_unix_millis; + +/// Longest the host may take to report a payment's current status, as +/// getcash waits. +const STATUS_TIMEOUT: Duration = Duration::from_secs(15); +/// Longest one watch pass waits on a withdrawal account and its payment. +const WATCH_READ: Duration = Duration::from_secs(45); + +/// Why a withdrawal could not be started or its payment asked for. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] +pub enum WithdrawError { + /// No such session. + #[display("no such funding session")] + NotFound, + /// The session is not an open outbound one naming its amount, without a + /// withdrawal account. + #[display("funding session is not awaiting a withdrawal account")] + NotAwaitingWithdrawal, + /// No signing host converts funding on this runtime. + #[display("this host does not convert funding")] + ConversionUnavailable, + /// The host has no payment engine. + #[display("this host does not take payments")] + PaymentsUnavailable, + /// Every account tried already holds CASH. + #[display("every withdrawal account tried already holds CASH")] + AccountsInUse, + /// The account could not be derived. + #[display("{}", _0.reason)] + Derive(GenericError), + /// The chain could not be read. + #[display("{}", _0.reason)] + Chain(GenericError), + /// The host or the user refused the payment. + #[display("{_0}")] + Refused(HostPaymentError), + /// The session could not be saved. + #[display("{_0}")] + Storage(FundingSessionError), +} + +impl From for WithdrawError { + fn from(error: FundingSessionError) -> Self { + Self::Storage(error) + } +} + +impl FundingRegistry { + /// Every session whose withdrawal account is still read, with it. + fn withdrawing_sessions(&self, now_ms: u64) -> Vec<(String, FundingWithdrawal)> { + self.lock_sessions() + .values() + .filter_map(|session| { + let withdrawal = session.watched_withdrawal(now_ms)?; + Some((session.intent.clone(), withdrawal.clone())) + }) + .collect() + } + + /// Record a reading of session `intent`'s withdrawal account. + async fn record_withdrawal( + &self, + storage: &(impl CoreStorage + ?Sized), + now_ms: u64, + intent: &str, + cash: u128, + payment: PaymentReading, + ) -> Result<(), FundingSessionError> { + let intent = intent.to_string(); + self.commit(storage, now_ms, move |sessions| { + let changed = sessions + .get_mut(&intent) + .is_some_and(|session| session.observe_withdrawal(cash, payment, now_ms)); + ((), if changed { vec![intent] } else { Vec::new() }) + }) + .await + } +} + +impl RuntimeServices { + /// Give open outbound session `intent` a withdrawal account for + /// `destination_id`, the next one whose account holds no CASH, and ask + /// the host to have the user pay the session's amount into it. Returns + /// the account once the user has decided. + pub async fn assign_funding_withdrawal( + self: &Arc, + intent: &str, + destination_id: &str, + derive: impl Fn(u32) -> Result<[u8; 32], GenericError>, + ) -> Result<[u8; 32], WithdrawError> { + let registry = self.funding(); + let session = registry.get(intent).ok_or(WithdrawError::NotFound)?; + if !session.awaits_withdrawal() { + return Err(WithdrawError::NotAwaitingWithdrawal); + } + let conversion = registry + .conversion + .get() + .ok_or(WithdrawError::ConversionUnavailable)?; + if self.payment_platform().is_none() { + return Err(WithdrawError::PaymentsUnavailable); + } + let chains = self + .funding_chains(conversion.network, false) + .await + .map_err(|error| WithdrawError::Chain(GenericError { + reason: error.to_string(), + }))?; + let storage = self.platform.as_ref(); + // getcash numbers withdrawals per destination under `wd:`, apart + // from deposit sources. + let counter = format!("wd:{destination_id}"); + for _ in 0..MAX_USED_ACCOUNTS { + let number = registry.next_account_number(storage, &counter).await?; + let account = derive(number).map_err(WithdrawError::Derive)?; + let cash = within_chain_timeout(chains.landed(&account)) + .await + .map_err(WithdrawError::Chain)? + .map_err(|error| WithdrawError::Chain(GenericError { + reason: error.to_string(), + }))?; + if cash > 0 { + continue; + } + let withdrawal = FundingWithdrawal { + destination_id: destination_id.to_string(), + number, + account, + attempt: 0, + since_ms: current_unix_millis(), + taken: false, + }; + let owned = intent.to_string(); + let assigned = registry + .commit(storage, current_unix_millis(), move |sessions| { + let assigned = sessions + .get_mut(&owned) + .is_some_and(|session| session.assign_withdrawal(withdrawal)); + (assigned, if assigned { vec![owned] } else { Vec::new() }) + }) + .await?; + if !assigned { + return Err(WithdrawError::NotAwaitingWithdrawal); + } + // Watched before the request, which waits on the user, so the + // payment window runs and CASH is seen whatever the host does. + self.watch_funding_deposits(); + self.request_withdrawal_payment(intent).await?; + return Ok(account); + } + Err(WithdrawError::AccountsInUse) + } + + /// Ask the host to have the user pay session `intent`'s amount into its + /// withdrawal account, under the current attempt's id. Resolves once the + /// user has decided, which can take as long as the host's sheet stays + /// up. A refusal ends the session for a retry under the next one. + pub async fn request_withdrawal_payment(self: &Arc, intent: &str) -> Result<(), WithdrawError> { + let registry = self.funding(); + let session = registry.get(intent).ok_or(WithdrawError::NotFound)?; + let (Some(withdrawal), Some(amount)) = (session.withdrawal, session.amount) else { + return Err(WithdrawError::NotAwaitingWithdrawal); + }; + let conversion = registry + .conversion + .get() + .ok_or(WithdrawError::ConversionUnavailable)?; + let platform = self + .payment_platform() + .ok_or(WithdrawError::PaymentsUnavailable)?; + let product = ProductContext { + product_id: conversion.signer.funding_product_id(), + execution_kind: Default::default(), + }; + let request = HostPaymentRequest { + from: None, + amount, + destination: withdrawal.account, + id: funding_attempt_id(&withdrawal.account, withdrawal.attempt), + }; + match platform.request_payment(&product, request).await { + Ok(()) | Err(HostPaymentError::AlreadyExists) => Ok(()), + Err(error) => { + let message = error.to_string(); + let owned = intent.to_string(); + registry + .commit(self.platform.as_ref(), current_unix_millis(), move |sessions| { + let now_ms = current_unix_millis(); + let changed = sessions + .get_mut(&owned) + .is_some_and(|session| session.refuse_payment(message, now_ms)); + ((), if changed { vec![owned] } else { Vec::new() }) + }) + .await?; + Err(WithdrawError::Refused(error)) + } + } + } + + /// Read `withdrawal`'s CASH on People and the host's word on its current + /// payment, within `limit`. + async fn read_withdrawal( + self: &Arc, + withdrawal: &FundingWithdrawal, + limit: Duration, + ) -> Result<(u128, PaymentReading), String> { + let conversion = self + .funding() + .conversion + .get() + .ok_or_else(|| "this host does not convert funding".to_string())?; + let product = ProductContext { + product_id: conversion.signer.funding_product_id(), + execution_kind: Default::default(), + }; + let platform = self.payment_platform(); + within_timeout(limit, async { + let chains = self + .funding_chains(conversion.network, false) + .await + .map_err(|error| error.to_string())?; + let cash = chains.landed(&withdrawal.account).await.map_err(|error| error.to_string())?; + let word = match &platform { + Some(platform) => { + let id = funding_attempt_id(&withdrawal.account, withdrawal.attempt); + let mut statuses = platform.subscribe_payment_status(&product, id); + match within_timeout(STATUS_TIMEOUT, statuses.next()).await { + Ok(Some(Ok(status))) => PaymentWord::Said(status), + Ok(Some(Err(HostPaymentStatusSubscribeError::PaymentNotFound))) => { + PaymentWord::NotFound + } + Ok(Some(Err(HostPaymentStatusSubscribeError::Unknown { .. })) | None) | Err(_) => { + PaymentWord::Unanswered + } + } + } + None => PaymentWord::Unanswered, + }; + Ok(( + cash, + PaymentReading { + attempt: withdrawal.attempt, + word, + }, + )) + }) + .await + .map_err(|error| error.reason) + .and_then(|read| read) + } + + /// Read session `intent`'s withdrawal account and payment fresh, and + /// record what they show. + async fn refresh_withdrawal( + self: &Arc, + intent: &str, + withdrawal: &FundingWithdrawal, + limit: Duration, + ) -> Result<(), String> { + let (cash, payment) = self.read_withdrawal(withdrawal, limit).await?; + self.funding() + .record_withdrawal(self.platform.as_ref(), current_unix_millis(), intent, cash, payment) + .await + .map_err(|error| error.to_string()) + } + + /// Read session `intent`'s withdrawal account and payment fresh before a + /// cancel, as getcash does, recording what they show: CASH on the + /// account or a payment under way then refuses the cancel. Nothing is + /// cancelled when either cannot be read in time. + pub async fn confirm_payment_untaken( + self: &Arc, + intent: &str, + withdrawal: &FundingWithdrawal, + ) -> Result<(), CancelFundingError> { + let (cash, payment) = self.read_withdrawal(withdrawal, CANCEL_CONFIRM).await.map_err(|reason| { + tracing::warn!(%intent, %reason, "confirming a withdrawal cancel failed"); + CancelFundingError::Unconfirmed + })?; + // The host must have answered for the cancel to know the payment + // was not taken. + if payment.word == PaymentWord::Unanswered { + return Err(CancelFundingError::Unconfirmed); + } + self.funding() + .record_withdrawal(self.platform.as_ref(), current_unix_millis(), intent, cash, payment) + .await?; + Ok(()) + } + + /// One pass over the withdrawal accounts still read: their CASH on + /// People and the host's word on each payment. + pub async fn advance_withdrawals(self: &Arc) { + for (intent, withdrawal) in self.funding().withdrawing_sessions(current_unix_millis()) { + if let Err(reason) = self.refresh_withdrawal(&intent, &withdrawal, WATCH_READ).await { + tracing::warn!(%intent, %reason, "reading a withdrawal account failed"); + } + } + } +} diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 7f5da9dafb..d21b6da50f 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -2838,6 +2838,8 @@ fn a_top_up_needs_a_session() { struct RecordingPaymentPlatform { requested: Mutex>, followed: Mutex>, + /// Refuses the next request with this, once. + refuse: Mutex>, } #[truapi::async_trait] @@ -2851,7 +2853,10 @@ impl crate::platform::PaymentPlatform for RecordingPaymentPlatform { .lock() .expect("requested mutex poisoned") .push((product.product_id.clone(), request)); - Ok(()) + match self.refuse.lock().expect("refuse mutex poisoned").take() { + Some(refusal) => Err(refusal), + None => Ok(()), + } } fn subscribe_payment_status( @@ -2976,6 +2981,97 @@ fn no_product_pays_as_the_funding_product_or_without_a_session() { ); } +/// A signer for the funding product with no keys, enough for core to make +/// payments as the funding product. +struct FundingProduct; + +impl crate::runtime::FundingSigner for FundingProduct { + fn deposit_keypair( + &self, + _: &str, + _: u32, + ) -> Result, truapi::latest::GenericError> { + Ok(None) + } + + fn funding_product_id(&self) -> String { + "fund.dot".into() + } +} + +// The user pays a withdrawal into its account through the host, as the +// funding product, under getcash's payment ids: the account first, then a +// new id for each retry, since the host refuses an id it has seen. A refused +// payment ends the session for that retry, which asks the user again. +#[test] +fn a_withdrawal_is_paid_through_the_host_and_a_refusal_is_asked_again() { + let services = funding_services(); + let engine = Arc::new(RecordingPaymentPlatform::default()); + assert!(services.install_payment_platform(engine.clone())); + assert!(services.funding().install_platform(RecordingFundingPlatform::answering( + crate::platform::FundingPresentOutcome::Started, + ))); + assert!(services.funding().install_conversion( + crate::runtime::FundingNetwork { cash_asset_id: 1 }, + Vec::new(), + Arc::new(FundingProduct), + )); + let intent = futures::executor::block_on(services.open_funding(None, v01::FundingDirection::Out, Some(1_000))) + .expect("opened") + .intent; + let account = [4; 32]; + futures::executor::block_on(services.funding().commit( + services.platform.as_ref(), + crate::runtime::current_unix_millis(), + |sessions| { + if let Some(session) = sessions.get_mut(&intent) { + session.assign_withdrawal(crate::host_logic::funding::FundingWithdrawal { + destination_id: "dot-assethub".into(), + number: 1, + account, + attempt: 0, + since_ms: crate::runtime::current_unix_millis(), + taken: false, + }); + } + ((), Vec::new()) + }, + )) + .expect("assigned"); + *engine.refuse.lock().expect("refuse mutex poisoned") = Some(v01::HostPaymentError::Rejected); + + let refused = futures::executor::block_on(services.request_withdrawal_payment(&intent)); + let failed = services.funding().get(&intent).map(|session| session.step()); + let retried = futures::executor::block_on(services.retry_funding(&intent)).is_ok(); + let paid_to = |id| v01::HostPaymentRequest { + from: None, + amount: 1_000, + destination: account, + id, + }; + + assert_eq!( + ( + refused.is_err(), + failed, + retried, + engine.requested.lock().expect("requested mutex poisoned").clone(), + ), + ( + true, + Some(crate::host_logic::funding::FundingStep::Failed), + true, + vec![ + ("fund.dot".to_string(), paid_to(account)), + ( + "fund.dot".to_string(), + paid_to(crate::host_logic::funding::funding_attempt_id(&account, 1)) + ), + ], + ) + ); +} + #[test] fn chain_follow_ids_are_scoped_per_product_core() { let (host_config, product) = runtime_config("same.dot"); From b86afc27b90ac8134ba00abbfe891db87d752cc2 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Tue, 6 Oct 2026 14:31:07 +0200 Subject: [PATCH 3/4] feat(truapi): move paid withdrawals to Asset Hub as getcash does --- .changeset/funding-withdraw-convert.md | 5 + .../truapi-host-cli/src/funding_check.rs | 3 +- rust/crates/truapi/RUNTIME.md | 11 + rust/crates/truapi/src/host_logic/funding.rs | 286 +++++- rust/crates/truapi/src/runtime/funding.rs | 59 +- .../truapi/src/runtime/funding/conversion.rs | 169 ++-- .../runtime/funding/conversion/withdraw.rs | 938 ++++++++++++++++++ .../truapi/src/runtime/funding/credit.rs | 8 + .../truapi/src/runtime/funding/withdraw.rs | 470 ++++++++- .../crates/truapi/src/runtime/signing_host.rs | 11 + rust/crates/truapi/src/runtime/tests.rs | 8 + 11 files changed, 1872 insertions(+), 96 deletions(-) create mode 100644 .changeset/funding-withdraw-convert.md create mode 100644 rust/crates/truapi/src/runtime/funding/conversion/withdraw.rs diff --git a/.changeset/funding-withdraw-convert.md b/.changeset/funding-withdraw-convert.md new file mode 100644 index 0000000000..2525af40a6 --- /dev/null +++ b/.changeset/funding-withdraw-convert.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": patch +--- + +A paid withdrawal's CASH moves to Asset Hub as getcash moves it: a fee swap on People, then one XCM sized by dry runs on both chains that sells the CASH for PAS on Asset Hub, landing on the withdrawal account (`Withdrawn`). diff --git a/rust/crates/truapi-host-cli/src/funding_check.rs b/rust/crates/truapi-host-cli/src/funding_check.rs index 9fda903286..c91fe27497 100644 --- a/rust/crates/truapi-host-cli/src/funding_check.rs +++ b/rust/crates/truapi-host-cli/src/funding_check.rs @@ -376,7 +376,8 @@ async fn follow(runtime: &SigningHostRuntime, intent: &str) -> Result<()> { | FundingStage::Converting { .. } | FundingStage::Converted | FundingStage::Crediting { .. } - | FundingStage::Paid { .. } => {} + | FundingStage::Paid { .. } + | FundingStage::Withdrawn { .. } => {} } tokio::time::sleep(POLL).await; } diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 28c994f29e..7166aa3149 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -441,6 +441,17 @@ AutoSigning without approval. Legacy-account signing still asks the user. still read, and one never taken expires after 30 minutes. A cancel reads both first and is refused once the payment is taken. The watch starts before the request, which resolves only once the user has decided. + A `Paid` session's CASH then moves to Asset Hub as getcash moves it, one + transaction per pass decided from the account's balances: a pool swap on + People, paid in CASH, buys the PAS the fees need, then one XCM, paid in + that PAS, carries all the CASH and PAS to Asset Hub, sells the CASH there + for PAS and deposits it on the withdrawal account. The XCM is sized by dry + runs on People (fees measured with all the PAS, then an exact allowance + that traps nothing) and on Asset Hub (what lands); CASH teleports when + Asset Hub trusts People for it and is reserve-withdrawn otherwise. It has + landed once the account holds no CASH and Asset Hub shows at least the + dry run's landing less 5%, and the session is `Withdrawn`; three rejected + transactions hold it for `retry_funding`. `cancel_funding` cancels a session while nothing has arrived, as getcash does: it is refused once a deposit was seen or while anything is on the deposit account, which is read first, and nothing is cancelled if that diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index 8736d4b2b3..5920a93401 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -154,7 +154,8 @@ pub enum FundingStep { DepositSeen, /// The conversion to CASH is on its way. Converting, - /// The CASH is on People. + /// The converted funds reached the other chain: an on-ramp's CASH on + /// People, a withdrawal's PAS on Asset Hub. Landed, /// The host's top-up is claiming the CASH. Claiming, @@ -453,10 +454,23 @@ pub enum FundingStage { progress: CreditProgress, }, /// Outbound: the user's CASH is on the withdrawal account on People, - /// to be converted and sent on. + /// being moved to Asset Hub. Paid { - /// CASH on the account, in payment balance units. + /// CASH the account received, in payment balance units. paid: u128, + /// Withdrawal transactions included on People that failed so far. + rejections: u8, + /// When sizing or People's transaction pool started refusing the + /// next transaction, if they still do, in Unix milliseconds. + refused_since_ms: Option, + /// The withdrawal transaction on its way, if one is. + submission: Option, + }, + /// Outbound: the withdrawal's PAS is on the withdrawal account on Asset + /// Hub, to be paid out. + Withdrawn { + /// PAS that landed, in planck. + landed: u128, }, /// Inbound terminal success: the CASH is in the user's balance. Delivered { @@ -490,6 +504,12 @@ pub enum FundingResume { /// The payment into the withdrawal account did not go through: ask for /// it again, under the next attempt's id. Payment, + /// The CASH or PAS is on the withdrawal account on People: move it to + /// Asset Hub again. + Withdrawal { + /// CASH the account received, in payment balance units. + paid: u128, + }, /// The CASH is on People: credit it from `progress`. Credit { /// What the top-ups claimed, and the attempt to go on with. @@ -563,7 +583,12 @@ impl FundingSession { match &self.stage { FundingStage::Open if self.deposit.is_some() => FundingStep::AwaitingDeposit, FundingStage::Open if self.withdrawal.is_some() => FundingStep::AwaitingPayment, + FundingStage::Paid { + submission: Some(WithdrawSubmission::Transfer { .. }), + .. + } => FundingStep::Converting, FundingStage::Paid { .. } => FundingStep::Paid, + FundingStage::Withdrawn { .. } => FundingStep::Landed, FundingStage::Open => FundingStep::Started, FundingStage::Converting { submission: None, .. @@ -638,7 +663,8 @@ impl FundingSession { | FundingStage::Converting { .. } | FundingStage::Converted | FundingStage::Crediting { .. } - | FundingStage::Paid { .. } => None, + | FundingStage::Paid { .. } + | FundingStage::Withdrawn { .. } => None, FundingStage::Delivered { settled_at_ms, .. } => Some(settled_at_ms), FundingStage::Failed { settled_at_ms, .. } => Some(settled_at_ms), } @@ -659,7 +685,8 @@ impl FundingSession { FundingStage::Converting { .. } | FundingStage::Converted | FundingStage::Crediting { .. } - | FundingStage::Paid { .. }, + | FundingStage::Paid { .. } + | FundingStage::Withdrawn { .. }, _, ) => HostFundingStatusSubscribeItem::Converting, (FundingStage::Delivered { credited, .. }, _) => { @@ -835,6 +862,12 @@ impl FundingSession { withdrawal.taken = false; FundingStage::Open } + FundingResume::Withdrawal { paid } => FundingStage::Paid { + paid, + rejections: 0, + refused_since_ms: None, + submission: None, + }, FundingResume::Credit { progress } => FundingStage::Crediting { progress: CreditProgress { claim: progress.claim.map(|claim| Claim { @@ -1008,7 +1041,12 @@ impl FundingSession { return false; } if cash > 0 { - self.stage = FundingStage::Paid { paid: cash }; + self.stage = FundingStage::Paid { + paid: cash, + rejections: 0, + refused_since_ms: None, + submission: None, + }; return true; } if self.stage != FundingStage::Open { @@ -1324,6 +1362,151 @@ impl PsmRefusal { } } +/// A withdrawal transaction on its way on People. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] +pub enum WithdrawSubmission { + /// The pool swap buying the PAS the XCM's fees need. + Swap { + /// The account's nonce it was signed with. + nonce: u32, + /// Last People block it can be included in. + valid_until_block: u64, + /// The account's PAS on People just before, which a swap that went + /// through adds to. + pas_before: u128, + }, + /// The XCM moving everything to Asset Hub. + Transfer { + /// The account's nonce it was signed with. + nonce: u32, + /// Last People block it can be included in. + valid_until_block: u64, + /// When it was submitted, in Unix milliseconds. + submitted_at_ms: u64, + /// The landing account's PAS on Asset Hub just before. + landing_before: u128, + /// PAS the Asset Hub dry run credited to the landing account. + expected_landing: u128, + }, +} + +/// What one pass of a withdrawal's move to Asset Hub found or did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WithdrawStep { + /// A transaction is about to be submitted. + Submitted(WithdrawSubmission), + /// The swap went through; the XCM is next. + Swapped, + /// The submitted transaction's era ended unincluded; the next pass + /// submits again. + Dropped, + /// It was included and failed, which costs a fee. + Rejected { + /// Why. + reason: String, + }, + /// Sizing it, or People's transaction pool, refused it; nothing was + /// spent, and the next pass tries again. + Refused { + /// Why. + reason: String, + }, + /// The PAS reached the landing account on Asset Hub. + Landed { + /// PAS that landed, in planck. + landed: u128, + }, + /// The XCM left People but nothing reached Asset Hub in time; the + /// assets wait in Asset Hub's trap for the account to claim. + Stalled, +} + +/// Rejections at inclusion, after a passing dry run, before a withdrawal is +/// held, as getcash gives up on a transaction rejected three times. +const MAX_WITHDRAW_REJECTIONS: u8 = 3; +/// How long sizing or the pool may keep refusing a withdrawal's next +/// transaction before it is held, as getcash bounds a run's worked time. +const WITHDRAW_REFUSAL_WINDOW_MS: u64 = 15 * 60 * 1_000; + +impl FundingSession { + /// The withdrawal account and the transaction on its way, of a session + /// whose CASH is being moved to Asset Hub. + pub fn withdrawing(&self) -> Option<(&FundingWithdrawal, Option)> { + match (&self.stage, &self.withdrawal) { + (FundingStage::Paid { submission, .. }, Some(withdrawal)) => Some((withdrawal, *submission)), + _ => None, + } + } + + /// Advance a withdrawal being moved to Asset Hub by one step. Returns + /// whether the session changed. + pub fn advance_withdrawal(&mut self, step: WithdrawStep, now_ms: u64) -> bool { + let FundingStage::Paid { + paid, + rejections, + refused_since_ms, + submission, + } = &mut self.stage + else { + return false; + }; + match step { + WithdrawStep::Submitted(submitted) => { + *submission = Some(submitted); + *refused_since_ms = None; + } + WithdrawStep::Refused { reason } => { + *submission = None; + let since = *refused_since_ms.get_or_insert(now_ms); + if now_ms.saturating_sub(since) > WITHDRAW_REFUSAL_WINDOW_MS { + let paid = *paid; + return self.fail_resumable( + FundingFailure::Other { + code: "withdraw_refused".into(), + message: reason, + }, + Some(FundingResume::Withdrawal { paid }), + now_ms, + ); + } + } + WithdrawStep::Swapped | WithdrawStep::Dropped => *submission = None, + WithdrawStep::Rejected { reason } => { + *submission = None; + *rejections = rejections.saturating_add(1); + if *rejections >= MAX_WITHDRAW_REJECTIONS { + let paid = *paid; + return self.fail_resumable( + FundingFailure::Other { + code: "withdraw_rejected".into(), + message: format!( + "the withdrawal was rejected {MAX_WITHDRAW_REJECTIONS} times, last: {reason}" + ), + }, + Some(FundingResume::Withdrawal { paid }), + now_ms, + ); + } + } + WithdrawStep::Landed { landed } => self.stage = FundingStage::Withdrawn { landed }, + WithdrawStep::Stalled => { + return self.fail( + FundingFailure::Other { + code: "withdraw_stalled".into(), + message: "the withdrawal left People but never reached Asset Hub; its assets wait in Asset Hub's trap for the withdrawal account".into(), + }, + now_ms, + ); + } + } + true + } +} + /// What one pass of a conversion found or did. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConversionStep { @@ -1861,6 +2044,97 @@ mod tests { ); } + fn paid() -> FundingSession { + let mut session = withdrawing(true); + session.observe_withdrawal( + 1_000, + PaymentReading { + attempt: 0, + word: PaymentWord::Unanswered, + }, + NOW, + ); + session + } + + // getcash gives a withdrawal up after a transaction is rejected three + // times at inclusion, with the funds still on the account for a retry, + // which starts the count again; landing on Asset Hub ends the move. + #[test] + fn a_withdrawal_move_is_held_after_three_rejections_and_lands_on_asset_hub() { + let rejected = || WithdrawStep::Rejected { reason: "no".into() }; + let mut held = paid(); + for _ in 0..3 { + held.advance_withdrawal(rejected(), NOW); + } + let held_stage = held.stage.clone(); + held.retry(NOW + 1).expect("retried"); + let mut landed = paid(); + landed.advance_withdrawal(WithdrawStep::Landed { landed: 950 }, NOW); + + assert_eq!( + (held_stage, held.stage, landed.step(), landed.stage), + ( + FundingStage::Failed { + reason: FundingFailure::Other { + code: "withdraw_rejected".into(), + message: "the withdrawal was rejected 3 times, last: no".into(), + }, + settled_at_ms: NOW, + resume: Some(FundingResume::Withdrawal { paid: 1_000 }), + }, + FundingStage::Paid { + paid: 1_000, + rejections: 0, + refused_since_ms: None, + submission: None, + }, + FundingStep::Landed, + FundingStage::Withdrawn { landed: 950 }, + ) + ); + } + + // A refusal costs nothing and may pass, so it is tried again, as getcash + // retries a tick that throws, until refusing has gone on longer than a + // run may take; a transaction that goes out clears it. + #[test] + fn refusals_hold_a_withdrawal_only_after_the_window() { + let refused = || WithdrawStep::Refused { reason: "no quote".into() }; + let mut kept = paid(); + kept.advance_withdrawal(refused(), NOW); + kept.advance_withdrawal(refused(), NOW + WITHDRAW_REFUSAL_WINDOW_MS); + let mut held = kept.clone(); + held.advance_withdrawal(refused(), NOW + WITHDRAW_REFUSAL_WINDOW_MS + 1); + let mut cleared = kept.clone(); + cleared.advance_withdrawal( + WithdrawStep::Submitted(WithdrawSubmission::Swap { + nonce: 1, + valid_until_block: 10, + pas_before: 0, + }), + NOW + 1, + ); + + assert_eq!( + (kept.step(), held.step(), cleared.stage), + ( + FundingStep::Paid, + FundingStep::Failed, + FundingStage::Paid { + paid: 1_000, + rejections: 0, + refused_since_ms: None, + submission: Some(WithdrawSubmission::Swap { + nonce: 1, + valid_until_block: 10, + pas_before: 0, + }), + }, + ) + ); + } + // A deposit screen and a provider must see the address Asset Hub // wallets and Chainflip accept: prefix 0, as getcash encodes it. #[test] diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs index 4100c5c929..7f4370532c 100644 --- a/rust/crates/truapi/src/runtime/funding.rs +++ b/rust/crates/truapi/src/runtime/funding.rs @@ -29,7 +29,7 @@ mod conversion; mod credit; mod withdraw; -use conversion::{Chains, ConversionChains, ConversionError}; +use conversion::{Chains, ConversionChains, ConversionError, SigningChain}; #[cfg(test)] use conversion::Prepared; pub use conversion::{FundingNetwork, FundingSigner}; @@ -65,6 +65,17 @@ const STALL_AFTER_MS: u64 = 30 * 60 * 1_000; /// Longest a cancel waits to confirm the deposit account is empty, as /// getcash waits. const CANCEL_CONFIRM: Duration = Duration::from_secs(8); +/// The chain a pass signs on, whose signed-extension metadata it loads. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SignOn { + /// It only reads. + Nowhere, + /// It signs a conversion on Asset Hub. + AssetHub, + /// It signs a withdrawal on People. + People, +} + /// Numbered accounts skipped for already holding funds before assignment /// gives up. const MAX_USED_ACCOUNTS: usize = 16; @@ -490,6 +501,7 @@ impl FundingRegistry { .any(|session| { session.watched_deposit(current_unix_millis()).is_some() || session.watched_withdrawal(current_unix_millis()).is_some() + || session.withdrawing().is_some() || session.converting().is_some() || session.crediting().is_some() }) @@ -926,7 +938,7 @@ impl RuntimeServices { let network = self.funding_network()?; let quoting = session.amount.is_some() && session.quote.is_none(); let chains = self - .funding_chains(network, quoting) + .funding_chains(network, if quoting { SignOn::AssetHub } else { SignOn::Nowhere }) .await .map_err(AssignDepositError::from_conversion)?; // A session that names its amount must be paid enough to credit it, @@ -988,7 +1000,7 @@ impl RuntimeServices { (Some(deposit), Some(conversion)) => { let read = within_timeout(CANCEL_CONFIRM, async { let chains = self - .funding_chains(conversion.network, false) + .funding_chains(conversion.network, SignOn::Nowhere) .await .map_err(|error| GenericError { reason: error.to_string(), @@ -1082,7 +1094,7 @@ impl RuntimeServices { None => return Err(AssignDepositError::Refused(AcceptRefusal::NothingArrived.to_string())), }; let chains = self - .funding_chains(self.funding_network()?, false) + .funding_chains(self.funding_network()?, SignOn::Nowhere) .await .map_err(AssignDepositError::from_conversion)?; let route = within_chain_timeout(chains.choose_route(asset, arrived)) @@ -1122,7 +1134,7 @@ impl RuntimeServices { .ok_or(AssignDepositError::NotFound)?; let target = session.amount.ok_or(AssignDepositError::NoAmount)?; let chains = self - .funding_chains(self.funding_network()?, true) + .funding_chains(self.funding_network()?, SignOn::AssetHub) .await .map_err(AssignDepositError::from_conversion)?; self.record_quote(intent, &chains, asset, target) @@ -1186,6 +1198,9 @@ impl RuntimeServices { tracing::warn!(%reason, "funding credit pass failed"); } services.advance_withdrawals().await; + if let Err(reason) = services.advance_withdrawal_moves().await { + tracing::warn!(%reason, "funding withdrawal move failed"); + } } })); } @@ -1195,7 +1210,7 @@ impl RuntimeServices { async fn funding_chains( &self, network: FundingNetwork, - signing: bool, + signing: SignOn, ) -> Result { within_chain_timeout(async { let failed = |error: &dyn core::fmt::Display| ConversionError::Chain(error.to_string()); @@ -1218,31 +1233,29 @@ impl RuntimeServices { .online_client(&people_genesis) .await .map_err(|error| failed(&error))?; - let extensions = match signing { - true => Some(self.signing_metadata(asset_hub_genesis).await?), - false => None, + let signing = match signing { + SignOn::Nowhere => None, + SignOn::AssetHub => Some(SigningChain::AssetHub(self.signing_metadata(asset_hub_genesis).await?)), + SignOn::People => Some(SigningChain::People(self.signing_metadata(people_genesis).await?)), }; - Chains::at_finalized(&asset_hub, &people, network, extensions).await + Chains::at_finalized(&asset_hub, &people, network, signing).await }) .await .map_err(|error| ConversionError::Chain(error.reason))? } - /// Asset Hub's signed-extension metadata from the per-chain cache the + /// A chain's signed-extension metadata from the per-chain cache the /// allowance path keeps, so signing never downloads it again. - async fn signing_metadata( - &self, - asset_hub_genesis: [u8; 32], - ) -> Result { + async fn signing_metadata(&self, genesis: [u8; 32]) -> Result { let failed = |error: &dyn core::fmt::Display| ConversionError::Chain(error.to_string()); let rpc = RpcClient::new(subxt_rpcs::RpcClient::new( self.chain - .rpc_client("funding conversion", &asset_hub_genesis) + .rpc_client("funding conversion", &genesis) .await .map_err(|error| failed(&error))?, )); self.chain_context - .get(&ChainClient::new(rpc, asset_hub_genesis)) + .get(&ChainClient::new(rpc, genesis)) .await .map_err(|error| failed(&error)) } @@ -1271,7 +1284,7 @@ impl RuntimeServices { // Each top-up is sized from what the account holds on People when // it starts, as getcash sizes its claims. let chains = if crediting.iter().any(CreditingSession::sizing) { - match self.funding_chains(conversion.network, false).await { + match self.funding_chains(conversion.network, SignOn::Nowhere).await { Ok(chains) => Some(chains), Err(error) => { tracing::warn!(%error, "reading funding accounts on People failed"); @@ -1341,7 +1354,7 @@ impl RuntimeServices { return Ok(()); } let chains = self - .funding_chains(conversion.network, signing) + .funding_chains(conversion.network, if signing { SignOn::AssetHub } else { SignOn::Nowhere }) .await .map_err(|error| error.to_string())?; let observed = registry @@ -2127,6 +2140,14 @@ mod tests { Ok(self.0.clone()) } + fn withdrawal_keypair( + &self, + _: &str, + _: u32, + ) -> Result, GenericError> { + Ok(self.0.clone()) + } + fn funding_product_id(&self) -> String { "fund.dot".into() } diff --git a/rust/crates/truapi/src/runtime/funding/conversion.rs b/rust/crates/truapi/src/runtime/funding/conversion.rs index 0268fada45..71586ec4be 100644 --- a/rust/crates/truapi/src/runtime/funding/conversion.rs +++ b/rust/crates/truapi/src/runtime/funding/conversion.rs @@ -26,6 +26,10 @@ use crate::host_logic::funding::{ConversionRoute, DepositAsset, DepositQuote, Fu use crate::runtime::statement_allowance::ChainContext; use crate::runtime::statement_allowance::extension::{ChainState, Metadata as ExtensionMetadata}; +mod withdraw; + +pub use withdraw::{PreparedWithdrawal, WithdrawCall, WithdrawChains, landing_floor}; + /// XCM version every program and dry run uses. const XCM_VERSION: u32 = 5; /// Margin added to every fee estimate, in percent. @@ -66,6 +70,14 @@ pub trait FundingSigner: Send + Sync { number: u32, ) -> Result, GenericError>; + /// The keypair of the `number`th withdrawal account for + /// `destination_id`, or `None` while no signing session is active. + fn withdrawal_keypair( + &self, + destination_id: &str, + number: u32, + ) -> Result, GenericError>; + /// The reserved funding product the deposit accounts sit under, which /// the top-ups crediting them are made as. fn funding_product_id(&self) -> String; @@ -77,8 +89,21 @@ pub struct Chains { asset_hub: OnlineClientAtBlock, people: OnlineClientAtBlock, places: Places, - /// Signed-extension metadata, loaded only when a conversion is signed. + /// Asset Hub's signed-extension metadata, loaded only when a conversion + /// is signed. extensions: Option>, + /// People's signed-extension metadata, loaded only when a withdrawal is + /// signed. + people_extensions: Option>, +} + +/// The chain a set of reads will sign on, with its signed-extension +/// metadata. +pub enum SigningChain { + /// A conversion on Asset Hub. + AssetHub(ChainContext), + /// A withdrawal on People. + People(ChainContext), } /// A signed conversion, with what tells later whether it worked. @@ -177,19 +202,22 @@ impl Chains { asset_hub: &subxt::OnlineClient, people: &subxt::OnlineClient, network: FundingNetwork, - signing: Option, + signing: Option, ) -> Result { let asset_hub = asset_hub.at_current_block().await.map_err(chain)?; + let people = people.at_current_block().await.map_err(chain)?; // The cache is checked against the best block; signing at the // finalized one needs the same runtime's extensions. - let extensions = match signing { - Some(context) if context.state.spec_version == asset_hub.spec_version() => { - Some(context.metadata) - } - Some(_) => return Err(chain("Asset Hub is between runtime versions")), - None => None, + let same_runtime = |context: ChainContext, at: &OnlineClientAtBlock, name: &str| { + (context.state.spec_version == at.spec_version()) + .then_some(context.metadata) + .ok_or_else(|| chain(format!("{name} is between runtime versions"))) + }; + let (extensions, people_extensions) = match signing { + Some(SigningChain::AssetHub(context)) => (Some(same_runtime(context, &asset_hub, "Asset Hub")?), None), + Some(SigningChain::People(context)) => (None, Some(same_runtime(context, &people, "People")?)), + None => (None, None), }; - let people = people.at_current_block().await.map_err(chain)?; let asset_hub_para = parachain_id(&asset_hub).await?; let people_para = parachain_id(&people).await?; let assets_pallet = asset_hub @@ -207,6 +235,7 @@ impl Chains { assets_pallet, }, extensions, + people_extensions, }) } @@ -906,43 +935,52 @@ impl Chains { fee_asset: &Value, nonce: u32, ) -> Result, ConversionError> { - let call_data = call.runtime_call().encode(self.asset_hub.metadata_ref())?; - let genesis: [u8; 32] = self - .asset_hub - .genesis_hash() - .ok_or_else(|| chain("Asset Hub genesis unknown"))? - .0; - let state = ChainState { - spec_version: self.asset_hub.spec_version(), - transaction_version: self.asset_hub.transaction_version(), - genesis_hash: genesis, - nonce, - restrict_origins: false, - }; - let payment = self.charge_asset_tx_payment(fee_asset)?; - let block_hash = self.asset_hub.block_hash().0; - let era = Era::mortal(MORTAL_PERIOD_BLOCKS, self.asset_hub.block_number()).encode(); - let extensions: Vec = extensions - .extension_ids() - .into_iter() - .zip(extensions.encode_signed_extensions(&state)) - .map(|(id, encoded)| { - let (extra, additional_signed) = match id { - "CheckMortality" => (era.clone(), block_hash.to_vec()), - "ChargeAssetTxPayment" => (payment.clone(), encoded.additional_signed), - _ => (encoded.extra, encoded.additional_signed), - }; - TxPayloadExtension { - id: id.to_string(), - extra, - additional_signed, - } - }) - .collect(); - Ok(build_signed_extrinsic_v4(signer, &call_data, &extensions)) + sign_on(&self.asset_hub, extensions, signer, &call.runtime_call(), fee_asset, nonce) } } +/// Sign `call` on the chain `at` is pinned to, with `signer` at `nonce`, +/// mortal from that block, paying fees in `fee_asset`. +fn sign_on( + at: &OnlineClientAtBlock, + extensions: &ExtensionMetadata, + signer: &Sr25519Signer, + call: &RuntimeCall, + fee_asset: &Value, + nonce: u32, +) -> Result, ConversionError> { + let call_data = call.encode(at.metadata_ref())?; + let genesis: [u8; 32] = at.genesis_hash().ok_or_else(|| chain("genesis unknown"))?.0; + let state = ChainState { + spec_version: at.spec_version(), + transaction_version: at.transaction_version(), + genesis_hash: genesis, + nonce, + restrict_origins: false, + }; + let payment = charge_asset_tx_payment(at, fee_asset)?; + let block_hash = at.block_hash().0; + let era = Era::mortal(MORTAL_PERIOD_BLOCKS, at.block_number()).encode(); + let extensions: Vec = extensions + .extension_ids() + .into_iter() + .zip(extensions.encode_signed_extensions(&state)) + .map(|(id, encoded)| { + let (extra, additional_signed) = match id { + "CheckMortality" => (era.clone(), block_hash.to_vec()), + "ChargeAssetTxPayment" => (payment.clone(), encoded.additional_signed), + _ => (encoded.extra, encoded.additional_signed), + }; + TxPayloadExtension { + id: id.to_string(), + extra, + additional_signed, + } + }) + .collect(); + Ok(build_signed_extrinsic_v4(signer, &call_data, &extensions)) +} + impl Places { /// CASH as Asset Hub names it. fn cash(&self) -> Value { @@ -1458,27 +1496,28 @@ fn with_margin(fee: u128) -> u128 { fee.saturating_add((fee * FEE_MARGIN_PERCENT).div_ceil(100)) } -impl Chains { - /// `ChargeAssetTxPayment`'s bytes for no tip and fees in `fee_asset`, - /// encoded against the type the runtime declares for it. - fn charge_asset_tx_payment(&self, fee_asset: &Value) -> Result, ConversionError> { - use subxt::ext::scale_encode::EncodeAsType; - let metadata = self.asset_hub.metadata_ref(); - let extension = metadata - .extrinsic() - .transaction_extensions_to_use_for_encoding() - .find(|extension| extension.identifier() == "ChargeAssetTxPayment") - .ok_or_else(|| chain("Asset Hub does not charge fees in assets"))?; - // Fees in the native token are the default and name no asset. - let asset_id = if fee_asset == &native() { - Value::unnamed_variant("None", []) - } else { - Value::unnamed_variant("Some", [fee_asset.clone()]) - }; - Value::named_composite([("tip", Value::u128(0)), ("asset_id", asset_id)]) - .encode_as_type(extension.extra_ty(), metadata.types()) - .map_err(chain) - } +/// `ChargeAssetTxPayment`'s bytes for no tip and fees in `fee_asset`, encoded +/// against the type the chain `at` is pinned to declares for it. +fn charge_asset_tx_payment( + at: &OnlineClientAtBlock, + fee_asset: &Value, +) -> Result, ConversionError> { + use subxt::ext::scale_encode::EncodeAsType; + let metadata = at.metadata_ref(); + let extension = metadata + .extrinsic() + .transaction_extensions_to_use_for_encoding() + .find(|extension| extension.identifier() == "ChargeAssetTxPayment") + .ok_or_else(|| chain("the chain does not charge fees in assets"))?; + // Fees in the native token are the default and name no asset. + let asset_id = if fee_asset == &native() { + Value::unnamed_variant("None", []) + } else { + Value::unnamed_variant("Some", [fee_asset.clone()]) + }; + Value::named_composite([("tip", Value::u128(0)), ("asset_id", asset_id)]) + .encode_as_type(extension.extra_ty(), metadata.types()) + .map_err(chain) } /// A conversion: the XCM program it executes, the PSM mint ahead of it if @@ -2080,7 +2119,7 @@ mod live { )) .await .expect("metadata"); - Chains::at_finalized(&asset_hub, &client(PEOPLE).await, NETWORK, Some(context)) + Chains::at_finalized(&asset_hub, &client(PEOPLE).await, NETWORK, Some(SigningChain::AssetHub(context))) .await .expect("chains pinned") } diff --git a/rust/crates/truapi/src/runtime/funding/conversion/withdraw.rs b/rust/crates/truapi/src/runtime/funding/conversion/withdraw.rs new file mode 100644 index 0000000000..ffb3f29556 --- /dev/null +++ b/rust/crates/truapi/src/runtime/funding/conversion/withdraw.rs @@ -0,0 +1,938 @@ +//! Withdrawals moved from People to Asset Hub, the way getcash's withdrawal +//! tick moves them. +//! +//! A paid withdrawal account holds only CASH. People has no XCM exchanger, +//! so a pool swap paid for in CASH first buys the PAS the XCM's fees need. +//! The XCM comes second and pays in that PAS: it withdraws all the CASH and +//! the PAS, pays People's execution and delivery with an exact allowance, +//! and sends both to Asset Hub, where the program sells the CASH for PAS and +//! deposits it on the landing account. The XCM is sized by dry runs: one +//! pays People's fees with every PAS withdrawn and reads back what was left +//! as trapped, the next confirm an allowance that traps nothing, and Asset +//! Hub runs the forwarded program to say what lands. + +use futures::future::BoxFuture; +use parity_scale_codec::Decode; +use subxt::dynamic::Value; +use subxt::tx::ValidationResult; + +use super::{ + Chains, ConversionError, RuntimeCall, Sr25519Signer, XCM_VERSION, as_u128, beneficiary, + call_api, chain, fetch_value, field, find_variant, fungible_total, items, junction, location, + module_error_names, native, ok, sign_on, u128_at, unversioned, unwrap_newtype, variant_name, + versioned, weight, +}; + +/// CASH set aside for Asset Hub's execution fee, above the one percent +/// floor, as getcash sets it; the unused part is refunded into the sale. +const ASSET_HUB_FEE_BUFFER_CASH: u128 = 10_000; +/// Headroom the swap may spend above the CASH the dry run took, percent. +const SWAP_HEADROOM_PERCENT: u128 = 2; +/// Headroom on the XCM's transaction fee estimate, percent. +const XCM_TX_FEE_HEADROOM_PERCENT: u128 = 5; +/// Rounds of measure and confirm before an exact People allowance is given +/// up on. +const FEE_ROUNDS: usize = 3; +/// An amount whose compact encoding is the longest any amount below 2^64 +/// gets, so a stand-in call is no shorter than the real one. +const LONGEST_AMOUNT: u128 = 1 << 63; +/// How far below the quoted sale the Asset Hub price may move before the +/// program fails there, percent; a landing counts once it reaches the dry +/// run's less this. +pub const WITHDRAW_SLIPPAGE_PERCENT: u128 = 5; +/// Weight ceiling for the XCM when People will not weigh it. +const FALLBACK_MAX_WEIGHT: (u64, u64) = (5_000_000_000, 300_000); + +/// How CASH moves from People to Asset Hub on the network. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CashTransfer { + /// Both chains trust a teleport of CASH. + Teleport, + /// People withdraws CASH from its reserve on Asset Hub. + Reserve, +} + +/// A withdrawal transaction signed and ready to submit on People. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedWithdrawal { + /// The signed extrinsic. + pub extrinsic: Vec, + /// Last People block it can be included in. + pub valid_until_block: u64, + /// What it does. + pub call: WithdrawCall, +} + +/// Which of a withdrawal's two transactions a prepared one is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WithdrawCall { + /// The pool swap buying the PAS the XCM's fees need. + Swap, + /// The XCM moving everything to Asset Hub. + Transfer { + /// PAS the Asset Hub dry run credited to the landing account. + expected_landing: u128, + }, +} + +/// Why a transfer could not be sized. +enum Sizing { + /// The account holds too little PAS for the XCM's fees: swap first. + NeedsSwap, + /// Anything else. + Failed(ConversionError), +} + +impl From for Sizing { + fn from(error: ConversionError) -> Self { + Self::Failed(error) + } +} + +/// Who signs a withdrawal transaction, and where its funds go. +struct WithdrawSigning<'a> { + extensions: &'a crate::runtime::statement_allowance::extension::Metadata, + signer: &'a Sr25519Signer, + nonce: u32, + account: [u8; 32], + landing: [u8; 32], + transfer: CashTransfer, +} + +/// The amounts and accounts one XCM is built from. +#[derive(Debug, Clone, Copy)] +struct Transfer { + cash: u128, + pas_to_withdraw: u128, + pay_fees_pas: u128, + remote_fees_cash: u128, + min_pas_out: u128, + landing: [u8; 32], + claimer: [u8; 32], + transfer: CashTransfer, +} + +impl Chains { + /// `account`'s CASH and PAS on People. + pub async fn people_holdings(&self, account: &[u8; 32]) -> Result<(u128, u128), ConversionError> { + let cash = self.people_cash(account).await?; + let pas = match fetch_value(&self.people, "System", "Account", vec![Value::from_bytes(account)]).await? { + Some(entry) => u128_at(field(&entry, "data")?, "free")?, + None => 0, + }; + Ok((cash, pas)) + } + + /// `account`'s next nonce on People. + pub async fn people_nonce(&self, account: &[u8; 32]) -> Result { + let nonce = call_api( + &self.people, + "AccountNonceApi", + "account_nonce", + vec![Value::from_bytes(account)], + ) + .await?; + u32::try_from(as_u128(&nonce)?).map_err(chain) + } + + /// The finalized People block these reads are pinned to. + pub fn people_block(&self) -> u64 { + self.people.block_number() + } + + /// `account`'s PAS on Asset Hub. + pub async fn asset_hub_native(&self, account: &[u8; 32]) -> Result { + self.asset_hub_balance(crate::host_logic::funding::DepositAsset::Native, account) + .await + } + + /// The next withdrawal transaction for the account `keypair` holds at + /// `nonce`, landing on `landing`: the XCM when the account holds the PAS + /// its fees need, the swap buying it otherwise. + pub async fn prepare_withdrawal( + &self, + keypair: &schnorrkel::Keypair, + nonce: u32, + landing: [u8; 32], + ) -> Result { + let extensions = self + .people_extensions + .as_deref() + .ok_or_else(|| chain("People's signing metadata was not loaded"))?; + let signer = Sr25519Signer::from_keypair(keypair); + let account = keypair.public.to_bytes(); + let (cash, pas) = self.people_holdings(&account).await?; + if cash == 0 { + return Err(ConversionError::Refused("the withdrawal account holds no CASH".into())); + } + let signing = WithdrawSigning { + extensions, + signer: &signer, + nonce, + account, + landing, + transfer: self.cash_transfer().await?, + }; + if pas > 0 { + match self.size_transfer(&signing, cash, pas).await { + Ok(prepared) => return Ok(prepared), + Err(Sizing::NeedsSwap) => {} + Err(Sizing::Failed(error)) => return Err(error), + } + } + self.size_swap(&signing, cash).await + } + + /// Submit a signed People transaction, after People's transaction pool + /// accepts it. Only the pool's refusal is a refusal. + pub async fn submit_on_people(&self, extrinsic: Vec) -> Result<(), ConversionError> { + let transaction = self.people.tx().from_bytes(extrinsic); + match transaction.validate().await.map_err(chain)? { + ValidationResult::Valid(_) => {} + invalid => { + return Err(ConversionError::Refused(format!( + "People rejects the transaction: {invalid:?}" + ))); + } + } + transaction.submit().await.map(|_| ()).map_err(chain) + } + + /// How CASH moves, as getcash chooses it from the trust each runtime + /// enforces: teleport when Asset Hub trusts People as a CASH teleporter + /// and People agrees or does not answer; reserve withdrawal when People + /// trusts Asset Hub as CASH's reserve or does not answer; neither + /// otherwise. + async fn cash_transfer(&self) -> Result { + let people_answers = self + .people + .metadata_ref() + .runtime_api_trait_by_name("TrustedQueryApi") + .is_some(); + let asset_hub_teleports = trusted( + &self.asset_hub, + "is_trusted_teleporter", + self.places.asset(&self.places.cash(), 1), + self.people_on_asset_hub(), + ) + .await?; + let people_trusts = |method| async move { + match people_answers { + false => Ok(true), + true => { + trusted( + &self.people, + method, + self.places.asset(&self.places.cash_on_people(), 1), + self.asset_hub_from_people(), + ) + .await + } + } + }; + if asset_hub_teleports && people_trusts("is_trusted_teleporter").await? { + return Ok(CashTransfer::Teleport); + } + if people_trusts("is_trusted_reserve").await? { + return Ok(CashTransfer::Reserve); + } + Err(ConversionError::Refused( + "this network does not let CASH move between People and Asset Hub".into(), + )) + } + + /// The swap buying People's existential deposit plus the XCM's fee in + /// PAS, paid for in CASH, its cost capped at what a dry run spent plus + /// headroom. + async fn size_swap(&self, signing: &WithdrawSigning<'_>, cash: u128) -> Result { + let account = signing.account; + let stand_in = Transfer { + cash, + pas_to_withdraw: LONGEST_AMOUNT, + pay_fees_pas: LONGEST_AMOUNT, + remote_fees_cash: remote_fees_cash(cash), + min_pas_out: LONGEST_AMOUNT, + landing: signing.landing, + claimer: account, + transfer: signing.transfer, + }; + let (_, reserve) = self.xcm_tx_fee_reserve(signing, &stand_in).await?; + let pas_out = self.people_existential_deposit()?.saturating_add(reserve); + let spent = self.dry_run_swap(&account, pas_out, cash).await?; + let cash_in_max = spent.saturating_mul(100 + SWAP_HEADROOM_PERCENT).div_ceil(100); + if cash_in_max >= cash { + return Err(ConversionError::Refused(format!( + "{cash} CASH cannot buy the {pas_out} PAS the withdrawal's fees need" + ))); + } + let call = swap_call(&self.places.cash_on_people(), &account, pas_out, cash_in_max); + Ok(PreparedWithdrawal { + extrinsic: sign_on( + &self.people, + signing.extensions, + signing.signer, + &call, + &self.places.cash_on_people(), + signing.nonce, + )?, + valid_until_block: self.people.block_number() + super::MORTAL_PERIOD_BLOCKS, + call: WithdrawCall::Swap, + }) + } + + /// The XCM moving all the account's CASH and PAS to Asset Hub, with + /// People's fees paid by an exact allowance, as getcash sizes it. + async fn size_transfer( + &self, + signing: &WithdrawSigning<'_>, + cash: u128, + pas: u128, + ) -> Result { + let (account, landing, transfer) = (signing.account, signing.landing, signing.transfer); + let quoted = self.quote_cash_for_pas(cash).await?; + let base = |pas_to_withdraw, pay_fees_pas| Transfer { + cash, + pas_to_withdraw, + pay_fees_pas, + remote_fees_cash: remote_fees_cash(cash), + min_pas_out: quoted / 100 * (100 - WITHDRAW_SLIPPAGE_PERCENT) + + quoted % 100 * (100 - WITHDRAW_SLIPPAGE_PERCENT) / 100, + landing, + claimer: account, + transfer, + }; + let (max_weight, reserve) = self.xcm_tx_fee_reserve(signing, &base(pas, pas)).await?; + if pas < self.people_existential_deposit()?.saturating_add(reserve) { + return Err(Sizing::NeedsSwap); + } + let pas_to_withdraw = pas - reserve; + let generous = base(pas_to_withdraw, pas_to_withdraw); + let first = match self.dry_run_transfer(&account, &generous, &max_weight).await? { + PeopleRun::Ran { trapped, forwarded } => (trapped, forwarded), + PeopleRun::ShortOfFees => return Err(Sizing::NeedsSwap), + PeopleRun::Refused(reason) => return Err(Sizing::Failed(ConversionError::Refused(reason))), + }; + let charged = pas_to_withdraw.saturating_sub(first.0); + let local = charged.saturating_sub(self.people_delivery_fee(&first.1).await?); + let mut pay_fees_pas = local.saturating_add( + self.people_delivery_fee(&forwarded_stand_in(&self.places, &base(pas_to_withdraw, charged))) + .await?, + ); + let mut confirmed = None; + for _ in 0..FEE_ROUNDS { + if pay_fees_pas >= pas_to_withdraw { + return Err(Sizing::NeedsSwap); + } + let attempt = base(pas_to_withdraw, pay_fees_pas); + match self.dry_run_transfer(&account, &attempt, &max_weight).await? { + PeopleRun::Ran { trapped: 0, forwarded } => { + confirmed = Some((attempt, forwarded)); + break; + } + PeopleRun::Ran { trapped, .. } => pay_fees_pas -= trapped.min(pay_fees_pas), + PeopleRun::ShortOfFees => pay_fees_pas += pay_fees_pas / 100 + 1, + PeopleRun::Refused(reason) => return Err(Sizing::Failed(ConversionError::Refused(reason))), + } + } + let Some((final_transfer, forwarded)) = confirmed else { + return Err(Sizing::Failed(ConversionError::Refused( + "no exact People fee allowance for the withdrawal was found".into(), + ))); + }; + let expected_landing = self.dry_run_landing(&forwarded, &landing).await?; + let call = execute_call(&self.places, &final_transfer, max_weight); + Ok(PreparedWithdrawal { + extrinsic: sign_on(&self.people, signing.extensions, signing.signer, &call, &native(), signing.nonce)?, + valid_until_block: self.people.block_number() + super::MORTAL_PERIOD_BLOCKS, + call: WithdrawCall::Transfer { expected_landing }, + }) + } + + /// People's existential deposit in PAS. + fn people_existential_deposit(&self) -> Result { + let deposit = self + .people + .metadata_ref() + .pallet_by_name("Balances") + .and_then(|pallet| pallet.constant_by_name("ExistentialDeposit")) + .ok_or_else(|| chain("People declares no existential deposit"))? + .value(); + u128::decode(&mut &deposit[..]).map_err(chain) + } + + /// The PAS Asset Hub's pools give for `cash` CASH now. + async fn quote_cash_for_pas(&self, cash: u128) -> Result { + let quoted = call_api( + &self.asset_hub, + "AssetConversionApi", + "quote_price_exact_tokens_for_tokens", + vec![self.places.cash(), native(), Value::u128(cash), Value::bool(true)], + ) + .await?; + let quoted = super::variant_fields("ed) + .filter(|_| variant_name("ed) == Some("Some")) + .and_then(|fields| fields.values().next()) + .ok_or_else(|| ConversionError::Refused("Asset Hub cannot quote the sale of the CASH".into()))?; + as_u128(quoted) + } + + /// The XCM's weight, as People weighs it, and its transaction fee in PAS + /// with headroom, estimated on the call `transfer` builds. + async fn xcm_tx_fee_reserve( + &self, + signing: &WithdrawSigning<'_>, + transfer: &Transfer, + ) -> Result<(Value, u128), ConversionError> { + let message = withdraw_message(&self.places, transfer); + let weighed = ok(call_api(&self.people, "XcmPaymentApi", "query_xcm_weight", vec![message]).await?) + .unwrap_or_else(|_| weight(FALLBACK_MAX_WEIGHT.0, FALLBACK_MAX_WEIGHT.1)); + let call = execute_call(&self.places, transfer, weighed.clone()); + let extrinsic = sign_on(&self.people, signing.extensions, signing.signer, &call, &native(), signing.nonce)?; + let fee = self.people_dispatch_fee(&extrinsic).await?; + Ok((weighed, fee.saturating_mul(100 + XCM_TX_FEE_HEADROOM_PERCENT).div_ceil(100))) + } + + /// What dispatching `extrinsic` on People costs, in PAS. + async fn people_dispatch_fee(&self, extrinsic: &[u8]) -> Result { + let unprefixed = Vec::::decode(&mut &extrinsic[..]).map_err(chain)?; + let length = u32::try_from(extrinsic.len()).map_err(chain)?; + let info = call_api( + &self.people, + "TransactionPaymentApi", + "query_info", + vec![Value::from_bytes(&unprefixed), Value::u128(length.into())], + ) + .await?; + u128_at(&info, "partial_fee") + } + + /// What delivering `message` from People to Asset Hub costs, in PAS. + async fn people_delivery_fee(&self, message: &Value) -> Result { + let fees = ok(call_api( + &self.people, + "XcmPaymentApi", + "query_delivery_fees", + vec![versioned(self.asset_hub_from_people()), message.clone(), versioned(native())], + ) + .await?)?; + Ok(fungible_total(unversioned(&fees)?)) + } + + /// The CASH a swap for `pas_out` PAS takes from `account`, as a dry run + /// on People spends it. + async fn dry_run_swap(&self, account: &[u8; 32], pas_out: u128, cash: u128) -> Result { + let call = swap_call(&self.places.cash_on_people(), account, pas_out, cash); + let effects = self.dry_run_on_people_call(account, &call).await?; + let execution = field(&effects, "execution_result")?; + if variant_name(execution) != Some("Ok") { + let names = module_error_names(self.people.metadata_ref(), execution); + return Err(ConversionError::Refused(format!( + "People would not swap CASH for the withdrawal's fees: {}", + names.join(" / ") + ))); + } + let events = field(&effects, "emitted_events")?; + items(events) + .into_iter() + .find_map(|event| find_variant(event, "SwapExecuted")) + .map(|swapped| u128_at(swapped, "amount_in")) + .ok_or_else(|| chain("the swap's dry run reported no swap"))? + } + + /// Dry-run the XCM `transfer` builds on People. + async fn dry_run_transfer( + &self, + account: &[u8; 32], + transfer: &Transfer, + max_weight: &Value, + ) -> Result { + let call = execute_call(&self.places, transfer, max_weight.clone()); + let effects = self.dry_run_on_people_call(account, &call).await?; + let execution = field(&effects, "execution_result")?; + if variant_name(execution) != Some("Ok") { + let names = module_error_names(self.people.metadata_ref(), execution); + if names.iter().any(|name| name == "NotHoldingFees" || name == "TooExpensive") { + return Ok(PeopleRun::ShortOfFees); + } + return Ok(PeopleRun::Refused(format!( + "People rejects the withdrawal: {} ({execution})", + names.join(" / ") + ))); + } + let trapped = trapped_in(field(&effects, "emitted_events")?); + let asset_hub = self.places.asset_hub_para; + let forwarded = items(field(&effects, "forwarded_xcms")?) + .into_iter() + .find_map(|entry| { + let [destination, messages] = items(entry)[..] else { + return None; + }; + (super::parachain_of(destination) == Some(asset_hub)) + .then(|| items(messages).first().map(|message| (*message).clone())) + .flatten() + }) + .ok_or_else(|| ConversionError::Refused("the withdrawal forwards nothing to Asset Hub".into()))?; + Ok(PeopleRun::Ran { trapped, forwarded }) + } + + /// `DryRunApi::dry_run_call` of `call` from `account` on People. + async fn dry_run_on_people_call(&self, account: &[u8; 32], call: &RuntimeCall) -> Result { + let origin = Value::unnamed_variant( + "system", + [Value::unnamed_variant("Signed", [Value::from_bytes(account)])], + ); + ok(call_api( + &self.people, + "DryRunApi", + "dry_run_call", + vec![origin, call.value(), Value::u128(XCM_VERSION.into())], + ) + .await?) + } + + /// Run the forwarded program on Asset Hub as People: it must complete, + /// trap nothing and credit `landing` PAS, which it returns. + async fn dry_run_landing(&self, forwarded: &Value, landing: &[u8; 32]) -> Result { + let effects = ok(call_api( + &self.asset_hub, + "DryRunApi", + "dry_run_xcm", + vec![versioned(self.people_on_asset_hub()), forwarded.clone()], + ) + .await?)?; + let outcome = field(&effects, "execution_result")?; + if variant_name(outcome) != Some("Complete") { + return Err(ConversionError::Refused(format!( + "the withdrawal would not complete on Asset Hub: {outcome}" + ))); + } + let events = field(&effects, "emitted_events")?; + if trapped_in(events) > 0 { + return Err(ConversionError::Refused("the withdrawal would trap assets on Asset Hub".into())); + } + match credited_native(events, landing) { + 0 => Err(ConversionError::Refused("nothing of the withdrawal would reach Asset Hub".into())), + landed => Ok(landed), + } + } + + /// People as Asset Hub sees it. + fn people_on_asset_hub(&self) -> Value { + location(1, vec![junction("Parachain", Value::u128(self.places.people_para.into()))]) + } + + /// Asset Hub as People sees it. + fn asset_hub_from_people(&self) -> Value { + location(1, vec![junction("Parachain", Value::u128(self.places.asset_hub_para.into()))]) + } +} + +/// What moving a withdrawal to Asset Hub reads and does on the chains. +pub trait WithdrawChains: Send + Sync { + /// `account`'s CASH and PAS on People. + fn people_holdings<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result<(u128, u128), ConversionError>>; + /// `account`'s next nonce on People. + fn people_nonce<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result>; + /// The finalized People block these reads are pinned to. + fn people_block(&self) -> u64; + /// `account`'s PAS on Asset Hub. + fn asset_hub_native<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result>; + /// Size, dry-run and sign the next withdrawal transaction. + fn prepare_withdrawal<'a>( + &'a self, + keypair: &'a schnorrkel::Keypair, + nonce: u32, + landing: [u8; 32], + ) -> BoxFuture<'a, Result>; +} + +impl WithdrawChains for Chains { + fn people_holdings<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result<(u128, u128), ConversionError>> { + Box::pin(Chains::people_holdings(self, account)) + } + + fn people_nonce<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result> { + Box::pin(Chains::people_nonce(self, account)) + } + + fn people_block(&self) -> u64 { + Chains::people_block(self) + } + + fn asset_hub_native<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result> { + Box::pin(Chains::asset_hub_native(self, account)) + } + + fn prepare_withdrawal<'a>( + &'a self, + keypair: &'a schnorrkel::Keypair, + nonce: u32, + landing: [u8; 32], + ) -> BoxFuture<'a, Result> { + Box::pin(Chains::prepare_withdrawal(self, keypair, nonce, landing)) + } +} + +/// A chain's `TrustedQueryApi` answer for `asset` from `location`. +async fn trusted( + at: &subxt::client::OnlineClientAtBlock, + method: &str, + asset: Value, + from: Value, +) -> Result { + let answer = ok(call_api(at, "TrustedQueryApi", method, vec![versioned(asset), versioned(from)]).await?)?; + match answer.value { + subxt::ext::scale_value::ValueDef::Primitive(subxt::ext::scale_value::Primitive::Bool(trusted)) => Ok(trusted), + _ => Err(chain(format!("TrustedQueryApi::{method} gave no answer: {answer}"))), + } +} + +/// What one dry run of the XCM on People found. +enum PeopleRun { + /// It ran: what it left trapped, and the message it forwards to Asset + /// Hub. + Ran { trapped: u128, forwarded: Value }, + /// It could not pay its fees. + ShortOfFees, + /// It failed for another reason. + Refused(String), +} + +/// CASH the remote fee carries on Asset Hub: one percent, at least the +/// buffer, as getcash earmarks it. +fn remote_fees_cash(cash: u128) -> u128 { + (cash / 100).max(ASSET_HUB_FEE_BUFFER_CASH) +} + +/// The pool swap of up to `cash_in_max` CASH for exactly `pas_out` PAS, +/// credited to `account`. +fn swap_call(cash_on_people: &Value, account: &[u8; 32], pas_out: u128, cash_in_max: u128) -> RuntimeCall { + RuntimeCall::new( + "AssetConversion", + "swap_tokens_for_exact_tokens", + vec![ + ("path", Value::unnamed_composite([cash_on_people.clone(), native()])), + ("amount_out", Value::u128(pas_out)), + ("amount_in_max", Value::u128(cash_in_max)), + ("send_to", Value::from_bytes(account)), + ("keep_alive", Value::bool(false)), + ], + ) +} + +/// The XCM transaction for `transfer`, weighed at `max_weight`. +fn execute_call(places: &super::Places, transfer: &Transfer, max_weight: Value) -> RuntimeCall { + RuntimeCall::new( + "PolkadotXcm", + "execute", + vec![("message", withdraw_message(places, transfer)), ("max_weight", max_weight)], + ) +} + +fn wild(name: &'static str, inner: Value) -> Value { + Value::unnamed_variant("Wild", [Value::unnamed_variant(name, [inner])]) +} + +fn all_of(id: Value) -> Value { + Value::unnamed_variant( + "Wild", + [Value::named_variant( + "AllOf", + [("id", id), ("fun", Value::unnamed_variant("Fungible", []))], + )], + ) +} + +/// The program Asset Hub runs on arrival: name the claimer of a trap, refund +/// the fee's surplus, sell all the CASH for at least the floor, deposit +/// everything on the landing account. Keyed as Asset Hub sees CASH. +fn remote_program(places: &super::Places, transfer: &Transfer) -> Vec { + vec![ + Value::named_variant( + "SetHints", + [( + "hints", + Value::unnamed_composite([Value::named_variant( + "AssetClaimer", + [("location", beneficiary(&transfer.claimer))], + )]), + )], + ), + Value::unnamed_variant("RefundSurplus", []), + Value::named_variant( + "ExchangeAsset", + [ + ("give", all_of(places.cash())), + ("want", Value::unnamed_composite([places.asset(&native(), transfer.min_pas_out)])), + ("maximal", Value::bool(true)), + ], + ), + Value::named_variant( + "DepositAsset", + [ + ("assets", wild("AllCounted", Value::u128(2))), + ("beneficiary", beneficiary(&transfer.landing)), + ], + ), + ] +} + +/// The XCM message: withdraw the PAS and all the CASH, pay People's fees +/// with the allowance, and send both to Asset Hub with the remote program. +/// The PAS always teleports; the CASH follows the network's transfer. +fn withdraw_message(places: &super::Places, transfer: &Transfer) -> Value { + let cash = |amount| places.asset(&places.cash_on_people(), amount); + let pas = |amount| places.asset(&native(), amount); + let (remote_fees, filters) = match transfer.transfer { + CashTransfer::Teleport => ( + "Teleport", + vec![Value::unnamed_variant("Teleport", [wild("AllCounted", Value::u128(2))])], + ), + CashTransfer::Reserve => ( + "ReserveWithdraw", + vec![ + Value::unnamed_variant("Teleport", [all_of(native())]), + Value::unnamed_variant("ReserveWithdraw", [all_of(places.cash_on_people())]), + ], + ), + }; + versioned(Value::unnamed_composite([ + Value::unnamed_variant( + "WithdrawAsset", + [Value::unnamed_composite([pas(transfer.pas_to_withdraw), cash(transfer.cash)])], + ), + Value::named_variant("PayFees", [("asset", pas(transfer.pay_fees_pas))]), + Value::named_variant( + "InitiateTransfer", + [ + ( + "destination", + location(1, vec![junction("Parachain", Value::u128(places.asset_hub_para.into()))]), + ), + ( + "remote_fees", + Value::unnamed_variant( + "Some", + [Value::unnamed_variant( + remote_fees, + [Value::unnamed_variant( + "Definite", + [Value::unnamed_composite([cash(transfer.remote_fees_cash)])], + )], + )], + ), + ), + ("preserve_origin", Value::bool(false)), + ("assets", Value::unnamed_composite(filters)), + ("remote_xcm", Value::unnamed_composite(remote_program(places, transfer))), + ], + ), + ])) +} + +/// The message People forwards to Asset Hub for `transfer`, as the runtime +/// builds it, with the allowance's remainder as the PAS that travels: what +/// prices the delivery before a dry run produces the real one. +fn forwarded_stand_in(places: &super::Places, transfer: &Transfer) -> Value { + let cash = |amount| places.asset(&places.cash_on_people(), amount); + let pas = |amount| places.asset(&native(), amount); + let pas_left = transfer.pas_to_withdraw.saturating_sub(transfer.pay_fees_pas); + let fee = cash(transfer.remote_fees_cash); + let mut message = match transfer.transfer { + CashTransfer::Teleport => vec![ + Value::unnamed_variant("ReceiveTeleportedAsset", [Value::unnamed_composite([fee.clone()])]), + Value::named_variant("PayFees", [("asset", fee)]), + Value::unnamed_variant( + "ReceiveTeleportedAsset", + [Value::unnamed_composite(match pas_left { + 0 => vec![cash(transfer.cash)], + left => vec![pas(left), cash(transfer.cash)], + })], + ), + ], + CashTransfer::Reserve => vec![ + Value::unnamed_variant("WithdrawAsset", [Value::unnamed_composite([fee.clone()])]), + Value::named_variant("PayFees", [("asset", fee)]), + Value::unnamed_variant( + "ReceiveTeleportedAsset", + [Value::unnamed_composite(match pas_left { + 0 => Vec::new(), + left => vec![pas(left)], + })], + ), + Value::unnamed_variant("WithdrawAsset", [Value::unnamed_composite([cash(transfer.cash)])]), + ], + }; + message.push(Value::unnamed_variant("ClearOrigin", [])); + message.extend(remote_program(places, transfer)); + message.push(Value::unnamed_variant("SetTopic", [Value::from_bytes([0; 32])])); + versioned(Value::unnamed_composite(message)) +} + +/// The assets the events report trapped. +fn trapped_in(events: &Value) -> u128 { + items(events) + .into_iter() + .filter_map(|event| find_variant(event, "AssetsTrapped")) + .filter_map(|trap| field(trap, "assets").ok()) + .map(fungible_total) + .sum() +} + +/// The native token the events report credited to `account`. +fn credited_native(events: &Value, account: &[u8; 32]) -> u128 { + items(events) + .into_iter() + .filter(|event| variant_name(event) == Some("Balances")) + .filter_map(|event| { + let credit = find_variant(event, "Minted").or_else(|| find_variant(event, "Deposit"))?; + let who = account_bytes(field(credit, "who").ok()?)?; + (who == *account).then(|| u128_at(credit, "amount").ok()).flatten() + }) + .sum() +} + +/// The 32 bytes of an `AccountId32` value. +fn account_bytes(value: &Value) -> Option<[u8; 32]> { + let bytes: Vec = items(unwrap_newtype(value)) + .into_iter() + .filter_map(|byte| as_u128(byte).ok().and_then(|byte| u8::try_from(byte).ok())) + .collect(); + bytes.try_into().ok() +} + +/// The least a landing must reach to count: the dry run's, less the +/// slippage the program allows the sale, since a sale that slips further +/// fails the program and lands nothing. +pub fn landing_floor(expected: u128) -> u128 { + expected - (expected / 100 * WITHDRAW_SLIPPAGE_PERCENT + expected % 100 * WITHDRAW_SLIPPAGE_PERCENT / 100) +} + + +#[cfg(test)] +mod live { + //! Withdrawals sized against Paseo Next, from accounts that already hold + //! CASH on People. Run with + //! `cargo test -p truapi --lib funding::conversion::withdraw::live -- --ignored`. + + use subxt::config::substrate::SubstrateConfig; + use subxt::dynamic; + + use super::*; + use crate::runtime::FundingNetwork; + use crate::runtime::funding::conversion::SigningChain; + use crate::runtime::statement_allowance::{ChainClient, ChainContextCache}; + + const ASSET_HUB: &str = "wss://paseo-asset-hub-next-rpc.polkadot.io"; + const PEOPLE: &str = "wss://paseo-people-next-system-rpc.polkadot.io"; + const NETWORK: FundingNetwork = FundingNetwork { + cash_asset_id: 50_000_413, + }; + + async fn client(url: &str) -> subxt::OnlineClient { + let rpc = subxt_rpcs::RpcClient::from_insecure_url(url) + .await + .expect("node reachable"); + let backend = subxt::backend::LegacyBackend::builder().build(rpc); + subxt::OnlineClient::from_backend(std::sync::Arc::new(backend)) + .await + .expect("client builds") + } + + async fn chains() -> Chains { + let people = client(PEOPLE).await; + let rpc = crate::runtime::statement_allowance::rpc::RpcClient::connect(PEOPLE) + .await + .expect("node reachable"); + let context = ChainContextCache::default() + .get(&ChainClient::new(rpc, people.genesis_hash().0)) + .await + .expect("metadata"); + Chains::at_finalized(&client(ASSET_HUB).await, &people, NETWORK, Some(SigningChain::People(context))) + .await + .expect("chains pinned") + } + + /// A People account holding at least `cash` CASH and `pas` PAS. + async fn holder(chains: &Chains, cash: u128, pas: u128) -> [u8; 32] { + let mut entries = chains + .people + .storage() + .iter( + dynamic::storage::<(Value, Value), Value>("Assets", "Account"), + (chains.places.cash_on_people(),), + ) + .await + .expect("accounts iterate"); + while let Some(entry) = entries.next().await { + let entry = entry.expect("entry reads"); + let balance = u128_at(&entry.value().decode().expect("decodes"), "balance").expect("balance"); + if balance < cash { + continue; + } + let key = entry.key_bytes(); + let account: [u8; 32] = key[key.len() - 32..].try_into().expect("account id"); + let (_, held_pas) = chains.people_holdings(&account).await.expect("holdings"); + if held_pas >= pas { + return account; + } + } + panic!("no People account holds {cash} CASH and {pas} PAS"); + } + + fn quoting_key() -> schnorrkel::Keypair { + schnorrkel::MiniSecretKey::from_bytes(&[1; 32]) + .expect("seed") + .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519) + } + + async fn signing<'a>(chains: &'a Chains, signer: &'a Sr25519Signer, account: [u8; 32]) -> WithdrawSigning<'a> { + WithdrawSigning { + extensions: chains.people_extensions.as_deref().expect("People metadata"), + signer, + nonce: 0, + account, + landing: account, + transfer: chains.cash_transfer().await.expect("transfer chosen"), + } + } + + // The swap is named and encoded as People's runtime takes it: a dry run + // swaps the account's CASH for the PAS the XCM's fees need, and the cap + // leaves CASH for the XCM. + #[tokio::test] + #[ignore = "reaches Paseo Next"] + async fn the_fee_swap_is_sized_from_a_live_dry_run() { + let chains = chains().await; + let account = holder(&chains, 2_000_000, 0).await; + let (cash, _) = chains.people_holdings(&account).await.expect("holdings"); + let signer = Sr25519Signer::from_keypair("ing_key()); + + let swap = chains + .size_swap(&signing(&chains, &signer, account).await, cash) + .await + .expect("swap sized"); + + assert_eq!(swap.call, WithdrawCall::Swap); + } + + // The XCM's program is named and encoded as People's runtime takes it: + // its fees are paid by an exact allowance, the forwarded program runs + // on Asset Hub, sells the CASH and lands PAS on the landing account. + #[tokio::test] + #[ignore = "reaches Paseo Next"] + async fn the_transfer_is_sized_from_live_dry_runs_on_both_chains() { + let chains = chains().await; + let account = holder(&chains, 2_000_000, 20_000_000_000).await; + let (cash, pas) = chains.people_holdings(&account).await.expect("holdings"); + let signer = Sr25519Signer::from_keypair("ing_key()); + + let transfer = match chains.size_transfer(&signing(&chains, &signer, account).await, cash, pas).await { + Ok(prepared) => prepared, + Err(Sizing::NeedsSwap) => panic!("the holder lacks the PAS for the fees"), + Err(Sizing::Failed(error)) => panic!("transfer not sized: {error}"), + }; + + assert!(matches!(transfer.call, WithdrawCall::Transfer { expected_landing } if expected_landing > 0)); + } +} diff --git a/rust/crates/truapi/src/runtime/funding/credit.rs b/rust/crates/truapi/src/runtime/funding/credit.rs index af346659d5..e5935bb172 100644 --- a/rust/crates/truapi/src/runtime/funding/credit.rs +++ b/rust/crates/truapi/src/runtime/funding/credit.rs @@ -241,6 +241,14 @@ mod tests { Ok(Some(self.0.clone())) } + fn withdrawal_keypair( + &self, + _: &str, + _: u32, + ) -> Result, GenericError> { + Ok(Some(self.0.clone())) + } + fn funding_product_id(&self) -> String { "fund.dot".into() } diff --git a/rust/crates/truapi/src/runtime/funding/withdraw.rs b/rust/crates/truapi/src/runtime/funding/withdraw.rs index b983171f1c..4f89fce299 100644 --- a/rust/crates/truapi/src/runtime/funding/withdraw.rs +++ b/rust/crates/truapi/src/runtime/funding/withdraw.rs @@ -14,13 +14,17 @@ use truapi::latest::{ GenericError, HostPaymentError, HostPaymentRequest, HostPaymentStatusSubscribeError, }; -use super::conversion::ConversionChains; +use super::conversion::{ + ConversionChains, ConversionError, PreparedWithdrawal, WithdrawCall, WithdrawChains, + landing_floor, +}; use super::{ - CANCEL_CONFIRM, CancelFundingError, FundingRegistry, MAX_USED_ACCOUNTS, within_chain_timeout, + FundingSigner, STALL_AFTER_MS, SignOn, CANCEL_CONFIRM, CancelFundingError, FundingRegistry, MAX_USED_ACCOUNTS, within_chain_timeout, within_timeout, }; use crate::host_logic::funding::{ - FundingSessionError, FundingWithdrawal, PaymentReading, PaymentWord, funding_attempt_id, + FundingSessionError, FundingWithdrawal, PaymentReading, PaymentWord, WithdrawStep, + WithdrawSubmission, funding_attempt_id, }; use crate::platform::{CoreStorage, ProductContext}; use crate::runtime::services::RuntimeServices; @@ -71,7 +75,156 @@ impl From for WithdrawError { } } +/// What a pass decided for one withdrawal being moved to Asset Hub. +#[derive(Debug, PartialEq, Eq)] +enum PlannedWithdrawal { + /// Record a step. + Record(WithdrawStep), + /// Record the submission, then submit `extrinsic` on People. + Submit { + submission: WithdrawSubmission, + extrinsic: Vec, + }, +} + +/// Decide the next step for `withdrawal`, given the transaction on its way, +/// as getcash's withdrawal tick does, from balances alone. A submitted XCM +/// has landed once the account holds no CASH and Asset Hub shows the PAS; +/// one included that left the CASH failed; one whose era passed unincluded +/// is dropped; one that took the CASH but landed nothing in time stalled. A +/// swap went through once it left PAS on the account. With nothing on its +/// way, the account's next transaction is prepared: the XCM when it holds +/// the PAS its fees need, the swap otherwise. +async fn plan_withdrawal( + chains: &dyn WithdrawChains, + signer: &dyn FundingSigner, + withdrawal: &FundingWithdrawal, + submission: Option, + now_ms: u64, +) -> Result, ConversionError> { + let account = &withdrawal.account; + let (cash, pas) = chains.people_holdings(account).await?; + let record = |step| Ok(Some(PlannedWithdrawal::Record(step))); + match submission { + Some(WithdrawSubmission::Transfer { + nonce, + valid_until_block, + submitted_at_ms, + landing_before, + expected_landing, + }) => { + let landed = chains.asset_hub_native(account).await?.saturating_sub(landing_before); + if cash == 0 && landed >= landing_floor(expected_landing) { + return record(WithdrawStep::Landed { landed }); + } + if chains.people_nonce(account).await? > nonce { + if cash > 0 { + return record(WithdrawStep::Rejected { + reason: "the withdrawal was included and failed".into(), + }); + } + if now_ms.saturating_sub(submitted_at_ms) > STALL_AFTER_MS { + // What did land is the user's: pay it out rather than + // fail, where getcash holds the run until its bound. + return record(match landed { + 0 => WithdrawStep::Stalled, + landed => WithdrawStep::Landed { landed }, + }); + } + return Ok(None); + } + if chains.people_block() > valid_until_block { + return record(WithdrawStep::Dropped); + } + return Ok(None); + } + Some(WithdrawSubmission::Swap { + nonce, + valid_until_block, + pas_before, + }) => { + if chains.people_nonce(account).await? > nonce { + return record(if pas > pas_before { + WithdrawStep::Swapped + } else { + WithdrawStep::Rejected { + reason: "the swap for the withdrawal's fees was included and failed".into(), + } + }); + } + if chains.people_block() > valid_until_block { + return record(WithdrawStep::Dropped); + } + return Ok(None); + } + None => {} + } + if cash == 0 { + return Ok(None); + } + let keypair = signer + .withdrawal_keypair(&withdrawal.destination_id, withdrawal.number) + .map_err(|error| ConversionError::Chain(error.reason))?; + let Some(keypair) = keypair.filter(|keypair| keypair.public.to_bytes() == *account) else { + return Ok(None); + }; + let nonce = chains.people_nonce(account).await?; + let PreparedWithdrawal { + extrinsic, + valid_until_block, + call, + } = chains.prepare_withdrawal(&keypair, nonce, *account).await?; + let submission = match call { + WithdrawCall::Swap => WithdrawSubmission::Swap { + nonce, + valid_until_block, + pas_before: pas, + }, + WithdrawCall::Transfer { expected_landing } => WithdrawSubmission::Transfer { + nonce, + valid_until_block, + submitted_at_ms: now_ms, + landing_before: chains.asset_hub_native(account).await?, + expected_landing, + }, + }; + Ok(Some(PlannedWithdrawal::Submit { + submission, + extrinsic, + })) +} + impl FundingRegistry { + /// Every session whose CASH is being moved to Asset Hub, with its + /// withdrawal account and the transaction on its way. + fn moving_withdrawals(&self) -> Vec<(String, FundingWithdrawal, Option)> { + self.lock_sessions() + .values() + .filter_map(|session| { + let (withdrawal, submission) = session.withdrawing()?; + Some((session.intent.clone(), withdrawal.clone(), submission)) + }) + .collect() + } + + /// Apply one step of a withdrawal's move to session `intent`. + async fn record_withdraw_step( + &self, + storage: &(impl CoreStorage + ?Sized), + now_ms: u64, + intent: &str, + step: WithdrawStep, + ) -> Result<(), FundingSessionError> { + let intent = intent.to_string(); + self.commit(storage, now_ms, move |sessions| { + let changed = sessions + .get_mut(&intent) + .is_some_and(|session| session.advance_withdrawal(step, now_ms)); + ((), if changed { vec![intent] } else { Vec::new() }) + }) + .await + } + /// Every session whose withdrawal account is still read, with it. fn withdrawing_sessions(&self, now_ms: u64) -> Vec<(String, FundingWithdrawal)> { self.lock_sessions() @@ -127,7 +280,7 @@ impl RuntimeServices { return Err(WithdrawError::PaymentsUnavailable); } let chains = self - .funding_chains(conversion.network, false) + .funding_chains(conversion.network, SignOn::Nowhere) .await .map_err(|error| WithdrawError::Chain(GenericError { reason: error.to_string(), @@ -242,7 +395,7 @@ impl RuntimeServices { let platform = self.payment_platform(); within_timeout(limit, async { let chains = self - .funding_chains(conversion.network, false) + .funding_chains(conversion.network, SignOn::Nowhere) .await .map_err(|error| error.to_string())?; let cash = chains.landed(&withdrawal.account).await.map_err(|error| error.to_string())?; @@ -323,4 +476,311 @@ impl RuntimeServices { } } } + + /// One pass over the withdrawals being moved to Asset Hub: record what + /// landed, was swapped, dropped or rejected, and submit what is ready. + pub async fn advance_withdrawal_moves(self: &Arc) -> Result<(), String> { + let registry = self.funding(); + let moving = registry.moving_withdrawals(); + let Some(conversion) = registry.conversion.get() else { + return Ok(()); + }; + if moving.is_empty() { + return Ok(()); + } + let signing = moving.iter().any(|(_, _, submission)| submission.is_none()); + let chains = self + .funding_chains(conversion.network, if signing { SignOn::People } else { SignOn::Nowhere }) + .await + .map_err(|error| error.to_string())?; + let storage = self.platform.as_ref(); + for (intent, withdrawal, submission) in moving { + let now_ms = current_unix_millis(); + let planned = within_chain_timeout(plan_withdrawal( + &chains, + conversion.signer.as_ref(), + &withdrawal, + submission, + now_ms, + )) + .await; + let planned = match planned { + Ok(Ok(planned)) => planned, + Ok(Err(ConversionError::Refused(reason) | ConversionError::PsmRefused { reason, .. })) => { + Some(PlannedWithdrawal::Record(WithdrawStep::Refused { reason })) + } + Ok(Err(ConversionError::Chain(reason))) | Err(GenericError { reason }) => { + tracing::warn!(%intent, %reason, "funding withdrawal pass failed"); + continue; + } + }; + match planned { + None => {} + Some(PlannedWithdrawal::Record(step)) => { + if let Err(error) = registry.record_withdraw_step(storage, now_ms, &intent, step).await { + tracing::warn!(%intent, %error, "recording a funding withdrawal failed"); + } + } + Some(PlannedWithdrawal::Submit { + submission, + extrinsic, + }) => { + let submitted = WithdrawStep::Submitted(submission); + // Never submitted unless recorded, so a lost answer is + // still judged by its nonce. + if let Err(error) = registry.record_withdraw_step(storage, now_ms, &intent, submitted).await { + tracing::warn!(%intent, %error, "recording a funding withdrawal failed"); + continue; + } + match chains.submit_on_people(extrinsic).await { + Ok(()) => {} + Err(ConversionError::Refused(reason) | ConversionError::PsmRefused { reason, .. }) => { + let refused = WithdrawStep::Refused { reason }; + if let Err(error) = registry + .record_withdraw_step(storage, current_unix_millis(), &intent, refused) + .await + { + tracing::warn!(%intent, %error, "recording a funding withdrawal failed"); + } + } + // It may have reached the chain anyway; its era or + // the nonce decides. + Err(ConversionError::Chain(reason)) => { + tracing::warn!(%intent, %reason, "submitting a funding withdrawal failed"); + } + } + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use futures::executor::block_on; + use futures::future::BoxFuture; + + use super::*; + + const NOW: u64 = 1_700_000_000_000; + + /// Chains answering fixed reads and preparing a fixed transaction. + struct Scripted { + cash: u128, + pas: u128, + nonce: u32, + block: u64, + landing: u128, + prepares: WithdrawCall, + } + + impl WithdrawChains for Scripted { + fn people_holdings<'a>(&'a self, _: &'a [u8; 32]) -> BoxFuture<'a, Result<(u128, u128), ConversionError>> { + Box::pin(async { Ok((self.cash, self.pas)) }) + } + + fn people_nonce<'a>(&'a self, _: &'a [u8; 32]) -> BoxFuture<'a, Result> { + Box::pin(async { Ok(self.nonce) }) + } + + fn people_block(&self) -> u64 { + self.block + } + + fn asset_hub_native<'a>(&'a self, _: &'a [u8; 32]) -> BoxFuture<'a, Result> { + Box::pin(async { Ok(self.landing) }) + } + + fn prepare_withdrawal<'a>( + &'a self, + _: &'a schnorrkel::Keypair, + _: u32, + _: [u8; 32], + ) -> BoxFuture<'a, Result> { + Box::pin(async { + Ok(PreparedWithdrawal { + extrinsic: vec![1, 2, 3], + valid_until_block: 164, + call: self.prepares, + }) + }) + } + } + + struct Keys(schnorrkel::Keypair); + + impl FundingSigner for Keys { + fn deposit_keypair(&self, _: &str, _: u32) -> Result, GenericError> { + Ok(None) + } + + fn withdrawal_keypair(&self, _: &str, _: u32) -> Result, GenericError> { + Ok(Some(self.0.clone())) + } + + fn funding_product_id(&self) -> String { + "fund.dot".into() + } + } + + fn keypair(seed: u8) -> schnorrkel::Keypair { + schnorrkel::MiniSecretKey::from_bytes(&[seed; 32]) + .expect("seed") + .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519) + } + + fn withdrawal() -> FundingWithdrawal { + FundingWithdrawal { + destination_id: "dot-assethub".into(), + number: 1, + account: keypair(1).public.to_bytes(), + attempt: 0, + since_ms: NOW, + taken: true, + } + } + + fn chains(cash: u128, pas: u128, nonce: u32, block: u64, landing: u128) -> Scripted { + Scripted { + cash, + pas, + nonce, + block, + landing, + prepares: WithdrawCall::Swap, + } + } + + fn plan(chains: &Scripted, submission: Option, now_ms: u64) -> Option { + block_on(plan_withdrawal(chains, &Keys(keypair(1)), &withdrawal(), submission, now_ms)).expect("planned") + } + + const SWAP: WithdrawSubmission = WithdrawSubmission::Swap { + nonce: 4, + valid_until_block: 164, + pas_before: 30, + }; + const TRANSFER: WithdrawSubmission = WithdrawSubmission::Transfer { + nonce: 5, + valid_until_block: 164, + submitted_at_ms: NOW, + landing_before: 10, + expected_landing: 1_000, + }; + + fn record(step: WithdrawStep) -> Option { + Some(PlannedWithdrawal::Record(step)) + } + + // With nothing on its way, the account's next transaction goes out, as + // getcash's tick acts once per reading: the XCM is measured from where + // the landing account stood just before, so its arrival can be told. + #[test] + fn the_next_withdrawal_transaction_is_prepared_from_what_the_account_holds() { + let transfer = Scripted { + prepares: WithdrawCall::Transfer { expected_landing: 900 }, + ..chains(500, 30, 6, 100, 7) + }; + + assert_eq!( + [ + plan(&chains(0, 0, 4, 100, 0), None, NOW), + plan(&chains(500, 0, 4, 100, 0), None, NOW), + plan(&transfer, None, NOW), + ], + [ + None, + Some(PlannedWithdrawal::Submit { + submission: WithdrawSubmission::Swap { + nonce: 4, + valid_until_block: 164, + pas_before: 0, + }, + extrinsic: vec![1, 2, 3], + }), + Some(PlannedWithdrawal::Submit { + submission: WithdrawSubmission::Transfer { + nonce: 6, + valid_until_block: 164, + submitted_at_ms: NOW, + landing_before: 7, + expected_landing: 900, + }, + extrinsic: vec![1, 2, 3], + }), + ] + ); + } + + // A swap is judged by what it added: more PAS on the account than + // before means it went through, the same after inclusion means it failed + // and cost a fee, even when PAS from an earlier swap is there; one whose + // era passed unincluded is dropped; until then it is waited for. + #[test] + fn a_swap_is_judged_by_the_pas_it_added() { + let failed = || { + record(WithdrawStep::Rejected { + reason: "the swap for the withdrawal's fees was included and failed".into(), + }) + }; + + assert_eq!( + [ + plan(&chains(400, 60, 5, 100, 0), Some(SWAP), NOW), + plan(&chains(500, 30, 5, 100, 0), Some(SWAP), NOW), + plan(&chains(500, 0, 5, 100, 0), Some(SWAP), NOW), + plan(&chains(500, 30, 4, 165, 0), Some(SWAP), NOW), + plan(&chains(500, 30, 4, 164, 0), Some(SWAP), NOW), + ], + [record(WithdrawStep::Swapped), failed(), failed(), record(WithdrawStep::Dropped), None] + ); + } + + // getcash counts an arrival only on both signals: the account holds no + // CASH, which only the XCM takes in full, and the landing account gained + // at least what a sale within the slippage lands. An XCM included with + // the CASH still there failed; one that took it and landed nothing in + // time stalled, and what did land is paid out rather than lost. + #[test] + fn a_transfer_lands_on_both_signals() { + let late = NOW + STALL_AFTER_MS + 1; + + assert_eq!( + [ + plan(&chains(0, 0, 6, 100, 960), Some(TRANSFER), NOW), + plan(&chains(0, 0, 6, 100, 950), Some(TRANSFER), NOW), + plan(&chains(500, 0, 6, 100, 0), Some(TRANSFER), NOW), + plan(&chains(0, 0, 6, 100, 10), Some(TRANSFER), late), + plan(&chains(0, 0, 6, 100, 410), Some(TRANSFER), late), + plan(&chains(500, 30, 5, 165, 0), Some(TRANSFER), NOW), + ], + [ + record(WithdrawStep::Landed { landed: 950 }), + None, + record(WithdrawStep::Rejected { + reason: "the withdrawal was included and failed".into(), + }), + record(WithdrawStep::Stalled), + record(WithdrawStep::Landed { landed: 400 }), + record(WithdrawStep::Dropped), + ] + ); + } + + // A withdrawal outlives a sign-out; another identity's key must not + // sign for this account. + #[test] + fn only_the_withdrawal_accounts_key_signs() { + let wrong = block_on(plan_withdrawal( + &chains(500, 0, 4, 100, 0), + &Keys(keypair(2)), + &withdrawal(), + None, + NOW, + )) + .expect("planned"); + + assert_eq!(wrong, None); + } } diff --git a/rust/crates/truapi/src/runtime/signing_host.rs b/rust/crates/truapi/src/runtime/signing_host.rs index e48d944e7c..5230c2d7c0 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -1768,6 +1768,17 @@ impl super::FundingSigner for SigningHost { }) } + fn withdrawal_keypair( + &self, + destination_id: &str, + number: u32, + ) -> Result, GenericError> { + self.funding_keypair(FundingAccountKind::Withdrawal, destination_id, number) + .map_err(|err| GenericError { + reason: err.to_string(), + }) + } + fn funding_product_id(&self) -> String { funding_product_id(&self.network_suffix) } diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index d21b6da50f..49ab755151 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -2994,6 +2994,14 @@ impl crate::runtime::FundingSigner for FundingProduct { Ok(None) } + fn withdrawal_keypair( + &self, + _: &str, + _: u32, + ) -> Result, truapi::latest::GenericError> { + Ok(None) + } + fn funding_product_id(&self) -> String { "fund.dot".into() } From a56581294820b354308ea6a848f0e9ef39ec1296 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Tue, 6 Oct 2026 14:55:12 +0200 Subject: [PATCH 4/4] feat(truapi): pay withdrawals out to their channel as getcash does --- .changeset/funding-payout.md | 5 + .../truapi-host-cli/src/funding_check.rs | 4 + rust/crates/truapi/RUNTIME.md | 14 +- rust/crates/truapi/src/host_core.rs | 25 ++ rust/crates/truapi/src/host_logic/funding.rs | 373 +++++++++++++++++- rust/crates/truapi/src/native/runtime.rs | 15 + rust/crates/truapi/src/runtime/funding.rs | 5 + .../truapi/src/runtime/funding/conversion.rs | 4 +- .../runtime/funding/conversion/withdraw.rs | 118 ++++++ .../truapi/src/runtime/funding/withdraw.rs | 367 ++++++++++++++++- rust/crates/truapi/src/runtime/tests.rs | 1 + 11 files changed, 921 insertions(+), 10 deletions(-) create mode 100644 .changeset/funding-payout.md diff --git a/.changeset/funding-payout.md b/.changeset/funding-payout.md new file mode 100644 index 0000000000..c17dfb94d8 --- /dev/null +++ b/.changeset/funding-payout.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": patch +--- + +`set_withdrawal_payout` names the Asset Hub account a withdrawal pays out to; the withdrawal account then sends everything there with `transfer_all`, never into a channel about to close, and the session ends `Released` (Rust and native API). diff --git a/rust/crates/truapi-host-cli/src/funding_check.rs b/rust/crates/truapi-host-cli/src/funding_check.rs index c91fe27497..865365c9e5 100644 --- a/rust/crates/truapi-host-cli/src/funding_check.rs +++ b/rust/crates/truapi-host-cli/src/funding_check.rs @@ -368,6 +368,10 @@ async fn follow(runtime: &SigningHostRuntime, intent: &str) -> Result<()> { "the session failed: {reason:?}; its funds are still held, try again with --retry" ), FundingStage::Failed { reason, .. } => bail!("the session failed: {reason:?}"), + FundingStage::Released { debited, .. } => { + println!("paid out a withdrawal of {debited} CASH units"); + return Ok(()); + } FundingStage::Delivered { credited, .. } => { println!("credited {credited} CASH units (stand-in top-up: no coins moved)"); return Ok(()); diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 7166aa3149..45b61d4692 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -451,7 +451,19 @@ AutoSigning without approval. Legacy-account signing still asks the user. Asset Hub trusts People for it and is reserve-withdrawn otherwise. It has landed once the account holds no CASH and Asset Hub shows at least the dry run's landing less 5%, and the session is `Withdrawn`; three rejected - transactions hold it for `retry_funding`. + transactions, or 15 minutes of refusals, hold it for `retry_funding`. + `set_withdrawal_payout` names where the PAS goes from there: a provider's + deposit channel with its expiry, checked against the provider's own record + by whoever hands it over, or the user's own account. The withdrawal + account then pays everything it holds there with `Balances::transfer_all`, + which closes it, as getcash's sweep pays a channel; a channel within + about 23 minutes of closing (the read, the transaction's 64-block era and + ten minutes for the provider) is not paid, and the session waits for a new + payout and a retry. Once the account is empty, down to the existential + deposit, the session is `Released`. Following the provider's swap to its + destination is the provider's; a channel should name the withdrawal + account as its refund address, as getcash's do, and a refund that lands + there is paid out again once a new payout is set. `cancel_funding` cancels a session while nothing has arrived, as getcash does: it is refused once a deposit was seen or while anything is on the deposit account, which is read first, and nothing is cancelled if that diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index 46bde99ae3..1331b71cec 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -715,6 +715,31 @@ impl SigningHostRuntime { }) } + /// Name the Asset Hub account session `intent`'s withdrawal pays out + /// to, `address` as its public key: a provider's deposit channel closing + /// at `expires_at_ms`, checked against the provider's own record before + /// it is handed over, or the user's own account with no expiry. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.set_withdrawal_payout"))] + pub async fn set_withdrawal_payout( + &self, + intent: &str, + address: [u8; 32], + expires_at_ms: Option, + ) -> Result<(), v01::GenericError> { + self.services + .set_withdrawal_payout( + intent, + crate::host_logic::funding::WithdrawalPayout { + address, + expires_at_ms, + }, + ) + .await + .map_err(|err| v01::GenericError { + reason: err.to_string(), + }) + } + /// The product's hard-subtree public key, derived from the active session /// root, or `None` while no session is active. /// diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index 5920a93401..1f90257ee9 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -94,8 +94,32 @@ pub struct FundingWithdrawal { /// the session no longer expires or cancels, as getcash holds a taken /// payment. pub taken: bool, + /// Where the PAS goes from Asset Hub, once the host or provider names it. + pub payout: Option, } +/// The Asset Hub account a withdrawal pays out to: a provider's deposit +/// channel, or the user's own account. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct WithdrawalPayout { + /// The account, as its public key. + pub address: [u8; 32], + /// When a provider closes the channel, in Unix milliseconds; `None` for + /// an account that does not close. + pub expires_at_ms: Option, +} + +/// How close to a channel's expiry a payout may still go out: the transfer +/// has to be read, signed, included within its mortal era and then seen by +/// the provider before the channel closes, and what lands in a closed one +/// is lost. getcash's 30 s read and 10 min for the provider, with the era's +/// 64 blocks at 12 s in place of its 180 s submit bound. +pub const PAYOUT_EXPIRY_MARGIN_MS: u64 = 30_000 + 64 * 12_000 + 600_000; + /// The id of attempt `attempt` on `account`, as getcash numbers its top-ups /// and payments: the account itself first, then /// `blake2_256(account ‖ u32le attempt)`. @@ -159,7 +183,8 @@ pub enum FundingStep { Landed, /// The host's top-up is claiming the CASH. Claiming, - /// The CASH is in the user's balance. + /// The session succeeded: the CASH is in the user's balance, or a + /// withdrawal was paid out. Settled, /// The session ran out of time. Expired, @@ -469,8 +494,25 @@ pub enum FundingStage { /// Outbound: the withdrawal's PAS is on the withdrawal account on Asset /// Hub, to be paid out. Withdrawn { + /// CASH the user paid in, in payment balance units. + paid: u128, /// PAS that landed, in planck. landed: u128, + /// Payout transactions included on Asset Hub that failed so far. + rejections: u8, + /// When the next payout transaction started being refused, if it + /// still is, in Unix milliseconds. + refused_since_ms: Option, + /// The payout transaction on its way, if one is. + submission: Option, + }, + /// Outbound terminal success: the withdrawal account paid everything it + /// held to the payout account and closed. + Released { + /// CASH the user paid in, in payment balance units. + debited: u128, + /// When the payout left, in Unix milliseconds. + settled_at_ms: u64, }, /// Inbound terminal success: the CASH is in the user's balance. Delivered { @@ -510,6 +552,14 @@ pub enum FundingResume { /// CASH the account received, in payment balance units. paid: u128, }, + /// The PAS is on the withdrawal account on Asset Hub: pay it out again, + /// to the payout set by then. + Payout { + /// CASH the user paid in, in payment balance units. + paid: u128, + /// PAS that landed, in planck. + landed: u128, + }, /// The CASH is on People: credit it from `progress`. Credit { /// What the top-ups claimed, and the attempt to go on with. @@ -589,6 +639,7 @@ impl FundingSession { } => FundingStep::Converting, FundingStage::Paid { .. } => FundingStep::Paid, FundingStage::Withdrawn { .. } => FundingStep::Landed, + FundingStage::Released { .. } => FundingStep::Settled, FundingStage::Open => FundingStep::Started, FundingStage::Converting { submission: None, .. @@ -665,7 +716,9 @@ impl FundingSession { | FundingStage::Crediting { .. } | FundingStage::Paid { .. } | FundingStage::Withdrawn { .. } => None, - FundingStage::Delivered { settled_at_ms, .. } => Some(settled_at_ms), + FundingStage::Delivered { settled_at_ms, .. } | FundingStage::Released { settled_at_ms, .. } => { + Some(settled_at_ms) + } FundingStage::Failed { settled_at_ms, .. } => Some(settled_at_ms), } } @@ -689,6 +742,9 @@ impl FundingSession { | FundingStage::Withdrawn { .. }, _, ) => HostFundingStatusSubscribeItem::Converting, + (FundingStage::Released { debited, .. }, _) => HostFundingStatusSubscribeItem::Released { + debited: *debited, + }, (FundingStage::Delivered { credited, .. }, _) => { HostFundingStatusSubscribeItem::Delivered { credited: *credited, @@ -862,6 +918,13 @@ impl FundingSession { withdrawal.taken = false; FundingStage::Open } + FundingResume::Payout { paid, landed } => FundingStage::Withdrawn { + paid, + landed, + rejections: 0, + refused_since_ms: None, + submission: None, + }, FundingResume::Withdrawal { paid } => FundingStage::Paid { paid, rejections: 0, @@ -1492,7 +1555,15 @@ impl FundingSession { ); } } - WithdrawStep::Landed { landed } => self.stage = FundingStage::Withdrawn { landed }, + WithdrawStep::Landed { landed } => { + self.stage = FundingStage::Withdrawn { + paid: *paid, + landed, + rejections: 0, + refused_since_ms: None, + submission: None, + } + } WithdrawStep::Stalled => { return self.fail( FundingFailure::Other { @@ -1507,6 +1578,188 @@ impl FundingSession { } } +/// A payout transaction on its way on Asset Hub. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct PayoutSubmission { + /// The account's nonce it was signed with. + pub nonce: u32, + /// Last Asset Hub block it can be included in. + pub valid_until_block: u64, +} + +/// What one pass of a withdrawal's payout found or did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PayoutStep { + /// The payout to `payout` is about to be submitted. + Submitted { + /// The transaction. + submission: PayoutSubmission, + /// Where it pays, which must still be the session's payout. + payout: WithdrawalPayout, + }, + /// The account is empty: the payout went out. + Sent, + /// The submitted payout's era ended unincluded; the next pass submits + /// again. + Dropped, + /// It was included and failed, which costs a fee. + Rejected { + /// Why. + reason: String, + }, + /// Signing it, or Asset Hub's transaction pool, refused it. + Refused { + /// Why. + reason: String, + }, + /// Nothing is on the account to pay out, and nothing went out. + Empty, + /// The channel closes too soon to pay it; nothing moved. + ChannelClosing, +} + +impl FundingSession { + /// The withdrawal account, its payout and the payout transaction on its + /// way, of a session whose PAS is on Asset Hub, once a payout is named. + pub fn paying_out(&self) -> Option<(&FundingWithdrawal, WithdrawalPayout, Option)> { + match (&self.stage, &self.withdrawal) { + (FundingStage::Withdrawn { submission, .. }, Some(withdrawal)) => { + Some((withdrawal, withdrawal.payout?, *submission)) + } + _ => None, + } + } + + /// Name the account a withdrawal pays out to, or a new one after a + /// channel closed: while the withdrawal is in flight or held for a + /// retry, never while a payout is on its way. A released withdrawal + /// takes a new payout too and pays out again, for PAS a provider refunded + /// to the withdrawal account, as getcash pays a refund out to a fresh + /// channel. Returns whether it was set. + pub fn set_payout(&mut self, payout: WithdrawalPayout) -> bool { + let settable = match &self.stage { + FundingStage::Withdrawn { submission, .. } => submission.is_none(), + FundingStage::Released { .. } => true, + FundingStage::Failed { resume, .. } => matches!( + resume, + Some(FundingResume::Payment | FundingResume::Withdrawal { .. } | FundingResume::Payout { .. }) + ), + _ => !self.is_terminal(), + }; + if !settable || self.withdrawal.is_none() { + return false; + } + if let FundingStage::Released { debited, .. } = self.stage { + self.stage = FundingStage::Withdrawn { + paid: debited, + landed: 0, + rejections: 0, + refused_since_ms: None, + submission: None, + }; + } + if let Some(withdrawal) = self.withdrawal.as_mut() { + withdrawal.payout = Some(payout); + } + true + } + + /// Advance a withdrawal being paid out by one step. Returns whether the + /// session changed. + pub fn advance_payout(&mut self, step: PayoutStep, now_ms: u64) -> bool { + let FundingStage::Withdrawn { + paid, + landed, + rejections, + refused_since_ms, + submission, + } = &mut self.stage + else { + return false; + }; + let resume = Some(FundingResume::Payout { + paid: *paid, + landed: *landed, + }); + match step { + PayoutStep::Submitted { + submission: submitted, + payout, + } => { + // Re-pointed since the pass read it, or already on its way: + // not submitted. + let current = self.withdrawal.as_ref().and_then(|withdrawal| withdrawal.payout); + if submission.is_some() || current != Some(payout) { + return false; + } + *submission = Some(submitted); + *refused_since_ms = None; + } + PayoutStep::Dropped => *submission = None, + PayoutStep::Empty => { + return self.fail( + FundingFailure::Other { + code: "payout_empty".into(), + message: "the withdrawal account holds nothing to pay out".into(), + }, + now_ms, + ); + } + PayoutStep::Sent => { + self.stage = FundingStage::Released { + debited: *paid, + settled_at_ms: now_ms, + }; + } + PayoutStep::Rejected { reason } => { + *submission = None; + *rejections = rejections.saturating_add(1); + if *rejections >= MAX_WITHDRAW_REJECTIONS { + return self.fail_resumable( + FundingFailure::Other { + code: "payout_rejected".into(), + message: format!( + "the payout was rejected {MAX_WITHDRAW_REJECTIONS} times, last: {reason}" + ), + }, + resume, + now_ms, + ); + } + } + PayoutStep::Refused { reason } => { + *submission = None; + let since = *refused_since_ms.get_or_insert(now_ms); + if now_ms.saturating_sub(since) > WITHDRAW_REFUSAL_WINDOW_MS { + return self.fail_resumable( + FundingFailure::Other { + code: "payout_refused".into(), + message: reason, + }, + resume, + now_ms, + ); + } + } + PayoutStep::ChannelClosing => { + return self.fail_resumable( + FundingFailure::Other { + code: "channel_expired".into(), + message: "the provider's channel closes too soon to pay it; nothing was sent".into(), + }, + resume, + now_ms, + ); + } + } + true + } +} + /// What one pass of a conversion found or did. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConversionStep { @@ -1952,6 +2205,7 @@ mod tests { attempt: 0, since_ms: NOW, taken, + payout: None, }); session } @@ -2090,7 +2344,13 @@ mod tests { submission: None, }, FundingStep::Landed, - FundingStage::Withdrawn { landed: 950 }, + FundingStage::Withdrawn { + paid: 1_000, + landed: 950, + rejections: 0, + refused_since_ms: None, + submission: None, + }, ) ); } @@ -2135,6 +2395,111 @@ mod tests { ); } + fn withdrawn() -> FundingSession { + let mut session = paid(); + session.advance_withdrawal(WithdrawStep::Landed { landed: 950 }, NOW); + session + } + + // A payout goes only where it was named, never to one re-pointed since + // the pass read it; a channel that closed holds the PAS for a new one, + // which can then be named and paid; once a payout is on its way the + // account cannot be re-pointed; once it left the session is released + // with what the user paid in, and a refund to the account can be paid + // out again to a fresh channel. + #[test] + fn a_withdrawal_is_paid_out_where_named_and_released() { + let channel = WithdrawalPayout { + address: [8; 32], + expires_at_ms: Some(NOW + 1), + }; + let own = WithdrawalPayout { + address: [9; 32], + expires_at_ms: None, + }; + let mut closed = withdrawn(); + closed.set_payout(channel); + closed.advance_payout(PayoutStep::ChannelClosing, NOW); + let renamed = closed.set_payout(own); + closed.retry(NOW + 1).expect("retried"); + let paying = closed.paying_out().map(|(_, payout, _)| payout); + let stale = closed.advance_payout( + PayoutStep::Submitted { + submission: PayoutSubmission { + nonce: 3, + valid_until_block: 164, + }, + payout: channel, + }, + NOW + 2, + ); + closed.advance_payout( + PayoutStep::Submitted { + submission: PayoutSubmission { + nonce: 3, + valid_until_block: 164, + }, + payout: own, + }, + NOW + 2, + ); + let repointed = closed.set_payout(channel); + closed.advance_payout(PayoutStep::Sent, NOW + 3); + let released = (closed.wire_item(), closed.step()); + let refunded = closed.set_payout(channel); + + assert_eq!( + (renamed, paying, stale, repointed, released, refunded, closed.step()), + ( + true, + Some(own), + false, + false, + (HostFundingStatusSubscribeItem::Released { debited: 1_000 }, FundingStep::Settled), + true, + FundingStep::Landed, + ) + ); + } + + // A payout that keeps failing at inclusion costs a fee each time, so the + // third holds it for a retry; an account with nothing to pay out ends + // the session, since a retry would find the same. + #[test] + fn a_payout_is_held_after_three_rejections_and_an_empty_account_ends_it() { + let mut rejected = withdrawn(); + for _ in 0..3 { + rejected.advance_payout(PayoutStep::Rejected { reason: "no".into() }, NOW); + } + let mut empty = withdrawn(); + empty.advance_payout(PayoutStep::Empty, NOW); + + assert_eq!( + (rejected.stage, empty.stage), + ( + FundingStage::Failed { + reason: FundingFailure::Other { + code: "payout_rejected".into(), + message: "the payout was rejected 3 times, last: no".into(), + }, + settled_at_ms: NOW, + resume: Some(FundingResume::Payout { + paid: 1_000, + landed: 950, + }), + }, + FundingStage::Failed { + reason: FundingFailure::Other { + code: "payout_empty".into(), + message: "the withdrawal account holds nothing to pay out".into(), + }, + settled_at_ms: NOW, + resume: None, + }, + ) + ); + } + // A deposit screen and a provider must see the address Asset Hub // wallets and Chainflip accept: prefix 0, as getcash encodes it. #[test] diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index c758c87078..d150d4c8f6 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -383,6 +383,21 @@ impl NativeTrUApiHostRuntime { .await?) } + /// Name the Asset Hub account session `intent`'s withdrawal pays out + /// to: a provider's channel closing at `expires_at_ms`, or the user's own + /// account with none. + pub async fn set_withdrawal_payout( + &self, + intent: String, + address: crate::Bytes32, + expires_at_ms: Option, + ) -> Result<(), HostRejection> { + Ok(self + .runtime + .set_withdrawal_payout(&intent, address, expires_at_ms) + .await?) + } + /// Give session `intent` the deposit account its provider pays into. pub async fn assign_funding_deposit( &self, diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs index 7f4370532c..2d3fe58abb 100644 --- a/rust/crates/truapi/src/runtime/funding.rs +++ b/rust/crates/truapi/src/runtime/funding.rs @@ -502,6 +502,7 @@ impl FundingRegistry { session.watched_deposit(current_unix_millis()).is_some() || session.watched_withdrawal(current_unix_millis()).is_some() || session.withdrawing().is_some() + || session.paying_out().is_some() || session.converting().is_some() || session.crediting().is_some() }) @@ -1201,6 +1202,9 @@ impl RuntimeServices { if let Err(reason) = services.advance_withdrawal_moves().await { tracing::warn!(%reason, "funding withdrawal move failed"); } + if let Err(reason) = services.advance_payouts().await { + tracing::warn!(%reason, "funding payout pass failed"); + } } })); } @@ -1795,6 +1799,7 @@ mod tests { attempt: 0, since_ms: NOW, taken: false, + payout: None, }); insert(®istry, storage.as_ref(), withdrawing); diff --git a/rust/crates/truapi/src/runtime/funding/conversion.rs b/rust/crates/truapi/src/runtime/funding/conversion.rs index 71586ec4be..267ce1c2dc 100644 --- a/rust/crates/truapi/src/runtime/funding/conversion.rs +++ b/rust/crates/truapi/src/runtime/funding/conversion.rs @@ -28,7 +28,9 @@ use crate::runtime::statement_allowance::extension::{ChainState, Metadata as Ext mod withdraw; -pub use withdraw::{PreparedWithdrawal, WithdrawCall, WithdrawChains, landing_floor}; +pub use withdraw::{ + PayoutChains, PreparedPayout, PreparedWithdrawal, WithdrawCall, WithdrawChains, landing_floor, +}; /// XCM version every program and dry run uses. const XCM_VERSION: u32 = 5; diff --git a/rust/crates/truapi/src/runtime/funding/conversion/withdraw.rs b/rust/crates/truapi/src/runtime/funding/conversion/withdraw.rs index ffb3f29556..778dde11ba 100644 --- a/rust/crates/truapi/src/runtime/funding/conversion/withdraw.rs +++ b/rust/crates/truapi/src/runtime/funding/conversion/withdraw.rs @@ -573,6 +573,86 @@ impl WithdrawChains for Chains { } } +/// A payout transaction signed and ready to submit on Asset Hub. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedPayout { + /// The signed extrinsic. + pub extrinsic: Vec, + /// Last Asset Hub block it can be included in. + pub valid_until_block: u64, +} + +/// What paying a withdrawal out reads and does on Asset Hub. +pub trait PayoutChains: Send + Sync { + /// `account`'s PAS on Asset Hub. + fn asset_hub_native<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result>; + /// `account`'s next nonce on Asset Hub. + fn asset_hub_nonce<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result>; + /// The finalized Asset Hub block these reads are pinned to. + fn asset_hub_block(&self) -> u64; + /// Asset Hub's existential deposit, the most a payout that went through + /// can leave behind. + fn asset_hub_existential_deposit<'a>(&'a self) -> BoxFuture<'a, Result>; + /// Sign the payout of everything the account `keypair` holds to `to`. + fn prepare_payout<'a>( + &'a self, + keypair: &'a schnorrkel::Keypair, + nonce: u32, + to: [u8; 32], + ) -> BoxFuture<'a, Result>; +} + +impl PayoutChains for Chains { + fn asset_hub_native<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result> { + Box::pin(Chains::asset_hub_native(self, account)) + } + + fn asset_hub_nonce<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result> { + Box::pin(self.account_nonce(account)) + } + + fn asset_hub_block(&self) -> u64 { + self.asset_hub.block_number() + } + + fn asset_hub_existential_deposit<'a>(&'a self) -> BoxFuture<'a, Result> { + Box::pin(self.min_balance(crate::host_logic::funding::DepositAsset::Native)) + } + + fn prepare_payout<'a>( + &'a self, + keypair: &'a schnorrkel::Keypair, + nonce: u32, + to: [u8; 32], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let extensions = self + .extensions + .as_deref() + .ok_or_else(|| chain("Asset Hub's signing metadata was not loaded"))?; + let signer = Sr25519Signer::from_keypair(keypair); + Ok(PreparedPayout { + extrinsic: sign_on(&self.asset_hub, extensions, &signer, &payout_call(&to), &native(), nonce)?, + valid_until_block: self.asset_hub.block_number() + super::MORTAL_PERIOD_BLOCKS, + }) + }) + } +} + +/// Everything the signing account holds on Asset Hub to `to`, the account +/// reaped behind it, as getcash's sweep pays a provider's channel: a +/// transfer inside an extrinsic, which is what a channel witnesses. +fn payout_call(to: &[u8; 32]) -> RuntimeCall { + RuntimeCall::new( + "Balances", + "transfer_all", + vec![ + ("dest", Value::unnamed_variant("Id", [Value::from_bytes(to)])), + ("keep_alive", Value::bool(false)), + ], + ) +} + /// A chain's `TrustedQueryApi` answer for `asset` from `location`. async fn trusted( at: &subxt::client::OnlineClientAtBlock, @@ -897,6 +977,44 @@ mod live { } } + // The payout is named and encoded as Asset Hub's runtime takes it: a + // dry run of it from an account holding PAS runs. + #[tokio::test] + #[ignore = "reaches Paseo Next"] + async fn the_payout_call_encodes_and_dry_runs_live() { + let chains = chains().await; + let mut entries = chains + .asset_hub + .storage() + .iter(dynamic::storage::<(Value,), Value>("System", "Account"), ()) + .await + .expect("accounts iterate"); + let mut holder = None; + while let Some(entry) = entries.next().await { + let entry = entry.expect("entry reads"); + let value = entry.value().decode().expect("decodes"); + if u128_at(field(&value, "data").expect("data"), "free").expect("free") >= 10_000_000_000 { + let key = entry.key_bytes(); + holder = Some(<[u8; 32]>::try_from(&key[key.len() - 32..]).expect("account id")); + break; + } + } + let holder = holder.expect("an Asset Hub account holds PAS"); + let origin = Value::unnamed_variant("system", [Value::unnamed_variant("Signed", [Value::from_bytes(holder)])]); + + let effects = ok(call_api( + &chains.asset_hub, + "DryRunApi", + "dry_run_call", + vec![origin, payout_call(&[7; 32]).value(), Value::u128(XCM_VERSION.into())], + ) + .await + .expect("dry run")) + .expect("dry run ran"); + + assert_eq!(variant_name(field(&effects, "execution_result").expect("result")), Some("Ok")); + } + // The swap is named and encoded as People's runtime takes it: a dry run // swaps the account's CASH for the PAS the XCM's fees need, and the cap // leaves CASH for the XCM. diff --git a/rust/crates/truapi/src/runtime/funding/withdraw.rs b/rust/crates/truapi/src/runtime/funding/withdraw.rs index 4f89fce299..00139af6e5 100644 --- a/rust/crates/truapi/src/runtime/funding/withdraw.rs +++ b/rust/crates/truapi/src/runtime/funding/withdraw.rs @@ -15,16 +15,17 @@ use truapi::latest::{ }; use super::conversion::{ - ConversionChains, ConversionError, PreparedWithdrawal, WithdrawCall, WithdrawChains, - landing_floor, + ConversionChains, ConversionError, PayoutChains, PreparedPayout, PreparedWithdrawal, + WithdrawCall, WithdrawChains, landing_floor, }; use super::{ FundingSigner, STALL_AFTER_MS, SignOn, CANCEL_CONFIRM, CancelFundingError, FundingRegistry, MAX_USED_ACCOUNTS, within_chain_timeout, within_timeout, }; use crate::host_logic::funding::{ - FundingSessionError, FundingWithdrawal, PaymentReading, PaymentWord, WithdrawStep, - WithdrawSubmission, funding_attempt_id, + FundingSessionError, FundingWithdrawal, PAYOUT_EXPIRY_MARGIN_MS, PaymentReading, PaymentWord, + PayoutStep, PayoutSubmission, WithdrawStep, WithdrawSubmission, WithdrawalPayout, + funding_attempt_id, }; use crate::platform::{CoreStorage, ProductContext}; use crate::runtime::services::RuntimeServices; @@ -194,7 +195,119 @@ async fn plan_withdrawal( })) } +/// What a pass decided for one withdrawal being paid out. +#[derive(Debug, PartialEq, Eq)] +enum PlannedPayout { + /// Record a step. + Record(PayoutStep), + /// Record the submission to `payout`, then submit `extrinsic` on Asset + /// Hub. + Submit { + submission: PayoutSubmission, + payout: WithdrawalPayout, + extrinsic: Vec, + }, +} + +/// Decide the next step for paying `withdrawal` out to `payout`, as +/// getcash's sweep does, from the account's balance alone: an empty account +/// after a submission was paid out; one included that left the PAS failed; +/// one whose era passed unincluded is dropped. With nothing on its way, a +/// channel too close to its expiry is not paid, since what lands in a closed +/// one is lost; otherwise the transfer of everything goes out. +async fn plan_payout( + chains: &dyn PayoutChains, + signer: &dyn FundingSigner, + withdrawal: &FundingWithdrawal, + payout: WithdrawalPayout, + submission: Option, + now_ms: u64, +) -> Result, ConversionError> { + let account = &withdrawal.account; + let held = chains.asset_hub_native(account).await?; + let record = |step| Ok(Some(PlannedPayout::Record(step))); + if let Some(PayoutSubmission { + nonce, + valid_until_block, + }) = submission + { + // A transfer that went through leaves at most the existential + // deposit, when the account could not be reaped. + if held <= chains.asset_hub_existential_deposit().await? { + return record(PayoutStep::Sent); + } + if chains.asset_hub_nonce(account).await? > nonce { + return record(PayoutStep::Rejected { + reason: "the payout was included and failed".into(), + }); + } + if chains.asset_hub_block() > valid_until_block { + return record(PayoutStep::Dropped); + } + return Ok(None); + } + if held == 0 { + return record(PayoutStep::Empty); + } + if payout + .expires_at_ms + .is_some_and(|expires_at_ms| now_ms.saturating_add(PAYOUT_EXPIRY_MARGIN_MS) >= expires_at_ms) + { + return record(PayoutStep::ChannelClosing); + } + let keypair = signer + .withdrawal_keypair(&withdrawal.destination_id, withdrawal.number) + .map_err(|error| ConversionError::Chain(error.reason))?; + let Some(keypair) = keypair.filter(|keypair| keypair.public.to_bytes() == *account) else { + tracing::warn!("the active identity does not hold this withdrawal account's key"); + return Ok(None); + }; + let nonce = chains.asset_hub_nonce(account).await?; + let PreparedPayout { + extrinsic, + valid_until_block, + } = chains.prepare_payout(&keypair, nonce, payout.address).await?; + Ok(Some(PlannedPayout::Submit { + submission: PayoutSubmission { + nonce, + valid_until_block, + }, + payout, + extrinsic, + })) +} + impl FundingRegistry { + /// Every session paying a withdrawal out, with its withdrawal account, + /// payout and the payout transaction on its way. + fn paying_out(&self) -> Vec<(String, FundingWithdrawal, WithdrawalPayout, Option)> { + self.lock_sessions() + .values() + .filter_map(|session| { + let (withdrawal, payout, submission) = session.paying_out()?; + Some((session.intent.clone(), withdrawal.clone(), payout, submission)) + }) + .collect() + } + + /// Apply one step of a withdrawal's payout to session `intent`. + async fn record_payout_step( + &self, + storage: &(impl CoreStorage + ?Sized), + now_ms: u64, + intent: &str, + step: PayoutStep, + ) -> Result<(), FundingSessionError> { + let intent = intent.to_string(); + self.commit(storage, now_ms, move |sessions| { + let changed = sessions + .get_mut(&intent) + .is_some_and(|session| session.advance_payout(step, now_ms)); + ((), if changed { vec![intent] } else { Vec::new() }) + }) + .await + } + /// Every session whose CASH is being moved to Asset Hub, with its /// withdrawal account and the transaction on its way. fn moving_withdrawals(&self) -> Vec<(String, FundingWithdrawal, Option)> { @@ -308,6 +421,7 @@ impl RuntimeServices { attempt: 0, since_ms: current_unix_millis(), taken: false, + payout: None, }; let owned = intent.to_string(); let assigned = registry @@ -554,6 +668,126 @@ impl RuntimeServices { } Ok(()) } + + /// Name the Asset Hub account session `intent`'s withdrawal pays out to: + /// the provider's channel, checked against the provider's own record by + /// whoever hands it over, or the user's own account. Refused once a + /// payout is on its way or done. + pub async fn set_withdrawal_payout( + self: &Arc, + intent: &str, + payout: WithdrawalPayout, + ) -> Result<(), WithdrawError> { + let owned = intent.to_string(); + let set = self + .funding() + .commit(self.platform.as_ref(), current_unix_millis(), move |sessions| { + let set = sessions.get_mut(&owned).map(|session| session.set_payout(payout)); + (set, Vec::new()) + }) + .await?; + match set { + None => Err(WithdrawError::NotFound), + Some(false) => Err(WithdrawError::NotAwaitingWithdrawal), + Some(true) => { + self.watch_funding_deposits(); + Ok(()) + } + } + } + + /// One pass over the withdrawals being paid out: record what was sent, + /// dropped, rejected or refused, and submit what is ready. + pub async fn advance_payouts(self: &Arc) -> Result<(), String> { + let registry = self.funding(); + let paying = registry.paying_out(); + let Some(conversion) = registry.conversion.get() else { + return Ok(()); + }; + if paying.is_empty() { + return Ok(()); + } + let signing = paying.iter().any(|(_, _, _, submission)| submission.is_none()); + let chains = self + .funding_chains(conversion.network, if signing { SignOn::AssetHub } else { SignOn::Nowhere }) + .await + .map_err(|error| error.to_string())?; + let storage = self.platform.as_ref(); + let record = |intent: String, step| async move { + if let Err(error) = registry.record_payout_step(storage, current_unix_millis(), &intent, step).await { + tracing::warn!(%intent, %error, "recording a funding payout failed"); + return false; + } + true + }; + for (intent, withdrawal, payout, submission) in paying { + let planned = within_chain_timeout(plan_payout( + &chains, + conversion.signer.as_ref(), + &withdrawal, + payout, + submission, + current_unix_millis(), + )) + .await; + let planned = match planned { + Ok(Ok(planned)) => planned, + Ok(Err(ConversionError::Refused(reason) | ConversionError::PsmRefused { reason, .. })) => { + Some(PlannedPayout::Record(PayoutStep::Refused { reason })) + } + Ok(Err(ConversionError::Chain(reason))) | Err(GenericError { reason }) => { + tracing::warn!(%intent, %reason, "funding payout pass failed"); + continue; + } + }; + match planned { + None => {} + Some(PlannedPayout::Record(step)) => { + record(intent.clone(), step).await; + } + Some(PlannedPayout::Submit { + submission, + payout, + extrinsic, + }) => { + // Never submitted unless recorded for the payout still + // named, so a lost answer is judged by the balance and a + // re-pointed payout is not paid to the old account. + let recorded = registry + .commit(storage, current_unix_millis(), { + let intent = intent.clone(); + move |sessions| { + let now_ms = current_unix_millis(); + let step = PayoutStep::Submitted { submission, payout }; + let changed = sessions + .get_mut(&intent) + .is_some_and(|session| session.advance_payout(step, now_ms)); + (changed, if changed { vec![intent] } else { Vec::new() }) + } + }) + .await; + match recorded { + Ok(true) => {} + Ok(false) => continue, + Err(error) => { + tracing::warn!(%intent, %error, "recording a funding payout failed"); + continue; + } + } + match chains.submit(extrinsic).await { + Ok(()) => {} + Err(ConversionError::Refused(reason) | ConversionError::PsmRefused { reason, .. }) => { + record(intent.clone(), PayoutStep::Refused { reason }).await; + } + Err(ConversionError::Chain(reason)) => { + tracing::warn!(%intent, %reason, "submitting a funding payout failed"); + } + } + } + } + } + Ok(()) + } } #[cfg(test)] @@ -638,6 +872,7 @@ mod tests { attempt: 0, since_ms: NOW, taken: true, + payout: None, } } @@ -783,4 +1018,128 @@ mod tests { assert_eq!(wrong, None); } + + /// Asset Hub answering fixed reads and signing a fixed payout. + struct AssetHub { + held: u128, + nonce: u32, + block: u64, + } + + impl PayoutChains for AssetHub { + fn asset_hub_native<'a>(&'a self, _: &'a [u8; 32]) -> BoxFuture<'a, Result> { + Box::pin(async { Ok(self.held) }) + } + + fn asset_hub_nonce<'a>(&'a self, _: &'a [u8; 32]) -> BoxFuture<'a, Result> { + Box::pin(async { Ok(self.nonce) }) + } + + fn asset_hub_block(&self) -> u64 { + self.block + } + + fn asset_hub_existential_deposit<'a>(&'a self) -> BoxFuture<'a, Result> { + Box::pin(async { Ok(10) }) + } + + fn prepare_payout<'a>( + &'a self, + _: &'a schnorrkel::Keypair, + _: u32, + _: [u8; 32], + ) -> BoxFuture<'a, Result> { + Box::pin(async { + Ok(PreparedPayout { + extrinsic: vec![9], + valid_until_block: 164, + }) + }) + } + } + + fn pay_out( + held: u128, + nonce: u32, + block: u64, + expires_at_ms: Option, + submission: Option, + now_ms: u64, + ) -> Option { + let payout = WithdrawalPayout { + address: [8; 32], + expires_at_ms, + }; + block_on(plan_payout( + &AssetHub { held, nonce, block }, + &Keys(keypair(1)), + &withdrawal(), + payout, + submission, + now_ms, + )) + .expect("planned") + } + + // getcash's sweep is judged by the balance alone: an account emptied, + // down to the existential deposit it may not be reaped below, after a + // submission was paid out whatever became of the answer; one included + // that left the PAS failed and cost a fee; one whose era passed is + // dropped. A channel about to close is not paid, since what lands in a + // closed one is neither swapped nor refunded. + #[test] + fn a_payout_is_judged_by_the_balance_and_never_sent_to_a_closing_channel() { + let sent = Some(PayoutSubmission { + nonce: 3, + valid_until_block: 164, + }); + let closes = NOW + PAYOUT_EXPIRY_MARGIN_MS; + + assert_eq!( + [ + pay_out(950, 3, 100, None, None, NOW), + pay_out(950, 3, 100, Some(closes + 1), None, NOW), + pay_out(950, 3, 100, Some(closes), None, NOW), + pay_out(0, 3, 100, None, None, NOW), + pay_out(0, 4, 100, None, sent, NOW), + pay_out(10, 4, 100, None, sent, NOW), + pay_out(950, 4, 100, None, sent, NOW), + pay_out(950, 3, 165, None, sent, NOW), + pay_out(950, 3, 164, None, sent, NOW), + ], + [ + Some(PlannedPayout::Submit { + submission: PayoutSubmission { + nonce: 3, + valid_until_block: 164, + }, + payout: WithdrawalPayout { + address: [8; 32], + expires_at_ms: None, + }, + extrinsic: vec![9], + }), + Some(PlannedPayout::Submit { + submission: PayoutSubmission { + nonce: 3, + valid_until_block: 164, + }, + payout: WithdrawalPayout { + address: [8; 32], + expires_at_ms: Some(closes + 1), + }, + extrinsic: vec![9], + }), + Some(PlannedPayout::Record(PayoutStep::ChannelClosing)), + Some(PlannedPayout::Record(PayoutStep::Empty)), + Some(PlannedPayout::Record(PayoutStep::Sent)), + Some(PlannedPayout::Record(PayoutStep::Sent)), + Some(PlannedPayout::Record(PayoutStep::Rejected { + reason: "the payout was included and failed".into(), + })), + Some(PlannedPayout::Record(PayoutStep::Dropped)), + None, + ] + ); + } } diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 49ab755151..ab6157d7ff 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -3040,6 +3040,7 @@ fn a_withdrawal_is_paid_through_the_host_and_a_refusal_is_asked_again() { attempt: 0, since_ms: crate::runtime::current_unix_millis(), taken: false, + payout: None, }); } ((), Vec::new())