From 06410ebd255b8dcea8c42a3846871b08098392be Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Sat, 3 Oct 2026 15:53:36 +0200 Subject: [PATCH 01/20] feat(truapi): derive funding accounts under fund. --- .changeset/funding-deposit-account.md | 5 + .../tests/golden/host-callbacks.ts | 8 +- rust/crates/truapi/src/host_core.rs | 31 +++++ rust/crates/truapi/src/host_logic/funding.rs | 121 ++++++++++++++++++ .../truapi/src/host_logic/product_account.rs | 10 ++ rust/crates/truapi/src/platform.rs | 5 + rust/crates/truapi/src/platform/mock.rs | 1 + rust/crates/truapi/src/runtime.rs | 18 ++- .../src/runtime/capabilities/payment.rs | 5 + rust/crates/truapi/src/runtime/funding.rs | 14 +- .../crates/truapi/src/runtime/signing_host.rs | 116 +++++++++++++++-- rust/crates/truapi/src/runtime/tests.rs | 29 +++++ 12 files changed, 350 insertions(+), 13 deletions(-) create mode 100644 .changeset/funding-deposit-account.md diff --git a/.changeset/funding-deposit-account.md b/.changeset/funding-deposit-account.md new file mode 100644 index 0000000000..a80a46bf88 --- /dev/null +++ b/.changeset/funding-deposit-account.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": patch +--- + +Signing hosts derive funding accounts under the reserved `fund.` product, labelled as getcash labels them, with a persisted counter per source (Rust API: `funding_account`, `next_funding_account_number`). No product can use accounts under `fund.`. diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index ddc7d2f238..17ce0793f5 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -212,7 +212,12 @@ export type CoreStorageKey = * Funding sessions: every live one plus a bounded tail of settled ones, * as one SCALE blob. */ - | { tag: "FundingSessions"; value?: undefined }; + | { tag: "FundingSessions"; value?: undefined } + /** + * Last funding account number handed out per source, as one SCALE blob. + * Never reset, so no account is reused. + */ + | { tag: "FundingAccountCounters"; value?: undefined }; /** * Review shown before a product creates a ring-VRF proof (RFC 0004). @@ -793,6 +798,7 @@ export const CoreStorageKey: S.Codec = S.lazy( productId: string; }>, FundingSessions: S._void, + FundingAccountCounters: S._void, }), ); diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index 16d956da8e..e063c2803e 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -613,6 +613,37 @@ impl SigningHostRuntime { self.signing_host.set_grant_allowances_unchecked(granted); } + /// Public key of the `number`th funding account of `kind` for + /// `source_id`, or `None` while no signing session is active. Number + /// accounts with [`Self::next_funding_account_number`]. + pub fn funding_account( + &self, + kind: crate::host_logic::funding::FundingAccountKind, + source_id: &str, + number: u32, + ) -> Result, v01::GenericError> { + self.signing_host + .derive_funding_account(kind, source_id, number) + .map_err(|err| v01::GenericError { + reason: err.to_string(), + }) + } + + /// Reserve the next account number for `source_id`, counting up from 1, + /// so no two sessions share an account. + pub async fn next_funding_account_number( + &self, + source_id: &str, + ) -> Result { + self.services + .funding() + .next_account_number(self.services.platform.as_ref(), source_id) + .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 845b9246e5..741872ae7d 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -7,6 +7,8 @@ //! `Funding::status_subscribe`. A session always terminates, because the core //! expires it on its own clock. +use std::collections::BTreeMap; + use parity_scale_codec::{Decode, Encode}; use tracing::warn; use truapi::latest::{FundingDirection, FundingFailure, HostFundingStatusSubscribeItem}; @@ -124,6 +126,47 @@ impl FundingSession { } } +/// Which of a session's accounts under the reserved funding product. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FundingAccountKind { + /// Where an inbound provider delivers. + Deposit, + /// Where a crypto rail returns funds it could not deliver. + Refund, + /// Where an outbound session stages funds before paying the provider. + Withdrawal, +} + +/// Why a funding account label could not be built. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] +#[display("funding account label is longer than 32 bytes")] +pub struct FundingAccountLabelTooLong; + +/// Derivation index of the `number`th account of `kind` for `source_id`: the +/// label `onramp:eph::`, `onramp:rf::` or +/// `wd:eph::`, zero-padded to 32 bytes. +/// +/// The labels are the ones getcash uses, and `number` counts up from 1 per +/// source, so every account can be found again from the seed alone. +pub fn funding_account_index( + kind: FundingAccountKind, + source_id: &str, + number: u32, +) -> Result<[u8; 32], FundingAccountLabelTooLong> { + let prefix = match kind { + FundingAccountKind::Deposit => "onramp:eph", + FundingAccountKind::Refund => "onramp:rf", + FundingAccountKind::Withdrawal => "wd:eph", + }; + let label = format!("{prefix}:{source_id}:{number}"); + let mut index = [0u8; 32]; + index + .get_mut(..label.len()) + .ok_or(FundingAccountLabelTooLong)? + .copy_from_slice(label.as_bytes()); + Ok(index) +} + /// Why a session operation failed. #[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] pub enum FundingSessionError { @@ -190,6 +233,41 @@ pub async fn store_sessions( written.map_err(|err| FundingSessionError::Storage { reason: err.reason }) } +/// Reserve the next account number for `source_id`, counting up from 1. +/// +/// Counters are never reset, so no two sessions on this device share an +/// account. A blob that does not decode is an error rather than a reset for +/// the same reason. Counters are per device: the same seed on a new install +/// starts from 1 again, so whoever hands an account to a provider must first +/// check it is empty on chain. +pub async fn next_account_number( + storage: &(impl CoreStorage + ?Sized), + source_id: &str, +) -> Result { + let storage_error = |reason: String| FundingSessionError::Storage { reason }; + let mut counters = match storage + .read_core_storage(CoreStorageKey::FundingAccountCounters) + .await + .map_err(|err| storage_error(err.reason))? + { + Some(blob) => BTreeMap::::decode(&mut blob.as_slice()) + .ok() + .filter(|counters| counters.encoded_size() == blob.len()) + .ok_or_else(|| storage_error("funding account counters do not decode".into()))?, + None => BTreeMap::new(), + }; + let counter = counters.entry(source_id.to_string()).or_default(); + *counter = counter + .checked_add(1) + .ok_or_else(|| storage_error(format!("funding accounts for {source_id} exhausted")))?; + let number = *counter; + storage + .write_core_storage(CoreStorageKey::FundingAccountCounters, counters.encode()) + .await + .map_err(|err| storage_error(err.reason))?; + Ok(number) +} + #[cfg(test)] mod tests { use super::*; @@ -306,6 +384,49 @@ mod tests { assert_eq!(block_on(load_sessions(storage.as_ref())), Ok(Vec::new())); } + // A reused number hands a second session an account that may still hold + // the first one's funds, so counters only ever move up, per source, and a + // counter blob that cannot be read stops funding instead of restarting. + #[test] + fn account_numbers_count_up_per_source_and_never_restart() { + let storage = stub_platform(); + let next = |source: &str| block_on(next_account_number(storage.as_ref(), source)); + let issued = [next("usdt"), next("usdt"), next("btc"), next("usdt")]; + block_on(storage.write_core_storage(CoreStorageKey::FundingAccountCounters, vec![0xff])) + .expect("written"); + + assert_eq!( + (issued, next("usdt").is_err()), + ([Ok(1), Ok(2), Ok(1), Ok(3)], true) + ); + } + + // Funds sit in these accounts, so an index that drifts between releases + // strands them. The bytes are pinned to the labels getcash uses. + #[test] + fn funding_account_indices_are_the_padded_getcash_labels() { + let padded = |label: &str| { + let mut index = [0u8; 32]; + index[..label.len()].copy_from_slice(label.as_bytes()); + index + }; + + assert_eq!( + [ + funding_account_index(FundingAccountKind::Deposit, "usdt-assethub", 1), + funding_account_index(FundingAccountKind::Refund, "btc", 2), + funding_account_index(FundingAccountKind::Withdrawal, "dot-assethub", 3), + funding_account_index(FundingAccountKind::Deposit, "x".repeat(40).as_str(), 1), + ], + [ + Ok(padded("onramp:eph:usdt-assethub:1")), + Ok(padded("onramp:rf:btc:2")), + Ok(padded("wd:eph:dot-assethub:3")), + Err(FundingAccountLabelTooLong), + ] + ); + } + #[test] fn storing_nothing_clears_the_slot() { let storage = stub_platform(); diff --git a/rust/crates/truapi/src/host_logic/product_account.rs b/rust/crates/truapi/src/host_logic/product_account.rs index c810a7c5f2..782e7e6f4c 100644 --- a/rust/crates/truapi/src/host_logic/product_account.rs +++ b/rust/crates/truapi/src/host_logic/product_account.rs @@ -29,6 +29,10 @@ pub const IDENTITY_LABEL: &str = "uid"; /// domain holds the full and light person keys; the product id is /// `peopl.`, see [`personhood_product_id`]. pub const PERSONHOOD_LABEL: &str = "peopl"; +/// Reserved dotNS label of the funding modality, under whose subtree every +/// funding session's deposit account lives; the product id is +/// `fund.`, see [`funding_product_id`]. +pub const FUNDING_LABEL: &str = "fund"; const RING_VRF_ROOT_KEY: &[u8] = b"ring-vrf"; /// The reserved identity product id on the network with `network_suffix`: @@ -47,6 +51,12 @@ pub fn personhood_product_id(network_suffix: &str) -> String { format!("{PERSONHOOD_LABEL}.{network_suffix}") } +/// The reserved funding product id on the network with `network_suffix`: +/// `fund.dot` on Polkadot, `fund.paseo` on paseo-next-v2. +pub fn funding_product_id(network_suffix: &str) -> String { + format!("{FUNDING_LABEL}.{network_suffix}") +} + /// Substrate sr25519 signing-context string. Shared by every sr25519 signature /// the core produces: statement store, product raw signing, dotNS gateway. pub const SR25519_SIGNING_CONTEXT: &[u8] = b"substrate"; diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index 21c26f418c..4694c40a8a 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -1460,6 +1460,10 @@ pub enum CoreStorageKey { /// as one SCALE blob. #[codec(index = 13)] FundingSessions, + /// Last funding account number handed out per source, as one SCALE blob. + /// Never reset, so no account is reused. + #[codec(index = 14)] + FundingAccountCounters, } /// Stable metadata describing one strictly decoded [`CoreStorageKey`]. @@ -1514,6 +1518,7 @@ pub fn describe_core_storage_key( CoreStorageKey::SsoResponderRequestLedger { .. } => ("SsoResponderRequestLedger", None), CoreStorageKey::ProductManifest { product_id } => ("ProductManifest", Some(product_id)), CoreStorageKey::FundingSessions => ("FundingSessions", None), + CoreStorageKey::FundingAccountCounters => ("FundingAccountCounters", None), }; Ok(CoreStorageKeyDescription { kind, product_id }) } diff --git a/rust/crates/truapi/src/platform/mock.rs b/rust/crates/truapi/src/platform/mock.rs index c212ba7e3e..2051f11cc6 100644 --- a/rust/crates/truapi/src/platform/mock.rs +++ b/rust/crates/truapi/src/platform/mock.rs @@ -799,6 +799,7 @@ fn core_key(key: &CoreStorageKey) -> String { format!("core:product-manifest:{product_id}") } CoreStorageKey::FundingSessions => "core:funding-sessions".to_string(), + CoreStorageKey::FundingAccountCounters => "core:funding-account-counters".to_string(), CoreStorageKey::AllowanceKeys { session_id } => { format!("core:allowance-keys:{session_id}") } diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index f4b92b608f..ac31d9552c 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -123,10 +123,10 @@ use web_time::Instant; use crate::chain_runtime::RuntimeFailure; use crate::host_internal::bulletin::preimage_key; use crate::host_internal::permissions::{PermissionsService, TemporaryPermissions}; -use crate::host_internal::product_manifest::Granted; +use crate::host_internal::product_manifest::{Granted, bare_product_label}; use crate::host_internal::sso_messages::RingVrfError; use crate::host_logic::product_account::{ - derivation_index_bytes, derive_product_public_key, public_key_from_address, + FUNDING_LABEL, derivation_index_bytes, derive_product_public_key, public_key_from_address, }; use crate::host_logic::session::SessionInfo; #[cfg(test)] @@ -540,7 +540,9 @@ impl ProductRuntimeHost { // them. Production hosts must reject localhost products before creating // the product runtime. if crate::platform::is_localhost_product_identifier(&product_id) { - return normalize_product_identifier(dot_ns_identifier).ok(); + return normalize_product_identifier(dot_ns_identifier) + .ok() + .filter(|target| !is_funding_product(target)); } // Bounded here rather than left to the lookup: it can reach dotNS on // the Asset Hub, and a caller's own deadline is what decides how long @@ -549,6 +551,7 @@ impl ProductRuntimeHost { let cx = remote_authority_context(cx); self.bounded_cross_product_scope_target(dot_ns_identifier, Granted::Context, &cx) .await + .filter(|target| !is_funding_product(target)) } /// Resolve the grant under the caller's deadline and cancellation, answering @@ -854,11 +857,20 @@ impl ProductRuntimeHost { } } +/// Whether `product_id` is the reserved funding product or a subname of it. +/// Its accounts hold users' funds in transit, so only the host derives them. +fn is_funding_product(product_id: &str) -> bool { + bare_product_label(product_id) == FUNDING_LABEL +} + async fn account_access_authorization( platform: &dyn Platform, requesting_product_id: &str, target_product_id: &str, ) -> Result { + if is_funding_product(target_product_id) { + return Ok(PermissionAuthorizationStatus::Denied); + } if requesting_product_id == target_product_id || crate::platform::normalizes_to_trusted_remote_permissions(requesting_product_id) { diff --git a/rust/crates/truapi/src/runtime/capabilities/payment.rs b/rust/crates/truapi/src/runtime/capabilities/payment.rs index 326fcccd65..ffa9a8fb72 100644 --- a/rust/crates/truapi/src/runtime/capabilities/payment.rs +++ b/rust/crates/truapi/src/runtime/capabilities/payment.rs @@ -184,6 +184,11 @@ impl Payment for ProductRuntimeHost { if !source_keys_are_valid(&request.source) { return Err(domain(v01::HostPaymentTopUpError::InvalidSource)); } + if matches!(request.source, v01::PaymentTopUpSource::ProductAccount { .. }) + && crate::runtime::is_funding_product(&self.product_id()) + { + return Err(CallError::Denied); + } platform .top_up(&self.product, request) .await diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs index 9cd66efff7..03ea9a1608 100644 --- a/rust/crates/truapi/src/runtime/funding.rs +++ b/rust/crates/truapi/src/runtime/funding.rs @@ -22,7 +22,8 @@ use truapi::latest::{FundingDirection, GenericError, HostFundingStatusSubscribeI use super::services::RuntimeServices; use crate::host_logic::funding::{ - FundingSession, FundingSessionError, load_sessions, retained, store_sessions, + FundingSession, FundingSessionError, load_sessions, next_account_number, retained, + store_sessions, }; use crate::platform::{ CoreStorage, FundingPlatform, FundingPresentOutcome, FundingPresentation, Platform, @@ -134,6 +135,17 @@ impl FundingRegistry { Ok(result) } + /// Reserve the next account number for `source_id`, serialised with every + /// other funding write. + pub async fn next_account_number( + &self, + storage: &(impl CoreStorage + ?Sized), + source_id: &str, + ) -> Result { + let _writes = self.writes.lock().await; + next_account_number(storage, source_id).await + } + /// Keep one task waiting on the earliest open deadline while any session /// is open, so a session expires on time whether or not anyone asks. The /// task ends once no session is open or the registry is dropped. diff --git a/rust/crates/truapi/src/runtime/signing_host.rs b/rust/crates/truapi/src/runtime/signing_host.rs index a4a8f196fe..125063fb1b 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -56,10 +56,12 @@ use crate::host_internal::sso_messages::{OnExistingAllowancePolicy, ProductReque use crate::host_internal::transaction::sign_extrinsic_payload; use crate::host_logic::entropy::derive_product_entropy; use crate::host_logic::features::genesis_for; +use crate::host_logic::funding::{FundingAccountKind, funding_account_index}; use crate::host_logic::product_account::{ ProductAccountError, SR25519_SIGNING_CONTEXT, derivation_index_bytes, derive_identity_keypair, - derive_product_keypair, derive_product_subtree_keypair, derive_ring_vrf_entropy, - derive_root_keypair_from_entropy, personhood_product_id, + derive_product_keypair, derive_product_public_key, derive_product_subtree_keypair, + derive_ring_vrf_entropy, derive_root_keypair_from_entropy, funding_product_id, + personhood_product_id, }; use crate::host_logic::product_account::{ derive_full_person_ring_vrf_entropy, derive_lite_person_ring_vrf_entropy, @@ -505,6 +507,33 @@ impl SigningHost { Ok(Some(subtree.public.to_bytes())) } + /// Public key of the `number`th funding account of `kind` for + /// `source_id`, under the reserved funding product. `None` while no + /// signing session is active. + /// + /// The host claims it by calling its top-up engine as the funding product + /// with source `ProductAccount { derivation_index: Raw(index) }`, where + /// `index` is [`funding_account_index`] for the same arguments. + pub fn derive_funding_account( + &self, + kind: FundingAccountKind, + source_id: &str, + number: u32, + ) -> Result, AuthorityError> { + let index = funding_account_index(kind, source_id, number).map_err(|err| { + AuthorityError::Unavailable { + reason: err.to_string(), + } + })?; + let Some(subtree) = self.derive_subtree_public_key(&funding_product_id(&self.network_suffix))? + else { + return Ok(None); + }; + derive_product_public_key(subtree, index) + .map(Some) + .map_err(product_authority_error) + } + /// Derive the product-account keypair for `account` from the root entropy. /// /// The root keypair is recomputed per call (PBKDF2, 2048 rounds, via @@ -517,12 +546,13 @@ impl SigningHost { let entropy = self.root_entropy()?; let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; let owner = root.public.to_bytes(); - let product_id = - normalize_product_identifier(&account.dot_ns_identifier).map_err(|err| { - AuthorityError::Unavailable { - reason: err.to_string(), - } + let product_id = normalize_product_identifier(&account.dot_ns_identifier) + .map_err(|err| AuthorityError::Unavailable { + reason: err.to_string(), })?; + if super::is_funding_product(&product_id) { + return Err(AuthorityError::Rejected); + } derive_product_keypair( &root, &product_id, @@ -1002,6 +1032,9 @@ impl ProductAuthority for SigningHost { reason: err.to_string(), } })?; + if super::is_funding_product(&product_id) { + return Err(AuthorityError::Rejected); + } let entropy = self.root_entropy()?; let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; derive_product_subtree_keypair(&root, &product_id) @@ -1676,13 +1709,14 @@ mod tests { use super::TEST_NETWORK_SUFFIX; use super::ring_vrf::{MemberCandidate, ResolvedRing, RingResolver}; use super::{LocalActivation, RingVrfError, SR25519_SIGNING_CONTEXT}; + use crate::host_logic::funding::FundingAccountKind; use crate::host_internal::extrinsic::tests::split_v4; use crate::host_internal::sso_messages::ProductRequest; use crate::host_internal::transaction::{ extrinsic_payload_extensions, extrinsic_payload_preimage, }; use crate::host_logic::product_account::{ - derive_identity_keypair, derive_product_keypair, derive_ring_vrf_entropy, + derivation_index_bytes, derive_identity_keypair, derive_product_keypair, derive_ring_vrf_entropy, derive_root_keypair_from_entropy, index_bytes, }; use crate::platform::{HostInfo, Platform, PlatformInfo, ProductContext, SigningHostConfig}; @@ -3226,6 +3260,34 @@ mod tests { ); } + // The address shown to a provider must be the account the host later + // claims with the matching product-account key, under the `fund.` product + // the mobile hosts already reserve. + #[test] + fn a_funding_account_is_the_fund_products_account_at_its_label() { + let (_services, authority) = signing_runtime(); + let before = authority.derive_funding_account(FundingAccountKind::Deposit, "usdt-assethub", 1); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + + let mut label = [0u8; 32]; + label[..26].copy_from_slice(b"onramp:eph:usdt-assethub:1"); + let root = derive_root_keypair_from_entropy(&ENTROPY).expect("root derives"); + let claimable = derive_product_keypair( + &root, + &format!("fund.{TEST_NETWORK_SUFFIX}"), + derivation_index_bytes(&v01::DerivationIndex::Raw(label)), + ) + .expect("deposit key derives"); + assert_eq!( + ( + before, + authority.derive_funding_account(FundingAccountKind::Deposit, "usdt-assethub", 1) + ), + (Ok(None), Ok(Some(claimable.public.to_bytes()))) + ); + } + #[test] fn local_activation_exposes_the_uid_dot_identity_account() { let (_services, authority) = signing_runtime(); @@ -3952,6 +4014,44 @@ mod tests { assert_eq!(err, AuthorityError::Disconnected); } + // Every product path, the SSO responder's included, derives keys through + // these two calls, so refusing here keeps the funding accounts host-only. + #[test] + fn no_request_derives_a_funding_key() { + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + let cx = CallContext::default(); + let request = v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "app.fund.dot".to_string(), + derivation_index: v01::DerivationIndex::Index(0), + }, + payload: v01::RawPayload::Bytes { + bytes: vec![1, 2, 3], + }, + }; + assert_eq!( + ( + futures::executor::block_on(authority.product_subtree_public_key( + &cx, + &session, + "fund.dot".to_string(), + )), + futures::executor::block_on(authority.sign_raw( + &cx, + &session, + None, + SignRawAuthorityRequest::Product(request), + true, + )) + .map(|_| ()), + ), + (Err(AuthorityError::Rejected), Err(AuthorityError::Rejected)) + ); + } + #[test] fn disconnect_clears_local_session() { let (_services, authority) = signing_runtime(); diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 686845f1af..00ca63d812 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -2741,6 +2741,35 @@ fn bare_localhost_product_allows_dev_product_accounts() { ); } +/// The funding product's accounts hold deposits in transit, so no product +/// signs with them: not one that registers the name, and not a development +/// product that may otherwise reach any account. +#[test] +fn no_product_reaches_the_funding_accounts() { + let registered = ProductRuntimeHost::new(stub_platform(), runtime_config("fund.dot"), test_spawner()); + let localhost = + ProductRuntimeHost::new(stub_platform(), runtime_config("localhost"), test_spawner()); + // The user would allow it, so only the guard can refuse. + let platform = StubPlatform { + account_access_confirmed: true, + ..StubPlatform::default() + }; + assert_eq!( + ( + account_target(®istered, "fund.dot"), + account_target(&localhost, "fund.dot"), + account_target(&localhost, "app.fund.dot"), + futures::executor::block_on(crate::runtime::account_access_authorization( + &platform, + "wallet.dot", + "fund.dot", + )) + .ok(), + ), + (None, None, None, Some(PermissionAuthorizationStatus::Denied)) + ); +} + /// A product destination reaches the platform as a `polkadot://` URL, whatever /// the product spelled it as. Asserting only that the call succeeded would not /// notice it arriving as `https://`. From 6101881b1ed3615c5792ffc0dd8818b3a32548a4 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Sat, 3 Oct 2026 16:48:05 +0200 Subject: [PATCH 02/20] feat(truapi): watch funding deposits on Asset Hub --- .changeset/funding-deposit-watch.md | 5 + .../truapi-codegen/tests/golden/wire_table.rs | 2 +- rust/crates/truapi/RUNTIME.md | 4 + rust/crates/truapi/src/host_core.rs | 34 +- rust/crates/truapi/src/host_logic/funding.rs | 83 ++- rust/crates/truapi/src/runtime/funding.rs | 618 +++++++++++++++++- .../truapi/src/runtime/statement_allowance.rs | 1 + rust/crates/truapi/src/v01/funding.rs | 3 + 8 files changed, 732 insertions(+), 18 deletions(-) create mode 100644 .changeset/funding-deposit-watch.md diff --git a/.changeset/funding-deposit-watch.md b/.changeset/funding-deposit-watch.md new file mode 100644 index 0000000000..3bc606608c --- /dev/null +++ b/.changeset/funding-deposit-watch.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": minor +--- + +Funding status adds `Converting`. Signing hosts assign an inbound session its deposit account with `assign_funding_deposit` (Rust API), skipping accounts that already hold funds; the core watches it at finalized Asset Hub blocks and reports `Converting` once the expected balance arrives. diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index e9b2118702..8cb0b6206e 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 = "4477d07e737019aa"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "e9f7964f909918cf"; /// 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 5603dda69c..e69a5e0e92 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -403,6 +403,10 @@ AutoSigning without approval. Legacy-account signing still asks the user. Balance card opens sessions with `open_funding`. The core owns the sessions, persists them, expires them, and answers Funding calls `Unsupported` while no overlay is installed. + Once a provider is chosen, a signing host calls `assign_funding_deposit` to + give an inbound session its deposit account under `fund.`; + the core then polls that account at finalized Asset Hub blocks and moves the + session to `Converting` once the expected balance is there. - `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/host_core.rs b/rust/crates/truapi/src/host_core.rs index e063c2803e..85019a56e0 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -614,8 +614,7 @@ impl SigningHostRuntime { } /// Public key of the `number`th funding account of `kind` for - /// `source_id`, or `None` while no signing session is active. Number - /// accounts with [`Self::next_funding_account_number`]. + /// `source_id`, or `None` while no signing session is active. pub fn funding_account( &self, kind: crate::host_logic::funding::FundingAccountKind, @@ -629,15 +628,32 @@ impl SigningHostRuntime { }) } - /// Reserve the next account number for `source_id`, counting up from 1, - /// so no two sessions share an account. - pub async fn next_funding_account_number( + /// Give the open inbound session `intent` its deposit account for the + /// request's source, and watch it until the expected balance arrives on + /// Asset Hub, which moves the session to converting. Returns the account + /// the provider pays into. + pub async fn assign_funding_deposit( &self, - source_id: &str, - ) -> Result { + intent: &str, + request: crate::host_logic::funding::DepositRequest, + ) -> Result<[u8; 32], v01::GenericError> { + let source_id = request.source_id.clone(); + let derive = |number| { + self.signing_host + .derive_funding_account( + crate::host_logic::funding::FundingAccountKind::Deposit, + &source_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 - .funding() - .next_account_number(self.services.platform.as_ref(), source_id) + .assign_funding_deposit(intent, request, derive) .await .map_err(|err| v01::GenericError { reason: err.to_string(), diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index 741872ae7d..ed0dcbeb8b 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -39,6 +39,45 @@ pub struct FundingSession { pub opened_at_ms: u64, /// When the session expires if still open, in Unix milliseconds. pub deadline_ms: u64, + /// Where an inbound session's provider delivers, once the source is + /// known. + pub deposit: Option, +} + +/// Asset Hub asset a deposit arrives in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum DepositAsset { + /// The relay chain's native token. + Native, + /// An `Assets` pallet asset. + Asset(u32), +} + +/// What an inbound session's provider delivers, once it is chosen. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DepositRequest { + /// Deposit source, as in the account label, such as `usdt-assethub`. + pub source_id: String, + /// Asset the provider delivers. + pub asset: DepositAsset, + /// Balance at which the deposit counts as delivered, in `asset` units. + pub expected: u128, +} + +/// The account an inbound session watches and what it waits for. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct FundingDeposit { + /// Deposit source, as in the account label. + pub source_id: String, + /// Account number for `source_id`; the refund account shares it. + pub number: u32, + /// Asset the provider delivers. + pub asset: DepositAsset, + /// Public key of the deposit account, kept so the watch needs no signing + /// session. + pub account: [u8; 32], + /// Balance at which the deposit counts as delivered, in `asset` units. + pub expected: u128, } /// Stage of a session, as the core persists it. @@ -46,6 +85,12 @@ pub struct FundingSession { pub enum FundingStage { /// In flight. Open, + /// Inbound: the deposit arrived and is being converted. + Converting { + /// Balance of the deposit account when it was seen, in its asset's + /// units. + deposited: u128, + }, /// Ended without success. Failed { /// Why it ended. @@ -72,6 +117,7 @@ impl FundingSession { stage: FundingStage::Open, opened_at_ms: now_ms, deadline_ms: now_ms.saturating_add(SESSION_WINDOW_MS), + deposit: None, } } @@ -83,7 +129,7 @@ impl FundingSession { /// When the session ended, if it has. pub fn settled_at_ms(&self) -> Option { match self.stage { - FundingStage::Open => None, + FundingStage::Open | FundingStage::Converting { .. } => None, FundingStage::Failed { settled_at_ms, .. } => Some(settled_at_ms), } } @@ -99,6 +145,7 @@ impl FundingSession { (FundingStage::Open, FundingDirection::Out) => { HostFundingStatusSubscribeItem::AwaitingRelease } + (FundingStage::Converting { .. }, _) => HostFundingStatusSubscribeItem::Converting, (FundingStage::Failed { reason, .. }, _) => HostFundingStatusSubscribeItem::Failed { reason: reason.clone(), moved: 0, @@ -119,9 +166,39 @@ impl FundingSession { true } - /// Expire the session if it is still open at its deadline. Returns whether - /// it expired. + /// 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. + pub fn expires_by_sweep(&self) -> bool { + self.stage == FundingStage::Open && self.deposit.is_none() + } + + /// Expire the session if the sweep owns it and its deadline passed. + /// Returns whether it expired. pub fn expire_if_due(&mut self, now_ms: u64) -> bool { + self.expires_by_sweep() + && now_ms >= self.deadline_ms + && self.fail(FundingFailure::Expired, 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) + .then_some(self.deposit.as_ref()) + .flatten() + } + + /// Record a finalized reading of the deposit account's balance, taken at + /// `now_ms`: converting once it covers the expected amount, expired if it + /// does not by the deadline. Returns whether the session changed. + pub fn observe_deposit(&mut self, balance: u128, now_ms: u64) -> bool { + let Some(deposit) = self.awaited_deposit() else { + return false; + }; + if balance >= deposit.expected { + self.stage = FundingStage::Converting { deposited: balance }; + return true; + } now_ms >= self.deadline_ms && self.fail(FundingFailure::Expired, now_ms) } } diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs index 03ea9a1608..dd59f9a46d 100644 --- a/rust/crates/truapi/src/runtime/funding.rs +++ b/rust/crates/truapi/src/runtime/funding.rs @@ -17,13 +17,24 @@ use core::time::Duration; use futures::channel::mpsc; use futures::lock::Mutex as AsyncMutex; +use core::future::Future; + +use futures::future::{BoxFuture, FutureExt}; use futures::stream::{self, BoxStream, StreamExt}; -use truapi::latest::{FundingDirection, GenericError, HostFundingStatusSubscribeItem}; +use truapi::latest::{ + ChainIdentifier, FundingDirection, GenericError, HostFundingStatusSubscribeItem, +}; use super::services::RuntimeServices; +use parity_scale_codec::Decode; +use sp_crypto_hashing::twox_128; + +use super::statement_allowance::blake2_128_concat; +use super::statement_allowance::rpc::RpcClient; +use crate::host_logic::features; use crate::host_logic::funding::{ - FundingSession, FundingSessionError, load_sessions, next_account_number, retained, - store_sessions, + DepositAsset, DepositRequest, FundingDeposit, FundingSession, FundingSessionError, FundingStage, + load_sessions, next_account_number, retained, store_sessions, }; use crate::platform::{ CoreStorage, FundingPlatform, FundingPresentOutcome, FundingPresentation, Platform, @@ -33,6 +44,13 @@ use crate::unix_time::current_unix_millis; /// Wait before retrying an expiry sweep whose write failed. const SWEEP_RETRY: Duration = Duration::from_secs(30); +/// Wait between reads of the awaited deposits: two Asset Hub blocks. +const DEPOSIT_POLL: Duration = Duration::from_secs(12); +/// Longest a chain read may take before the pass gives up on it. +const CHAIN_TIMEOUT: Duration = Duration::from_secs(30); +/// Numbered accounts skipped for already holding funds before assignment +/// gives up. +const MAX_USED_ACCOUNTS: usize = 16; type Subscribers = HashMap>>; @@ -46,6 +64,8 @@ pub struct FundingRegistry { writes: AsyncMutex, /// Whether a task is waiting on the next deadline. sweeping: AtomicBool, + /// Whether a task is polling the awaited deposits. + watching: AtomicBool, platform: OnceLock>, } @@ -194,11 +214,129 @@ impl FundingRegistry { fn next_deadline(&self) -> Option { self.lock_sessions() .values() - .filter(|session| !session.is_terminal()) + .filter(|session| session.expires_by_sweep()) .map(|session| session.deadline_ms) .min() } + /// Give an open inbound session the first numbered account for the + /// request's source that holds none of its asset, so a seed restored on a new + /// install never reuses an account a provider may still pay into. + /// `derive` maps an account number to its public key. + pub async fn assign_empty_deposit( + &self, + storage: &(impl CoreStorage + ?Sized), + balances: &dyn DepositBalances, + now_ms: u64, + intent: &str, + request: DepositRequest, + derive: impl Fn(u32) -> Result<[u8; 32], GenericError>, + ) -> Result<[u8; 32], AssignDepositError> { + self.get(intent) + .ok_or(AssignDepositError::NotFound) + .and_then(|session| assignable(&session))?; + for _ in 0..MAX_USED_ACCOUNTS { + let number = self + .next_account_number(storage, &request.source_id) + .await?; + let account = derive(number).map_err(AssignDepositError::Derive)?; + let held = balances + .balance(request.asset, &account) + .await + .map_err(AssignDepositError::Chain)?; + if held > 0 { + continue; + } + let deposit = FundingDeposit { + source_id: request.source_id, + number, + asset: request.asset, + account, + expected: request.expected, + }; + let intent = intent.to_string(); + return self + .commit(storage, now_ms, move |sessions| { + let assigned = match sessions.get_mut(&intent) { + None => Err(AssignDepositError::NotFound), + Some(session) => assignable(session).map(|()| { + session.deposit = Some(deposit); + account + }), + }; + (assigned, Vec::new()) + }) + .await?; + } + Err(AssignDepositError::AccountsInUse) + } + + /// Read every awaited deposit once: a session whose deposit arrived + /// moves to converting, one past its deadline without it expires. A + /// failed read leaves its session for the next pass. + pub async fn observe_deposits( + &self, + storage: &(impl CoreStorage + ?Sized), + now_ms: u64, + balances: &dyn DepositBalances, + ) -> Result<(), FundingSessionError> { + let awaited: Vec<_> = self + .lock_sessions() + .values() + .filter_map(|session| { + let deposit = session.awaited_deposit()?; + Some((session.intent.clone(), deposit.asset, deposit.account)) + }) + .collect(); + let mut readings = Vec::new(); + for (intent, asset, account) in awaited { + match balances.balance(asset, &account).await { + Ok(balance) => readings.push((intent, balance)), + Err(error) => { + tracing::warn!(%intent, reason = %error.reason, "reading a funding deposit failed") + } + } + } + self.commit(storage, now_ms, move |sessions| { + let arrived = readings + .into_iter() + .filter(|(intent, balance)| { + sessions + .get_mut(intent) + .is_some_and(|session| session.observe_deposit(*balance, now_ms)) + }) + .map(|(intent, _)| intent) + .collect(); + ((), arrived) + }) + .await + } + + /// Whether a polling task should keep going, clearing the watching flag + /// once no deposit is awaited. + fn still_watching(registry: &Weak) -> bool { + let Some(live) = registry.upgrade() else { + return false; + }; + loop { + if live.awaits_deposit() { + return true; + } + live.watching.store(false, Ordering::Release); + // A deposit assigned after the check would otherwise wait for the + // next caller to arm the watch. + if !live.awaits_deposit() || live.watching.swap(true, Ordering::AcqRel) { + return false; + } + } + } + + fn awaits_deposit(&self) -> bool { + self.lock_sessions() + .values() + .any(|session| session.awaited_deposit().is_some()) + } + /// Tell subscribers and the host about a session's current stage. A /// terminal stage ends the subscriber streams. fn fan_out(&self, session: &FundingSession) { @@ -230,6 +368,156 @@ impl FundingRegistry { } } +/// Reads deposit-account balances on Asset Hub. +pub trait DepositBalances: Send + Sync { + /// `account`'s balance of `asset`; zero when the account does not exist. + fn balance<'a>( + &'a self, + asset: DepositAsset, + account: &'a [u8; 32], + ) -> BoxFuture<'a, Result>; +} + +/// Asset Hub balances read at one finalized block, so a deposit counts only +/// once it cannot be reverted. +struct FinalizedAssetHubBalances { + rpc: RpcClient, + finalized: String, +} + +impl FinalizedAssetHubBalances { + async fn connect(services: &RuntimeServices) -> Result { + within_chain_timeout(Self::connect_unbounded(services)).await? + } + + async fn connect_unbounded(services: &RuntimeServices) -> Result { + let failed = |reason: String| GenericError { reason }; + let chains = features::supported_chains(services.platform.as_ref()).await?; + let genesis = features::genesis_for(&chains, ChainIdentifier::AssetHub) + .ok_or_else(|| failed("the host serves no Asset Hub".into()))?; + let rpc = RpcClient::new(subxt_rpcs::RpcClient::new( + services + .chain + .rpc_client("funding deposit watch", &genesis) + .await + .map_err(|err| failed(err.to_string()))?, + )); + let finalized = rpc + .finalized_head() + .await + .map_err(|err| failed(err.to_string()))?; + Ok(Self { rpc, finalized }) + } +} + +impl DepositBalances for FinalizedAssetHubBalances { + fn balance<'a>( + &'a self, + asset: DepositAsset, + account: &'a [u8; 32], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let value = within_chain_timeout( + self.rpc + .get_storage_at(&balance_key(asset, account), &self.finalized), + ) + .await? + .map_err(|err| GenericError { + reason: err.to_string(), + })?; + decode_balance(asset, value.as_deref()).ok_or_else(|| GenericError { + reason: "undecodable deposit balance".into(), + }) + }) + } +} + +/// Run a chain read, giving up after [`CHAIN_TIMEOUT`] so a stalled +/// connection cannot park the deposit watch. +async fn within_chain_timeout(read: impl Future) -> Result { + let read = read.fuse(); + let timeout = futures_timer::Delay::new(CHAIN_TIMEOUT).fuse(); + futures::pin_mut!(read, timeout); + futures::select! { + value = read => Ok(value), + () = timeout => Err(GenericError { + reason: "Asset Hub read timed out".into(), + }), + } +} + +/// Asset Hub storage key holding `account`'s balance of `asset`: +/// `System.Account` for the native token, `Assets.Account` otherwise. +fn balance_key(asset: DepositAsset, account: &[u8; 32]) -> Vec { + match asset { + DepositAsset::Native => [ + twox_128(b"System").as_slice(), + &twox_128(b"Account"), + &blake2_128_concat(account), + ] + .concat(), + DepositAsset::Asset(id) => [ + twox_128(b"Assets").as_slice(), + &twox_128(b"Account"), + &blake2_128_concat(&id.to_le_bytes()), + &blake2_128_concat(account), + ] + .concat(), + } +} + +/// The balance in a value read from [`balance_key`]. An absent value is a +/// zero balance. The native balance is the free balance after +/// `AccountInfo`'s four `u32` counters; an asset account leads with it. +fn decode_balance(asset: DepositAsset, value: Option<&[u8]>) -> Option { + let Some(mut value) = value else { + return Some(0); + }; + if asset == DepositAsset::Native { + value = value.get(16..)?; + } + u128::decode(&mut value).ok() +} + +/// Why a deposit account could not be assigned. +#[derive(Debug, derive_more::Display)] +pub enum AssignDepositError { + /// No such session. + #[display("no such funding session")] + NotFound, + /// The session is not an open inbound one without a deposit. + #[display("funding session is not awaiting a deposit account")] + NotAwaitingDeposit, + /// Every account tried already holds funds. + #[display("every funding account tried already holds funds")] + AccountsInUse, + /// The account could not be derived. + #[display("{}", _0.reason)] + Derive(GenericError), + /// Asset Hub could not be read. + #[display("{}", _0.reason)] + Chain(GenericError), + /// The session could not be stored. + #[display("{_0}")] + Session(FundingSessionError), +} + +impl From for AssignDepositError { + fn from(error: FundingSessionError) -> Self { + Self::Session(error) + } +} + +/// Whether `session` can take a deposit account. +fn assignable(session: &FundingSession) -> Result<(), AssignDepositError> { + let awaiting = session.direction == FundingDirection::In + && session.stage == FundingStage::Open + && session.deposit.is_none(); + awaiting + .then_some(()) + .ok_or(AssignDepositError::NotAwaitingDeposit) +} + /// Why a session could not be opened. #[derive(Debug, derive_more::Display)] pub enum OpenFundingError { @@ -269,12 +557,80 @@ impl RuntimeServices { ) .await; match loaded { - Ok(()) => registry.keep_expiring(&services), + Ok(()) => { + registry.keep_expiring(&services); + services.watch_funding_deposits(); + } Err(error) => tracing::warn!(%error, "loading funding sessions failed"), } })); } + /// Give an open inbound session its deposit account for the request's + /// source and watch it until the expected balance arrives. Returns the + /// account the provider pays into. + pub async fn assign_funding_deposit( + self: &Arc, + intent: &str, + request: DepositRequest, + derive: impl Fn(u32) -> Result<[u8; 32], GenericError>, + ) -> Result<[u8; 32], AssignDepositError> { + self.funding() + .get(intent) + .ok_or(AssignDepositError::NotFound) + .and_then(|session| assignable(&session))?; + let balances = FinalizedAssetHubBalances::connect(self) + .await + .map_err(AssignDepositError::Chain)?; + let account = self + .funding() + .assign_empty_deposit( + self.platform.as_ref(), + &balances, + current_unix_millis(), + intent, + request, + derive, + ) + .await?; + self.watch_funding_deposits(); + Ok(account) + } + + /// Keep one task polling the awaited deposits while any is awaited. The + /// task ends once none is, or the services are dropped. + pub fn watch_funding_deposits(self: &Arc) { + let registry = self.funding(); + if registry.watching.swap(true, Ordering::AcqRel) { + return; + } + let watched = Arc::downgrade(registry); + let services = Arc::downgrade(self); + (self.spawner)(Box::pin(async move { + while FundingRegistry::still_watching(&watched) { + futures_timer::Delay::new(DEPOSIT_POLL).await; + let Some(services) = services.upgrade() else { + return; + }; + let observed = match FinalizedAssetHubBalances::connect(&services).await { + Ok(balances) => services + .funding() + .observe_deposits( + services.platform.as_ref(), + current_unix_millis(), + &balances, + ) + .await + .map_err(|error| error.to_string()), + Err(error) => Err(error.reason), + }; + if let Err(reason) = observed { + tracing::warn!(%reason, "funding deposit watch failed"); + } + } + })); + } + /// Open a session and show the host's funding overlay for it: the one /// path a product's `request` and the host's own Balance card both take. /// @@ -354,6 +710,7 @@ mod tests { use super::*; use futures::executor::block_on; + use parity_scale_codec::Encode; use truapi::latest::FundingFailure; use crate::host_logic::funding::FundingStage; @@ -463,4 +820,255 @@ mod tests { assert!(failed.is_err()); assert_eq!(registry.get("fs_1"), Some(session("fs_1", NOW))); } + + const USDT: DepositAsset = DepositAsset::Asset(1984); + + /// Balances keyed by account; any other account is empty. + struct Balances(HashMap<[u8; 32], u128>); + + impl DepositBalances for Balances { + fn balance<'a>( + &'a self, + _asset: DepositAsset, + account: &'a [u8; 32], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { Ok(self.0.get(account).copied().unwrap_or(0)) }) + } + } + + fn request(expected: u128) -> DepositRequest { + DepositRequest { + source_id: "usdt-assethub".to_string(), + asset: USDT, + expected, + } + } + + fn account(number: u32) -> [u8; 32] { + [u8::try_from(number).expect("small"); 32] + } + + fn assign( + registry: &FundingRegistry, + storage: &dyn CoreStorage, + balances: &Balances, + intent: &str, + ) -> Result<[u8; 32], String> { + block_on(registry.assign_empty_deposit(storage, balances, NOW, intent, request(50), |n| { + Ok(account(n)) + })) + .map_err(|error| error.to_string()) + } + + // A restored seed starts its counters again, and a provider may still pay + // into an account handed out before, so assignment passes over any + // account that already holds the asset. + #[test] + fn assignment_skips_accounts_that_already_hold_funds() { + let storage = stub_platform(); + let registry = FundingRegistry::default(); + insert(®istry, storage.as_ref(), session("fs_1", NOW)); + let balances = Balances(HashMap::from([(account(1), 7), (account(2), 1)])); + + let assigned = assign(®istry, storage.as_ref(), &balances, "fs_1"); + + assert_eq!( + ( + assigned, + registry.get("fs_1").and_then(|session| session.deposit) + ), + ( + Ok(account(3)), + Some(FundingDeposit { + source_id: "usdt-assethub".to_string(), + number: 3, + asset: USDT, + account: account(3), + expected: 50, + }) + ) + ); + } + + // Numbers are never reused, so one burned on a session that cannot take + // an account is gone for good. + #[test] + fn assignment_refuses_without_spending_a_number() { + let storage = stub_platform(); + let registry = FundingRegistry::default(); + let outbound = FundingSession { + direction: FundingDirection::Out, + ..session("fs_out", NOW) + }; + insert(®istry, storage.as_ref(), outbound); + insert(®istry, storage.as_ref(), session("fs_in", NOW)); + let empty = Balances(HashMap::new()); + + let refused = [ + assign(®istry, storage.as_ref(), &empty, "fs_missing"), + assign(®istry, storage.as_ref(), &empty, "fs_out"), + ]; + let first = assign(®istry, storage.as_ref(), &empty, "fs_in"); + let again = assign(®istry, storage.as_ref(), &empty, "fs_in"); + + assert_eq!( + (refused, first, again), + ( + [ + Err("no such funding session".to_string()), + Err("funding session is not awaiting a deposit account".to_string()), + ], + Ok(account(1)), + Err("funding session is not awaiting a deposit account".to_string()), + ) + ); + } + + // Converting starts only once the whole expected balance is on chain, and + // the stage survives a restart, so a deposit is converted exactly once. + #[test] + fn a_covering_deposit_moves_the_session_to_converting() { + let storage = stub_platform(); + let registry = FundingRegistry::default(); + insert(®istry, storage.as_ref(), session("fs_1", NOW)); + assign( + ®istry, + storage.as_ref(), + &Balances(HashMap::new()), + "fs_1", + ) + .expect("assigned"); + let stream = registry.subscribe("fs_1").expect("session exists"); + + for held in [49, 50] { + let balances = Balances(HashMap::from([(account(1), held)])); + block_on(registry.observe_deposits(storage.as_ref(), NOW, &balances)) + .expect("observed"); + } + let restarted = FundingRegistry::default(); + block_on(restarted.commit(storage.as_ref(), NOW, |_| ((), Vec::new()))).expect("loaded"); + + assert_eq!( + ( + block_on(stream.take(2).collect::>()), + restarted.get("fs_1").map(|session| session.stage), + ), + ( + vec![ + HostFundingStatusSubscribeItem::AwaitingDeposit { + expires_at: Some(NOW + DAY_MS), + }, + HostFundingStatusSubscribeItem::Converting, + ], + Some(FundingStage::Converting { deposited: 50 }), + ) + ); + } + + // A provider may pay moments before the deadline, and finality and the + // poll both lag, so a session with a deposit account ends only on a read + // taken after the deadline: converting if the funds made it, expired if + // not. The sweep alone never strands a payment. + #[test] + fn a_deposit_session_expires_only_on_a_read_after_its_deadline() { + let storage = stub_platform(); + let registry = FundingRegistry::default(); + let empty = Balances(HashMap::new()); + for intent in ["fs_late", "fs_never"] { + insert(®istry, storage.as_ref(), session(intent, NOW)); + assign(®istry, storage.as_ref(), &empty, intent).expect("assigned"); + } + let past_deadline = NOW + DAY_MS; + + block_on(registry.commit(storage.as_ref(), past_deadline, |_| ((), Vec::new()))) + .expect("swept"); + let after_sweep = [registry.get("fs_late"), registry.get("fs_never")] + .map(|session| session.map(|session| session.stage)); + let late_payment = Balances(HashMap::from([(account(1), 50)])); + block_on(registry.observe_deposits(storage.as_ref(), past_deadline, &late_payment)) + .expect("observed"); + + assert_eq!( + ( + after_sweep, + [registry.get("fs_late"), registry.get("fs_never")] + .map(|session| session.map(|session| session.stage)), + ), + ( + [Some(FundingStage::Open), Some(FundingStage::Open)], + [ + Some(FundingStage::Converting { deposited: 50 }), + Some(FundingStage::Failed { + reason: FundingFailure::Expired, + settled_at_ms: past_deadline, + }), + ], + ) + ); + } + + // The deposit is already on chain, so the deposit window no longer + // applies, and the expiry sweep must not wait on a deadline that passed. + #[test] + fn a_converting_session_does_not_expire() { + let storage = stub_platform(); + let registry = FundingRegistry::default(); + let converting = FundingSession { + stage: FundingStage::Converting { deposited: 50 }, + ..session("fs_1", NOW - DAY_MS) + }; + insert(®istry, storage.as_ref(), converting.clone()); + + block_on(registry.commit(storage.as_ref(), NOW, |_| ((), Vec::new()))).expect("swept"); + + assert_eq!( + (registry.get("fs_1"), registry.next_deadline()), + (Some(converting), None) + ); + } + + // A wrong key reads an empty account forever and no deposit is ever + // seen, so the keys are pinned to the pallets' well-known prefixes. + #[test] + fn balance_keys_address_system_and_assets_accounts() { + let account = [7u8; 32]; + let hashed_account = [sp_crypto_hashing::blake2_128(&account).as_slice(), &account].concat(); + let hashed_id = [sp_crypto_hashing::blake2_128(&1984u32.to_le_bytes()).as_slice(), &1984u32.to_le_bytes()].concat(); + + assert_eq!( + ( + hex::encode(balance_key(DepositAsset::Native, &account)), + hex::encode(balance_key(USDT, &account)), + ), + ( + format!( + "26aa394eea5630e07c48ae0c9558cef7b99d880ec681799c0cf30e8886371da9{}", + hex::encode(&hashed_account) + ), + format!( + "682a59d51ab9e48a8c8cc418ff9708d2b99d880ec681799c0cf30e8886371da9{}{}", + hex::encode(hashed_id), + hex::encode(&hashed_account) + ), + ) + ); + } + + // The native balance sits behind `AccountInfo`'s counters, while an asset + // account leads with it; reading the wrong offset would see a counter. + #[test] + fn balances_decode_from_each_account_layout() { + let account_info = (1u32, 2u32, 3u32, 4u32, 500u128, 9u128).encode(); + let asset_account = (70u128, 0u8).encode(); + + assert_eq!( + [ + decode_balance(DepositAsset::Native, Some(&account_info)), + decode_balance(USDT, Some(&asset_account)), + decode_balance(USDT, None), + decode_balance(DepositAsset::Native, Some(&account_info[..12])), + ], + [Some(500), Some(70), Some(0), None] + ); + } } diff --git a/rust/crates/truapi/src/runtime/statement_allowance.rs b/rust/crates/truapi/src/runtime/statement_allowance.rs index 876e453b28..70a05b01c3 100644 --- a/rust/crates/truapi/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi/src/runtime/statement_allowance.rs @@ -21,6 +21,7 @@ mod test_fixtures; mod view; mod view_cache; +pub use key_hash::blake2_128_concat; pub use view::ViewFunctionError; use std::collections::HashMap; diff --git a/rust/crates/truapi/src/v01/funding.rs b/rust/crates/truapi/src/v01/funding.rs index 42e55a6c98..93df7908d4 100644 --- a/rust/crates/truapi/src/v01/funding.rs +++ b/rust/crates/truapi/src/v01/funding.rs @@ -117,6 +117,9 @@ pub enum HostFundingStatusSubscribeItem { /// Amount moved before the failure. May be non-zero. moved: u128, }, + /// Inbound: the deposit arrived on chain and the host is converting it + /// into the user's balance. + Converting, } /// Error from [`crate::api::Funding::status_subscribe`]. From a64114de971faec5b3d6ed0667af99d69e7fe2ce Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 5 Oct 2026 12:12:42 +0200 Subject: [PATCH 03/20] feat(truapi): convert funding deposits into CASH on People --- .changeset/funding-conversion.md | 5 + rust/crates/truapi/RUNTIME.md | 8 +- rust/crates/truapi/src/host_core.rs | 15 + rust/crates/truapi/src/host_logic/funding.rs | 228 ++- rust/crates/truapi/src/runtime.rs | 2 +- rust/crates/truapi/src/runtime/funding.rs | 478 +++++- .../truapi/src/runtime/funding/conversion.rs | 1370 +++++++++++++++++ .../crates/truapi/src/runtime/signing_host.rs | 43 +- .../runtime/statement_allowance/extension.rs | 1 - 9 files changed, 2133 insertions(+), 17 deletions(-) create mode 100644 .changeset/funding-conversion.md create mode 100644 rust/crates/truapi/src/runtime/funding/conversion.rs diff --git a/.changeset/funding-conversion.md b/.changeset/funding-conversion.md new file mode 100644 index 0000000000..ee3c1be854 --- /dev/null +++ b/.changeset/funding-conversion.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": patch +--- + +Signing hosts convert funding deposits into CASH on People once `enable_funding_conversion` gives the network's CASH asset id (Rust API). A CASH deposit is teleported; a stablecoin the PSM serves is minted into CASH first. Fees are paid in the deposited asset, and both chains dry-run the conversion before it is submitted. diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index e69a5e0e92..e7136448b3 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -406,7 +406,13 @@ AutoSigning without approval. Legacy-account signing still asks the user. Once a provider is chosen, a signing host calls `assign_funding_deposit` to give an inbound session its deposit account under `fund.`; the core then polls that account at finalized Asset Hub blocks and moves the - session to `Converting` once the expected balance is there. + session to `Converting` once the expected balance is there. Assignment needs + `enable_funding_conversion` with the network's CASH asset id, and fixes the + route then: a teleport for CASH, a PSM mint for a stablecoin the PSM serves. + The core converts with one Asset Hub transaction signed by the deposit + account, paying fees in the deposited asset, after dry-running it on Asset + Hub and the message it forwards on People, and records the CASH that lands + on People. - `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/host_core.rs b/rust/crates/truapi/src/host_core.rs index 85019a56e0..4e076a9ced 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -795,6 +795,21 @@ impl SigningHostRuntime { installed } + /// Convert funding deposits into CASH on People on `network`, signing + /// with the deposit accounts this host derives. Without it, assigning a + /// deposit account fails. Set-once; returns whether this call enabled it. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.enable_funding_conversion"))] + pub fn enable_funding_conversion(&self, network: crate::runtime::FundingNetwork) -> bool { + let enabled = self + .services + .funding() + .install_conversion(network, self.signing_host.clone()); + if enabled { + self.services.watch_funding_deposits(); + } + enabled + } + /// Open a funding session on the host's own behalf, as the Balance card's /// Add and Withdraw do, and show the overlay. Returns the session id, or /// `None` when the user dismissed the overlay without starting. diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index ed0dcbeb8b..28d5cba44f 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -78,8 +78,45 @@ pub struct FundingDeposit { pub account: [u8; 32], /// Balance at which the deposit counts as delivered, in `asset` units. pub expected: u128, + /// How the deposit becomes CASH on People. + pub route: ConversionRoute, } +/// How a deposit becomes CASH on People, fixed when its account is assigned +/// so a later change on chain cannot switch it mid-session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum ConversionRoute { + /// The deposit is CASH already: teleport it. + Teleport, + /// The deposit is a stablecoin the PSM mints CASH against: mint, then + /// teleport. + Psm { + /// Minting fee the route was chosen at, in parts per million. + fee_ppm: u32, + }, +} + +/// A conversion transaction handed to Asset Hub, with what tells whether it +/// worked. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct ConversionSubmission { + /// The deposit account's nonce the transaction was signed at. + pub nonce: u32, + /// When it was submitted, in Unix milliseconds. + pub submitted_at_ms: u64, + /// Last Asset Hub block its mortal era admits it in. + pub valid_until_block: u64, + /// CASH the account held on People before it was submitted. + pub people_before: u128, + /// Least CASH the conversion lands on People. + pub landing: u128, + /// Deposit the transaction takes from the account on Asset Hub. + pub spent: u128, +} + +/// Dry runs refused before a conversion gives up. +const MAX_CONVERSION_REFUSALS: u8 = 3; + /// Stage of a session, as the core persists it. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum FundingStage { @@ -90,6 +127,16 @@ pub enum FundingStage { /// Balance of the deposit account when it was seen, in its asset's /// units. deposited: u128, + /// Dry runs the chain has refused so far. + refusals: u8, + /// The conversion transaction, once one is on its way. + submission: Option, + }, + /// Inbound: CASH landed on the deposit account on People and awaits + /// crediting. + Converted { + /// CASH on People, in payment balance units. + landed: u128, }, /// Ended without success. Failed { @@ -129,7 +176,9 @@ impl FundingSession { /// When the session ended, if it has. pub fn settled_at_ms(&self) -> Option { match self.stage { - FundingStage::Open | FundingStage::Converting { .. } => None, + FundingStage::Open | FundingStage::Converting { .. } | FundingStage::Converted { .. } => { + None + } FundingStage::Failed { settled_at_ms, .. } => Some(settled_at_ms), } } @@ -145,7 +194,9 @@ impl FundingSession { (FundingStage::Open, FundingDirection::Out) => { HostFundingStatusSubscribeItem::AwaitingRelease } - (FundingStage::Converting { .. }, _) => HostFundingStatusSubscribeItem::Converting, + (FundingStage::Converting { .. } | FundingStage::Converted { .. }, _) => { + HostFundingStatusSubscribeItem::Converting + } (FundingStage::Failed { reason, .. }, _) => HostFundingStatusSubscribeItem::Failed { reason: reason.clone(), moved: 0, @@ -196,11 +247,92 @@ impl FundingSession { return false; }; if balance >= deposit.expected { - self.stage = FundingStage::Converting { deposited: balance }; + self.stage = FundingStage::Converting { + deposited: balance, + refusals: 0, + submission: None, + }; return true; } now_ms >= self.deadline_ms && self.fail(FundingFailure::Expired, now_ms) } + + /// The deposit of a session being converted, with its submission so far. + pub fn converting(&self) -> Option<(&FundingDeposit, Option)> { + match (&self.stage, &self.deposit) { + (FundingStage::Converting { submission, .. }, Some(deposit)) => { + Some((deposit, *submission)) + } + _ => None, + } + } + + /// Advance a converting session by one step of its conversion. Returns + /// whether the session changed. + pub fn advance_conversion(&mut self, step: ConversionStep, now_ms: u64) -> bool { + let FundingStage::Converting { + refusals, + submission, + .. + } = &mut self.stage + else { + return false; + }; + match step { + ConversionStep::Submitted(submitted) => *submission = Some(submitted), + ConversionStep::Dropped => *submission = None, + ConversionStep::Refused { reason } => { + *submission = None; + *refusals = refusals.saturating_add(1); + if *refusals >= MAX_CONVERSION_REFUSALS { + return self.fail( + FundingFailure::Other { + code: "conversion_refused".into(), + message: reason, + }, + now_ms, + ); + } + } + ConversionStep::Landed { landed } => { + self.stage = FundingStage::Converted { landed }; + } + ConversionStep::Stalled => { + return self.fail( + FundingFailure::Other { + code: "conversion_stalled".into(), + message: "the conversion left Asset Hub but never reached People".into(), + }, + now_ms, + ); + } + } + true + } +} + +/// What one pass of a conversion found or did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConversionStep { + /// The conversion transaction is about to be submitted. + Submitted(ConversionSubmission), + /// The submitted transaction can no longer convert anything: its era + /// ended unincluded, or it was included and failed. The next pass + /// submits again. + Dropped, + /// A dry run refused the conversion. + Refused { + /// Why, as the chain reported it. + reason: String, + }, + /// CASH arrived on People. + Landed { + /// CASH on People, in payment balance units. + landed: u128, + }, + /// The transaction took the deposit on Asset Hub but its CASH never + /// reached People. + Stalled, } /// Which of a session's accounts under the reserved funding product. @@ -478,6 +610,96 @@ mod tests { ); } + const SUBMISSION: ConversionSubmission = ConversionSubmission { + nonce: 4, + submitted_at_ms: NOW, + valid_until_block: 164, + people_before: 0, + landing: 40, + spent: 50, + }; + + fn converting() -> FundingSession { + FundingSession { + stage: FundingStage::Converting { + deposited: 50, + refusals: 0, + submission: None, + }, + deposit: Some(FundingDeposit { + source_id: "usdt-assethub".to_string(), + number: 1, + asset: DepositAsset::Asset(1984), + account: [1; 32], + expected: 50, + route: ConversionRoute::Teleport, + }), + ..session(FundingDirection::In) + } + } + + // A dry run that keeps refusing will not start passing, so the third + // refusal ends the session rather than retrying forever, and a refusal + // clears any submission so the next attempt starts clean. + #[test] + fn a_conversion_ends_on_its_third_refusal() { + let mut session = converting(); + session.advance_conversion(ConversionStep::Submitted(SUBMISSION), NOW); + let refused = |session: &mut FundingSession| { + session.advance_conversion( + ConversionStep::Refused { + reason: "no pool".into(), + }, + NOW, + ); + session.stage.clone() + }; + + assert_eq!( + [refused(&mut session), refused(&mut session), refused(&mut session)], + [ + FundingStage::Converting { + deposited: 50, + refusals: 1, + submission: None, + }, + FundingStage::Converting { + deposited: 50, + refusals: 2, + submission: None, + }, + FundingStage::Failed { + reason: FundingFailure::Other { + code: "conversion_refused".into(), + message: "no pool".into(), + }, + settled_at_ms: NOW, + }, + ] + ); + } + + // CASH on People is what the user is owed, so landing ends conversion + // whatever the submission state, and the subscriber keeps seeing + // converting until it is credited. + #[test] + fn landing_on_people_ends_the_conversion() { + let mut session = converting(); + session.advance_conversion(ConversionStep::Submitted(SUBMISSION), NOW); + let submitted = session.converting().and_then(|(_, submission)| submission); + session.advance_conversion(ConversionStep::Landed { landed: 49 }, NOW); + + assert_eq!( + (submitted, session.stage.clone(), session.wire_item(), session.is_terminal()), + ( + Some(SUBMISSION), + FundingStage::Converted { landed: 49 }, + HostFundingStatusSubscribeItem::Converting, + false, + ) + ); + } + // Funds sit in these accounts, so an index that drifts between releases // strands them. The bytes are pinned to the labels getcash uses. #[test] diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index ac31d9552c..7ab4282c08 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -20,7 +20,7 @@ mod chat; pub mod contacts; mod dotns_lookup; mod funding; -pub use funding::OpenFundingError; +pub use funding::{FundingNetwork, FundingSigner, OpenFundingError}; mod identity; pub mod login_failure; mod pairing_host; diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs index dd59f9a46d..834f1fa1b0 100644 --- a/rust/crates/truapi/src/runtime/funding.rs +++ b/rust/crates/truapi/src/runtime/funding.rs @@ -25,6 +25,13 @@ use truapi::latest::{ ChainIdentifier, FundingDirection, GenericError, HostFundingStatusSubscribeItem, }; +mod conversion; + +use conversion::{Chains, ConversionChains, ConversionError}; +#[cfg(test)] +use conversion::Prepared; +pub use conversion::{FundingNetwork, FundingSigner}; + use super::services::RuntimeServices; use parity_scale_codec::Decode; use sp_crypto_hashing::twox_128; @@ -33,7 +40,8 @@ use super::statement_allowance::blake2_128_concat; use super::statement_allowance::rpc::RpcClient; use crate::host_logic::features; use crate::host_logic::funding::{ - DepositAsset, DepositRequest, FundingDeposit, FundingSession, FundingSessionError, FundingStage, + ConversionRoute, ConversionStep, ConversionSubmission, DepositAsset, DepositRequest, + FundingDeposit, FundingSession, FundingSessionError, FundingStage, load_sessions, next_account_number, retained, store_sessions, }; use crate::platform::{ @@ -48,6 +56,9 @@ const SWEEP_RETRY: Duration = Duration::from_secs(30); const DEPOSIT_POLL: Duration = Duration::from_secs(12); /// Longest a chain read may take before the pass gives up on it. const CHAIN_TIMEOUT: Duration = Duration::from_secs(30); +/// How long a conversion that took the deposit on Asset Hub may take to +/// reach People before it counts as stalled. +const STALL_AFTER_MS: u64 = 30 * 60 * 1_000; /// Numbered accounts skipped for already holding funds before assignment /// gives up. const MAX_USED_ACCOUNTS: usize = 16; @@ -66,6 +77,8 @@ pub struct FundingRegistry { sweeping: AtomicBool, /// Whether a task is polling the awaited deposits. watching: AtomicBool, + /// What converts deposits, once a signing host provides it. + conversion: OnceLock, platform: OnceLock>, } @@ -81,6 +94,12 @@ impl FundingRegistry { self.platform.get().cloned() } + /// Let deposits be converted on `network`, signed by `signer`. Set-once; + /// returns whether this call installed it. + pub fn install_conversion(&self, network: FundingNetwork, signer: Arc) -> bool { + self.conversion.set(Conversion { network, signer }).is_ok() + } + /// Snapshot one session. pub fn get(&self, intent: &str) -> Option { self.lock_sessions().get(intent).cloned() @@ -229,12 +248,13 @@ impl FundingRegistry { balances: &dyn DepositBalances, now_ms: u64, intent: &str, - request: DepositRequest, + plan: DepositPlan, derive: impl Fn(u32) -> Result<[u8; 32], GenericError>, ) -> Result<[u8; 32], AssignDepositError> { self.get(intent) .ok_or(AssignDepositError::NotFound) .and_then(|session| assignable(&session))?; + let DepositPlan { request, route } = plan; for _ in 0..MAX_USED_ACCOUNTS { let number = self .next_account_number(storage, &request.source_id) @@ -253,6 +273,7 @@ impl FundingRegistry { asset: request.asset, account, expected: request.expected, + route, }; let intent = intent.to_string(); return self @@ -334,7 +355,36 @@ impl FundingRegistry { fn awaits_deposit(&self) -> bool { self.lock_sessions() .values() - .any(|session| session.awaited_deposit().is_some()) + .any(|session| session.awaited_deposit().is_some() || session.converting().is_some()) + } + + /// Every session being converted, with its deposit and submission. + fn converting_sessions(&self) -> Vec<(String, FundingDeposit, Option)> { + self.lock_sessions() + .values() + .filter_map(|session| { + let (deposit, submission) = session.converting()?; + Some((session.intent.clone(), deposit.clone(), submission)) + }) + .collect() + } + + /// Apply one conversion `step` to session `intent`. + async fn record_conversion( + &self, + storage: &(impl CoreStorage + ?Sized), + now_ms: u64, + intent: &str, + step: ConversionStep, + ) -> 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_conversion(step, now_ms)); + ((), if changed { vec![intent] } else { Vec::new() }) + }) + .await } /// Tell subscribers and the host about a session's current stage. A @@ -368,6 +418,93 @@ impl FundingRegistry { } } +/// A deposit request with the route core chose for it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DepositPlan { + /// What the provider delivers. + pub request: DepositRequest, + /// How the deposit becomes CASH on People. + pub route: ConversionRoute, +} + +/// What converts deposits: the network's constants and the deposit keys. +struct Conversion { + network: FundingNetwork, + signer: Arc, +} + +/// What a conversion pass decided for one session. +#[derive(Debug, PartialEq, Eq)] +enum PlannedStep { + /// Record a step. + Record(ConversionStep), + /// Record the submission, then submit `extrinsic`. + Submit { + submission: ConversionSubmission, + extrinsic: Vec, + }, +} + +/// Decide the next step for `deposit`, given its `submission` so far. +/// +/// A submitted conversion is judged only by what it did: landed once People +/// shows its CASH on top of what was there before; dropped once its era has +/// passed unincluded, or once it was included and the deposit is still on +/// Asset Hub; stalled once it took the deposit and nothing arrived in time. +/// A fresh conversion is signed only by the account the deposit sits on. +async fn plan_conversion( + chains: &dyn ConversionChains, + signer: &dyn FundingSigner, + deposit: &FundingDeposit, + submission: Option, + now_ms: u64, +) -> Result, ConversionError> { + let account = &deposit.account; + if let Some(submission) = submission { + let landed = chains + .landed(account) + .await? + .saturating_sub(submission.people_before); + if landed >= submission.landing { + return Ok(Some(PlannedStep::Record(ConversionStep::Landed { landed }))); + } + let step = if chains.nonce(account).await? > submission.nonce { + if chains.deposit_balance(deposit.asset, account).await? >= submission.spent { + Some(ConversionStep::Dropped) + } else if now_ms.saturating_sub(submission.submitted_at_ms) > STALL_AFTER_MS { + Some(ConversionStep::Stalled) + } else { + None + } + } else if chains.finalized_block() > submission.valid_until_block { + Some(ConversionStep::Dropped) + } else { + None + }; + return Ok(step.map(PlannedStep::Record)); + } + let keypair = signer + .deposit_keypair(&deposit.source_id, deposit.number) + .map_err(|error| ConversionError::Chain(error.reason))?; + let Some(keypair) = keypair.filter(|keypair| keypair.public.to_bytes() == *account) else { + return Ok(None); + }; + let people_before = chains.landed(account).await?; + let nonce = chains.nonce(account).await?; + let prepared = chains.prepare(deposit, &keypair, nonce).await?; + Ok(Some(PlannedStep::Submit { + submission: ConversionSubmission { + nonce, + submitted_at_ms: now_ms, + valid_until_block: prepared.valid_until_block, + people_before, + landing: prepared.landing, + spent: prepared.spent, + }, + extrinsic: prepared.extrinsic, + })) +} + /// Reads deposit-account balances on Asset Hub. pub trait DepositBalances: Send + Sync { /// `account`'s balance of `asset`; zero when the account does not exist. @@ -488,6 +625,12 @@ pub enum AssignDepositError { /// The session is not an open inbound one without a deposit. #[display("funding session is not awaiting a deposit account")] NotAwaitingDeposit, + /// No signing host converts deposits on this runtime. + #[display("this host does not convert funding deposits")] + ConversionUnavailable, + /// No route turns this asset into CASH. + #[display("no route converts this deposit into CASH")] + NoRoute, /// Every account tried already holds funds. #[display("every funding account tried already holds funds")] AccountsInUse, @@ -567,8 +710,9 @@ impl RuntimeServices { } /// Give an open inbound session its deposit account for the request's - /// source and watch it until the expected balance arrives. Returns the - /// account the provider pays into. + /// source, fix the route that will convert it, and watch the account until + /// the expected balance arrives. Returns the account the provider pays + /// into. pub async fn assign_funding_deposit( self: &Arc, intent: &str, @@ -579,6 +723,21 @@ impl RuntimeServices { .get(intent) .ok_or(AssignDepositError::NotFound) .and_then(|session| assignable(&session))?; + let network = self + .funding() + .conversion + .get() + .ok_or(AssignDepositError::ConversionUnavailable)? + .network; + let chains = self + .funding_chains(network) + .await + .map_err(|error| AssignDepositError::Chain(GenericError { reason: error.to_string() }))?; + let route = chains + .choose_route(request.asset, request.expected) + .await + .map_err(|error| AssignDepositError::Chain(GenericError { reason: error.to_string() }))? + .ok_or(AssignDepositError::NoRoute)?; let balances = FinalizedAssetHubBalances::connect(self) .await .map_err(AssignDepositError::Chain)?; @@ -589,7 +748,7 @@ impl RuntimeServices { &balances, current_unix_millis(), intent, - request, + DepositPlan { request, route }, derive, ) .await?; @@ -627,10 +786,107 @@ impl RuntimeServices { if let Err(reason) = observed { tracing::warn!(%reason, "funding deposit watch failed"); } + if let Err(reason) = services.advance_conversions().await { + tracing::warn!(%reason, "funding conversion pass failed"); + } } })); } + /// Asset Hub and People, pinned at their latest finalized blocks. + async fn funding_chains(&self, network: FundingNetwork) -> Result { + within_chain_timeout(async { + let chains = features::supported_chains(self.platform.as_ref()) + .await + .map_err(|error| ConversionError::Chain(error.reason))?; + let client = |chain: ChainIdentifier| { + let genesis = features::genesis_for(&chains, chain); + async move { + let genesis = genesis + .ok_or_else(|| ConversionError::Chain(format!("the host serves no {chain:?}")))?; + self.chain + .online_client(&genesis) + .await + .map_err(|error| ConversionError::Chain(error.to_string())) + } + }; + let asset_hub = client(ChainIdentifier::AssetHub).await?; + let people = client(ChainIdentifier::People).await?; + Chains::at_finalized(&asset_hub, &people, network).await + }) + .await + .map_err(|error| ConversionError::Chain(error.reason))? + } + + /// One pass over the sessions being converted: record what landed, + /// what was dropped or stalled, and submit what is ready. + async fn advance_conversions(self: &Arc) -> Result<(), String> { + let registry = self.funding(); + let converting = registry.converting_sessions(); + let Some(conversion) = registry.conversion.get().filter(|_| !converting.is_empty()) else { + return Ok(()); + }; + let chains = self + .funding_chains(conversion.network) + .await + .map_err(|error| error.to_string())?; + let storage = self.platform.as_ref(); + for (intent, deposit, submission) in converting { + let now_ms = current_unix_millis(); + let planned = within_chain_timeout(plan_conversion( + &chains, + conversion.signer.as_ref(), + &deposit, + submission, + now_ms, + )) + .await; + let planned = match planned { + Ok(Ok(planned)) => planned, + Ok(Err(ConversionError::Refused(reason))) => { + Some(PlannedStep::Record(ConversionStep::Refused { reason })) + } + Ok(Err(ConversionError::Chain(reason))) | Err(GenericError { reason }) => { + tracing::warn!(%intent, %reason, "funding conversion pass failed"); + continue; + } + }; + let recorded = match planned { + None => Ok(()), + Some(PlannedStep::Record(step)) => { + registry.record_conversion(storage, now_ms, &intent, step).await + } + Some(PlannedStep::Submit { + submission, + extrinsic, + }) => { + let submitted = ConversionStep::Submitted(submission); + registry + .record_conversion(storage, now_ms, &intent, submitted) + .await + .map_err(|error| error.to_string())?; + match chains.submit(extrinsic).await { + Ok(()) => Ok(()), + Err(ConversionError::Refused(reason)) => { + let refused = ConversionStep::Refused { reason }; + registry + .record_conversion(storage, current_unix_millis(), &intent, refused) + .await + } + // It may have reached the chain anyway; the + // submission stays, and its era or the nonce decides. + Err(ConversionError::Chain(reason)) => { + tracing::warn!(%intent, %reason, "submitting a funding conversion failed"); + Ok(()) + } + } + } + }; + recorded.map_err(|error| error.to_string())?; + } + Ok(()) + } + /// Open a session and show the host's funding overlay for it: the one /// path a product's `request` and the host's own Balance card both take. /// @@ -836,6 +1092,13 @@ mod tests { } } + fn plan(expected: u128) -> DepositPlan { + DepositPlan { + request: request(expected), + route: ConversionRoute::Teleport, + } + } + fn request(expected: u128) -> DepositRequest { DepositRequest { source_id: "usdt-assethub".to_string(), @@ -854,7 +1117,7 @@ mod tests { balances: &Balances, intent: &str, ) -> Result<[u8; 32], String> { - block_on(registry.assign_empty_deposit(storage, balances, NOW, intent, request(50), |n| { + block_on(registry.assign_empty_deposit(storage, balances, NOW, intent, plan(50), |n| { Ok(account(n)) })) .map_err(|error| error.to_string()) @@ -885,6 +1148,7 @@ mod tests { asset: USDT, account: account(3), expected: 50, + route: ConversionRoute::Teleport, }) ) ); @@ -960,7 +1224,11 @@ mod tests { }, HostFundingStatusSubscribeItem::Converting, ], - Some(FundingStage::Converting { deposited: 50 }), + Some(FundingStage::Converting { + deposited: 50, + refusals: 0, + submission: None, + }), ) ); } @@ -997,7 +1265,11 @@ mod tests { ( [Some(FundingStage::Open), Some(FundingStage::Open)], [ - Some(FundingStage::Converting { deposited: 50 }), + Some(FundingStage::Converting { + deposited: 50, + refusals: 0, + submission: None, + }), Some(FundingStage::Failed { reason: FundingFailure::Expired, settled_at_ms: past_deadline, @@ -1014,7 +1286,11 @@ mod tests { let storage = stub_platform(); let registry = FundingRegistry::default(); let converting = FundingSession { - stage: FundingStage::Converting { deposited: 50 }, + stage: FundingStage::Converting { + deposited: 50, + refusals: 0, + submission: None, + }, ..session("fs_1", NOW - DAY_MS) }; insert(®istry, storage.as_ref(), converting.clone()); @@ -1071,4 +1347,186 @@ mod tests { [Some(500), Some(70), Some(0), None] ); } + + /// Chains answering fixed reads, and preparing a fixed conversion. + struct Scripted { + landed: u128, + nonce: u32, + balance: u128, + block: u64, + } + + const PREPARED: Prepared = Prepared { + extrinsic: Vec::new(), + valid_until_block: 164, + landing: 40, + spent: 50, + }; + + impl ConversionChains for Scripted { + fn landed<'a>(&'a self, _: &'a [u8; 32]) -> BoxFuture<'a, Result> { + Box::pin(async move { Ok(self.landed) }) + } + + fn nonce<'a>(&'a self, _: &'a [u8; 32]) -> BoxFuture<'a, Result> { + Box::pin(async move { Ok(self.nonce) }) + } + + fn deposit_balance<'a>( + &'a self, + _: DepositAsset, + _: &'a [u8; 32], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { Ok(self.balance) }) + } + + fn finalized_block(&self) -> u64 { + self.block + } + + fn prepare<'a>( + &'a self, + _: &'a FundingDeposit, + _: &'a schnorrkel::Keypair, + _: u32, + ) -> BoxFuture<'a, Result> { + Box::pin(async { Ok(PREPARED) }) + } + } + + struct Keys(Option); + + impl FundingSigner for Keys { + fn deposit_keypair( + &self, + _: &str, + _: u32, + ) -> Result, GenericError> { + Ok(self.0.clone()) + } + } + + fn keypair(seed: u8) -> schnorrkel::Keypair { + schnorrkel::MiniSecretKey::from_bytes(&[seed; 32]) + .expect("seed") + .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519) + } + + fn converting_deposit() -> FundingDeposit { + FundingDeposit { + source_id: "usdt-assethub".to_string(), + number: 1, + asset: USDT, + account: keypair(1).public.to_bytes(), + expected: 50, + route: ConversionRoute::Teleport, + } + } + + const SUBMITTED: ConversionSubmission = ConversionSubmission { + nonce: 4, + submitted_at_ms: NOW, + valid_until_block: 164, + people_before: 10, + landing: 40, + spent: 50, + }; + + fn next_step(chains: Scripted, submission: Option, now_ms: u64) -> Option { + block_on(plan_conversion( + &chains, + &Keys(Some(keypair(1))), + &converting_deposit(), + submission, + now_ms, + )) + .expect("planned") + } + + // Anyone can send CASH to the account on People, so only CASH on top of + // what was there before, and at least what the conversion lands, ends it. + #[test] + fn only_the_conversions_own_cash_counts_as_landed() { + let reading = |landed| Scripted { + landed, + nonce: 4, + balance: 50, + block: 100, + }; + + assert_eq!( + [ + next_step(reading(11), Some(SUBMITTED), NOW), + next_step(reading(55), Some(SUBMITTED), NOW), + ], + [ + None, + Some(PlannedStep::Record(ConversionStep::Landed { landed: 45 })), + ] + ); + } + + // Resubmitting while the first transaction can still land would convert + // twice, so a submission is dropped only once it provably cannot: its era + // passed unincluded, or it was included and left the deposit in place. + #[test] + fn a_submission_is_dropped_only_once_it_cannot_convert() { + let chains = |nonce, balance, block| Scripted { + landed: 10, + nonce, + balance, + block, + }; + let late = NOW + STALL_AFTER_MS + 1; + + assert_eq!( + [ + next_step(chains(4, 50, 164), Some(SUBMITTED), late), + next_step(chains(4, 50, 165), Some(SUBMITTED), NOW), + next_step(chains(5, 50, 100), Some(SUBMITTED), NOW), + next_step(chains(5, 1, 100), Some(SUBMITTED), NOW), + next_step(chains(5, 1, 100), Some(SUBMITTED), late), + ], + [ + None, + Some(PlannedStep::Record(ConversionStep::Dropped)), + Some(PlannedStep::Record(ConversionStep::Dropped)), + None, + Some(PlannedStep::Record(ConversionStep::Stalled)), + ] + ); + } + + // A session outlives a sign-out, so after switching identity the key on + // hand is not the deposit account's; signing with it would dry-run one + // account and pay from another. + #[test] + fn a_conversion_is_signed_only_by_the_deposit_account() { + let chains = || Scripted { + landed: 10, + nonce: 7, + balance: 50, + block: 100, + }; + let planned = |keys: Keys| { + block_on(plan_conversion(&chains(), &keys, &converting_deposit(), None, NOW)) + .expect("planned") + }; + + assert_eq!( + [planned(Keys(None)), planned(Keys(Some(keypair(2)))), planned(Keys(Some(keypair(1))))], + [ + None, + None, + Some(PlannedStep::Submit { + submission: ConversionSubmission { + nonce: 7, + people_before: 10, + ..SUBMITTED + }, + extrinsic: Vec::new(), + }), + ] + ); + } } diff --git a/rust/crates/truapi/src/runtime/funding/conversion.rs b/rust/crates/truapi/src/runtime/funding/conversion.rs new file mode 100644 index 0000000000..0911536f53 --- /dev/null +++ b/rust/crates/truapi/src/runtime/funding/conversion.rs @@ -0,0 +1,1370 @@ +//! Turning a funding deposit into CASH on People, the way getcash does it. +//! +//! One Asset Hub transaction, signed by the deposit account, converts the +//! deposit (nothing to do for CASH, a PSM mint for an approved stablecoin) and +//! teleports the CASH to the same account on People. Every fee is paid in the +//! deposited asset, since that is all the account holds. Before submitting, +//! the transaction is dry-run on Asset Hub and the message it forwards is +//! dry-run on People, so a conversion that would trap funds is never sent. + +use parity_scale_codec::{Decode, Encode}; +use subxt::client::OnlineClientAtBlock; +use subxt::config::substrate::SubstrateConfig; +use subxt::dynamic::{self, Value}; +use subxt::ext::scale_value::{Composite, ValueDef}; +use subxt::tx::ValidationResult; +use subxt::utils::Era; +use futures::future::BoxFuture; +use truapi::latest::{GenericError, TxPayloadExtension}; + +use crate::host_internal::extrinsic::{Sr25519Signer, build_signed_extrinsic_v4}; +use crate::host_logic::funding::{ConversionRoute, DepositAsset, FundingDeposit}; +use crate::runtime::statement_allowance::extension::{ChainState, Metadata as ExtensionMetadata}; + +/// XCM version every program and dry run uses. +const XCM_VERSION: u32 = 5; +/// Margin added to every fee estimate, in percent. +const FEE_MARGIN_PERCENT: u128 = 10; +/// Least CASH set aside to pay for execution on People. +const REMOTE_FEE_FLOOR: u128 = 1_000; +/// Share of the teleported CASH set aside for execution on People, in +/// percent. +const REMOTE_FEE_PERCENT: u128 = 1; +/// Mortality of a conversion transaction, in blocks. +const MORTAL_PERIOD_BLOCKS: u64 = 64; +/// PSM capacity a mint must leave spare, in percent of the amount. +const PSM_CAPACITY_MARGIN_PERCENT: u128 = 10; +/// Least PSM capacity a mint must leave spare, in CASH units. +const PSM_CAPACITY_MARGIN_FLOOR: u128 = 1_000_000; +/// Parts per million in a `Permill`. +const PARTS_PER_MILLION: u128 = 1_000_000; + +/// Network constants the conversion needs that the chains do not state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FundingNetwork { + /// `Assets` pallet id of CASH on Asset Hub. + pub cash_asset_id: u32, +} + +/// Signs conversions with funding deposit accounts. +pub trait FundingSigner: Send + Sync { + /// The keypair of the `number`th deposit account for `source_id`, or + /// `None` while no signing session is active. + fn deposit_keypair( + &self, + source_id: &str, + number: u32, + ) -> Result, GenericError>; +} + +/// Asset Hub and People, each pinned to its latest finalized block, with the +/// facts about them the programs need. +pub struct Chains { + asset_hub: OnlineClientAtBlock, + people: OnlineClientAtBlock, + places: Places, +} + +/// A signed conversion, with what tells later whether it worked. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Prepared { + /// The signed transaction. + pub extrinsic: Vec, + /// Last Asset Hub block its mortal era admits it in. + pub valid_until_block: u64, + /// Least CASH it lands on People. + pub landing: u128, + /// Deposit it takes from the account on Asset Hub. + pub spent: u128, +} + +/// What a conversion pass reads and does on the chains. +pub trait ConversionChains: Send + Sync { + /// CASH `account` holds on People. + fn landed<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result>; + /// `account`'s next nonce on Asset Hub. + fn nonce<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result>; + /// `account`'s balance of `asset` on Asset Hub. + fn deposit_balance<'a>( + &'a self, + asset: DepositAsset, + account: &'a [u8; 32], + ) -> BoxFuture<'a, Result>; + /// The finalized Asset Hub block these reads are pinned to. + fn finalized_block(&self) -> u64; + /// Size, dry-run and sign the conversion of `deposit` at `nonce`. + fn prepare<'a>( + &'a self, + deposit: &'a FundingDeposit, + keypair: &'a schnorrkel::Keypair, + nonce: u32, + ) -> BoxFuture<'a, Result>; +} + +/// Where CASH, the chains and the deposit account sit, as the programs name +/// them. +#[derive(Debug, Clone, Copy)] +struct Places { + network: FundingNetwork, + asset_hub_para: u32, + people_para: u32, + assets_pallet: u8, +} + +/// Why a conversion pass could not go ahead. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] +pub enum ConversionError { + /// A dry run or the transaction pool refused the conversion. Counted, + /// since retrying the same conversion keeps failing. + #[display("refused: {_0}")] + Refused(String), + /// The chains could not be read or reached. Retried on the next pass. + #[display("{_0}")] + Chain(String), +} + +fn chain(reason: impl core::fmt::Display) -> ConversionError { + ConversionError::Chain(reason.to_string()) +} + +impl Chains { + /// Pin both chains at their latest finalized blocks. + pub async fn at_finalized( + asset_hub: &subxt::OnlineClient, + people: &subxt::OnlineClient, + network: FundingNetwork, + ) -> Result { + let asset_hub = asset_hub.at_current_block().await.map_err(chain)?; + 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 + .metadata_ref() + .pallet_by_name("Assets") + .ok_or_else(|| chain("Asset Hub has no Assets pallet"))? + .call_index(); + Ok(Self { + asset_hub, + people, + places: Places { + network, + asset_hub_para, + people_para, + assets_pallet, + }, + }) + } + + /// Asset Hub's transaction extensions, as the extension encoder reads + /// them. Loaded only to sign, since it downloads the whole metadata. + async fn extensions(&self) -> Result { + let opaque = self + .asset_hub + .runtime_apis() + .call_raw("Metadata_metadata_at_version", Some(&15u32.encode())) + .await + .map_err(chain)?; + let raw = Option::>::decode(&mut &opaque[..]) + .map_err(chain)? + .ok_or_else(|| chain("Asset Hub serves no V15 metadata"))?; + ExtensionMetadata::decode(&raw).map_err(chain) + } + + /// The route for `expected` of `asset`: a teleport for CASH, a PSM mint + /// for a stablecoin the PSM serves with room to spare. + pub async fn choose_route( + &self, + asset: DepositAsset, + expected: u128, + ) -> Result, ConversionError> { + let DepositAsset::Asset(id) = asset else { + return Ok(None); + }; + if id == self.places.network.cash_asset_id { + return Ok(Some(ConversionRoute::Teleport)); + } + let Some(psm) = self.psm(id).await? else { + return Ok(None); + }; + let amount = psm.to_internal(expected); + let margin = (amount * PSM_CAPACITY_MARGIN_PERCENT / 100).max(PSM_CAPACITY_MARGIN_FLOOR); + let serves = psm.minting_enabled + && amount >= psm.min_swap_amount + && psm.headroom >= amount.saturating_add(margin); + Ok(serves.then_some(ConversionRoute::Psm { + fee_ppm: psm.fee_ppm, + })) + } + + /// The PSM's terms for minting CASH against asset `id`, if it lists it. + async fn psm(&self, id: u32) -> Result, ConversionError> { + let cash = self.places.cash(); + let external = self.places.asset_location(id); + let storage = self.asset_hub.storage(); + let Some(instance) = fetch_value( + &self.asset_hub, + "Psm", + "Psm", + vec![cash.clone()], + ) + .await? + else { + return Ok(None); + }; + let Some(listing) = fetch_value( + &self.asset_hub, + "Psm", + "ExternalAssets", + vec![cash.clone(), external.clone()], + ) + .await? + else { + return Ok(None); + }; + let max_debt = u128_at(&instance, "max_debt")?; + let fee_ppm = fetch_value( + &self.asset_hub, + "Psm", + "MintingFee", + vec![cash.clone(), external.clone()], + ) + .await? + .map(|fee| as_u128(&fee)) + .transpose()? + .unwrap_or(0); + + let suffix = external_key_suffix(&self.asset_hub, &external)?; + let mut total_weight = 0u128; + let mut weight = 0u128; + let mut weights = storage + .iter( + dynamic::storage::<(Value, Value), Value>("Psm", "AssetCeilingWeight"), + (cash.clone(),), + ) + .await + .map_err(chain)?; + while let Some(entry) = weights.next().await { + let entry = entry.map_err(chain)?; + let value = as_u128(&entry.value().decode().map_err(chain)?)?; + total_weight += value; + if entry.key_bytes().ends_with(&suffix) { + weight = value; + } + } + let mut total_debt = 0u128; + let mut debt = 0u128; + let mut debts = storage + .iter( + dynamic::storage::<(Value, Value), Value>("Psm", "PsmDebt"), + (cash.clone(),), + ) + .await + .map_err(chain)?; + while let Some(entry) = debts.next().await { + let entry = entry.map_err(chain)?; + let value = as_u128(&entry.value().decode().map_err(chain)?)?; + total_debt += value; + if entry.key_bytes().ends_with(&suffix) { + debt = value; + } + } + let ceiling = max_debt + .saturating_mul(weight) + .checked_div(total_weight) + .unwrap_or(0); + let headroom = max_debt + .saturating_sub(total_debt) + .min(ceiling.saturating_sub(debt)); + Ok(Some(PsmTerms { + minting_enabled: variant_name(field(&listing, "status")?) == Some("AllEnabled"), + min_swap_amount: u128_at(&instance, "min_swap_amount")?, + internal_decimals: u8::try_from(u128_at(&instance, "internal_decimals")?) + .map_err(chain)?, + external_decimals: u8::try_from(u128_at(&listing, "decimals")?).map_err(chain)?, + fee_ppm: u32::try_from(fee_ppm).map_err(chain)?, + headroom, + })) + } + + /// `account`'s balance of `asset` on Asset Hub. + async fn asset_hub_balance( + &self, + asset: DepositAsset, + account: &[u8; 32], + ) -> Result { + let (pallet, keys) = match asset { + DepositAsset::Native => ("System", vec![Value::from_bytes(account)]), + DepositAsset::Asset(id) => ( + "Assets", + vec![Value::u128(id.into()), Value::from_bytes(account)], + ), + }; + let Some(entry) = fetch_value(&self.asset_hub, pallet, "Account", keys).await? else { + return Ok(0); + }; + match asset { + DepositAsset::Native => u128_at(field(&entry, "data")?, "free"), + DepositAsset::Asset(_) => u128_at(&entry, "balance"), + } + } + + /// The least balance of `asset` an Asset Hub account must keep. + async fn min_balance(&self, asset: DepositAsset) -> Result { + let DepositAsset::Asset(id) = asset else { + let deposit = self + .asset_hub + .metadata_ref() + .pallet_by_name("Balances") + .and_then(|pallet| pallet.constant_by_name("ExistentialDeposit")) + .ok_or_else(|| chain("Asset Hub declares no existential deposit"))? + .value(); + return u128::decode(&mut &deposit[..]).map_err(chain); + }; + let details = fetch_value(&self.asset_hub, "Assets", "Asset", vec![Value::u128(id.into())]) + .await? + .ok_or_else(|| chain(format!("asset {id} does not exist")))?; + u128_at(&details, "min_balance") + } + + /// CASH `account` holds on People. + async fn people_cash(&self, account: &[u8; 32]) -> Result { + let Some(entry) = fetch_value( + &self.people, + "Assets", + "Account", + vec![self.places.cash_on_people(), Value::from_bytes(account)], + ) + .await? + else { + return Ok(0); + }; + u128_at(&entry, "balance") + } + + /// `account`'s next nonce on Asset Hub. + async fn account_nonce(&self, account: &[u8; 32]) -> Result { + let nonce = call_api( + &self.asset_hub, + "AccountNonceApi", + "account_nonce", + vec![Value::from_bytes(account)], + ) + .await?; + u32::try_from(as_u128(&nonce)?).map_err(chain) + } + + async fn prepare_conversion( + &self, + deposit: &FundingDeposit, + keypair: &schnorrkel::Keypair, + nonce: u32, + ) -> Result { + let signer = Sr25519Signer::from_keypair(keypair); + let account = deposit.account; + let fee_asset = self.places.deposit_location(deposit.asset); + let held = self.asset_hub_balance(deposit.asset, &account).await?; + let kept = self.min_balance(deposit.asset).await?; + let spendable = held + .checked_sub(kept) + .ok_or_else(|| ConversionError::Refused("the deposit is below the minimum balance".into()))?; + let mint = match (deposit.route, deposit.asset) { + (ConversionRoute::Teleport, _) => None, + (ConversionRoute::Psm { fee_ppm }, DepositAsset::Asset(id)) => Some(PsmMint { + id, + terms: self + .psm(id) + .await? + .ok_or_else(|| ConversionError::Refused("the PSM no longer lists the asset".into()))?, + max_fee_ppm: fee_ppm, + }), + (ConversionRoute::Psm { .. }, DepositAsset::Native) => { + return Err(ConversionError::Refused("the PSM mints only from assets".into())); + } + }; + let extensions = self.extensions().await?; + + // A first draft with a generous fee allowance measures the fees, + // which barely depend on the amounts; the final call is then sized + // from them. + let draft = self + .measured(self.places.conversion_call(&account, spendable, spendable / 5, mint)?) + .await?; + let forwarded = self.dry_run(&account, &draft).await?; + let local_fee = self.local_fee(&draft.program, &fee_asset).await?; + let delivery_fee = self.delivery_fee(&forwarded, &fee_asset).await?; + let draft_extrinsic = self.sign(&extensions, &signer, &draft, &fee_asset, nonce)?; + let dispatch_fee = self.dispatch_fee(&draft_extrinsic, &fee_asset).await?; + + let allowance = with_margin(local_fee.saturating_add(delivery_fee)); + let available = spendable + .checked_sub(with_margin(dispatch_fee)) + .and_then(|left| left.checked_sub(allowance)) + .filter(|left| *left > 0) + .ok_or_else(|| ConversionError::Refused("the deposit does not cover the fees".into()))?; + let call = self + .measured(self.places.conversion_call(&account, available + allowance, allowance, mint)?) + .await?; + let forwarded = self.dry_run(&account, &call).await?; + self.dry_run_on_people(&forwarded).await?; + Ok(Prepared { + extrinsic: self.sign(&extensions, &signer, &call, &fee_asset, nonce)?, + valid_until_block: self.asset_hub.block_number() + MORTAL_PERIOD_BLOCKS, + landing: call.landing, + spent: call.spent, + }) + } + + /// Validate a signed conversion against Asset Hub's transaction pool, + /// which is where fee payment in the deposit asset is checked, then + /// submit it. Only the pool's refusal is a refusal: a failed submit may + /// still have reached the chain. + pub async fn submit(&self, extrinsic: Vec) -> Result<(), ConversionError> { + let transaction = self.asset_hub.tx().from_bytes(extrinsic); + match transaction.validate().await.map_err(chain)? { + ValidationResult::Valid(_) => {} + invalid => { + return Err(ConversionError::Refused(format!( + "Asset Hub rejects the transaction: {invalid:?}" + ))); + } + } + transaction.submit().await.map(|_| ()).map_err(chain) + } + + /// Dry-run `call` from `account` on Asset Hub, returning the message it + /// forwards to People. + async fn dry_run( + &self, + account: &[u8; 32], + call: &ConversionCall, + ) -> Result { + let origin = Value::unnamed_variant( + "system", + [Value::unnamed_variant("Signed", [Value::from_bytes(account)])], + ); + let effects = ok(call_api( + &self.asset_hub, + "DryRunApi", + "dry_run_call", + vec![origin, call.runtime_call().value(), Value::u128(XCM_VERSION.into())], + ) + .await?)?; + let execution = field(&effects, "execution_result")?; + if variant_name(execution) != Some("Ok") { + return Err(ConversionError::Refused(format!( + "dry run failed on Asset Hub: {execution}" + ))); + } + let events = field(&effects, "emitted_events")?; + if mentions_variant(events, "AssetsTrapped") { + return Err(ConversionError::Refused("the conversion would trap assets on Asset Hub".into())); + } + let forwarded = field(&effects, "forwarded_xcms")?; + items(forwarded) + .into_iter() + .find_map(|entry| { + let [destination, messages] = items(entry)[..] else { + return None; + }; + (parachain_of(destination) == Some(self.places.people_para)) + .then(|| items(messages).first().map(|message| (*message).clone())) + .flatten() + }) + .ok_or_else(|| ConversionError::Refused("the conversion forwards nothing to People".into())) + } + + /// Dry-run the forwarded `message` on People as Asset Hub sends it. + async fn dry_run_on_people(&self, message: &Value) -> Result<(), ConversionError> { + let origin = versioned(location( + 1, + vec![junction("Parachain", Value::u128(self.places.asset_hub_para.into()))], + )); + let effects = ok(call_api( + &self.people, + "DryRunApi", + "dry_run_xcm", + vec![origin, message.clone()], + ) + .await?)?; + let outcome = field(&effects, "execution_result")?; + if variant_name(outcome) != Some("Complete") { + return Err(ConversionError::Refused(format!( + "the message would not complete on People: {outcome}" + ))); + } + if mentions_variant(field(&effects, "emitted_events")?, "AssetsTrapped") { + return Err(ConversionError::Refused("the conversion would trap assets on People".into())); + } + Ok(()) + } + + /// `call` with its execute's weight limit set to what its program weighs. + async fn measured(&self, call: ConversionCall) -> Result { + let measured = self.program_weight(&call.program).await?; + Ok(ConversionCall { + max_weight: measured, + ..call + }) + } + + async fn program_weight(&self, program: &[Value]) -> Result { + ok(call_api( + &self.asset_hub, + "XcmPaymentApi", + "query_xcm_weight", + vec![versioned(Value::unnamed_composite(program.to_vec()))], + ) + .await?) + } + + /// What executing `program` locally costs, in `fee_asset`. + async fn local_fee(&self, program: &[Value], fee_asset: &Value) -> Result { + let weight = self.program_weight(program).await?; + as_u128(&ok(call_api( + &self.asset_hub, + "XcmPaymentApi", + "query_weight_to_asset_fee", + vec![weight, versioned(fee_asset.clone())], + ) + .await?)?) + } + + /// What delivering `message` to People costs, in `fee_asset`. + async fn delivery_fee(&self, message: &Value, fee_asset: &Value) -> Result { + let people = versioned(location( + 1, + vec![junction("Parachain", Value::u128(self.places.people_para.into()))], + )); + let fees = ok(call_api( + &self.asset_hub, + "XcmPaymentApi", + "query_delivery_fees", + vec![people, message.clone(), versioned(fee_asset.clone())], + ) + .await?)?; + Ok(fungible_total(unversioned(&fees)?)) + } + + /// What dispatching `extrinsic` costs, in `fee_asset`. + async fn dispatch_fee(&self, extrinsic: &[u8], fee_asset: &Value) -> 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.asset_hub, + "TransactionPaymentApi", + "query_info", + vec![Value::from_bytes(&unprefixed), Value::u128(length.into())], + ) + .await?; + let native = u128_at(&info, "partial_fee")?; + if fee_asset == &location(1, Vec::new()) { + return Ok(native); + } + let quoted = call_api( + &self.asset_hub, + "AssetConversionApi", + "quote_price_tokens_for_exact_tokens", + vec![fee_asset.clone(), location(1, Vec::new()), Value::u128(native), Value::bool(true)], + ) + .await?; + let quoted = variant_fields("ed) + .filter(|_| variant_name("ed) == Some("Some")) + .and_then(|fields| fields.values().next()) + .ok_or_else(|| ConversionError::Refused("no pool prices the fee asset".into()))?; + as_u128(quoted) + } + + /// Sign `call` with the deposit account at `nonce`, mortal from this + /// block, paying fees in `fee_asset`. + fn sign( + &self, + extensions: &ExtensionMetadata, + signer: &Sr25519Signer, + call: &ConversionCall, + 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)) + } +} + +impl Places { + /// CASH as Asset Hub names it. + fn cash(&self) -> Value { + self.asset_location(self.network.cash_asset_id) + } + + /// An `Assets` pallet asset as Asset Hub names it. + fn asset_location(&self, id: u32) -> Value { + location( + 0, + vec![ + junction("PalletInstance", Value::u128(self.assets_pallet.into())), + junction("GeneralIndex", Value::u128(id.into())), + ], + ) + } + + /// The deposited asset as Asset Hub names it. + fn deposit_location(&self, asset: DepositAsset) -> Value { + match asset { + DepositAsset::Native => location(1, Vec::new()), + DepositAsset::Asset(id) => self.asset_location(id), + } + } + + /// CASH as People names it. + fn cash_on_people(&self) -> Value { + location( + 1, + vec![ + junction("Parachain", Value::u128(self.asset_hub_para.into())), + junction("PalletInstance", Value::u128(self.assets_pallet.into())), + junction("GeneralIndex", Value::u128(self.network.cash_asset_id.into())), + ], + ) + } + + /// The call converting `withdrawn` of `asset`, of which `allowance` pays + /// for local execution and delivery. + fn conversion_call( + &self, + account: &[u8; 32], + withdrawn: u128, + allowance: u128, + mint: Option, + ) -> Result { + let converted = withdrawn + .checked_sub(allowance) + .ok_or_else(|| ConversionError::Refused("the fee allowance exceeds the deposit".into()))?; + let (cash, withdrawn_assets) = match mint { + None => (converted, vec![self.asset(&self.cash(), withdrawn)]), + Some(mint) => { + let internal = mint.terms.to_internal(converted); + if internal < mint.terms.min_swap_amount { + return Err(ConversionError::Refused( + "the deposit is below the PSM's minimum swap after fees".into(), + )); + } + let minted = psm_mint_out(internal, mint.terms.fee_ppm); + // `Assets` must be sorted, and both sit under one pallet, so + // the general index decides the order. + let mut assets = vec![ + (mint.id, self.asset(&self.asset_location(mint.id), allowance)), + (self.network.cash_asset_id, self.asset(&self.cash(), minted)), + ]; + assets.sort_by_key(|(index, _)| *index); + (minted, assets.into_iter().map(|(_, asset)| asset).collect()) + } + }; + let remote_fee = (cash * REMOTE_FEE_PERCENT / 100).max(REMOTE_FEE_FLOOR); + if remote_fee >= cash { + return Err(ConversionError::Refused( + "the deposit does not cover the fee on People".into(), + )); + } + let beneficiary = location( + 0, + vec![Value::named_variant( + "AccountId32", + [ + ("network", Value::unnamed_variant("None", [])), + ("id", Value::from_bytes(account)), + ], + )], + ); + let everything = || { + Value::unnamed_variant( + "Wild", + [Value::unnamed_variant("AllCounted", [Value::u128(1)])], + ) + }; + let deposit_everything = || { + Value::named_variant( + "DepositAsset", + [ + ("assets", everything()), + ("beneficiary", beneficiary.clone()), + ], + ) + }; + let teleport = |filter: Value| Value::unnamed_variant("Teleport", [filter]); + let fee_asset = match mint { + None => self.asset(&self.cash(), allowance), + Some(mint) => self.asset(&self.asset_location(mint.id), allowance), + }; + let program = vec![ + Value::unnamed_variant("WithdrawAsset", [Value::unnamed_composite(withdrawn_assets)]), + Value::named_variant("PayFees", [("asset", fee_asset)]), + Value::named_variant( + "InitiateTransfer", + [ + ( + "destination", + location(1, vec![junction("Parachain", Value::u128(self.people_para.into()))]), + ), + ( + "remote_fees", + Value::unnamed_variant( + "Some", + [teleport(Value::unnamed_variant( + "Definite", + [Value::unnamed_composite([self.asset(&self.cash(), remote_fee)])], + ))], + ), + ), + ("preserve_origin", Value::bool(false)), + ("assets", Value::unnamed_composite([teleport(everything())])), + ( + "remote_xcm", + Value::unnamed_composite([ + Value::unnamed_variant("RefundSurplus", []), + deposit_everything(), + ]), + ), + ], + ), + Value::unnamed_variant("RefundSurplus", []), + deposit_everything(), + ]; + let mint_call = mint.map(|mint| { + RuntimeCall::new( + "Psm", + "mint", + vec![ + ("internal_asset", self.cash()), + ("external_asset", self.asset_location(mint.id)), + ("external_amount", Value::u128(converted)), + ("max_fee", Value::u128(mint.max_fee_ppm.into())), + ], + ) + }); + Ok(ConversionCall { + program, + mint: mint_call, + max_weight: weight(0, 0), + landing: cash - remote_fee, + spent: withdrawn, + }) + } + + fn asset(&self, id: &Value, amount: u128) -> Value { + Value::named_composite([ + ("id", id.clone()), + ("fun", Value::unnamed_variant("Fungible", [Value::u128(amount)])), + ]) + } +} + +/// A PSM mint ahead of the teleport. +#[derive(Debug, Clone, Copy)] +struct PsmMint { + /// The stablecoin minted against. + id: u32, + /// The PSM's current terms, which size the mint. + terms: PsmTerms, + /// The fee the route was chosen at, the most the mint may charge. + max_fee_ppm: u32, +} + +impl ConversionChains for Chains { + fn landed<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result> { + Box::pin(self.people_cash(account)) + } + + fn nonce<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result> { + Box::pin(self.account_nonce(account)) + } + + fn deposit_balance<'a>( + &'a self, + asset: DepositAsset, + account: &'a [u8; 32], + ) -> BoxFuture<'a, Result> { + Box::pin(self.asset_hub_balance(asset, account)) + } + + fn finalized_block(&self) -> u64 { + self.asset_hub.block_number() + } + + fn prepare<'a>( + &'a self, + deposit: &'a FundingDeposit, + keypair: &'a schnorrkel::Keypair, + nonce: u32, + ) -> BoxFuture<'a, Result> { + Box::pin(self.prepare_conversion(deposit, keypair, nonce)) + } +} + +/// The PSM's terms for one stablecoin. +#[derive(Debug, Clone, Copy)] +struct PsmTerms { + minting_enabled: bool, + min_swap_amount: u128, + internal_decimals: u8, + external_decimals: u8, + fee_ppm: u32, + /// CASH the PSM can still mint against this stablecoin. + headroom: u128, +} + +impl PsmTerms { + /// `amount` of the stablecoin in CASH units, rounded down. + fn to_internal(self, amount: u128) -> u128 { + let (internal, external) = (u32::from(self.internal_decimals), u32::from(self.external_decimals)); + if internal >= external { + amount.saturating_mul(10u128.pow(internal - external)) + } else { + amount / 10u128.pow(external - internal) + } + } +} + +/// CASH the PSM mints for `amount` in CASH units at `fee_ppm`. +fn psm_mint_out(amount: u128, fee_ppm: u32) -> u128 { + let fee = (amount * u128::from(fee_ppm)).div_ceil(PARTS_PER_MILLION); + amount.saturating_sub(fee) +} + +/// `fee` plus the fee margin, rounded up. +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"))?; + Value::named_composite([ + ("tip", Value::u128(0)), + ("asset_id", Value::unnamed_variant("Some", [fee_asset.clone()])), + ]) + .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 +/// any, the program's weight limit, and what it moves. +struct ConversionCall { + program: Vec, + mint: Option, + max_weight: Value, + /// Least CASH it lands on People. + landing: u128, + /// Deposit it takes from the account on Asset Hub. + spent: u128, +} + +impl ConversionCall { + /// The call to sign: the execute alone, or batched after the mint. + fn runtime_call(&self) -> RuntimeCall { + let execute = RuntimeCall::new( + "PolkadotXcm", + "execute", + vec![ + ("message", versioned(Value::unnamed_composite(self.program.clone()))), + ("max_weight", self.max_weight.clone()), + ], + ); + match &self.mint { + None => execute, + Some(mint) => RuntimeCall::new( + "Utility", + "batch_all", + vec![( + "calls", + Value::unnamed_composite([mint.value(), execute.value()]), + )], + ), + } + } +} + +/// A runtime call built from metadata names. +#[derive(Clone)] +struct RuntimeCall { + pallet: &'static str, + name: &'static str, + fields: Vec<(&'static str, Value)>, +} + +impl RuntimeCall { + fn new(pallet: &'static str, name: &'static str, fields: Vec<(&'static str, Value)>) -> Self { + Self { pallet, name, fields } + } + + /// The call as a `RuntimeCall` value. + fn value(&self) -> Value { + Value::unnamed_variant( + self.pallet, + [Value::named_variant(self.name, self.fields.clone())], + ) + } + + /// The call's SCALE bytes, encoded against `metadata`. + fn encode(&self, metadata: &subxt::Metadata) -> Result, ConversionError> { + subxt::ext::frame_decode::extrinsics::encode_call_data( + self.pallet, + self.name, + &Value::named_composite(self.fields.clone()), + metadata, + metadata.types(), + ) + .map_err(chain) + } +} + +/// A location `parents` up with `junctions` below. +fn location(parents: u8, junctions: Vec) -> Value { + let interior = match junctions.len() { + 0 => Value::unnamed_variant("Here", []), + count => Value::unnamed_variant(format!("X{count}"), [Value::unnamed_composite(junctions)]), + }; + Value::named_composite([("parents", Value::u128(parents.into())), ("interior", interior)]) +} + +fn junction(name: &'static str, value: Value) -> Value { + Value::unnamed_variant(name, [value]) +} + +fn weight(ref_time: u64, proof_size: u64) -> Value { + Value::named_composite([ + ("ref_time", Value::u128(ref_time.into())), + ("proof_size", Value::u128(proof_size.into())), + ]) +} + +/// `value` as XCM version 5. +fn versioned(value: Value) -> Value { + Value::unnamed_variant(format!("V{XCM_VERSION}"), [value]) +} + +/// The value inside a versioned XCM type. +fn unversioned(value: &subxt::ext::scale_value::Value) -> Result<&subxt::ext::scale_value::Value, ConversionError> { + variant_fields(value) + .and_then(|fields| fields.values().next()) + .ok_or_else(|| chain("expected a versioned XCM value")) +} + +/// The SCALE bytes of `value` as the type of the first key of `pallet.item`. +fn encode_as(at: &OnlineClientAtBlock, value: &Value, pallet: &str, item: &str) -> Result, ConversionError> { + use subxt::ext::scale_encode::EncodeAsType; + let metadata = at.metadata_ref(); + let entry = metadata + .pallet_by_name(pallet) + .and_then(|pallet| pallet.storage()) + .and_then(|storage| storage.entry_by_name(item)) + .ok_or_else(|| chain(format!("{pallet}.{item} not in metadata")))?; + let key_type = entry + .keys() + .next() + .ok_or_else(|| chain(format!("{pallet}.{item} has no key")))? + .key_id; + value.encode_as_type(key_type, metadata.types()).map_err(chain) +} + +/// The SCALE bytes `external` contributes at the end of a `Psm` double-map +/// key: its `Blake2_128Concat` hash. +fn external_key_suffix(at: &OnlineClientAtBlock, external: &Value) -> Result, ConversionError> { + let encoded = encode_as(at, external, "Psm", "Psm")?; + Ok(super::super::statement_allowance::blake2_128_concat(&encoded)) +} + +/// The parachain id a versioned location names, if it is `../Parachain(id)`. +fn parachain_of(location: &subxt::ext::scale_value::Value) -> Option { + let location = unversioned(location).ok()?; + let interior = field(location, "interior").ok()?; + let junctions = variant_fields(interior)?.values().next()?; + let parachain = *items(junctions).first()?; + (variant_name(parachain) == Some("Parachain")) + .then(|| variant_fields(parachain)?.values().next().and_then(|id| as_u128(id).ok())) + .flatten() + .and_then(|id| u32::try_from(id).ok()) +} + +async fn parachain_id(at: &OnlineClientAtBlock) -> Result { + let id = fetch_value(at, "ParachainInfo", "ParachainId", Vec::new()) + .await? + .ok_or_else(|| chain("the chain names no parachain id"))?; + u32::try_from(as_u128(unwrap_newtype(&id))?).map_err(chain) +} + +async fn fetch_value( + at: &OnlineClientAtBlock, + pallet: &str, + item: &str, + keys: Vec, +) -> Result, ConversionError> { + let address = dynamic::storage::, Value>(pallet, item); + match at.storage().try_fetch(address, keys).await.map_err(chain)? { + Some(value) => value.decode().map(Some).map_err(chain), + None => Ok(None), + } +} + +async fn call_api( + at: &OnlineClientAtBlock, + api: &str, + method: &str, + args: Vec, +) -> Result { + at.runtime_apis() + .call(dynamic::runtime_api_call::<_, Value>(api, method, args)) + .await + .map_err(chain) +} + +/// The `Ok` side of a `Result` value, or a refusal naming the error. +fn ok(value: Value) -> Result { + match &value.value { + ValueDef::Variant(result) if result.name == "Ok" => result + .values + .values() + .next() + .cloned() + .ok_or_else(|| chain("empty Ok")), + _ => Err(ConversionError::Refused(value.to_string())), + } +} + +fn field<'a, T>( + value: &'a subxt::ext::scale_value::Value, + name: &str, +) -> Result<&'a subxt::ext::scale_value::Value, ConversionError> { + use subxt::ext::scale_value::At; + value.at(name).ok_or_else(|| chain(format!("missing field {name}"))) +} + +fn u128_at(value: &subxt::ext::scale_value::Value, name: &str) -> Result { + as_u128(field(value, name)?) +} + +/// `value` read through any single-field wrappers. +fn unwrap_newtype(mut value: &subxt::ext::scale_value::Value) -> &subxt::ext::scale_value::Value { + while let ValueDef::Composite(composite) = &value.value { + let mut values = composite.values(); + match (values.next(), values.next()) { + (Some(inner), None) => value = inner, + _ => break, + } + } + value +} + +fn as_u128(value: &subxt::ext::scale_value::Value) -> Result { + unwrap_newtype(value) + .as_u128() + .ok_or_else(|| chain(format!("expected a number, got {value}"))) +} + +fn variant_name(value: &subxt::ext::scale_value::Value) -> Option<&str> { + match &value.value { + ValueDef::Variant(variant) => Some(variant.name.as_str()), + _ => None, + } +} + +fn variant_fields(value: &subxt::ext::scale_value::Value) -> Option<&Composite> { + match &value.value { + ValueDef::Variant(variant) => Some(&variant.values), + _ => None, + } +} + +/// The direct items of a sequence, tuple or composite value. +fn items(value: &subxt::ext::scale_value::Value) -> Vec<&subxt::ext::scale_value::Value> { + match &value.value { + ValueDef::Composite(composite) => composite.values().collect(), + _ => Vec::new(), + } +} + +/// The sum of every `Fungible` amount anywhere in `value`. +fn fungible_total(value: &subxt::ext::scale_value::Value) -> u128 { + match &value.value { + ValueDef::Variant(variant) if variant.name == "Fungible" => variant + .values + .values() + .next() + .and_then(|amount| as_u128(amount).ok()) + .unwrap_or(0), + ValueDef::Variant(variant) => variant.values.values().map(fungible_total).sum(), + ValueDef::Composite(composite) => composite.values().map(fungible_total).sum(), + _ => 0, + } +} + +/// Whether any variant named `name` appears anywhere in `value`. +fn mentions_variant(value: &subxt::ext::scale_value::Value, name: &str) -> bool { + match &value.value { + ValueDef::Variant(variant) => { + variant.name == name || variant.values.values().any(|inner| mentions_variant(inner, name)) + } + ValueDef::Composite(composite) => composite.values().any(|inner| mentions_variant(inner, name)), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use frame_metadata::RuntimeMetadataPrefixed; + + const CASH: u32 = 50_000_413; + const PLACES: Places = Places { + network: FundingNetwork { + cash_asset_id: CASH, + }, + asset_hub_para: 1500, + people_para: 1502, + assets_pallet: 50, + }; + + fn asset_hub_metadata() -> subxt::Metadata { + let bytes = include_bytes!("../../../tests/fixtures/paseo-next-asset-hub-metadata.scale"); + let prefixed = RuntimeMetadataPrefixed::decode(&mut &bytes[..]).expect("fixture decodes"); + subxt::Metadata::try_from(prefixed).expect("fixture converts") + } + + // The program is built from names, not indices, so a renamed pallet, + // call, instruction or field fails here instead of on chain. + #[test] + fn a_teleport_encodes_as_an_asset_hub_xcm_execute() { + let metadata = asset_hub_metadata(); + let call = PLACES + .conversion_call(&[1; 32], 1_000_000, 100_000, None) + .expect("sized") + .runtime_call(); + let pallet = metadata.pallet_by_name("PolkadotXcm").expect("pallet"); + let execute = pallet.call_variant_by_name("execute").expect("call"); + + let encoded = call.encode(&metadata).expect("encodes"); + + assert_eq!(encoded[..2], [pallet.call_index(), execute.index]); + } + + // Sized from the getcash formulas: the PSM keeps its fee, rounded up, + // and every fee estimate gets a tenth more, rounded up. + #[test] + fn psm_and_fee_sizing_round_against_the_user() { + let six_to_six = PsmTerms { + minting_enabled: true, + min_swap_amount: 0, + internal_decimals: 6, + external_decimals: 6, + fee_ppm: 5_000, + headroom: 0, + }; + let eighteen_to_six = PsmTerms { + external_decimals: 18, + ..six_to_six + }; + + assert_eq!( + ( + psm_mint_out(1_000_000, 5_000), + psm_mint_out(1_001, 5_000), + with_margin(1_001), + six_to_six.to_internal(1_234_567), + eighteen_to_six.to_internal(1_234_567_000_000_999_999), + ), + (995_000, 995, 1_102, 1_234_567, 1_234_567) + ); + } + + // Without CASH left for execution on People the teleport would land + // nothing, so a deposit that small is refused before any dry run. + #[test] + fn a_deposit_too_small_for_the_fee_on_people_is_refused() { + let refused = PLACES + .conversion_call(&[1; 32], 1_500, 600, None) + .map(|_| ()); + + assert_eq!( + refused, + Err(ConversionError::Refused( + "the deposit does not cover the fee on People".into() + )) + ); + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod live { + //! Dry runs against Paseo Next, from accounts that already hold the + //! assets. Run with `cargo test -p truapi --lib funding::conversion::live -- --ignored`. + + use super::*; + + 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, + }; + const USDT: u32 = 1984; + + 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 { + Chains::at_finalized(&client(ASSET_HUB).await, &client(PEOPLE).await, NETWORK) + .await + .expect("chains pinned") + } + + /// An account holding at least `least` of asset `id` on Asset Hub. + async fn holder(chains: &Chains, id: u32, least: u128) -> [u8; 32] { + let mut entries = chains + .asset_hub + .storage() + .iter( + dynamic::storage::<(Value, Value), Value>("Assets", "Account"), + (Value::u128(id.into()),), + ) + .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 >= least { + let key = entry.key_bytes(); + return key[key.len() - 32..].try_into().expect("account id"); + } + } + panic!("no account holds {least} of asset {id}"); + } + + fn deposit(asset: DepositAsset, account: [u8; 32], route: ConversionRoute) -> FundingDeposit { + FundingDeposit { + source_id: "live".into(), + number: 1, + asset, + account, + expected: 0, + route, + } + } + + #[tokio::test] + #[ignore = "reaches Paseo Next"] + async fn a_cash_deposit_teleports_to_people() { + let chains = chains().await; + let account = holder(&chains, NETWORK.cash_asset_id, 5_000_000).await; + let route = chains + .choose_route(DepositAsset::Asset(NETWORK.cash_asset_id), 1_000_000) + .await + .expect("route"); + let keypair = schnorrkel::MiniSecretKey::from_bytes(&[7; 32]) + .expect("seed") + .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519); + let prepared = chains + .prepare(&deposit(DepositAsset::Asset(NETWORK.cash_asset_id), account, ConversionRoute::Teleport), &keypair, 0) + .await; + assert_eq!((route, prepared.map(|_| ())), (Some(ConversionRoute::Teleport), Ok(()))); + } + + // The dry runs above never see the signed extensions. Signed by an + // account with nothing to pay with, a well-formed transaction fails on + // payment alone; a mis-encoded extension or signature fails before that. + #[tokio::test] + #[ignore = "reaches Paseo Next"] + async fn a_signed_conversion_is_well_formed_down_to_its_fee_payment() { + let chains = chains().await; + let account = holder(&chains, NETWORK.cash_asset_id, 5_000_000).await; + let keypair = schnorrkel::MiniSecretKey::from_bytes(&[7; 32]) + .expect("seed") + .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519); + let extrinsic = chains + .prepare( + &deposit(DepositAsset::Asset(NETWORK.cash_asset_id), account, ConversionRoute::Teleport), + &keypair, + 0, + ) + .await + .expect("prepared") + .extrinsic; + let validity = chains + .asset_hub + .tx() + .from_bytes(extrinsic) + .validate() + .await + .expect("validates"); + assert_eq!( + format!("{validity:?}"), + format!("{:?}", ValidationResult::Invalid(subxt::tx::TransactionInvalid::Payment)) + ); + } + + #[tokio::test] + #[ignore = "reaches Paseo Next"] + async fn a_usdt_deposit_mints_through_the_psm_and_teleports() { + let chains = chains().await; + let account = holder(&chains, USDT, 5_000_000).await; + let route = chains + .choose_route(DepositAsset::Asset(USDT), 2_000_000) + .await + .expect("route"); + let Some(route) = route else { + panic!("the PSM does not serve USDT"); + }; + let keypair = schnorrkel::MiniSecretKey::from_bytes(&[7; 32]) + .expect("seed") + .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519); + let prepared = chains + .prepare(&deposit(DepositAsset::Asset(USDT), account, route), &keypair, 0) + .await; + assert_eq!(prepared.map(|_| ()), Ok(())); + } +} diff --git a/rust/crates/truapi/src/runtime/signing_host.rs b/rust/crates/truapi/src/runtime/signing_host.rs index 125063fb1b..8b178a4a0d 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -25,7 +25,7 @@ mod sso_service; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use truapi::latest::{ - ChainIdentifier, DerivationIndex, HostAccountCreateProofRequest, HostAccountGetAliasRequest, + ChainIdentifier, DerivationIndex, GenericError, HostAccountCreateProofRequest, HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, ProductAccountId, RingLocation, RingLocationJunction, }; @@ -534,6 +534,34 @@ impl SigningHost { .map_err(product_authority_error) } + /// Keypair of a funding deposit account, for the core's own conversion. + /// Products never reach it: their requests derive through + /// `product_keypair_with_owner`, which refuses the funding product. + fn funding_deposit_keypair( + &self, + source_id: &str, + number: u32, + ) -> Result, AuthorityError> { + let index = funding_account_index(FundingAccountKind::Deposit, source_id, number).map_err( + |err| AuthorityError::Unavailable { + reason: err.to_string(), + }, + )?; + let entropy = match self.root_entropy() { + Ok(entropy) => entropy, + Err(AuthorityError::Disconnected) => return Ok(None), + Err(err) => return Err(err), + }; + let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; + derive_product_keypair( + &root, + &funding_product_id(&self.network_suffix), + derivation_index_bytes(&v01::DerivationIndex::Raw(index)), + ) + .map(Some) + .map_err(product_authority_error) + } + /// Derive the product-account keypair for `account` from the root entropy. /// /// The root keypair is recomputed per call (PBKDF2, 2048 rounds, via @@ -1690,6 +1718,19 @@ fn product_authority_error(err: ProductAccountError) -> AuthorityError { } } +impl super::FundingSigner for SigningHost { + fn deposit_keypair( + &self, + source_id: &str, + number: u32, + ) -> Result, GenericError> { + self.funding_deposit_keypair(source_id, number) + .map_err(|err| GenericError { + reason: err.to_string(), + }) + } +} + #[cfg(test)] mod tests { mod allowance_keys; diff --git a/rust/crates/truapi/src/runtime/statement_allowance/extension.rs b/rust/crates/truapi/src/runtime/statement_allowance/extension.rs index d033dc41dd..0383667288 100644 --- a/rust/crates/truapi/src/runtime/statement_allowance/extension.rs +++ b/rust/crates/truapi/src/runtime/statement_allowance/extension.rs @@ -733,7 +733,6 @@ impl Metadata { } /// The signed-extension identifiers, in metadata order. - #[cfg(test)] pub fn extension_ids(&self) -> Vec<&str> { self.extensions .iter() From 3ae59f740e8b8feccb4782c6294e8732b3655851 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 5 Oct 2026 14:37:59 +0200 Subject: [PATCH 04/20] fix(truapi): export the funding conversion types --- rust/crates/truapi/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 7c3bf4599d..08a34aa988 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -630,8 +630,9 @@ runtime_items! { pub use runtime::product_manifest::{encode_cached_root_manifest, manifest_cache_key}; pub use runtime::statement_allowance; pub use runtime::{ - AnnouncedPairing, DevicePairingObserver, MAX_PAIRING_METADATA_CHARS, PairedSsoPeer, - PairingProposal, PairingProposalMetadata, ResponderExit, + AnnouncedPairing, DevicePairingObserver, FundingNetwork, FundingSigner, + MAX_PAIRING_METADATA_CHARS, PairedSsoPeer, PairingProposal, PairingProposalMetadata, + ResponderExit, }; #[cfg(not(target_arch = "wasm32"))] From 7660760adbebdc829f6275fba0dc5cd6a7b880d2 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 5 Oct 2026 14:41:58 +0200 Subject: [PATCH 05/20] feat(truapi-host-cli): add funding-check --- .changeset/funding-check-cli.md | 5 + .../truapi-host-cli/src/funding_check.rs | 199 ++++++++++++++++++ rust/crates/truapi-host-cli/src/main.rs | 55 +++++ rust/crates/truapi/src/host_core.rs | 9 + 4 files changed, 268 insertions(+) create mode 100644 .changeset/funding-check-cli.md create mode 100644 rust/crates/truapi-host-cli/src/funding_check.rs diff --git a/.changeset/funding-check-cli.md b/.changeset/funding-check-cli.md new file mode 100644 index 0000000000..43c7810f60 --- /dev/null +++ b/.changeset/funding-check-cli.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": patch +--- + +`truapi-host funding-check` runs a real on-ramp from the terminal: it opens a funding session, prints the deposit address, and follows the session until the CASH lands on People. Signing hosts can read one funding session with `funding_session` (Rust API). diff --git a/rust/crates/truapi-host-cli/src/funding_check.rs b/rust/crates/truapi-host-cli/src/funding_check.rs new file mode 100644 index 0000000000..3ee90f37a0 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/funding_check.rs @@ -0,0 +1,199 @@ +//! A real on-ramp against a live network, run from the terminal. +//! +//! The command opens a funding session on a signing host, prints the deposit +//! address its provider would pay, and follows the session while someone pays +//! that address from any funded account. It ends once the CASH lands on +//! People, or the session fails. +//! +//! Sessions and account counters live under the state directory, so a second +//! run with `--intent` picks up the same session, and no run reuses an +//! account. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use clap::ValueEnum; +use truapi::host_logic::funding::{DepositAsset, DepositRequest, FundingStage}; +use truapi::latest::{FundingDirection, GenericError, HostFundingStatusSubscribeItem}; +use truapi::platform::{ + FundingPlatform, FundingPresentOutcome, FundingPresentation, ProductContext, async_trait, +}; +use truapi::{FundingNetwork, SigningHostRuntime}; + +use crate::network::{Network, NetworkConfig}; + +/// How often the command reports the session's stage. +const POLL: Duration = Duration::from_secs(6); +/// Polls a session may be missing for before the command gives up on it. +const MISSING_POLLS: u32 = 5; + +/// The asset a provider pays the deposit in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum FundingAsset { + /// dotUSD, which is CASH already: teleported to People. + Cash, + /// Minted into CASH through the PSM, then teleported. + Usdt, + /// Minted into CASH through the PSM, then teleported. + Usdc, +} + +/// Asset ids a network's Asset Hub uses for the funding assets. +struct FundingAssets { + cash: u32, + usdt: u32, + usdc: u32, +} + +impl FundingAssets { + /// The ids on `network`, where they are known. + fn of(network: Network) -> Option { + match network { + Network::PaseoNextV2 => Some(Self { + cash: 50_000_413, + usdt: 1984, + usdc: 1337, + }), + Network::Previewnet => None, + } + } + + /// The asset id and the getcash source id for `asset`. + fn source(&self, asset: FundingAsset) -> (u32, &'static str) { + match asset { + FundingAsset::Cash => (self.cash, "dotusd-assethub"), + FundingAsset::Usdt => (self.usdt, "usdt-assethub"), + FundingAsset::Usdc => (self.usdc, "usdc-assethub"), + } + } +} + +/// A funding overlay that starts every session at once and prints what the +/// core reports. +struct TerminalFundingHost; + +#[async_trait] +impl FundingPlatform for TerminalFundingHost { + async fn present_funding( + &self, + _product: Option<&ProductContext>, + _session: FundingPresentation, + ) -> Result { + Ok(FundingPresentOutcome::Started) + } + + fn funding_session_changed(&self, intent: String, status: HostFundingStatusSubscribeItem) { + println!("{intent}: {status:?}"); + } +} + +/// What to run. +pub struct FundingCheck { + /// Mnemonic of the identity whose funding accounts are used. + pub mnemonic: String, + /// Network preset. + pub network: Network, + /// Asset the deposit is paid in. + pub asset: FundingAsset, + /// Balance that counts as delivered, in the asset's smallest units. + pub expected: u128, + /// Where sessions and account counters persist between runs. + pub state_dir: PathBuf, + /// A session to follow instead of opening a new one. + pub intent: Option, +} + +/// Run `check` until its session lands CASH on People or fails. +pub async fn run( + check: FundingCheck, + build_runtime: impl FnOnce(NetworkConfig, PathBuf) -> Result>, +) -> Result<()> { + let assets = FundingAssets::of(check.network) + .context("no funding asset ids are known for this network")?; + let runtime = build_runtime(check.network.config(), check.state_dir)?; + let entropy = bip39::Mnemonic::parse(check.mnemonic.trim()) + .context("invalid mnemonic")? + .to_entropy(); + runtime + .activate_local_session(entropy) + .await + .map_err(|error| anyhow::anyhow!("activating the signer failed: {}", error.reason))?; + runtime.set_funding_platform(Arc::new(TerminalFundingHost)); + runtime.enable_funding_conversion(FundingNetwork { + cash_asset_id: assets.cash, + }); + + let intent = match check.intent { + Some(intent) => intent, + None => open_and_assign(&runtime, &assets, check.asset, check.expected).await?, + }; + follow(&runtime, &intent).await +} + +/// Open a session and give it a deposit account, printing where to pay. +async fn open_and_assign( + runtime: &SigningHostRuntime, + assets: &FundingAssets, + asset: FundingAsset, + expected: u128, +) -> Result { + let (asset_id, source_id) = assets.source(asset); + let intent = runtime + .open_funding(FundingDirection::In, Some(expected)) + .await + .map_err(|error| anyhow::anyhow!("opening a session failed: {}", error.reason))? + .context("the session was dismissed")?; + let account = runtime + .assign_funding_deposit( + &intent, + DepositRequest { + source_id: source_id.to_string(), + asset: DepositAsset::Asset(asset_id), + expected, + }, + ) + .await + .map_err(|error| anyhow::anyhow!("assigning a deposit account failed: {}", error.reason))?; + println!("session {intent}"); + println!("pay {expected} of asset {asset_id} ({source_id}) on Asset Hub to"); + println!( + " {}", + truapi::host_logic::product_account::product_public_key_to_address(account) + ); + println!(" 0x{}", hex::encode(account)); + println!("resume --intent {intent}"); + Ok(intent) +} + +/// Print the session's stage whenever it changes, until it settles. +async fn follow(runtime: &SigningHostRuntime, intent: &str) -> Result<()> { + let mut last = None; + let mut missing_polls = 0; + loop { + // Persisted sessions load in the background after the funding host + // is installed, so a resumed one can take a moment to appear. + let Some(session) = runtime.funding_session(intent) else { + missing_polls += 1; + if missing_polls > MISSING_POLLS { + bail!("no funding session {intent}"); + } + tokio::time::sleep(POLL).await; + continue; + }; + if last.as_ref() != Some(&session.stage) { + println!("stage {:?}", session.stage); + last = Some(session.stage.clone()); + } + match session.stage { + FundingStage::Converted { landed } => { + println!("landed {landed} CASH units on People"); + return Ok(()); + } + FundingStage::Failed { reason, .. } => bail!("the session failed: {reason:?}"), + FundingStage::Open | FundingStage::Converting { .. } => {} + } + tokio::time::sleep(POLL).await; + } +} diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 5cdd8cc813..ea16e8039b 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -21,6 +21,7 @@ mod chat; mod contacts; mod dotns_read; mod frame_server; +mod funding_check; mod network; mod platform; mod pocket; @@ -304,6 +305,30 @@ enum Command { #[arg(long)] submit: bool, }, + /// Run a real on-ramp: open a funding session, print the deposit address, + /// and follow the session while you pay that address from any funded + /// account, until the CASH lands on People. + FundingCheck { + /// BIP-39 mnemonic of the identity whose funding accounts are used. + #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC")] + mnemonic: String, + /// Network preset to use. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, + /// Asset the deposit is paid in. + #[arg(long, value_enum, default_value = "usdt")] + asset: funding_check::FundingAsset, + /// Balance that counts as delivered, in the asset's smallest units. + #[arg(long, default_value_t = 2_000_000)] + expected: u128, + /// Where sessions and account counters persist between runs. Keep it: + /// a fresh directory restarts the account numbers. + #[arg(long, default_value = ".funding-check")] + state_dir: PathBuf, + /// Follow an existing session instead of opening a new one. + #[arg(long)] + intent: Option, + }, /// Install the current stable release over this one. /// /// Only works for a binary the installer put in place; a `cargo install` @@ -645,6 +670,36 @@ async fn dispatch( lookback, submit, } => run_pgas_check(mnemonic, network.config(), target, lookback, submit).await, + Command::FundingCheck { + mnemonic, + network, + asset, + expected, + state_dir, + intent, + } => { + let check = funding_check::FundingCheck { + mnemonic, + network, + asset, + expected, + state_dir, + intent, + }; + funding_check::run(check, |config, state_dir| { + build_signing_runtime( + config, + state_dir.join("core"), + state_dir.join("products"), + ApprovalPolicy::AutoAccept, + None, + None, + None, + ) + .map(|(runtime, _platform)| runtime) + }) + .await + } } } diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index 4e076a9ced..8cf0a343c6 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -795,6 +795,15 @@ impl SigningHostRuntime { installed } + /// One funding session as the core holds it, stage and deposit included, + /// for the host's own status and history views. + pub fn funding_session( + &self, + intent: &str, + ) -> Option { + self.services.funding().get(intent) + } + /// Convert funding deposits into CASH on People on `network`, signing /// with the deposit accounts this host derives. Without it, assigning a /// deposit account fails. Set-once; returns whether this call enabled it. From 94f5fd61564cf8a0dedb33f408667413c50bf8bf Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 5 Oct 2026 15:21:19 +0200 Subject: [PATCH 06/20] feat(truapi): credit converted funding deposits to the balance --- .changeset/funding-credit.md | 5 + .../truapi-host-cli/src/funding_check.rs | 8 +- rust/crates/truapi/RUNTIME.md | 5 +- rust/crates/truapi/src/host_logic/funding.rs | 160 ++++++- .../src/runtime/capabilities/payment.rs | 16 +- rust/crates/truapi/src/runtime/funding.rs | 117 ++++- .../truapi/src/runtime/funding/conversion.rs | 4 + .../truapi/src/runtime/funding/credit.rs | 406 ++++++++++++++++++ .../crates/truapi/src/runtime/signing_host.rs | 10 +- rust/crates/truapi/src/runtime/tests.rs | 43 ++ 10 files changed, 749 insertions(+), 25 deletions(-) create mode 100644 .changeset/funding-credit.md create mode 100644 rust/crates/truapi/src/runtime/funding/credit.rs diff --git a/.changeset/funding-credit.md b/.changeset/funding-credit.md new file mode 100644 index 0000000000..bb29c44a92 --- /dev/null +++ b/.changeset/funding-credit.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": patch +--- + +Funding sessions end `Delivered`: once the converted CASH lands on People, the core credits it through the host's top-up, with the deposit account's key as the source, and retries under a new id when a claim takes nothing. Products under `fund.` can neither start nor follow top-ups. diff --git a/rust/crates/truapi-host-cli/src/funding_check.rs b/rust/crates/truapi-host-cli/src/funding_check.rs index 3ee90f37a0..884864f0fd 100644 --- a/rust/crates/truapi-host-cli/src/funding_check.rs +++ b/rust/crates/truapi-host-cli/src/funding_check.rs @@ -192,7 +192,13 @@ async fn follow(runtime: &SigningHostRuntime, intent: &str) -> Result<()> { return Ok(()); } FundingStage::Failed { reason, .. } => bail!("the session failed: {reason:?}"), - FundingStage::Open | FundingStage::Converting { .. } => {} + FundingStage::Delivered { credited, .. } => { + println!("credited {credited} CASH units to the balance"); + return Ok(()); + } + FundingStage::Open + | FundingStage::Converting { .. } + | FundingStage::Crediting { .. } => {} } tokio::time::sleep(POLL).await; } diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index e7136448b3..0b43a08b6c 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -411,8 +411,9 @@ AutoSigning without approval. Legacy-account signing still asks the user. route then: a teleport for CASH, a PSM mint for a stablecoin the PSM serves. The core converts with one Asset Hub transaction signed by the deposit account, paying fees in the deposited asset, after dry-running it on Asset - Hub and the message it forwards on People, and records the CASH that lands - on People. + Hub and the message it forwards on People. Once the CASH lands on People, + the core credits it through `TopUpPlatform` with the deposit account's key + as a `PrivateKey` source, and the session ends `Delivered`. - `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/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index 28d5cba44f..32aa0ed3ee 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -138,6 +138,25 @@ pub enum FundingStage { /// CASH on People, in payment balance units. landed: u128, }, + /// Inbound: the host's top-up is claiming the landed CASH into the + /// user's balance. + Crediting { + /// CASH on People, in payment balance units. + landed: u128, + /// Which top-up attempt is running, from 0. + attempt: u8, + /// When that attempt was registered, in Unix milliseconds. + since_ms: u64, + /// When the first attempt was registered, in Unix milliseconds. + started_ms: u64, + }, + /// Inbound terminal success: the CASH is in the user's balance. + Delivered { + /// Amount credited, in payment balance units. + credited: u128, + /// When it was credited, in Unix milliseconds. + settled_at_ms: u64, + }, /// Ended without success. Failed { /// Why it ended. @@ -176,9 +195,11 @@ impl FundingSession { /// When the session ended, if it has. pub fn settled_at_ms(&self) -> Option { match self.stage { - FundingStage::Open | FundingStage::Converting { .. } | FundingStage::Converted { .. } => { - None - } + FundingStage::Open + | FundingStage::Converting { .. } + | FundingStage::Converted { .. } + | FundingStage::Crediting { .. } => None, + FundingStage::Delivered { settled_at_ms, .. } => Some(settled_at_ms), FundingStage::Failed { settled_at_ms, .. } => Some(settled_at_ms), } } @@ -194,8 +215,16 @@ impl FundingSession { (FundingStage::Open, FundingDirection::Out) => { HostFundingStatusSubscribeItem::AwaitingRelease } - (FundingStage::Converting { .. } | FundingStage::Converted { .. }, _) => { - HostFundingStatusSubscribeItem::Converting + ( + FundingStage::Converting { .. } + | FundingStage::Converted { .. } + | FundingStage::Crediting { .. }, + _, + ) => HostFundingStatusSubscribeItem::Converting, + (FundingStage::Delivered { credited, .. }, _) => { + HostFundingStatusSubscribeItem::Delivered { + credited: *credited, + } } (FundingStage::Failed { reason, .. }, _) => HostFundingStatusSubscribeItem::Failed { reason: reason.clone(), @@ -311,6 +340,100 @@ impl FundingSession { } } +/// A top-up attempt that is running. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CreditAttempt { + /// Which attempt, from 0. + pub attempt: u8, + /// When it was registered, in Unix milliseconds. + pub since_ms: u64, + /// When the first attempt was registered, in Unix milliseconds. + pub started_ms: u64, +} + +impl FundingSession { + /// The deposit and landed CASH of a session awaiting or being credited, + /// with the attempt running, if any. + pub fn crediting(&self) -> Option<(&FundingDeposit, u128, Option)> { + let deposit = self.deposit.as_ref()?; + match self.stage { + FundingStage::Converted { landed } => Some((deposit, landed, None)), + FundingStage::Crediting { + landed, + attempt, + since_ms, + started_ms, + } => Some(( + deposit, + landed, + Some(CreditAttempt { + attempt, + since_ms, + started_ms, + }), + )), + _ => None, + } + } + + /// Advance a session being credited by one step. Returns whether it + /// changed. + pub fn advance_credit(&mut self, step: CreditStep, now_ms: u64) -> bool { + let (landed, started_ms) = match self.stage { + FundingStage::Converted { landed } => (landed, now_ms), + FundingStage::Crediting { + landed, started_ms, .. + } => (landed, started_ms), + _ => return false, + }; + match step { + CreditStep::Registered { attempt } => { + self.stage = FundingStage::Crediting { + landed, + attempt, + since_ms: now_ms, + started_ms, + }; + true + } + CreditStep::Credited { credited } => { + self.stage = FundingStage::Delivered { + credited, + settled_at_ms: now_ms, + }; + true + } + CreditStep::Abandoned { reason } => self.fail( + FundingFailure::Other { + code: "credit_failed".into(), + message: reason, + }, + now_ms, + ), + } + } +} + +/// What one pass of crediting found or did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CreditStep { + /// The host accepted top-up `attempt`. + Registered { + /// Which attempt, from 0. + attempt: u8, + }, + /// The top-up credited the user's balance. + Credited { + /// Amount credited, in payment balance units. + credited: u128, + }, + /// Crediting cannot succeed; the CASH stays on the account on People. + Abandoned { + /// Why. + reason: String, + }, +} + /// What one pass of a conversion found or did. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConversionStep { @@ -679,6 +802,33 @@ mod tests { ); } + // Delivered is the one inbound success: it ends the session for + // subscribers and history, and the credited amount is what they see. + #[test] + fn a_credited_session_is_delivered() { + let mut session = converting(); + session.advance_conversion(ConversionStep::Landed { landed: 49 }, NOW); + session.advance_credit(CreditStep::Registered { attempt: 0 }, NOW); + let crediting = session.crediting().map(|(_, landed, running)| (landed, running)); + session.advance_credit(CreditStep::Credited { credited: 40 }, NOW + 1); + + assert_eq!( + (crediting, session.wire_item(), session.settled_at_ms()), + ( + Some(( + 49, + Some(CreditAttempt { + attempt: 0, + since_ms: NOW, + started_ms: NOW, + }) + )), + HostFundingStatusSubscribeItem::Delivered { credited: 40 }, + Some(NOW + 1), + ) + ); + } + // CASH on People is what the user is owed, so landing ends conversion // whatever the submission state, and the subscriber keeps seeing // converting until it is credited. diff --git a/rust/crates/truapi/src/runtime/capabilities/payment.rs b/rust/crates/truapi/src/runtime/capabilities/payment.rs index ffa9a8fb72..b7de6cadbd 100644 --- a/rust/crates/truapi/src/runtime/capabilities/payment.rs +++ b/rust/crates/truapi/src/runtime/capabilities/payment.rs @@ -177,18 +177,18 @@ impl Payment for ProductRuntimeHost { .services .top_up_platform() .ok_or(CallError::Unsupported)?; - if self.authority.current_session().is_none() { + // The core credits funding deposits through top-ups made as the + // funding product, so a product under that name could race or fake + // them. + if self.authority.current_session().is_none() + || crate::runtime::is_funding_product(&self.product_id()) + { return Err(CallError::Denied); } let domain = |error| CallError::Domain(HostPaymentTopUpError::V1(error)); if !source_keys_are_valid(&request.source) { return Err(domain(v01::HostPaymentTopUpError::InvalidSource)); } - if matches!(request.source, v01::PaymentTopUpSource::ProductAccount { .. }) - && crate::runtime::is_funding_product(&self.product_id()) - { - return Err(CallError::Denied); - } platform .top_up(&self.product, request) .await @@ -209,7 +209,9 @@ impl Payment for ProductRuntimeHost { let Some(platform) = self.services.top_up_platform() else { return Subscription::interrupted(CallError::Unsupported); }; - if self.authority.current_session().is_none() { + if self.authority.current_session().is_none() + || crate::runtime::is_funding_product(&self.product_id()) + { return Subscription::interrupted(CallError::Denied); } Subscription::new(Box::pin( diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs index 834f1fa1b0..b00717bfa4 100644 --- a/rust/crates/truapi/src/runtime/funding.rs +++ b/rust/crates/truapi/src/runtime/funding.rs @@ -26,6 +26,7 @@ use truapi::latest::{ }; mod conversion; +mod credit; use conversion::{Chains, ConversionChains, ConversionError}; #[cfg(test)] @@ -40,7 +41,7 @@ use super::statement_allowance::blake2_128_concat; use super::statement_allowance::rpc::RpcClient; use crate::host_logic::features; use crate::host_logic::funding::{ - ConversionRoute, ConversionStep, ConversionSubmission, DepositAsset, DepositRequest, + ConversionRoute, ConversionStep, ConversionSubmission, CreditAttempt, CreditStep, DepositAsset, DepositRequest, FundingDeposit, FundingSession, FundingSessionError, FundingStage, load_sessions, next_account_number, retained, store_sessions, }; @@ -355,7 +356,11 @@ impl FundingRegistry { fn awaits_deposit(&self) -> bool { self.lock_sessions() .values() - .any(|session| session.awaited_deposit().is_some() || session.converting().is_some()) + .any(|session| { + session.awaited_deposit().is_some() + || session.converting().is_some() + || session.crediting().is_some() + }) } /// Every session being converted, with its deposit and submission. @@ -369,6 +374,41 @@ impl FundingRegistry { .collect() } + /// Every session awaiting or being credited, with its deposit, landed + /// CASH and running attempt. + fn crediting_sessions(&self) -> Vec { + self.lock_sessions() + .values() + .filter_map(|session| { + let (deposit, landed, running) = session.crediting()?; + Some(CreditingSession { + intent: session.intent.clone(), + deposit: deposit.clone(), + landed, + running, + }) + }) + .collect() + } + + /// Apply one credit `step` to session `intent`. + async fn record_credit( + &self, + storage: &(impl CoreStorage + ?Sized), + now_ms: u64, + intent: &str, + step: CreditStep, + ) -> 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_credit(step, now_ms)); + ((), if changed { vec![intent] } else { Vec::new() }) + }) + .await + } + /// Apply one conversion `step` to session `intent`. async fn record_conversion( &self, @@ -418,6 +458,14 @@ impl FundingRegistry { } } +/// A session awaiting or being credited, as one credit pass reads it. +struct CreditingSession { + intent: String, + deposit: FundingDeposit, + landed: u128, + running: Option, +} + /// A deposit request with the route core chose for it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DepositPlan { @@ -572,13 +620,18 @@ impl DepositBalances for FinalizedAssetHubBalances { /// Run a chain read, giving up after [`CHAIN_TIMEOUT`] so a stalled /// connection cannot park the deposit watch. async fn within_chain_timeout(read: impl Future) -> Result { - let read = read.fuse(); - let timeout = futures_timer::Delay::new(CHAIN_TIMEOUT).fuse(); - futures::pin_mut!(read, timeout); + within_timeout(CHAIN_TIMEOUT, read).await +} + +/// Run `work`, giving up after `limit`. +async fn within_timeout(limit: Duration, work: impl Future) -> Result { + let work = work.fuse(); + let timeout = futures_timer::Delay::new(limit).fuse(); + futures::pin_mut!(work, timeout); futures::select! { - value = read => Ok(value), + value = work => Ok(value), () = timeout => Err(GenericError { - reason: "Asset Hub read timed out".into(), + reason: format!("timed out after {}s", limit.as_secs()), }), } } @@ -789,6 +842,9 @@ impl RuntimeServices { if let Err(reason) = services.advance_conversions().await { tracing::warn!(%reason, "funding conversion pass failed"); } + if let Err(reason) = services.advance_credits().await { + tracing::warn!(%reason, "funding credit pass failed"); + } } })); } @@ -818,6 +874,49 @@ impl RuntimeServices { .map_err(|error| ConversionError::Chain(error.reason))? } + /// One pass over the sessions whose CASH landed: register top-ups and + /// record what they credited. Waits while the host has no top-up. + async fn advance_credits(self: &Arc) -> Result<(), String> { + let registry = self.funding(); + let (Some(conversion), Some(top_up)) = (registry.conversion.get(), self.top_up_platform()) + else { + return Ok(()); + }; + let crediting = registry.crediting_sessions(); + if crediting.is_empty() { + return Ok(()); + } + let product = ProductContext { + product_id: conversion.signer.funding_product_id(), + execution_kind: Default::default(), + }; + let credit = credit::Credit { + top_up: top_up.as_ref(), + signer: conversion.signer.as_ref(), + product: &product, + }; + for CreditingSession { + intent, + deposit, + landed, + running, + } in crediting + { + let now_ms = current_unix_millis(); + match credit.plan(&deposit, landed, running, now_ms).await { + Ok(Some(step)) => registry + .record_credit(self.platform.as_ref(), now_ms, &intent, step) + .await + .map_err(|error| error.to_string())?, + Ok(None) => {} + Err(error) => { + tracing::warn!(%intent, reason = %error.reason, "funding credit pass failed"); + } + } + } + Ok(()) + } + /// One pass over the sessions being converted: record what landed, /// what was dropped or stalled, and submit what is ready. async fn advance_conversions(self: &Arc) -> Result<(), String> { @@ -1404,6 +1503,10 @@ mod tests { ) -> Result, GenericError> { Ok(self.0.clone()) } + + fn funding_product_id(&self) -> String { + "fund.dot".into() + } } fn keypair(seed: u8) -> schnorrkel::Keypair { diff --git a/rust/crates/truapi/src/runtime/funding/conversion.rs b/rust/crates/truapi/src/runtime/funding/conversion.rs index 0911536f53..1fb81e7c37 100644 --- a/rust/crates/truapi/src/runtime/funding/conversion.rs +++ b/rust/crates/truapi/src/runtime/funding/conversion.rs @@ -55,6 +55,10 @@ pub trait FundingSigner: Send + Sync { source_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; } /// Asset Hub and People, each pinned to its latest finalized block, with the diff --git a/rust/crates/truapi/src/runtime/funding/credit.rs b/rust/crates/truapi/src/runtime/funding/credit.rs new file mode 100644 index 0000000000..d1a182d61a --- /dev/null +++ b/rust/crates/truapi/src/runtime/funding/credit.rs @@ -0,0 +1,406 @@ +//! Crediting landed CASH into the user's balance, the way getcash does it. +//! +//! The host's top-up claims the CASH on the deposit account on People, given +//! the account's secret key. Each attempt has its own id, so a retried call +//! for the same attempt is answered `AlreadyExists` and never claims twice. +//! A claim that takes nothing, or never finishes, moves on to the next +//! attempt; after the last one the session fails with the CASH still on the +//! account, where the same key can claim it later. + +use core::time::Duration; + +use futures::StreamExt; +use truapi::latest::{ + GenericError, HostPaymentTopUpError, HostPaymentTopUpRequest, + HostPaymentTopUpStatusSubscribeError, HostPaymentTopUpStatusSubscribeItem, PaymentTopUpSource, +}; + +use super::FundingSigner; +use crate::host_logic::funding::{CreditAttempt, CreditStep, FundingDeposit}; +use crate::platform::{ProductContext, TopUpPlatform}; + +/// Smallest amount a top-up claims, in CASH units: the landed CASH is +/// claimed rounded down to it. +const CLAIM_UNIT: u128 = 10_000; +/// Top-up attempts before crediting gives up. +const MAX_ATTEMPTS: u8 = 3; +/// How long one attempt may run before the next replaces it. +const ATTEMPT_WINDOW_MS: u64 = 90 * 60 * 1_000; +/// How long crediting may take in all before it gives up, so a claim that +/// never finalizes or a host that keeps answering busy cannot hold a session +/// open for good. +const CREDIT_DEADLINE_MS: u64 = 4 * ATTEMPT_WINDOW_MS; +/// Longest the host may take to report a top-up's current status. +const STATUS_TIMEOUT: Duration = Duration::from_secs(10); + +/// What crediting one session needs. +pub struct Credit<'a> { + /// The host's top-up. + pub top_up: &'a dyn TopUpPlatform, + /// Holds the deposit account's key. + pub signer: &'a dyn FundingSigner, + /// The funding product the top-ups are made as. + pub product: &'a ProductContext, +} + +impl Credit<'_> { + /// Decide the next step for a session whose CASH `landed` on `deposit`'s + /// account, given the attempt running and when it started. + pub async fn plan( + &self, + deposit: &FundingDeposit, + landed: u128, + running: Option, + now_ms: u64, + ) -> Result, GenericError> { + let amount = landed - landed % CLAIM_UNIT; + if amount == 0 { + return Ok(Some(CreditStep::Abandoned { + reason: "less CASH landed than a top-up can claim".into(), + })); + } + let Some(CreditAttempt { + attempt, + since_ms, + started_ms, + }) = running + else { + return self.register(deposit, amount, 0).await; + }; + if now_ms.saturating_sub(started_ms) > CREDIT_DEADLINE_MS { + return Ok(Some(CreditStep::Abandoned { + reason: "crediting did not finish in time".into(), + })); + } + let status = self.status(deposit, attempt).await; + let overdue = now_ms.saturating_sub(since_ms) > ATTEMPT_WINDOW_MS; + match status { + Some(Ok(HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true })) => { + Ok(Some(CreditStep::Credited { credited: amount })) + } + Some(Ok(HostPaymentTopUpStatusSubscribeItem::ClaimedPartially { actual_claimed })) => { + Ok(Some(CreditStep::Credited { + credited: actual_claimed, + })) + } + Some(Ok(HostPaymentTopUpStatusSubscribeItem::NotClaimed)) => { + self.next_attempt(deposit, amount, attempt).await + } + Some(Ok( + HostPaymentTopUpStatusSubscribeItem::Detecting + | HostPaymentTopUpStatusSubscribeItem::Claiming, + )) if overdue => self.next_attempt(deposit, amount, attempt).await, + Some(Err(HostPaymentTopUpStatusSubscribeError::NotFound)) => { + self.register(deposit, amount, attempt).await + } + Some(Ok(_)) | Some(Err(HostPaymentTopUpStatusSubscribeError::Unknown { .. })) | None => { + Ok(None) + } + } + } + + async fn next_attempt( + &self, + deposit: &FundingDeposit, + amount: u128, + attempt: u8, + ) -> Result, GenericError> { + let next = attempt + 1; + if next >= MAX_ATTEMPTS { + return Ok(Some(CreditStep::Abandoned { + reason: "no top-up claimed the CASH".into(), + })); + } + self.register(deposit, amount, next).await + } + + /// Ask the host to claim `amount` from the deposit account as `attempt`. + async fn register( + &self, + deposit: &FundingDeposit, + amount: u128, + attempt: u8, + ) -> Result, GenericError> { + let keypair = self + .signer + .deposit_keypair(&deposit.source_id, deposit.number)? + .filter(|keypair| keypair.public.to_bytes() == deposit.account); + let Some(keypair) = keypair else { + return Ok(None); + }; + // Canonical schnorrkel bytes, the form getcash converts its burner + // key to before handing it to the host's top-up. + let request = HostPaymentTopUpRequest { + into: None, + amount, + source: PaymentTopUpSource::PrivateKey { + sr25519_secret_key: keypair.secret.to_bytes(), + }, + id: top_up_id(&deposit.account, attempt), + }; + match self.top_up.top_up(self.product, request).await { + Ok(()) | Err(HostPaymentTopUpError::AlreadyExists) => { + Ok(Some(CreditStep::Registered { attempt })) + } + Err(HostPaymentTopUpError::InvalidSource) => Ok(Some(CreditStep::Abandoned { + reason: "the host refused the deposit account as a top-up source".into(), + })), + Err(HostPaymentTopUpError::SourceBusy | HostPaymentTopUpError::Unknown { .. }) => { + Ok(None) + } + } + } + + /// The current status of `attempt`, or `None` if the host reports none + /// in time. + async fn status( + &self, + deposit: &FundingDeposit, + attempt: u8, + ) -> Option> + { + let mut statuses = self + .top_up + .subscribe_top_up_status(self.product, top_up_id(&deposit.account, attempt)); + super::within_timeout(STATUS_TIMEOUT, statuses.next()) + .await + .ok() + .flatten() + } +} + +/// 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; + + use futures::executor::block_on; + use futures::stream::{self, BoxStream}; + + use super::*; + use crate::host_logic::funding::{ConversionRoute, DepositAsset}; + use crate::platform::async_trait; + + const NOW: u64 = 1_700_000_000_000; + + /// A top-up that answers fixed results and records every request. + struct Host { + accepts: Result<(), HostPaymentTopUpError>, + status: Option>, + requests: Mutex>, + } + + impl Host { + fn new( + accepts: Result<(), HostPaymentTopUpError>, + status: Option>, + ) -> Self { + Self { + accepts, + status, + requests: Mutex::new(Vec::new()), + } + } + + fn requests(&self) -> Vec<(u128, [u8; 32])> { + self.requests + .lock() + .expect("requests") + .iter() + .map(|request| (request.amount, request.id)) + .collect() + } + } + + #[async_trait] + impl TopUpPlatform for Host { + async fn top_up( + &self, + _product: &ProductContext, + request: HostPaymentTopUpRequest, + ) -> Result<(), HostPaymentTopUpError> { + self.requests.lock().expect("requests").push(request); + self.accepts.clone() + } + + fn subscribe_top_up_status( + &self, + _product: &ProductContext, + _id: [u8; 32], + ) -> BoxStream< + 'static, + Result, + > { + stream::iter(self.status.clone()).boxed() + } + } + + struct Keys(schnorrkel::Keypair); + + impl FundingSigner for Keys { + fn deposit_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 deposit() -> FundingDeposit { + FundingDeposit { + source_id: "usdt-assethub".into(), + number: 1, + asset: DepositAsset::Asset(1984), + account: keypair(1).public.to_bytes(), + expected: 2_000_000, + route: ConversionRoute::Psm { fee_ppm: 5_000 }, + } + } + + fn plan( + host: &Host, + key: u8, + running: Option, + now_ms: u64, + ) -> Option { + let product = ProductContext { + product_id: "fund.dot".into(), + execution_kind: Default::default(), + }; + let keys = Keys(keypair(key)); + let credit = Credit { + top_up: host, + signer: &keys, + product: &product, + }; + block_on(credit.plan(&deposit(), 1_987_654, running, now_ms)).expect("planned") + } + + // The first top-up is the one getcash would make: the landed CASH rounded + // down to the claim unit, identified by the account, so a top-up the host + // already holds is not made twice. + #[test] + fn landed_cash_is_claimed_once_under_the_accounts_id() { + let fresh = Host::new(Ok(()), None); + let known = Host::new(Err(HostPaymentTopUpError::AlreadyExists), None); + let account = deposit().account; + + assert_eq!( + ( + plan(&fresh, 1, None, NOW), + fresh.requests(), + plan(&known, 1, None, NOW), + ), + ( + Some(CreditStep::Registered { attempt: 0 }), + vec![(1_980_000, account)], + Some(CreditStep::Registered { attempt: 0 }), + ) + ); + } + + // Only a finalized claim credits; one that is claimed but unfinalized is + // waited for rather than retried, since a fresh attempt would find + // nothing to claim, until crediting as a whole runs out of time. + #[test] + fn only_a_finalized_claim_credits_the_balance() { + let overdue = NOW + ATTEMPT_WINDOW_MS + 1; + let unfinalized = Host::new( + Ok(()), + Some(Ok(HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: false })), + ); + let finalized = Host::new( + Ok(()), + Some(Ok(HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true })), + ); + let partial = Host::new( + Ok(()), + Some(Ok(HostPaymentTopUpStatusSubscribeItem::ClaimedPartially { + actual_claimed: 1_000_000, + })), + ); + + assert_eq!( + [ + plan(&unfinalized, 1, Some(CreditAttempt { attempt: 0, since_ms: NOW, started_ms: NOW }), overdue), + plan( + &unfinalized, + 1, + Some(CreditAttempt { attempt: 0, since_ms: NOW, started_ms: NOW }), + NOW + CREDIT_DEADLINE_MS + 1, + ), + plan(&finalized, 1, Some(CreditAttempt { attempt: 0, since_ms: NOW, started_ms: NOW }), NOW), + plan(&partial, 1, Some(CreditAttempt { attempt: 0, since_ms: NOW, started_ms: NOW }), NOW), + ], + [ + None, + Some(CreditStep::Abandoned { + reason: "crediting did not finish in time".into(), + }), + Some(CreditStep::Credited { + credited: 1_980_000 + }), + Some(CreditStep::Credited { + credited: 1_000_000 + }), + ] + ); + } + + // A claim that took nothing, or is stuck, gets a fresh attempt under a + // new id, up to the last, after which the CASH stays on the account. + #[test] + fn an_unclaimed_top_up_is_retried_under_a_new_id_until_the_last() { + let not_claimed = Host::new(Ok(()), Some(Ok(HostPaymentTopUpStatusSubscribeItem::NotClaimed))); + let stuck = Host::new(Ok(()), Some(Ok(HostPaymentTopUpStatusSubscribeItem::Detecting))); + let account = deposit().account; + let second_id = sp_crypto_hashing::blake2_256(&[account.as_slice(), &1u32.to_le_bytes()].concat()); + + assert_eq!( + ( + plan(¬_claimed, 1, Some(CreditAttempt { attempt: 0, since_ms: NOW, started_ms: NOW }), NOW), + not_claimed.requests(), + plan(&stuck, 1, Some(CreditAttempt { attempt: 1, since_ms: NOW, started_ms: NOW }), NOW), + plan(&stuck, 1, Some(CreditAttempt { attempt: 1, since_ms: NOW, started_ms: NOW }), NOW + ATTEMPT_WINDOW_MS + 1), + plan(¬_claimed, 1, Some(CreditAttempt { attempt: 2, since_ms: NOW, started_ms: NOW }), NOW), + ), + ( + Some(CreditStep::Registered { attempt: 1 }), + vec![(1_980_000, second_id)], + None, + Some(CreditStep::Registered { attempt: 2 }), + Some(CreditStep::Abandoned { + reason: "no top-up claimed the CASH".into(), + }), + ) + ); + } + + // A session outlives a sign-out; another identity's key must not be + // handed to the host as this account's. + #[test] + fn only_the_deposit_accounts_key_is_handed_to_the_host() { + let host = Host::new(Ok(()), None); + + assert_eq!((plan(&host, 2, None, NOW), host.requests()), (None, Vec::new())); + } +} diff --git a/rust/crates/truapi/src/runtime/signing_host.rs b/rust/crates/truapi/src/runtime/signing_host.rs index 8b178a4a0d..0bd46428f2 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -511,9 +511,9 @@ impl SigningHost { /// `source_id`, under the reserved funding product. `None` while no /// signing session is active. /// - /// The host claims it by calling its top-up engine as the funding product - /// with source `ProductAccount { derivation_index: Raw(index) }`, where - /// `index` is [`funding_account_index`] for the same arguments. + /// The core credits what lands on it by handing the account's key to the + /// host's top-up as a `PrivateKey` source; the key is the product account + /// at [`funding_account_index`] for the same arguments. pub fn derive_funding_account( &self, kind: FundingAccountKind, @@ -1729,6 +1729,10 @@ impl super::FundingSigner for SigningHost { reason: err.to_string(), }) } + + fn funding_product_id(&self) -> String { + funding_product_id(&self.network_suffix) + } } #[cfg(test)] diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 00ca63d812..e9d00be046 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -2685,6 +2685,49 @@ fn a_top_up_with_a_malformed_key_is_refused_before_the_host_sees_it() { ); } +// The core credits funding deposits with top-ups made as the funding +// product, under ids anyone can work out from the deposit address. A product +// under that name could otherwise register them first or read their status. +#[test] +fn no_product_tops_up_or_follows_top_ups_as_the_funding_product() { + let services = funding_services(); + let engine = Arc::new(RecordingTopUpPlatform::default()); + assert!(services.install_top_up_platform(engine.clone())); + let host = funding_host(&services, "fund.dot", true); + let secret = schnorrkel::MiniSecretKey::from_bytes(&[7; 32]) + .expect("seed") + .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519) + .secret + .to_bytes(); + + let started = top_up( + &host, + v01::PaymentTopUpSource::PrivateKey { + sr25519_secret_key: secret, + }, + ); + let followed = futures::executor::block_on( + futures::executor::block_on(truapi::api::Payment::top_up_status_subscribe( + &host, + &CallContext::default(), + truapi::versioned::payment::HostPaymentTopUpStatusSubscribeRequest::V1( + v01::HostPaymentTopUpStatusSubscribeRequest { id: [7; 32] }, + ), + )) + .collect::>(), + ); + + assert_eq!( + ( + started, + followed, + engine.started.lock().expect("started mutex poisoned").len(), + engine.followed.lock().expect("followed mutex poisoned").len(), + ), + (Err(CallError::Denied), vec![Err(CallError::Denied)], 0, 0) + ); +} + #[test] fn a_top_up_needs_a_session() { let services = funding_services(); From ecbcafea40b82a10d960f7572e1d4fcab1ac5eb0 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 5 Oct 2026 15:56:07 +0200 Subject: [PATCH 07/20] feat(truapi): derive funding accounts as getcash does --- .changeset/funding-getcash-derivation.md | 5 + rust/crates/truapi/src/host_logic/funding.rs | 112 +++++--- .../truapi/src/host_logic/product_account.rs | 6 +- .../src/runtime/capabilities/resources.rs | 5 + rust/crates/truapi/src/runtime/funding.rs | 252 +++++------------- .../truapi/src/runtime/funding/conversion.rs | 73 ++--- .../crates/truapi/src/runtime/signing_host.rs | 94 +++---- rust/crates/truapi/src/runtime/tests.rs | 19 ++ 8 files changed, 263 insertions(+), 303 deletions(-) create mode 100644 .changeset/funding-getcash-derivation.md diff --git a/.changeset/funding-getcash-derivation.md b/.changeset/funding-getcash-derivation.md new file mode 100644 index 0000000000..251450d897 --- /dev/null +++ b/.changeset/funding-getcash-derivation.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": patch +--- + +Funding accounts are derived as getcash derives its burners: the `fund.` product's entropy for the account's label, taken as a mini secret. No product under that name can derive entropy. diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index 32aa0ed3ee..964c23584c 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -13,6 +13,8 @@ use parity_scale_codec::{Decode, Encode}; use tracing::warn; use truapi::latest::{FundingDirection, FundingFailure, HostFundingStatusSubscribeItem}; +use crate::host_logic::entropy::derive_product_entropy; +use crate::host_logic::product_account::derive_root_keypair_from_entropy; use crate::platform::{CoreStorage, CoreStorageKey}; /// How long a session may stay open before it expires. @@ -469,34 +471,52 @@ pub enum FundingAccountKind { Withdrawal, } -/// Why a funding account label could not be built. +/// Why a funding account could not be derived. #[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] -#[display("funding account label is longer than 32 bytes")] -pub struct FundingAccountLabelTooLong; +pub enum FundingAccountError { + /// The label is longer than the 32 bytes entropy derivation takes. + #[display("funding account label is longer than 32 bytes")] + LabelTooLong, + /// The key could not be derived. + #[display("{_0}")] + Derivation(#[error(not(source))] String), +} -/// Derivation index of the `number`th account of `kind` for `source_id`: the -/// label `onramp:eph::`, `onramp:rf::` or -/// `wd:eph::`, zero-padded to 32 bytes. -/// -/// The labels are the ones getcash uses, and `number` counts up from 1 per -/// source, so every account can be found again from the seed alone. -pub fn funding_account_index( +/// The label of the `number`th account of `kind` for `source_id`: +/// `onramp:eph::`, `onramp:rf::` or +/// `wd:eph::`, the labels getcash uses. `number` counts up from 1 +/// per source, so every account can be found again from the seed alone. +pub fn funding_account_label( kind: FundingAccountKind, source_id: &str, number: u32, -) -> Result<[u8; 32], FundingAccountLabelTooLong> { +) -> Result { let prefix = match kind { FundingAccountKind::Deposit => "onramp:eph", FundingAccountKind::Refund => "onramp:rf", FundingAccountKind::Withdrawal => "wd:eph", }; let label = format!("{prefix}:{source_id}:{number}"); - let mut index = [0u8; 32]; - index - .get_mut(..label.len()) - .ok_or(FundingAccountLabelTooLong)? - .copy_from_slice(label.as_bytes()); - Ok(index) + (label.len() <= 32) + .then_some(label) + .ok_or(FundingAccountError::LabelTooLong) +} + +/// The keypair of the `number`th account of `kind` for `source_id`, derived +/// as getcash derives its burners: the funding product's `deriveEntropy` for +/// the account's label, taken as a mini secret. +pub fn funding_keypair( + root_entropy: &[u8], + funding_product_id: &str, + kind: FundingAccountKind, + source_id: &str, + number: u32, +) -> Result { + let label = funding_account_label(kind, source_id, number)?; + let derivation = |err: &dyn core::fmt::Display| FundingAccountError::Derivation(err.to_string()); + let entropy = derive_product_entropy(root_entropy, funding_product_id, label.as_bytes()) + .map_err(|err| derivation(&err))?; + derive_root_keypair_from_entropy(&entropy).map_err(|err| derivation(&err)) } /// Why a session operation failed. @@ -850,32 +870,56 @@ mod tests { ); } - // Funds sit in these accounts, so an index that drifts between releases - // strands them. The bytes are pinned to the labels getcash uses. + // Funds sit in these accounts, so a label that drifts between releases + // strands them. The labels are getcash's, byte for byte, unpadded. #[test] - fn funding_account_indices_are_the_padded_getcash_labels() { - let padded = |label: &str| { - let mut index = [0u8; 32]; - index[..label.len()].copy_from_slice(label.as_bytes()); - index - }; - + fn funding_account_labels_are_getcash_labels() { assert_eq!( [ - funding_account_index(FundingAccountKind::Deposit, "usdt-assethub", 1), - funding_account_index(FundingAccountKind::Refund, "btc", 2), - funding_account_index(FundingAccountKind::Withdrawal, "dot-assethub", 3), - funding_account_index(FundingAccountKind::Deposit, "x".repeat(40).as_str(), 1), + funding_account_label(FundingAccountKind::Deposit, "usdt-assethub", 1), + funding_account_label(FundingAccountKind::Refund, "btc", 2), + funding_account_label(FundingAccountKind::Withdrawal, "dot-assethub", 3), + funding_account_label(FundingAccountKind::Deposit, "x".repeat(40).as_str(), 1), ], [ - Ok(padded("onramp:eph:usdt-assethub:1")), - Ok(padded("onramp:rf:btc:2")), - Ok(padded("wd:eph:dot-assethub:3")), - Err(FundingAccountLabelTooLong), + Ok("onramp:eph:usdt-assethub:1".to_string()), + Ok("onramp:rf:btc:2".to_string()), + Ok("wd:eph:dot-assethub:3".to_string()), + Err(FundingAccountError::LabelTooLong), ] ); } + // getcash turns 32 bytes of entropy into its burner with + // `entropyToMiniSecret` and `sr25519CreateDerive(mini)("")`. The vector is + // from those libraries for entropy `[7; 32]`, so the same seed reaches the + // same account through either implementation. + #[test] + fn a_funding_key_is_the_one_getcash_derives_from_the_same_entropy() { + let root = [9u8; 32]; + let entropy = derive_product_entropy(&root, "fund.dot", b"onramp:eph:usdt-assethub:1") + .expect("entropy"); + + assert_eq!( + ( + hex::encode( + derive_root_keypair_from_entropy(&[7; 32]) + .expect("key") + .public + .to_bytes() + ), + funding_keypair(&root, "fund.dot", FundingAccountKind::Deposit, "usdt-assethub", 1) + .map(|keypair| keypair.public), + ), + ( + "ae78b88f68f8a3391cd7d1a8908766e1d068b2c1db6244a373e8b643e49d085f".to_string(), + derive_root_keypair_from_entropy(&entropy).map(|keypair| keypair.public).map_err(|err| { + FundingAccountError::Derivation(err.to_string()) + }), + ) + ); + } + #[test] fn storing_nothing_clears_the_slot() { let storage = stub_platform(); diff --git a/rust/crates/truapi/src/host_logic/product_account.rs b/rust/crates/truapi/src/host_logic/product_account.rs index 782e7e6f4c..c008e804ec 100644 --- a/rust/crates/truapi/src/host_logic/product_account.rs +++ b/rust/crates/truapi/src/host_logic/product_account.rs @@ -29,9 +29,9 @@ pub const IDENTITY_LABEL: &str = "uid"; /// domain holds the full and light person keys; the product id is /// `peopl.`, see [`personhood_product_id`]. pub const PERSONHOOD_LABEL: &str = "peopl"; -/// Reserved dotNS label of the funding modality, under whose subtree every -/// funding session's deposit account lives; the product id is -/// `fund.`, see [`funding_product_id`]. +/// Reserved dotNS label of the funding modality, whose entropy every funding +/// account is derived from; the product id is `fund.`, see +/// [`funding_product_id`]. pub const FUNDING_LABEL: &str = "fund"; const RING_VRF_ROOT_KEY: &[u8] = b"ring-vrf"; diff --git a/rust/crates/truapi/src/runtime/capabilities/resources.rs b/rust/crates/truapi/src/runtime/capabilities/resources.rs index e675d6be73..5a68ae02e0 100644 --- a/rust/crates/truapi/src/runtime/capabilities/resources.rs +++ b/rust/crates/truapi/src/runtime/capabilities/resources.rs @@ -99,6 +99,11 @@ impl Entropy for ProductRuntimeHost { }, ))); }; + // The funding accounts are the funding product's entropy for their + // labels, so no product under that name may derive it. + if crate::runtime::is_funding_product(&self.product_id()) { + return Err(CallError::Denied); + } let entropy = self .authority .derive_entropy(&session, &self.product_id(), &context) diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs index b00717bfa4..2413b29172 100644 --- a/rust/crates/truapi/src/runtime/funding.rs +++ b/rust/crates/truapi/src/runtime/funding.rs @@ -34,10 +34,8 @@ use conversion::Prepared; pub use conversion::{FundingNetwork, FundingSigner}; use super::services::RuntimeServices; -use parity_scale_codec::Decode; -use sp_crypto_hashing::twox_128; -use super::statement_allowance::blake2_128_concat; +use super::statement_allowance::ChainClient; use super::statement_allowance::rpc::RpcClient; use crate::host_logic::features; use crate::host_logic::funding::{ @@ -517,7 +515,11 @@ async fn plan_conversion( return Ok(Some(PlannedStep::Record(ConversionStep::Landed { landed }))); } let step = if chains.nonce(account).await? > submission.nonce { - if chains.deposit_balance(deposit.asset, account).await? >= submission.spent { + let held = chains + .balance(deposit.asset, account) + .await + .map_err(|error| ConversionError::Chain(error.reason))?; + if held >= submission.spent { Some(ConversionStep::Dropped) } else if now_ms.saturating_sub(submission.submitted_at_ms) > STALL_AFTER_MS { Some(ConversionStep::Stalled) @@ -563,60 +565,6 @@ pub trait DepositBalances: Send + Sync { ) -> BoxFuture<'a, Result>; } -/// Asset Hub balances read at one finalized block, so a deposit counts only -/// once it cannot be reverted. -struct FinalizedAssetHubBalances { - rpc: RpcClient, - finalized: String, -} - -impl FinalizedAssetHubBalances { - async fn connect(services: &RuntimeServices) -> Result { - within_chain_timeout(Self::connect_unbounded(services)).await? - } - - async fn connect_unbounded(services: &RuntimeServices) -> Result { - let failed = |reason: String| GenericError { reason }; - let chains = features::supported_chains(services.platform.as_ref()).await?; - let genesis = features::genesis_for(&chains, ChainIdentifier::AssetHub) - .ok_or_else(|| failed("the host serves no Asset Hub".into()))?; - let rpc = RpcClient::new(subxt_rpcs::RpcClient::new( - services - .chain - .rpc_client("funding deposit watch", &genesis) - .await - .map_err(|err| failed(err.to_string()))?, - )); - let finalized = rpc - .finalized_head() - .await - .map_err(|err| failed(err.to_string()))?; - Ok(Self { rpc, finalized }) - } -} - -impl DepositBalances for FinalizedAssetHubBalances { - fn balance<'a>( - &'a self, - asset: DepositAsset, - account: &'a [u8; 32], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - let value = within_chain_timeout( - self.rpc - .get_storage_at(&balance_key(asset, account), &self.finalized), - ) - .await? - .map_err(|err| GenericError { - reason: err.to_string(), - })?; - decode_balance(asset, value.as_deref()).ok_or_else(|| GenericError { - reason: "undecodable deposit balance".into(), - }) - }) - } -} - /// Run a chain read, giving up after [`CHAIN_TIMEOUT`] so a stalled /// connection cannot park the deposit watch. async fn within_chain_timeout(read: impl Future) -> Result { @@ -636,39 +584,6 @@ async fn within_timeout(limit: Duration, work: impl Future) -> Re } } -/// Asset Hub storage key holding `account`'s balance of `asset`: -/// `System.Account` for the native token, `Assets.Account` otherwise. -fn balance_key(asset: DepositAsset, account: &[u8; 32]) -> Vec { - match asset { - DepositAsset::Native => [ - twox_128(b"System").as_slice(), - &twox_128(b"Account"), - &blake2_128_concat(account), - ] - .concat(), - DepositAsset::Asset(id) => [ - twox_128(b"Assets").as_slice(), - &twox_128(b"Account"), - &blake2_128_concat(&id.to_le_bytes()), - &blake2_128_concat(account), - ] - .concat(), - } -} - -/// The balance in a value read from [`balance_key`]. An absent value is a -/// zero balance. The native balance is the free balance after -/// `AccountInfo`'s four `u32` counters; an asset account leads with it. -fn decode_balance(asset: DepositAsset, value: Option<&[u8]>) -> Option { - let Some(mut value) = value else { - return Some(0); - }; - if asset == DepositAsset::Native { - value = value.get(16..)?; - } - u128::decode(&mut value).ok() -} - /// Why a deposit account could not be assigned. #[derive(Debug, derive_more::Display)] pub enum AssignDepositError { @@ -791,14 +706,11 @@ impl RuntimeServices { .await .map_err(|error| AssignDepositError::Chain(GenericError { reason: error.to_string() }))? .ok_or(AssignDepositError::NoRoute)?; - let balances = FinalizedAssetHubBalances::connect(self) - .await - .map_err(AssignDepositError::Chain)?; let account = self .funding() .assign_empty_deposit( self.platform.as_ref(), - &balances, + &chains, current_unix_millis(), intent, DepositPlan { request, route }, @@ -824,24 +736,9 @@ impl RuntimeServices { let Some(services) = services.upgrade() else { return; }; - let observed = match FinalizedAssetHubBalances::connect(&services).await { - Ok(balances) => services - .funding() - .observe_deposits( - services.platform.as_ref(), - current_unix_millis(), - &balances, - ) - .await - .map_err(|error| error.to_string()), - Err(error) => Err(error.reason), - }; - if let Err(reason) = observed { + if let Err(reason) = services.advance_chain_sessions().await { tracing::warn!(%reason, "funding deposit watch failed"); } - if let Err(reason) = services.advance_conversions().await { - tracing::warn!(%reason, "funding conversion pass failed"); - } if let Err(reason) = services.advance_credits().await { tracing::warn!(%reason, "funding credit pass failed"); } @@ -855,20 +752,29 @@ impl RuntimeServices { let chains = features::supported_chains(self.platform.as_ref()) .await .map_err(|error| ConversionError::Chain(error.reason))?; - let client = |chain: ChainIdentifier| { - let genesis = features::genesis_for(&chains, chain); - async move { - let genesis = genesis - .ok_or_else(|| ConversionError::Chain(format!("the host serves no {chain:?}")))?; - self.chain - .online_client(&genesis) - .await - .map_err(|error| ConversionError::Chain(error.to_string())) - } + let failed = |error: &dyn core::fmt::Display| ConversionError::Chain(error.to_string()); + let genesis = |chain: ChainIdentifier| { + features::genesis_for(&chains, chain) + .ok_or_else(|| ConversionError::Chain(format!("the host serves no {chain:?}"))) }; - let asset_hub = client(ChainIdentifier::AssetHub).await?; - let people = client(ChainIdentifier::People).await?; - Chains::at_finalized(&asset_hub, &people, network).await + let (asset_hub_genesis, people_genesis) = + (genesis(ChainIdentifier::AssetHub)?, genesis(ChainIdentifier::People)?); + let asset_hub = self.chain.online_client(&asset_hub_genesis).await.map_err(|e| failed(&e))?; + let people = self.chain.online_client(&people_genesis).await.map_err(|e| failed(&e))?; + // The signed-extension metadata comes from the per-chain cache the + // allowance path keeps, so signing never downloads it again. + let rpc = RpcClient::new(subxt_rpcs::RpcClient::new( + self.chain + .rpc_client("funding conversion", &asset_hub_genesis) + .await + .map_err(|e| failed(&e))?, + )); + let context = self + .chain_context + .get(&ChainClient::new(rpc, asset_hub_genesis)) + .await + .map_err(|e| failed(&e))?; + Chains::at_finalized(&asset_hub, &people, network, context.metadata).await }) .await .map_err(|error| ConversionError::Chain(error.reason))? @@ -917,23 +823,45 @@ impl RuntimeServices { Ok(()) } - /// One pass over the sessions being converted: record what landed, - /// what was dropped or stalled, and submit what is ready. - async fn advance_conversions(self: &Arc) -> Result<(), String> { + /// One pass over the sessions that need the chains: read the awaited + /// deposits, then advance the conversions, against one pair of finalized + /// blocks. + async fn advance_chain_sessions(self: &Arc) -> Result<(), String> { let registry = self.funding(); - let converting = registry.converting_sessions(); - let Some(conversion) = registry.conversion.get().filter(|_| !converting.is_empty()) else { + let Some(conversion) = registry.conversion.get() else { return Ok(()); }; + let pending = registry.lock_sessions().values().any(|session| { + session.awaited_deposit().is_some() || session.converting().is_some() + }); + if !pending { + return Ok(()); + } let chains = self .funding_chains(conversion.network) .await .map_err(|error| error.to_string())?; + registry + .observe_deposits(self.platform.as_ref(), current_unix_millis(), &chains) + .await + .map_err(|error| error.to_string())?; + self.advance_conversions(&chains, conversion).await + } + + /// One pass over the sessions being converted: record what landed, + /// what was dropped or stalled, and submit what is ready. + async fn advance_conversions( + self: &Arc, + chains: &Chains, + conversion: &Conversion, + ) -> Result<(), String> { + let registry = self.funding(); + let converting = registry.converting_sessions(); let storage = self.platform.as_ref(); for (intent, deposit, submission) in converting { let now_ms = current_unix_millis(); let planned = within_chain_timeout(plan_conversion( - &chains, + chains, conversion.signer.as_ref(), &deposit, submission, @@ -1065,7 +993,6 @@ mod tests { use super::*; use futures::executor::block_on; - use parity_scale_codec::Encode; use truapi::latest::FundingFailure; use crate::host_logic::funding::FundingStage; @@ -1402,51 +1329,6 @@ mod tests { ); } - // A wrong key reads an empty account forever and no deposit is ever - // seen, so the keys are pinned to the pallets' well-known prefixes. - #[test] - fn balance_keys_address_system_and_assets_accounts() { - let account = [7u8; 32]; - let hashed_account = [sp_crypto_hashing::blake2_128(&account).as_slice(), &account].concat(); - let hashed_id = [sp_crypto_hashing::blake2_128(&1984u32.to_le_bytes()).as_slice(), &1984u32.to_le_bytes()].concat(); - - assert_eq!( - ( - hex::encode(balance_key(DepositAsset::Native, &account)), - hex::encode(balance_key(USDT, &account)), - ), - ( - format!( - "26aa394eea5630e07c48ae0c9558cef7b99d880ec681799c0cf30e8886371da9{}", - hex::encode(&hashed_account) - ), - format!( - "682a59d51ab9e48a8c8cc418ff9708d2b99d880ec681799c0cf30e8886371da9{}{}", - hex::encode(hashed_id), - hex::encode(&hashed_account) - ), - ) - ); - } - - // The native balance sits behind `AccountInfo`'s counters, while an asset - // account leads with it; reading the wrong offset would see a counter. - #[test] - fn balances_decode_from_each_account_layout() { - let account_info = (1u32, 2u32, 3u32, 4u32, 500u128, 9u128).encode(); - let asset_account = (70u128, 0u8).encode(); - - assert_eq!( - [ - decode_balance(DepositAsset::Native, Some(&account_info)), - decode_balance(USDT, Some(&asset_account)), - decode_balance(USDT, None), - decode_balance(DepositAsset::Native, Some(&account_info[..12])), - ], - [Some(500), Some(70), Some(0), None] - ); - } - /// Chains answering fixed reads, and preparing a fixed conversion. struct Scripted { landed: u128, @@ -1462,6 +1344,16 @@ mod tests { spent: 50, }; + impl DepositBalances for Scripted { + fn balance<'a>( + &'a self, + _: DepositAsset, + _: &'a [u8; 32], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { Ok(self.balance) }) + } + } + impl ConversionChains for Scripted { fn landed<'a>(&'a self, _: &'a [u8; 32]) -> BoxFuture<'a, Result> { Box::pin(async move { Ok(self.landed) }) @@ -1471,14 +1363,6 @@ mod tests { Box::pin(async move { Ok(self.nonce) }) } - fn deposit_balance<'a>( - &'a self, - _: DepositAsset, - _: &'a [u8; 32], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { Ok(self.balance) }) - } - fn finalized_block(&self) -> u64 { self.block } diff --git a/rust/crates/truapi/src/runtime/funding/conversion.rs b/rust/crates/truapi/src/runtime/funding/conversion.rs index 1fb81e7c37..cc6e7dd83f 100644 --- a/rust/crates/truapi/src/runtime/funding/conversion.rs +++ b/rust/crates/truapi/src/runtime/funding/conversion.rs @@ -18,6 +18,9 @@ use futures::future::BoxFuture; use truapi::latest::{GenericError, TxPayloadExtension}; use crate::host_internal::extrinsic::{Sr25519Signer, build_signed_extrinsic_v4}; +use std::sync::Arc; + +use super::DepositBalances; use crate::host_logic::funding::{ConversionRoute, DepositAsset, FundingDeposit}; use crate::runtime::statement_allowance::extension::{ChainState, Metadata as ExtensionMetadata}; @@ -67,6 +70,7 @@ pub struct Chains { asset_hub: OnlineClientAtBlock, people: OnlineClientAtBlock, places: Places, + extensions: Arc, } /// A signed conversion, with what tells later whether it worked. @@ -83,17 +87,11 @@ pub struct Prepared { } /// What a conversion pass reads and does on the chains. -pub trait ConversionChains: Send + Sync { +pub trait ConversionChains: DepositBalances { /// CASH `account` holds on People. fn landed<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result>; /// `account`'s next nonce on Asset Hub. fn nonce<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result>; - /// `account`'s balance of `asset` on Asset Hub. - fn deposit_balance<'a>( - &'a self, - asset: DepositAsset, - account: &'a [u8; 32], - ) -> BoxFuture<'a, Result>; /// The finalized Asset Hub block these reads are pinned to. fn finalized_block(&self) -> u64; /// Size, dry-run and sign the conversion of `deposit` at `nonce`. @@ -137,6 +135,7 @@ impl Chains { asset_hub: &subxt::OnlineClient, people: &subxt::OnlineClient, network: FundingNetwork, + extensions: Arc, ) -> Result { let asset_hub = asset_hub.at_current_block().await.map_err(chain)?; let people = people.at_current_block().await.map_err(chain)?; @@ -156,24 +155,10 @@ impl Chains { people_para, assets_pallet, }, + extensions, }) } - /// Asset Hub's transaction extensions, as the extension encoder reads - /// them. Loaded only to sign, since it downloads the whole metadata. - async fn extensions(&self) -> Result { - let opaque = self - .asset_hub - .runtime_apis() - .call_raw("Metadata_metadata_at_version", Some(&15u32.encode())) - .await - .map_err(chain)?; - let raw = Option::>::decode(&mut &opaque[..]) - .map_err(chain)? - .ok_or_else(|| chain("Asset Hub serves no V15 metadata"))?; - ExtensionMetadata::decode(&raw).map_err(chain) - } - /// The route for `expected` of `asset`: a teleport for CASH, a PSM mint /// for a stablecoin the PSM serves with room to spare. pub async fn choose_route( @@ -385,7 +370,6 @@ impl Chains { return Err(ConversionError::Refused("the PSM mints only from assets".into())); } }; - let extensions = self.extensions().await?; // A first draft with a generous fee allowance measures the fees, // which barely depend on the amounts; the final call is then sized @@ -396,7 +380,7 @@ impl Chains { let forwarded = self.dry_run(&account, &draft).await?; let local_fee = self.local_fee(&draft.program, &fee_asset).await?; let delivery_fee = self.delivery_fee(&forwarded, &fee_asset).await?; - let draft_extrinsic = self.sign(&extensions, &signer, &draft, &fee_asset, nonce)?; + let draft_extrinsic = self.sign(&self.extensions, &signer, &draft, &fee_asset, nonce)?; let dispatch_fee = self.dispatch_fee(&draft_extrinsic, &fee_asset).await?; let allowance = with_margin(local_fee.saturating_add(delivery_fee)); @@ -411,7 +395,7 @@ impl Chains { let forwarded = self.dry_run(&account, &call).await?; self.dry_run_on_people(&forwarded).await?; Ok(Prepared { - extrinsic: self.sign(&extensions, &signer, &call, &fee_asset, nonce)?, + extrinsic: self.sign(&self.extensions, &signer, &call, &fee_asset, nonce)?, valid_until_block: self.asset_hub.block_number() + MORTAL_PERIOD_BLOCKS, landing: call.landing, spent: call.spent, @@ -804,6 +788,22 @@ struct PsmMint { max_fee_ppm: u32, } +impl DepositBalances for Chains { + fn balance<'a>( + &'a self, + asset: DepositAsset, + account: &'a [u8; 32], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.asset_hub_balance(asset, account) + .await + .map_err(|error| GenericError { + reason: error.to_string(), + }) + }) + } +} + impl ConversionChains for Chains { fn landed<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result> { Box::pin(self.people_cash(account)) @@ -813,14 +813,6 @@ impl ConversionChains for Chains { Box::pin(self.account_nonce(account)) } - fn deposit_balance<'a>( - &'a self, - asset: DepositAsset, - account: &'a [u8; 32], - ) -> BoxFuture<'a, Result> { - Box::pin(self.asset_hub_balance(asset, account)) - } - fn finalized_block(&self) -> u64 { self.asset_hub.block_number() } @@ -1262,9 +1254,20 @@ mod live { } async fn chains() -> Chains { - Chains::at_finalized(&client(ASSET_HUB).await, &client(PEOPLE).await, NETWORK) + let rpc = crate::runtime::statement_allowance::rpc::RpcClient::connect(ASSET_HUB) + .await + .expect("node reachable"); + let extensions = crate::runtime::statement_allowance::fetch_metadata(&rpc) .await - .expect("chains pinned") + .expect("metadata"); + Chains::at_finalized( + &client(ASSET_HUB).await, + &client(PEOPLE).await, + NETWORK, + Arc::new(extensions), + ) + .await + .expect("chains pinned") } /// An account holding at least `least` of asset `id` on Asset Hub. diff --git a/rust/crates/truapi/src/runtime/signing_host.rs b/rust/crates/truapi/src/runtime/signing_host.rs index 0bd46428f2..5b89290974 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -56,10 +56,10 @@ use crate::host_internal::sso_messages::{OnExistingAllowancePolicy, ProductReque use crate::host_internal::transaction::sign_extrinsic_payload; use crate::host_logic::entropy::derive_product_entropy; use crate::host_logic::features::genesis_for; -use crate::host_logic::funding::{FundingAccountKind, funding_account_index}; +use crate::host_logic::funding::{FundingAccountKind, funding_keypair}; use crate::host_logic::product_account::{ ProductAccountError, SR25519_SIGNING_CONTEXT, derivation_index_bytes, derive_identity_keypair, - derive_product_keypair, derive_product_public_key, derive_product_subtree_keypair, + derive_product_keypair, derive_product_subtree_keypair, derive_ring_vrf_entropy, derive_root_keypair_from_entropy, funding_product_id, personhood_product_id, }; @@ -511,55 +511,45 @@ impl SigningHost { /// `source_id`, under the reserved funding product. `None` while no /// signing session is active. /// - /// The core credits what lands on it by handing the account's key to the - /// host's top-up as a `PrivateKey` source; the key is the product account - /// at [`funding_account_index`] for the same arguments. + /// The account is the one getcash derives for the same label: the + /// funding product's entropy for it, taken as a mini secret. The core + /// credits what lands on it by handing its key to the host's top-up as a + /// `PrivateKey` source. pub fn derive_funding_account( &self, kind: FundingAccountKind, source_id: &str, number: u32, ) -> Result, AuthorityError> { - let index = funding_account_index(kind, source_id, number).map_err(|err| { - AuthorityError::Unavailable { - reason: err.to_string(), - } - })?; - let Some(subtree) = self.derive_subtree_public_key(&funding_product_id(&self.network_suffix))? - else { - return Ok(None); - }; - derive_product_public_key(subtree, index) - .map(Some) - .map_err(product_authority_error) + self.funding_keypair(kind, source_id, number) + .map(|keypair| keypair.map(|keypair| keypair.public.to_bytes())) } - /// Keypair of a funding deposit account, for the core's own conversion. - /// Products never reach it: their requests derive through - /// `product_keypair_with_owner`, which refuses the funding product. - fn funding_deposit_keypair( + /// Keypair of a funding account, for the core's own conversion and + /// crediting. Products never reach it: `derive_entropy` refuses the + /// funding product. + fn funding_keypair( &self, + kind: FundingAccountKind, source_id: &str, number: u32, ) -> Result, AuthorityError> { - let index = funding_account_index(FundingAccountKind::Deposit, source_id, number).map_err( - |err| AuthorityError::Unavailable { - reason: err.to_string(), - }, - )?; let entropy = match self.root_entropy() { Ok(entropy) => entropy, Err(AuthorityError::Disconnected) => return Ok(None), Err(err) => return Err(err), }; - let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; - derive_product_keypair( - &root, + funding_keypair( + &entropy, &funding_product_id(&self.network_suffix), - derivation_index_bytes(&v01::DerivationIndex::Raw(index)), + kind, + source_id, + number, ) .map(Some) - .map_err(product_authority_error) + .map_err(|err| AuthorityError::Unavailable { + reason: err.to_string(), + }) } /// Derive the product-account keypair for `account` from the root entropy. @@ -1685,6 +1675,15 @@ impl ProductAuthority for SigningHost { context: &[u8], ) -> Result<[u8; 32], AuthorityError> { self.require_current_session(session)?; + // The funding accounts are the funding product's entropy for their + // labels, so it is never derived on a request's behalf. + let funding = normalize_product_identifier(product_id) + .map_or(super::is_funding_product(product_id), |id| { + super::is_funding_product(&id) + }); + if funding { + return Err(AuthorityError::Rejected); + } let entropy = self.root_entropy()?; derive_product_entropy(&entropy, product_id, context).map_err(|err| { AuthorityError::Unknown { @@ -1724,7 +1723,7 @@ impl super::FundingSigner for SigningHost { source_id: &str, number: u32, ) -> Result, GenericError> { - self.funding_deposit_keypair(source_id, number) + self.funding_keypair(FundingAccountKind::Deposit, source_id, number) .map_err(|err| GenericError { reason: err.to_string(), }) @@ -1761,7 +1760,7 @@ mod tests { extrinsic_payload_extensions, extrinsic_payload_preimage, }; use crate::host_logic::product_account::{ - derivation_index_bytes, derive_identity_keypair, derive_product_keypair, derive_ring_vrf_entropy, + derive_identity_keypair, derive_product_keypair, derive_ring_vrf_entropy, derive_root_keypair_from_entropy, index_bytes, }; use crate::platform::{HostInfo, Platform, PlatformInfo, ProductContext, SigningHostConfig}; @@ -3305,31 +3304,32 @@ mod tests { ); } - // The address shown to a provider must be the account the host later - // claims with the matching product-account key, under the `fund.` product - // the mobile hosts already reserve. + // A funding account is what getcash's derivation gives for its label: + // the funding product's `deriveEntropy`, taken as a mini secret. Since + // that entropy is the key, no request may derive it. #[test] - fn a_funding_account_is_the_fund_products_account_at_its_label() { + fn a_funding_account_is_the_funding_products_entropy_for_its_label() { let (_services, authority) = signing_runtime(); let before = authority.derive_funding_account(FundingAccountKind::Deposit, "usdt-assethub", 1); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); + let session = authority.current_session().expect("active session"); + let funding_product = format!("fund.{TEST_NETWORK_SUFFIX}"); - let mut label = [0u8; 32]; - label[..26].copy_from_slice(b"onramp:eph:usdt-assethub:1"); - let root = derive_root_keypair_from_entropy(&ENTROPY).expect("root derives"); - let claimable = derive_product_keypair( - &root, - &format!("fund.{TEST_NETWORK_SUFFIX}"), - derivation_index_bytes(&v01::DerivationIndex::Raw(label)), + let entropy = crate::host_logic::entropy::derive_product_entropy( + &ENTROPY, + &funding_product, + b"onramp:eph:usdt-assethub:1", ) - .expect("deposit key derives"); + .expect("entropy"); + let expected = derive_root_keypair_from_entropy(&entropy).expect("key").public.to_bytes(); assert_eq!( ( before, - authority.derive_funding_account(FundingAccountKind::Deposit, "usdt-assethub", 1) + authority.derive_funding_account(FundingAccountKind::Deposit, "usdt-assethub", 1), + authority.derive_entropy(&session, &funding_product, b"onramp:eph:usdt-assethub:1"), ), - (Ok(None), Ok(Some(claimable.public.to_bytes()))) + (Ok(None), Ok(Some(expected)), Err(AuthorityError::Rejected)) ); } diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index e9d00be046..ddbdffe9b8 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -4376,6 +4376,25 @@ fn derive_entropy_matches_dotli_vector() { ); } +// Funding accounts are the funding product's entropy for their getcash +// labels, so a product under that name deriving entropy would hold their +// keys; a paired host refuses it as the signing host does. +#[test] +fn no_product_derives_entropy_as_the_funding_product() { + let host = ProductRuntimeHost::new(stub_platform(), runtime_config("fund.dot"), test_spawner()); + let mut session = sso_session_info(); + session.root_entropy_source = session_info().root_entropy_source; + install_pairing_session(&host, session); + let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { + context: b"onramp:eph:usdt-assethub:1".to_vec(), + }); + + assert_eq!( + futures::executor::block_on(host.derive(&CallContext::default(), request)), + Err(CallError::Denied) + ); +} + #[test] fn derive_entropy_requires_session() { let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); From cb5a95527ed3202ff1e408ccf4b97e724657915a Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 5 Oct 2026 16:16:18 +0200 Subject: [PATCH 08/20] fix(truapi): bound funding reads and load signing metadata only to sign --- rust/crates/truapi/src/host_logic/funding.rs | 53 ++++++------ rust/crates/truapi/src/runtime.rs | 5 +- rust/crates/truapi/src/runtime/funding.rs | 86 +++++++++++++------ .../truapi/src/runtime/funding/conversion.rs | 49 +++++++---- .../crates/truapi/src/runtime/pairing_host.rs | 3 + 5 files changed, 124 insertions(+), 72 deletions(-) diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index 964c23584c..5b0176f4f3 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -13,8 +13,8 @@ use parity_scale_codec::{Decode, Encode}; use tracing::warn; use truapi::latest::{FundingDirection, FundingFailure, HostFundingStatusSubscribeItem}; -use crate::host_logic::entropy::derive_product_entropy; -use crate::host_logic::product_account::derive_root_keypair_from_entropy; +use crate::host_logic::entropy::{ProductEntropyError, derive_product_entropy}; +use crate::host_logic::product_account::{ProductAccountError, derive_root_keypair_from_entropy}; use crate::platform::{CoreStorage, CoreStorageKey}; /// How long a session may stay open before it expires. @@ -472,14 +472,17 @@ pub enum FundingAccountKind { } /// Why a funding account could not be derived. -#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] +#[derive(Debug, PartialEq, Eq, derive_more::Display, derive_more::Error)] pub enum FundingAccountError { /// The label is longer than the 32 bytes entropy derivation takes. #[display("funding account label is longer than 32 bytes")] LabelTooLong, - /// The key could not be derived. + /// The entropy could not be derived. + #[display("{_0}")] + Entropy(ProductEntropyError), + /// The key could not be derived from the entropy. #[display("{_0}")] - Derivation(#[error(not(source))] String), + Key(ProductAccountError), } /// The label of the `number`th account of `kind` for `source_id`: @@ -513,10 +516,9 @@ pub fn funding_keypair( number: u32, ) -> Result { let label = funding_account_label(kind, source_id, number)?; - let derivation = |err: &dyn core::fmt::Display| FundingAccountError::Derivation(err.to_string()); let entropy = derive_product_entropy(root_entropy, funding_product_id, label.as_bytes()) - .map_err(|err| derivation(&err))?; - derive_root_keypair_from_entropy(&entropy).map_err(|err| derivation(&err)) + .map_err(FundingAccountError::Entropy)?; + derive_root_keypair_from_entropy(&entropy).map_err(FundingAccountError::Key) } /// Why a session operation failed. @@ -890,36 +892,33 @@ mod tests { ); } - // getcash turns 32 bytes of entropy into its burner with - // `entropyToMiniSecret` and `sr25519CreateDerive(mini)("")`. The vector is - // from those libraries for entropy `[7; 32]`, so the same seed reaches the - // same account through either implementation. + // getcash turns its `deriveEntropy(label)` into the burner with + // `entropyToMiniSecret` and `sr25519CreateDerive(mini)("")`. The keys are + // from those libraries, fed the entropy core's `deriveEntropy` gives + // `fund.dot` for the label (itself pinned to dotli's vector), so the same + // root reaches the same account through either implementation. #[test] fn a_funding_key_is_the_one_getcash_derives_from_the_same_entropy() { - let root = [9u8; 32]; - let entropy = derive_product_entropy(&root, "fund.dot", b"onramp:eph:usdt-assethub:1") - .expect("entropy"); + let key = |root: &[u8]| { + funding_keypair(root, "fund.dot", FundingAccountKind::Deposit, "usdt-assethub", 1) + .map(|keypair| hex::encode(keypair.public.to_bytes())) + }; assert_eq!( ( - hex::encode( - derive_root_keypair_from_entropy(&[7; 32]) - .expect("key") - .public - .to_bytes() - ), - funding_keypair(&root, "fund.dot", FundingAccountKind::Deposit, "usdt-assethub", 1) - .map(|keypair| keypair.public), + derive_root_keypair_from_entropy(&[7; 32]) + .map(|keypair| hex::encode(keypair.public.to_bytes())), + key(&[9; 32]), ), ( - "ae78b88f68f8a3391cd7d1a8908766e1d068b2c1db6244a373e8b643e49d085f".to_string(), - derive_root_keypair_from_entropy(&entropy).map(|keypair| keypair.public).map_err(|err| { - FundingAccountError::Derivation(err.to_string()) - }), + Ok("ae78b88f68f8a3391cd7d1a8908766e1d068b2c1db6244a373e8b643e49d085f".to_string()), + Ok("fefa1fc85ecec3e8efa2cf47672fe85220dfa74c4aeda155b673f414b141b054".to_string()), ) ); } + + #[test] fn storing_nothing_clears_the_slot() { let storage = stub_platform(); diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 7ab4282c08..899524e089 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -858,7 +858,10 @@ impl ProductRuntimeHost { } /// Whether `product_id` is the reserved funding product or a subname of it. -/// Its accounts hold users' funds in transit, so only the host derives them. +/// Its entropy is the key to every funding account, which holds users' funds +/// in transit, so no product derives it or uses its accounts. Paired hosts +/// hold the root entropy source and can compute it, as getcash's burners +/// always could be: the host is trusted with funding keys, products are not. fn is_funding_product(product_id: &str) -> bool { bare_product_label(product_id) == FUNDING_LABEL } diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs index 2413b29172..cdca33f304 100644 --- a/rust/crates/truapi/src/runtime/funding.rs +++ b/rust/crates/truapi/src/runtime/funding.rs @@ -35,7 +35,7 @@ pub use conversion::{FundingNetwork, FundingSigner}; use super::services::RuntimeServices; -use super::statement_allowance::ChainClient; +use super::statement_allowance::{ChainClient, ChainContext}; use super::statement_allowance::rpc::RpcClient; use crate::host_logic::features; use crate::host_logic::funding::{ @@ -698,7 +698,7 @@ impl RuntimeServices { .ok_or(AssignDepositError::ConversionUnavailable)? .network; let chains = self - .funding_chains(network) + .funding_chains(network, false) .await .map_err(|error| AssignDepositError::Chain(GenericError { reason: error.to_string() }))?; let route = chains @@ -746,40 +746,63 @@ impl RuntimeServices { })); } - /// Asset Hub and People, pinned at their latest finalized blocks. - async fn funding_chains(&self, network: FundingNetwork) -> Result { + /// Asset Hub and People, pinned at their latest finalized blocks, with the + /// signed-extension metadata when `signing`. + async fn funding_chains( + &self, + network: FundingNetwork, + signing: bool, + ) -> Result { within_chain_timeout(async { + let failed = |error: &dyn core::fmt::Display| ConversionError::Chain(error.to_string()); let chains = features::supported_chains(self.platform.as_ref()) .await .map_err(|error| ConversionError::Chain(error.reason))?; - let failed = |error: &dyn core::fmt::Display| ConversionError::Chain(error.to_string()); let genesis = |chain: ChainIdentifier| { features::genesis_for(&chains, chain) .ok_or_else(|| ConversionError::Chain(format!("the host serves no {chain:?}"))) }; let (asset_hub_genesis, people_genesis) = (genesis(ChainIdentifier::AssetHub)?, genesis(ChainIdentifier::People)?); - let asset_hub = self.chain.online_client(&asset_hub_genesis).await.map_err(|e| failed(&e))?; - let people = self.chain.online_client(&people_genesis).await.map_err(|e| failed(&e))?; - // The signed-extension metadata comes from the per-chain cache the - // allowance path keeps, so signing never downloads it again. - let rpc = RpcClient::new(subxt_rpcs::RpcClient::new( - self.chain - .rpc_client("funding conversion", &asset_hub_genesis) - .await - .map_err(|e| failed(&e))?, - )); - let context = self - .chain_context - .get(&ChainClient::new(rpc, asset_hub_genesis)) + let asset_hub = self + .chain + .online_client(&asset_hub_genesis) + .await + .map_err(|error| failed(&error))?; + let people = self + .chain + .online_client(&people_genesis) .await - .map_err(|e| failed(&e))?; - Chains::at_finalized(&asset_hub, &people, network, context.metadata).await + .map_err(|error| failed(&error))?; + let extensions = match signing { + true => Some(self.signing_metadata(asset_hub_genesis).await?), + false => None, + }; + Chains::at_finalized(&asset_hub, &people, network, extensions).await }) .await .map_err(|error| ConversionError::Chain(error.reason))? } + /// Asset Hub'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 { + 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) + .await + .map_err(|error| failed(&error))?, + )); + self.chain_context + .get(&ChainClient::new(rpc, asset_hub_genesis)) + .await + .map_err(|error| failed(&error)) + } + /// One pass over the sessions whose CASH landed: register top-ups and /// record what they credited. Waits while the host has no top-up. async fn advance_credits(self: &Arc) -> Result<(), String> { @@ -831,20 +854,29 @@ impl RuntimeServices { let Some(conversion) = registry.conversion.get() else { return Ok(()); }; - let pending = registry.lock_sessions().values().any(|session| { - session.awaited_deposit().is_some() || session.converting().is_some() - }); + let (pending, signing) = { + let sessions = registry.lock_sessions(); + let pending = sessions.values().any(|session| { + session.awaited_deposit().is_some() || session.converting().is_some() + }); + let signing = sessions + .values() + .any(|session| matches!(session.converting(), Some((_, None)))); + (pending, signing) + }; if !pending { return Ok(()); } let chains = self - .funding_chains(conversion.network) + .funding_chains(conversion.network, signing) .await .map_err(|error| error.to_string())?; - registry + let observed = registry .observe_deposits(self.platform.as_ref(), current_unix_millis(), &chains) - .await - .map_err(|error| error.to_string())?; + .await; + if let Err(error) = observed { + tracing::warn!(%error, "recording funding deposits failed"); + } self.advance_conversions(&chains, conversion).await } diff --git a/rust/crates/truapi/src/runtime/funding/conversion.rs b/rust/crates/truapi/src/runtime/funding/conversion.rs index cc6e7dd83f..c11faf9be6 100644 --- a/rust/crates/truapi/src/runtime/funding/conversion.rs +++ b/rust/crates/truapi/src/runtime/funding/conversion.rs @@ -7,6 +7,8 @@ //! the transaction is dry-run on Asset Hub and the message it forwards is //! dry-run on People, so a conversion that would trap funds is never sent. +use std::sync::Arc; + use parity_scale_codec::{Decode, Encode}; use subxt::client::OnlineClientAtBlock; use subxt::config::substrate::SubstrateConfig; @@ -18,10 +20,9 @@ use futures::future::BoxFuture; use truapi::latest::{GenericError, TxPayloadExtension}; use crate::host_internal::extrinsic::{Sr25519Signer, build_signed_extrinsic_v4}; -use std::sync::Arc; - use super::DepositBalances; use crate::host_logic::funding::{ConversionRoute, DepositAsset, FundingDeposit}; +use crate::runtime::statement_allowance::ChainContext; use crate::runtime::statement_allowance::extension::{ChainState, Metadata as ExtensionMetadata}; /// XCM version every program and dry run uses. @@ -70,7 +71,8 @@ pub struct Chains { asset_hub: OnlineClientAtBlock, people: OnlineClientAtBlock, places: Places, - extensions: Arc, + /// Signed-extension metadata, loaded only when a conversion is signed. + extensions: Option>, } /// A signed conversion, with what tells later whether it worked. @@ -135,9 +137,18 @@ impl Chains { asset_hub: &subxt::OnlineClient, people: &subxt::OnlineClient, network: FundingNetwork, - extensions: Arc, + signing: Option, ) -> Result { let asset_hub = asset_hub.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 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?; @@ -356,6 +367,10 @@ impl Chains { let spendable = held .checked_sub(kept) .ok_or_else(|| ConversionError::Refused("the deposit is below the minimum balance".into()))?; + let extensions = self + .extensions + .as_deref() + .ok_or_else(|| chain("signing metadata was not loaded"))?; let mint = match (deposit.route, deposit.asset) { (ConversionRoute::Teleport, _) => None, (ConversionRoute::Psm { fee_ppm }, DepositAsset::Asset(id)) => Some(PsmMint { @@ -380,7 +395,7 @@ impl Chains { let forwarded = self.dry_run(&account, &draft).await?; let local_fee = self.local_fee(&draft.program, &fee_asset).await?; let delivery_fee = self.delivery_fee(&forwarded, &fee_asset).await?; - let draft_extrinsic = self.sign(&self.extensions, &signer, &draft, &fee_asset, nonce)?; + let draft_extrinsic = self.sign(extensions, &signer, &draft, &fee_asset, nonce)?; let dispatch_fee = self.dispatch_fee(&draft_extrinsic, &fee_asset).await?; let allowance = with_margin(local_fee.saturating_add(delivery_fee)); @@ -395,7 +410,7 @@ impl Chains { let forwarded = self.dry_run(&account, &call).await?; self.dry_run_on_people(&forwarded).await?; Ok(Prepared { - extrinsic: self.sign(&self.extensions, &signer, &call, &fee_asset, nonce)?, + extrinsic: self.sign(extensions, &signer, &call, &fee_asset, nonce)?, valid_until_block: self.asset_hub.block_number() + MORTAL_PERIOD_BLOCKS, landing: call.landing, spent: call.spent, @@ -795,8 +810,8 @@ impl DepositBalances for Chains { account: &'a [u8; 32], ) -> BoxFuture<'a, Result> { Box::pin(async move { - self.asset_hub_balance(asset, account) - .await + super::within_chain_timeout(self.asset_hub_balance(asset, account)) + .await? .map_err(|error| GenericError { reason: error.to_string(), }) @@ -1254,20 +1269,20 @@ mod live { } async fn chains() -> Chains { + let asset_hub = client(ASSET_HUB).await; let rpc = crate::runtime::statement_allowance::rpc::RpcClient::connect(ASSET_HUB) .await .expect("node reachable"); - let extensions = crate::runtime::statement_allowance::fetch_metadata(&rpc) + let context = crate::runtime::statement_allowance::ChainContextCache::default() + .get(&crate::runtime::statement_allowance::ChainClient::new( + rpc, + asset_hub.genesis_hash().0, + )) .await .expect("metadata"); - Chains::at_finalized( - &client(ASSET_HUB).await, - &client(PEOPLE).await, - NETWORK, - Arc::new(extensions), - ) - .await - .expect("chains pinned") + Chains::at_finalized(&asset_hub, &client(PEOPLE).await, NETWORK, Some(context)) + .await + .expect("chains pinned") } /// An account holding at least `least` of asset `id` on Asset Hub. diff --git a/rust/crates/truapi/src/runtime/pairing_host.rs b/rust/crates/truapi/src/runtime/pairing_host.rs index 663940814d..e8facf181a 100644 --- a/rust/crates/truapi/src/runtime/pairing_host.rs +++ b/rust/crates/truapi/src/runtime/pairing_host.rs @@ -2563,6 +2563,9 @@ impl PairingHost { if session.sso.is_none() { return Err(AuthorityError::Disconnected); } + if super::is_funding_product(product_id) { + return Err(AuthorityError::Rejected); + } let root_entropy_source = session .root_entropy_source From 95a0e3d85e2191dec97874c0b02027ec0e3b4095 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 5 Oct 2026 16:24:44 +0200 Subject: [PATCH 09/20] feat(truapi): quote funding deposits and read the PSM fee default --- .changeset/funding-deposit-sizing.md | 5 + .../truapi-host-cli/src/funding_check.rs | 16 +- rust/crates/truapi-host-cli/src/main.rs | 9 +- rust/crates/truapi/RUNTIME.md | 4 +- rust/crates/truapi/src/host_core.rs | 15 + rust/crates/truapi/src/host_logic/funding.rs | 58 +++ rust/crates/truapi/src/runtime/funding.rs | 127 +++++- .../truapi/src/runtime/funding/conversion.rs | 419 ++++++++++++++++-- .../truapi/src/runtime/funding/credit.rs | 2 +- 9 files changed, 583 insertions(+), 72 deletions(-) create mode 100644 .changeset/funding-deposit-sizing.md diff --git a/.changeset/funding-deposit-sizing.md b/.changeset/funding-deposit-sizing.md new file mode 100644 index 0000000000..31ed186653 --- /dev/null +++ b/.changeset/funding-deposit-sizing.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": patch +--- + +Signing hosts quote the deposit a funding session needs with `quote_funding_deposit`: enough to credit the session's amount after the fee on People, the PSM fee, transaction fees and the minimum balance (Rust API). Assigning a deposit account refuses a smaller expected deposit, and an unset PSM minting fee reads as the pallet's default. `funding-check` takes the CASH amount to credit. diff --git a/rust/crates/truapi-host-cli/src/funding_check.rs b/rust/crates/truapi-host-cli/src/funding_check.rs index 884864f0fd..3d1bb82dcf 100644 --- a/rust/crates/truapi-host-cli/src/funding_check.rs +++ b/rust/crates/truapi-host-cli/src/funding_check.rs @@ -97,8 +97,8 @@ pub struct FundingCheck { pub network: Network, /// Asset the deposit is paid in. pub asset: FundingAsset, - /// Balance that counts as delivered, in the asset's smallest units. - pub expected: u128, + /// CASH to credit, in its smallest units. + pub amount: u128, /// Where sessions and account counters persist between runs. pub state_dir: PathBuf, /// A session to follow instead of opening a new one. @@ -127,7 +127,7 @@ pub async fn run( let intent = match check.intent { Some(intent) => intent, - None => open_and_assign(&runtime, &assets, check.asset, check.expected).await?, + None => open_and_assign(&runtime, &assets, check.asset, check.amount).await?, }; follow(&runtime, &intent).await } @@ -137,14 +137,18 @@ async fn open_and_assign( runtime: &SigningHostRuntime, assets: &FundingAssets, asset: FundingAsset, - expected: u128, + amount: u128, ) -> Result { let (asset_id, source_id) = assets.source(asset); let intent = runtime - .open_funding(FundingDirection::In, Some(expected)) + .open_funding(FundingDirection::In, Some(amount)) .await .map_err(|error| anyhow::anyhow!("opening a session failed: {}", error.reason))? .context("the session was dismissed")?; + let expected = runtime + .quote_funding_deposit(&intent, DepositAsset::Asset(asset_id)) + .await + .map_err(|error| anyhow::anyhow!("quoting the deposit failed: {}", error.reason))?; let account = runtime .assign_funding_deposit( &intent, @@ -156,7 +160,7 @@ async fn open_and_assign( ) .await .map_err(|error| anyhow::anyhow!("assigning a deposit account failed: {}", error.reason))?; - println!("session {intent}"); + println!("session {intent}, crediting {amount} CASH units"); println!("pay {expected} of asset {asset_id} ({source_id}) on Asset Hub to"); println!( " {}", diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index ea16e8039b..43ff5189c1 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -318,9 +318,10 @@ enum Command { /// Asset the deposit is paid in. #[arg(long, value_enum, default_value = "usdt")] asset: funding_check::FundingAsset, - /// Balance that counts as delivered, in the asset's smallest units. + /// CASH to credit, in its smallest units; core quotes the deposit + /// that covers it. #[arg(long, default_value_t = 2_000_000)] - expected: u128, + amount: u128, /// Where sessions and account counters persist between runs. Keep it: /// a fresh directory restarts the account numbers. #[arg(long, default_value = ".funding-check")] @@ -674,7 +675,7 @@ async fn dispatch( mnemonic, network, asset, - expected, + amount, state_dir, intent, } => { @@ -682,7 +683,7 @@ async fn dispatch( mnemonic, network, asset, - expected, + amount, state_dir, intent, }; diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 0b43a08b6c..99abe25ed8 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -406,7 +406,9 @@ AutoSigning without approval. Legacy-account signing still asks the user. Once a provider is chosen, a signing host calls `assign_funding_deposit` to give an inbound session its deposit account under `fund.`; the core then polls that account at finalized Asset Hub blocks and moves the - session to `Converting` once the expected balance is there. Assignment needs + session to `Converting` once the expected balance is there. `quote_funding_deposit` gives the + deposit that credits the session's amount, and assignment refuses less. + Assignment needs `enable_funding_conversion` with the network's CASH asset id, and fixes the route then: a teleport for CASH, a PSM mint for a stablecoin the PSM serves. The core converts with one Asset Hub transaction signed by the deposit diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index 8cf0a343c6..02126c75bc 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -628,6 +628,21 @@ impl SigningHostRuntime { }) } + /// The deposit of `asset` a provider must deliver to credit the amount + /// session `intent` names, fees and minimum balances included. + pub async fn quote_funding_deposit( + &self, + intent: &str, + asset: crate::host_logic::funding::DepositAsset, + ) -> Result { + self.services + .quote_funding_deposit(intent, asset) + .await + .map_err(|err| v01::GenericError { + reason: err.to_string(), + }) + } + /// Give the open inbound session `intent` its deposit account for the /// request's source, and watch it until the expected balance arrives on /// Asset Hub, which moves the session to converting. Returns the account diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index 5b0176f4f3..392b7209ce 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -44,6 +44,21 @@ pub struct FundingSession { /// Where an inbound session's provider delivers, once the source is /// known. pub deposit: Option, + /// The deposit quoted to credit `amount`, frozen so the provider is held + /// to the figure it was given rather than one re-priced later. + pub quote: Option, +} + +/// The route for a deposit and what it must deliver to credit a session's +/// amount. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub struct DepositQuote { + /// Asset the deposit is quoted in. + pub asset: DepositAsset, + /// How the deposit becomes CASH. + pub route: ConversionRoute, + /// Least deposit, in the asset's units. + pub deposit: u128, } /// Asset Hub asset a deposit arrives in. @@ -186,6 +201,7 @@ impl FundingSession { opened_at_ms: now_ms, deadline_ms: now_ms.saturating_add(SESSION_WINDOW_MS), deposit: None, + quote: None, } } @@ -263,6 +279,23 @@ impl FundingSession { && self.fail(FundingFailure::Expired, now_ms) } + /// The route for a provider delivering `expected` of `asset`, judged + /// against the quote frozen for that asset: the quoted route when it + /// covers the quote, the deposit needed when it falls short, `None` + /// without a quote for the asset. + pub fn quoted_route( + &self, + asset: DepositAsset, + expected: u128, + ) -> Option> { + let quote = self.quote.filter(|quote| quote.asset == asset)?; + Some(if expected >= quote.deposit { + Ok(quote.route) + } else { + Err(quote.deposit) + }) + } + /// The deposit an open inbound session is waiting on, if one is assigned. pub fn awaited_deposit(&self) -> Option<&FundingDeposit> { (self.stage == FundingStage::Open) @@ -824,6 +857,31 @@ mod tests { ); } + // The provider is told the quoted figure, so the quote it was given, + // not one re-priced when the account is assigned, decides whether its + // deposit is enough. + #[test] + fn a_deposit_is_judged_against_the_quote_for_its_asset() { + let usdt = DepositAsset::Asset(1984); + let session = FundingSession { + quote: Some(DepositQuote { + asset: usdt, + route: ConversionRoute::Psm { fee_ppm: 5_000 }, + deposit: 2_136_987, + }), + ..session(FundingDirection::In) + }; + + assert_eq!( + [ + session.quoted_route(usdt, 2_136_987), + session.quoted_route(usdt, 2_136_986), + session.quoted_route(DepositAsset::Asset(1337), 9_000_000), + ], + [Some(Ok(ConversionRoute::Psm { fee_ppm: 5_000 })), Some(Err(2_136_987)), None] + ); + } + // Delivered is the one inbound success: it ends the session for // subscribers and history, and the credited amount is what they see. #[test] diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs index cdca33f304..0b0550c569 100644 --- a/rust/crates/truapi/src/runtime/funding.rs +++ b/rust/crates/truapi/src/runtime/funding.rs @@ -39,7 +39,8 @@ use super::statement_allowance::{ChainClient, ChainContext}; use super::statement_allowance::rpc::RpcClient; use crate::host_logic::features; use crate::host_logic::funding::{ - ConversionRoute, ConversionStep, ConversionSubmission, CreditAttempt, CreditStep, DepositAsset, DepositRequest, + ConversionRoute, ConversionStep, ConversionSubmission, CreditAttempt, CreditStep, DepositAsset, + DepositQuote, DepositRequest, FundingDeposit, FundingSession, FundingSessionError, FundingStage, load_sessions, next_account_number, retained, store_sessions, }; @@ -599,6 +600,19 @@ pub enum AssignDepositError { /// No route turns this asset into CASH. #[display("no route converts this deposit into CASH")] NoRoute, + /// The chain refused to price the deposit. + #[display("{_0}")] + Refused(String), + /// The session names no amount to quote a deposit for. + #[display("the funding session names no amount")] + NoAmount, + /// The provider would deliver less than it takes to credit the session's + /// amount. + #[display("the deposit must be at least {needed} to credit the amount")] + DepositTooSmall { + /// Least deposit, in the asset's units. + needed: u128, + }, /// Every account tried already holds funds. #[display("every funding account tried already holds funds")] AccountsInUse, @@ -613,6 +627,16 @@ pub enum AssignDepositError { Session(FundingSessionError), } +impl AssignDepositError { + /// A conversion error as an assignment error: a refusal stays one. + fn from_conversion(error: ConversionError) -> Self { + match error { + ConversionError::Refused(reason) => Self::Refused(reason), + ConversionError::Chain(reason) => Self::Chain(GenericError { reason }), + } + } +} + impl From for AssignDepositError { fn from(error: FundingSessionError) -> Self { Self::Session(error) @@ -687,25 +711,39 @@ impl RuntimeServices { request: DepositRequest, derive: impl Fn(u32) -> Result<[u8; 32], GenericError>, ) -> Result<[u8; 32], AssignDepositError> { - self.funding() - .get(intent) - .ok_or(AssignDepositError::NotFound) - .and_then(|session| assignable(&session))?; - let network = self + let session = self .funding() - .conversion - .get() - .ok_or(AssignDepositError::ConversionUnavailable)? - .network; + .get(intent) + .ok_or(AssignDepositError::NotFound)?; + assignable(&session)?; + let network = self.funding_network()?; + let quoting = session.amount.is_some() && session.quote.is_none(); let chains = self - .funding_chains(network, false) + .funding_chains(network, quoting) .await - .map_err(|error| AssignDepositError::Chain(GenericError { reason: error.to_string() }))?; - let route = chains - .choose_route(request.asset, request.expected) - .await - .map_err(|error| AssignDepositError::Chain(GenericError { reason: error.to_string() }))? - .ok_or(AssignDepositError::NoRoute)?; + .map_err(AssignDepositError::from_conversion)?; + // A session that names its amount must be paid enough to credit it, + // judged against the quote the provider was given; one that does not + // takes what the provider delivers. + let route = match (session.amount, session.quoted_route(request.asset, request.expected)) { + (_, Some(judged)) => { + judged.map_err(|needed| AssignDepositError::DepositTooSmall { needed })? + } + (Some(target), None) => { + let quote = self.record_quote(intent, &chains, request.asset, target).await?; + if request.expected < quote.deposit { + return Err(AssignDepositError::DepositTooSmall { + needed: quote.deposit, + }); + } + quote.route + } + (None, None) => within_chain_timeout(chains.choose_route(request.asset, request.expected)) + .await + .map_err(AssignDepositError::Chain)? + .map_err(AssignDepositError::from_conversion)? + .ok_or(AssignDepositError::NoRoute)?, + }; let account = self .funding() .assign_empty_deposit( @@ -721,6 +759,61 @@ impl RuntimeServices { Ok(account) } + /// The deposit of `asset` a provider must deliver to credit the amount + /// session `intent` names. + pub async fn quote_funding_deposit( + self: &Arc, + intent: &str, + asset: DepositAsset, + ) -> Result { + let session = self + .funding() + .get(intent) + .ok_or(AssignDepositError::NotFound)?; + let target = session.amount.ok_or(AssignDepositError::NoAmount)?; + let chains = self + .funding_chains(self.funding_network()?, true) + .await + .map_err(AssignDepositError::from_conversion)?; + self.record_quote(intent, &chains, asset, target) + .await + .map(|quote| quote.deposit) + } + + /// Quote `target` in `asset` and freeze the quote on session `intent`, + /// so the deposit is later held to the figure the provider was given. + async fn record_quote( + &self, + intent: &str, + chains: &Chains, + asset: DepositAsset, + target: u128, + ) -> Result { + let quote = within_chain_timeout(chains.deposit_quote(asset, target)) + .await + .map_err(AssignDepositError::Chain)? + .map_err(AssignDepositError::from_conversion)? + .ok_or(AssignDepositError::NoRoute)?; + let intent = intent.to_string(); + self.funding() + .commit(self.platform.as_ref(), current_unix_millis(), move |sessions| { + if let Some(session) = sessions.get_mut(&intent) { + session.quote = Some(quote); + } + ((), Vec::new()) + }) + .await?; + Ok(quote) + } + + fn funding_network(&self) -> Result { + self.funding() + .conversion + .get() + .map(|conversion| conversion.network) + .ok_or(AssignDepositError::ConversionUnavailable) + } + /// Keep one task polling the awaited deposits while any is awaited. The /// task ends once none is, or the services are dropped. pub fn watch_funding_deposits(self: &Arc) { diff --git a/rust/crates/truapi/src/runtime/funding/conversion.rs b/rust/crates/truapi/src/runtime/funding/conversion.rs index c11faf9be6..05547f22d8 100644 --- a/rust/crates/truapi/src/runtime/funding/conversion.rs +++ b/rust/crates/truapi/src/runtime/funding/conversion.rs @@ -21,7 +21,8 @@ use truapi::latest::{GenericError, TxPayloadExtension}; use crate::host_internal::extrinsic::{Sr25519Signer, build_signed_extrinsic_v4}; use super::DepositBalances; -use crate::host_logic::funding::{ConversionRoute, DepositAsset, FundingDeposit}; +use super::credit::CLAIM_UNIT; +use crate::host_logic::funding::{ConversionRoute, DepositAsset, DepositQuote, FundingDeposit}; use crate::runtime::statement_allowance::ChainContext; use crate::runtime::statement_allowance::extension::{ChainState, Metadata as ExtensionMetadata}; @@ -88,6 +89,32 @@ pub struct Prepared { pub spent: u128, } +/// Fees a conversion pays in its deposit asset, margins included. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Fees { + /// For the transaction itself. + dispatch: u128, + /// For executing the program on Asset Hub and delivering it to People. + allowance: u128, +} + +/// What signing a conversion needs. +#[derive(Clone, Copy)] +struct Signing<'a> { + extensions: &'a ExtensionMetadata, + signer: &'a Sr25519Signer, + nonce: u32, +} + +/// A signer whose signature only gives a quote's transaction its length. +fn quoting_signer() -> Sr25519Signer { + Sr25519Signer::from_keypair( + &schnorrkel::MiniSecretKey::from_bytes(&[1; 32]) + .expect("32 bytes are a mini secret") + .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519), + ) +} + /// What a conversion pass reads and does on the chains. pub trait ConversionChains: DepositBalances { /// CASH `account` holds on People. @@ -186,16 +213,134 @@ impl Chains { let Some(psm) = self.psm(id).await? else { return Ok(None); }; - let amount = psm.to_internal(expected); - let margin = (amount * PSM_CAPACITY_MARGIN_PERCENT / 100).max(PSM_CAPACITY_MARGIN_FLOOR); - let serves = psm.minting_enabled - && amount >= psm.min_swap_amount - && psm.headroom >= amount.saturating_add(margin); - Ok(serves.then_some(ConversionRoute::Psm { + Ok(psm.serves(psm.to_internal(expected)).then_some(ConversionRoute::Psm { fee_ppm: psm.fee_ppm, })) } + /// The route for a deposit of `asset` that credits at least `target` + /// CASH, with the deposit it takes: the target rounded up to what a + /// top-up claims, plus the CASH set aside to execute on People, the PSM + /// fee, the transaction fees with their margin, and the account's + /// minimum balance. + /// + /// The set-aside on People is the quote's cushion: what execution there + /// does not spend is refunded to the account and lands as CASH, so fees + /// that rise between the quote and the conversion still credit the + /// target. + pub async fn deposit_quote( + &self, + asset: DepositAsset, + target: u128, + ) -> Result, ConversionError> { + let DepositAsset::Asset(id) = asset else { + return Ok(None); + }; + let too_large = || ConversionError::Refused("the amount is too large to quote".into()); + if target == 0 { + return Err(ConversionError::Refused("the amount to credit is zero".into())); + } + let claimed = target + .div_ceil(CLAIM_UNIT) + .checked_mul(CLAIM_UNIT) + .ok_or_else(too_large)?; + let send = teleported_for(claimed).ok_or_else(too_large)?; + let (route, mint, converted) = if id == self.places.network.cash_asset_id { + (ConversionRoute::Teleport, None, send) + } else { + let Some(terms) = self.psm(id).await? else { + return Ok(None); + }; + let internal = psm_mint_in(send, terms.fee_ppm).ok_or_else(too_large)?; + if !terms.serves(internal) { + return Ok(None); + } + let route = ConversionRoute::Psm { + fee_ppm: terms.fee_ppm, + }; + let mint = PsmMint { + id, + terms, + max_fee_ppm: terms.fee_ppm, + }; + (route, Some(mint), terms.to_external(internal)) + }; + let extensions = self + .extensions + .as_deref() + .ok_or_else(|| chain("signing metadata was not loaded"))?; + let signer = quoting_signer(); + let signing = Signing { + extensions, + signer: &signer, + nonce: 0, + }; + let fees = self + .estimate_fees(signing, &[0; 32], asset, converted, mint, false) + .await?; + let kept = self.min_balance(asset).await?; + let deposit = [fees.allowance, fees.dispatch, kept] + .into_iter() + .try_fold(converted, u128::checked_add) + .ok_or_else(too_large)?; + Ok(Some(DepositQuote { + asset, + route, + deposit, + })) + } + + /// Fees a conversion of about `converted` from `account` pays in its + /// deposit asset, with their margins: local execution from the program's + /// weight, dispatch from the transaction's length, and delivery to People. + /// + /// Delivery is priced on the message the transfer forwards as the + /// executor builds it, so a quote needs no funded account. When `funded`, + /// the draft is also dry-run and its real forwarded message priced, and + /// the dearer of the two counts, so a change in what the runtime forwards + /// cannot leave a conversion short. + async fn estimate_fees( + &self, + signing: Signing<'_>, + account: &[u8; 32], + asset: DepositAsset, + converted: u128, + mint: Option, + funded: bool, + ) -> Result { + let fee_asset = self.places.deposit_location(asset); + let (withdrawn, allowance) = match funded { + true => (converted, converted / 5), + false => (converted.saturating_mul(2), converted), + }; + let draft = self + .measured(self.places.conversion_call(account, withdrawn, allowance, mint)?) + .await?; + let local = self.local_fee(&draft.program, &fee_asset).await?; + let cash = match mint { + None => converted, + Some(mint) => psm_mint_out(mint.terms.to_internal(converted), mint.terms.fee_ppm), + }; + let forwarded = self.places.forwarded_to_people(account, cash); + let mut delivery = self.delivery_fee(&forwarded, &fee_asset).await?; + if funded { + let real = self.dry_run(account, &draft).await?; + delivery = delivery.max(self.delivery_fee(&real, &fee_asset).await?); + } + let extrinsic = self.sign( + signing.extensions, + signing.signer, + &draft, + &fee_asset, + signing.nonce, + )?; + let dispatch = self.dispatch_fee(&extrinsic, &fee_asset).await?; + Ok(Fees { + dispatch: with_margin(dispatch), + allowance: with_margin(local.saturating_add(delivery)), + }) + } + /// The PSM's terms for minting CASH against asset `id`, if it lists it. async fn psm(&self, id: u32) -> Result, ConversionError> { let cash = self.places.cash(); @@ -222,16 +367,17 @@ impl Chains { return Ok(None); }; let max_debt = u128_at(&instance, "max_debt")?; - let fee_ppm = fetch_value( - &self.asset_hub, - "Psm", - "MintingFee", - vec![cash.clone(), external.clone()], - ) - .await? - .map(|fee| as_u128(&fee)) - .transpose()? - .unwrap_or(0); + // An unset fee is the pallet's default, not zero: reading it as zero + // would cap every mint's fee at nothing and have the PSM refuse it. + let fee_ppm = as_u128( + &fetch_or_default( + &self.asset_hub, + "Psm", + "MintingFee", + vec![cash.clone(), external.clone()], + ) + .await?, + )?; let suffix = external_key_suffix(&self.asset_hub, &external)?; let mut total_weight = 0u128; @@ -386,26 +532,26 @@ impl Chains { } }; - // A first draft with a generous fee allowance measures the fees, - // which barely depend on the amounts; the final call is then sized - // from them. - let draft = self - .measured(self.places.conversion_call(&account, spendable, spendable / 5, mint)?) + let signing = Signing { + extensions, + signer: &signer, + nonce, + }; + let fees = self + .estimate_fees(signing, &account, deposit.asset, spendable, mint, true) .await?; - let forwarded = self.dry_run(&account, &draft).await?; - let local_fee = self.local_fee(&draft.program, &fee_asset).await?; - let delivery_fee = self.delivery_fee(&forwarded, &fee_asset).await?; - let draft_extrinsic = self.sign(extensions, &signer, &draft, &fee_asset, nonce)?; - let dispatch_fee = self.dispatch_fee(&draft_extrinsic, &fee_asset).await?; - - let allowance = with_margin(local_fee.saturating_add(delivery_fee)); let available = spendable - .checked_sub(with_margin(dispatch_fee)) - .and_then(|left| left.checked_sub(allowance)) + .checked_sub(fees.dispatch) + .and_then(|left| left.checked_sub(fees.allowance)) .filter(|left| *left > 0) .ok_or_else(|| ConversionError::Refused("the deposit does not cover the fees".into()))?; let call = self - .measured(self.places.conversion_call(&account, available + allowance, allowance, mint)?) + .measured(self.places.conversion_call( + &account, + available + fees.allowance, + fees.allowance, + mint, + )?) .await?; let forwarded = self.dry_run(&account, &call).await?; self.dry_run_on_people(&forwarded).await?; @@ -693,22 +839,13 @@ impl Places { (minted, assets.into_iter().map(|(_, asset)| asset).collect()) } }; - let remote_fee = (cash * REMOTE_FEE_PERCENT / 100).max(REMOTE_FEE_FLOOR); + let remote_fee = remote_fee(cash); if remote_fee >= cash { return Err(ConversionError::Refused( "the deposit does not cover the fee on People".into(), )); } - let beneficiary = location( - 0, - vec![Value::named_variant( - "AccountId32", - [ - ("network", Value::unnamed_variant("None", [])), - ("id", Value::from_bytes(account)), - ], - )], - ); + let beneficiary = beneficiary(account); let everything = || { Value::unnamed_variant( "Wild", @@ -784,6 +921,41 @@ impl Places { }) } + /// The message a teleport of `cash` forwards to People, as Asset Hub's + /// executor builds it from the program: what prices its delivery before + /// a dry run can produce the real one. + fn forwarded_to_people(&self, account: &[u8; 32], cash: u128) -> Value { + let remote_fee = remote_fee(cash); + let on_people = |amount| self.asset(&self.cash_on_people(), amount); + versioned(Value::unnamed_composite([ + Value::unnamed_variant( + "ReceiveTeleportedAsset", + [Value::unnamed_composite([on_people(remote_fee)])], + ), + Value::named_variant("PayFees", [("asset", on_people(remote_fee))]), + Value::unnamed_variant( + "ReceiveTeleportedAsset", + [Value::unnamed_composite([on_people(cash.saturating_sub(remote_fee))])], + ), + Value::unnamed_variant("ClearOrigin", []), + Value::unnamed_variant("RefundSurplus", []), + Value::named_variant( + "DepositAsset", + [ + ( + "assets", + Value::unnamed_variant( + "Wild", + [Value::unnamed_variant("AllCounted", [Value::u128(1)])], + ), + ), + ("beneficiary", beneficiary(account)), + ], + ), + Value::unnamed_variant("SetTopic", [Value::from_bytes([0; 32])]), + ])) + } + fn asset(&self, id: &Value, amount: u128) -> Value { Value::named_composite([ ("id", id.clone()), @@ -855,6 +1027,25 @@ struct PsmTerms { } impl PsmTerms { + /// Whether the PSM mints `amount` CASH: minting is on, it is at least the + /// minimum swap, and it leaves the capacity margin spare. + fn serves(self, amount: u128) -> bool { + let margin = (amount * PSM_CAPACITY_MARGIN_PERCENT / 100).max(PSM_CAPACITY_MARGIN_FLOOR); + self.minting_enabled + && amount >= self.min_swap_amount + && self.headroom >= amount.saturating_add(margin) + } + + /// `amount` CASH in the stablecoin's units, rounded up. + fn to_external(self, amount: u128) -> u128 { + let (internal, external) = (u32::from(self.internal_decimals), u32::from(self.external_decimals)); + if external >= internal { + amount.saturating_mul(10u128.pow(external - internal)) + } else { + amount.div_ceil(10u128.pow(internal - external)) + } + } + /// `amount` of the stablecoin in CASH units, rounded down. fn to_internal(self, amount: u128) -> u128 { let (internal, external) = (u32::from(self.internal_decimals), u32::from(self.external_decimals)); @@ -866,6 +1057,40 @@ impl PsmTerms { } } +/// CASH the PSM must be given, in CASH units, to mint `out` at `fee_ppm`, +/// or `None` when no amount does. +fn psm_mint_in(out: u128, fee_ppm: u32) -> Option { + let kept = PARTS_PER_MILLION.checked_sub(u128::from(fee_ppm)).filter(|kept| *kept > 0)?; + let mut amount = out.checked_mul(PARTS_PER_MILLION)?.div_ceil(kept); + while psm_mint_out(amount, fee_ppm) < out { + amount = amount.checked_add(1)?; + } + Some(amount) +} + +/// The least CASH to teleport so that at least `target` lands on People +/// after the CASH set aside to execute there, or `None` on overflow. +fn teleported_for(target: u128) -> Option { + let lands = |send: u128| send.saturating_sub(remote_fee(send)) >= target; + let mut send = target.checked_add( + target + .div_ceil(100 / REMOTE_FEE_PERCENT - 1) + .max(REMOTE_FEE_FLOOR), + )?; + while !lands(send) { + send = send.checked_add(1)?; + } + while send > target && lands(send - 1) { + send -= 1; + } + Some(send) +} + +/// CASH set aside for execution on People out of `cash` teleported. +fn remote_fee(cash: u128) -> u128 { + (cash / 100 * REMOTE_FEE_PERCENT).max(REMOTE_FEE_FLOOR) +} + /// CASH the PSM mints for `amount` in CASH units at `fee_ppm`. fn psm_mint_out(amount: u128, fee_ppm: u32) -> u128 { let fee = (amount * u128::from(fee_ppm)).div_ceil(PARTS_PER_MILLION); @@ -968,6 +1193,20 @@ impl RuntimeCall { } } +/// `account` on the chain a message executes on. +fn beneficiary(account: &[u8; 32]) -> Value { + location( + 0, + vec![Value::named_variant( + "AccountId32", + [ + ("network", Value::unnamed_variant("None", [])), + ("id", Value::from_bytes(account)), + ], + )], + ) +} + /// A location `parents` up with `junctions` below. fn location(parents: u8, junctions: Vec) -> Value { let interior = match junctions.len() { @@ -1056,6 +1295,23 @@ async fn fetch_value( } } +/// The value of `pallet.item` at `keys`, or the default the runtime declares +/// for it when unset. +async fn fetch_or_default( + at: &OnlineClientAtBlock, + pallet: &str, + item: &str, + keys: Vec, +) -> Result { + let address = dynamic::storage::, Value>(pallet, item); + at.storage() + .fetch(address, keys) + .await + .map_err(chain)? + .decode() + .map_err(chain) +} + async fn call_api( at: &OnlineClientAtBlock, api: &str, @@ -1227,6 +1483,45 @@ mod tests { ); } + // A quoted deposit must credit at least what the session asked for, or + // the user is short; each inverse is checked against the forward rule + // the conversion applies. + #[test] + fn sizing_inverts_the_conversion_rounding_up() { + let terms = PsmTerms { + minting_enabled: true, + min_swap_amount: 0, + internal_decimals: 6, + external_decimals: 18, + fee_ppm: 5_000, + headroom: 0, + }; + let teleport = |target: u128| { + let send = teleported_for(target).expect("sized"); + (send, send - remote_fee(send), send - 1 - remote_fee(send - 1)) + }; + let mint = |out: u128| { + let given = psm_mint_in(out, 5_000).expect("sized"); + (given, psm_mint_out(given, 5_000), psm_mint_out(given - 1, 5_000)) + }; + + assert_eq!( + ( + [teleport(10_000), teleport(2_000_000)], + [mint(995), mint(1_990_000)], + (terms.to_external(1), terms.to_internal(terms.to_external(1_234_567))), + (psm_mint_in(1, 1_000_000), teleported_for(u128::MAX)), + ), + ( + [(11_000, 10_000, 9_999), (2_020_202, 2_000_000, 1_999_999)], + [(1_000, 995, 994), (2_000_000, 1_990_000, 1_989_999)], + (1_000_000_000_000, 1_234_567), + (None, None), + ) + ); + } + + // Without CASH left for execution on People the teleport would land // nothing, so a deposit that small is refused before any dry run. #[test] @@ -1336,6 +1631,44 @@ mod live { assert_eq!((route, prepared.map(|_| ())), (Some(ConversionRoute::Teleport), Ok(()))); } + // A quote is made before any deposit exists, so its delivery fee is + // priced on a stand-in for the message the transfer forwards; it must + // cost what the real one does. + #[tokio::test] + #[ignore = "reaches Paseo Next"] + async fn the_quoted_delivery_fee_is_the_real_ones() { + let chains = chains().await; + let account = holder(&chains, NETWORK.cash_asset_id, 5_000_000).await; + let cash = chains.places.cash(); + let call = chains + .measured(chains.places.conversion_call(&account, 2_000_000, 200_000, None).expect("sized")) + .await + .expect("measured"); + let forwarded = chains.dry_run(&account, &call).await.expect("dry run"); + + assert_eq!( + chains + .delivery_fee(&chains.places.forwarded_to_people(&account, 1_800_000), &cash) + .await, + chains.delivery_fee(&forwarded, &cash).await + ); + } + + #[tokio::test] + #[ignore = "reaches Paseo Next"] + async fn deposits_are_quoted_for_cash_and_usdt() { + let chains = chains().await; + let quote = |id| chains.deposit_quote(DepositAsset::Asset(id), 2_000_000); + let (cash, usdt) = (quote(NETWORK.cash_asset_id).await, quote(USDT).await); + eprintln!("quotes for 2 CASH: {cash:?} {usdt:?}"); + + assert!( + matches!(cash, Ok(Some(DepositQuote { route: ConversionRoute::Teleport, deposit, .. })) if deposit > 2_000_000) + && matches!(usdt, Ok(Some(DepositQuote { route: ConversionRoute::Psm { .. }, deposit, .. })) if deposit > 2_000_000), + "{cash:?} {usdt:?}" + ); + } + // The dry runs above never see the signed extensions. Signed by an // account with nothing to pay with, a well-formed transaction fails on // payment alone; a mis-encoded extension or signature fails before that. diff --git a/rust/crates/truapi/src/runtime/funding/credit.rs b/rust/crates/truapi/src/runtime/funding/credit.rs index d1a182d61a..b26df0f3bf 100644 --- a/rust/crates/truapi/src/runtime/funding/credit.rs +++ b/rust/crates/truapi/src/runtime/funding/credit.rs @@ -21,7 +21,7 @@ use crate::platform::{ProductContext, TopUpPlatform}; /// Smallest amount a top-up claims, in CASH units: the landed CASH is /// claimed rounded down to it. -const CLAIM_UNIT: u128 = 10_000; +pub const CLAIM_UNIT: u128 = 10_000; /// Top-up attempts before crediting gives up. const MAX_ATTEMPTS: u8 = 3; /// How long one attempt may run before the next replaces it. From 02041b76f5b735f57f77e659f9437ea6865c1562 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 5 Oct 2026 16:48:37 +0200 Subject: [PATCH 10/20] feat(truapi): convert native and unserved stablecoin deposits through the pools --- .changeset/funding-pool-route.md | 5 + .../truapi-host-cli/src/funding_check.rs | 21 +- rust/crates/truapi/src/host_logic/funding.rs | 4 + .../truapi/src/runtime/funding/conversion.rs | 470 +++++++++++++++--- 4 files changed, 421 insertions(+), 79 deletions(-) create mode 100644 .changeset/funding-pool-route.md diff --git a/.changeset/funding-pool-route.md b/.changeset/funding-pool-route.md new file mode 100644 index 0000000000..397edf8d62 --- /dev/null +++ b/.changeset/funding-pool-route.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": patch +--- + +Funding deposits in the native token, and in a stablecoin the PSM cannot serve, convert through the asset-conversion pools: one XCM program swaps them into CASH (through the native token for a stablecoin) and teleports it to People, as getcash does. `funding-check` accepts `--asset dot`. diff --git a/rust/crates/truapi-host-cli/src/funding_check.rs b/rust/crates/truapi-host-cli/src/funding_check.rs index 3d1bb82dcf..460346c525 100644 --- a/rust/crates/truapi-host-cli/src/funding_check.rs +++ b/rust/crates/truapi-host-cli/src/funding_check.rs @@ -32,6 +32,8 @@ const MISSING_POLLS: u32 = 5; /// The asset a provider pays the deposit in. #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum FundingAsset { + /// The native token, swapped into CASH through the pools. + Dot, /// dotUSD, which is CASH already: teleported to People. Cash, /// Minted into CASH through the PSM, then teleported. @@ -60,12 +62,13 @@ impl FundingAssets { } } - /// The asset id and the getcash source id for `asset`. - fn source(&self, asset: FundingAsset) -> (u32, &'static str) { + /// The deposit asset and the getcash source id for `asset`. + fn source(&self, asset: FundingAsset) -> (DepositAsset, &'static str) { match asset { - FundingAsset::Cash => (self.cash, "dotusd-assethub"), - FundingAsset::Usdt => (self.usdt, "usdt-assethub"), - FundingAsset::Usdc => (self.usdc, "usdc-assethub"), + FundingAsset::Dot => (DepositAsset::Native, "dot-assethub"), + FundingAsset::Cash => (DepositAsset::Asset(self.cash), "dotusd-assethub"), + FundingAsset::Usdt => (DepositAsset::Asset(self.usdt), "usdt-assethub"), + FundingAsset::Usdc => (DepositAsset::Asset(self.usdc), "usdc-assethub"), } } } @@ -139,14 +142,14 @@ async fn open_and_assign( asset: FundingAsset, amount: u128, ) -> Result { - let (asset_id, source_id) = assets.source(asset); + let (deposit_asset, source_id) = assets.source(asset); let intent = runtime .open_funding(FundingDirection::In, Some(amount)) .await .map_err(|error| anyhow::anyhow!("opening a session failed: {}", error.reason))? .context("the session was dismissed")?; let expected = runtime - .quote_funding_deposit(&intent, DepositAsset::Asset(asset_id)) + .quote_funding_deposit(&intent, deposit_asset) .await .map_err(|error| anyhow::anyhow!("quoting the deposit failed: {}", error.reason))?; let account = runtime @@ -154,14 +157,14 @@ async fn open_and_assign( &intent, DepositRequest { source_id: source_id.to_string(), - asset: DepositAsset::Asset(asset_id), + asset: deposit_asset, expected, }, ) .await .map_err(|error| anyhow::anyhow!("assigning a deposit account failed: {}", error.reason))?; println!("session {intent}, crediting {amount} CASH units"); - println!("pay {expected} of asset {asset_id} ({source_id}) on Asset Hub to"); + println!("pay {expected} of {deposit_asset:?} ({source_id}) on Asset Hub to"); println!( " {}", truapi::host_logic::product_account::product_public_key_to_address(account) diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index 392b7209ce..df8a811283 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -111,6 +111,10 @@ pub enum ConversionRoute { /// Minting fee the route was chosen at, in parts per million. fee_ppm: u32, }, + /// The deposit is the native token, or a stablecoin the PSM cannot + /// serve: swap it to CASH through the asset-conversion pools, then + /// teleport. + Pool, } /// A conversion transaction handed to Asset Hub, with what tells whether it diff --git a/rust/crates/truapi/src/runtime/funding/conversion.rs b/rust/crates/truapi/src/runtime/funding/conversion.rs index 05547f22d8..31e80d116d 100644 --- a/rust/crates/truapi/src/runtime/funding/conversion.rs +++ b/rust/crates/truapi/src/runtime/funding/conversion.rs @@ -41,6 +41,11 @@ const MORTAL_PERIOD_BLOCKS: u64 = 64; const PSM_CAPACITY_MARGIN_PERCENT: u128 = 10; /// Least PSM capacity a mint must leave spare, in CASH units. const PSM_CAPACITY_MARGIN_FLOOR: u128 = 1_000_000; +/// Times a stablecoin swap's dry run is retried with its fee allowance +/// doubled. +const SWAP_ALLOWANCE_RETRIES: u32 = 2; +/// Headroom a pool swap's least output leaves below its quote, in percent. +const SWAP_SLIPPAGE_PERCENT: u128 = 5; /// Parts per million in a `Permill`. const PARTS_PER_MILLION: u128 = 1_000_000; @@ -204,18 +209,113 @@ impl Chains { asset: DepositAsset, expected: u128, ) -> Result, ConversionError> { - let DepositAsset::Asset(id) = asset else { - return Ok(None); - }; - if id == self.places.network.cash_asset_id { + if asset == DepositAsset::Asset(self.places.network.cash_asset_id) { return Ok(Some(ConversionRoute::Teleport)); } - let Some(psm) = self.psm(id).await? else { + if let DepositAsset::Asset(id) = asset + && let Some(psm) = self.psm(id).await? + && psm.serves(psm.to_internal(expected)) + { + return Ok(Some(ConversionRoute::Psm { + fee_ppm: psm.fee_ppm, + })); + } + let swaps = self.swap_out(asset, expected).await?.is_some(); + Ok(swaps.then_some(ConversionRoute::Pool)) + } + + /// CASH the pools return for `amount` of `asset`, through the native + /// token for a stablecoin, or `None` without a pool path. + async fn swap_out(&self, asset: DepositAsset, amount: u128) -> Result, ConversionError> { + let through_native = match asset { + DepositAsset::Native => Some(amount), + DepositAsset::Asset(id) => { + self.pool_quote("quote_price_exact_tokens_for_tokens", &self.places.asset_location(id), &native(), amount) + .await? + } + }; + let Some(native_amount) = through_native else { return Ok(None); }; - Ok(psm.serves(psm.to_internal(expected)).then_some(ConversionRoute::Psm { - fee_ppm: psm.fee_ppm, - })) + self.pool_quote("quote_price_exact_tokens_for_tokens", &native(), &self.places.cash(), native_amount) + .await + } + + /// `asset` the pools take to return `cash`, through the native token for + /// a stablecoin, with the slippage headroom on each hop, or `None` + /// without a pool path. + async fn swap_in(&self, asset: DepositAsset, cash: u128) -> Result, ConversionError> { + let Some(native_in) = self + .pool_quote("quote_price_tokens_for_exact_tokens", &native(), &self.places.cash(), with_slippage_room(cash)) + .await? + else { + return Ok(None); + }; + match asset { + DepositAsset::Native => Ok(Some(native_in)), + DepositAsset::Asset(id) => { + self.pool_quote( + "quote_price_tokens_for_exact_tokens", + &self.places.asset_location(id), + &native(), + with_slippage_room(native_in), + ) + .await + } + } + } + + /// One `AssetConversionApi` price between `give` and `want`. + async fn pool_quote( + &self, + method: &str, + give: &Value, + want: &Value, + amount: u128, + ) -> Result, ConversionError> { + let quoted = call_api( + &self.asset_hub, + "AssetConversionApi", + method, + vec![give.clone(), want.clone(), Value::u128(amount), Value::bool(true)], + ) + .await?; + match variant_name("ed) { + Some("Some") => variant_fields("ed) + .and_then(|fields| fields.values().next()) + .map(as_u128) + .transpose(), + _ => Ok(None), + } + } + + /// The pool swap for `give` of `asset`, its least outputs the quotes less + /// the slippage headroom. + async fn swap(&self, asset: DepositAsset, give: u128) -> Result { + let no_pool = || ConversionError::Refused("no pool swaps the deposit into CASH".into()); + let min_native = match asset { + DepositAsset::Native => None, + DepositAsset::Asset(id) => Some(less_slippage( + self.pool_quote("quote_price_exact_tokens_for_tokens", &self.places.asset_location(id), &native(), give) + .await? + .ok_or_else(no_pool)?, + )), + }; + let min_cash = less_slippage( + self.pool_quote( + "quote_price_exact_tokens_for_tokens", + &native(), + &self.places.cash(), + min_native.unwrap_or(give), + ) + .await? + .ok_or_else(no_pool)?, + ); + Ok(PoolSwap { + from: asset, + min_native, + min_cash, + }) } /// The route for a deposit of `asset` that credits at least `target` @@ -233,9 +333,6 @@ impl Chains { asset: DepositAsset, target: u128, ) -> Result, ConversionError> { - let DepositAsset::Asset(id) = asset else { - return Ok(None); - }; let too_large = || ConversionError::Refused("the amount is too large to quote".into()); if target == 0 { return Err(ConversionError::Refused("the amount to credit is zero".into())); @@ -245,25 +342,41 @@ impl Chains { .checked_mul(CLAIM_UNIT) .ok_or_else(too_large)?; let send = teleported_for(claimed).ok_or_else(too_large)?; - let (route, mint, converted) = if id == self.places.network.cash_asset_id { - (ConversionRoute::Teleport, None, send) - } else { - let Some(terms) = self.psm(id).await? else { - return Ok(None); - }; - let internal = psm_mint_in(send, terms.fee_ppm).ok_or_else(too_large)?; - if !terms.serves(internal) { - return Ok(None); + let psm = match asset { + DepositAsset::Asset(id) if id != self.places.network.cash_asset_id => { + self.psm(id).await?.map(|terms| (id, terms)) } - let route = ConversionRoute::Psm { - fee_ppm: terms.fee_ppm, - }; + _ => None, + }; + let minted = psm.and_then(|(id, terms)| { + let internal = psm_mint_in(send, terms.fee_ppm)?; + terms.serves(internal).then_some((id, terms, internal)) + }); + let (route, converter, converted) = if asset == DepositAsset::Asset(self.places.network.cash_asset_id) { + (ConversionRoute::Teleport, Converter::Teleport, send) + } else if let Some((id, terms, internal)) = minted { let mint = PsmMint { id, terms, max_fee_ppm: terms.fee_ppm, }; - (route, Some(mint), terms.to_external(internal)) + ( + ConversionRoute::Psm { + fee_ppm: terms.fee_ppm, + }, + Converter::Mint(mint), + terms.to_external(internal), + ) + } else { + let Some(given) = self.swap_in(asset, send).await? else { + return Ok(None); + }; + let swap = PoolSwap { + from: asset, + min_native: matches!(asset, DepositAsset::Asset(_)).then_some(1), + min_cash: send, + }; + (ConversionRoute::Pool, Converter::Swap(swap), given) }; let extensions = self .extensions @@ -276,7 +389,7 @@ impl Chains { nonce: 0, }; let fees = self - .estimate_fees(signing, &[0; 32], asset, converted, mint, false) + .estimate_fees(signing, &[0; 32], asset, converted, converter, false) .await?; let kept = self.min_balance(asset).await?; let deposit = [fees.allowance, fees.dispatch, kept] @@ -305,7 +418,7 @@ impl Chains { account: &[u8; 32], asset: DepositAsset, converted: u128, - mint: Option, + converter: Converter, funded: bool, ) -> Result { let fee_asset = self.places.deposit_location(asset); @@ -314,12 +427,13 @@ impl Chains { false => (converted.saturating_mul(2), converted), }; let draft = self - .measured(self.places.conversion_call(account, withdrawn, allowance, mint)?) + .measured(self.places.conversion_call(account, withdrawn, allowance, converter.drafted())?) .await?; let local = self.local_fee(&draft.program, &fee_asset).await?; - let cash = match mint { - None => converted, - Some(mint) => psm_mint_out(mint.terms.to_internal(converted), mint.terms.fee_ppm), + let cash = match converter { + Converter::Teleport => converted, + Converter::Mint(mint) => psm_mint_out(mint.terms.to_internal(converted), mint.terms.fee_ppm), + Converter::Swap(swap) => swap.min_cash, }; let forwarded = self.places.forwarded_to_people(account, cash); let mut delivery = self.delivery_fee(&forwarded, &fee_asset).await?; @@ -517,9 +631,9 @@ impl Chains { .extensions .as_deref() .ok_or_else(|| chain("signing metadata was not loaded"))?; - let mint = match (deposit.route, deposit.asset) { - (ConversionRoute::Teleport, _) => None, - (ConversionRoute::Psm { fee_ppm }, DepositAsset::Asset(id)) => Some(PsmMint { + let converter = match (deposit.route, deposit.asset) { + (ConversionRoute::Teleport, _) => Converter::Teleport, + (ConversionRoute::Psm { fee_ppm }, DepositAsset::Asset(id)) => Converter::Mint(PsmMint { id, terms: self .psm(id) @@ -530,6 +644,7 @@ impl Chains { (ConversionRoute::Psm { .. }, DepositAsset::Native) => { return Err(ConversionError::Refused("the PSM mints only from assets".into())); } + (ConversionRoute::Pool, _) => Converter::Swap(self.swap(deposit.asset, spendable).await?), }; let signing = Signing { @@ -538,22 +653,41 @@ impl Chains { nonce, }; let fees = self - .estimate_fees(signing, &account, deposit.asset, spendable, mint, true) + .estimate_fees(signing, &account, deposit.asset, spendable, converter, true) .await?; - let available = spendable - .checked_sub(fees.dispatch) - .and_then(|left| left.checked_sub(fees.allowance)) - .filter(|left| *left > 0) - .ok_or_else(|| ConversionError::Refused("the deposit does not cover the fees".into()))?; - let call = self - .measured(self.places.conversion_call( - &account, - available + fees.allowance, - fees.allowance, - mint, - )?) - .await?; - let forwarded = self.dry_run(&account, &call).await?; + // A stablecoin swap moves its own pool before delivery is charged in + // that stablecoin, so a large one can need more than the allowance + // quoted beforehand: it retries with the allowance doubled, and + // what goes unspent is refunded to the account. + let retries = match converter { + Converter::Swap(PoolSwap { + min_native: Some(_), .. + }) => SWAP_ALLOWANCE_RETRIES, + _ => 0, + }; + let mut allowance = fees.allowance; + let (call, forwarded) = loop { + let given = spendable + .checked_sub(fees.dispatch) + .and_then(|left| left.checked_sub(allowance)) + .filter(|left| *left > 0) + .ok_or_else(|| ConversionError::Refused("the deposit does not cover the fees".into()))?; + // A swap's least outputs are quoted for what it actually gives. + let converter = match converter { + Converter::Swap(_) => Converter::Swap(self.swap(deposit.asset, given).await?), + other => other, + }; + let call = self + .measured(self.places.conversion_call(&account, given + allowance, allowance, converter)?) + .await?; + match self.dry_run(&account, &call).await { + Ok(forwarded) => break (call, forwarded), + Err(ConversionError::Refused(_)) if allowance < fees.allowance << retries => { + allowance *= 2; + } + Err(error) => return Err(error), + } + }; self.dry_run_on_people(&forwarded).await?; Ok(Prepared { extrinsic: self.sign(extensions, &signer, &call, &fee_asset, nonce)?, @@ -705,15 +839,15 @@ impl Chains { vec![Value::from_bytes(&unprefixed), Value::u128(length.into())], ) .await?; - let native = u128_at(&info, "partial_fee")?; - if fee_asset == &location(1, Vec::new()) { - return Ok(native); + let native_fee = u128_at(&info, "partial_fee")?; + if fee_asset == &native() { + return Ok(native_fee); } let quoted = call_api( &self.asset_hub, "AssetConversionApi", "quote_price_tokens_for_exact_tokens", - vec![fee_asset.clone(), location(1, Vec::new()), Value::u128(native), Value::bool(true)], + vec![fee_asset.clone(), native(), Value::u128(native_fee), Value::bool(true)], ) .await?; let quoted = variant_fields("ed) @@ -790,7 +924,7 @@ impl Places { /// The deposited asset as Asset Hub names it. fn deposit_location(&self, asset: DepositAsset) -> Value { match asset { - DepositAsset::Native => location(1, Vec::new()), + DepositAsset::Native => native(), DepositAsset::Asset(id) => self.asset_location(id), } } @@ -814,14 +948,50 @@ impl Places { account: &[u8; 32], withdrawn: u128, allowance: u128, - mint: Option, + converter: Converter, ) -> Result { let converted = withdrawn .checked_sub(allowance) .ok_or_else(|| ConversionError::Refused("the fee allowance exceeds the deposit".into()))?; - let (cash, withdrawn_assets) = match mint { - None => (converted, vec![self.asset(&self.cash(), withdrawn)]), - Some(mint) => { + let mut exchanges = Vec::new(); + let (cash, withdrawn_assets) = match converter { + Converter::Teleport => (converted, vec![self.asset(&self.cash(), withdrawn)]), + Converter::Swap(swap) => { + let from = self.deposit_location(swap.from); + let exchange = |give: Value, want: Value| { + Value::named_variant( + "ExchangeAsset", + [ + ("give", give), + ("want", Value::unnamed_composite([want])), + ("maximal", Value::bool(true)), + ], + ) + }; + let definite = |asset: Value| { + Value::unnamed_variant("Definite", [Value::unnamed_composite([asset])]) + }; + let cash_out = self.asset(&self.cash(), swap.min_cash); + match swap.min_native { + None => exchanges.push(exchange(definite(self.asset(&from, converted)), cash_out)), + Some(min_native) => { + exchanges.push(exchange( + definite(self.asset(&from, converted)), + self.asset(&native(), min_native), + )); + let all_native = Value::unnamed_variant( + "Wild", + [Value::named_variant( + "AllOf", + [("id", native()), ("fun", Value::unnamed_variant("Fungible", []))], + )], + ); + exchanges.push(exchange(all_native, cash_out)); + } + } + (swap.min_cash, vec![self.asset(&from, withdrawn)]) + } + Converter::Mint(mint) => { let internal = mint.terms.to_internal(converted); if internal < mint.terms.min_swap_amount { return Err(ConversionError::Refused( @@ -862,13 +1032,17 @@ impl Places { ) }; let teleport = |filter: Value| Value::unnamed_variant("Teleport", [filter]); - let fee_asset = match mint { - None => self.asset(&self.cash(), allowance), - Some(mint) => self.asset(&self.asset_location(mint.id), allowance), + let fee_asset = match converter { + Converter::Teleport => self.asset(&self.cash(), allowance), + Converter::Mint(mint) => self.asset(&self.asset_location(mint.id), allowance), + Converter::Swap(swap) => self.asset(&self.deposit_location(swap.from), allowance), }; - let program = vec![ + let mut program = vec![ Value::unnamed_variant("WithdrawAsset", [Value::unnamed_composite(withdrawn_assets)]), Value::named_variant("PayFees", [("asset", fee_asset)]), + ]; + program.extend(exchanges); + program.extend([ Value::named_variant( "InitiateTransfer", [ @@ -899,7 +1073,11 @@ impl Places { ), Value::unnamed_variant("RefundSurplus", []), deposit_everything(), - ]; + ]); + let mint = match converter { + Converter::Mint(mint) => Some(mint), + Converter::Teleport | Converter::Swap(_) => None, + }; let mint_call = mint.map(|mint| { RuntimeCall::new( "Psm", @@ -964,6 +1142,45 @@ impl Places { } } +/// How a conversion turns the deposit into CASH before the teleport. +#[derive(Debug, Clone, Copy)] +enum Converter { + /// The deposit is CASH. + Teleport, + /// A PSM mint, batched ahead of the program. + Mint(PsmMint), + /// Pool swaps inside the program. + Swap(PoolSwap), +} + +impl Converter { + /// The converter a fee draft runs: a swap's least outputs set just above + /// what executing on People needs, since a draft is weighed and + /// dry-run, not executed for value, and any real deposit swaps for more. + fn drafted(self) -> Self { + match self { + Self::Swap(swap) => Self::Swap(PoolSwap { + min_native: swap.min_native.map(|_| 1), + min_cash: 2 * REMOTE_FEE_FLOOR, + ..swap + }), + other => other, + } + } +} + +/// Pool swaps from the deposit asset to CASH: straight for the native token, +/// through it for a stablecoin, each giving all of what it holds. +#[derive(Debug, Clone, Copy)] +struct PoolSwap { + /// The deposit asset given. + from: DepositAsset, + /// Least native token the first hop returns, for a stablecoin. + min_native: Option, + /// Least CASH the last hop returns. + min_cash: u128, +} + /// A PSM mint ahead of the teleport. #[derive(Debug, Clone, Copy)] struct PsmMint { @@ -1086,6 +1303,22 @@ fn teleported_for(target: u128) -> Option { Some(send) } +/// The native token, as Asset Hub names it. +fn native() -> Value { + location(1, Vec::new()) +} + +/// `amount` less the slippage headroom: a swap's least output. +fn less_slippage(amount: u128) -> u128 { + amount / 100 * (100 - SWAP_SLIPPAGE_PERCENT) +} + +/// An output to ask the pools for so that, less the slippage headroom, it +/// still covers `amount`. +fn with_slippage_room(amount: u128) -> u128 { + amount.saturating_mul(100).div_ceil(100 - SWAP_SLIPPAGE_PERCENT) +} + /// CASH set aside for execution on People out of `cash` teleported. fn remote_fee(cash: u128) -> u128 { (cash / 100 * REMOTE_FEE_PERCENT).max(REMOTE_FEE_FLOOR) @@ -1113,10 +1346,13 @@ impl Chains { .transaction_extensions_to_use_for_encoding() .find(|extension| extension.identifier() == "ChargeAssetTxPayment") .ok_or_else(|| chain("Asset Hub does not charge fees in assets"))?; - Value::named_composite([ - ("tip", Value::u128(0)), - ("asset_id", Value::unnamed_variant("Some", [fee_asset.clone()])), - ]) + // 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) } @@ -1443,7 +1679,7 @@ mod tests { fn a_teleport_encodes_as_an_asset_hub_xcm_execute() { let metadata = asset_hub_metadata(); let call = PLACES - .conversion_call(&[1; 32], 1_000_000, 100_000, None) + .conversion_call(&[1; 32], 1_000_000, 100_000, Converter::Teleport) .expect("sized") .runtime_call(); let pallet = metadata.pallet_by_name("PolkadotXcm").expect("pallet"); @@ -1454,6 +1690,44 @@ mod tests { assert_eq!(encoded[..2], [pallet.call_index(), execute.index]); } + // The swaps are XCM `ExchangeAsset`s built from names, as getcash builds + // them: one hop for the native token, two through it for a stablecoin. + // Each encodes as an Asset Hub `PolkadotXcm.execute`. + #[test] + fn pool_swaps_encode_as_asset_hub_xcm_executes() { + let metadata = asset_hub_metadata(); + let encoded = |from, min_native| { + PLACES + .conversion_call( + &[1; 32], + 10_000_000_000, + 100_000_000, + Converter::Swap(PoolSwap { + from, + min_native, + min_cash: 1_000_000, + }), + ) + .expect("sized") + .runtime_call() + .encode(&metadata) + .map(|bytes| bytes[..2].to_vec()) + }; + let pallet = metadata.pallet_by_name("PolkadotXcm").expect("pallet"); + let execute = vec![ + pallet.call_index(), + pallet.call_variant_by_name("execute").expect("call").index, + ]; + + assert_eq!( + [ + encoded(DepositAsset::Native, None), + encoded(DepositAsset::Asset(1984), Some(1_000_000_000)), + ], + [Ok(execute.clone()), Ok(execute)] + ); + } + // Sized from the getcash formulas: the PSM keeps its fee, rounded up, // and every fee estimate gets a tenth more, rounded up. #[test] @@ -1527,7 +1801,7 @@ mod tests { #[test] fn a_deposit_too_small_for_the_fee_on_people_is_refused() { let refused = PLACES - .conversion_call(&[1; 32], 1_500, 600, None) + .conversion_call(&[1; 32], 1_500, 600, Converter::Teleport) .map(|_| ()); assert_eq!( @@ -1582,6 +1856,12 @@ mod live { /// An account holding at least `least` of asset `id` on Asset Hub. async fn holder(chains: &Chains, id: u32, least: u128) -> [u8; 32] { + holder_between(chains, id, least, u128::MAX).await + } + + /// An account holding between `least` and `most` of asset `id`: a + /// deposit-sized balance, which a swap does not move the pool much for. + async fn holder_between(chains: &Chains, id: u32, least: u128, most: u128) -> [u8; 32] { let mut entries = chains .asset_hub .storage() @@ -1594,7 +1874,7 @@ mod live { 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 >= least { + if (least..=most).contains(&balance) { let key = entry.key_bytes(); return key[key.len() - 32..].try_into().expect("account id"); } @@ -1641,7 +1921,7 @@ mod live { let account = holder(&chains, NETWORK.cash_asset_id, 5_000_000).await; let cash = chains.places.cash(); let call = chains - .measured(chains.places.conversion_call(&account, 2_000_000, 200_000, None).expect("sized")) + .measured(chains.places.conversion_call(&account, 2_000_000, 200_000, Converter::Teleport).expect("sized")) .await .expect("measured"); let forwarded = chains.dry_run(&account, &call).await.expect("dry run"); @@ -1702,6 +1982,56 @@ mod live { ); } + /// An account holding at least `least` of the native token. + async fn native_holder(chains: &Chains, least: u128) -> [u8; 32] { + let mut entries = chains + .asset_hub + .storage() + .iter(dynamic::storage::<(Value,), Value>("System", "Account"), ()) + .await + .expect("accounts iterate"); + while let Some(entry) = entries.next().await { + let entry = entry.expect("entry reads"); + let value = entry.value().decode().expect("decodes"); + let free = u128_at(field(&value, "data").expect("data"), "free").expect("free"); + if free >= least { + let key = entry.key_bytes(); + return key[key.len() - 32..].try_into().expect("account id"); + } + } + panic!("no account holds {least} of the native token"); + } + + // The native token has no PSM pair, so it always swaps through the + // pool; a stablecoin swaps the same way, through the native token, when + // the PSM cannot serve it. + #[tokio::test] + #[ignore = "reaches Paseo Next"] + async fn native_and_stable_deposits_swap_through_the_pools_and_teleport() { + let chains = chains().await; + let keypair = schnorrkel::MiniSecretKey::from_bytes(&[7; 32]) + .expect("seed") + .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519); + let native_account = native_holder(&chains, 100_000_000_000).await; + let usdt_account = holder_between(&chains, USDT, 5_000_000, 50_000_000).await; + let route = chains.choose_route(DepositAsset::Native, 10_000_000_000).await; + let quote = chains.deposit_quote(DepositAsset::Native, 2_000_000).await; + eprintln!("native route {route:?}, quote {quote:?}"); + let native = chains + .prepare(&deposit(DepositAsset::Native, native_account, ConversionRoute::Pool), &keypair, 0) + .await + .map(|_| ()); + let stable = chains + .prepare(&deposit(DepositAsset::Asset(USDT), usdt_account, ConversionRoute::Pool), &keypair, 0) + .await + .map(|_| ()); + + assert_eq!( + (route, native, stable), + (Ok(Some(ConversionRoute::Pool)), Ok(()), Ok(())) + ); + } + #[tokio::test] #[ignore = "reaches Paseo Next"] async fn a_usdt_deposit_mints_through_the_psm_and_teleports() { From 362ec834668403c1119acad15bae170d8549107f Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 5 Oct 2026 16:57:26 +0200 Subject: [PATCH 11/20] fix(truapi): retry pool swaps only for fees and floor them at the target --- rust/crates/truapi/src/host_logic/funding.rs | 3 + rust/crates/truapi/src/runtime/funding.rs | 8 +- .../truapi/src/runtime/funding/conversion.rs | 328 ++++++++++++++---- .../truapi/src/runtime/funding/credit.rs | 1 + 4 files changed, 279 insertions(+), 61 deletions(-) diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index df8a811283..4d024cc973 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -97,6 +97,8 @@ pub struct FundingDeposit { pub expected: u128, /// How the deposit becomes CASH on People. pub route: ConversionRoute, + /// CASH the session asks to credit, which a swap must not land below. + pub target: Option, } /// How a deposit becomes CASH on People, fixed when its account is assigned @@ -815,6 +817,7 @@ mod tests { account: [1; 32], expected: 50, route: ConversionRoute::Teleport, + target: None, }), ..session(FundingDirection::In) } diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs index 0b0550c569..e34a79d125 100644 --- a/rust/crates/truapi/src/runtime/funding.rs +++ b/rust/crates/truapi/src/runtime/funding.rs @@ -251,9 +251,10 @@ impl FundingRegistry { plan: DepositPlan, derive: impl Fn(u32) -> Result<[u8; 32], GenericError>, ) -> Result<[u8; 32], AssignDepositError> { - self.get(intent) + let target = self + .get(intent) .ok_or(AssignDepositError::NotFound) - .and_then(|session| assignable(&session))?; + .and_then(|session| assignable(&session).map(|()| session.amount))?; let DepositPlan { request, route } = plan; for _ in 0..MAX_USED_ACCOUNTS { let number = self @@ -274,6 +275,7 @@ impl FundingRegistry { account, expected: request.expected, route, + target, }; let intent = intent.to_string(); return self @@ -1300,6 +1302,7 @@ mod tests { account: account(3), expected: 50, route: ConversionRoute::Teleport, + target: Some(100), }) ) ); @@ -1532,6 +1535,7 @@ mod tests { account: keypair(1).public.to_bytes(), expected: 50, route: ConversionRoute::Teleport, + target: None, } } diff --git a/rust/crates/truapi/src/runtime/funding/conversion.rs b/rust/crates/truapi/src/runtime/funding/conversion.rs index 31e80d116d..db170b1999 100644 --- a/rust/crates/truapi/src/runtime/funding/conversion.rs +++ b/rust/crates/truapi/src/runtime/funding/conversion.rs @@ -220,25 +220,13 @@ impl Chains { fee_ppm: psm.fee_ppm, })); } - let swaps = self.swap_out(asset, expected).await?.is_some(); - Ok(swaps.then_some(ConversionRoute::Pool)) - } - - /// CASH the pools return for `amount` of `asset`, through the native - /// token for a stablecoin, or `None` without a pool path. - async fn swap_out(&self, asset: DepositAsset, amount: u128) -> Result, ConversionError> { - let through_native = match asset { - DepositAsset::Native => Some(amount), - DepositAsset::Asset(id) => { - self.pool_quote("quote_price_exact_tokens_for_tokens", &self.places.asset_location(id), &native(), amount) - .await? - } - }; - let Some(native_amount) = through_native else { - return Ok(None); - }; - self.pool_quote("quote_price_exact_tokens_for_tokens", &native(), &self.places.cash(), native_amount) - .await + // A pool path counts only if it swaps enough to pay for execution on + // People and still land something. + match self.swap(asset, expected).await { + Ok(swap) if swap.min_cash > 2 * REMOTE_FEE_FLOOR => Ok(Some(ConversionRoute::Pool)), + Ok(_) | Err(ConversionError::Refused(_)) => Ok(None), + Err(error) => Err(error), + } } /// `asset` the pools take to return `cash`, through the native token for @@ -337,11 +325,7 @@ impl Chains { if target == 0 { return Err(ConversionError::Refused("the amount to credit is zero".into())); } - let claimed = target - .div_ceil(CLAIM_UNIT) - .checked_mul(CLAIM_UNIT) - .ok_or_else(too_large)?; - let send = teleported_for(claimed).ok_or_else(too_large)?; + let send = cash_to_teleport(target).ok_or_else(too_large)?; let psm = match asset { DepositAsset::Asset(id) if id != self.places.network.cash_asset_id => { self.psm(id).await?.map(|terms| (id, terms)) @@ -644,7 +628,11 @@ impl Chains { (ConversionRoute::Psm { .. }, DepositAsset::Native) => { return Err(ConversionError::Refused("the PSM mints only from assets".into())); } - (ConversionRoute::Pool, _) => Converter::Swap(self.swap(deposit.asset, spendable).await?), + (ConversionRoute::Pool, asset) => Converter::Swap(PoolSwap { + from: asset, + min_native: matches!(asset, DepositAsset::Asset(_)).then_some(1), + min_cash: 2 * REMOTE_FEE_FLOOR, + }), }; let signing = Signing { @@ -666,26 +654,41 @@ impl Chains { _ => 0, }; let mut allowance = fees.allowance; + let mut first_refusal = None; let (call, forwarded) = loop { let given = spendable .checked_sub(fees.dispatch) .and_then(|left| left.checked_sub(allowance)) - .filter(|left| *left > 0) - .ok_or_else(|| ConversionError::Refused("the deposit does not cover the fees".into()))?; - // A swap's least outputs are quoted for what it actually gives. + .filter(|left| *left > 0); + let Some(given) = given else { + return Err(ConversionError::Refused( + first_refusal.unwrap_or_else(|| "the deposit does not cover the fees".into()), + )); + }; + // A swap's least outputs are quoted for what it actually gives, + // and never below what credits the session's target. let converter = match converter { - Converter::Swap(_) => Converter::Swap(self.swap(deposit.asset, given).await?), + Converter::Swap(_) => { + let mut swap = self.swap(deposit.asset, given).await?; + if let Some(floor) = deposit.target.and_then(cash_to_teleport) { + swap.min_cash = swap.min_cash.max(floor); + } + Converter::Swap(swap) + } other => other, }; let call = self .measured(self.places.conversion_call(&account, given + allowance, allowance, converter)?) .await?; - match self.dry_run(&account, &call).await { + match self.dry_run_detailed(&account, &call).await? { Ok(forwarded) => break (call, forwarded), - Err(ConversionError::Refused(_)) if allowance < fees.allowance << retries => { + Err(refusal) if refusal.fees_short && allowance < fees.allowance << retries => { + first_refusal.get_or_insert(refusal.reason); allowance *= 2; } - Err(error) => return Err(error), + Err(refusal) => { + return Err(ConversionError::Refused(first_refusal.unwrap_or(refusal.reason))); + } } }; self.dry_run_on_people(&forwarded).await?; @@ -721,6 +724,24 @@ impl Chains { account: &[u8; 32], call: &ConversionCall, ) -> Result { + self.dry_run_detailed(account, call) + .await? + .map_err(|refusal| ConversionError::Refused(refusal.reason)) + } + + /// [`Self::dry_run`], telling a refusal for want of fees apart, which is + /// the one a larger fee allowance can cure. + async fn dry_run_detailed( + &self, + account: &[u8; 32], + call: &ConversionCall, + ) -> Result, ConversionError> { + let refused = |reason: String| { + Ok(Err(DryRunRefusal { + reason, + fees_short: false, + })) + }; let origin = Value::unnamed_variant( "system", [Value::unnamed_variant("Signed", [Value::from_bytes(account)])], @@ -734,26 +755,29 @@ impl Chains { .await?)?; let execution = field(&effects, "execution_result")?; if variant_name(execution) != Some("Ok") { - return Err(ConversionError::Refused(format!( - "dry run failed on Asset Hub: {execution}" - ))); + let names = module_error_names(self.asset_hub.metadata_ref(), execution); + return Ok(Err(DryRunRefusal { + reason: format!("dry run failed on Asset Hub: {} ({execution})", names.join(" / ")), + fees_short: names.iter().any(|name| name == "NotHoldingFees"), + })); } let events = field(&effects, "emitted_events")?; if mentions_variant(events, "AssetsTrapped") { - return Err(ConversionError::Refused("the conversion would trap assets on Asset Hub".into())); + return refused("the conversion would trap assets on Asset Hub".into()); } let forwarded = field(&effects, "forwarded_xcms")?; - items(forwarded) - .into_iter() - .find_map(|entry| { - let [destination, messages] = items(entry)[..] else { - return None; - }; - (parachain_of(destination) == Some(self.places.people_para)) - .then(|| items(messages).first().map(|message| (*message).clone())) - .flatten() - }) - .ok_or_else(|| ConversionError::Refused("the conversion forwards nothing to People".into())) + let to_people = items(forwarded).into_iter().find_map(|entry| { + let [destination, messages] = items(entry)[..] else { + return None; + }; + (parachain_of(destination) == Some(self.places.people_para)) + .then(|| items(messages).first().map(|message| (*message).clone())) + .flatten() + }); + match to_people { + Some(message) => Ok(Ok(message)), + None => refused("the conversion forwards nothing to People".into()), + } } /// Dry-run the forwarded `message` on People as Asset Hub sends it. @@ -1310,7 +1334,8 @@ fn native() -> Value { /// `amount` less the slippage headroom: a swap's least output. fn less_slippage(amount: u128) -> u128 { - amount / 100 * (100 - SWAP_SLIPPAGE_PERCENT) + let kept = 100 - SWAP_SLIPPAGE_PERCENT; + amount / 100 * kept + amount % 100 * kept / 100 } /// An output to ask the pools for so that, less the slippage headroom, it @@ -1319,6 +1344,78 @@ fn with_slippage_room(amount: u128) -> u128 { amount.saturating_mul(100).div_ceil(100 - SWAP_SLIPPAGE_PERCENT) } +/// CASH to teleport so that a top-up of `target`, rounded up to what one +/// claims, lands on People, or `None` on overflow. +fn cash_to_teleport(target: u128) -> Option { + teleported_for(target.div_ceil(CLAIM_UNIT).checked_mul(CLAIM_UNIT)?) +} + +/// Why a dry run refused a conversion, and whether more fees would cure it. +#[derive(Debug)] +struct DryRunRefusal { + reason: String, + fees_short: bool, +} + +/// The error names a failed dispatch's module error decodes to through +/// `metadata`: the pallet error, then any enum inside it, such +/// as the XCM error a failed local execution carries. +fn module_error_names(metadata: &subxt::Metadata, execution: &Value) -> Vec { + let Some(module) = find_variant(execution, "Module") else { + return Vec::new(); + }; + let Some(pallet_index) = field(module, "index").ok().and_then(|index| as_u128(index).ok()) else { + return Vec::new(); + }; + let bytes: Vec = field(module, "error") + .map(|error| { + items(unwrap_newtype(error)) + .into_iter() + .filter_map(|byte| as_u128(byte).ok().and_then(|byte| u8::try_from(byte).ok())) + .collect() + }) + .unwrap_or_default(); + let Some(pallet) = u8::try_from(pallet_index) + .ok() + .and_then(|index| metadata.pallet_by_error_index(index)) + else { + return Vec::new(); + }; + let Some(variant) = bytes.first().and_then(|byte| pallet.error_variant_by_index(*byte)) else { + return Vec::new(); + }; + let mut names = vec![variant.name.clone()]; + let mut cursor = 1; + for field in &variant.fields { + let Some(ty) = metadata.types().resolve(field.ty.id) else { + break; + }; + match &ty.type_def { + scale_info::TypeDef::Variant(inner) => { + if let Some(name) = bytes + .get(cursor) + .and_then(|byte| inner.variants.iter().find(|variant| variant.index == *byte)) + { + names.push(name.name.clone()); + } + cursor += 1; + } + _ => cursor += 1, + } + } + names +} + +/// The first variant named `name` anywhere in `value`. +fn find_variant<'a>(value: &'a Value, name: &str) -> Option<&'a Value> { + match &value.value { + ValueDef::Variant(variant) if variant.name == name => Some(value), + ValueDef::Variant(variant) => variant.values.values().find_map(|inner| find_variant(inner, name)), + ValueDef::Composite(composite) => composite.values().find_map(|inner| find_variant(inner, name)), + _ => None, + } +} + /// CASH set aside for execution on People out of `cash` teleported. fn remote_fee(cash: u128) -> u128 { (cash / 100 * REMOTE_FEE_PERCENT).max(REMOTE_FEE_FLOOR) @@ -1696,8 +1793,8 @@ mod tests { #[test] fn pool_swaps_encode_as_asset_hub_xcm_executes() { let metadata = asset_hub_metadata(); - let encoded = |from, min_native| { - PLACES + let program = |from, min_native| { + let call = PLACES .conversion_call( &[1; 32], 10_000_000_000, @@ -1708,26 +1805,133 @@ mod tests { min_cash: 1_000_000, }), ) - .expect("sized") - .runtime_call() - .encode(&metadata) - .map(|bytes| bytes[..2].to_vec()) + .expect("sized"); + let shape: Vec<_> = call + .program + .iter() + .map(|instruction| { + let name = variant_name(instruction).unwrap_or_default().to_string(); + let exchange = (name == "ExchangeAsset").then(|| { + let fields = variant_fields(instruction).expect("fields"); + let give = fields.values().next().and_then(variant_name).map(str::to_string); + let maximal = fields.values().nth(2).map(|maximal| maximal.to_string()); + (give, maximal) + }); + (name, exchange) + }) + .collect(); + (call.runtime_call().encode(&metadata).map(|bytes| bytes[..2].to_vec()), shape) }; let pallet = metadata.pallet_by_name("PolkadotXcm").expect("pallet"); let execute = vec![ pallet.call_index(), pallet.call_variant_by_name("execute").expect("call").index, ]; + let step = |name: &str| (name.to_string(), None); + let exchange = |give: &str| { + ( + "ExchangeAsset".to_string(), + Some((Some(give.to_string()), Some("true".to_string()))), + ) + }; + let tail = [step("InitiateTransfer"), step("RefundSurplus"), step("DepositAsset")]; assert_eq!( [ - encoded(DepositAsset::Native, None), - encoded(DepositAsset::Asset(1984), Some(1_000_000_000)), + program(DepositAsset::Native, None), + program(DepositAsset::Asset(1984), Some(1_000_000_000)), ], - [Ok(execute.clone()), Ok(execute)] + [ + ( + Ok(execute.clone()), + [vec![step("WithdrawAsset"), step("PayFees"), exchange("Definite")], tail.to_vec()] + .concat(), + ), + ( + Ok(execute), + [ + vec![ + step("WithdrawAsset"), + step("PayFees"), + exchange("Definite"), + exchange("Wild"), + ], + tail.to_vec(), + ] + .concat(), + ), + ] + ); + } + + // A swap's least output is its quote less the headroom, and asking the + // pools for `with_slippage_room` gives an output that, less the headroom, + // still covers what was wanted. A fee draft keeps a swap able to pay for + // People whatever its real outputs. + #[test] + fn slippage_room_covers_the_least_output_and_drafts_stay_payable() { + let drafted = Converter::Swap(PoolSwap { + from: DepositAsset::Asset(1984), + min_native: Some(77), + min_cash: 5, + }) + .drafted(); + let Converter::Swap(drafted) = drafted else { + panic!("a swap drafts as a swap"); + }; + + assert_eq!( + ( + less_slippage(2_000_000), + less_slippage(with_slippage_room(2_000_000)) >= 2_000_000, + (drafted.min_native, drafted.min_cash), + ), + (1_900_000, true, (Some(1), 2 * REMOTE_FEE_FLOOR)) ); } + // Only a dry run that ran out of fees is worth retrying with more; the + // XCM error inside a failed local execution is what says so. + #[test] + fn a_failed_execution_names_its_xcm_error() { + let metadata = asset_hub_metadata(); + let index = metadata.pallet_by_name("PolkadotXcm").expect("pallet").error_index(); + let incomplete = metadata + .pallet_by_name("PolkadotXcm") + .and_then(|pallet| pallet.error_variants()) + .and_then(|variants| { + variants + .iter() + .find(|variant| variant.name == "LocalExecutionIncompleteWithError") + }) + .expect("variant") + .index; + let execution = Value::unnamed_variant( + "Err", + [Value::named_composite([( + "error", + Value::named_variant( + "Module", + [ + ("index", Value::u128(index.into())), + ( + "error", + Value::unnamed_composite( + [incomplete, 4, 19, 0].map(|byte| Value::u128(byte.into())), + ), + ), + ], + ), + )])], + ); + + assert_eq!( + module_error_names(&metadata, &execution), + ["LocalExecutionIncompleteWithError", "NotHoldingFees"] + ); + } + + // Sized from the getcash formulas: the PSM keeps its fee, rounded up, // and every fee estimate gets a tenth more, rounded up. #[test] @@ -1890,6 +2094,7 @@ mod live { account, expected: 0, route, + target: None, } } @@ -2027,8 +2232,13 @@ mod live { .map(|_| ()); assert_eq!( - (route, native, stable), - (Ok(Some(ConversionRoute::Pool)), Ok(()), Ok(())) + (route, quote.map(|quote| quote.map(|quote| (quote.asset, quote.route))), native, stable), + ( + Ok(Some(ConversionRoute::Pool)), + Ok(Some((DepositAsset::Native, ConversionRoute::Pool))), + Ok(()), + Ok(()) + ) ); } diff --git a/rust/crates/truapi/src/runtime/funding/credit.rs b/rust/crates/truapi/src/runtime/funding/credit.rs index b26df0f3bf..1c26f369f3 100644 --- a/rust/crates/truapi/src/runtime/funding/credit.rs +++ b/rust/crates/truapi/src/runtime/funding/credit.rs @@ -273,6 +273,7 @@ mod tests { account: keypair(1).public.to_bytes(), expected: 2_000_000, route: ConversionRoute::Psm { fee_ppm: 5_000 }, + target: None, } } From 0248bb59ba635c627cb840e61b10117cafe71eb3 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 5 Oct 2026 17:03:35 +0200 Subject: [PATCH 12/20] feat(truapi-host-cli): stand-in top-up for funding-check --- .changeset/funding-check-stand-in-top-up.md | 5 + rust/crates/truapi-host-cli/Cargo.toml | 1 + .../truapi-host-cli/src/funding_check.rs | 168 ++++++++++++++++-- 3 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 .changeset/funding-check-stand-in-top-up.md diff --git a/.changeset/funding-check-stand-in-top-up.md b/.changeset/funding-check-stand-in-top-up.md new file mode 100644 index 0000000000..bdc906bd1f --- /dev/null +++ b/.changeset/funding-check-stand-in-top-up.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": patch +--- + +`truapi-host funding-check` follows a session to `Delivered`: a stand-in top-up checks each claim (the key controls the deposit account, the amount is within the CASH that landed) and reports it finalized without moving coins, since the CLI has no coinage engine. diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml index 607bbc2516..e925a58e38 100644 --- a/rust/crates/truapi-host-cli/Cargo.toml +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -38,6 +38,7 @@ ratatui = { workspace = true, features = ["crossterm", "unstable-rendered-line-i reqwest = { workspace = true, features = ["json", "rustls-tls"] } rqrr = { workspace = true } rustls = { workspace = true, features = ["ring"] } +schnorrkel = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/rust/crates/truapi-host-cli/src/funding_check.rs b/rust/crates/truapi-host-cli/src/funding_check.rs index 460346c525..efd5b1e29f 100644 --- a/rust/crates/truapi-host-cli/src/funding_check.rs +++ b/rust/crates/truapi-host-cli/src/funding_check.rs @@ -2,23 +2,31 @@ //! //! The command opens a funding session on a signing host, prints the deposit //! address its provider would pay, and follows the session while someone pays -//! that address from any funded account. It ends once the CASH lands on -//! People, or the session fails. +//! that address from any funded account. It ends once the session is +//! credited, or fails. The CLI has no coinage engine, so a stand-in top-up +//! checks the claim and reports it without moving coins. //! //! Sessions and account counters live under the state directory, so a second //! run with `--intent` picks up the same session, and no run reuses an //! account. +use std::collections::HashMap; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex, OnceLock, Weak}; use std::time::Duration; use anyhow::{Context, Result, bail}; use clap::ValueEnum; +use futures::stream::{self, BoxStream, StreamExt}; use truapi::host_logic::funding::{DepositAsset, DepositRequest, FundingStage}; -use truapi::latest::{FundingDirection, GenericError, HostFundingStatusSubscribeItem}; +use truapi::latest::{ + FundingDirection, GenericError, HostFundingStatusSubscribeItem, HostPaymentTopUpError, + HostPaymentTopUpRequest, HostPaymentTopUpStatusSubscribeError, + HostPaymentTopUpStatusSubscribeItem, PaymentTopUpSource, +}; use truapi::platform::{ - FundingPlatform, FundingPresentOutcome, FundingPresentation, ProductContext, async_trait, + FundingPlatform, FundingPresentOutcome, FundingPresentation, ProductContext, TopUpPlatform, + async_trait, }; use truapi::{FundingNetwork, SigningHostRuntime}; @@ -92,6 +100,100 @@ impl FundingPlatform for TerminalFundingHost { } } +/// A top-up that stands in for a host's coinage engine, which the CLI does +/// not have. +/// +/// It checks what a real claim would rest on: the source key controls the +/// session's deposit account, and the amount is within the CASH core saw land +/// on People. Then it reports the claim finalized without moving anything, so +/// a run reaches `Delivered` with every core step real except the claim. +struct StandInTopUp { + runtime: OnceLock>, + intent: OnceLock, + claimed: Mutex>, +} + +impl StandInTopUp { + fn new() -> Self { + Self { + runtime: OnceLock::new(), + intent: OnceLock::new(), + claimed: Mutex::new(HashMap::new()), + } + } + + /// Why the claim in `request` would fail, if it would. + fn refusal(&self, request: &HostPaymentTopUpRequest) -> Option<&'static str> { + let PaymentTopUpSource::PrivateKey { sr25519_secret_key } = &request.source else { + return Some("the source is not a private key"); + }; + let Ok(secret) = schnorrkel::SecretKey::from_bytes(sr25519_secret_key) else { + return Some("the source key is not a schnorrkel secret"); + }; + let session = self + .runtime + .get() + .and_then(Weak::upgrade) + .zip(self.intent.get()) + .and_then(|(runtime, intent)| runtime.funding_session(intent)); + let Some(session) = session else { + return Some("no session to claim for"); + }; + let landed = match session.stage { + FundingStage::Converted { landed } | FundingStage::Crediting { landed, .. } => landed, + _ => return Some("no CASH has landed for the session"), + }; + if session.deposit.map(|deposit| deposit.account) != Some(secret.to_public().to_bytes()) { + return Some("the source key does not control the deposit account"); + } + (request.amount > landed).then_some("the amount exceeds the CASH that landed") + } +} + +#[async_trait] +impl TopUpPlatform for StandInTopUp { + async fn top_up( + &self, + _product: &ProductContext, + request: HostPaymentTopUpRequest, + ) -> Result<(), HostPaymentTopUpError> { + if let Some(reason) = self.refusal(&request) { + println!("stand-in top-up refused: {reason}"); + return Err(HostPaymentTopUpError::InvalidSource); + } + let mut claimed = self.claimed.lock().expect("claims mutex poisoned"); + if claimed.contains_key(&request.id) { + return Err(HostPaymentTopUpError::AlreadyExists); + } + println!( + "stand-in top-up: would claim {} CASH units into the balance (no coins moved)", + request.amount + ); + claimed.insert(request.id, request.amount); + Ok(()) + } + + fn subscribe_top_up_status( + &self, + _product: &ProductContext, + id: [u8; 32], + ) -> BoxStream< + 'static, + Result, + > { + let known = self + .claimed + .lock() + .expect("claims mutex poisoned") + .contains_key(&id); + let status = match known { + true => Ok(HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true }), + false => Err(HostPaymentTopUpStatusSubscribeError::NotFound), + }; + stream::iter([status]).boxed() + } +} + /// What to run. pub struct FundingCheck { /// Mnemonic of the identity whose funding accounts are used. @@ -124,6 +226,9 @@ pub async fn run( .await .map_err(|error| anyhow::anyhow!("activating the signer failed: {}", error.reason))?; runtime.set_funding_platform(Arc::new(TerminalFundingHost)); + let top_up = Arc::new(StandInTopUp::new()); + let _ = top_up.runtime.set(Arc::downgrade(&runtime)); + runtime.set_top_up_platform(top_up.clone()); runtime.enable_funding_conversion(FundingNetwork { cash_asset_id: assets.cash, }); @@ -132,6 +237,7 @@ pub async fn run( Some(intent) => intent, None => open_and_assign(&runtime, &assets, check.asset, check.amount).await?, }; + let _ = top_up.intent.set(intent.clone()); follow(&runtime, &intent).await } @@ -194,19 +300,61 @@ async fn follow(runtime: &SigningHostRuntime, intent: &str) -> Result<()> { last = Some(session.stage.clone()); } match session.stage { - FundingStage::Converted { landed } => { - println!("landed {landed} CASH units on People"); - return Ok(()); - } FundingStage::Failed { reason, .. } => bail!("the session failed: {reason:?}"), FundingStage::Delivered { credited, .. } => { - println!("credited {credited} CASH units to the balance"); + println!("credited {credited} CASH units (stand-in top-up: no coins moved)"); return Ok(()); } FundingStage::Open | FundingStage::Converting { .. } + | FundingStage::Converted { .. } | FundingStage::Crediting { .. } => {} } tokio::time::sleep(POLL).await; } } + +#[cfg(test)] +mod tests { + use super::*; + + fn request(source: PaymentTopUpSource) -> HostPaymentTopUpRequest { + HostPaymentTopUpRequest { + into: None, + amount: 1_000, + source, + id: [1; 32], + } + } + + // The stand-in must refuse what a real coinage engine would, or a run + // that reaches `Delivered` proves nothing about the claim core asked for. + #[test] + fn the_stand_in_refuses_claims_a_real_engine_would() { + let top_up = StandInTopUp::new(); + let secret = schnorrkel::MiniSecretKey::from_bytes(&[7; 32]) + .expect("seed") + .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519) + .secret + .to_bytes(); + + assert_eq!( + [ + top_up.refusal(&request(PaymentTopUpSource::Coins { + sr25519_secret_keys: vec![secret], + })), + top_up.refusal(&request(PaymentTopUpSource::PrivateKey { + sr25519_secret_key: [0xff; 64], + })), + top_up.refusal(&request(PaymentTopUpSource::PrivateKey { + sr25519_secret_key: secret, + })), + ], + [ + Some("the source is not a private key"), + Some("the source key is not a schnorrkel secret"), + Some("no session to claim for"), + ] + ); + } +} From 5372f591769f857e336bd2a6b3a1f275cc70e7c2 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 5 Oct 2026 17:20:30 +0200 Subject: [PATCH 13/20] chore: lock schnorrkel for truapi-host-cli --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index fa1aa05eb7..8fcbf68d8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5625,6 +5625,7 @@ dependencies = [ "rqrr", "rustix", "rustls", + "schnorrkel", "serde", "serde_json", "sha2 0.10.9", From 177b0d3e25355638bb6832d994fb5358bb7ea87b Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 5 Oct 2026 17:17:19 +0200 Subject: [PATCH 14/20] feat(truapi): native bridge for funding and top-ups --- .changeset/funding-native-bridge.md | 5 + rust/crates/truapi/RUNTIME.md | 5 + rust/crates/truapi/src/host_logic/funding.rs | 32 ++ rust/crates/truapi/src/lib.rs | 20 ++ rust/crates/truapi/src/native.rs | 4 +- rust/crates/truapi/src/native/callbacks.rs | 51 +++ rust/crates/truapi/src/native/errors.rs | 10 + rust/crates/truapi/src/native/platform.rs | 342 ++++++++++++++++++- rust/crates/truapi/src/native/runtime.rs | 90 ++++- rust/crates/truapi/src/platform.rs | 1 + rust/crates/truapi/src/v01/funding.rs | 12 + rust/crates/truapi/src/v01/payment.rs | 22 +- 12 files changed, 586 insertions(+), 8 deletions(-) create mode 100644 .changeset/funding-native-bridge.md diff --git a/.changeset/funding-native-bridge.md b/.changeset/funding-native-bridge.md new file mode 100644 index 0000000000..728fdea293 --- /dev/null +++ b/.changeset/funding-native-bridge.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": patch +--- + +Native hosts drive funding through `NativeTrUApiHostRuntime`: `set_funding_callbacks` installs the overlay, `set_top_up_callbacks` and `notify_top_up_status` the top-up engine, and `enable_funding_conversion`, `open_funding`, `quote_funding_deposit`, `assign_funding_deposit` and `funding_session` run the on-ramp. Amounts cross the FFI as decimal strings. diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 99abe25ed8..1cb09806cd 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -416,6 +416,11 @@ AutoSigning without approval. Legacy-account signing still asks the user. Hub and the message it forwards on People. Once the CASH lands on People, the core credits it through `TopUpPlatform` with the deposit account's key as a `PrivateKey` source, and the session ends `Delivered`. + Native hosts reach all of this through `NativeTrUApiHostRuntime`: + `set_funding_callbacks` (the overlay), `set_top_up_callbacks` with + `notify_top_up_status` (the top-up engine), `enable_funding_conversion`, + `open_funding`, `quote_funding_deposit`, `assign_funding_deposit` and + `funding_session`. Amounts cross the FFI as decimal strings. - `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/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index 4d024cc973..fc7e6c9f39 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -24,6 +24,10 @@ const SETTLED_HISTORY_LIMIT: usize = 50; /// What the core knows about one session, independent of any host surface. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct FundingSession { /// Identifier handed back to the caller and used to re-attach. pub intent: String, @@ -52,6 +56,10 @@ pub struct FundingSession { /// The route for a deposit and what it must deliver to credit a session's /// amount. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct DepositQuote { /// Asset the deposit is quoted in. pub asset: DepositAsset, @@ -63,6 +71,10 @@ pub struct DepositQuote { /// Asset Hub asset a deposit arrives in. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum DepositAsset { /// The relay chain's native token. Native, @@ -72,6 +84,10 @@ pub enum DepositAsset { /// What an inbound session's provider delivers, once it is chosen. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct DepositRequest { /// Deposit source, as in the account label, such as `usdt-assethub`. pub source_id: String, @@ -83,6 +99,10 @@ pub struct DepositRequest { /// The account an inbound session watches and what it waits for. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct FundingDeposit { /// Deposit source, as in the account label. pub source_id: String, @@ -104,6 +124,10 @@ pub struct FundingDeposit { /// How a deposit becomes CASH on People, fixed when its account is assigned /// so a later change on chain cannot switch it mid-session. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum ConversionRoute { /// The deposit is CASH already: teleport it. Teleport, @@ -122,6 +146,10 @@ pub enum ConversionRoute { /// A conversion transaction handed to Asset Hub, with what tells whether it /// worked. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct ConversionSubmission { /// The deposit account's nonce the transaction was signed at. pub nonce: u32, @@ -142,6 +170,10 @@ const MAX_CONVERSION_REFUSALS: u8 = 3; /// Stage of a session, as the core persists it. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum FundingStage { /// In flight. Open, diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 08a34aa988..7453035e13 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -66,6 +66,26 @@ uniffi::custom_type!(Bytes32, Vec, { try_lift: |bytes| Ok(bytes.as_slice().try_into()?), }); +/// A 64-byte value, such as an sr25519 secret key, passed as plain bytes on +/// FFI surfaces. +pub type Bytes64 = [u8; 64]; + +#[cfg(all(feature = "runtime", not(target_arch = "wasm32")))] +uniffi::custom_type!(Bytes64, Vec, { + remote, + lower: |bytes| bytes.to_vec(), + try_lift: |bytes| Ok(bytes.as_slice().try_into()?), +}); + +// Swift and Kotlin have no 128-bit integer, so amounts cross FFI surfaces as +// decimal strings. +#[cfg(all(feature = "runtime", not(target_arch = "wasm32")))] +uniffi::custom_type!(u128, String, { + remote, + lower: |amount| amount.to_string(), + try_lift: |amount| Ok(amount.parse()?), +}); + /// Latest-version protocol payload types, unwrapped from their versioned /// envelopes. Runtime code should use these instead of per-version modules. pub mod latest { diff --git a/rust/crates/truapi/src/native.rs b/rust/crates/truapi/src/native.rs index 37813d19db..3ee265faf8 100644 --- a/rust/crates/truapi/src/native.rs +++ b/rust/crates/truapi/src/native.rs @@ -29,8 +29,8 @@ mod ws_bridge; pub use crate::host_internal::sso_messages::SsoRequestOutcome; pub use crate::host_logic::dotns::{NavigateDecision, PocketDeeplinkAction}; pub use callbacks::{ - HostCallbacks, NativeChatCallbacks, NativeContactsCallbacks, NativePocketCallbacks, - NativePocketRemoval, + HostCallbacks, NativeChatCallbacks, NativeContactsCallbacks, NativeFundingCallbacks, + 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 976400d9d0..a062b72412 100644 --- a/rust/crates/truapi/src/native/callbacks.rs +++ b/rust/crates/truapi/src/native/callbacks.rs @@ -306,3 +306,54 @@ pub trait NativeContactsCallbacks: Send + Sync { product_id: String, ) -> Result; } + +/// Native funding overlay. A host with a funding modality passes an +/// implementation to [`NativeTrUApiHostRuntime::set_funding_callbacks`]; +/// without one, funding requests answer `Unsupported`. +/// +/// [`NativeTrUApiHostRuntime::set_funding_callbacks`]: super::NativeTrUApiHostRuntime::set_funding_callbacks +#[uniffi::export(rust, foreign)] +#[async_trait::async_trait] +pub trait NativeFundingCallbacks: Send + Sync { + /// Show the funding overlay for session `intent`, which `product_id` + /// opened, or the host itself when `None`, on the screen `direction` + /// names, and report whether the user started or dismissed it. `amount` + /// is a decimal string of CASH units. + async fn present_funding( + &self, + product_id: Option, + intent: String, + direction: v01::FundingDirection, + amount: Option, + ) -> Result; + + /// A session's status changed, for host UI such as the in-flight pill. + fn funding_session_changed(&self, intent: String, status: v01::HostFundingStatusSubscribeItem); +} + +/// 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 +/// status with [`NativeTrUApiHostRuntime::notify_top_up_status`]. +/// +/// [`NativeTrUApiHostRuntime::set_top_up_callbacks`]: super::NativeTrUApiHostRuntime::set_top_up_callbacks +/// [`NativeTrUApiHostRuntime::notify_top_up_status`]: super::NativeTrUApiHostRuntime::notify_top_up_status +#[uniffi::export(rust, foreign)] +#[async_trait::async_trait] +pub trait NativeTopUpCallbacks: Send + Sync { + /// Start top-up `request` for `product_id`, returning once the host has + /// accepted it. Its amount is a decimal string of CASH units. + async fn top_up( + &self, + product_id: String, + request: v01::HostPaymentTopUpRequest, + ) -> Result<(), v01::HostPaymentTopUpError>; + + /// The current status of `product_id`'s top-up `id`, or `None` when the + /// host holds no such top-up. + fn top_up_status( + &self, + product_id: String, + id: crate::Bytes32, + ) -> Result, HostRejection>; +} diff --git a/rust/crates/truapi/src/native/errors.rs b/rust/crates/truapi/src/native/errors.rs index 3d95108e6d..626b981739 100644 --- a/rust/crates/truapi/src/native/errors.rs +++ b/rust/crates/truapi/src/native/errors.rs @@ -50,6 +50,16 @@ impl From for v01::HostLocalStorageReadEr } } +impl From for v01::HostPaymentTopUpError { + fn from(err: uniffi::UnexpectedUniFFICallbackError) -> Self { + tracing::warn!( + reason = %err.reason, + "host callback threw an undeclared error; reporting it as a rejection" + ); + v01::HostPaymentTopUpError::Unknown { reason: err.reason } + } +} + impl From for v01::HostNavigateToError { fn from(err: uniffi::UnexpectedUniFFICallbackError) -> Self { tracing::warn!( diff --git a/rust/crates/truapi/src/native/platform.rs b/rust/crates/truapi/src/native/platform.rs index 871775d3e7..d1f258e99c 100644 --- a/rust/crates/truapi/src/native/platform.rs +++ b/rust/crates/truapi/src/native/platform.rs @@ -16,8 +16,8 @@ use crate::host_logic::worker::WorkerTransition; use crate::{DevicePairingObserver, PairedSsoPeer}; use super::callbacks::{ - HostCallbacks, NativeChatCallbacks, NativeContactsCallbacks, NativePocketCallbacks, - NativePocketRemoval, + HostCallbacks, NativeChatCallbacks, NativeContactsCallbacks, NativeFundingCallbacks, + NativePocketCallbacks, NativePocketRemoval, NativeTopUpCallbacks, }; use super::errors::HostRejection; use super::events::NativeEventBus; @@ -632,3 +632,341 @@ impl crate::platform::PocketPlatform for PocketCallbackPlatform { } } } + +/// [`crate::platform::FundingPlatform`] served by host-provided +/// [`NativeFundingCallbacks`]. +pub struct FundingCallbackPlatform { + /// Host funding overlay. + pub funding: Arc, +} + +#[async_trait] +impl crate::platform::FundingPlatform for FundingCallbackPlatform { + async fn present_funding( + &self, + product: Option<&ProductContext>, + session: crate::platform::FundingPresentation, + ) -> Result { + self.funding + .present_funding( + product.map(|product| product.product_id.clone()), + session.intent, + session.direction, + session.amount, + ) + .await + .map_err(v01::GenericError::from) + } + + fn funding_session_changed(&self, intent: String, status: v01::HostFundingStatusSubscribeItem) { + self.funding.funding_session_changed(intent, status); + } +} + +type TopUpStatus = + Result; + +/// One subscription to a top-up's status. +struct TopUpFollower { + /// Tells this subscription apart from others on the same top-up. + subscription: u64, + sender: mpsc::UnboundedSender, +} + +/// Who follows which top-up, per product and id. +#[derive(Default)] +struct TopUpFollowers { + next: u64, + by_top_up: std::collections::HashMap<(String, crate::Bytes32), Vec>, +} + +impl TopUpFollowers { + /// Forget followers whose streams were dropped. + fn prune(&mut self) { + self.by_top_up.retain(|_, senders| { + senders.retain(|follower| !follower.sender.is_closed()); + !senders.is_empty() + }); + } +} + +/// [`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, +} + +impl TopUpCallbackPlatform { + /// Serve top-ups from `top_up`. + pub fn new(top_up: Arc) -> Self { + Self { + top_up, + followers: Mutex::new(TopUpFollowers::default()), + } + } + + /// Deliver a later status of `product_id`'s top-up `id` to everyone + /// following it; a terminal one ends their streams. + pub fn notify_status( + &self, + product_id: String, + 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") + } +} + +/// 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 { + v01::HostPaymentTopUpStatusSubscribeItem::Detecting => 0, + v01::HostPaymentTopUpStatusSubscribeItem::Claiming => 1, + v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: false } => 2, + v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true } + | v01::HostPaymentTopUpStatusSubscribeItem::ClaimedPartially { .. } + | v01::HostPaymentTopUpStatusSubscribeItem::NotClaimed => TERMINAL_RANK, + } +} + +#[async_trait] +impl crate::platform::TopUpPlatform for TopUpCallbackPlatform { + async fn top_up( + &self, + product: &ProductContext, + request: v01::HostPaymentTopUpRequest, + ) -> Result<(), v01::HostPaymentTopUpError> { + self.top_up.top_up(product.product_id.clone(), request).await + } + + fn subscribe_top_up_status( + &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); + } + followers.prune(); + return stream::iter([first]).boxed(); + } + 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; + } + 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 { + use super::*; + + use futures::executor::block_on; + + use crate::platform::TopUpPlatform; + + /// A top-up engine holding one status per id, and nothing else. + struct Engine(Mutex>); + + #[async_trait::async_trait] + impl NativeTopUpCallbacks for Engine { + async fn top_up( + &self, + _product_id: String, + _request: v01::HostPaymentTopUpRequest, + ) -> Result<(), v01::HostPaymentTopUpError> { + Ok(()) + } + + fn top_up_status( + &self, + _product_id: String, + id: crate::Bytes32, + ) -> Result, HostRejection> { + Ok(self.0.lock().expect("statuses").get(&id).cloned()) + } + } + + fn product() -> ProductContext { + ProductContext { + product_id: "fund.dot".into(), + execution_kind: Default::default(), + } + } + + // A follower sees the status the host holds now, then every status the + // host pushes, and its stream ends with the claim's verdict, so core's + // credit step and a product's subscription both learn how it ended. + #[test] + fn a_top_up_is_followed_from_its_current_status_to_its_verdict() { + let engine = Arc::new(Engine(Mutex::new(std::collections::HashMap::from([( + [1; 32], + v01::HostPaymentTopUpStatusSubscribeItem::Detecting, + )])))); + let platform = TopUpCallbackPlatform::new(engine); + let followed = platform.subscribe_top_up_status(&product(), [1; 32]); + let missing = platform.subscribe_top_up_status(&product(), [2; 32]); + + platform.notify_status( + "fund.dot".into(), + [1; 32], + v01::HostPaymentTopUpStatusSubscribeItem::Claiming, + ); + platform.notify_status( + "fund.dot".into(), + [1; 32], + v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true }, + ); + + assert_eq!( + ( + block_on(followed.collect::>()), + block_on(missing.collect::>()), + platform.followers.lock().expect("followers").by_top_up.len(), + ), + ( + vec![ + Ok(v01::HostPaymentTopUpStatusSubscribeItem::Detecting), + Ok(v01::HostPaymentTopUpStatusSubscribeItem::Claiming), + Ok(v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true }), + ], + vec![Err(v01::HostPaymentTopUpStatusSubscribeError::NotFound)], + 0, + ) + ); + } + + // Core's credit step subscribes afresh on every pass. Its subscription + // seeing a verdict in the host's store must not end a product's stream + // that still waits for the host to push that verdict. + #[test] + fn a_subscriber_that_sees_the_verdict_leaves_other_followers_waiting() { + let engine = Arc::new(Engine(Mutex::new(std::collections::HashMap::from([( + [1; 32], + v01::HostPaymentTopUpStatusSubscribeItem::Claiming, + )])))); + let platform = TopUpCallbackPlatform::new(engine.clone()); + let product_stream = platform.subscribe_top_up_status(&product(), [1; 32]); + engine.0.lock().expect("statuses").insert( + [1; 32], + v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true }, + ); + let core_pass = platform.subscribe_top_up_status(&product(), [1; 32]); + let verdict = v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true }; + platform.notify_status("fund.dot".into(), [1; 32], verdict.clone()); + + assert_eq!( + ( + block_on(core_pass.collect::>()), + block_on(product_stream.collect::>()), + ), + ( + vec![Ok(verdict.clone())], + vec![ + Ok(v01::HostPaymentTopUpStatusSubscribeItem::Claiming), + Ok(verdict) + ], + ) + ); + } + + // A status pushed while the snapshot is read reaches the follower too; + // a stream shows each status once and never steps back, and a follower + // that stops listening is forgotten. + #[test] + fn a_stream_never_repeats_or_steps_back_and_dropped_followers_are_forgotten() { + let engine = Arc::new(Engine(Mutex::new(std::collections::HashMap::from([( + [1; 32], + v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: false }, + )])))); + let platform = TopUpCallbackPlatform::new(engine); + let followed = platform.subscribe_top_up_status(&product(), [1; 32]); + let dropped = platform.subscribe_top_up_status(&product(), [1; 32]); + drop(dropped); + for status in [ + v01::HostPaymentTopUpStatusSubscribeItem::Claiming, + v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: false }, + v01::HostPaymentTopUpStatusSubscribeItem::NotClaimed, + ] { + platform.notify_status("fund.dot".into(), [1; 32], status); + } + let _ = platform.subscribe_top_up_status(&product(), [2; 32]); + + assert_eq!( + ( + block_on(followed.collect::>()), + platform.followers.lock().expect("followers").by_top_up.len(), + ), + ( + vec![ + Ok(v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: false }), + Ok(v01::HostPaymentTopUpStatusSubscribeItem::NotClaimed), + ], + 0, + ) + ); + } +} diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index 5d62ff1a58..f7bdc0e02b 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, Weak}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; use crate::platform::{ CoreAdmin, PermissionAuthorizationRequest, PermissionAuthorizationStatus, ProductContext, @@ -25,7 +25,8 @@ use crate::subscription::Spawner; use crate::{PairedSsoPeer, ResponderExit, SigningHostRuntime}; use super::callbacks::{ - HostCallbacks, NativeChatCallbacks, NativeContactsCallbacks, NativePocketCallbacks, + HostCallbacks, NativeChatCallbacks, NativeContactsCallbacks, NativeFundingCallbacks, + NativePocketCallbacks, NativeTopUpCallbacks, }; use super::config::{ HostRuntimeConfig, NativeResolvedHostRuntimeConfig, NativeRuntimeConfigError, @@ -35,7 +36,8 @@ use super::errors::{HostRejection, NativeCoreDatabaseError}; use super::executor::shared_native_executor; use super::events::NativeEventBus; use super::platform::{ - CallbackPlatform, ChatCallbackPlatform, ContactsCallbackPlatform, PocketCallbackPlatform, + CallbackPlatform, ChatCallbackPlatform, ContactsCallbackPlatform, FundingCallbackPlatform, + PocketCallbackPlatform, TopUpCallbackPlatform, }; #[cfg(doc)] use crate::WorkerTransition; @@ -51,6 +53,9 @@ pub struct NativeTrUApiHostRuntime { ws_bridge: Arc, /// The one Worker execution per product; opening another replaces it. worker_executions: Mutex>>, + /// The host's top-up engine, once installed, which later statuses are + /// pushed through. + top_up: OnceLock>, } impl NativeTrUApiHostRuntime { @@ -116,6 +121,7 @@ impl NativeTrUApiHostRuntime { callbacks.on_core_log(marker.to_string(), detail.to_string()); }))), worker_executions: Mutex::new(HashMap::new()), + top_up: OnceLock::new(), })) } @@ -237,6 +243,84 @@ pub struct NativeAnnouncedPairing { inner: AnnouncedPairing, } +#[uniffi::export] +impl NativeTrUApiHostRuntime { + /// Install the host's funding overlay. Set-once; answers whether this + /// call installed it. Call it before opening any product execution. + pub fn set_funding_callbacks(&self, callbacks: Arc) -> bool { + self.runtime + .set_funding_platform(Arc::new(FundingCallbackPlatform { funding: callbacks })) + } + + /// Install the host's top-up engine. Set-once; answers whether this call + /// installed it. Report each later status with + /// [`Self::notify_top_up_status`]. + pub fn set_top_up_callbacks(&self, callbacks: Arc) -> bool { + let platform = Arc::new(TopUpCallbackPlatform::new(callbacks)); + self.runtime.set_top_up_platform(platform.clone()) && self.top_up.set(platform).is_ok() + } + + /// Report a later status of `product_id`'s top-up `id` to the products + /// and core following it. + pub fn notify_top_up_status( + &self, + product_id: String, + id: crate::Bytes32, + status: v01::HostPaymentTopUpStatusSubscribeItem, + ) { + if let Some(platform) = self.top_up.get() { + platform.notify_status(product_id, id, status); + } + } + + /// Convert funding deposits into CASH on People, where CASH is Asset + /// Hub asset `cash_asset_id`. Set-once; answers whether this call + /// enabled it. + pub fn enable_funding_conversion(&self, cash_asset_id: u32) -> bool { + self.runtime + .enable_funding_conversion(crate::FundingNetwork { cash_asset_id }) + } + + /// Open a funding session on the host's own behalf, as the Balance + /// card does, and show the overlay. Answers the session id, or `None` + /// when the user dismissed it. + pub async fn open_funding( + &self, + direction: v01::FundingDirection, + amount: Option, + ) -> Result, HostRejection> { + Ok(self.runtime.open_funding(direction, amount).await?) + } + + /// The deposit of `asset` a provider must deliver to credit session + /// `intent`'s amount. + pub async fn quote_funding_deposit( + &self, + intent: String, + asset: crate::host_logic::funding::DepositAsset, + ) -> Result { + Ok(self.runtime.quote_funding_deposit(&intent, asset).await?) + } + + /// Give session `intent` the deposit account its provider pays into. + pub async fn assign_funding_deposit( + &self, + intent: String, + request: crate::host_logic::funding::DepositRequest, + ) -> Result { + Ok(self.runtime.assign_funding_deposit(&intent, request).await?) + } + + /// Session `intent` as the core holds it, for the host's status and + /// history views. + pub fn funding_session( + &self, + intent: String, + ) -> Option { + self.runtime.funding_session(&intent) + } +} + #[uniffi::export] impl NativeTrUApiHostRuntime { /// Construct one host-level runtime and optionally activate its local session. diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index 4694c40a8a..177e16f706 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -3302,6 +3302,7 @@ pub struct FundingPresentation { /// How the user left the funding overlay. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Enum))] pub enum FundingPresentOutcome { /// The user went ahead; the session runs on without the overlay. Started, diff --git a/rust/crates/truapi/src/v01/funding.rs b/rust/crates/truapi/src/v01/funding.rs index 93df7908d4..9bbca7da7a 100644 --- a/rust/crates/truapi/src/v01/funding.rs +++ b/rust/crates/truapi/src/v01/funding.rs @@ -3,6 +3,10 @@ use parity_scale_codec::{Decode, Encode}; /// Which way value crosses the boundary between the user's Polkadot balance /// and everything outside it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum FundingDirection { /// Value moves in. The host confirms arrival by observing the chain. In, @@ -16,6 +20,10 @@ pub enum FundingDirection { /// Carries an outcome, never a rule, so a client renders these without holding /// any part of the operator's compliance logic. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum FundingFailure { /// Not offered in the user's market. RegionUnavailable, @@ -92,6 +100,10 @@ pub struct HostFundingStatusSubscribeRequest { /// the host saw them leave under the user's authorization, and says nothing /// about the off-chain leg: no host can verify that cash reached a bank. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum HostFundingStatusSubscribeItem { /// Inbound: awaiting the user's deposit. AwaitingDeposit { diff --git a/rust/crates/truapi/src/v01/payment.rs b/rust/crates/truapi/src/v01/payment.rs index 0b169cfa45..f331720b36 100644 --- a/rust/crates/truapi/src/v01/payment.rs +++ b/rust/crates/truapi/src/v01/payment.rs @@ -26,6 +26,10 @@ pub struct HostPaymentBalanceSubscribeItem { /// /// [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 PaymentTopUpSource { /// Fund from one of the calling product's scoped accounts. ProductAccount { @@ -51,6 +55,10 @@ pub enum PaymentTopUpSource { /// Request to top up the product payment balance. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct HostPaymentTopUpRequest { /// Optional purse selector. `None` means MAIN_PURSE. pub into: Option, @@ -127,15 +135,23 @@ pub enum HostPaymentBalanceSubscribeError { /// 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 HostPaymentTopUpError { /// The source key is malformed, or the source was not found. + #[display("invalid top-up source")] InvalidSource, /// A top-up with this id already exists. + #[display("a top-up with this id already exists")] AlreadyExists, /// Another top-up from the same source is still running. + #[display("another top-up from this source is running")] SourceBusy, /// Catch-all. + #[display("{reason}")] Unknown { /// Human-readable failure reason. reason: String, @@ -193,6 +209,10 @@ pub struct HostPaymentTopUpStatusSubscribeRequest { /// Progress of a top-up. `Claimed { finalized: true }`, `ClaimedPartially` and /// `NotClaimed` are terminal, and stay readable after the top-up ends. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum HostPaymentTopUpStatusSubscribeItem { /// Waiting for the source's funds to be seen. Detecting, From 645fabf6ea86065ea496f839c2654323fb76acf9 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Tue, 6 Oct 2026 11:42:25 +0200 Subject: [PATCH 15/20] fix(truapi): refuse the funding product's subtree secret --- rust/crates/truapi/src/platform.rs | 5 +++++ rust/crates/truapi/src/runtime/signing_host.rs | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index 1df0dbab15..a0d19e8c7f 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -2587,6 +2587,11 @@ mod tests { None, ), (CoreStorageKey::FundingSessions, "FundingSessions", None), + ( + CoreStorageKey::FundingAccountCounters, + "FundingAccountCounters", + None, + ), ] { let description = describe_core_storage_key(&key.encode()).expect("valid key"); assert_eq!(description.kind, kind); diff --git a/rust/crates/truapi/src/runtime/signing_host.rs b/rust/crates/truapi/src/runtime/signing_host.rs index 125063fb1b..bd6148627e 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -369,6 +369,12 @@ impl SigningHost { reason: err.to_string(), } })?; + // The AutoSigning allocation hands this secret to the calling + // product; under the funding product it would open every deposit + // account. + if super::is_funding_product(&product_id) { + return Err(AuthorityError::Rejected); + } derive_product_subtree_keypair(&root, &product_id) .map(|keypair| keypair.secret.to_bytes()) .map_err(product_authority_error) @@ -4015,7 +4021,8 @@ mod tests { } // Every product path, the SSO responder's included, derives keys through - // these two calls, so refusing here keeps the funding accounts host-only. + // these calls, AutoSigning's subtree secret among them, so refusing here + // keeps the funding accounts host-only. #[test] fn no_request_derives_a_funding_key() { let (_services, authority) = signing_runtime(); @@ -4047,8 +4054,13 @@ mod tests { true, )) .map(|_| ()), + authority.product_subtree_secret("fund.dot").map(|_| ()), ), - (Err(AuthorityError::Rejected), Err(AuthorityError::Rejected)) + ( + Err(AuthorityError::Rejected), + Err(AuthorityError::Rejected), + Err(AuthorityError::Rejected) + ) ); } From 211f2e767a4e8f7c71c8b1f0c32f740d8f702140 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Tue, 6 Oct 2026 11:46:40 +0200 Subject: [PATCH 16/20] fix(truapi): normalize the paired entropy guard and say whose accounts these are --- .changeset/funding-getcash-derivation.md | 2 +- rust/crates/truapi/src/host_logic/funding.rs | 5 +++-- rust/crates/truapi/src/runtime/pairing_host.rs | 6 +++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.changeset/funding-getcash-derivation.md b/.changeset/funding-getcash-derivation.md index 251450d897..4dfdcd6d0a 100644 --- a/.changeset/funding-getcash-derivation.md +++ b/.changeset/funding-getcash-derivation.md @@ -2,4 +2,4 @@ "@parity/truapi": patch --- -Funding accounts are derived as getcash derives its burners: the `fund.` product's entropy for the account's label, taken as a mini secret. No product under that name can derive entropy. +Funding accounts are derived with getcash's scheme under the reserved `fund.` product: that product's entropy for the account's getcash label, taken as a mini secret. The accounts are the funding product's, not getcash's own, so burners getcash already made under its product id are not among them. No product under `fund.` can derive entropy. diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index 5b0176f4f3..031d32de80 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -506,8 +506,9 @@ pub fn funding_account_label( } /// The keypair of the `number`th account of `kind` for `source_id`, derived -/// as getcash derives its burners: the funding product's `deriveEntropy` for -/// the account's label, taken as a mini secret. +/// with getcash's scheme: the funding product's `deriveEntropy` for the +/// account's getcash label, taken as a mini secret. getcash derives under its +/// own product id, so its existing burners are other accounts. pub fn funding_keypair( root_entropy: &[u8], funding_product_id: &str, diff --git a/rust/crates/truapi/src/runtime/pairing_host.rs b/rust/crates/truapi/src/runtime/pairing_host.rs index e8facf181a..bf354beae4 100644 --- a/rust/crates/truapi/src/runtime/pairing_host.rs +++ b/rust/crates/truapi/src/runtime/pairing_host.rs @@ -2563,7 +2563,11 @@ impl PairingHost { if session.sso.is_none() { return Err(AuthorityError::Disconnected); } - if super::is_funding_product(product_id) { + let funding = normalize_product_identifier(product_id) + .map_or(super::is_funding_product(product_id), |id| { + super::is_funding_product(&id) + }); + if funding { return Err(AuthorityError::Rejected); } let root_entropy_source = From 11e9594fcfab2dc4e05e7aeb7f91916aaafa0a09 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Tue, 6 Oct 2026 12:21:15 +0200 Subject: [PATCH 17/20] feat(truapi): recover short, wrong-asset and late funding deposits --- .changeset/funding-recovery.md | 5 + .../tests/golden/host-callbacks.ts | 5 +- .../truapi-host-cli/src/funding_check.rs | 52 ++- rust/crates/truapi-host-cli/src/main.rs | 13 + rust/crates/truapi/RUNTIME.md | 7 + rust/crates/truapi/src/host_core.rs | 58 +++- rust/crates/truapi/src/host_logic/funding.rs | 314 +++++++++++++++++- rust/crates/truapi/src/native/callbacks.rs | 3 +- rust/crates/truapi/src/native/runtime.rs | 41 ++- rust/crates/truapi/src/platform.rs | 5 +- rust/crates/truapi/src/runtime/funding.rs | 273 +++++++++++++-- .../truapi/src/runtime/funding/conversion.rs | 1 + .../truapi/src/runtime/funding/credit.rs | 1 + .../crates/truapi/src/runtime/signing_host.rs | 29 +- 14 files changed, 756 insertions(+), 51 deletions(-) create mode 100644 .changeset/funding-recovery.md diff --git a/.changeset/funding-recovery.md b/.changeset/funding-recovery.md new file mode 100644 index 0000000000..3d10714cab --- /dev/null +++ b/.changeset/funding-recovery.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": patch +--- + +Funding deposits handle getcash's edge cases: every deposit asset is read on each deposit account, so a short or wrong-asset deposit shows as a mismatch; `accept_funding_deposit` converts what arrived instead, and reopens a session that expired or had its conversion refused for 72 hours after; a conversion that lands less than expected credits what landed; and `funding_account_secret` exports an account's seed for a wallet. `enable_funding_conversion` takes the deposit asset ids (Rust and native API). diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 17ce0793f5..3acabf25b6 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -1465,8 +1465,9 @@ export interface FundingPlatform { ): Promise; /** - * Observe a session's status change, for host UI such as the in-flight - * pill. + * Observe a session's status change, or a change in what its deposit + * account holds, for host UI such as the in-flight pill or a mismatch + * prompt. */ fundingSessionChanged?( intent: string, diff --git a/rust/crates/truapi-host-cli/src/funding_check.rs b/rust/crates/truapi-host-cli/src/funding_check.rs index efd5b1e29f..89bd7a19ba 100644 --- a/rust/crates/truapi-host-cli/src/funding_check.rs +++ b/rust/crates/truapi-host-cli/src/funding_check.rs @@ -18,7 +18,7 @@ use std::time::Duration; use anyhow::{Context, Result, bail}; use clap::ValueEnum; use futures::stream::{self, BoxStream, StreamExt}; -use truapi::host_logic::funding::{DepositAsset, DepositRequest, FundingStage}; +use truapi::host_logic::funding::{DepositAsset, DepositRequest, FundingAccountKind, FundingStage}; use truapi::latest::{ FundingDirection, GenericError, HostFundingStatusSubscribeItem, HostPaymentTopUpError, HostPaymentTopUpRequest, HostPaymentTopUpStatusSubscribeError, @@ -208,6 +208,10 @@ pub struct FundingCheck { pub state_dir: PathBuf, /// A session to follow instead of opening a new one. pub intent: Option, + /// Convert what arrived of this asset instead of what was asked. + pub accept: Option, + /// Print the session's account seeds for a wallet, and stop. + pub export_key: bool, } /// Run `check` until its session lands CASH on People or fails. @@ -229,15 +233,29 @@ pub async fn run( let top_up = Arc::new(StandInTopUp::new()); let _ = top_up.runtime.set(Arc::downgrade(&runtime)); runtime.set_top_up_platform(top_up.clone()); - runtime.enable_funding_conversion(FundingNetwork { - cash_asset_id: assets.cash, - }); + runtime.enable_funding_conversion( + FundingNetwork { + cash_asset_id: assets.cash, + }, + vec![assets.cash, assets.usdt, assets.usdc], + ); let intent = match check.intent { Some(intent) => intent, None => open_and_assign(&runtime, &assets, check.asset, check.amount).await?, }; let _ = top_up.intent.set(intent.clone()); + if check.export_key { + return export_keys(&runtime, &intent); + } + if let Some(asset) = check.accept { + let (deposit_asset, _) = assets.source(asset); + runtime + .accept_funding_deposit(&intent, deposit_asset) + .await + .map_err(|error| anyhow::anyhow!("accepting the deposit failed: {}", error.reason))?; + println!("accepted what arrived of {deposit_asset:?}"); + } follow(&runtime, &intent).await } @@ -280,9 +298,23 @@ async fn open_and_assign( Ok(intent) } +/// Print the raw seeds of the session's deposit and refund accounts, in the +/// form a wallet imports and getcash exports: `0x` and the mini secret's hex. +fn export_keys(runtime: &SigningHostRuntime, intent: &str) -> Result<()> { + for kind in [FundingAccountKind::Deposit, FundingAccountKind::Refund] { + let secret = runtime + .funding_account_secret(intent, kind) + .map_err(|error| anyhow::anyhow!("exporting the key failed: {}", error.reason))? + .context("no signing session is active")?; + println!("{kind:?} seed 0x{}", hex::encode(secret)); + } + Ok(()) +} + /// Print the session's stage whenever it changes, until it settles. async fn follow(runtime: &SigningHostRuntime, intent: &str) -> Result<()> { let mut last = None; + let mut last_mismatch = None; let mut missing_polls = 0; loop { // Persisted sessions load in the background after the funding host @@ -299,6 +331,18 @@ async fn follow(runtime: &SigningHostRuntime, intent: &str) -> Result<()> { println!("stage {:?}", session.stage); last = Some(session.stage.clone()); } + let mismatch = session + .deposit + .as_ref() + .and_then(|deposit| deposit.mismatch()); + if mismatch != last_mismatch { + if let Some(mismatch) = mismatch { + println!( + "mismatch {mismatch:?}; accept with --accept or take it back with --export-key" + ); + } + last_mismatch = mismatch; + } match session.stage { FundingStage::Failed { reason, .. } => bail!("the session failed: {reason:?}"), FundingStage::Delivered { credited, .. } => { diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 43ff5189c1..0d3be01230 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -329,6 +329,15 @@ enum Command { /// Follow an existing session instead of opening a new one. #[arg(long)] intent: Option, + /// With `--intent`: convert what arrived of this asset instead of + /// what was asked, for a short or wrong-asset deposit, or one that + /// came after the session ended. + #[arg(long, value_enum, requires = "intent")] + accept: Option, + /// With `--intent`: print the session's account seeds for a wallet to + /// take the funds back by hand, and stop. + #[arg(long, requires = "intent")] + export_key: bool, }, /// Install the current stable release over this one. /// @@ -678,6 +687,8 @@ async fn dispatch( amount, state_dir, intent, + accept, + export_key, } => { let check = funding_check::FundingCheck { mnemonic, @@ -686,6 +697,8 @@ async fn dispatch( amount, state_dir, intent, + accept, + export_key, }; funding_check::run(check, |config, state_dir| { build_signing_runtime( diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 1cb09806cd..6a31e645f8 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -421,6 +421,13 @@ AutoSigning without approval. Legacy-account signing still asks the user. `notify_top_up_status` (the top-up engine), `enable_funding_conversion`, `open_funding`, `quote_funding_deposit`, `assign_funding_deposit` and `funding_session`. Amounts cross the FFI as decimal strings. + Each watched deposit account is read for every deposit asset the host + names when it enables conversion, and the native token, so a short or + wrong-asset deposit shows as a mismatch; `accept_funding_deposit` converts + what arrived instead, and also reopens a session that expired or whose + conversion was refused, for 72 hours after. `funding_account_secret` + exports an account's raw seed, as getcash does, for a user to take funds + back with a wallet. - `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/host_core.rs b/rust/crates/truapi/src/host_core.rs index 02126c75bc..6370bb80f9 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -823,17 +823,65 @@ impl SigningHostRuntime { /// with the deposit accounts this host derives. Without it, assigning a /// deposit account fails. Set-once; returns whether this call enabled it. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.enable_funding_conversion"))] - pub fn enable_funding_conversion(&self, network: crate::runtime::FundingNetwork) -> bool { - let enabled = self - .services - .funding() - .install_conversion(network, self.signing_host.clone()); + /// + /// Every watched deposit account is read for the `deposit_asset_ids` + /// (`Assets` pallet ids) and the native token, so a wrong or short + /// deposit is seen. + pub fn enable_funding_conversion( + &self, + network: crate::runtime::FundingNetwork, + deposit_asset_ids: Vec, + ) -> bool { + let enabled = self.services.funding().install_conversion( + network, + deposit_asset_ids, + self.signing_host.clone(), + ); if enabled { self.services.watch_funding_deposits(); } enabled } + /// Convert what arrived of `asset` on session `intent`'s deposit account + /// instead of what was asked: a short deposit, another asset, or funds + /// that came after the session expired or stayed after a refused + /// conversion. + pub async fn accept_funding_deposit( + &self, + intent: &str, + asset: crate::host_logic::funding::DepositAsset, + ) -> Result<(), v01::GenericError> { + self.services + .accept_funding_deposit(intent, asset) + .await + .map_err(|err| v01::GenericError { + reason: err.to_string(), + }) + } + + /// Raw seed of session `intent`'s `kind` account, for a wallet to import + /// and move its funds by hand. `None` while no signing session is active. + pub fn funding_account_secret( + &self, + intent: &str, + kind: crate::host_logic::funding::FundingAccountKind, + ) -> Result, v01::GenericError> { + let deposit = self + .services + .funding() + .get(intent) + .and_then(|session| session.deposit) + .ok_or_else(|| v01::GenericError { + reason: "the session has no deposit account".into(), + })?; + self.signing_host + .funding_account_secret(kind, &deposit.source_id, deposit.number) + .map_err(|err| v01::GenericError { + reason: err.to_string(), + }) + } + /// Open a funding session on the host's own behalf, as the Balance card's /// Add and Withdraw do, and show the overlay. Returns the session id, or /// `None` when the user dismissed the overlay without starting. diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index 2d6747ebb7..40d01f20e8 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -119,6 +119,81 @@ pub struct FundingDeposit { pub route: ConversionRoute, /// CASH the session asks to credit, which a swap must not land below. pub target: Option, + /// What the deposit account held at the last reading, each asset with a + /// balance, the native token last. + pub holdings: Vec, +} + +/// One asset on a deposit account and its balance. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct DepositHolding { + /// The asset. + pub asset: DepositAsset, + /// Its balance, in the asset's units. + pub balance: u128, +} + +/// What arrived on a deposit account that does not match what was asked for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] +pub enum DepositMismatch { + /// Less of the requested asset than the deposit was quoted at. + Short { + /// The requested asset. + asset: DepositAsset, + /// What arrived of it. + amount: u128, + }, + /// Another asset arrived while the requested one falls short. + WrongAsset { + /// The asset that arrived. + asset: DepositAsset, + /// How much. + amount: u128, + }, +} + +impl FundingDeposit { + /// The balance of `asset` at the last reading. + pub fn held(&self, asset: DepositAsset) -> u128 { + self.holdings + .iter() + .find(|holding| holding.asset == asset) + .map_or(0, |holding| holding.balance) + } + + /// What arrived that does not match the request, as getcash judges it: + /// nothing once the requested asset covers the deposit; otherwise another + /// asset that arrived, the native token only if nothing else did, since a + /// little of it sent to pay fees must not stand in for the stablecoin; + /// otherwise less of the requested asset than asked. + pub fn mismatch(&self) -> Option { + let held = self.held(self.asset); + if held >= self.expected { + return None; + } + let stray = self + .holdings + .iter() + .find(|holding| holding.asset != self.asset && holding.balance > 0); + match stray { + Some(holding) => Some(DepositMismatch::WrongAsset { + asset: holding.asset, + amount: holding.balance, + }), + None => (held > 0).then_some(DepositMismatch::Short { + asset: self.asset, + amount: held, + }), + } + } } /// How a deposit becomes CASH on People, fixed when its account is assigned @@ -167,6 +242,23 @@ pub struct ConversionSubmission { /// Dry runs refused before a conversion gives up. const MAX_CONVERSION_REFUSALS: u8 = 3; +/// How long a session that ended with its deposit recoverable keeps being +/// read, as getcash watches a payment: funds that arrive late, or stay after +/// a refused conversion, can still be converted. +pub const LATE_WATCH_MS: u64 = 72 * 60 * 60 * 1_000; +/// Code a session fails with when its conversion was refused. +const CONVERSION_REFUSED: &str = "conversion_refused"; + +/// Why a deposit could not be accepted as it arrived. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub enum AcceptRefusal { + /// The session is converting or done, or ended for good. + #[display("the session is not awaiting or holding a deposit")] + NotAcceptable, + /// Nothing of the asset is on the deposit account. + #[display("none of that asset is on the deposit account")] + NothingArrived, +} /// Stage of a session, as the core persists it. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -334,6 +426,74 @@ impl FundingSession { }) } + /// The deposit account a session still reads: an open session's, or one + /// that ended with the deposit recoverable, for [`LATE_WATCH_MS`] after. + pub fn watched_deposit(&self, now_ms: u64) -> Option<&FundingDeposit> { + self.deposit + .as_ref() + .filter(|_| self.stage == FundingStage::Open || self.recoverable_since(now_ms)) + } + + /// Whether the session ended in a way its deposit can come back from, + /// within the late watch window: it expired, or its conversion was + /// refused while the funds stayed on the account. + fn recoverable_since(&self, now_ms: u64) -> bool { + match &self.stage { + FundingStage::Failed { + reason, + settled_at_ms, + } => { + let recoverable = match reason { + FundingFailure::Expired => true, + FundingFailure::Other { code, .. } => code == CONVERSION_REFUSED, + _ => false, + }; + recoverable && now_ms.saturating_sub(*settled_at_ms) <= LATE_WATCH_MS + } + _ => false, + } + } + + /// Record what the deposit account holds. Returns whether it changed. + pub fn record_holdings(&mut self, holdings: Vec) -> bool { + match &mut self.deposit { + Some(deposit) if deposit.holdings != holdings => { + deposit.holdings = holdings; + true + } + _ => false, + } + } + + /// Convert what arrived of `asset` by `route` instead of what was asked: + /// getcash's "continue with what arrived". An open session waits for it + /// again; one that ended recoverably reopens for a fresh window. + pub fn accept_arrival( + &mut self, + asset: DepositAsset, + route: ConversionRoute, + now_ms: u64, + ) -> Result<(), AcceptRefusal> { + let reopens = self.recoverable_since(now_ms); + if self.stage != FundingStage::Open && !reopens { + return Err(AcceptRefusal::NotAcceptable); + } + let deposit = self.deposit.as_mut().ok_or(AcceptRefusal::NotAcceptable)?; + let arrived = deposit.held(asset); + if arrived == 0 { + return Err(AcceptRefusal::NothingArrived); + } + deposit.asset = asset; + deposit.expected = arrived; + deposit.route = route; + deposit.target = None; + if reopens { + self.stage = FundingStage::Open; + self.deadline_ms = now_ms.saturating_add(SESSION_WINDOW_MS); + } + Ok(()) + } + /// The deposit an open inbound session is waiting on, if one is assigned. pub fn awaited_deposit(&self) -> Option<&FundingDeposit> { (self.stage == FundingStage::Open) @@ -389,7 +549,7 @@ impl FundingSession { if *refusals >= MAX_CONVERSION_REFUSALS { return self.fail( FundingFailure::Other { - code: "conversion_refused".into(), + code: CONVERSION_REFUSED.into(), message: reason, }, now_ms, @@ -533,6 +693,10 @@ pub enum ConversionStep { /// Which of a session's accounts under the reserved funding product. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum FundingAccountKind { /// Where an inbound provider delivers. Deposit, @@ -593,6 +757,24 @@ pub fn funding_keypair( derive_root_keypair_from_entropy(&entropy).map_err(FundingAccountError::Key) } +/// The mini secret of the same account as [`funding_keypair`], the raw seed +/// a wallet imports it from: what getcash's `burnerSecretOf` hands a user +/// taking funds back by hand. +pub fn funding_mini_secret( + root_entropy: &[u8], + funding_product_id: &str, + kind: FundingAccountKind, + source_id: &str, + number: u32, +) -> Result<[u8; 32], FundingAccountError> { + let label = funding_account_label(kind, source_id, number)?; + let entropy = derive_product_entropy(root_entropy, funding_product_id, label.as_bytes()) + .map_err(FundingAccountError::Entropy)?; + substrate_bip39::mini_secret_from_entropy(&entropy, "") + .map(|mini| mini.to_bytes()) + .map_err(|err| FundingAccountError::Key(ProductAccountError::InvalidEntropy(format!("{err:?}")))) +} + /// Why a session operation failed. #[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] pub enum FundingSessionError { @@ -851,6 +1033,7 @@ mod tests { expected: 50, route: ConversionRoute::Teleport, target: None, + holdings: Vec::new(), }), ..session(FundingDirection::In) } @@ -922,6 +1105,135 @@ mod tests { ); } + fn with_holdings(holdings: &[(DepositAsset, u128)]) -> FundingDeposit { + FundingDeposit { + source_id: "usdt-assethub".into(), + number: 1, + asset: DepositAsset::Asset(1984), + account: [1; 32], + expected: 50, + route: ConversionRoute::Psm { fee_ppm: 5_000 }, + target: Some(40), + holdings: holdings + .iter() + .map(|(asset, balance)| DepositHolding { + asset: *asset, + balance: *balance, + }) + .collect(), + } + } + + // getcash's rules: once the requested asset covers the deposit nothing + // is wrong; otherwise another asset that arrived comes first, the native + // token only when nothing else did, since a little of it sent to pay + // fees must not stand in for the stablecoin; then a short amount. + #[test] + fn a_mismatch_is_judged_as_getcash_judges_it() { + let usdt = DepositAsset::Asset(1984); + let usdc = DepositAsset::Asset(1337); + let native = DepositAsset::Native; + + assert_eq!( + [ + with_holdings(&[(usdt, 50), (usdc, 9)]).mismatch(), + with_holdings(&[(usdt, 10), (usdc, 9), (native, 3)]).mismatch(), + with_holdings(&[(usdt, 10), (native, 3)]).mismatch(), + with_holdings(&[(usdt, 10)]).mismatch(), + with_holdings(&[]).mismatch(), + ], + [ + None, + Some(DepositMismatch::WrongAsset { + asset: usdc, + amount: 9 + }), + Some(DepositMismatch::WrongAsset { + asset: native, + amount: 3 + }), + Some(DepositMismatch::Short { + asset: usdt, + amount: 10 + }), + None, + ] + ); + } + + // Accepting converts what is there instead of what was asked; an expired + // or refused session reopens for a fresh window while its funds are + // still watched, and stays ended after. + #[test] + fn accepting_what_arrived_reroutes_and_reopens_within_the_watch_window() { + let usdc = DepositAsset::Asset(1337); + let open = FundingSession { + deposit: Some(with_holdings(&[(DepositAsset::Asset(1984), 10), (usdc, 9)])), + ..session(FundingDirection::In) + }; + let expired = |settled_at_ms| FundingSession { + stage: FundingStage::Failed { + reason: FundingFailure::Expired, + settled_at_ms, + }, + ..open.clone() + }; + let accept = |mut session: FundingSession, asset, now_ms| { + let accepted = session.accept_arrival(asset, ConversionRoute::Pool, now_ms); + (accepted, session.stage.clone(), session.deposit.map(|deposit| (deposit.asset, deposit.expected, deposit.target))) + }; + let late = NOW + LATE_WATCH_MS; + + assert_eq!( + [ + accept(open.clone(), usdc, NOW).0, + accept(open.clone(), DepositAsset::Native, NOW).0, + accept(expired(NOW), usdc, late).0, + accept(expired(NOW), usdc, late + 1).0, + ], + [ + Ok(()), + Err(AcceptRefusal::NothingArrived), + Ok(()), + Err(AcceptRefusal::NotAcceptable), + ] + ); + assert_eq!( + accept(expired(NOW), usdc, late), + (Ok(()), FundingStage::Open, Some((usdc, 9, None))) + ); + } + + // A user taking funds back by hand imports this seed into a wallet, so it + // must be what getcash's `burnerSecretOf` hands out: `entropyToMiniSecret` + // of the label's entropy (the vector is from that library), and the + // account it opens must be the one the core signs with. + #[test] + fn the_exported_seed_is_getcashs_and_opens_the_same_account() { + let root = [9u8; 32]; + let seed = funding_mini_secret(&root, "fund.dot", FundingAccountKind::Deposit, "usdt-assethub", 1) + .expect("seed"); + let opened = schnorrkel::MiniSecretKey::from_bytes(&seed) + .expect("mini secret") + .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519) + .public; + + assert_eq!( + ( + substrate_bip39::mini_secret_from_entropy(&[7; 32], "") + .map(|mini| hex::encode(mini.to_bytes())) + .ok(), + Some(opened), + ), + ( + Some("12c532afaa1c0ffe4d0eae23c7038d9e866e4335a00eaa3f0dbb01661295325d".to_string()), + funding_keypair(&root, "fund.dot", FundingAccountKind::Deposit, "usdt-assethub", 1) + .ok() + .map(|keypair| keypair.public), + ) + ); + } + // Delivered is the one inbound success: it ends the session for // subscribers and history, and the credited amount is what they see. #[test] diff --git a/rust/crates/truapi/src/native/callbacks.rs b/rust/crates/truapi/src/native/callbacks.rs index a062b72412..8191b075a6 100644 --- a/rust/crates/truapi/src/native/callbacks.rs +++ b/rust/crates/truapi/src/native/callbacks.rs @@ -327,7 +327,8 @@ pub trait NativeFundingCallbacks: Send + Sync { amount: Option, ) -> Result; - /// A session's status changed, for host UI such as the in-flight pill. + /// A session's status, or what its deposit account holds, changed, for + /// host UI such as the in-flight pill or a mismatch prompt. fn funding_session_changed(&self, intent: String, status: v01::HostFundingStatusSubscribeItem); } diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index f7bdc0e02b..9adde712ae 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -274,11 +274,44 @@ impl NativeTrUApiHostRuntime { } /// Convert funding deposits into CASH on People, where CASH is Asset - /// Hub asset `cash_asset_id`. Set-once; answers whether this call - /// enabled it. - pub fn enable_funding_conversion(&self, cash_asset_id: u32) -> bool { + /// Hub asset `cash_asset_id`, reading every deposit account for the + /// `deposit_asset_ids` and the native token. Set-once; answers whether + /// this call enabled it. + pub fn enable_funding_conversion(&self, cash_asset_id: u32, deposit_asset_ids: Vec) -> bool { self.runtime - .enable_funding_conversion(crate::FundingNetwork { cash_asset_id }) + .enable_funding_conversion(crate::FundingNetwork { cash_asset_id }, deposit_asset_ids) + } + + /// Convert what arrived of `asset` on session `intent`'s deposit account + /// instead of what was asked. + pub async fn accept_funding_deposit( + &self, + intent: String, + asset: crate::host_logic::funding::DepositAsset, + ) -> Result<(), HostRejection> { + Ok(self.runtime.accept_funding_deposit(&intent, asset).await?) + } + + /// What arrived on session `intent`'s deposit account that does not + /// match what was asked, if anything. + pub fn funding_deposit_mismatch( + &self, + intent: String, + ) -> Option { + self.runtime + .funding_session(&intent)? + .deposit? + .mismatch() + } + + /// Raw seed of session `intent`'s `kind` account, for a wallet to import + /// and move its funds by hand. `None` while no signing session is active. + pub fn funding_account_secret( + &self, + intent: String, + kind: crate::host_logic::funding::FundingAccountKind, + ) -> Result, HostRejection> { + Ok(self.runtime.funding_account_secret(&intent, kind)?) } /// Open a funding session on the host's own behalf, as the Balance diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index b23f7a8244..d6bea242d6 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -3330,8 +3330,9 @@ pub trait FundingPlatform: Send + Sync { session: FundingPresentation, ) -> Result; - /// Observe a session's status change, for host UI such as the in-flight - /// pill. + /// Observe a session's status change, or a change in what its deposit + /// account holds, for host UI such as the in-flight pill or a mismatch + /// prompt. fn funding_session_changed(&self, intent: String, status: HostFundingStatusSubscribeItem) { let _ = (intent, status); } diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs index e34a79d125..57ad547064 100644 --- a/rust/crates/truapi/src/runtime/funding.rs +++ b/rust/crates/truapi/src/runtime/funding.rs @@ -39,8 +39,8 @@ use super::statement_allowance::{ChainClient, ChainContext}; use super::statement_allowance::rpc::RpcClient; use crate::host_logic::features; use crate::host_logic::funding::{ - ConversionRoute, ConversionStep, ConversionSubmission, CreditAttempt, CreditStep, DepositAsset, - DepositQuote, DepositRequest, + AcceptRefusal, ConversionRoute, ConversionStep, ConversionSubmission, CreditAttempt, CreditStep, + DepositAsset, DepositHolding, DepositQuote, DepositRequest, FundingDeposit, FundingSession, FundingSessionError, FundingStage, load_sessions, next_account_number, retained, store_sessions, }; @@ -96,8 +96,25 @@ impl FundingRegistry { /// Let deposits be converted on `network`, signed by `signer`. Set-once; /// returns whether this call installed it. - pub fn install_conversion(&self, network: FundingNetwork, signer: Arc) -> bool { - self.conversion.set(Conversion { network, signer }).is_ok() + /// + /// The assets a deposit can arrive in, `deposit_asset_ids` from the + /// network's `Assets` pallet with the native token, are read on every + /// watched deposit account, so a wrong or short deposit is seen. + pub fn install_conversion( + &self, + network: FundingNetwork, + deposit_asset_ids: Vec, + signer: Arc, + ) -> bool { + let mut deposit_assets: Vec<_> = deposit_asset_ids.into_iter().map(DepositAsset::Asset).collect(); + deposit_assets.push(DepositAsset::Native); + self.conversion + .set(Conversion { + network, + deposit_assets, + signer, + }) + .is_ok() } /// Snapshot one session. @@ -125,7 +142,21 @@ impl FundingRegistry { .entry(intent.to_string()) .or_default() .push(sender); - Some(current.chain(receiver).boxed()) + // The host also hears when only a deposit account's holdings change; + // a subscriber sees each status once. + let mut shown = None; + Some( + current + .chain(receiver) + .filter(move |item| { + let fresh = shown.as_ref() != Some(item); + if fresh { + shown = Some(item.clone()); + } + futures::future::ready(fresh) + }) + .boxed(), + ) } /// Apply `edit` to a copy of the sessions, persist the result, then make it @@ -276,6 +307,7 @@ impl FundingRegistry { expected: request.expected, route, target, + holdings: Vec::new(), }; let intent = intent.to_string(); return self @@ -294,43 +326,61 @@ impl FundingRegistry { Err(AssignDepositError::AccountsInUse) } - /// Read every awaited deposit once: a session whose deposit arrived - /// moves to converting, one past its deadline without it expires. A - /// failed read leaves its session for the next pass. + /// Read every watched deposit account once, for its requested asset and + /// every asset in `deposit_assets`: an open session whose deposit arrived + /// moves to converting, one past its deadline without it expires, and + /// every watched session records what it holds, so a wrong, short or late + /// deposit is seen. A failed read leaves its session for the next pass. pub async fn observe_deposits( &self, storage: &(impl CoreStorage + ?Sized), now_ms: u64, balances: &dyn DepositBalances, + deposit_assets: &[DepositAsset], ) -> Result<(), FundingSessionError> { - let awaited: Vec<_> = self + let watched: Vec<_> = self .lock_sessions() .values() .filter_map(|session| { - let deposit = session.awaited_deposit()?; + let deposit = session.watched_deposit(now_ms)?; Some((session.intent.clone(), deposit.asset, deposit.account)) }) .collect(); let mut readings = Vec::new(); - for (intent, asset, account) in awaited { - match balances.balance(asset, &account).await { - Ok(balance) => readings.push((intent, balance)), - Err(error) => { - tracing::warn!(%intent, reason = %error.reason, "reading a funding deposit failed") + 'sessions: for (intent, asset, account) in watched { + let mut assets = vec![asset]; + assets.extend(deposit_assets.iter().filter(|other| **other != asset)); + // The native token goes last, so it is the stray of last resort. + assets.sort_by_key(|asset| *asset == DepositAsset::Native); + let mut holdings = Vec::new(); + for asset in assets { + match balances.balance(asset, &account).await { + Ok(0) => {} + Ok(balance) => holdings.push(DepositHolding { asset, balance }), + Err(error) => { + tracing::warn!(%intent, reason = %error.reason, "reading a funding deposit failed"); + continue 'sessions; + } } } + readings.push((intent, holdings)); } self.commit(storage, now_ms, move |sessions| { - let arrived = readings - .into_iter() - .filter(|(intent, balance)| { - sessions - .get_mut(intent) - .is_some_and(|session| session.observe_deposit(*balance, now_ms)) - }) - .map(|(intent, _)| intent) - .collect(); - ((), arrived) + let mut changed = Vec::new(); + for (intent, holdings) in readings { + let Some(session) = sessions.get_mut(&intent) else { + continue; + }; + let recorded = session.record_holdings(holdings); + let held = session + .awaited_deposit() + .map(|deposit| deposit.held(deposit.asset)); + let advanced = held.is_some_and(|held| session.observe_deposit(held, now_ms)); + if recorded || advanced { + changed.push(intent); + } + } + ((), changed) }) .await } @@ -358,7 +408,7 @@ impl FundingRegistry { self.lock_sessions() .values() .any(|session| { - session.awaited_deposit().is_some() + session.watched_deposit(current_unix_millis()).is_some() || session.converting().is_some() || session.crediting().is_some() }) @@ -479,6 +529,8 @@ pub struct DepositPlan { /// What converts deposits: the network's constants and the deposit keys. struct Conversion { network: FundingNetwork, + /// What a deposit account is read for, the native token last. + deposit_assets: Vec, signer: Arc, } @@ -525,7 +577,12 @@ async fn plan_conversion( if held >= submission.spent { Some(ConversionStep::Dropped) } else if now_ms.saturating_sub(submission.submitted_at_ms) > STALL_AFTER_MS { - Some(ConversionStep::Stalled) + // What did land on People is the user's: credit it rather + // than fail, where getcash fails the job as a shortfall. + Some(match landed { + 0 => ConversionStep::Stalled, + landed => ConversionStep::Landed { landed }, + }) } else { None } @@ -761,6 +818,49 @@ impl RuntimeServices { Ok(account) } + /// Convert what arrived of `asset` on session `intent`'s deposit account, + /// by the route that serves that much of it. + pub async fn accept_funding_deposit( + self: &Arc, + intent: &str, + asset: DepositAsset, + ) -> Result<(), AssignDepositError> { + let arrived = self + .funding() + .get(intent) + .ok_or(AssignDepositError::NotFound)? + .deposit + .map(|deposit| deposit.held(asset)) + .filter(|arrived| *arrived > 0) + .ok_or_else(|| AssignDepositError::Refused(AcceptRefusal::NothingArrived.to_string()))?; + let chains = self + .funding_chains(self.funding_network()?, false) + .await + .map_err(AssignDepositError::from_conversion)?; + let route = within_chain_timeout(chains.choose_route(asset, arrived)) + .await + .map_err(AssignDepositError::Chain)? + .map_err(AssignDepositError::from_conversion)? + .ok_or(AssignDepositError::NoRoute)?; + let intent = intent.to_string(); + self.funding() + .commit(self.platform.as_ref(), current_unix_millis(), move |sessions| { + let accepted = sessions + .get_mut(&intent) + .ok_or(AssignDepositError::NotFound) + .and_then(|session| { + session + .accept_arrival(asset, route, current_unix_millis()) + .map_err(|refusal| AssignDepositError::Refused(refusal.to_string())) + }); + let changed = if accepted.is_ok() { vec![intent] } else { Vec::new() }; + (accepted, changed) + }) + .await??; + self.watch_funding_deposits(); + Ok(()) + } + /// The deposit of `asset` a provider must deliver to credit the amount /// session `intent` names. pub async fn quote_funding_deposit( @@ -951,8 +1051,9 @@ impl RuntimeServices { }; let (pending, signing) = { let sessions = registry.lock_sessions(); + let now_ms = current_unix_millis(); let pending = sessions.values().any(|session| { - session.awaited_deposit().is_some() || session.converting().is_some() + session.watched_deposit(now_ms).is_some() || session.converting().is_some() }); let signing = sessions .values() @@ -967,7 +1068,12 @@ impl RuntimeServices { .await .map_err(|error| error.to_string())?; let observed = registry - .observe_deposits(self.platform.as_ref(), current_unix_millis(), &chains) + .observe_deposits( + self.platform.as_ref(), + current_unix_millis(), + &chains, + &conversion.deposit_assets, + ) .await; if let Err(error) = observed { tracing::warn!(%error, "recording funding deposits failed"); @@ -1303,6 +1409,7 @@ mod tests { expected: 50, route: ConversionRoute::Teleport, target: Some(100), + holdings: Vec::new(), }) ) ); @@ -1360,7 +1467,7 @@ mod tests { for held in [49, 50] { let balances = Balances(HashMap::from([(account(1), held)])); - block_on(registry.observe_deposits(storage.as_ref(), NOW, &balances)) + block_on(registry.observe_deposits(storage.as_ref(), NOW, &balances, &[])) .expect("observed"); } let restarted = FundingRegistry::default(); @@ -1407,7 +1514,7 @@ mod tests { let after_sweep = [registry.get("fs_late"), registry.get("fs_never")] .map(|session| session.map(|session| session.stage)); let late_payment = Balances(HashMap::from([(account(1), 50)])); - block_on(registry.observe_deposits(storage.as_ref(), past_deadline, &late_payment)) + block_on(registry.observe_deposits(storage.as_ref(), past_deadline, &late_payment, &[])) .expect("observed"); assert_eq!( @@ -1536,6 +1643,7 @@ mod tests { expected: 50, route: ConversionRoute::Teleport, target: None, + holdings: Vec::new(), } } @@ -1613,6 +1721,109 @@ mod tests { ); } + /// Balances per asset and account; anything else is empty. + struct AssetBalances(Vec<(DepositAsset, [u8; 32], u128)>); + + impl DepositBalances for AssetBalances { + fn balance<'a>( + &'a self, + asset: DepositAsset, + account: &'a [u8; 32], + ) -> BoxFuture<'a, Result> { + let balance = self + .0 + .iter() + .find(|(held, holder, _)| *held == asset && holder == account) + .map_or(0, |(_, _, balance)| *balance); + Box::pin(async move { Ok(balance) }) + } + } + + // A wrong or short deposit is only seen if every asset a deposit can + // arrive in is read, and a late one only if an expired session keeps + // being read; past the watch window it is left alone. + #[test] + fn the_watch_reads_every_deposit_asset_and_late_sessions_within_the_window() { + let storage = stub_platform(); + let registry = FundingRegistry::default(); + let usdc = DepositAsset::Asset(1337); + let now = NOW + crate::host_logic::funding::LATE_WATCH_MS; + let expired = |intent: &str, settled_at_ms, holder| FundingSession { + stage: FundingStage::Failed { + reason: FundingFailure::Expired, + settled_at_ms, + }, + deposit: Some(FundingDeposit { + account: holder, + ..converting_deposit() + }), + ..session(intent, settled_at_ms - DAY_MS) + }; + insert(®istry, storage.as_ref(), expired("fs_recent", NOW, account(1))); + insert(®istry, storage.as_ref(), expired("fs_old", NOW - 1, account(2))); + let balances = AssetBalances(vec![ + (DepositAsset::Native, account(1), 3), + (usdc, account(1), 9), + (usdc, account(2), 9), + ]); + + block_on(registry.observe_deposits( + storage.as_ref(), + now, + &balances, + &[usdc, DepositAsset::Native], + )) + .expect("observed"); + let holdings = |intent| { + registry + .get(intent) + .and_then(|session| session.deposit) + .map(|deposit| deposit.holdings) + }; + + assert_eq!( + (holdings("fs_recent"), holdings("fs_old")), + ( + Some(vec![ + DepositHolding { + asset: usdc, + balance: 9 + }, + DepositHolding { + asset: DepositAsset::Native, + balance: 3 + }, + ]), + Some(Vec::new()) + ) + ); + } + + // What did land on People is the user's even when the conversion took + // the deposit and less arrived than it should have: it is credited + // rather than the session failing, as getcash fails it. + #[test] + fn a_stalled_conversion_credits_what_did_land() { + let late = NOW + STALL_AFTER_MS + 1; + let chains = |landed| Scripted { + landed, + nonce: 5, + balance: 1, + block: 100, + }; + + assert_eq!( + [ + next_step(chains(30), Some(SUBMITTED), late), + next_step(chains(10), Some(SUBMITTED), late), + ], + [ + Some(PlannedStep::Record(ConversionStep::Landed { landed: 20 })), + Some(PlannedStep::Record(ConversionStep::Stalled)), + ] + ); + } + // A session outlives a sign-out, so after switching identity the key on // hand is not the deposit account's; signing with it would dry-run one // account and pay from another. diff --git a/rust/crates/truapi/src/runtime/funding/conversion.rs b/rust/crates/truapi/src/runtime/funding/conversion.rs index db170b1999..d1be2e8aa7 100644 --- a/rust/crates/truapi/src/runtime/funding/conversion.rs +++ b/rust/crates/truapi/src/runtime/funding/conversion.rs @@ -2095,6 +2095,7 @@ mod live { expected: 0, route, target: None, + holdings: Vec::new(), } } diff --git a/rust/crates/truapi/src/runtime/funding/credit.rs b/rust/crates/truapi/src/runtime/funding/credit.rs index 1c26f369f3..350d61c406 100644 --- a/rust/crates/truapi/src/runtime/funding/credit.rs +++ b/rust/crates/truapi/src/runtime/funding/credit.rs @@ -274,6 +274,7 @@ mod tests { expected: 2_000_000, route: ConversionRoute::Psm { fee_ppm: 5_000 }, target: None, + holdings: Vec::new(), } } diff --git a/rust/crates/truapi/src/runtime/signing_host.rs b/rust/crates/truapi/src/runtime/signing_host.rs index 66765e1a2c..47936b29cb 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -56,7 +56,7 @@ use crate::host_internal::sso_messages::{OnExistingAllowancePolicy, ProductReque use crate::host_internal::transaction::sign_extrinsic_payload; use crate::host_logic::entropy::derive_product_entropy; use crate::host_logic::features::genesis_for; -use crate::host_logic::funding::{FundingAccountKind, funding_keypair}; +use crate::host_logic::funding::{FundingAccountKind, funding_keypair, funding_mini_secret}; use crate::host_logic::product_account::{ ProductAccountError, SR25519_SIGNING_CONTEXT, derivation_index_bytes, derive_identity_keypair, derive_product_keypair, derive_product_subtree_keypair, @@ -531,6 +531,33 @@ impl SigningHost { .map(|keypair| keypair.map(|keypair| keypair.public.to_bytes())) } + /// Raw seed of the `number`th funding account of `kind` for `source_id`, + /// which a wallet imports to move the account's funds by hand. `None` + /// while no signing session is active. + pub fn funding_account_secret( + &self, + kind: FundingAccountKind, + source_id: &str, + number: u32, + ) -> Result, AuthorityError> { + let entropy = match self.root_entropy() { + Ok(entropy) => entropy, + Err(AuthorityError::Disconnected) => return Ok(None), + Err(err) => return Err(err), + }; + funding_mini_secret( + &entropy, + &funding_product_id(&self.network_suffix), + kind, + source_id, + number, + ) + .map(Some) + .map_err(|err| AuthorityError::Unavailable { + reason: err.to_string(), + }) + } + /// Keypair of a funding account, for the core's own conversion and /// crediting. Products never reach it: `derive_entropy` refuses the /// funding product. From 79f6720cc7acd362687e174fb943fefe4a604aeb Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Tue, 6 Oct 2026 12:33:23 +0200 Subject: [PATCH 18/20] fix(funding): gate deposits on the quote, convert late full deposits Accept only the mismatched asset, read ended sessions every five minutes, keep a stray's last reading on a failed read, and check the exported seed against the session's account. --- .../truapi-host-cli/src/funding_check.rs | 5 +- rust/crates/truapi/RUNTIME.md | 17 +- rust/crates/truapi/src/host_core.rs | 15 +- rust/crates/truapi/src/host_logic/funding.rs | 204 +++++++++++++----- rust/crates/truapi/src/native/runtime.rs | 3 +- rust/crates/truapi/src/runtime/funding.rs | 120 +++++++---- .../crates/truapi/src/runtime/signing_host.rs | 8 +- 7 files changed, 267 insertions(+), 105 deletions(-) diff --git a/rust/crates/truapi-host-cli/src/funding_check.rs b/rust/crates/truapi-host-cli/src/funding_check.rs index 89bd7a19ba..5cc1edde5f 100644 --- a/rust/crates/truapi-host-cli/src/funding_check.rs +++ b/rust/crates/truapi-host-cli/src/funding_check.rs @@ -331,10 +331,7 @@ async fn follow(runtime: &SigningHostRuntime, intent: &str) -> Result<()> { println!("stage {:?}", session.stage); last = Some(session.stage.clone()); } - let mismatch = session - .deposit - .as_ref() - .and_then(|deposit| deposit.mismatch()); + let mismatch = session.deposit_mismatch(); if mismatch != last_mismatch { if let Some(mismatch) = mismatch { println!( diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 6a31e645f8..c71eced484 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -422,12 +422,17 @@ AutoSigning without approval. Legacy-account signing still asks the user. `open_funding`, `quote_funding_deposit`, `assign_funding_deposit` and `funding_session`. Amounts cross the FFI as decimal strings. Each watched deposit account is read for every deposit asset the host - names when it enables conversion, and the native token, so a short or - wrong-asset deposit shows as a mismatch; `accept_funding_deposit` converts - what arrived instead, and also reopens a session that expired or whose - conversion was refused, for 72 hours after. `funding_account_secret` - exports an account's raw seed, as getcash does, for a user to take funds - back with a wallet. + names when it enables conversion, and the native token. A deposit counts + as delivered once it reaches the deposit quoted for its asset, or what the + provider was asked for without a quote. Below that, a short or wrong-asset + deposit shows as a mismatch, and `accept_funding_deposit` converts what + arrived instead. An ended session's account is read every five minutes + for 72 hours: a full deposit that arrives after expiry converts on its + own, and a mismatch can still be accepted. A product stream that already + ended on `Failed` does not hear of a reopened session; the product sees + 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. - `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/host_core.rs b/rust/crates/truapi/src/host_core.rs index 6370bb80f9..8e339bf731 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -822,11 +822,11 @@ impl SigningHostRuntime { /// Convert funding deposits into CASH on People on `network`, signing /// with the deposit accounts this host derives. Without it, assigning a /// deposit account fails. Set-once; returns whether this call enabled it. - #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.enable_funding_conversion"))] /// /// Every watched deposit account is read for the `deposit_asset_ids` /// (`Assets` pallet ids) and the native token, so a wrong or short /// deposit is seen. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.enable_funding_conversion"))] pub fn enable_funding_conversion( &self, network: crate::runtime::FundingNetwork, @@ -844,9 +844,10 @@ impl SigningHostRuntime { } /// Convert what arrived of `asset` on session `intent`'s deposit account - /// instead of what was asked: a short deposit, another asset, or funds - /// that came after the session expired or stayed after a refused - /// conversion. + /// instead of what was asked: the short deposit or other asset its + /// mismatch names, also after the session expired or its conversion was + /// refused. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.accept_funding_deposit"))] pub async fn accept_funding_deposit( &self, intent: &str, @@ -861,7 +862,9 @@ impl SigningHostRuntime { } /// Raw seed of session `intent`'s `kind` account, for a wallet to import - /// and move its funds by hand. `None` while no signing session is active. + /// and move its funds by hand. `None` while no signing session is active, + /// or while it is another account's than the one the session recorded. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.funding_account_secret"))] pub fn funding_account_secret( &self, intent: &str, @@ -876,7 +879,7 @@ impl SigningHostRuntime { reason: "the session has no deposit account".into(), })?; self.signing_host - .funding_account_secret(kind, &deposit.source_id, deposit.number) + .funding_account_secret(kind, &deposit.source_id, deposit.number, &deposit.account) .map_err(|err| v01::GenericError { reason: err.to_string(), }) diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index 40d01f20e8..c6dce72c0b 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -170,13 +170,13 @@ impl FundingDeposit { } /// What arrived that does not match the request, as getcash judges it: - /// nothing once the requested asset covers the deposit; otherwise another + /// nothing once the requested asset reaches `gate`; otherwise another /// asset that arrived, the native token only if nothing else did, since a /// little of it sent to pay fees must not stand in for the stablecoin; /// otherwise less of the requested asset than asked. - pub fn mismatch(&self) -> Option { + fn mismatch_against(&self, gate: u128) -> Option { let held = self.held(self.asset); - if held >= self.expected { + if held >= gate { return None; } let stray = self @@ -255,9 +255,15 @@ pub enum AcceptRefusal { /// The session is converting or done, or ended for good. #[display("the session is not awaiting or holding a deposit")] NotAcceptable, - /// Nothing of the asset is on the deposit account. - #[display("none of that asset is on the deposit account")] + /// Nothing that differs from the request is on the deposit account. + #[display("nothing other than the requested deposit is on the account")] NothingArrived, + /// The asset is not the one that arrived in place of the request. + #[display("that asset is not what arrived in place of the request")] + NotMismatched, + /// The deposit account changed while the route was being chosen. + #[display("the deposit account changed; read it again")] + Changed, } /// Stage of a session, as the core persists it. @@ -454,23 +460,71 @@ impl FundingSession { } } - /// Record what the deposit account holds. Returns whether it changed. - pub fn record_holdings(&mut self, holdings: Vec) -> bool { - match &mut self.deposit { - Some(deposit) if deposit.holdings != holdings => { - deposit.holdings = holdings; - true + /// Balance of the deposit's asset at which it counts as delivered, as + /// getcash gates a payment: the deposit quoted for that asset, else what + /// the provider was asked for. + pub fn deposit_gate(&self) -> Option { + let deposit = self.deposit.as_ref()?; + let quoted = self.quote.filter(|quote| quote.asset == deposit.asset); + Some(quoted.map_or(deposit.expected, |quote| quote.deposit)) + } + + /// What arrived on the deposit account that does not match the request. + pub fn deposit_mismatch(&self) -> Option { + self.deposit.as_ref()?.mismatch_against(self.deposit_gate()?) + } + + /// Record a finalized reading of what the deposit account holds, taken at + /// `now_ms`. An open session converts once its asset reaches the gate and + /// expires if it has not by the deadline; one that expired converts too + /// when its deposit arrives within the late watch window. Returns whether + /// the host should hear of it: a stage change, or new holdings on a + /// session still in flight. + pub fn observe_holdings(&mut self, holdings: Vec, now_ms: u64) -> bool { + let Some(deposit) = self.deposit.as_mut() else { + return false; + }; + let recorded = deposit.holdings != holdings; + deposit.holdings = holdings; + let held = deposit.held(deposit.asset); + let Some(gate) = self.deposit_gate() else { + return false; + }; + let arrived_late = matches!( + self.stage, + FundingStage::Failed { + reason: FundingFailure::Expired, + .. } - _ => false, + ) && self.recoverable_since(now_ms) + && held >= gate; + if arrived_late { + self.stage = FundingStage::Open; + } + if self.awaited_deposit().is_none() { + return recorded && !self.is_terminal(); } + if held >= gate { + self.stage = FundingStage::Converting { + deposited: held, + refusals: 0, + submission: None, + }; + return true; + } + let expired = now_ms >= self.deadline_ms && self.fail(FundingFailure::Expired, now_ms); + expired || recorded } - /// Convert what arrived of `asset` by `route` instead of what was asked: - /// getcash's "continue with what arrived". An open session waits for it - /// again; one that ended recoverably reopens for a fresh window. + /// Convert what arrived of the mismatched `asset` by `route` instead of + /// what was asked: getcash's "continue with what arrived". `arrived` is + /// the balance the route was chosen for, refused if the account has + /// changed since. An open session waits for it again; one that ended + /// recoverably reopens for a fresh window. pub fn accept_arrival( &mut self, asset: DepositAsset, + arrived: u128, route: ConversionRoute, now_ms: u64, ) -> Result<(), AcceptRefusal> { @@ -478,15 +532,25 @@ impl FundingSession { if self.stage != FundingStage::Open && !reopens { return Err(AcceptRefusal::NotAcceptable); } - let deposit = self.deposit.as_mut().ok_or(AcceptRefusal::NotAcceptable)?; - let arrived = deposit.held(asset); - if arrived == 0 { - return Err(AcceptRefusal::NothingArrived); + let mismatched = match self.deposit_mismatch() { + Some(DepositMismatch::Short { asset, amount } | DepositMismatch::WrongAsset { asset, amount }) => { + Some((asset, amount)) + } + None => None, + }; + match mismatched { + None => return Err(AcceptRefusal::NothingArrived), + Some((mismatched, _)) if mismatched != asset => return Err(AcceptRefusal::NotMismatched), + Some((_, amount)) if amount != arrived => return Err(AcceptRefusal::Changed), + Some(_) => {} } + let deposit = self.deposit.as_mut().ok_or(AcceptRefusal::NotAcceptable)?; deposit.asset = asset; deposit.expected = arrived; deposit.route = route; deposit.target = None; + // The quoted terms are for what was asked, not for what arrived. + self.quote = None; if reopens { self.stage = FundingStage::Open; self.deadline_ms = now_ms.saturating_add(SESSION_WINDOW_MS); @@ -501,24 +565,6 @@ impl FundingSession { .flatten() } - /// Record a finalized reading of the deposit account's balance, taken at - /// `now_ms`: converting once it covers the expected amount, expired if it - /// does not by the deadline. Returns whether the session changed. - pub fn observe_deposit(&mut self, balance: u128, now_ms: u64) -> bool { - let Some(deposit) = self.awaited_deposit() else { - return false; - }; - if balance >= deposit.expected { - self.stage = FundingStage::Converting { - deposited: balance, - refusals: 0, - submission: None, - }; - return true; - } - now_ms >= self.deadline_ms && self.fail(FundingFailure::Expired, now_ms) - } - /// The deposit of a session being converted, with its submission so far. pub fn converting(&self) -> Option<(&FundingDeposit, Option)> { match (&self.stage, &self.deposit) { @@ -1136,11 +1182,11 @@ mod tests { assert_eq!( [ - with_holdings(&[(usdt, 50), (usdc, 9)]).mismatch(), - with_holdings(&[(usdt, 10), (usdc, 9), (native, 3)]).mismatch(), - with_holdings(&[(usdt, 10), (native, 3)]).mismatch(), - with_holdings(&[(usdt, 10)]).mismatch(), - with_holdings(&[]).mismatch(), + with_holdings(&[(usdt, 50), (usdc, 9)]).mismatch_against(50), + with_holdings(&[(usdt, 10), (usdc, 9), (native, 3)]).mismatch_against(50), + with_holdings(&[(usdt, 10), (native, 3)]).mismatch_against(50), + with_holdings(&[(usdt, 10)]).mismatch_against(50), + with_holdings(&[]).mismatch_against(50), ], [ None, @@ -1178,32 +1224,92 @@ mod tests { }, ..open.clone() }; - let accept = |mut session: FundingSession, asset, now_ms| { - let accepted = session.accept_arrival(asset, ConversionRoute::Pool, now_ms); + let delivered = FundingSession { + deposit: Some(with_holdings(&[(DepositAsset::Asset(1984), 50)])), + ..session(FundingDirection::In) + }; + let accept = |mut session: FundingSession, asset, arrived, now_ms| { + let accepted = session.accept_arrival(asset, arrived, ConversionRoute::Pool, now_ms); (accepted, session.stage.clone(), session.deposit.map(|deposit| (deposit.asset, deposit.expected, deposit.target))) }; let late = NOW + LATE_WATCH_MS; assert_eq!( [ - accept(open.clone(), usdc, NOW).0, - accept(open.clone(), DepositAsset::Native, NOW).0, - accept(expired(NOW), usdc, late).0, - accept(expired(NOW), usdc, late + 1).0, + accept(open.clone(), usdc, 9, NOW).0, + accept(open.clone(), DepositAsset::Asset(1984), 10, NOW).0, + accept(open.clone(), usdc, 8, NOW).0, + accept(delivered, DepositAsset::Asset(1984), 50, NOW).0, + accept(expired(NOW), usdc, 9, late).0, + accept(expired(NOW), usdc, 9, late + 1).0, ], [ Ok(()), + Err(AcceptRefusal::NotMismatched), + Err(AcceptRefusal::Changed), Err(AcceptRefusal::NothingArrived), Ok(()), Err(AcceptRefusal::NotAcceptable), ] ); assert_eq!( - accept(expired(NOW), usdc, late), + accept(expired(NOW), usdc, 9, late), (Ok(()), FundingStage::Open, Some((usdc, 9, None))) ); } + // getcash gates a payment on its quoted deposit, so a provider asked for + // more than the quote has delivered once the quote is covered; and a + // full deposit that arrives after the session expired still converts, + // within the late watch window, with no one having to accept it. + #[test] + fn a_deposit_converts_at_its_quote_and_also_when_it_arrives_late() { + let usdt = DepositAsset::Asset(1984); + let holding = |balance| vec![DepositHolding { asset: usdt, balance }]; + let quoted = FundingSession { + deposit: Some(with_holdings(&[])), + quote: Some(DepositQuote { + asset: usdt, + route: ConversionRoute::Psm { fee_ppm: 5_000 }, + deposit: 45, + }), + ..session(FundingDirection::In) + }; + let expired = FundingSession { + stage: FundingStage::Failed { + reason: FundingFailure::Expired, + settled_at_ms: NOW, + }, + ..quoted.clone() + }; + let observe = |mut session: FundingSession, balance, now_ms| { + let heard = session.observe_holdings(holding(balance), now_ms); + (heard, session.stage) + }; + let converting = FundingStage::Converting { + deposited: 45, + refusals: 0, + submission: None, + }; + + assert_eq!( + [ + observe(quoted.clone(), 44, NOW), + observe(quoted, 45, NOW), + observe(expired.clone(), 44, NOW + 1), + observe(expired.clone(), 45, NOW + LATE_WATCH_MS), + observe(expired.clone(), 45, NOW + LATE_WATCH_MS + 1), + ], + [ + (true, FundingStage::Open), + (true, converting.clone()), + (false, expired.stage.clone()), + (true, converting), + (false, expired.stage), + ] + ); + } + // A user taking funds back by hand imports this seed into a wallet, so it // must be what getcash's `burnerSecretOf` hands out: `entropyToMiniSecret` // of the label's entropy (the vector is from that library), and the diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index 9adde712ae..562d2fbba9 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -300,8 +300,7 @@ impl NativeTrUApiHostRuntime { ) -> Option { self.runtime .funding_session(&intent)? - .deposit? - .mismatch() + .deposit_mismatch() } /// Raw seed of session `intent`'s `kind` account, for a wallet to import diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs index 57ad547064..7f07404da9 100644 --- a/rust/crates/truapi/src/runtime/funding.rs +++ b/rust/crates/truapi/src/runtime/funding.rs @@ -10,7 +10,7 @@ //! the change, and then notifies subscribers and the host. use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, Weak}; use core::time::Duration; @@ -40,7 +40,7 @@ use super::statement_allowance::rpc::RpcClient; use crate::host_logic::features; use crate::host_logic::funding::{ AcceptRefusal, ConversionRoute, ConversionStep, ConversionSubmission, CreditAttempt, CreditStep, - DepositAsset, DepositHolding, DepositQuote, DepositRequest, + DepositAsset, DepositHolding, DepositMismatch, DepositQuote, DepositRequest, FundingDeposit, FundingSession, FundingSessionError, FundingStage, load_sessions, next_account_number, retained, store_sessions, }; @@ -54,6 +54,8 @@ use crate::unix_time::current_unix_millis; const SWEEP_RETRY: Duration = Duration::from_secs(30); /// Wait between reads of the awaited deposits: two Asset Hub blocks. const DEPOSIT_POLL: Duration = Duration::from_secs(12); +/// How often the deposit accounts of sessions that already ended are read. +const LATE_READ_MS: u64 = 5 * 60 * 1_000; /// Longest a chain read may take before the pass gives up on it. const CHAIN_TIMEOUT: Duration = Duration::from_secs(30); /// How long a conversion that took the deposit on Asset Hub may take to @@ -77,6 +79,8 @@ pub struct FundingRegistry { sweeping: AtomicBool, /// Whether a task is polling the awaited deposits. watching: AtomicBool, + /// When sessions that already ended were last read, in Unix milliseconds. + late_read_ms: AtomicU64, /// What converts deposits, once a signing host provides it. conversion: OnceLock, platform: OnceLock>, @@ -327,10 +331,11 @@ impl FundingRegistry { } /// Read every watched deposit account once, for its requested asset and - /// every asset in `deposit_assets`: an open session whose deposit arrived - /// moves to converting, one past its deadline without it expires, and - /// every watched session records what it holds, so a wrong, short or late - /// deposit is seen. A failed read leaves its session for the next pass. + /// every asset in `deposit_assets`, and record what each holds, so a + /// wrong, short or late deposit is seen. Sessions that already ended are + /// read once per [`LATE_READ_MS`]. A failed read of the requested asset + /// leaves its session for the next pass; a failed read of another asset + /// keeps that asset's last reading. pub async fn observe_deposits( &self, storage: &(impl CoreStorage + ?Sized), @@ -338,48 +343,49 @@ impl FundingRegistry { balances: &dyn DepositBalances, deposit_assets: &[DepositAsset], ) -> Result<(), FundingSessionError> { + let read_late = now_ms.saturating_sub(self.late_read_ms.load(Ordering::Acquire)) >= LATE_READ_MS; let watched: Vec<_> = self .lock_sessions() .values() - .filter_map(|session| { - let deposit = session.watched_deposit(now_ms)?; - Some((session.intent.clone(), deposit.asset, deposit.account)) - }) + .filter(|session| read_late || !session.is_terminal()) + .filter_map(|session| Some((session.intent.clone(), session.watched_deposit(now_ms)?.clone()))) .collect(); + if read_late { + self.late_read_ms.store(now_ms, Ordering::Release); + } let mut readings = Vec::new(); - 'sessions: for (intent, asset, account) in watched { - let mut assets = vec![asset]; - assets.extend(deposit_assets.iter().filter(|other| **other != asset)); + 'sessions: for (intent, deposit) in watched { + let mut assets = vec![deposit.asset]; + assets.extend(deposit_assets.iter().filter(|other| **other != deposit.asset)); // The native token goes last, so it is the stray of last resort. assets.sort_by_key(|asset| *asset == DepositAsset::Native); let mut holdings = Vec::new(); for asset in assets { - match balances.balance(asset, &account).await { - Ok(0) => {} - Ok(balance) => holdings.push(DepositHolding { asset, balance }), + let balance = match balances.balance(asset, &deposit.account).await { + Ok(balance) => balance, + Err(error) if asset != deposit.asset => { + tracing::warn!(%intent, reason = %error.reason, "reading a funding deposit failed"); + deposit.held(asset) + } Err(error) => { tracing::warn!(%intent, reason = %error.reason, "reading a funding deposit failed"); continue 'sessions; } + }; + if balance > 0 { + holdings.push(DepositHolding { asset, balance }); } } readings.push((intent, holdings)); } self.commit(storage, now_ms, move |sessions| { - let mut changed = Vec::new(); - for (intent, holdings) in readings { - let Some(session) = sessions.get_mut(&intent) else { - continue; - }; - let recorded = session.record_holdings(holdings); - let held = session - .awaited_deposit() - .map(|deposit| deposit.held(deposit.asset)); - let advanced = held.is_some_and(|held| session.observe_deposit(held, now_ms)); - if recorded || advanced { - changed.push(intent); - } - } + let changed = readings + .into_iter() + .filter_map(|(intent, holdings)| { + let session = sessions.get_mut(&intent)?; + session.observe_holdings(holdings, now_ms).then_some(intent) + }) + .collect(); ((), changed) }) .await @@ -825,14 +831,20 @@ impl RuntimeServices { intent: &str, asset: DepositAsset, ) -> Result<(), AssignDepositError> { - let arrived = self + let arrived = match self .funding() .get(intent) .ok_or(AssignDepositError::NotFound)? - .deposit - .map(|deposit| deposit.held(asset)) - .filter(|arrived| *arrived > 0) - .ok_or_else(|| AssignDepositError::Refused(AcceptRefusal::NothingArrived.to_string()))?; + .deposit_mismatch() + { + Some(DepositMismatch::Short { asset: arrived, amount } | DepositMismatch::WrongAsset { asset: arrived, amount }) + if arrived == asset => + { + amount + } + Some(_) => return Err(AssignDepositError::Refused(AcceptRefusal::NotMismatched.to_string())), + None => return Err(AssignDepositError::Refused(AcceptRefusal::NothingArrived.to_string())), + }; let chains = self .funding_chains(self.funding_network()?, false) .await @@ -850,7 +862,7 @@ impl RuntimeServices { .ok_or(AssignDepositError::NotFound) .and_then(|session| { session - .accept_arrival(asset, route, current_unix_millis()) + .accept_arrival(asset, arrived, route, current_unix_millis()) .map_err(|refusal| AssignDepositError::Refused(refusal.to_string())) }); let changed = if accepted.is_ok() { vec![intent] } else { Vec::new() }; @@ -1771,7 +1783,7 @@ mod tests { storage.as_ref(), now, &balances, - &[usdc, DepositAsset::Native], + &[DepositAsset::Native, usdc], )) .expect("observed"); let holdings = |intent| { @@ -1799,6 +1811,40 @@ mod tests { ); } + // An ended session is only read for a late deposit, which can wait, so + // its account is read at the slower cadence rather than every pass. + #[test] + fn an_ended_session_is_read_at_the_slower_cadence() { + let storage = stub_platform(); + let registry = FundingRegistry::default(); + let usdc = DepositAsset::Asset(1337); + insert( + ®istry, + storage.as_ref(), + FundingSession { + stage: FundingStage::Failed { + reason: FundingFailure::Expired, + settled_at_ms: NOW, + }, + deposit: Some(converting_deposit()), + ..session("fs_ended", NOW - DAY_MS) + }, + ); + let read = |at, balance| { + let balances = AssetBalances(vec![(usdc, converting_deposit().account, balance)]); + block_on(registry.observe_deposits(storage.as_ref(), at, &balances, &[usdc])).expect("observed"); + registry + .get("fs_ended") + .and_then(|session| session.deposit) + .map(|deposit| deposit.held(usdc)) + }; + + assert_eq!( + [read(NOW + 1, 9), read(NOW + 2, 7), read(NOW + 1 + LATE_READ_MS, 7)], + [Some(9), Some(9), Some(7)] + ); + } + // What did land on People is the user's even when the conversion took // the deposit and less arrived than it should have: it is credited // rather than the session failing, as getcash fails it. diff --git a/rust/crates/truapi/src/runtime/signing_host.rs b/rust/crates/truapi/src/runtime/signing_host.rs index 47936b29cb..e48d944e7c 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -533,18 +533,24 @@ impl SigningHost { /// Raw seed of the `number`th funding account of `kind` for `source_id`, /// which a wallet imports to move the account's funds by hand. `None` - /// while no signing session is active. + /// while no signing session is active, or while the active one does not + /// derive `deposit_account` as that number's deposit account. pub fn funding_account_secret( &self, kind: FundingAccountKind, source_id: &str, number: u32, + deposit_account: &[u8; 32], ) -> Result, AuthorityError> { let entropy = match self.root_entropy() { Ok(entropy) => entropy, Err(AuthorityError::Disconnected) => return Ok(None), Err(err) => return Err(err), }; + let deposit = self.funding_keypair(FundingAccountKind::Deposit, source_id, number)?; + if deposit.is_none_or(|keypair| keypair.public.to_bytes() != *deposit_account) { + return Ok(None); + } funding_mini_secret( &entropy, &funding_product_id(&self.network_suffix), From 47046d306fd3f5499bdfda0ee1fd05350a784c04 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Tue, 6 Oct 2026 12:44:04 +0200 Subject: [PATCH 19/20] feat(truapi): settle partial funding claims and PSM refusals as getcash does --- .changeset/funding-partial-claims.md | 5 + .../truapi-host-cli/src/funding_check.rs | 30 +- rust/crates/truapi-host-cli/src/main.rs | 6 + rust/crates/truapi/RUNTIME.md | 19 +- rust/crates/truapi/src/host_core.rs | 13 + rust/crates/truapi/src/host_logic/funding.rs | 700 +++++++++++++++--- rust/crates/truapi/src/native/runtime.rs | 5 + rust/crates/truapi/src/runtime/funding.rs | 131 +++- .../truapi/src/runtime/funding/conversion.rs | 92 ++- .../truapi/src/runtime/funding/credit.rs | 298 ++++---- 10 files changed, 1017 insertions(+), 282 deletions(-) create mode 100644 .changeset/funding-partial-claims.md diff --git a/.changeset/funding-partial-claims.md b/.changeset/funding-partial-claims.md new file mode 100644 index 0000000000..d9158ace7b --- /dev/null +++ b/.changeset/funding-partial-claims.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi": patch +--- + +Funding credits and conversions settle as getcash settles them: each top-up is sized from what the deposit account holds on People, short claims add up across three attempts and deliver what was claimed, and the PSM's refusals hold the deposit at once or on the third. `retry_funding` resumes a held conversion or an unclaimed credit (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 5cc1edde5f..2d461bafc6 100644 --- a/rust/crates/truapi-host-cli/src/funding_check.rs +++ b/rust/crates/truapi-host-cli/src/funding_check.rs @@ -104,8 +104,8 @@ impl FundingPlatform for TerminalFundingHost { /// not have. /// /// It checks what a real claim would rest on: the source key controls the -/// session's deposit account, and the amount is within the CASH core saw land -/// on People. Then it reports the claim finalized without moving anything, so +/// session's deposit account, and the amount is the claim core sized from +/// the account's CASH on People. Then it reports the claim finalized without moving anything, so /// a run reaches `Delivered` with every core step real except the claim. struct StandInTopUp { runtime: OnceLock>, @@ -139,14 +139,14 @@ impl StandInTopUp { let Some(session) = session else { return Some("no session to claim for"); }; - let landed = match session.stage { - FundingStage::Converted { landed } | FundingStage::Crediting { landed, .. } => landed, - _ => return Some("no CASH has landed for the session"), - }; if session.deposit.map(|deposit| deposit.account) != Some(secret.to_public().to_bytes()) { return Some("the source key does not control the deposit account"); } - (request.amount > landed).then_some("the amount exceeds the CASH that landed") + let sized = match session.stage { + FundingStage::Crediting { progress } => progress.claim.map(|claim| claim.amount), + _ => None, + }; + (sized != Some(request.amount)).then_some("the amount is not the claim core sized") } } @@ -210,6 +210,8 @@ pub struct FundingCheck { pub intent: Option, /// Convert what arrived of this asset instead of what was asked. pub accept: Option, + /// Try a failed session again from where its funds are. + pub retry: bool, /// Print the session's account seeds for a wallet, and stop. pub export_key: bool, } @@ -256,6 +258,13 @@ pub async fn run( .map_err(|error| anyhow::anyhow!("accepting the deposit failed: {}", error.reason))?; println!("accepted what arrived of {deposit_asset:?}"); } + if check.retry { + runtime + .retry_funding(&intent) + .await + .map_err(|error| anyhow::anyhow!("retrying the session failed: {}", error.reason))?; + println!("retrying the session"); + } follow(&runtime, &intent).await } @@ -341,6 +350,13 @@ async fn follow(runtime: &SigningHostRuntime, intent: &str) -> Result<()> { last_mismatch = mismatch; } match session.stage { + FundingStage::Failed { + reason, + resume: Some(_), + .. + } => bail!( + "the session failed: {reason:?}; its funds are still held, try again with --retry" + ), FundingStage::Failed { reason, .. } => bail!("the session failed: {reason:?}"), FundingStage::Delivered { credited, .. } => { println!("credited {credited} CASH units (stand-in top-up: no coins moved)"); diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 0d3be01230..aff3c2f31a 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -334,6 +334,10 @@ enum Command { /// came after the session ended. #[arg(long, value_enum, requires = "intent")] accept: Option, + /// With `--intent`: try a failed session again from where its funds + /// are, a held conversion or an unclaimed credit. + #[arg(long, requires = "intent")] + retry: bool, /// With `--intent`: print the session's account seeds for a wallet to /// take the funds back by hand, and stop. #[arg(long, requires = "intent")] @@ -688,6 +692,7 @@ async fn dispatch( state_dir, intent, accept, + retry, export_key, } => { let check = funding_check::FundingCheck { @@ -698,6 +703,7 @@ async fn dispatch( state_dir, intent, accept, + retry, export_key, }; funding_check::run(check, |config, state_dir| { diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index c71eced484..a1466f1d47 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -415,12 +415,25 @@ AutoSigning without approval. Legacy-account signing still asks the user. account, paying fees in the deposited asset, after dry-running it on Asset Hub and the message it forwards on People. Once the CASH lands on People, the core credits it through `TopUpPlatform` with the deposit account's key - as a `PrivateKey` source, and the session ends `Delivered`. + as a `PrivateKey` source, and the session ends `Delivered`. Crediting + follows getcash: each of up to three top-ups is sized from the account's + CASH on People when it starts and has its own id. A short claim leaves + the rest to the next one, and after the last the session is delivered + with what was claimed. A conversion the PSM will not serve as quoted (fee + too high, amount out of range) is held at once; one it cannot serve now + (minting stopped, debt ceiling) is held on the third refusal, as is any + other refusal, a conversion included on chain that failed among them. A + top-up is kept once sized and before it is registered, so a restart + registers the same amount under the same id. A held conversion, a credit + that claimed nothing, or one that timed out fails with its funds still on + 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. Native hosts reach all of this through `NativeTrUApiHostRuntime`: `set_funding_callbacks` (the overlay), `set_top_up_callbacks` with `notify_top_up_status` (the top-up engine), `enable_funding_conversion`, - `open_funding`, `quote_funding_deposit`, `assign_funding_deposit` and - `funding_session`. Amounts cross the FFI as decimal strings. + `open_funding`, `quote_funding_deposit`, `assign_funding_deposit`, + `retry_funding` and `funding_session`. Amounts cross the FFI as decimal strings. Each watched deposit account is read for every deposit asset the host names when it enables conversion, and the native token. A deposit counts as delivered once it reaches the deposit quoted for its asset, or what the diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index 8e339bf731..50327dbbe6 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -861,6 +861,19 @@ impl SigningHostRuntime { }) } + /// Try failed session `intent` again from where its funds are, as + /// getcash's "try again": a refused or held conversion converts again by + /// its route, an unclaimed or timed-out credit goes on claiming. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.retry_funding"))] + pub async fn retry_funding(&self, intent: &str) -> Result<(), v01::GenericError> { + self.services + .retry_funding(intent) + .await + .map_err(|err| v01::GenericError { + reason: err.to_string(), + }) + } + /// Raw seed of session `intent`'s `kind` account, for a wallet to import /// and move its funds by hand. `None` while no signing session is active, /// or while it is another account's than the one the session recorded. diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index c6dce72c0b..2695484da7 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -240,14 +240,32 @@ pub struct ConversionSubmission { pub spent: u128, } -/// Dry runs refused before a conversion gives up. +/// Refusals before a conversion gives up, as getcash holds a mint the PSM +/// keeps refusing. const MAX_CONVERSION_REFUSALS: u8 = 3; +/// Top-up attempts before crediting settles for what they claimed. +const MAX_CLAIM_ATTEMPTS: u8 = 3; /// How long a session that ended with its deposit recoverable keeps being /// read, as getcash watches a payment: funds that arrive late, or stay after /// a refused conversion, can still be converted. pub const LATE_WATCH_MS: u64 = 72 * 60 * 60 * 1_000; /// Code a session fails with when its conversion was refused. const CONVERSION_REFUSED: &str = "conversion_refused"; +/// Code a session fails with when the PSM would not mint its deposit. +const CONVERSION_HELD: &str = "conversion_held"; +/// Code a session fails with when its top-ups claimed nothing. +const CREDIT_UNCLAIMED: &str = "credit_unclaimed"; + +/// Why a failed session cannot be retried. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub enum RetryRefusal { + /// It did not fail, or its funds are not where a retry can reach them. + #[display("the session has nothing to retry")] + NotResumable, + /// Nothing of the deposit's asset is on the deposit account. + #[display("the deposit account holds none of the deposit")] + NothingHeld, +} /// Why a deposit could not be accepted as it arrived. #[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] @@ -294,14 +312,8 @@ pub enum FundingStage { /// Inbound: the host's top-up is claiming the landed CASH into the /// user's balance. Crediting { - /// CASH on People, in payment balance units. - landed: u128, - /// Which top-up attempt is running, from 0. - attempt: u8, - /// When that attempt was registered, in Unix milliseconds. - since_ms: u64, - /// When the first attempt was registered, in Unix milliseconds. - started_ms: u64, + /// What the top-ups have claimed so far. + progress: CreditProgress, }, /// Inbound terminal success: the CASH is in the user's balance. Delivered { @@ -316,9 +328,64 @@ pub enum FundingStage { reason: FundingFailure, /// When it ended, in Unix milliseconds. settled_at_ms: u64, + /// Where a retry picks up, when the funds are still on the session's + /// accounts. + resume: Option, + }, +} + +/// Where a retry of a failed session picks up, as getcash re-arms a held or +/// unclaimed request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] +pub enum FundingResume { + /// The deposit is still on Asset Hub: convert it again by its route. + Conversion, + /// The CASH is on People: credit it from `progress`. + Credit { + /// What the top-ups claimed, and the attempt to go on with. + progress: CreditProgress, }, } +/// Top-ups claiming a session's CASH, as getcash tracks its claim. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct CreditProgress { + /// CASH the earlier attempts claimed, in payment balance units. + pub credited: u128, + /// Which attempt is running or next, from 0. + pub attempt: u8, + /// The attempt's claim, `None` until it is sized from what the account + /// holds. + pub claim: Option, + /// When crediting started, or was last retried, in Unix milliseconds. + pub started_ms: u64, +} + +/// One top-up, kept from when it is sized so that registering it again +/// after a restart asks for the same amount under the same id. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct Claim { + /// CASH it claims, in payment balance units. + pub amount: u128, + /// When it was sized, then when the host accepted it, in Unix + /// milliseconds. + pub since_ms: u64, + /// Whether the host accepted it. + pub registered: bool, +} + impl FundingSession { /// Open a session that expires one window from `now_ms`. pub fn new( @@ -390,12 +457,23 @@ impl FundingSession { /// End the session with `reason`, unless it already ended. Returns whether /// it changed. pub fn fail(&mut self, reason: FundingFailure, now_ms: u64) -> bool { + self.fail_resumable(reason, None, now_ms) + } + + /// [`Self::fail`], leaving where a retry picks up. + fn fail_resumable( + &mut self, + reason: FundingFailure, + resume: Option, + now_ms: u64, + ) -> bool { if self.is_terminal() { return false; } self.stage = FundingStage::Failed { reason, settled_at_ms: now_ms, + resume, }; true } @@ -442,24 +520,63 @@ impl FundingSession { /// Whether the session ended in a way its deposit can come back from, /// within the late watch window: it expired, or its conversion was - /// refused while the funds stayed on the account. + /// refused or held while the funds stayed on the account. fn recoverable_since(&self, now_ms: u64) -> bool { match &self.stage { FundingStage::Failed { reason, settled_at_ms, + resume, } => { - let recoverable = match reason { - FundingFailure::Expired => true, - FundingFailure::Other { code, .. } => code == CONVERSION_REFUSED, - _ => false, - }; + let recoverable = *reason == FundingFailure::Expired + || *resume == Some(FundingResume::Conversion); recoverable && now_ms.saturating_sub(*settled_at_ms) <= LATE_WATCH_MS } _ => false, } } + /// Pick a failed session up where its funds are, as getcash's "try + /// again" does: a refused or held conversion is tried again by its route + /// with its refusals cleared, an unclaimed or timed-out credit goes on + /// from its progress. The route and quote stay as they were. + pub fn retry(&mut self, now_ms: u64) -> Result<(), RetryRefusal> { + let FundingStage::Failed { + resume: Some(resume), + .. + } = self.stage + else { + return Err(RetryRefusal::NotResumable); + }; + self.stage = match resume { + FundingResume::Conversion => { + let deposited = self + .deposit + .as_ref() + .map_or(0, |deposit| deposit.held(deposit.asset)); + if deposited == 0 { + return Err(RetryRefusal::NothingHeld); + } + FundingStage::Converting { + deposited, + refusals: 0, + submission: None, + } + } + FundingResume::Credit { progress } => FundingStage::Crediting { + progress: CreditProgress { + claim: progress.claim.map(|claim| Claim { + since_ms: now_ms, + ..claim + }), + started_ms: now_ms, + ..progress + }, + }, + }; + Ok(()) + } + /// Balance of the deposit's asset at which it counts as delivered, as /// getcash gates a payment: the deposit quoted for that asset, else what /// the provider was asked for. @@ -589,18 +706,33 @@ impl FundingSession { match step { ConversionStep::Submitted(submitted) => *submission = Some(submitted), ConversionStep::Dropped => *submission = None, - ConversionStep::Refused { reason } => { + ConversionStep::Refused { reason, psm } => { *submission = None; - *refusals = refusals.saturating_add(1); - if *refusals >= MAX_CONVERSION_REFUSALS { - return self.fail( - FundingFailure::Other { - code: CONVERSION_REFUSED.into(), - message: reason, - }, - now_ms, - ); + if psm != Some(PsmRefusal::WillNotServe) { + *refusals = refusals.saturating_add(1); + if *refusals < MAX_CONVERSION_REFUSALS { + return true; + } } + let (code, message) = match psm { + Some(PsmRefusal::WillNotServe) => ( + CONVERSION_HELD, + format!("the PSM will not mint this deposit as quoted: {reason}"), + ), + Some(PsmRefusal::Unavailable) => ( + CONVERSION_HELD, + format!("the PSM refused the mint {MAX_CONVERSION_REFUSALS} times, last: {reason}"), + ), + None => (CONVERSION_REFUSED, reason), + }; + return self.fail_resumable( + FundingFailure::Other { + code: code.into(), + message, + }, + Some(FundingResume::Conversion), + now_ms, + ); } ConversionStep::Landed { landed } => { self.stage = FundingStage::Converted { landed }; @@ -619,113 +751,206 @@ impl FundingSession { } } -/// A top-up attempt that is running. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct CreditAttempt { - /// Which attempt, from 0. - pub attempt: u8, - /// When it was registered, in Unix milliseconds. - pub since_ms: u64, - /// When the first attempt was registered, in Unix milliseconds. - pub started_ms: u64, -} - impl FundingSession { - /// The deposit and landed CASH of a session awaiting or being credited, - /// with the attempt running, if any. - pub fn crediting(&self) -> Option<(&FundingDeposit, u128, Option)> { + /// The deposit of a session awaiting or being credited, with what its + /// top-ups have claimed so far, `None` before the first. + pub fn crediting(&self) -> Option<(&FundingDeposit, Option)> { let deposit = self.deposit.as_ref()?; match self.stage { - FundingStage::Converted { landed } => Some((deposit, landed, None)), - FundingStage::Crediting { - landed, - attempt, - since_ms, - started_ms, - } => Some(( - deposit, - landed, - Some(CreditAttempt { - attempt, - since_ms, - started_ms, - }), - )), + FundingStage::Converted { .. } => Some((deposit, None)), + FundingStage::Crediting { progress } => Some((deposit, Some(progress))), _ => None, } } - /// Advance a session being credited by one step. Returns whether it - /// changed. + /// Advance a session being credited by one step, as getcash settles its + /// claim. Returns whether it changed. pub fn advance_credit(&mut self, step: CreditStep, now_ms: u64) -> bool { - let (landed, started_ms) = match self.stage { - FundingStage::Converted { landed } => (landed, now_ms), - FundingStage::Crediting { - landed, started_ms, .. - } => (landed, started_ms), + let progress = match self.stage { + FundingStage::Converted { .. } => CreditProgress { + credited: 0, + attempt: 0, + claim: None, + started_ms: now_ms, + }, + FundingStage::Crediting { progress } => progress, _ => return false, }; + let credited_with = |claimed: u128| progress.credited.saturating_add(claimed); match step { - CreditStep::Registered { attempt } => { + CreditStep::Sized { amount } => { self.stage = FundingStage::Crediting { - landed, - attempt, - since_ms: now_ms, - started_ms, + progress: CreditProgress { + claim: Some(Claim { + amount, + since_ms: now_ms, + registered: false, + }), + ..progress + }, }; true } - CreditStep::Credited { credited } => { + CreditStep::Registered => { + let Some(claim) = progress.claim.filter(|claim| !claim.registered) else { + return false; + }; + self.stage = FundingStage::Crediting { + progress: CreditProgress { + claim: Some(Claim { + since_ms: now_ms, + registered: true, + ..claim + }), + ..progress + }, + }; + true + } + CreditStep::Claimed { claimed } => { self.stage = FundingStage::Delivered { - credited, + credited: credited_with(claimed), settled_at_ms: now_ms, }; true } - CreditStep::Abandoned { reason } => self.fail( + CreditStep::Short { claimed } => { + let progress = CreditProgress { + credited: credited_with(claimed), + attempt: progress.attempt.saturating_add(1), + claim: None, + ..progress + }; + if progress.attempt < MAX_CLAIM_ATTEMPTS { + self.stage = FundingStage::Crediting { progress }; + return true; + } + self.settle_credit(progress, now_ms) + } + CreditStep::Drained => self.settle_credit(progress, now_ms), + // Between attempts nothing is in flight, so what earlier ones + // claimed is delivered rather than the session failing. + CreditStep::TimedOut if progress.claim.is_none() && progress.credited > 0 => { + self.settle_credit(progress, now_ms) + } + CreditStep::TimedOut => self.fail_resumable( FundingFailure::Other { - code: "credit_failed".into(), + code: "credit_timeout".into(), + message: "the top-up did not finish in time".into(), + }, + Some(FundingResume::Credit { progress }), + now_ms, + ), + CreditStep::Refused { reason } => self.fail_resumable( + FundingFailure::Other { + code: "credit_refused".into(), message: reason, }, + Some(FundingResume::Credit { + progress: CreditProgress { + claim: None, + ..progress + }, + }), now_ms, ), } } + + /// End crediting with what the top-ups claimed: delivered, partly if + /// they fell short, or failed with the CASH still on People, to be + /// retried from `progress`. + fn settle_credit(&mut self, progress: CreditProgress, now_ms: u64) -> bool { + if progress.credited > 0 { + self.stage = FundingStage::Delivered { + credited: progress.credited, + settled_at_ms: now_ms, + }; + return true; + } + self.fail_resumable( + FundingFailure::Other { + code: CREDIT_UNCLAIMED.into(), + message: "the host claimed no CASH from the deposit account".into(), + }, + Some(FundingResume::Credit { progress }), + now_ms, + ) + } } /// What one pass of crediting found or did. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CreditStep { - /// The host accepted top-up `attempt`. - Registered { - /// Which attempt, from 0. - attempt: u8, + /// The next top-up was sized from what the account holds, to be kept + /// before it is registered. + Sized { + /// CASH it claims, in payment balance units. + amount: u128, }, - /// The top-up credited the user's balance. - Credited { - /// Amount credited, in payment balance units. - credited: u128, + /// The host accepted the sized top-up. + Registered, + /// The running top-up claimed all it asked for, and it is final. + Claimed { + /// Amount it claimed, in payment balance units. + claimed: u128, + }, + /// The running top-up claimed less than it asked for, or nothing; the + /// next attempt claims what is left. + Short { + /// Amount it claimed, in payment balance units. + claimed: u128, }, - /// Crediting cannot succeed; the CASH stays on the account on People. - Abandoned { + /// Less is left on the account than a top-up can claim. + Drained, + /// The running top-up, or crediting as a whole, took too long. + TimedOut, + /// The host will not take the deposit account as a top-up source. + Refused { /// Why. reason: String, }, } +/// How the PSM refused a mint, as getcash classes its dispatch errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PsmRefusal { + /// Minting is stopped or the PSM is at its debt ceiling, which may + /// pass: counted, the third holds the funds. + Unavailable, + /// The fee is above the quote's or the amount is outside what the PSM + /// takes, which retrying the same mint cannot cure: holds at once. + WillNotServe, +} + +impl PsmRefusal { + /// The class of the PSM pallet error named `error`, `None` for one that + /// is not a refusal. + pub fn of(error: &str) -> Option { + match error { + "MintingStopped" | "AllSwapsStopped" | "ExceedsMaxPsmDebt" => Some(Self::Unavailable), + "FeeTooHigh" | "BelowMinimumSwap" | "AmountTooSmallAfterConversion" => { + Some(Self::WillNotServe) + } + _ => None, + } + } +} + /// What one pass of a conversion found or did. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConversionStep { /// The conversion transaction is about to be submitted. Submitted(ConversionSubmission), /// The submitted transaction can no longer convert anything: its era - /// ended unincluded, or it was included and failed. The next pass - /// submits again. + /// ended unincluded. The next pass submits again. Dropped, - /// A dry run refused the conversion. + /// A dry run or the transaction pool refused the conversion. Refused { /// Why, as the chain reported it. reason: String, + /// How the PSM refused it, when it was the PSM. + psm: Option, }, /// CASH arrived on People. Landed { @@ -838,9 +1063,11 @@ pub enum FundingSessionError { /// The bound exists because [`CoreStorageKey::FundingSessions`] is one SCALE /// blob rewritten on every change; the host keeps the full history. pub fn retained(sessions: impl IntoIterator) -> Vec { - let (mut open, mut settled): (Vec<_>, Vec<_>) = sessions - .into_iter() - .partition(|session| !session.is_terminal()); + // A failed session whose funds a retry can still reach is kept like an + // open one, so history cannot push it out. + let (mut open, mut settled): (Vec<_>, Vec<_>) = sessions.into_iter().partition(|session| { + !session.is_terminal() || matches!(session.stage, FundingStage::Failed { resume: Some(_), .. }) + }); settled.sort_by_key(|session| core::cmp::Reverse(session.settled_at_ms())); settled.truncate(SETTLED_HISTORY_LIMIT); open.append(&mut settled); @@ -948,6 +1175,7 @@ mod tests { stage: FundingStage::Failed { reason: FundingFailure::Expired, settled_at_ms, + resume: None, }, ..session(FundingDirection::In) } @@ -1085,25 +1313,28 @@ mod tests { } } + fn refuse(session: &mut FundingSession, psm: Option) -> FundingStage { + session.advance_conversion( + ConversionStep::Refused { + reason: "no pool".into(), + psm, + }, + NOW, + ); + session.stage.clone() + } + // A dry run that keeps refusing will not start passing, so the third - // refusal ends the session rather than retrying forever, and a refusal - // clears any submission so the next attempt starts clean. + // refusal ends the session rather than retrying forever, with the + // deposit left where a retry can convert it; a refusal clears any + // submission so the next attempt starts clean. #[test] fn a_conversion_ends_on_its_third_refusal() { let mut session = converting(); session.advance_conversion(ConversionStep::Submitted(SUBMISSION), NOW); - let refused = |session: &mut FundingSession| { - session.advance_conversion( - ConversionStep::Refused { - reason: "no pool".into(), - }, - NOW, - ); - session.stage.clone() - }; assert_eq!( - [refused(&mut session), refused(&mut session), refused(&mut session)], + [refuse(&mut session, None), refuse(&mut session, None), refuse(&mut session, None)], [ FundingStage::Converting { deposited: 50, @@ -1121,11 +1352,71 @@ mod tests { message: "no pool".into(), }, settled_at_ms: NOW, + resume: Some(FundingResume::Conversion), }, ] ); } + // getcash holds the funds on the first refusal the PSM will never get + // past, such as a fee above the quote, and on the third it may get past; + // both leave the deposit for a retry, which starts the count again. + #[test] + fn the_psm_holds_the_deposit_as_getcash_holds_it() { + let held = |message: &str| FundingStage::Failed { + reason: FundingFailure::Other { + code: "conversion_held".into(), + message: message.into(), + }, + settled_at_ms: NOW, + resume: Some(FundingResume::Conversion), + }; + let mut will_not = converting(); + let mut unavailable = converting(); + let unavailable_stages = [ + refuse(&mut unavailable, Some(PsmRefusal::Unavailable)), + refuse(&mut unavailable, Some(PsmRefusal::Unavailable)), + refuse(&mut unavailable, Some(PsmRefusal::Unavailable)), + ]; + let held_deposit = FundingDeposit { + holdings: vec![DepositHolding { + asset: DepositAsset::Asset(1984), + balance: 50, + }], + ..converting().deposit.expect("deposit") + }; + unavailable.deposit = Some(held_deposit); + let retried = (unavailable.retry(NOW + 1), unavailable.stage.clone()); + + assert_eq!( + (refuse(&mut will_not, Some(PsmRefusal::WillNotServe)), unavailable_stages, retried), + ( + held("the PSM will not mint this deposit as quoted: no pool"), + [ + FundingStage::Converting { + deposited: 50, + refusals: 1, + submission: None, + }, + FundingStage::Converting { + deposited: 50, + refusals: 2, + submission: None, + }, + held("the PSM refused the mint 3 times, last: no pool"), + ], + ( + Ok(()), + FundingStage::Converting { + deposited: 50, + refusals: 0, + submission: None, + } + ), + ) + ); + } + // The provider is told the quoted figure, so the quote it was given, // not one re-priced when the account is assigned, decides whether its // deposit is enough. @@ -1221,6 +1512,7 @@ mod tests { stage: FundingStage::Failed { reason: FundingFailure::Expired, settled_at_ms, + resume: None, }, ..open.clone() }; @@ -1279,6 +1571,7 @@ mod tests { stage: FundingStage::Failed { reason: FundingFailure::Expired, settled_at_ms: NOW, + resume: None, }, ..quoted.clone() }; @@ -1340,33 +1633,220 @@ mod tests { ); } + fn landed() -> FundingSession { + let mut session = converting(); + session.advance_conversion(ConversionStep::Landed { landed: 49 }, NOW); + session + } + + fn credit(session: &mut FundingSession, steps: impl IntoIterator) -> FundingStage { + for step in steps { + session.advance_credit(step, NOW); + } + session.stage.clone() + } + // Delivered is the one inbound success: it ends the session for // subscribers and history, and the credited amount is what they see. #[test] fn a_credited_session_is_delivered() { - let mut session = converting(); - session.advance_conversion(ConversionStep::Landed { landed: 49 }, NOW); - session.advance_credit(CreditStep::Registered { attempt: 0 }, NOW); - let crediting = session.crediting().map(|(_, landed, running)| (landed, running)); - session.advance_credit(CreditStep::Credited { credited: 40 }, NOW + 1); + let mut session = landed(); + session.advance_credit(CreditStep::Sized { amount: 40 }, NOW); + session.advance_credit(CreditStep::Registered, NOW); + let crediting = session.crediting().map(|(_, progress)| progress); + session.advance_credit(CreditStep::Claimed { claimed: 40 }, NOW + 1); assert_eq!( (crediting, session.wire_item(), session.settled_at_ms()), ( - Some(( - 49, - Some(CreditAttempt { - attempt: 0, + Some(Some(CreditProgress { + credited: 0, + attempt: 0, + claim: Some(Claim { + amount: 40, since_ms: NOW, - started_ms: NOW, - }) - )), + registered: true, + }), + started_ms: NOW, + })), HostFundingStatusSubscribeItem::Delivered { credited: 40 }, Some(NOW + 1), ) ); } + // getcash adds up what each claim took: a short claim leaves the rest + // for the next attempt, a final one delivers the total, and after the + // last attempt whatever was claimed is delivered, short as it is. + #[test] + fn short_claims_add_up_and_the_last_attempt_settles_for_them() { + let short = |claimed| CreditStep::Short { claimed }; + + assert_eq!( + [ + credit(&mut landed(), [CreditStep::Sized { amount: 40 }, CreditStep::Registered, short(10)]), + credit(&mut landed(), [CreditStep::Sized { amount: 40 }, CreditStep::Registered, short(10), CreditStep::Sized { amount: 30 }, CreditStep::Registered, CreditStep::Claimed { claimed: 30 }]), + credit(&mut landed(), [CreditStep::Sized { amount: 40 }, CreditStep::Registered, short(10), CreditStep::Sized { amount: 30 }, CreditStep::Registered, short(0), CreditStep::Sized { amount: 30 }, CreditStep::Registered, short(5)]), + credit(&mut landed(), [CreditStep::Sized { amount: 40 }, CreditStep::Registered, short(10), CreditStep::Drained]), + ], + [ + FundingStage::Crediting { + progress: CreditProgress { + credited: 10, + attempt: 1, + claim: None, + started_ms: NOW, + }, + }, + FundingStage::Delivered { + credited: 40, + settled_at_ms: NOW, + }, + FundingStage::Delivered { + credited: 15, + settled_at_ms: NOW, + }, + FundingStage::Delivered { + credited: 10, + settled_at_ms: NOW, + }, + ] + ); + } + + // Between attempts nothing is in flight, so running out of time there + // delivers what was claimed; with a top-up in flight it fails, to be + // watched again on a retry. + #[test] + fn running_out_of_time_between_attempts_delivers_what_was_claimed() { + let short = CreditStep::Short { claimed: 10 }; + let sized = CreditStep::Sized { amount: 30 }; + + assert_eq!( + [ + credit(&mut landed(), [CreditStep::Sized { amount: 40 }, CreditStep::Registered, short.clone(), CreditStep::TimedOut]), + credit(&mut landed(), [CreditStep::Sized { amount: 40 }, CreditStep::Registered, short, sized, CreditStep::TimedOut]), + ], + [ + FundingStage::Delivered { + credited: 10, + settled_at_ms: NOW, + }, + FundingStage::Failed { + reason: FundingFailure::Other { + code: "credit_timeout".into(), + message: "the top-up did not finish in time".into(), + }, + settled_at_ms: NOW, + resume: Some(FundingResume::Credit { + progress: CreditProgress { + credited: 10, + attempt: 1, + claim: Some(Claim { + amount: 30, + since_ms: NOW, + registered: false, + }), + started_ms: NOW, + }, + }), + }, + ] + ); + } + + // A failed session whose funds a retry can still reach must outlive the + // history bound, or the host could lose track of where they are. + #[test] + fn history_keeps_a_failed_session_that_still_holds_funds() { + let held = FundingSession { + intent: "fs_held".into(), + stage: FundingStage::Failed { + reason: FundingFailure::Expired, + settled_at_ms: NOW - 1, + resume: Some(FundingResume::Conversion), + }, + ..session(FundingDirection::In) + }; + let history = (0..SETTLED_HISTORY_LIMIT as u64).map(|n| expired(&format!("fs_{n}"), NOW + n)); + + assert!( + retained(history.chain([held.clone()])) + .iter() + .any(|session| session.intent == held.intent) + ); + } + + // CASH no top-up claimed is still on People, so a failed credit is + // retried as getcash re-arms it: after an unclaimed last attempt with a + // fresh attempt and id, after a timeout with the same claim watched + // again, and after a refused source by registering the attempt again. + #[test] + fn a_failed_credit_is_retried_from_where_it_stopped() { + let short = CreditStep::Short { claimed: 0 }; + let retried = |mut session: FundingSession| { + let retried = session.retry(NOW + 5); + (retried, session.stage) + }; + let progress = |attempt, claim| FundingStage::Crediting { + progress: CreditProgress { + credited: 0, + attempt, + claim, + started_ms: NOW + 5, + }, + }; + let mut unclaimed = landed(); + let unclaimed_stage = credit( + &mut unclaimed, + [CreditStep::Sized { amount: 40 }, CreditStep::Registered, short.clone(), CreditStep::Sized { amount: 40 }, CreditStep::Registered, short.clone(), CreditStep::Sized { amount: 40 }, CreditStep::Registered, short], + ); + let mut timed_out = landed(); + credit(&mut timed_out, [CreditStep::Sized { amount: 40 }, CreditStep::Registered, CreditStep::TimedOut]); + let mut refused = landed(); + credit(&mut refused, [CreditStep::Refused { reason: "no".into() }]); + + assert_eq!( + ( + unclaimed_stage, + [retried(unclaimed), retried(timed_out), retried(refused), retried(landed())], + ), + ( + FundingStage::Failed { + reason: FundingFailure::Other { + code: "credit_unclaimed".into(), + message: "the host claimed no CASH from the deposit account".into(), + }, + settled_at_ms: NOW, + resume: Some(FundingResume::Credit { + progress: CreditProgress { + credited: 0, + attempt: 3, + claim: None, + started_ms: NOW, + }, + }), + }, + [ + (Ok(()), progress(3, None)), + ( + Ok(()), + progress( + 0, + Some(Claim { + amount: 40, + since_ms: NOW + 5, + registered: true, + }) + ) + ), + (Ok(()), progress(0, None)), + (Err(RetryRefusal::NotResumable), landed().stage), + ], + ) + ); + } + // CASH on People is what the user is owed, so landing ends conversion // whatever the submission state, and the subscriber keeps seeing // converting until it is credited. diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index 562d2fbba9..1068479f71 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -292,6 +292,11 @@ impl NativeTrUApiHostRuntime { Ok(self.runtime.accept_funding_deposit(&intent, asset).await?) } + /// Try failed session `intent` again from where its funds are. + pub async fn retry_funding(&self, intent: String) -> Result<(), HostRejection> { + Ok(self.runtime.retry_funding(&intent).await?) + } + /// What arrived on session `intent`'s deposit account that does not /// match what was asked, if anything. pub fn funding_deposit_mismatch( diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs index 7f07404da9..d7a4449a11 100644 --- a/rust/crates/truapi/src/runtime/funding.rs +++ b/rust/crates/truapi/src/runtime/funding.rs @@ -39,7 +39,7 @@ use super::statement_allowance::{ChainClient, ChainContext}; use super::statement_allowance::rpc::RpcClient; use crate::host_logic::features; use crate::host_logic::funding::{ - AcceptRefusal, ConversionRoute, ConversionStep, ConversionSubmission, CreditAttempt, CreditStep, + AcceptRefusal, ConversionRoute, ConversionStep, ConversionSubmission, CreditProgress, CreditStep, DepositAsset, DepositHolding, DepositMismatch, DepositQuote, DepositRequest, FundingDeposit, FundingSession, FundingSessionError, FundingStage, load_sessions, next_account_number, retained, store_sessions, @@ -431,18 +431,17 @@ impl FundingRegistry { .collect() } - /// Every session awaiting or being credited, with its deposit, landed - /// CASH and running attempt. + /// Every session awaiting or being credited, with its deposit and what + /// its top-ups claimed so far. fn crediting_sessions(&self) -> Vec { self.lock_sessions() .values() .filter_map(|session| { - let (deposit, landed, running) = session.crediting()?; + let (deposit, progress) = session.crediting()?; Some(CreditingSession { intent: session.intent.clone(), deposit: deposit.clone(), - landed, - running, + progress, }) }) .collect() @@ -519,8 +518,15 @@ impl FundingRegistry { struct CreditingSession { intent: String, deposit: FundingDeposit, - landed: u128, - running: Option, + progress: Option, +} + +impl CreditingSession { + /// Whether the next top-up has yet to be sized from what the account + /// holds. + fn sizing(&self) -> bool { + self.progress.is_none_or(|progress| progress.claim.is_none()) + } } /// A deposit request with the route core chose for it. @@ -556,8 +562,9 @@ enum PlannedStep { /// /// A submitted conversion is judged only by what it did: landed once People /// shows its CASH on top of what was there before; dropped once its era has -/// passed unincluded, or once it was included and the deposit is still on -/// Asset Hub; stalled once it took the deposit and nothing arrived in time. +/// passed unincluded; refused once it was included and the deposit is still +/// on Asset Hub, so a conversion failing on chain is bounded like one a dry +/// run refuses; stalled once it took the deposit and nothing arrived in time. /// A fresh conversion is signed only by the account the deposit sits on. async fn plan_conversion( chains: &dyn ConversionChains, @@ -581,7 +588,12 @@ async fn plan_conversion( .await .map_err(|error| ConversionError::Chain(error.reason))?; if held >= submission.spent { - Some(ConversionStep::Dropped) + // Included without taking the deposit: it failed on chain, + // and resubmitting it as is costs a fee each time. + Some(ConversionStep::Refused { + reason: "the conversion was included and failed".into(), + psm: None, + }) } else if now_ms.saturating_sub(submission.submitted_at_ms) > STALL_AFTER_MS { // What did land on People is the user's: credit it rather // than fail, where getcash fails the job as a shortfall. @@ -696,7 +708,9 @@ impl AssignDepositError { /// A conversion error as an assignment error: a refusal stays one. fn from_conversion(error: ConversionError) -> Self { match error { - ConversionError::Refused(reason) => Self::Refused(reason), + ConversionError::Refused(reason) | ConversionError::PsmRefused { reason, .. } => { + Self::Refused(reason) + } ConversionError::Chain(reason) => Self::Chain(GenericError { reason }), } } @@ -824,6 +838,30 @@ impl RuntimeServices { Ok(account) } + /// Try a failed session `intent` again from where its funds are: its + /// conversion if the deposit is still on Asset Hub, its crediting if the + /// CASH is on People. + pub async fn retry_funding(self: &Arc, intent: &str) -> Result<(), AssignDepositError> { + let intent = intent.to_string(); + let now_ms = current_unix_millis(); + self.funding() + .commit(self.platform.as_ref(), now_ms, move |sessions| { + let retried = sessions + .get_mut(&intent) + .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() }; + (retried, changed) + }) + .await??; + self.watch_funding_deposits(); + Ok(()) + } + /// Convert what arrived of `asset` on session `intent`'s deposit account, /// by the route that serves that much of it. pub async fn accept_funding_deposit( @@ -1031,15 +1069,43 @@ impl RuntimeServices { signer: conversion.signer.as_ref(), product: &product, }; - for CreditingSession { - intent, - deposit, - landed, - running, - } in crediting - { + // 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 { + Ok(chains) => Some(chains), + Err(error) => { + tracing::warn!(%error, "reading funding accounts on People failed"); + None + } + } + } else { + None + }; + for session in crediting { + let held = match &chains { + Some(chains) if session.sizing() => { + let read = within_chain_timeout(chains.landed(&session.deposit.account)).await; + match read + .map_err(|error| error.reason) + .and_then(|held| held.map_err(|error| error.to_string())) + { + Ok(held) => Some(held), + Err(reason) => { + tracing::warn!(intent = %session.intent, %reason, "reading a funding account on People failed"); + None + } + } + } + _ => None, + }; + let CreditingSession { + intent, + deposit, + progress, + } = session; let now_ms = current_unix_millis(); - match credit.plan(&deposit, landed, running, now_ms).await { + match credit.plan(&deposit, progress, held, now_ms).await { Ok(Some(step)) => registry .record_credit(self.platform.as_ref(), now_ms, &intent, step) .await @@ -1116,7 +1182,13 @@ impl RuntimeServices { let planned = match planned { Ok(Ok(planned)) => planned, Ok(Err(ConversionError::Refused(reason))) => { - Some(PlannedStep::Record(ConversionStep::Refused { reason })) + Some(PlannedStep::Record(ConversionStep::Refused { reason, psm: None })) + } + Ok(Err(ConversionError::PsmRefused { reason, refusal })) => { + Some(PlannedStep::Record(ConversionStep::Refused { + reason, + psm: Some(refusal), + })) } Ok(Err(ConversionError::Chain(reason))) | Err(GenericError { reason }) => { tracing::warn!(%intent, %reason, "funding conversion pass failed"); @@ -1139,8 +1211,8 @@ impl RuntimeServices { .map_err(|error| error.to_string())?; match chains.submit(extrinsic).await { Ok(()) => Ok(()), - Err(ConversionError::Refused(reason)) => { - let refused = ConversionStep::Refused { reason }; + Err(ConversionError::Refused(reason) | ConversionError::PsmRefused { reason, .. }) => { + let refused = ConversionStep::Refused { reason, psm: None }; registry .record_conversion(storage, current_unix_millis(), &intent, refused) .await @@ -1325,6 +1397,7 @@ mod tests { Some(FundingStage::Failed { reason: FundingFailure::Expired, settled_at_ms: NOW, + resume: None, }) ); } @@ -1546,6 +1619,7 @@ mod tests { Some(FundingStage::Failed { reason: FundingFailure::Expired, settled_at_ms: past_deadline, + resume: None, }), ], ) @@ -1704,7 +1778,9 @@ mod tests { // Resubmitting while the first transaction can still land would convert // twice, so a submission is dropped only once it provably cannot: its era - // passed unincluded, or it was included and left the deposit in place. + // passed unincluded, or it was included and left the deposit in place, + // which counts as a refusal so a failing conversion is not paid for + // forever. #[test] fn a_submission_is_dropped_only_once_it_cannot_convert() { let chains = |nonce, balance, block| Scripted { @@ -1726,7 +1802,10 @@ mod tests { [ None, Some(PlannedStep::Record(ConversionStep::Dropped)), - Some(PlannedStep::Record(ConversionStep::Dropped)), + Some(PlannedStep::Record(ConversionStep::Refused { + reason: "the conversion was included and failed".into(), + psm: None, + })), None, Some(PlannedStep::Record(ConversionStep::Stalled)), ] @@ -1764,6 +1843,7 @@ mod tests { stage: FundingStage::Failed { reason: FundingFailure::Expired, settled_at_ms, + resume: None, }, deposit: Some(FundingDeposit { account: holder, @@ -1825,6 +1905,7 @@ mod tests { stage: FundingStage::Failed { reason: FundingFailure::Expired, settled_at_ms: NOW, + resume: None, }, deposit: Some(converting_deposit()), ..session("fs_ended", NOW - DAY_MS) diff --git a/rust/crates/truapi/src/runtime/funding/conversion.rs b/rust/crates/truapi/src/runtime/funding/conversion.rs index d1be2e8aa7..0268fada45 100644 --- a/rust/crates/truapi/src/runtime/funding/conversion.rs +++ b/rust/crates/truapi/src/runtime/funding/conversion.rs @@ -22,7 +22,7 @@ use truapi::latest::{GenericError, TxPayloadExtension}; use crate::host_internal::extrinsic::{Sr25519Signer, build_signed_extrinsic_v4}; use super::DepositBalances; use super::credit::CLAIM_UNIT; -use crate::host_logic::funding::{ConversionRoute, DepositAsset, DepositQuote, FundingDeposit}; +use crate::host_logic::funding::{ConversionRoute, DepositAsset, DepositQuote, FundingDeposit, PsmRefusal}; use crate::runtime::statement_allowance::ChainContext; use crate::runtime::statement_allowance::extension::{ChainState, Metadata as ExtensionMetadata}; @@ -154,6 +154,14 @@ pub enum ConversionError { /// since retrying the same conversion keeps failing. #[display("refused: {_0}")] Refused(String), + /// The PSM refused the mint, as getcash classes the refusal. + #[display("refused by the PSM: {reason}")] + PsmRefused { + /// Why, as the chain reported it. + reason: String, + /// How the PSM refused it. + refusal: PsmRefusal, + }, /// The chains could not be read or reached. Retried on the next pass. #[display("{_0}")] Chain(String), @@ -686,6 +694,11 @@ impl Chains { first_refusal.get_or_insert(refusal.reason); allowance *= 2; } + Err(DryRunRefusal { + reason, + psm: Some(refusal), + .. + }) => return Err(ConversionError::PsmRefused { reason, refusal }), Err(refusal) => { return Err(ConversionError::Refused(first_refusal.unwrap_or(refusal.reason))); } @@ -740,6 +753,7 @@ impl Chains { Ok(Err(DryRunRefusal { reason, fees_short: false, + psm: None, })) }; let origin = Value::unnamed_variant( @@ -759,6 +773,7 @@ impl Chains { return Ok(Err(DryRunRefusal { reason: format!("dry run failed on Asset Hub: {} ({execution})", names.join(" / ")), fees_short: names.iter().any(|name| name == "NotHoldingFees"), + psm: psm_refusal(&names), })); } let events = field(&effects, "emitted_events")?; @@ -1350,16 +1365,18 @@ fn cash_to_teleport(target: u128) -> Option { teleported_for(target.div_ceil(CLAIM_UNIT).checked_mul(CLAIM_UNIT)?) } -/// Why a dry run refused a conversion, and whether more fees would cure it. +/// Why a dry run refused a conversion, whether more fees would cure it, and +/// how the PSM refused it when it was the PSM. #[derive(Debug)] struct DryRunRefusal { reason: String, fees_short: bool, + psm: Option, } -/// The error names a failed dispatch's module error decodes to through -/// `metadata`: the pallet error, then any enum inside it, such -/// as the XCM error a failed local execution carries. +/// The names a failed dispatch's module error decodes to through +/// `metadata`: the pallet, its error, then any enum inside it, such as the +/// XCM error a failed local execution carries. fn module_error_names(metadata: &subxt::Metadata, execution: &Value) -> Vec { let Some(module) = find_variant(execution, "Module") else { return Vec::new(); @@ -1384,7 +1401,7 @@ fn module_error_names(metadata: &subxt::Metadata, execution: &Value) -> Vec Vec Option { + match names { + [pallet, error, ..] if pallet == "Psm" => PsmRefusal::of(error), + _ => None, + } +} + /// The first variant named `name` anywhere in `value`. fn find_variant<'a>(value: &'a Value, name: &str) -> Option<&'a Value> { match &value.value { @@ -1927,11 +1953,12 @@ mod tests { assert_eq!( module_error_names(&metadata, &execution), - ["LocalExecutionIncompleteWithError", "NotHoldingFees"] + ["PolkadotXcm", "LocalExecutionIncompleteWithError", "NotHoldingFees"] ); } + // Sized from the getcash formulas: the PSM keeps its fee, rounded up, // and every fee estimate gets a tenth more, rounded up. #[test] @@ -2058,6 +2085,57 @@ mod live { .expect("chains pinned") } + // getcash holds a mint the PSM will not serve at once and counts one it + // cannot serve now; any other pallet's error is not a PSM refusal. + // Classed by name, so a renamed PSM error fails here instead of being + // retried as an unknown one. + #[tokio::test] + #[ignore = "reads Paseo Next"] + async fn a_psm_error_is_classed_as_getcash_classes_it() { + let chains = chains().await; + let metadata = chains.asset_hub.metadata_ref(); + let module_error = |pallet: &str, error: &str| { + let pallet = metadata.pallet_by_name(pallet).expect("pallet"); + let variant = pallet + .error_variants() + .and_then(|variants| variants.iter().find(|variant| variant.name == error)) + .expect("variant") + .index; + let execution = Value::unnamed_variant( + "Err", + [Value::named_variant( + "Module", + [ + ("index", Value::u128(pallet.error_index().into())), + ("error", Value::unnamed_composite([variant, 0, 0, 0].map(|byte| Value::u128(byte.into())))), + ], + )], + ); + psm_refusal(&module_error_names(metadata, &execution)) + }; + + assert_eq!( + [ + module_error("Psm", "MintingStopped"), + module_error("Psm", "ExceedsMaxPsmDebt"), + module_error("Psm", "FeeTooHigh"), + module_error("Psm", "BelowMinimumSwap"), + module_error("Psm", "AllSwapsStopped"), + module_error("Psm", "AmountTooSmallAfterConversion"), + module_error("PolkadotXcm", "LocalExecutionIncompleteWithError"), + ], + [ + Some(PsmRefusal::Unavailable), + Some(PsmRefusal::Unavailable), + Some(PsmRefusal::WillNotServe), + Some(PsmRefusal::WillNotServe), + Some(PsmRefusal::Unavailable), + Some(PsmRefusal::WillNotServe), + None, + ] + ); + } + /// An account holding at least `least` of asset `id` on Asset Hub. async fn holder(chains: &Chains, id: u32, least: u128) -> [u8; 32] { holder_between(chains, id, least, u128::MAX).await diff --git a/rust/crates/truapi/src/runtime/funding/credit.rs b/rust/crates/truapi/src/runtime/funding/credit.rs index 350d61c406..242d67d713 100644 --- a/rust/crates/truapi/src/runtime/funding/credit.rs +++ b/rust/crates/truapi/src/runtime/funding/credit.rs @@ -2,10 +2,11 @@ //! //! The host's top-up claims the CASH on the deposit account on People, given //! the account's secret key. Each attempt has its own id, so a retried call -//! for the same attempt is answered `AlreadyExists` and never claims twice. -//! A claim that takes nothing, or never finishes, moves on to the next -//! attempt; after the last one the session fails with the CASH still on the -//! account, where the same key can claim it later. +//! for the same attempt is answered `AlreadyExists` and never claims twice, +//! and each is sized from what the account holds when it starts. A claim +//! that falls short moves on to the next attempt; after the last, crediting +//! settles for what was claimed, or fails with the CASH still on the account +//! when nothing was, for a retry or the same key to claim later. use core::time::Duration; @@ -16,17 +17,15 @@ use truapi::latest::{ }; use super::FundingSigner; -use crate::host_logic::funding::{CreditAttempt, CreditStep, FundingDeposit}; +use crate::host_logic::funding::{CreditProgress, CreditStep, FundingDeposit}; use crate::platform::{ProductContext, TopUpPlatform}; -/// Smallest amount a top-up claims, in CASH units: the landed CASH is +/// Smallest amount a top-up claims, in CASH units: what the account holds is /// claimed rounded down to it. pub const CLAIM_UNIT: u128 = 10_000; -/// Top-up attempts before crediting gives up. -const MAX_ATTEMPTS: u8 = 3; -/// How long one attempt may run before the next replaces it. +/// How long one registered top-up may run before crediting times out. const ATTEMPT_WINDOW_MS: u64 = 90 * 60 * 1_000; -/// How long crediting may take in all before it gives up, so a claim that +/// How long crediting may take in all before it times out, so a claim that /// never finalizes or a host that keeps answering busy cannot hold a session /// open for good. const CREDIT_DEADLINE_MS: u64 = 4 * ATTEMPT_WINDOW_MS; @@ -44,82 +43,79 @@ pub struct Credit<'a> { } impl Credit<'_> { - /// Decide the next step for a session whose CASH `landed` on `deposit`'s - /// account, given the attempt running and when it started. + /// Decide the next step for a session crediting `deposit`'s CASH, given + /// its `progress` so far. `held` is the account's CASH on People, read + /// when the next top-up has yet to be sized. + /// + /// A sized top-up is kept before it is registered, so one registered + /// just before a restart is registered again for the same amount under + /// the same id rather than sized from what is left. What the host + /// reports is applied before any time limit, so a claim that finished + /// late still counts. pub async fn plan( &self, deposit: &FundingDeposit, - landed: u128, - running: Option, + progress: Option, + held: Option, now_ms: u64, ) -> Result, GenericError> { - let amount = landed - landed % CLAIM_UNIT; - if amount == 0 { - return Ok(Some(CreditStep::Abandoned { - reason: "less CASH landed than a top-up can claim".into(), - })); - } - let Some(CreditAttempt { - attempt, - since_ms, - started_ms, - }) = running - else { - return self.register(deposit, amount, 0).await; + let attempt = progress.map_or(0, |progress| progress.attempt); + let past_deadline = + progress.is_some_and(|progress| now_ms.saturating_sub(progress.started_ms) > CREDIT_DEADLINE_MS); + let claim = match progress.and_then(|progress| progress.claim) { + None if past_deadline => return Ok(Some(CreditStep::TimedOut)), + None => { + let Some(held) = held else { + return Ok(None); + }; + let amount = held - held % CLAIM_UNIT; + return Ok(Some(if amount == 0 { + CreditStep::Drained + } else { + CreditStep::Sized { amount } + })); + } + Some(claim) if !claim.registered && past_deadline => return Ok(Some(CreditStep::TimedOut)), + Some(claim) if !claim.registered => return self.register(deposit, attempt, claim.amount).await, + Some(claim) => claim, }; - if now_ms.saturating_sub(started_ms) > CREDIT_DEADLINE_MS { - return Ok(Some(CreditStep::Abandoned { - reason: "crediting did not finish in time".into(), - })); - } let status = self.status(deposit, attempt).await; - let overdue = now_ms.saturating_sub(since_ms) > ATTEMPT_WINDOW_MS; - match status { - Some(Ok(HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true })) => { - Ok(Some(CreditStep::Credited { credited: amount })) - } + let terminal = match status { + Some(Ok(HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true })) => Some(CreditStep::Claimed { + claimed: claim.amount, + }), Some(Ok(HostPaymentTopUpStatusSubscribeItem::ClaimedPartially { actual_claimed })) => { - Ok(Some(CreditStep::Credited { - credited: actual_claimed, - })) - } - Some(Ok(HostPaymentTopUpStatusSubscribeItem::NotClaimed)) => { - self.next_attempt(deposit, amount, attempt).await + Some(CreditStep::Short { + claimed: actual_claimed, + }) } - Some(Ok( - HostPaymentTopUpStatusSubscribeItem::Detecting - | HostPaymentTopUpStatusSubscribeItem::Claiming, - )) if overdue => self.next_attempt(deposit, amount, attempt).await, + Some(Ok(HostPaymentTopUpStatusSubscribeItem::NotClaimed)) => Some(CreditStep::Short { claimed: 0 }), + _ => None, + }; + if terminal.is_some() { + return Ok(terminal); + } + // As getcash, an attempt the host has not finished in its window + // times out whatever it last reported. + if past_deadline || now_ms.saturating_sub(claim.since_ms) > ATTEMPT_WINDOW_MS { + return Ok(Some(CreditStep::TimedOut)); + } + match status { + // Registered again under the same id; recording that changes + // nothing, so the attempt's window keeps running. Some(Err(HostPaymentTopUpStatusSubscribeError::NotFound)) => { - self.register(deposit, amount, attempt).await - } - Some(Ok(_)) | Some(Err(HostPaymentTopUpStatusSubscribeError::Unknown { .. })) | None => { - Ok(None) + self.register(deposit, attempt, claim.amount).await } + _ => Ok(None), } } - async fn next_attempt( - &self, - deposit: &FundingDeposit, - amount: u128, - attempt: u8, - ) -> Result, GenericError> { - let next = attempt + 1; - if next >= MAX_ATTEMPTS { - return Ok(Some(CreditStep::Abandoned { - reason: "no top-up claimed the CASH".into(), - })); - } - self.register(deposit, amount, next).await - } - /// Ask the host to claim `amount` from the deposit account as `attempt`. async fn register( &self, deposit: &FundingDeposit, - amount: u128, attempt: u8, + amount: u128, ) -> Result, GenericError> { let keypair = self .signer @@ -140,9 +136,9 @@ impl Credit<'_> { }; match self.top_up.top_up(self.product, request).await { Ok(()) | Err(HostPaymentTopUpError::AlreadyExists) => { - Ok(Some(CreditStep::Registered { attempt })) + Ok(Some(CreditStep::Registered)) } - Err(HostPaymentTopUpError::InvalidSource) => Ok(Some(CreditStep::Abandoned { + Err(HostPaymentTopUpError::InvalidSource) => Ok(Some(CreditStep::Refused { reason: "the host refused the deposit account as a top-up source".into(), })), Err(HostPaymentTopUpError::SourceBusy | HostPaymentTopUpError::Unknown { .. }) => { @@ -186,7 +182,7 @@ mod tests { use futures::stream::{self, BoxStream}; use super::*; - use crate::host_logic::funding::{ConversionRoute, DepositAsset}; + use crate::host_logic::funding::{Claim, ConversionRoute, DepositAsset}; use crate::platform::async_trait; const NOW: u64 = 1_700_000_000_000; @@ -281,7 +277,8 @@ mod tests { fn plan( host: &Host, key: u8, - running: Option, + progress: Option, + held: Option, now_ms: u64, ) -> Option { let product = ProductContext { @@ -294,105 +291,143 @@ mod tests { signer: &keys, product: &product, }; - block_on(credit.plan(&deposit(), 1_987_654, running, now_ms)).expect("planned") + block_on(credit.plan(&deposit(), progress, held, now_ms)).expect("planned") + } + + fn claim(attempt: u8, amount: u128, registered: bool) -> Option { + Some(CreditProgress { + credited: 0, + attempt, + claim: Some(Claim { + amount, + since_ms: NOW, + registered, + }), + started_ms: NOW, + }) } - // The first top-up is the one getcash would make: the landed CASH rounded - // down to the claim unit, identified by the account, so a top-up the host - // already holds is not made twice. + // The first top-up is the one getcash would make: what the account holds + // rounded down to the claim unit, kept before it is registered, then + // registered under the account's id, so a top-up the host already holds + // is not made twice. #[test] - fn landed_cash_is_claimed_once_under_the_accounts_id() { + fn landed_cash_is_sized_then_claimed_once_under_the_accounts_id() { let fresh = Host::new(Ok(()), None); let known = Host::new(Err(HostPaymentTopUpError::AlreadyExists), None); let account = deposit().account; + let sized = claim(0, 1_980_000, false); assert_eq!( ( - plan(&fresh, 1, None, NOW), + plan(&fresh, 1, None, Some(1_987_654), NOW), + plan(&fresh, 1, sized, Some(5), NOW), fresh.requests(), - plan(&known, 1, None, NOW), + plan(&known, 1, sized, None, NOW), ), ( - Some(CreditStep::Registered { attempt: 0 }), + Some(CreditStep::Sized { amount: 1_980_000 }), + Some(CreditStep::Registered), vec![(1_980_000, account)], - Some(CreditStep::Registered { attempt: 0 }), + Some(CreditStep::Registered), ) ); } - // Only a finalized claim credits; one that is claimed but unfinalized is - // waited for rather than retried, since a fresh attempt would find - // nothing to claim, until crediting as a whole runs out of time. + // What the host reports decides the step, even past a time limit, so a + // claim that finished late still counts: a final claim of everything, a + // short one whose remainder the next attempt claims. An attempt the host + // has not finished in its window times out rather than starting another + // top-up that could claim alongside it. #[test] - fn only_a_finalized_claim_credits_the_balance() { + fn each_top_up_status_is_settled_as_getcash_settles_it() { + let status = |item| Host::new(Ok(()), Some(Ok(item))); + let finalized = status(HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true }); + let unfinalized = status(HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: false }); + let detecting = status(HostPaymentTopUpStatusSubscribeItem::Detecting); let overdue = NOW + ATTEMPT_WINDOW_MS + 1; - let unfinalized = Host::new( - Ok(()), - Some(Ok(HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: false })), - ); - let finalized = Host::new( - Ok(()), - Some(Ok(HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true })), - ); - let partial = Host::new( - Ok(()), - Some(Ok(HostPaymentTopUpStatusSubscribeItem::ClaimedPartially { - actual_claimed: 1_000_000, - })), - ); + let running = claim(0, 1_980_000, true); assert_eq!( [ - plan(&unfinalized, 1, Some(CreditAttempt { attempt: 0, since_ms: NOW, started_ms: NOW }), overdue), + plan(&finalized, 1, running, None, NOW + CREDIT_DEADLINE_MS + 1), plan( - &unfinalized, + &status(HostPaymentTopUpStatusSubscribeItem::ClaimedPartially { + actual_claimed: 1_000_000, + }), 1, - Some(CreditAttempt { attempt: 0, since_ms: NOW, started_ms: NOW }), - NOW + CREDIT_DEADLINE_MS + 1, + running, + None, + NOW, ), - plan(&finalized, 1, Some(CreditAttempt { attempt: 0, since_ms: NOW, started_ms: NOW }), NOW), - plan(&partial, 1, Some(CreditAttempt { attempt: 0, since_ms: NOW, started_ms: NOW }), NOW), + plan(&status(HostPaymentTopUpStatusSubscribeItem::NotClaimed), 1, running, None, NOW), + plan(&detecting, 1, running, None, NOW), + plan(&detecting, 1, running, None, overdue), + plan(&unfinalized, 1, running, None, NOW), + plan(&unfinalized, 1, running, None, overdue), ], [ + Some(CreditStep::Claimed { claimed: 1_980_000 }), + Some(CreditStep::Short { claimed: 1_000_000 }), + Some(CreditStep::Short { claimed: 0 }), None, - Some(CreditStep::Abandoned { - reason: "crediting did not finish in time".into(), - }), - Some(CreditStep::Credited { - credited: 1_980_000 - }), - Some(CreditStep::Credited { - credited: 1_000_000 - }), + Some(CreditStep::TimedOut), + None, + Some(CreditStep::TimedOut), ] ); } - // A claim that took nothing, or is stuck, gets a fresh attempt under a - // new id, up to the last, after which the CASH stays on the account. + // A top-up the host lost is registered again with the same amount under + // the same id, never re-sized from what is left after it may have + // claimed part. + #[test] + fn a_lost_top_up_is_registered_again_as_it_was() { + let lost = Host::new(Ok(()), Some(Err(HostPaymentTopUpStatusSubscribeError::NotFound))); + + assert_eq!( + (plan(&lost, 1, claim(0, 1_980_000, true), Some(10_000), NOW), lost.requests()), + (Some(CreditStep::Registered), vec![(1_980_000, deposit().account)]) + ); + } + + // A later attempt claims what is left on the account, read again, under + // its own id; with less left than a top-up claims there is nothing more + // to try, and without a reading nothing is sized. #[test] - fn an_unclaimed_top_up_is_retried_under_a_new_id_until_the_last() { - let not_claimed = Host::new(Ok(()), Some(Ok(HostPaymentTopUpStatusSubscribeItem::NotClaimed))); - let stuck = Host::new(Ok(()), Some(Ok(HostPaymentTopUpStatusSubscribeItem::Detecting))); + fn a_later_attempt_claims_what_is_left_under_its_own_id() { + let host = Host::new(Ok(()), None); let account = deposit().account; let second_id = sp_crypto_hashing::blake2_256(&[account.as_slice(), &1u32.to_le_bytes()].concat()); + let next = Some(CreditProgress { + credited: 1_000_000, + attempt: 1, + claim: None, + started_ms: NOW, + }); + let sized = next.map(|progress| CreditProgress { + claim: Some(Claim { + amount: 980_000, + since_ms: NOW, + registered: false, + }), + ..progress + }); assert_eq!( ( - plan(¬_claimed, 1, Some(CreditAttempt { attempt: 0, since_ms: NOW, started_ms: NOW }), NOW), - not_claimed.requests(), - plan(&stuck, 1, Some(CreditAttempt { attempt: 1, since_ms: NOW, started_ms: NOW }), NOW), - plan(&stuck, 1, Some(CreditAttempt { attempt: 1, since_ms: NOW, started_ms: NOW }), NOW + ATTEMPT_WINDOW_MS + 1), - plan(¬_claimed, 1, Some(CreditAttempt { attempt: 2, since_ms: NOW, started_ms: NOW }), NOW), + plan(&host, 1, next, Some(987_654), NOW), + plan(&host, 1, next, Some(CLAIM_UNIT - 1), NOW), + plan(&host, 1, next, None, NOW), + plan(&host, 1, sized, None, NOW), + host.requests(), ), ( - Some(CreditStep::Registered { attempt: 1 }), - vec![(1_980_000, second_id)], + Some(CreditStep::Sized { amount: 980_000 }), + Some(CreditStep::Drained), None, - Some(CreditStep::Registered { attempt: 2 }), - Some(CreditStep::Abandoned { - reason: "no top-up claimed the CASH".into(), - }), + Some(CreditStep::Registered), + vec![(980_000, second_id)], ) ); } @@ -403,6 +438,9 @@ mod tests { fn only_the_deposit_accounts_key_is_handed_to_the_host() { let host = Host::new(Ok(()), None); - assert_eq!((plan(&host, 2, None, NOW), host.requests()), (None, Vec::new())); + assert_eq!( + (plan(&host, 2, claim(0, 1_980_000, false), None, NOW), host.requests()), + (None, Vec::new()) + ); } } From 20e6576292fdfb4e61759bcf056189fd653d86b1 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Tue, 6 Oct 2026 19:46:07 +0200 Subject: [PATCH 20/20] refactor(truapi): move the funding pipeline out of core The provider's worker runs the session, including the swap and teleport to CASH. Core keeps sessions, the native bridge and top-ups. --- .changeset/funding-check-cli.md | 5 - .changeset/funding-check-stand-in-top-up.md | 5 - .changeset/funding-conversion.md | 5 - .changeset/funding-credit.md | 5 - .changeset/funding-deposit-account.md | 5 - .changeset/funding-deposit-sizing.md | 5 - .changeset/funding-deposit-watch.md | 5 - .changeset/funding-getcash-derivation.md | 5 - .changeset/funding-native-bridge.md | 2 +- .changeset/funding-partial-claims.md | 5 - .changeset/funding-pool-route.md | 5 - .changeset/funding-recovery.md | 5 - Cargo.lock | 1 - .../tests/golden/host-callbacks.ts | 13 +- .../truapi-codegen/tests/golden/wire_table.rs | 2 +- rust/crates/truapi-host-cli/Cargo.toml | 1 - .../truapi-host-cli/src/funding_check.rs | 417 --- rust/crates/truapi-host-cli/src/main.rs | 75 - rust/crates/truapi/RUNTIME.md | 45 +- rust/crates/truapi/src/host_core.rs | 145 +- rust/crates/truapi/src/host_logic/funding.rs | 1615 +----------- .../truapi/src/host_logic/product_account.rs | 10 - rust/crates/truapi/src/lib.rs | 5 +- rust/crates/truapi/src/native/callbacks.rs | 3 +- rust/crates/truapi/src/native/runtime.rs | 64 - rust/crates/truapi/src/platform.rs | 15 +- rust/crates/truapi/src/platform/mock.rs | 1 - rust/crates/truapi/src/runtime.rs | 23 +- .../src/runtime/capabilities/payment.rs | 11 +- .../src/runtime/capabilities/resources.rs | 5 - rust/crates/truapi/src/runtime/funding.rs | 1544 +---------- .../truapi/src/runtime/funding/conversion.rs | 2344 ----------------- .../truapi/src/runtime/funding/credit.rs | 446 ---- .../crates/truapi/src/runtime/pairing_host.rs | 7 - .../crates/truapi/src/runtime/signing_host.rs | 206 +- .../truapi/src/runtime/statement_allowance.rs | 1 - .../runtime/statement_allowance/extension.rs | 1 + rust/crates/truapi/src/runtime/tests.rs | 91 - rust/crates/truapi/src/v01/funding.rs | 3 - 39 files changed, 43 insertions(+), 7108 deletions(-) delete mode 100644 .changeset/funding-check-cli.md delete mode 100644 .changeset/funding-check-stand-in-top-up.md delete mode 100644 .changeset/funding-conversion.md delete mode 100644 .changeset/funding-credit.md delete mode 100644 .changeset/funding-deposit-account.md delete mode 100644 .changeset/funding-deposit-sizing.md delete mode 100644 .changeset/funding-deposit-watch.md delete mode 100644 .changeset/funding-getcash-derivation.md delete mode 100644 .changeset/funding-partial-claims.md delete mode 100644 .changeset/funding-pool-route.md delete mode 100644 .changeset/funding-recovery.md delete mode 100644 rust/crates/truapi-host-cli/src/funding_check.rs delete mode 100644 rust/crates/truapi/src/runtime/funding/conversion.rs delete mode 100644 rust/crates/truapi/src/runtime/funding/credit.rs diff --git a/.changeset/funding-check-cli.md b/.changeset/funding-check-cli.md deleted file mode 100644 index 43c7810f60..0000000000 --- a/.changeset/funding-check-cli.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@parity/truapi": patch ---- - -`truapi-host funding-check` runs a real on-ramp from the terminal: it opens a funding session, prints the deposit address, and follows the session until the CASH lands on People. Signing hosts can read one funding session with `funding_session` (Rust API). diff --git a/.changeset/funding-check-stand-in-top-up.md b/.changeset/funding-check-stand-in-top-up.md deleted file mode 100644 index bdc906bd1f..0000000000 --- a/.changeset/funding-check-stand-in-top-up.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@parity/truapi": patch ---- - -`truapi-host funding-check` follows a session to `Delivered`: a stand-in top-up checks each claim (the key controls the deposit account, the amount is within the CASH that landed) and reports it finalized without moving coins, since the CLI has no coinage engine. diff --git a/.changeset/funding-conversion.md b/.changeset/funding-conversion.md deleted file mode 100644 index ee3c1be854..0000000000 --- a/.changeset/funding-conversion.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@parity/truapi": patch ---- - -Signing hosts convert funding deposits into CASH on People once `enable_funding_conversion` gives the network's CASH asset id (Rust API). A CASH deposit is teleported; a stablecoin the PSM serves is minted into CASH first. Fees are paid in the deposited asset, and both chains dry-run the conversion before it is submitted. diff --git a/.changeset/funding-credit.md b/.changeset/funding-credit.md deleted file mode 100644 index bb29c44a92..0000000000 --- a/.changeset/funding-credit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@parity/truapi": patch ---- - -Funding sessions end `Delivered`: once the converted CASH lands on People, the core credits it through the host's top-up, with the deposit account's key as the source, and retries under a new id when a claim takes nothing. Products under `fund.` can neither start nor follow top-ups. diff --git a/.changeset/funding-deposit-account.md b/.changeset/funding-deposit-account.md deleted file mode 100644 index a80a46bf88..0000000000 --- a/.changeset/funding-deposit-account.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@parity/truapi": patch ---- - -Signing hosts derive funding accounts under the reserved `fund.` product, labelled as getcash labels them, with a persisted counter per source (Rust API: `funding_account`, `next_funding_account_number`). No product can use accounts under `fund.`. diff --git a/.changeset/funding-deposit-sizing.md b/.changeset/funding-deposit-sizing.md deleted file mode 100644 index 31ed186653..0000000000 --- a/.changeset/funding-deposit-sizing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@parity/truapi": patch ---- - -Signing hosts quote the deposit a funding session needs with `quote_funding_deposit`: enough to credit the session's amount after the fee on People, the PSM fee, transaction fees and the minimum balance (Rust API). Assigning a deposit account refuses a smaller expected deposit, and an unset PSM minting fee reads as the pallet's default. `funding-check` takes the CASH amount to credit. diff --git a/.changeset/funding-deposit-watch.md b/.changeset/funding-deposit-watch.md deleted file mode 100644 index 3bc606608c..0000000000 --- a/.changeset/funding-deposit-watch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@parity/truapi": minor ---- - -Funding status adds `Converting`. Signing hosts assign an inbound session its deposit account with `assign_funding_deposit` (Rust API), skipping accounts that already hold funds; the core watches it at finalized Asset Hub blocks and reports `Converting` once the expected balance arrives. diff --git a/.changeset/funding-getcash-derivation.md b/.changeset/funding-getcash-derivation.md deleted file mode 100644 index 4dfdcd6d0a..0000000000 --- a/.changeset/funding-getcash-derivation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@parity/truapi": patch ---- - -Funding accounts are derived with getcash's scheme under the reserved `fund.` product: that product's entropy for the account's getcash label, taken as a mini secret. The accounts are the funding product's, not getcash's own, so burners getcash already made under its product id are not among them. No product under `fund.` can derive entropy. diff --git a/.changeset/funding-native-bridge.md b/.changeset/funding-native-bridge.md index 728fdea293..256f17fc9a 100644 --- a/.changeset/funding-native-bridge.md +++ b/.changeset/funding-native-bridge.md @@ -2,4 +2,4 @@ "@parity/truapi": patch --- -Native hosts drive funding through `NativeTrUApiHostRuntime`: `set_funding_callbacks` installs the overlay, `set_top_up_callbacks` and `notify_top_up_status` the top-up engine, and `enable_funding_conversion`, `open_funding`, `quote_funding_deposit`, `assign_funding_deposit` and `funding_session` run the on-ramp. Amounts cross the FFI as decimal strings. +Native hosts drive funding through `NativeTrUApiHostRuntime`: `set_funding_callbacks` installs the overlay, `set_top_up_callbacks` and `notify_top_up_status` the top-up engine, and `open_funding` and `funding_session` open and read sessions. Amounts cross the FFI as decimal strings. diff --git a/.changeset/funding-partial-claims.md b/.changeset/funding-partial-claims.md deleted file mode 100644 index d9158ace7b..0000000000 --- a/.changeset/funding-partial-claims.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@parity/truapi": patch ---- - -Funding credits and conversions settle as getcash settles them: each top-up is sized from what the deposit account holds on People, short claims add up across three attempts and deliver what was claimed, and the PSM's refusals hold the deposit at once or on the third. `retry_funding` resumes a held conversion or an unclaimed credit (Rust and native API). diff --git a/.changeset/funding-pool-route.md b/.changeset/funding-pool-route.md deleted file mode 100644 index 397edf8d62..0000000000 --- a/.changeset/funding-pool-route.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@parity/truapi": patch ---- - -Funding deposits in the native token, and in a stablecoin the PSM cannot serve, convert through the asset-conversion pools: one XCM program swaps them into CASH (through the native token for a stablecoin) and teleports it to People, as getcash does. `funding-check` accepts `--asset dot`. diff --git a/.changeset/funding-recovery.md b/.changeset/funding-recovery.md deleted file mode 100644 index 3d10714cab..0000000000 --- a/.changeset/funding-recovery.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@parity/truapi": patch ---- - -Funding deposits handle getcash's edge cases: every deposit asset is read on each deposit account, so a short or wrong-asset deposit shows as a mismatch; `accept_funding_deposit` converts what arrived instead, and reopens a session that expired or had its conversion refused for 72 hours after; a conversion that lands less than expected credits what landed; and `funding_account_secret` exports an account's seed for a wallet. `enable_funding_conversion` takes the deposit asset ids (Rust and native API). diff --git a/Cargo.lock b/Cargo.lock index 8fcbf68d8f..fa1aa05eb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5625,7 +5625,6 @@ dependencies = [ "rqrr", "rustix", "rustls", - "schnorrkel", "serde", "serde_json", "sha2 0.10.9", diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 3acabf25b6..ddc7d2f238 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -212,12 +212,7 @@ export type CoreStorageKey = * Funding sessions: every live one plus a bounded tail of settled ones, * as one SCALE blob. */ - | { tag: "FundingSessions"; value?: undefined } - /** - * Last funding account number handed out per source, as one SCALE blob. - * Never reset, so no account is reused. - */ - | { tag: "FundingAccountCounters"; value?: undefined }; + | { tag: "FundingSessions"; value?: undefined }; /** * Review shown before a product creates a ring-VRF proof (RFC 0004). @@ -798,7 +793,6 @@ export const CoreStorageKey: S.Codec = S.lazy( productId: string; }>, FundingSessions: S._void, - FundingAccountCounters: S._void, }), ); @@ -1465,9 +1459,8 @@ export interface FundingPlatform { ): Promise; /** - * Observe a session's status change, or a change in what its deposit - * account holds, for host UI such as the in-flight pill or a mismatch - * prompt. + * Observe a session's status change, for host UI such as the in-flight + * pill. */ fundingSessionChanged?( intent: string, diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 8cb0b6206e..e9b2118702 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 = "4477d07e737019aa"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: MethodIds = MethodIds { diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml index e925a58e38..607bbc2516 100644 --- a/rust/crates/truapi-host-cli/Cargo.toml +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -38,7 +38,6 @@ ratatui = { workspace = true, features = ["crossterm", "unstable-rendered-line-i reqwest = { workspace = true, features = ["json", "rustls-tls"] } rqrr = { workspace = true } rustls = { workspace = true, features = ["ring"] } -schnorrkel = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/rust/crates/truapi-host-cli/src/funding_check.rs b/rust/crates/truapi-host-cli/src/funding_check.rs deleted file mode 100644 index 2d461bafc6..0000000000 --- a/rust/crates/truapi-host-cli/src/funding_check.rs +++ /dev/null @@ -1,417 +0,0 @@ -//! A real on-ramp against a live network, run from the terminal. -//! -//! The command opens a funding session on a signing host, prints the deposit -//! address its provider would pay, and follows the session while someone pays -//! that address from any funded account. It ends once the session is -//! credited, or fails. The CLI has no coinage engine, so a stand-in top-up -//! checks the claim and reports it without moving coins. -//! -//! Sessions and account counters live under the state directory, so a second -//! run with `--intent` picks up the same session, and no run reuses an -//! account. - -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::{Arc, Mutex, OnceLock, Weak}; -use std::time::Duration; - -use anyhow::{Context, Result, bail}; -use clap::ValueEnum; -use futures::stream::{self, BoxStream, StreamExt}; -use truapi::host_logic::funding::{DepositAsset, DepositRequest, FundingAccountKind, FundingStage}; -use truapi::latest::{ - FundingDirection, GenericError, HostFundingStatusSubscribeItem, HostPaymentTopUpError, - HostPaymentTopUpRequest, HostPaymentTopUpStatusSubscribeError, - HostPaymentTopUpStatusSubscribeItem, PaymentTopUpSource, -}; -use truapi::platform::{ - FundingPlatform, FundingPresentOutcome, FundingPresentation, ProductContext, TopUpPlatform, - async_trait, -}; -use truapi::{FundingNetwork, SigningHostRuntime}; - -use crate::network::{Network, NetworkConfig}; - -/// How often the command reports the session's stage. -const POLL: Duration = Duration::from_secs(6); -/// Polls a session may be missing for before the command gives up on it. -const MISSING_POLLS: u32 = 5; - -/// The asset a provider pays the deposit in. -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum FundingAsset { - /// The native token, swapped into CASH through the pools. - Dot, - /// dotUSD, which is CASH already: teleported to People. - Cash, - /// Minted into CASH through the PSM, then teleported. - Usdt, - /// Minted into CASH through the PSM, then teleported. - Usdc, -} - -/// Asset ids a network's Asset Hub uses for the funding assets. -struct FundingAssets { - cash: u32, - usdt: u32, - usdc: u32, -} - -impl FundingAssets { - /// The ids on `network`, where they are known. - fn of(network: Network) -> Option { - match network { - Network::PaseoNextV2 => Some(Self { - cash: 50_000_413, - usdt: 1984, - usdc: 1337, - }), - Network::Previewnet => None, - } - } - - /// The deposit asset and the getcash source id for `asset`. - fn source(&self, asset: FundingAsset) -> (DepositAsset, &'static str) { - match asset { - FundingAsset::Dot => (DepositAsset::Native, "dot-assethub"), - FundingAsset::Cash => (DepositAsset::Asset(self.cash), "dotusd-assethub"), - FundingAsset::Usdt => (DepositAsset::Asset(self.usdt), "usdt-assethub"), - FundingAsset::Usdc => (DepositAsset::Asset(self.usdc), "usdc-assethub"), - } - } -} - -/// A funding overlay that starts every session at once and prints what the -/// core reports. -struct TerminalFundingHost; - -#[async_trait] -impl FundingPlatform for TerminalFundingHost { - async fn present_funding( - &self, - _product: Option<&ProductContext>, - _session: FundingPresentation, - ) -> Result { - Ok(FundingPresentOutcome::Started) - } - - fn funding_session_changed(&self, intent: String, status: HostFundingStatusSubscribeItem) { - println!("{intent}: {status:?}"); - } -} - -/// A top-up that stands in for a host's coinage engine, which the CLI does -/// not have. -/// -/// It checks what a real claim would rest on: the source key controls the -/// session's deposit account, and the amount is the claim core sized from -/// the account's CASH on People. Then it reports the claim finalized without moving anything, so -/// a run reaches `Delivered` with every core step real except the claim. -struct StandInTopUp { - runtime: OnceLock>, - intent: OnceLock, - claimed: Mutex>, -} - -impl StandInTopUp { - fn new() -> Self { - Self { - runtime: OnceLock::new(), - intent: OnceLock::new(), - claimed: Mutex::new(HashMap::new()), - } - } - - /// Why the claim in `request` would fail, if it would. - fn refusal(&self, request: &HostPaymentTopUpRequest) -> Option<&'static str> { - let PaymentTopUpSource::PrivateKey { sr25519_secret_key } = &request.source else { - return Some("the source is not a private key"); - }; - let Ok(secret) = schnorrkel::SecretKey::from_bytes(sr25519_secret_key) else { - return Some("the source key is not a schnorrkel secret"); - }; - let session = self - .runtime - .get() - .and_then(Weak::upgrade) - .zip(self.intent.get()) - .and_then(|(runtime, intent)| runtime.funding_session(intent)); - let Some(session) = session else { - return Some("no session to claim for"); - }; - if session.deposit.map(|deposit| deposit.account) != Some(secret.to_public().to_bytes()) { - return Some("the source key does not control the deposit account"); - } - let sized = match session.stage { - FundingStage::Crediting { progress } => progress.claim.map(|claim| claim.amount), - _ => None, - }; - (sized != Some(request.amount)).then_some("the amount is not the claim core sized") - } -} - -#[async_trait] -impl TopUpPlatform for StandInTopUp { - async fn top_up( - &self, - _product: &ProductContext, - request: HostPaymentTopUpRequest, - ) -> Result<(), HostPaymentTopUpError> { - if let Some(reason) = self.refusal(&request) { - println!("stand-in top-up refused: {reason}"); - return Err(HostPaymentTopUpError::InvalidSource); - } - let mut claimed = self.claimed.lock().expect("claims mutex poisoned"); - if claimed.contains_key(&request.id) { - return Err(HostPaymentTopUpError::AlreadyExists); - } - println!( - "stand-in top-up: would claim {} CASH units into the balance (no coins moved)", - request.amount - ); - claimed.insert(request.id, request.amount); - Ok(()) - } - - fn subscribe_top_up_status( - &self, - _product: &ProductContext, - id: [u8; 32], - ) -> BoxStream< - 'static, - Result, - > { - let known = self - .claimed - .lock() - .expect("claims mutex poisoned") - .contains_key(&id); - let status = match known { - true => Ok(HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true }), - false => Err(HostPaymentTopUpStatusSubscribeError::NotFound), - }; - stream::iter([status]).boxed() - } -} - -/// What to run. -pub struct FundingCheck { - /// Mnemonic of the identity whose funding accounts are used. - pub mnemonic: String, - /// Network preset. - pub network: Network, - /// Asset the deposit is paid in. - pub asset: FundingAsset, - /// CASH to credit, in its smallest units. - pub amount: u128, - /// Where sessions and account counters persist between runs. - pub state_dir: PathBuf, - /// A session to follow instead of opening a new one. - pub intent: Option, - /// Convert what arrived of this asset instead of what was asked. - pub accept: Option, - /// Try a failed session again from where its funds are. - pub retry: bool, - /// Print the session's account seeds for a wallet, and stop. - pub export_key: bool, -} - -/// Run `check` until its session lands CASH on People or fails. -pub async fn run( - check: FundingCheck, - build_runtime: impl FnOnce(NetworkConfig, PathBuf) -> Result>, -) -> Result<()> { - let assets = FundingAssets::of(check.network) - .context("no funding asset ids are known for this network")?; - let runtime = build_runtime(check.network.config(), check.state_dir)?; - let entropy = bip39::Mnemonic::parse(check.mnemonic.trim()) - .context("invalid mnemonic")? - .to_entropy(); - runtime - .activate_local_session(entropy) - .await - .map_err(|error| anyhow::anyhow!("activating the signer failed: {}", error.reason))?; - runtime.set_funding_platform(Arc::new(TerminalFundingHost)); - let top_up = Arc::new(StandInTopUp::new()); - let _ = top_up.runtime.set(Arc::downgrade(&runtime)); - runtime.set_top_up_platform(top_up.clone()); - runtime.enable_funding_conversion( - FundingNetwork { - cash_asset_id: assets.cash, - }, - vec![assets.cash, assets.usdt, assets.usdc], - ); - - let intent = match check.intent { - Some(intent) => intent, - None => open_and_assign(&runtime, &assets, check.asset, check.amount).await?, - }; - let _ = top_up.intent.set(intent.clone()); - if check.export_key { - return export_keys(&runtime, &intent); - } - if let Some(asset) = check.accept { - let (deposit_asset, _) = assets.source(asset); - runtime - .accept_funding_deposit(&intent, deposit_asset) - .await - .map_err(|error| anyhow::anyhow!("accepting the deposit failed: {}", error.reason))?; - println!("accepted what arrived of {deposit_asset:?}"); - } - if check.retry { - runtime - .retry_funding(&intent) - .await - .map_err(|error| anyhow::anyhow!("retrying the session failed: {}", error.reason))?; - println!("retrying the session"); - } - follow(&runtime, &intent).await -} - -/// Open a session and give it a deposit account, printing where to pay. -async fn open_and_assign( - runtime: &SigningHostRuntime, - assets: &FundingAssets, - asset: FundingAsset, - amount: u128, -) -> Result { - let (deposit_asset, source_id) = assets.source(asset); - let intent = runtime - .open_funding(FundingDirection::In, Some(amount)) - .await - .map_err(|error| anyhow::anyhow!("opening a session failed: {}", error.reason))? - .context("the session was dismissed")?; - let expected = runtime - .quote_funding_deposit(&intent, deposit_asset) - .await - .map_err(|error| anyhow::anyhow!("quoting the deposit failed: {}", error.reason))?; - let account = runtime - .assign_funding_deposit( - &intent, - DepositRequest { - source_id: source_id.to_string(), - asset: deposit_asset, - expected, - }, - ) - .await - .map_err(|error| anyhow::anyhow!("assigning a deposit account failed: {}", error.reason))?; - println!("session {intent}, crediting {amount} CASH units"); - println!("pay {expected} of {deposit_asset:?} ({source_id}) on Asset Hub to"); - println!( - " {}", - truapi::host_logic::product_account::product_public_key_to_address(account) - ); - println!(" 0x{}", hex::encode(account)); - println!("resume --intent {intent}"); - Ok(intent) -} - -/// Print the raw seeds of the session's deposit and refund accounts, in the -/// form a wallet imports and getcash exports: `0x` and the mini secret's hex. -fn export_keys(runtime: &SigningHostRuntime, intent: &str) -> Result<()> { - for kind in [FundingAccountKind::Deposit, FundingAccountKind::Refund] { - let secret = runtime - .funding_account_secret(intent, kind) - .map_err(|error| anyhow::anyhow!("exporting the key failed: {}", error.reason))? - .context("no signing session is active")?; - println!("{kind:?} seed 0x{}", hex::encode(secret)); - } - Ok(()) -} - -/// Print the session's stage whenever it changes, until it settles. -async fn follow(runtime: &SigningHostRuntime, intent: &str) -> Result<()> { - let mut last = None; - let mut last_mismatch = None; - let mut missing_polls = 0; - loop { - // Persisted sessions load in the background after the funding host - // is installed, so a resumed one can take a moment to appear. - let Some(session) = runtime.funding_session(intent) else { - missing_polls += 1; - if missing_polls > MISSING_POLLS { - bail!("no funding session {intent}"); - } - tokio::time::sleep(POLL).await; - continue; - }; - if last.as_ref() != Some(&session.stage) { - println!("stage {:?}", session.stage); - last = Some(session.stage.clone()); - } - let mismatch = session.deposit_mismatch(); - if mismatch != last_mismatch { - if let Some(mismatch) = mismatch { - println!( - "mismatch {mismatch:?}; accept with --accept or take it back with --export-key" - ); - } - last_mismatch = mismatch; - } - match session.stage { - FundingStage::Failed { - reason, - resume: Some(_), - .. - } => bail!( - "the session failed: {reason:?}; its funds are still held, try again with --retry" - ), - FundingStage::Failed { reason, .. } => bail!("the session failed: {reason:?}"), - FundingStage::Delivered { credited, .. } => { - println!("credited {credited} CASH units (stand-in top-up: no coins moved)"); - return Ok(()); - } - FundingStage::Open - | FundingStage::Converting { .. } - | FundingStage::Converted { .. } - | FundingStage::Crediting { .. } => {} - } - tokio::time::sleep(POLL).await; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn request(source: PaymentTopUpSource) -> HostPaymentTopUpRequest { - HostPaymentTopUpRequest { - into: None, - amount: 1_000, - source, - id: [1; 32], - } - } - - // The stand-in must refuse what a real coinage engine would, or a run - // that reaches `Delivered` proves nothing about the claim core asked for. - #[test] - fn the_stand_in_refuses_claims_a_real_engine_would() { - let top_up = StandInTopUp::new(); - let secret = schnorrkel::MiniSecretKey::from_bytes(&[7; 32]) - .expect("seed") - .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519) - .secret - .to_bytes(); - - assert_eq!( - [ - top_up.refusal(&request(PaymentTopUpSource::Coins { - sr25519_secret_keys: vec![secret], - })), - top_up.refusal(&request(PaymentTopUpSource::PrivateKey { - sr25519_secret_key: [0xff; 64], - })), - top_up.refusal(&request(PaymentTopUpSource::PrivateKey { - sr25519_secret_key: secret, - })), - ], - [ - Some("the source is not a private key"), - Some("the source key is not a schnorrkel secret"), - Some("no session to claim for"), - ] - ); - } -} diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index aff3c2f31a..5cdd8cc813 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -21,7 +21,6 @@ mod chat; mod contacts; mod dotns_read; mod frame_server; -mod funding_check; mod network; mod platform; mod pocket; @@ -305,44 +304,6 @@ enum Command { #[arg(long)] submit: bool, }, - /// Run a real on-ramp: open a funding session, print the deposit address, - /// and follow the session while you pay that address from any funded - /// account, until the CASH lands on People. - FundingCheck { - /// BIP-39 mnemonic of the identity whose funding accounts are used. - #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC")] - mnemonic: String, - /// Network preset to use. - #[arg(long, value_enum, default_value = "paseo-next-v2")] - network: Network, - /// Asset the deposit is paid in. - #[arg(long, value_enum, default_value = "usdt")] - asset: funding_check::FundingAsset, - /// CASH to credit, in its smallest units; core quotes the deposit - /// that covers it. - #[arg(long, default_value_t = 2_000_000)] - amount: u128, - /// Where sessions and account counters persist between runs. Keep it: - /// a fresh directory restarts the account numbers. - #[arg(long, default_value = ".funding-check")] - state_dir: PathBuf, - /// Follow an existing session instead of opening a new one. - #[arg(long)] - intent: Option, - /// With `--intent`: convert what arrived of this asset instead of - /// what was asked, for a short or wrong-asset deposit, or one that - /// came after the session ended. - #[arg(long, value_enum, requires = "intent")] - accept: Option, - /// With `--intent`: try a failed session again from where its funds - /// are, a held conversion or an unclaimed credit. - #[arg(long, requires = "intent")] - retry: bool, - /// With `--intent`: print the session's account seeds for a wallet to - /// take the funds back by hand, and stop. - #[arg(long, requires = "intent")] - export_key: bool, - }, /// Install the current stable release over this one. /// /// Only works for a binary the installer put in place; a `cargo install` @@ -684,42 +645,6 @@ async fn dispatch( lookback, submit, } => run_pgas_check(mnemonic, network.config(), target, lookback, submit).await, - Command::FundingCheck { - mnemonic, - network, - asset, - amount, - state_dir, - intent, - accept, - retry, - export_key, - } => { - let check = funding_check::FundingCheck { - mnemonic, - network, - asset, - amount, - state_dir, - intent, - accept, - retry, - export_key, - }; - funding_check::run(check, |config, state_dir| { - build_signing_runtime( - config, - state_dir.join("core"), - state_dir.join("products"), - ApprovalPolicy::AutoAccept, - None, - None, - None, - ) - .map(|(runtime, _platform)| runtime) - }) - .await - } } } diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index a1466f1d47..ae55aea705 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -403,49 +403,10 @@ AutoSigning without approval. Legacy-account signing still asks the user. Balance card opens sessions with `open_funding`. The core owns the sessions, persists them, expires them, and answers Funding calls `Unsupported` while no overlay is installed. - Once a provider is chosen, a signing host calls `assign_funding_deposit` to - give an inbound session its deposit account under `fund.`; - the core then polls that account at finalized Asset Hub blocks and moves the - session to `Converting` once the expected balance is there. `quote_funding_deposit` gives the - deposit that credits the session's amount, and assignment refuses less. - Assignment needs - `enable_funding_conversion` with the network's CASH asset id, and fixes the - route then: a teleport for CASH, a PSM mint for a stablecoin the PSM serves. - The core converts with one Asset Hub transaction signed by the deposit - account, paying fees in the deposited asset, after dry-running it on Asset - Hub and the message it forwards on People. Once the CASH lands on People, - the core credits it through `TopUpPlatform` with the deposit account's key - as a `PrivateKey` source, and the session ends `Delivered`. Crediting - follows getcash: each of up to three top-ups is sized from the account's - CASH on People when it starts and has its own id. A short claim leaves - the rest to the next one, and after the last the session is delivered - with what was claimed. A conversion the PSM will not serve as quoted (fee - too high, amount out of range) is held at once; one it cannot serve now - (minting stopped, debt ceiling) is held on the third refusal, as is any - other refusal, a conversion included on chain that failed among them. A - top-up is kept once sized and before it is registered, so a restart - registers the same amount under the same id. A held conversion, a credit - that claimed nothing, or one that timed out fails with its funds still on - 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. - Native hosts reach all of this through `NativeTrUApiHostRuntime`: + Native hosts reach this through `NativeTrUApiHostRuntime`: `set_funding_callbacks` (the overlay), `set_top_up_callbacks` with - `notify_top_up_status` (the top-up engine), `enable_funding_conversion`, - `open_funding`, `quote_funding_deposit`, `assign_funding_deposit`, - `retry_funding` and `funding_session`. Amounts cross the FFI as decimal strings. - Each watched deposit account is read for every deposit asset the host - names when it enables conversion, and the native token. A deposit counts - as delivered once it reaches the deposit quoted for its asset, or what the - provider was asked for without a quote. Below that, a short or wrong-asset - deposit shows as a mismatch, and `accept_funding_deposit` converts what - arrived instead. An ended session's account is read every five minutes - for 72 hours: a full deposit that arrives after expiry converts on its - own, and a mismatch can still be accepted. A product stream that already - ended on `Failed` does not hear of a reopened session; the product sees - 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. + `notify_top_up_status` (the top-up engine), `open_funding` and + `funding_session`. Amounts cross the FFI as decimal strings. - `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/host_core.rs b/rust/crates/truapi/src/host_core.rs index 50327dbbe6..78119f770e 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -613,68 +613,6 @@ impl SigningHostRuntime { self.signing_host.set_grant_allowances_unchecked(granted); } - /// Public key of the `number`th funding account of `kind` for - /// `source_id`, or `None` while no signing session is active. - pub fn funding_account( - &self, - kind: crate::host_logic::funding::FundingAccountKind, - source_id: &str, - number: u32, - ) -> Result, v01::GenericError> { - self.signing_host - .derive_funding_account(kind, source_id, number) - .map_err(|err| v01::GenericError { - reason: err.to_string(), - }) - } - - /// The deposit of `asset` a provider must deliver to credit the amount - /// session `intent` names, fees and minimum balances included. - pub async fn quote_funding_deposit( - &self, - intent: &str, - asset: crate::host_logic::funding::DepositAsset, - ) -> Result { - self.services - .quote_funding_deposit(intent, asset) - .await - .map_err(|err| v01::GenericError { - reason: err.to_string(), - }) - } - - /// Give the open inbound session `intent` its deposit account for the - /// request's source, and watch it until the expected balance arrives on - /// Asset Hub, which moves the session to converting. Returns the account - /// the provider pays into. - pub async fn assign_funding_deposit( - &self, - intent: &str, - request: crate::host_logic::funding::DepositRequest, - ) -> Result<[u8; 32], v01::GenericError> { - let source_id = request.source_id.clone(); - let derive = |number| { - self.signing_host - .derive_funding_account( - crate::host_logic::funding::FundingAccountKind::Deposit, - &source_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_deposit(intent, request, 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. /// @@ -810,8 +748,8 @@ impl SigningHostRuntime { installed } - /// One funding session as the core holds it, stage and deposit included, - /// for the host's own status and history views. + /// One funding session as the core holds it, for the host's own status + /// and history views. pub fn funding_session( &self, intent: &str, @@ -819,85 +757,6 @@ impl SigningHostRuntime { self.services.funding().get(intent) } - /// Convert funding deposits into CASH on People on `network`, signing - /// with the deposit accounts this host derives. Without it, assigning a - /// deposit account fails. Set-once; returns whether this call enabled it. - /// - /// Every watched deposit account is read for the `deposit_asset_ids` - /// (`Assets` pallet ids) and the native token, so a wrong or short - /// deposit is seen. - #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.enable_funding_conversion"))] - pub fn enable_funding_conversion( - &self, - network: crate::runtime::FundingNetwork, - deposit_asset_ids: Vec, - ) -> bool { - let enabled = self.services.funding().install_conversion( - network, - deposit_asset_ids, - self.signing_host.clone(), - ); - if enabled { - self.services.watch_funding_deposits(); - } - enabled - } - - /// Convert what arrived of `asset` on session `intent`'s deposit account - /// instead of what was asked: the short deposit or other asset its - /// mismatch names, also after the session expired or its conversion was - /// refused. - #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.accept_funding_deposit"))] - pub async fn accept_funding_deposit( - &self, - intent: &str, - asset: crate::host_logic::funding::DepositAsset, - ) -> Result<(), v01::GenericError> { - self.services - .accept_funding_deposit(intent, asset) - .await - .map_err(|err| v01::GenericError { - reason: err.to_string(), - }) - } - - /// Try failed session `intent` again from where its funds are, as - /// getcash's "try again": a refused or held conversion converts again by - /// its route, an unclaimed or timed-out credit goes on claiming. - #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.retry_funding"))] - pub async fn retry_funding(&self, intent: &str) -> Result<(), v01::GenericError> { - self.services - .retry_funding(intent) - .await - .map_err(|err| v01::GenericError { - reason: err.to_string(), - }) - } - - /// Raw seed of session `intent`'s `kind` account, for a wallet to import - /// and move its funds by hand. `None` while no signing session is active, - /// or while it is another account's than the one the session recorded. - #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.funding_account_secret"))] - pub fn funding_account_secret( - &self, - intent: &str, - kind: crate::host_logic::funding::FundingAccountKind, - ) -> Result, v01::GenericError> { - let deposit = self - .services - .funding() - .get(intent) - .and_then(|session| session.deposit) - .ok_or_else(|| v01::GenericError { - reason: "the session has no deposit account".into(), - })?; - self.signing_host - .funding_account_secret(kind, &deposit.source_id, deposit.number, &deposit.account) - .map_err(|err| v01::GenericError { - reason: err.to_string(), - }) - } - /// Open a funding session on the host's own behalf, as the Balance card's /// Add and Withdraw do, and show the overlay. Returns the session id, or /// `None` when the user dismissed the overlay without starting. diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index 2695484da7..7366f2b5f2 100644 --- a/rust/crates/truapi/src/host_logic/funding.rs +++ b/rust/crates/truapi/src/host_logic/funding.rs @@ -7,14 +7,10 @@ //! `Funding::status_subscribe`. A session always terminates, because the core //! expires it on its own clock. -use std::collections::BTreeMap; - use parity_scale_codec::{Decode, Encode}; use tracing::warn; use truapi::latest::{FundingDirection, FundingFailure, HostFundingStatusSubscribeItem}; -use crate::host_logic::entropy::{ProductEntropyError, derive_product_entropy}; -use crate::host_logic::product_account::{ProductAccountError, derive_root_keypair_from_entropy}; use crate::platform::{CoreStorage, CoreStorageKey}; /// How long a session may stay open before it expires. @@ -45,243 +41,6 @@ pub struct FundingSession { pub opened_at_ms: u64, /// When the session expires if still open, in Unix milliseconds. pub deadline_ms: u64, - /// Where an inbound session's provider delivers, once the source is - /// known. - pub deposit: Option, - /// The deposit quoted to credit `amount`, frozen so the provider is held - /// to the figure it was given rather than one re-priced later. - pub quote: Option, -} - -/// The route for a deposit and what it must deliver to credit a session's -/// amount. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr( - all(feature = "runtime", not(target_arch = "wasm32")), - derive(uniffi::Record) -)] -pub struct DepositQuote { - /// Asset the deposit is quoted in. - pub asset: DepositAsset, - /// How the deposit becomes CASH. - pub route: ConversionRoute, - /// Least deposit, in the asset's units. - pub deposit: u128, -} - -/// Asset Hub asset a deposit arrives in. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr( - all(feature = "runtime", not(target_arch = "wasm32")), - derive(uniffi::Enum) -)] -pub enum DepositAsset { - /// The relay chain's native token. - Native, - /// An `Assets` pallet asset. - Asset(u32), -} - -/// What an inbound session's provider delivers, once it is chosen. -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr( - all(feature = "runtime", not(target_arch = "wasm32")), - derive(uniffi::Record) -)] -pub struct DepositRequest { - /// Deposit source, as in the account label, such as `usdt-assethub`. - pub source_id: String, - /// Asset the provider delivers. - pub asset: DepositAsset, - /// Balance at which the deposit counts as delivered, in `asset` units. - pub expected: u128, -} - -/// The account an inbound session watches and what it waits for. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr( - all(feature = "runtime", not(target_arch = "wasm32")), - derive(uniffi::Record) -)] -pub struct FundingDeposit { - /// Deposit source, as in the account label. - pub source_id: String, - /// Account number for `source_id`; the refund account shares it. - pub number: u32, - /// Asset the provider delivers. - pub asset: DepositAsset, - /// Public key of the deposit account, kept so the watch needs no signing - /// session. - pub account: [u8; 32], - /// Balance at which the deposit counts as delivered, in `asset` units. - pub expected: u128, - /// How the deposit becomes CASH on People. - pub route: ConversionRoute, - /// CASH the session asks to credit, which a swap must not land below. - pub target: Option, - /// What the deposit account held at the last reading, each asset with a - /// balance, the native token last. - pub holdings: Vec, -} - -/// One asset on a deposit account and its balance. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr( - all(feature = "runtime", not(target_arch = "wasm32")), - derive(uniffi::Record) -)] -pub struct DepositHolding { - /// The asset. - pub asset: DepositAsset, - /// Its balance, in the asset's units. - pub balance: u128, -} - -/// What arrived on a deposit account that does not match what was asked for. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr( - all(feature = "runtime", not(target_arch = "wasm32")), - derive(uniffi::Enum) -)] -pub enum DepositMismatch { - /// Less of the requested asset than the deposit was quoted at. - Short { - /// The requested asset. - asset: DepositAsset, - /// What arrived of it. - amount: u128, - }, - /// Another asset arrived while the requested one falls short. - WrongAsset { - /// The asset that arrived. - asset: DepositAsset, - /// How much. - amount: u128, - }, -} - -impl FundingDeposit { - /// The balance of `asset` at the last reading. - pub fn held(&self, asset: DepositAsset) -> u128 { - self.holdings - .iter() - .find(|holding| holding.asset == asset) - .map_or(0, |holding| holding.balance) - } - - /// What arrived that does not match the request, as getcash judges it: - /// nothing once the requested asset reaches `gate`; otherwise another - /// asset that arrived, the native token only if nothing else did, since a - /// little of it sent to pay fees must not stand in for the stablecoin; - /// otherwise less of the requested asset than asked. - fn mismatch_against(&self, gate: u128) -> Option { - let held = self.held(self.asset); - if held >= gate { - return None; - } - let stray = self - .holdings - .iter() - .find(|holding| holding.asset != self.asset && holding.balance > 0); - match stray { - Some(holding) => Some(DepositMismatch::WrongAsset { - asset: holding.asset, - amount: holding.balance, - }), - None => (held > 0).then_some(DepositMismatch::Short { - asset: self.asset, - amount: held, - }), - } - } -} - -/// How a deposit becomes CASH on People, fixed when its account is assigned -/// so a later change on chain cannot switch it mid-session. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr( - all(feature = "runtime", not(target_arch = "wasm32")), - derive(uniffi::Enum) -)] -pub enum ConversionRoute { - /// The deposit is CASH already: teleport it. - Teleport, - /// The deposit is a stablecoin the PSM mints CASH against: mint, then - /// teleport. - Psm { - /// Minting fee the route was chosen at, in parts per million. - fee_ppm: u32, - }, - /// The deposit is the native token, or a stablecoin the PSM cannot - /// serve: swap it to CASH through the asset-conversion pools, then - /// teleport. - Pool, -} - -/// A conversion transaction handed to Asset Hub, with what tells whether it -/// worked. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr( - all(feature = "runtime", not(target_arch = "wasm32")), - derive(uniffi::Record) -)] -pub struct ConversionSubmission { - /// The deposit account's nonce the transaction was signed at. - pub nonce: u32, - /// When it was submitted, in Unix milliseconds. - pub submitted_at_ms: u64, - /// Last Asset Hub block its mortal era admits it in. - pub valid_until_block: u64, - /// CASH the account held on People before it was submitted. - pub people_before: u128, - /// Least CASH the conversion lands on People. - pub landing: u128, - /// Deposit the transaction takes from the account on Asset Hub. - pub spent: u128, -} - -/// Refusals before a conversion gives up, as getcash holds a mint the PSM -/// keeps refusing. -const MAX_CONVERSION_REFUSALS: u8 = 3; -/// Top-up attempts before crediting settles for what they claimed. -const MAX_CLAIM_ATTEMPTS: u8 = 3; -/// How long a session that ended with its deposit recoverable keeps being -/// read, as getcash watches a payment: funds that arrive late, or stay after -/// a refused conversion, can still be converted. -pub const LATE_WATCH_MS: u64 = 72 * 60 * 60 * 1_000; -/// Code a session fails with when its conversion was refused. -const CONVERSION_REFUSED: &str = "conversion_refused"; -/// Code a session fails with when the PSM would not mint its deposit. -const CONVERSION_HELD: &str = "conversion_held"; -/// Code a session fails with when its top-ups claimed nothing. -const CREDIT_UNCLAIMED: &str = "credit_unclaimed"; - -/// Why a failed session cannot be retried. -#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] -pub enum RetryRefusal { - /// It did not fail, or its funds are not where a retry can reach them. - #[display("the session has nothing to retry")] - NotResumable, - /// Nothing of the deposit's asset is on the deposit account. - #[display("the deposit account holds none of the deposit")] - NothingHeld, -} - -/// Why a deposit could not be accepted as it arrived. -#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] -pub enum AcceptRefusal { - /// The session is converting or done, or ended for good. - #[display("the session is not awaiting or holding a deposit")] - NotAcceptable, - /// Nothing that differs from the request is on the deposit account. - #[display("nothing other than the requested deposit is on the account")] - NothingArrived, - /// The asset is not the one that arrived in place of the request. - #[display("that asset is not what arrived in place of the request")] - NotMismatched, - /// The deposit account changed while the route was being chosen. - #[display("the deposit account changed; read it again")] - Changed, } /// Stage of a session, as the core persists it. @@ -293,99 +52,15 @@ pub enum AcceptRefusal { pub enum FundingStage { /// In flight. Open, - /// Inbound: the deposit arrived and is being converted. - Converting { - /// Balance of the deposit account when it was seen, in its asset's - /// units. - deposited: u128, - /// Dry runs the chain has refused so far. - refusals: u8, - /// The conversion transaction, once one is on its way. - submission: Option, - }, - /// Inbound: CASH landed on the deposit account on People and awaits - /// crediting. - Converted { - /// CASH on People, in payment balance units. - landed: u128, - }, - /// Inbound: the host's top-up is claiming the landed CASH into the - /// user's balance. - Crediting { - /// What the top-ups have claimed so far. - progress: CreditProgress, - }, - /// Inbound terminal success: the CASH is in the user's balance. - Delivered { - /// Amount credited, in payment balance units. - credited: u128, - /// When it was credited, in Unix milliseconds. - settled_at_ms: u64, - }, /// Ended without success. Failed { /// Why it ended. reason: FundingFailure, /// When it ended, in Unix milliseconds. settled_at_ms: u64, - /// Where a retry picks up, when the funds are still on the session's - /// accounts. - resume: Option, - }, -} - -/// Where a retry of a failed session picks up, as getcash re-arms a held or -/// unclaimed request. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr( - all(feature = "runtime", not(target_arch = "wasm32")), - derive(uniffi::Enum) -)] -pub enum FundingResume { - /// The deposit is still on Asset Hub: convert it again by its route. - Conversion, - /// The CASH is on People: credit it from `progress`. - Credit { - /// What the top-ups claimed, and the attempt to go on with. - progress: CreditProgress, }, } -/// Top-ups claiming a session's CASH, as getcash tracks its claim. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr( - all(feature = "runtime", not(target_arch = "wasm32")), - derive(uniffi::Record) -)] -pub struct CreditProgress { - /// CASH the earlier attempts claimed, in payment balance units. - pub credited: u128, - /// Which attempt is running or next, from 0. - pub attempt: u8, - /// The attempt's claim, `None` until it is sized from what the account - /// holds. - pub claim: Option, - /// When crediting started, or was last retried, in Unix milliseconds. - pub started_ms: u64, -} - -/// One top-up, kept from when it is sized so that registering it again -/// after a restart asks for the same amount under the same id. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -#[cfg_attr( - all(feature = "runtime", not(target_arch = "wasm32")), - derive(uniffi::Record) -)] -pub struct Claim { - /// CASH it claims, in payment balance units. - pub amount: u128, - /// When it was sized, then when the host accepted it, in Unix - /// milliseconds. - pub since_ms: u64, - /// Whether the host accepted it. - pub registered: bool, -} - impl FundingSession { /// Open a session that expires one window from `now_ms`. pub fn new( @@ -403,8 +78,6 @@ impl FundingSession { stage: FundingStage::Open, opened_at_ms: now_ms, deadline_ms: now_ms.saturating_add(SESSION_WINDOW_MS), - deposit: None, - quote: None, } } @@ -416,11 +89,7 @@ impl FundingSession { /// When the session ended, if it has. pub fn settled_at_ms(&self) -> Option { match self.stage { - FundingStage::Open - | FundingStage::Converting { .. } - | FundingStage::Converted { .. } - | FundingStage::Crediting { .. } => None, - FundingStage::Delivered { settled_at_ms, .. } => Some(settled_at_ms), + FundingStage::Open => None, FundingStage::Failed { settled_at_ms, .. } => Some(settled_at_ms), } } @@ -436,17 +105,6 @@ impl FundingSession { (FundingStage::Open, FundingDirection::Out) => { HostFundingStatusSubscribeItem::AwaitingRelease } - ( - FundingStage::Converting { .. } - | FundingStage::Converted { .. } - | FundingStage::Crediting { .. }, - _, - ) => HostFundingStatusSubscribeItem::Converting, - (FundingStage::Delivered { credited, .. }, _) => { - HostFundingStatusSubscribeItem::Delivered { - credited: *credited, - } - } (FundingStage::Failed { reason, .. }, _) => HostFundingStatusSubscribeItem::Failed { reason: reason.clone(), moved: 0, @@ -457,593 +115,21 @@ impl FundingSession { /// End the session with `reason`, unless it already ended. Returns whether /// it changed. pub fn fail(&mut self, reason: FundingFailure, now_ms: u64) -> bool { - self.fail_resumable(reason, None, now_ms) - } - - /// [`Self::fail`], leaving where a retry picks up. - fn fail_resumable( - &mut self, - reason: FundingFailure, - resume: Option, - now_ms: u64, - ) -> bool { if self.is_terminal() { return false; } self.stage = FundingStage::Failed { reason, settled_at_ms: now_ms, - resume, }; true } - /// 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. - pub fn expires_by_sweep(&self) -> bool { - self.stage == FundingStage::Open && self.deposit.is_none() - } - - /// Expire the session if the sweep owns it and its deadline passed. - /// Returns whether it expired. + /// Expire the session if it is still open at its deadline. Returns whether + /// it expired. pub fn expire_if_due(&mut self, now_ms: u64) -> bool { - self.expires_by_sweep() - && now_ms >= self.deadline_ms - && self.fail(FundingFailure::Expired, now_ms) - } - - /// The route for a provider delivering `expected` of `asset`, judged - /// against the quote frozen for that asset: the quoted route when it - /// covers the quote, the deposit needed when it falls short, `None` - /// without a quote for the asset. - pub fn quoted_route( - &self, - asset: DepositAsset, - expected: u128, - ) -> Option> { - let quote = self.quote.filter(|quote| quote.asset == asset)?; - Some(if expected >= quote.deposit { - Ok(quote.route) - } else { - Err(quote.deposit) - }) - } - - /// The deposit account a session still reads: an open session's, or one - /// that ended with the deposit recoverable, for [`LATE_WATCH_MS`] after. - pub fn watched_deposit(&self, now_ms: u64) -> Option<&FundingDeposit> { - self.deposit - .as_ref() - .filter(|_| self.stage == FundingStage::Open || self.recoverable_since(now_ms)) - } - - /// Whether the session ended in a way its deposit can come back from, - /// within the late watch window: it expired, or its conversion was - /// refused or held while the funds stayed on the account. - fn recoverable_since(&self, now_ms: u64) -> bool { - match &self.stage { - FundingStage::Failed { - reason, - settled_at_ms, - resume, - } => { - let recoverable = *reason == FundingFailure::Expired - || *resume == Some(FundingResume::Conversion); - recoverable && now_ms.saturating_sub(*settled_at_ms) <= LATE_WATCH_MS - } - _ => false, - } - } - - /// Pick a failed session up where its funds are, as getcash's "try - /// again" does: a refused or held conversion is tried again by its route - /// with its refusals cleared, an unclaimed or timed-out credit goes on - /// from its progress. The route and quote stay as they were. - pub fn retry(&mut self, now_ms: u64) -> Result<(), RetryRefusal> { - let FundingStage::Failed { - resume: Some(resume), - .. - } = self.stage - else { - return Err(RetryRefusal::NotResumable); - }; - self.stage = match resume { - FundingResume::Conversion => { - let deposited = self - .deposit - .as_ref() - .map_or(0, |deposit| deposit.held(deposit.asset)); - if deposited == 0 { - return Err(RetryRefusal::NothingHeld); - } - FundingStage::Converting { - deposited, - refusals: 0, - submission: None, - } - } - FundingResume::Credit { progress } => FundingStage::Crediting { - progress: CreditProgress { - claim: progress.claim.map(|claim| Claim { - since_ms: now_ms, - ..claim - }), - started_ms: now_ms, - ..progress - }, - }, - }; - Ok(()) - } - - /// Balance of the deposit's asset at which it counts as delivered, as - /// getcash gates a payment: the deposit quoted for that asset, else what - /// the provider was asked for. - pub fn deposit_gate(&self) -> Option { - let deposit = self.deposit.as_ref()?; - let quoted = self.quote.filter(|quote| quote.asset == deposit.asset); - Some(quoted.map_or(deposit.expected, |quote| quote.deposit)) + now_ms >= self.deadline_ms && self.fail(FundingFailure::Expired, now_ms) } - - /// What arrived on the deposit account that does not match the request. - pub fn deposit_mismatch(&self) -> Option { - self.deposit.as_ref()?.mismatch_against(self.deposit_gate()?) - } - - /// Record a finalized reading of what the deposit account holds, taken at - /// `now_ms`. An open session converts once its asset reaches the gate and - /// expires if it has not by the deadline; one that expired converts too - /// when its deposit arrives within the late watch window. Returns whether - /// the host should hear of it: a stage change, or new holdings on a - /// session still in flight. - pub fn observe_holdings(&mut self, holdings: Vec, now_ms: u64) -> bool { - let Some(deposit) = self.deposit.as_mut() else { - return false; - }; - let recorded = deposit.holdings != holdings; - deposit.holdings = holdings; - let held = deposit.held(deposit.asset); - let Some(gate) = self.deposit_gate() else { - return false; - }; - let arrived_late = matches!( - self.stage, - FundingStage::Failed { - reason: FundingFailure::Expired, - .. - } - ) && self.recoverable_since(now_ms) - && held >= gate; - if arrived_late { - self.stage = FundingStage::Open; - } - if self.awaited_deposit().is_none() { - return recorded && !self.is_terminal(); - } - if held >= gate { - self.stage = FundingStage::Converting { - deposited: held, - refusals: 0, - submission: None, - }; - return true; - } - let expired = now_ms >= self.deadline_ms && self.fail(FundingFailure::Expired, now_ms); - expired || recorded - } - - /// Convert what arrived of the mismatched `asset` by `route` instead of - /// what was asked: getcash's "continue with what arrived". `arrived` is - /// the balance the route was chosen for, refused if the account has - /// changed since. An open session waits for it again; one that ended - /// recoverably reopens for a fresh window. - pub fn accept_arrival( - &mut self, - asset: DepositAsset, - arrived: u128, - route: ConversionRoute, - now_ms: u64, - ) -> Result<(), AcceptRefusal> { - let reopens = self.recoverable_since(now_ms); - if self.stage != FundingStage::Open && !reopens { - return Err(AcceptRefusal::NotAcceptable); - } - let mismatched = match self.deposit_mismatch() { - Some(DepositMismatch::Short { asset, amount } | DepositMismatch::WrongAsset { asset, amount }) => { - Some((asset, amount)) - } - None => None, - }; - match mismatched { - None => return Err(AcceptRefusal::NothingArrived), - Some((mismatched, _)) if mismatched != asset => return Err(AcceptRefusal::NotMismatched), - Some((_, amount)) if amount != arrived => return Err(AcceptRefusal::Changed), - Some(_) => {} - } - let deposit = self.deposit.as_mut().ok_or(AcceptRefusal::NotAcceptable)?; - deposit.asset = asset; - deposit.expected = arrived; - deposit.route = route; - deposit.target = None; - // The quoted terms are for what was asked, not for what arrived. - self.quote = None; - if reopens { - self.stage = FundingStage::Open; - self.deadline_ms = now_ms.saturating_add(SESSION_WINDOW_MS); - } - Ok(()) - } - - /// The deposit an open inbound session is waiting on, if one is assigned. - pub fn awaited_deposit(&self) -> Option<&FundingDeposit> { - (self.stage == FundingStage::Open) - .then_some(self.deposit.as_ref()) - .flatten() - } - - /// The deposit of a session being converted, with its submission so far. - pub fn converting(&self) -> Option<(&FundingDeposit, Option)> { - match (&self.stage, &self.deposit) { - (FundingStage::Converting { submission, .. }, Some(deposit)) => { - Some((deposit, *submission)) - } - _ => None, - } - } - - /// Advance a converting session by one step of its conversion. Returns - /// whether the session changed. - pub fn advance_conversion(&mut self, step: ConversionStep, now_ms: u64) -> bool { - let FundingStage::Converting { - refusals, - submission, - .. - } = &mut self.stage - else { - return false; - }; - match step { - ConversionStep::Submitted(submitted) => *submission = Some(submitted), - ConversionStep::Dropped => *submission = None, - ConversionStep::Refused { reason, psm } => { - *submission = None; - if psm != Some(PsmRefusal::WillNotServe) { - *refusals = refusals.saturating_add(1); - if *refusals < MAX_CONVERSION_REFUSALS { - return true; - } - } - let (code, message) = match psm { - Some(PsmRefusal::WillNotServe) => ( - CONVERSION_HELD, - format!("the PSM will not mint this deposit as quoted: {reason}"), - ), - Some(PsmRefusal::Unavailable) => ( - CONVERSION_HELD, - format!("the PSM refused the mint {MAX_CONVERSION_REFUSALS} times, last: {reason}"), - ), - None => (CONVERSION_REFUSED, reason), - }; - return self.fail_resumable( - FundingFailure::Other { - code: code.into(), - message, - }, - Some(FundingResume::Conversion), - now_ms, - ); - } - ConversionStep::Landed { landed } => { - self.stage = FundingStage::Converted { landed }; - } - ConversionStep::Stalled => { - return self.fail( - FundingFailure::Other { - code: "conversion_stalled".into(), - message: "the conversion left Asset Hub but never reached People".into(), - }, - now_ms, - ); - } - } - true - } -} - -impl FundingSession { - /// The deposit of a session awaiting or being credited, with what its - /// top-ups have claimed so far, `None` before the first. - pub fn crediting(&self) -> Option<(&FundingDeposit, Option)> { - let deposit = self.deposit.as_ref()?; - match self.stage { - FundingStage::Converted { .. } => Some((deposit, None)), - FundingStage::Crediting { progress } => Some((deposit, Some(progress))), - _ => None, - } - } - - /// Advance a session being credited by one step, as getcash settles its - /// claim. Returns whether it changed. - pub fn advance_credit(&mut self, step: CreditStep, now_ms: u64) -> bool { - let progress = match self.stage { - FundingStage::Converted { .. } => CreditProgress { - credited: 0, - attempt: 0, - claim: None, - started_ms: now_ms, - }, - FundingStage::Crediting { progress } => progress, - _ => return false, - }; - let credited_with = |claimed: u128| progress.credited.saturating_add(claimed); - match step { - CreditStep::Sized { amount } => { - self.stage = FundingStage::Crediting { - progress: CreditProgress { - claim: Some(Claim { - amount, - since_ms: now_ms, - registered: false, - }), - ..progress - }, - }; - true - } - CreditStep::Registered => { - let Some(claim) = progress.claim.filter(|claim| !claim.registered) else { - return false; - }; - self.stage = FundingStage::Crediting { - progress: CreditProgress { - claim: Some(Claim { - since_ms: now_ms, - registered: true, - ..claim - }), - ..progress - }, - }; - true - } - CreditStep::Claimed { claimed } => { - self.stage = FundingStage::Delivered { - credited: credited_with(claimed), - settled_at_ms: now_ms, - }; - true - } - CreditStep::Short { claimed } => { - let progress = CreditProgress { - credited: credited_with(claimed), - attempt: progress.attempt.saturating_add(1), - claim: None, - ..progress - }; - if progress.attempt < MAX_CLAIM_ATTEMPTS { - self.stage = FundingStage::Crediting { progress }; - return true; - } - self.settle_credit(progress, now_ms) - } - CreditStep::Drained => self.settle_credit(progress, now_ms), - // Between attempts nothing is in flight, so what earlier ones - // claimed is delivered rather than the session failing. - CreditStep::TimedOut if progress.claim.is_none() && progress.credited > 0 => { - self.settle_credit(progress, now_ms) - } - CreditStep::TimedOut => self.fail_resumable( - FundingFailure::Other { - code: "credit_timeout".into(), - message: "the top-up did not finish in time".into(), - }, - Some(FundingResume::Credit { progress }), - now_ms, - ), - CreditStep::Refused { reason } => self.fail_resumable( - FundingFailure::Other { - code: "credit_refused".into(), - message: reason, - }, - Some(FundingResume::Credit { - progress: CreditProgress { - claim: None, - ..progress - }, - }), - now_ms, - ), - } - } - - /// End crediting with what the top-ups claimed: delivered, partly if - /// they fell short, or failed with the CASH still on People, to be - /// retried from `progress`. - fn settle_credit(&mut self, progress: CreditProgress, now_ms: u64) -> bool { - if progress.credited > 0 { - self.stage = FundingStage::Delivered { - credited: progress.credited, - settled_at_ms: now_ms, - }; - return true; - } - self.fail_resumable( - FundingFailure::Other { - code: CREDIT_UNCLAIMED.into(), - message: "the host claimed no CASH from the deposit account".into(), - }, - Some(FundingResume::Credit { progress }), - now_ms, - ) - } -} - -/// What one pass of crediting found or did. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CreditStep { - /// The next top-up was sized from what the account holds, to be kept - /// before it is registered. - Sized { - /// CASH it claims, in payment balance units. - amount: u128, - }, - /// The host accepted the sized top-up. - Registered, - /// The running top-up claimed all it asked for, and it is final. - Claimed { - /// Amount it claimed, in payment balance units. - claimed: u128, - }, - /// The running top-up claimed less than it asked for, or nothing; the - /// next attempt claims what is left. - Short { - /// Amount it claimed, in payment balance units. - claimed: u128, - }, - /// Less is left on the account than a top-up can claim. - Drained, - /// The running top-up, or crediting as a whole, took too long. - TimedOut, - /// The host will not take the deposit account as a top-up source. - Refused { - /// Why. - reason: String, - }, -} - -/// How the PSM refused a mint, as getcash classes its dispatch errors. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PsmRefusal { - /// Minting is stopped or the PSM is at its debt ceiling, which may - /// pass: counted, the third holds the funds. - Unavailable, - /// The fee is above the quote's or the amount is outside what the PSM - /// takes, which retrying the same mint cannot cure: holds at once. - WillNotServe, -} - -impl PsmRefusal { - /// The class of the PSM pallet error named `error`, `None` for one that - /// is not a refusal. - pub fn of(error: &str) -> Option { - match error { - "MintingStopped" | "AllSwapsStopped" | "ExceedsMaxPsmDebt" => Some(Self::Unavailable), - "FeeTooHigh" | "BelowMinimumSwap" | "AmountTooSmallAfterConversion" => { - Some(Self::WillNotServe) - } - _ => None, - } - } -} - -/// What one pass of a conversion found or did. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ConversionStep { - /// The conversion transaction is about to be submitted. - Submitted(ConversionSubmission), - /// The submitted transaction can no longer convert anything: its era - /// ended unincluded. The next pass submits again. - Dropped, - /// A dry run or the transaction pool refused the conversion. - Refused { - /// Why, as the chain reported it. - reason: String, - /// How the PSM refused it, when it was the PSM. - psm: Option, - }, - /// CASH arrived on People. - Landed { - /// CASH on People, in payment balance units. - landed: u128, - }, - /// The transaction took the deposit on Asset Hub but its CASH never - /// reached People. - Stalled, -} - -/// Which of a session's accounts under the reserved funding product. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr( - all(feature = "runtime", not(target_arch = "wasm32")), - derive(uniffi::Enum) -)] -pub enum FundingAccountKind { - /// Where an inbound provider delivers. - Deposit, - /// Where a crypto rail returns funds it could not deliver. - Refund, - /// Where an outbound session stages funds before paying the provider. - Withdrawal, -} - -/// Why a funding account could not be derived. -#[derive(Debug, PartialEq, Eq, derive_more::Display, derive_more::Error)] -pub enum FundingAccountError { - /// The label is longer than the 32 bytes entropy derivation takes. - #[display("funding account label is longer than 32 bytes")] - LabelTooLong, - /// The entropy could not be derived. - #[display("{_0}")] - Entropy(ProductEntropyError), - /// The key could not be derived from the entropy. - #[display("{_0}")] - Key(ProductAccountError), -} - -/// The label of the `number`th account of `kind` for `source_id`: -/// `onramp:eph::`, `onramp:rf::` or -/// `wd:eph::`, the labels getcash uses. `number` counts up from 1 -/// per source, so every account can be found again from the seed alone. -pub fn funding_account_label( - kind: FundingAccountKind, - source_id: &str, - number: u32, -) -> Result { - let prefix = match kind { - FundingAccountKind::Deposit => "onramp:eph", - FundingAccountKind::Refund => "onramp:rf", - FundingAccountKind::Withdrawal => "wd:eph", - }; - let label = format!("{prefix}:{source_id}:{number}"); - (label.len() <= 32) - .then_some(label) - .ok_or(FundingAccountError::LabelTooLong) -} - -/// The keypair of the `number`th account of `kind` for `source_id`, derived -/// with getcash's scheme: the funding product's `deriveEntropy` for the -/// account's getcash label, taken as a mini secret. getcash derives under its -/// own product id, so its existing burners are other accounts. -pub fn funding_keypair( - root_entropy: &[u8], - funding_product_id: &str, - kind: FundingAccountKind, - source_id: &str, - number: u32, -) -> Result { - let label = funding_account_label(kind, source_id, number)?; - let entropy = derive_product_entropy(root_entropy, funding_product_id, label.as_bytes()) - .map_err(FundingAccountError::Entropy)?; - derive_root_keypair_from_entropy(&entropy).map_err(FundingAccountError::Key) -} - -/// The mini secret of the same account as [`funding_keypair`], the raw seed -/// a wallet imports it from: what getcash's `burnerSecretOf` hands a user -/// taking funds back by hand. -pub fn funding_mini_secret( - root_entropy: &[u8], - funding_product_id: &str, - kind: FundingAccountKind, - source_id: &str, - number: u32, -) -> Result<[u8; 32], FundingAccountError> { - let label = funding_account_label(kind, source_id, number)?; - let entropy = derive_product_entropy(root_entropy, funding_product_id, label.as_bytes()) - .map_err(FundingAccountError::Entropy)?; - substrate_bip39::mini_secret_from_entropy(&entropy, "") - .map(|mini| mini.to_bytes()) - .map_err(|err| FundingAccountError::Key(ProductAccountError::InvalidEntropy(format!("{err:?}")))) } /// Why a session operation failed. @@ -1063,11 +149,9 @@ pub enum FundingSessionError { /// The bound exists because [`CoreStorageKey::FundingSessions`] is one SCALE /// blob rewritten on every change; the host keeps the full history. pub fn retained(sessions: impl IntoIterator) -> Vec { - // A failed session whose funds a retry can still reach is kept like an - // open one, so history cannot push it out. - let (mut open, mut settled): (Vec<_>, Vec<_>) = sessions.into_iter().partition(|session| { - !session.is_terminal() || matches!(session.stage, FundingStage::Failed { resume: Some(_), .. }) - }); + let (mut open, mut settled): (Vec<_>, Vec<_>) = sessions + .into_iter() + .partition(|session| !session.is_terminal()); settled.sort_by_key(|session| core::cmp::Reverse(session.settled_at_ms())); settled.truncate(SETTLED_HISTORY_LIMIT); open.append(&mut settled); @@ -1114,41 +198,6 @@ pub async fn store_sessions( written.map_err(|err| FundingSessionError::Storage { reason: err.reason }) } -/// Reserve the next account number for `source_id`, counting up from 1. -/// -/// Counters are never reset, so no two sessions on this device share an -/// account. A blob that does not decode is an error rather than a reset for -/// the same reason. Counters are per device: the same seed on a new install -/// starts from 1 again, so whoever hands an account to a provider must first -/// check it is empty on chain. -pub async fn next_account_number( - storage: &(impl CoreStorage + ?Sized), - source_id: &str, -) -> Result { - let storage_error = |reason: String| FundingSessionError::Storage { reason }; - let mut counters = match storage - .read_core_storage(CoreStorageKey::FundingAccountCounters) - .await - .map_err(|err| storage_error(err.reason))? - { - Some(blob) => BTreeMap::::decode(&mut blob.as_slice()) - .ok() - .filter(|counters| counters.encoded_size() == blob.len()) - .ok_or_else(|| storage_error("funding account counters do not decode".into()))?, - None => BTreeMap::new(), - }; - let counter = counters.entry(source_id.to_string()).or_default(); - *counter = counter - .checked_add(1) - .ok_or_else(|| storage_error(format!("funding accounts for {source_id} exhausted")))?; - let number = *counter; - storage - .write_core_storage(CoreStorageKey::FundingAccountCounters, counters.encode()) - .await - .map_err(|err| storage_error(err.reason))?; - Ok(number) -} - #[cfg(test)] mod tests { use super::*; @@ -1175,7 +224,6 @@ mod tests { stage: FundingStage::Failed { reason: FundingFailure::Expired, settled_at_ms, - resume: None, }, ..session(FundingDirection::In) } @@ -1266,655 +314,6 @@ mod tests { assert_eq!(block_on(load_sessions(storage.as_ref())), Ok(Vec::new())); } - // A reused number hands a second session an account that may still hold - // the first one's funds, so counters only ever move up, per source, and a - // counter blob that cannot be read stops funding instead of restarting. - #[test] - fn account_numbers_count_up_per_source_and_never_restart() { - let storage = stub_platform(); - let next = |source: &str| block_on(next_account_number(storage.as_ref(), source)); - let issued = [next("usdt"), next("usdt"), next("btc"), next("usdt")]; - block_on(storage.write_core_storage(CoreStorageKey::FundingAccountCounters, vec![0xff])) - .expect("written"); - - assert_eq!( - (issued, next("usdt").is_err()), - ([Ok(1), Ok(2), Ok(1), Ok(3)], true) - ); - } - - const SUBMISSION: ConversionSubmission = ConversionSubmission { - nonce: 4, - submitted_at_ms: NOW, - valid_until_block: 164, - people_before: 0, - landing: 40, - spent: 50, - }; - - fn converting() -> FundingSession { - FundingSession { - stage: FundingStage::Converting { - deposited: 50, - refusals: 0, - submission: None, - }, - deposit: Some(FundingDeposit { - source_id: "usdt-assethub".to_string(), - number: 1, - asset: DepositAsset::Asset(1984), - account: [1; 32], - expected: 50, - route: ConversionRoute::Teleport, - target: None, - holdings: Vec::new(), - }), - ..session(FundingDirection::In) - } - } - - fn refuse(session: &mut FundingSession, psm: Option) -> FundingStage { - session.advance_conversion( - ConversionStep::Refused { - reason: "no pool".into(), - psm, - }, - NOW, - ); - session.stage.clone() - } - - // A dry run that keeps refusing will not start passing, so the third - // refusal ends the session rather than retrying forever, with the - // deposit left where a retry can convert it; a refusal clears any - // submission so the next attempt starts clean. - #[test] - fn a_conversion_ends_on_its_third_refusal() { - let mut session = converting(); - session.advance_conversion(ConversionStep::Submitted(SUBMISSION), NOW); - - assert_eq!( - [refuse(&mut session, None), refuse(&mut session, None), refuse(&mut session, None)], - [ - FundingStage::Converting { - deposited: 50, - refusals: 1, - submission: None, - }, - FundingStage::Converting { - deposited: 50, - refusals: 2, - submission: None, - }, - FundingStage::Failed { - reason: FundingFailure::Other { - code: "conversion_refused".into(), - message: "no pool".into(), - }, - settled_at_ms: NOW, - resume: Some(FundingResume::Conversion), - }, - ] - ); - } - - // getcash holds the funds on the first refusal the PSM will never get - // past, such as a fee above the quote, and on the third it may get past; - // both leave the deposit for a retry, which starts the count again. - #[test] - fn the_psm_holds_the_deposit_as_getcash_holds_it() { - let held = |message: &str| FundingStage::Failed { - reason: FundingFailure::Other { - code: "conversion_held".into(), - message: message.into(), - }, - settled_at_ms: NOW, - resume: Some(FundingResume::Conversion), - }; - let mut will_not = converting(); - let mut unavailable = converting(); - let unavailable_stages = [ - refuse(&mut unavailable, Some(PsmRefusal::Unavailable)), - refuse(&mut unavailable, Some(PsmRefusal::Unavailable)), - refuse(&mut unavailable, Some(PsmRefusal::Unavailable)), - ]; - let held_deposit = FundingDeposit { - holdings: vec![DepositHolding { - asset: DepositAsset::Asset(1984), - balance: 50, - }], - ..converting().deposit.expect("deposit") - }; - unavailable.deposit = Some(held_deposit); - let retried = (unavailable.retry(NOW + 1), unavailable.stage.clone()); - - assert_eq!( - (refuse(&mut will_not, Some(PsmRefusal::WillNotServe)), unavailable_stages, retried), - ( - held("the PSM will not mint this deposit as quoted: no pool"), - [ - FundingStage::Converting { - deposited: 50, - refusals: 1, - submission: None, - }, - FundingStage::Converting { - deposited: 50, - refusals: 2, - submission: None, - }, - held("the PSM refused the mint 3 times, last: no pool"), - ], - ( - Ok(()), - FundingStage::Converting { - deposited: 50, - refusals: 0, - submission: None, - } - ), - ) - ); - } - - // The provider is told the quoted figure, so the quote it was given, - // not one re-priced when the account is assigned, decides whether its - // deposit is enough. - #[test] - fn a_deposit_is_judged_against_the_quote_for_its_asset() { - let usdt = DepositAsset::Asset(1984); - let session = FundingSession { - quote: Some(DepositQuote { - asset: usdt, - route: ConversionRoute::Psm { fee_ppm: 5_000 }, - deposit: 2_136_987, - }), - ..session(FundingDirection::In) - }; - - assert_eq!( - [ - session.quoted_route(usdt, 2_136_987), - session.quoted_route(usdt, 2_136_986), - session.quoted_route(DepositAsset::Asset(1337), 9_000_000), - ], - [Some(Ok(ConversionRoute::Psm { fee_ppm: 5_000 })), Some(Err(2_136_987)), None] - ); - } - - fn with_holdings(holdings: &[(DepositAsset, u128)]) -> FundingDeposit { - FundingDeposit { - source_id: "usdt-assethub".into(), - number: 1, - asset: DepositAsset::Asset(1984), - account: [1; 32], - expected: 50, - route: ConversionRoute::Psm { fee_ppm: 5_000 }, - target: Some(40), - holdings: holdings - .iter() - .map(|(asset, balance)| DepositHolding { - asset: *asset, - balance: *balance, - }) - .collect(), - } - } - - // getcash's rules: once the requested asset covers the deposit nothing - // is wrong; otherwise another asset that arrived comes first, the native - // token only when nothing else did, since a little of it sent to pay - // fees must not stand in for the stablecoin; then a short amount. - #[test] - fn a_mismatch_is_judged_as_getcash_judges_it() { - let usdt = DepositAsset::Asset(1984); - let usdc = DepositAsset::Asset(1337); - let native = DepositAsset::Native; - - assert_eq!( - [ - with_holdings(&[(usdt, 50), (usdc, 9)]).mismatch_against(50), - with_holdings(&[(usdt, 10), (usdc, 9), (native, 3)]).mismatch_against(50), - with_holdings(&[(usdt, 10), (native, 3)]).mismatch_against(50), - with_holdings(&[(usdt, 10)]).mismatch_against(50), - with_holdings(&[]).mismatch_against(50), - ], - [ - None, - Some(DepositMismatch::WrongAsset { - asset: usdc, - amount: 9 - }), - Some(DepositMismatch::WrongAsset { - asset: native, - amount: 3 - }), - Some(DepositMismatch::Short { - asset: usdt, - amount: 10 - }), - None, - ] - ); - } - - // Accepting converts what is there instead of what was asked; an expired - // or refused session reopens for a fresh window while its funds are - // still watched, and stays ended after. - #[test] - fn accepting_what_arrived_reroutes_and_reopens_within_the_watch_window() { - let usdc = DepositAsset::Asset(1337); - let open = FundingSession { - deposit: Some(with_holdings(&[(DepositAsset::Asset(1984), 10), (usdc, 9)])), - ..session(FundingDirection::In) - }; - let expired = |settled_at_ms| FundingSession { - stage: FundingStage::Failed { - reason: FundingFailure::Expired, - settled_at_ms, - resume: None, - }, - ..open.clone() - }; - let delivered = FundingSession { - deposit: Some(with_holdings(&[(DepositAsset::Asset(1984), 50)])), - ..session(FundingDirection::In) - }; - let accept = |mut session: FundingSession, asset, arrived, now_ms| { - let accepted = session.accept_arrival(asset, arrived, ConversionRoute::Pool, now_ms); - (accepted, session.stage.clone(), session.deposit.map(|deposit| (deposit.asset, deposit.expected, deposit.target))) - }; - let late = NOW + LATE_WATCH_MS; - - assert_eq!( - [ - accept(open.clone(), usdc, 9, NOW).0, - accept(open.clone(), DepositAsset::Asset(1984), 10, NOW).0, - accept(open.clone(), usdc, 8, NOW).0, - accept(delivered, DepositAsset::Asset(1984), 50, NOW).0, - accept(expired(NOW), usdc, 9, late).0, - accept(expired(NOW), usdc, 9, late + 1).0, - ], - [ - Ok(()), - Err(AcceptRefusal::NotMismatched), - Err(AcceptRefusal::Changed), - Err(AcceptRefusal::NothingArrived), - Ok(()), - Err(AcceptRefusal::NotAcceptable), - ] - ); - assert_eq!( - accept(expired(NOW), usdc, 9, late), - (Ok(()), FundingStage::Open, Some((usdc, 9, None))) - ); - } - - // getcash gates a payment on its quoted deposit, so a provider asked for - // more than the quote has delivered once the quote is covered; and a - // full deposit that arrives after the session expired still converts, - // within the late watch window, with no one having to accept it. - #[test] - fn a_deposit_converts_at_its_quote_and_also_when_it_arrives_late() { - let usdt = DepositAsset::Asset(1984); - let holding = |balance| vec![DepositHolding { asset: usdt, balance }]; - let quoted = FundingSession { - deposit: Some(with_holdings(&[])), - quote: Some(DepositQuote { - asset: usdt, - route: ConversionRoute::Psm { fee_ppm: 5_000 }, - deposit: 45, - }), - ..session(FundingDirection::In) - }; - let expired = FundingSession { - stage: FundingStage::Failed { - reason: FundingFailure::Expired, - settled_at_ms: NOW, - resume: None, - }, - ..quoted.clone() - }; - let observe = |mut session: FundingSession, balance, now_ms| { - let heard = session.observe_holdings(holding(balance), now_ms); - (heard, session.stage) - }; - let converting = FundingStage::Converting { - deposited: 45, - refusals: 0, - submission: None, - }; - - assert_eq!( - [ - observe(quoted.clone(), 44, NOW), - observe(quoted, 45, NOW), - observe(expired.clone(), 44, NOW + 1), - observe(expired.clone(), 45, NOW + LATE_WATCH_MS), - observe(expired.clone(), 45, NOW + LATE_WATCH_MS + 1), - ], - [ - (true, FundingStage::Open), - (true, converting.clone()), - (false, expired.stage.clone()), - (true, converting), - (false, expired.stage), - ] - ); - } - - // A user taking funds back by hand imports this seed into a wallet, so it - // must be what getcash's `burnerSecretOf` hands out: `entropyToMiniSecret` - // of the label's entropy (the vector is from that library), and the - // account it opens must be the one the core signs with. - #[test] - fn the_exported_seed_is_getcashs_and_opens_the_same_account() { - let root = [9u8; 32]; - let seed = funding_mini_secret(&root, "fund.dot", FundingAccountKind::Deposit, "usdt-assethub", 1) - .expect("seed"); - let opened = schnorrkel::MiniSecretKey::from_bytes(&seed) - .expect("mini secret") - .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519) - .public; - - assert_eq!( - ( - substrate_bip39::mini_secret_from_entropy(&[7; 32], "") - .map(|mini| hex::encode(mini.to_bytes())) - .ok(), - Some(opened), - ), - ( - Some("12c532afaa1c0ffe4d0eae23c7038d9e866e4335a00eaa3f0dbb01661295325d".to_string()), - funding_keypair(&root, "fund.dot", FundingAccountKind::Deposit, "usdt-assethub", 1) - .ok() - .map(|keypair| keypair.public), - ) - ); - } - - fn landed() -> FundingSession { - let mut session = converting(); - session.advance_conversion(ConversionStep::Landed { landed: 49 }, NOW); - session - } - - fn credit(session: &mut FundingSession, steps: impl IntoIterator) -> FundingStage { - for step in steps { - session.advance_credit(step, NOW); - } - session.stage.clone() - } - - // Delivered is the one inbound success: it ends the session for - // subscribers and history, and the credited amount is what they see. - #[test] - fn a_credited_session_is_delivered() { - let mut session = landed(); - session.advance_credit(CreditStep::Sized { amount: 40 }, NOW); - session.advance_credit(CreditStep::Registered, NOW); - let crediting = session.crediting().map(|(_, progress)| progress); - session.advance_credit(CreditStep::Claimed { claimed: 40 }, NOW + 1); - - assert_eq!( - (crediting, session.wire_item(), session.settled_at_ms()), - ( - Some(Some(CreditProgress { - credited: 0, - attempt: 0, - claim: Some(Claim { - amount: 40, - since_ms: NOW, - registered: true, - }), - started_ms: NOW, - })), - HostFundingStatusSubscribeItem::Delivered { credited: 40 }, - Some(NOW + 1), - ) - ); - } - - // getcash adds up what each claim took: a short claim leaves the rest - // for the next attempt, a final one delivers the total, and after the - // last attempt whatever was claimed is delivered, short as it is. - #[test] - fn short_claims_add_up_and_the_last_attempt_settles_for_them() { - let short = |claimed| CreditStep::Short { claimed }; - - assert_eq!( - [ - credit(&mut landed(), [CreditStep::Sized { amount: 40 }, CreditStep::Registered, short(10)]), - credit(&mut landed(), [CreditStep::Sized { amount: 40 }, CreditStep::Registered, short(10), CreditStep::Sized { amount: 30 }, CreditStep::Registered, CreditStep::Claimed { claimed: 30 }]), - credit(&mut landed(), [CreditStep::Sized { amount: 40 }, CreditStep::Registered, short(10), CreditStep::Sized { amount: 30 }, CreditStep::Registered, short(0), CreditStep::Sized { amount: 30 }, CreditStep::Registered, short(5)]), - credit(&mut landed(), [CreditStep::Sized { amount: 40 }, CreditStep::Registered, short(10), CreditStep::Drained]), - ], - [ - FundingStage::Crediting { - progress: CreditProgress { - credited: 10, - attempt: 1, - claim: None, - started_ms: NOW, - }, - }, - FundingStage::Delivered { - credited: 40, - settled_at_ms: NOW, - }, - FundingStage::Delivered { - credited: 15, - settled_at_ms: NOW, - }, - FundingStage::Delivered { - credited: 10, - settled_at_ms: NOW, - }, - ] - ); - } - - // Between attempts nothing is in flight, so running out of time there - // delivers what was claimed; with a top-up in flight it fails, to be - // watched again on a retry. - #[test] - fn running_out_of_time_between_attempts_delivers_what_was_claimed() { - let short = CreditStep::Short { claimed: 10 }; - let sized = CreditStep::Sized { amount: 30 }; - - assert_eq!( - [ - credit(&mut landed(), [CreditStep::Sized { amount: 40 }, CreditStep::Registered, short.clone(), CreditStep::TimedOut]), - credit(&mut landed(), [CreditStep::Sized { amount: 40 }, CreditStep::Registered, short, sized, CreditStep::TimedOut]), - ], - [ - FundingStage::Delivered { - credited: 10, - settled_at_ms: NOW, - }, - FundingStage::Failed { - reason: FundingFailure::Other { - code: "credit_timeout".into(), - message: "the top-up did not finish in time".into(), - }, - settled_at_ms: NOW, - resume: Some(FundingResume::Credit { - progress: CreditProgress { - credited: 10, - attempt: 1, - claim: Some(Claim { - amount: 30, - since_ms: NOW, - registered: false, - }), - started_ms: NOW, - }, - }), - }, - ] - ); - } - - // A failed session whose funds a retry can still reach must outlive the - // history bound, or the host could lose track of where they are. - #[test] - fn history_keeps_a_failed_session_that_still_holds_funds() { - let held = FundingSession { - intent: "fs_held".into(), - stage: FundingStage::Failed { - reason: FundingFailure::Expired, - settled_at_ms: NOW - 1, - resume: Some(FundingResume::Conversion), - }, - ..session(FundingDirection::In) - }; - let history = (0..SETTLED_HISTORY_LIMIT as u64).map(|n| expired(&format!("fs_{n}"), NOW + n)); - - assert!( - retained(history.chain([held.clone()])) - .iter() - .any(|session| session.intent == held.intent) - ); - } - - // CASH no top-up claimed is still on People, so a failed credit is - // retried as getcash re-arms it: after an unclaimed last attempt with a - // fresh attempt and id, after a timeout with the same claim watched - // again, and after a refused source by registering the attempt again. - #[test] - fn a_failed_credit_is_retried_from_where_it_stopped() { - let short = CreditStep::Short { claimed: 0 }; - let retried = |mut session: FundingSession| { - let retried = session.retry(NOW + 5); - (retried, session.stage) - }; - let progress = |attempt, claim| FundingStage::Crediting { - progress: CreditProgress { - credited: 0, - attempt, - claim, - started_ms: NOW + 5, - }, - }; - let mut unclaimed = landed(); - let unclaimed_stage = credit( - &mut unclaimed, - [CreditStep::Sized { amount: 40 }, CreditStep::Registered, short.clone(), CreditStep::Sized { amount: 40 }, CreditStep::Registered, short.clone(), CreditStep::Sized { amount: 40 }, CreditStep::Registered, short], - ); - let mut timed_out = landed(); - credit(&mut timed_out, [CreditStep::Sized { amount: 40 }, CreditStep::Registered, CreditStep::TimedOut]); - let mut refused = landed(); - credit(&mut refused, [CreditStep::Refused { reason: "no".into() }]); - - assert_eq!( - ( - unclaimed_stage, - [retried(unclaimed), retried(timed_out), retried(refused), retried(landed())], - ), - ( - FundingStage::Failed { - reason: FundingFailure::Other { - code: "credit_unclaimed".into(), - message: "the host claimed no CASH from the deposit account".into(), - }, - settled_at_ms: NOW, - resume: Some(FundingResume::Credit { - progress: CreditProgress { - credited: 0, - attempt: 3, - claim: None, - started_ms: NOW, - }, - }), - }, - [ - (Ok(()), progress(3, None)), - ( - Ok(()), - progress( - 0, - Some(Claim { - amount: 40, - since_ms: NOW + 5, - registered: true, - }) - ) - ), - (Ok(()), progress(0, None)), - (Err(RetryRefusal::NotResumable), landed().stage), - ], - ) - ); - } - - // CASH on People is what the user is owed, so landing ends conversion - // whatever the submission state, and the subscriber keeps seeing - // converting until it is credited. - #[test] - fn landing_on_people_ends_the_conversion() { - let mut session = converting(); - session.advance_conversion(ConversionStep::Submitted(SUBMISSION), NOW); - let submitted = session.converting().and_then(|(_, submission)| submission); - session.advance_conversion(ConversionStep::Landed { landed: 49 }, NOW); - - assert_eq!( - (submitted, session.stage.clone(), session.wire_item(), session.is_terminal()), - ( - Some(SUBMISSION), - FundingStage::Converted { landed: 49 }, - HostFundingStatusSubscribeItem::Converting, - false, - ) - ); - } - - // Funds sit in these accounts, so a label that drifts between releases - // strands them. The labels are getcash's, byte for byte, unpadded. - #[test] - fn funding_account_labels_are_getcash_labels() { - assert_eq!( - [ - funding_account_label(FundingAccountKind::Deposit, "usdt-assethub", 1), - funding_account_label(FundingAccountKind::Refund, "btc", 2), - funding_account_label(FundingAccountKind::Withdrawal, "dot-assethub", 3), - funding_account_label(FundingAccountKind::Deposit, "x".repeat(40).as_str(), 1), - ], - [ - Ok("onramp:eph:usdt-assethub:1".to_string()), - Ok("onramp:rf:btc:2".to_string()), - Ok("wd:eph:dot-assethub:3".to_string()), - Err(FundingAccountError::LabelTooLong), - ] - ); - } - - // getcash turns its `deriveEntropy(label)` into the burner with - // `entropyToMiniSecret` and `sr25519CreateDerive(mini)("")`. The keys are - // from those libraries, fed the entropy core's `deriveEntropy` gives - // `fund.dot` for the label (itself pinned to dotli's vector), so the same - // root reaches the same account through either implementation. - #[test] - fn a_funding_key_is_the_one_getcash_derives_from_the_same_entropy() { - let key = |root: &[u8]| { - funding_keypair(root, "fund.dot", FundingAccountKind::Deposit, "usdt-assethub", 1) - .map(|keypair| hex::encode(keypair.public.to_bytes())) - }; - - assert_eq!( - ( - derive_root_keypair_from_entropy(&[7; 32]) - .map(|keypair| hex::encode(keypair.public.to_bytes())), - key(&[9; 32]), - ), - ( - Ok("ae78b88f68f8a3391cd7d1a8908766e1d068b2c1db6244a373e8b643e49d085f".to_string()), - Ok("fefa1fc85ecec3e8efa2cf47672fe85220dfa74c4aeda155b673f414b141b054".to_string()), - ) - ); - } - - - #[test] fn storing_nothing_clears_the_slot() { let storage = stub_platform(); diff --git a/rust/crates/truapi/src/host_logic/product_account.rs b/rust/crates/truapi/src/host_logic/product_account.rs index c008e804ec..c810a7c5f2 100644 --- a/rust/crates/truapi/src/host_logic/product_account.rs +++ b/rust/crates/truapi/src/host_logic/product_account.rs @@ -29,10 +29,6 @@ pub const IDENTITY_LABEL: &str = "uid"; /// domain holds the full and light person keys; the product id is /// `peopl.`, see [`personhood_product_id`]. pub const PERSONHOOD_LABEL: &str = "peopl"; -/// Reserved dotNS label of the funding modality, whose entropy every funding -/// account is derived from; the product id is `fund.`, see -/// [`funding_product_id`]. -pub const FUNDING_LABEL: &str = "fund"; const RING_VRF_ROOT_KEY: &[u8] = b"ring-vrf"; /// The reserved identity product id on the network with `network_suffix`: @@ -51,12 +47,6 @@ pub fn personhood_product_id(network_suffix: &str) -> String { format!("{PERSONHOOD_LABEL}.{network_suffix}") } -/// The reserved funding product id on the network with `network_suffix`: -/// `fund.dot` on Polkadot, `fund.paseo` on paseo-next-v2. -pub fn funding_product_id(network_suffix: &str) -> String { - format!("{FUNDING_LABEL}.{network_suffix}") -} - /// Substrate sr25519 signing-context string. Shared by every sr25519 signature /// the core produces: statement store, product raw signing, dotNS gateway. pub const SR25519_SIGNING_CONTEXT: &[u8] = b"substrate"; diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 7453035e13..5c641b089b 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -650,9 +650,8 @@ runtime_items! { pub use runtime::product_manifest::{encode_cached_root_manifest, manifest_cache_key}; pub use runtime::statement_allowance; pub use runtime::{ - AnnouncedPairing, DevicePairingObserver, FundingNetwork, FundingSigner, - MAX_PAIRING_METADATA_CHARS, PairedSsoPeer, PairingProposal, PairingProposalMetadata, - ResponderExit, + AnnouncedPairing, DevicePairingObserver, MAX_PAIRING_METADATA_CHARS, PairedSsoPeer, + PairingProposal, PairingProposalMetadata, ResponderExit, }; #[cfg(not(target_arch = "wasm32"))] diff --git a/rust/crates/truapi/src/native/callbacks.rs b/rust/crates/truapi/src/native/callbacks.rs index 8191b075a6..a062b72412 100644 --- a/rust/crates/truapi/src/native/callbacks.rs +++ b/rust/crates/truapi/src/native/callbacks.rs @@ -327,8 +327,7 @@ pub trait NativeFundingCallbacks: Send + Sync { amount: Option, ) -> Result; - /// A session's status, or what its deposit account holds, changed, for - /// host UI such as the in-flight pill or a mismatch prompt. + /// A session's status changed, for host UI such as the in-flight pill. fn funding_session_changed(&self, intent: String, status: v01::HostFundingStatusSubscribeItem); } diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index 1068479f71..ae95457562 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -273,51 +273,6 @@ impl NativeTrUApiHostRuntime { } } - /// 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 - /// this call enabled it. - pub fn enable_funding_conversion(&self, cash_asset_id: u32, deposit_asset_ids: Vec) -> bool { - self.runtime - .enable_funding_conversion(crate::FundingNetwork { cash_asset_id }, deposit_asset_ids) - } - - /// Convert what arrived of `asset` on session `intent`'s deposit account - /// instead of what was asked. - pub async fn accept_funding_deposit( - &self, - intent: String, - asset: crate::host_logic::funding::DepositAsset, - ) -> Result<(), HostRejection> { - Ok(self.runtime.accept_funding_deposit(&intent, asset).await?) - } - - /// Try failed session `intent` again from where its funds are. - pub async fn retry_funding(&self, intent: String) -> Result<(), HostRejection> { - Ok(self.runtime.retry_funding(&intent).await?) - } - - /// What arrived on session `intent`'s deposit account that does not - /// match what was asked, if anything. - pub fn funding_deposit_mismatch( - &self, - intent: String, - ) -> Option { - self.runtime - .funding_session(&intent)? - .deposit_mismatch() - } - - /// Raw seed of session `intent`'s `kind` account, for a wallet to import - /// and move its funds by hand. `None` while no signing session is active. - pub fn funding_account_secret( - &self, - intent: String, - kind: crate::host_logic::funding::FundingAccountKind, - ) -> Result, HostRejection> { - Ok(self.runtime.funding_account_secret(&intent, kind)?) - } - /// Open a funding session on the host's own behalf, as the Balance /// card does, and show the overlay. Answers the session id, or `None` /// when the user dismissed it. @@ -329,25 +284,6 @@ impl NativeTrUApiHostRuntime { Ok(self.runtime.open_funding(direction, amount).await?) } - /// The deposit of `asset` a provider must deliver to credit session - /// `intent`'s amount. - pub async fn quote_funding_deposit( - &self, - intent: String, - asset: crate::host_logic::funding::DepositAsset, - ) -> Result { - Ok(self.runtime.quote_funding_deposit(&intent, asset).await?) - } - - /// Give session `intent` the deposit account its provider pays into. - pub async fn assign_funding_deposit( - &self, - intent: String, - request: crate::host_logic::funding::DepositRequest, - ) -> Result { - Ok(self.runtime.assign_funding_deposit(&intent, request).await?) - } - /// Session `intent` as the core holds it, for the host's status and /// history views. pub fn funding_session( diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index d6bea242d6..6d81a1acaa 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -1460,10 +1460,6 @@ pub enum CoreStorageKey { /// as one SCALE blob. #[codec(index = 13)] FundingSessions, - /// Last funding account number handed out per source, as one SCALE blob. - /// Never reset, so no account is reused. - #[codec(index = 14)] - FundingAccountCounters, } /// Stable metadata describing one strictly decoded [`CoreStorageKey`]. @@ -1518,7 +1514,6 @@ pub fn describe_core_storage_key( CoreStorageKey::SsoResponderRequestLedger { .. } => ("SsoResponderRequestLedger", None), CoreStorageKey::ProductManifest { product_id } => ("ProductManifest", Some(product_id)), CoreStorageKey::FundingSessions => ("FundingSessions", None), - CoreStorageKey::FundingAccountCounters => ("FundingAccountCounters", None), }; Ok(CoreStorageKeyDescription { kind, product_id }) } @@ -2587,11 +2582,6 @@ mod tests { None, ), (CoreStorageKey::FundingSessions, "FundingSessions", None), - ( - CoreStorageKey::FundingAccountCounters, - "FundingAccountCounters", - None, - ), ] { let description = describe_core_storage_key(&key.encode()).expect("valid key"); assert_eq!(description.kind, kind); @@ -3330,9 +3320,8 @@ pub trait FundingPlatform: Send + Sync { session: FundingPresentation, ) -> Result; - /// Observe a session's status change, or a change in what its deposit - /// account holds, for host UI such as the in-flight pill or a mismatch - /// prompt. + /// Observe a session's status change, for host UI such as the in-flight + /// pill. fn funding_session_changed(&self, intent: String, status: HostFundingStatusSubscribeItem) { let _ = (intent, status); } diff --git a/rust/crates/truapi/src/platform/mock.rs b/rust/crates/truapi/src/platform/mock.rs index 2051f11cc6..c212ba7e3e 100644 --- a/rust/crates/truapi/src/platform/mock.rs +++ b/rust/crates/truapi/src/platform/mock.rs @@ -799,7 +799,6 @@ fn core_key(key: &CoreStorageKey) -> String { format!("core:product-manifest:{product_id}") } CoreStorageKey::FundingSessions => "core:funding-sessions".to_string(), - CoreStorageKey::FundingAccountCounters => "core:funding-account-counters".to_string(), CoreStorageKey::AllowanceKeys { session_id } => { format!("core:allowance-keys:{session_id}") } diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 899524e089..f4b92b608f 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -20,7 +20,7 @@ mod chat; pub mod contacts; mod dotns_lookup; mod funding; -pub use funding::{FundingNetwork, FundingSigner, OpenFundingError}; +pub use funding::OpenFundingError; mod identity; pub mod login_failure; mod pairing_host; @@ -123,10 +123,10 @@ use web_time::Instant; use crate::chain_runtime::RuntimeFailure; use crate::host_internal::bulletin::preimage_key; use crate::host_internal::permissions::{PermissionsService, TemporaryPermissions}; -use crate::host_internal::product_manifest::{Granted, bare_product_label}; +use crate::host_internal::product_manifest::Granted; use crate::host_internal::sso_messages::RingVrfError; use crate::host_logic::product_account::{ - FUNDING_LABEL, derivation_index_bytes, derive_product_public_key, public_key_from_address, + derivation_index_bytes, derive_product_public_key, public_key_from_address, }; use crate::host_logic::session::SessionInfo; #[cfg(test)] @@ -540,9 +540,7 @@ impl ProductRuntimeHost { // them. Production hosts must reject localhost products before creating // the product runtime. if crate::platform::is_localhost_product_identifier(&product_id) { - return normalize_product_identifier(dot_ns_identifier) - .ok() - .filter(|target| !is_funding_product(target)); + return normalize_product_identifier(dot_ns_identifier).ok(); } // Bounded here rather than left to the lookup: it can reach dotNS on // the Asset Hub, and a caller's own deadline is what decides how long @@ -551,7 +549,6 @@ impl ProductRuntimeHost { let cx = remote_authority_context(cx); self.bounded_cross_product_scope_target(dot_ns_identifier, Granted::Context, &cx) .await - .filter(|target| !is_funding_product(target)) } /// Resolve the grant under the caller's deadline and cancellation, answering @@ -857,23 +854,11 @@ impl ProductRuntimeHost { } } -/// Whether `product_id` is the reserved funding product or a subname of it. -/// Its entropy is the key to every funding account, which holds users' funds -/// in transit, so no product derives it or uses its accounts. Paired hosts -/// hold the root entropy source and can compute it, as getcash's burners -/// always could be: the host is trusted with funding keys, products are not. -fn is_funding_product(product_id: &str) -> bool { - bare_product_label(product_id) == FUNDING_LABEL -} - async fn account_access_authorization( platform: &dyn Platform, requesting_product_id: &str, target_product_id: &str, ) -> Result { - if is_funding_product(target_product_id) { - return Ok(PermissionAuthorizationStatus::Denied); - } if requesting_product_id == target_product_id || crate::platform::normalizes_to_trusted_remote_permissions(requesting_product_id) { diff --git a/rust/crates/truapi/src/runtime/capabilities/payment.rs b/rust/crates/truapi/src/runtime/capabilities/payment.rs index b7de6cadbd..326fcccd65 100644 --- a/rust/crates/truapi/src/runtime/capabilities/payment.rs +++ b/rust/crates/truapi/src/runtime/capabilities/payment.rs @@ -177,12 +177,7 @@ impl Payment for ProductRuntimeHost { .services .top_up_platform() .ok_or(CallError::Unsupported)?; - // The core credits funding deposits through top-ups made as the - // funding product, so a product under that name could race or fake - // them. - if self.authority.current_session().is_none() - || crate::runtime::is_funding_product(&self.product_id()) - { + if self.authority.current_session().is_none() { return Err(CallError::Denied); } let domain = |error| CallError::Domain(HostPaymentTopUpError::V1(error)); @@ -209,9 +204,7 @@ impl Payment for ProductRuntimeHost { let Some(platform) = self.services.top_up_platform() else { return Subscription::interrupted(CallError::Unsupported); }; - if self.authority.current_session().is_none() - || crate::runtime::is_funding_product(&self.product_id()) - { + if self.authority.current_session().is_none() { return Subscription::interrupted(CallError::Denied); } Subscription::new(Box::pin( diff --git a/rust/crates/truapi/src/runtime/capabilities/resources.rs b/rust/crates/truapi/src/runtime/capabilities/resources.rs index 5a68ae02e0..e675d6be73 100644 --- a/rust/crates/truapi/src/runtime/capabilities/resources.rs +++ b/rust/crates/truapi/src/runtime/capabilities/resources.rs @@ -99,11 +99,6 @@ impl Entropy for ProductRuntimeHost { }, ))); }; - // The funding accounts are the funding product's entropy for their - // labels, so no product under that name may derive it. - if crate::runtime::is_funding_product(&self.product_id()) { - return Err(CallError::Denied); - } let entropy = self .authority .derive_entropy(&session, &self.product_id(), &context) diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs index d7a4449a11..9cd66efff7 100644 --- a/rust/crates/truapi/src/runtime/funding.rs +++ b/rust/crates/truapi/src/runtime/funding.rs @@ -10,39 +10,19 @@ //! the change, and then notifies subscribers and the host. use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock, Weak}; use core::time::Duration; use futures::channel::mpsc; use futures::lock::Mutex as AsyncMutex; -use core::future::Future; - -use futures::future::{BoxFuture, FutureExt}; use futures::stream::{self, BoxStream, StreamExt}; -use truapi::latest::{ - ChainIdentifier, FundingDirection, GenericError, HostFundingStatusSubscribeItem, -}; - -mod conversion; -mod credit; - -use conversion::{Chains, ConversionChains, ConversionError}; -#[cfg(test)] -use conversion::Prepared; -pub use conversion::{FundingNetwork, FundingSigner}; +use truapi::latest::{FundingDirection, GenericError, HostFundingStatusSubscribeItem}; use super::services::RuntimeServices; - -use super::statement_allowance::{ChainClient, ChainContext}; -use super::statement_allowance::rpc::RpcClient; -use crate::host_logic::features; use crate::host_logic::funding::{ - AcceptRefusal, ConversionRoute, ConversionStep, ConversionSubmission, CreditProgress, CreditStep, - DepositAsset, DepositHolding, DepositMismatch, DepositQuote, DepositRequest, - FundingDeposit, FundingSession, FundingSessionError, FundingStage, - load_sessions, next_account_number, retained, store_sessions, + FundingSession, FundingSessionError, load_sessions, retained, store_sessions, }; use crate::platform::{ CoreStorage, FundingPlatform, FundingPresentOutcome, FundingPresentation, Platform, @@ -52,18 +32,6 @@ use crate::unix_time::current_unix_millis; /// Wait before retrying an expiry sweep whose write failed. const SWEEP_RETRY: Duration = Duration::from_secs(30); -/// Wait between reads of the awaited deposits: two Asset Hub blocks. -const DEPOSIT_POLL: Duration = Duration::from_secs(12); -/// How often the deposit accounts of sessions that already ended are read. -const LATE_READ_MS: u64 = 5 * 60 * 1_000; -/// Longest a chain read may take before the pass gives up on it. -const CHAIN_TIMEOUT: Duration = Duration::from_secs(30); -/// How long a conversion that took the deposit on Asset Hub may take to -/// reach People before it counts as stalled. -const STALL_AFTER_MS: u64 = 30 * 60 * 1_000; -/// Numbered accounts skipped for already holding funds before assignment -/// gives up. -const MAX_USED_ACCOUNTS: usize = 16; type Subscribers = HashMap>>; @@ -77,12 +45,6 @@ pub struct FundingRegistry { writes: AsyncMutex, /// Whether a task is waiting on the next deadline. sweeping: AtomicBool, - /// Whether a task is polling the awaited deposits. - watching: AtomicBool, - /// When sessions that already ended were last read, in Unix milliseconds. - late_read_ms: AtomicU64, - /// What converts deposits, once a signing host provides it. - conversion: OnceLock, platform: OnceLock>, } @@ -98,29 +60,6 @@ impl FundingRegistry { self.platform.get().cloned() } - /// Let deposits be converted on `network`, signed by `signer`. Set-once; - /// returns whether this call installed it. - /// - /// The assets a deposit can arrive in, `deposit_asset_ids` from the - /// network's `Assets` pallet with the native token, are read on every - /// watched deposit account, so a wrong or short deposit is seen. - pub fn install_conversion( - &self, - network: FundingNetwork, - deposit_asset_ids: Vec, - signer: Arc, - ) -> bool { - let mut deposit_assets: Vec<_> = deposit_asset_ids.into_iter().map(DepositAsset::Asset).collect(); - deposit_assets.push(DepositAsset::Native); - self.conversion - .set(Conversion { - network, - deposit_assets, - signer, - }) - .is_ok() - } - /// Snapshot one session. pub fn get(&self, intent: &str) -> Option { self.lock_sessions().get(intent).cloned() @@ -146,21 +85,7 @@ impl FundingRegistry { .entry(intent.to_string()) .or_default() .push(sender); - // The host also hears when only a deposit account's holdings change; - // a subscriber sees each status once. - let mut shown = None; - Some( - current - .chain(receiver) - .filter(move |item| { - let fresh = shown.as_ref() != Some(item); - if fresh { - shown = Some(item.clone()); - } - futures::future::ready(fresh) - }) - .boxed(), - ) + Some(current.chain(receiver).boxed()) } /// Apply `edit` to a copy of the sessions, persist the result, then make it @@ -209,17 +134,6 @@ impl FundingRegistry { Ok(result) } - /// Reserve the next account number for `source_id`, serialised with every - /// other funding write. - pub async fn next_account_number( - &self, - storage: &(impl CoreStorage + ?Sized), - source_id: &str, - ) -> Result { - let _writes = self.writes.lock().await; - next_account_number(storage, source_id).await - } - /// Keep one task waiting on the earliest open deadline while any session /// is open, so a session expires on time whether or not anyone asks. The /// task ends once no session is open or the registry is dropped. @@ -268,221 +182,11 @@ impl FundingRegistry { fn next_deadline(&self) -> Option { self.lock_sessions() .values() - .filter(|session| session.expires_by_sweep()) + .filter(|session| !session.is_terminal()) .map(|session| session.deadline_ms) .min() } - /// Give an open inbound session the first numbered account for the - /// request's source that holds none of its asset, so a seed restored on a new - /// install never reuses an account a provider may still pay into. - /// `derive` maps an account number to its public key. - pub async fn assign_empty_deposit( - &self, - storage: &(impl CoreStorage + ?Sized), - balances: &dyn DepositBalances, - now_ms: u64, - intent: &str, - plan: DepositPlan, - derive: impl Fn(u32) -> Result<[u8; 32], GenericError>, - ) -> Result<[u8; 32], AssignDepositError> { - let target = self - .get(intent) - .ok_or(AssignDepositError::NotFound) - .and_then(|session| assignable(&session).map(|()| session.amount))?; - let DepositPlan { request, route } = plan; - for _ in 0..MAX_USED_ACCOUNTS { - let number = self - .next_account_number(storage, &request.source_id) - .await?; - let account = derive(number).map_err(AssignDepositError::Derive)?; - let held = balances - .balance(request.asset, &account) - .await - .map_err(AssignDepositError::Chain)?; - if held > 0 { - continue; - } - let deposit = FundingDeposit { - source_id: request.source_id, - number, - asset: request.asset, - account, - expected: request.expected, - route, - target, - holdings: Vec::new(), - }; - let intent = intent.to_string(); - return self - .commit(storage, now_ms, move |sessions| { - let assigned = match sessions.get_mut(&intent) { - None => Err(AssignDepositError::NotFound), - Some(session) => assignable(session).map(|()| { - session.deposit = Some(deposit); - account - }), - }; - (assigned, Vec::new()) - }) - .await?; - } - Err(AssignDepositError::AccountsInUse) - } - - /// Read every watched deposit account once, for its requested asset and - /// every asset in `deposit_assets`, and record what each holds, so a - /// wrong, short or late deposit is seen. Sessions that already ended are - /// read once per [`LATE_READ_MS`]. A failed read of the requested asset - /// leaves its session for the next pass; a failed read of another asset - /// keeps that asset's last reading. - pub async fn observe_deposits( - &self, - storage: &(impl CoreStorage + ?Sized), - now_ms: u64, - balances: &dyn DepositBalances, - deposit_assets: &[DepositAsset], - ) -> Result<(), FundingSessionError> { - let read_late = now_ms.saturating_sub(self.late_read_ms.load(Ordering::Acquire)) >= LATE_READ_MS; - let watched: Vec<_> = self - .lock_sessions() - .values() - .filter(|session| read_late || !session.is_terminal()) - .filter_map(|session| Some((session.intent.clone(), session.watched_deposit(now_ms)?.clone()))) - .collect(); - if read_late { - self.late_read_ms.store(now_ms, Ordering::Release); - } - let mut readings = Vec::new(); - 'sessions: for (intent, deposit) in watched { - let mut assets = vec![deposit.asset]; - assets.extend(deposit_assets.iter().filter(|other| **other != deposit.asset)); - // The native token goes last, so it is the stray of last resort. - assets.sort_by_key(|asset| *asset == DepositAsset::Native); - let mut holdings = Vec::new(); - for asset in assets { - let balance = match balances.balance(asset, &deposit.account).await { - Ok(balance) => balance, - Err(error) if asset != deposit.asset => { - tracing::warn!(%intent, reason = %error.reason, "reading a funding deposit failed"); - deposit.held(asset) - } - Err(error) => { - tracing::warn!(%intent, reason = %error.reason, "reading a funding deposit failed"); - continue 'sessions; - } - }; - if balance > 0 { - holdings.push(DepositHolding { asset, balance }); - } - } - readings.push((intent, holdings)); - } - self.commit(storage, now_ms, move |sessions| { - let changed = readings - .into_iter() - .filter_map(|(intent, holdings)| { - let session = sessions.get_mut(&intent)?; - session.observe_holdings(holdings, now_ms).then_some(intent) - }) - .collect(); - ((), changed) - }) - .await - } - - /// Whether a polling task should keep going, clearing the watching flag - /// once no deposit is awaited. - fn still_watching(registry: &Weak) -> bool { - let Some(live) = registry.upgrade() else { - return false; - }; - loop { - if live.awaits_deposit() { - return true; - } - live.watching.store(false, Ordering::Release); - // A deposit assigned after the check would otherwise wait for the - // next caller to arm the watch. - if !live.awaits_deposit() || live.watching.swap(true, Ordering::AcqRel) { - return false; - } - } - } - - fn awaits_deposit(&self) -> bool { - self.lock_sessions() - .values() - .any(|session| { - session.watched_deposit(current_unix_millis()).is_some() - || session.converting().is_some() - || session.crediting().is_some() - }) - } - - /// Every session being converted, with its deposit and submission. - fn converting_sessions(&self) -> Vec<(String, FundingDeposit, Option)> { - self.lock_sessions() - .values() - .filter_map(|session| { - let (deposit, submission) = session.converting()?; - Some((session.intent.clone(), deposit.clone(), submission)) - }) - .collect() - } - - /// Every session awaiting or being credited, with its deposit and what - /// its top-ups claimed so far. - fn crediting_sessions(&self) -> Vec { - self.lock_sessions() - .values() - .filter_map(|session| { - let (deposit, progress) = session.crediting()?; - Some(CreditingSession { - intent: session.intent.clone(), - deposit: deposit.clone(), - progress, - }) - }) - .collect() - } - - /// Apply one credit `step` to session `intent`. - async fn record_credit( - &self, - storage: &(impl CoreStorage + ?Sized), - now_ms: u64, - intent: &str, - step: CreditStep, - ) -> 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_credit(step, now_ms)); - ((), if changed { vec![intent] } else { Vec::new() }) - }) - .await - } - - /// Apply one conversion `step` to session `intent`. - async fn record_conversion( - &self, - storage: &(impl CoreStorage + ?Sized), - now_ms: u64, - intent: &str, - step: ConversionStep, - ) -> 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_conversion(step, now_ms)); - ((), if changed { vec![intent] } else { Vec::new() }) - }) - .await - } - /// Tell subscribers and the host about a session's current stage. A /// terminal stage ends the subscriber streams. fn fan_out(&self, session: &FundingSession) { @@ -514,224 +218,6 @@ impl FundingRegistry { } } -/// A session awaiting or being credited, as one credit pass reads it. -struct CreditingSession { - intent: String, - deposit: FundingDeposit, - progress: Option, -} - -impl CreditingSession { - /// Whether the next top-up has yet to be sized from what the account - /// holds. - fn sizing(&self) -> bool { - self.progress.is_none_or(|progress| progress.claim.is_none()) - } -} - -/// A deposit request with the route core chose for it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DepositPlan { - /// What the provider delivers. - pub request: DepositRequest, - /// How the deposit becomes CASH on People. - pub route: ConversionRoute, -} - -/// What converts deposits: the network's constants and the deposit keys. -struct Conversion { - network: FundingNetwork, - /// What a deposit account is read for, the native token last. - deposit_assets: Vec, - signer: Arc, -} - -/// What a conversion pass decided for one session. -#[derive(Debug, PartialEq, Eq)] -enum PlannedStep { - /// Record a step. - Record(ConversionStep), - /// Record the submission, then submit `extrinsic`. - Submit { - submission: ConversionSubmission, - extrinsic: Vec, - }, -} - -/// Decide the next step for `deposit`, given its `submission` so far. -/// -/// A submitted conversion is judged only by what it did: landed once People -/// shows its CASH on top of what was there before; dropped once its era has -/// passed unincluded; refused once it was included and the deposit is still -/// on Asset Hub, so a conversion failing on chain is bounded like one a dry -/// run refuses; stalled once it took the deposit and nothing arrived in time. -/// A fresh conversion is signed only by the account the deposit sits on. -async fn plan_conversion( - chains: &dyn ConversionChains, - signer: &dyn FundingSigner, - deposit: &FundingDeposit, - submission: Option, - now_ms: u64, -) -> Result, ConversionError> { - let account = &deposit.account; - if let Some(submission) = submission { - let landed = chains - .landed(account) - .await? - .saturating_sub(submission.people_before); - if landed >= submission.landing { - return Ok(Some(PlannedStep::Record(ConversionStep::Landed { landed }))); - } - let step = if chains.nonce(account).await? > submission.nonce { - let held = chains - .balance(deposit.asset, account) - .await - .map_err(|error| ConversionError::Chain(error.reason))?; - if held >= submission.spent { - // Included without taking the deposit: it failed on chain, - // and resubmitting it as is costs a fee each time. - Some(ConversionStep::Refused { - reason: "the conversion was included and failed".into(), - psm: None, - }) - } else if now_ms.saturating_sub(submission.submitted_at_ms) > STALL_AFTER_MS { - // What did land on People is the user's: credit it rather - // than fail, where getcash fails the job as a shortfall. - Some(match landed { - 0 => ConversionStep::Stalled, - landed => ConversionStep::Landed { landed }, - }) - } else { - None - } - } else if chains.finalized_block() > submission.valid_until_block { - Some(ConversionStep::Dropped) - } else { - None - }; - return Ok(step.map(PlannedStep::Record)); - } - let keypair = signer - .deposit_keypair(&deposit.source_id, deposit.number) - .map_err(|error| ConversionError::Chain(error.reason))?; - let Some(keypair) = keypair.filter(|keypair| keypair.public.to_bytes() == *account) else { - return Ok(None); - }; - let people_before = chains.landed(account).await?; - let nonce = chains.nonce(account).await?; - let prepared = chains.prepare(deposit, &keypair, nonce).await?; - Ok(Some(PlannedStep::Submit { - submission: ConversionSubmission { - nonce, - submitted_at_ms: now_ms, - valid_until_block: prepared.valid_until_block, - people_before, - landing: prepared.landing, - spent: prepared.spent, - }, - extrinsic: prepared.extrinsic, - })) -} - -/// Reads deposit-account balances on Asset Hub. -pub trait DepositBalances: Send + Sync { - /// `account`'s balance of `asset`; zero when the account does not exist. - fn balance<'a>( - &'a self, - asset: DepositAsset, - account: &'a [u8; 32], - ) -> BoxFuture<'a, Result>; -} - -/// Run a chain read, giving up after [`CHAIN_TIMEOUT`] so a stalled -/// connection cannot park the deposit watch. -async fn within_chain_timeout(read: impl Future) -> Result { - within_timeout(CHAIN_TIMEOUT, read).await -} - -/// Run `work`, giving up after `limit`. -async fn within_timeout(limit: Duration, work: impl Future) -> Result { - let work = work.fuse(); - let timeout = futures_timer::Delay::new(limit).fuse(); - futures::pin_mut!(work, timeout); - futures::select! { - value = work => Ok(value), - () = timeout => Err(GenericError { - reason: format!("timed out after {}s", limit.as_secs()), - }), - } -} - -/// Why a deposit account could not be assigned. -#[derive(Debug, derive_more::Display)] -pub enum AssignDepositError { - /// No such session. - #[display("no such funding session")] - NotFound, - /// The session is not an open inbound one without a deposit. - #[display("funding session is not awaiting a deposit account")] - NotAwaitingDeposit, - /// No signing host converts deposits on this runtime. - #[display("this host does not convert funding deposits")] - ConversionUnavailable, - /// No route turns this asset into CASH. - #[display("no route converts this deposit into CASH")] - NoRoute, - /// The chain refused to price the deposit. - #[display("{_0}")] - Refused(String), - /// The session names no amount to quote a deposit for. - #[display("the funding session names no amount")] - NoAmount, - /// The provider would deliver less than it takes to credit the session's - /// amount. - #[display("the deposit must be at least {needed} to credit the amount")] - DepositTooSmall { - /// Least deposit, in the asset's units. - needed: u128, - }, - /// Every account tried already holds funds. - #[display("every funding account tried already holds funds")] - AccountsInUse, - /// The account could not be derived. - #[display("{}", _0.reason)] - Derive(GenericError), - /// Asset Hub could not be read. - #[display("{}", _0.reason)] - Chain(GenericError), - /// The session could not be stored. - #[display("{_0}")] - Session(FundingSessionError), -} - -impl AssignDepositError { - /// A conversion error as an assignment error: a refusal stays one. - fn from_conversion(error: ConversionError) -> Self { - match error { - ConversionError::Refused(reason) | ConversionError::PsmRefused { reason, .. } => { - Self::Refused(reason) - } - ConversionError::Chain(reason) => Self::Chain(GenericError { reason }), - } - } -} - -impl From for AssignDepositError { - fn from(error: FundingSessionError) -> Self { - Self::Session(error) - } -} - -/// Whether `session` can take a deposit account. -fn assignable(session: &FundingSession) -> Result<(), AssignDepositError> { - let awaiting = session.direction == FundingDirection::In - && session.stage == FundingStage::Open - && session.deposit.is_none(); - awaiting - .then_some(()) - .ok_or(AssignDepositError::NotAwaitingDeposit) -} - /// Why a session could not be opened. #[derive(Debug, derive_more::Display)] pub enum OpenFundingError { @@ -771,466 +257,12 @@ impl RuntimeServices { ) .await; match loaded { - Ok(()) => { - registry.keep_expiring(&services); - services.watch_funding_deposits(); - } + Ok(()) => registry.keep_expiring(&services), Err(error) => tracing::warn!(%error, "loading funding sessions failed"), } })); } - /// Give an open inbound session its deposit account for the request's - /// source, fix the route that will convert it, and watch the account until - /// the expected balance arrives. Returns the account the provider pays - /// into. - pub async fn assign_funding_deposit( - self: &Arc, - intent: &str, - request: DepositRequest, - derive: impl Fn(u32) -> Result<[u8; 32], GenericError>, - ) -> Result<[u8; 32], AssignDepositError> { - let session = self - .funding() - .get(intent) - .ok_or(AssignDepositError::NotFound)?; - assignable(&session)?; - let network = self.funding_network()?; - let quoting = session.amount.is_some() && session.quote.is_none(); - let chains = self - .funding_chains(network, quoting) - .await - .map_err(AssignDepositError::from_conversion)?; - // A session that names its amount must be paid enough to credit it, - // judged against the quote the provider was given; one that does not - // takes what the provider delivers. - let route = match (session.amount, session.quoted_route(request.asset, request.expected)) { - (_, Some(judged)) => { - judged.map_err(|needed| AssignDepositError::DepositTooSmall { needed })? - } - (Some(target), None) => { - let quote = self.record_quote(intent, &chains, request.asset, target).await?; - if request.expected < quote.deposit { - return Err(AssignDepositError::DepositTooSmall { - needed: quote.deposit, - }); - } - quote.route - } - (None, None) => within_chain_timeout(chains.choose_route(request.asset, request.expected)) - .await - .map_err(AssignDepositError::Chain)? - .map_err(AssignDepositError::from_conversion)? - .ok_or(AssignDepositError::NoRoute)?, - }; - let account = self - .funding() - .assign_empty_deposit( - self.platform.as_ref(), - &chains, - current_unix_millis(), - intent, - DepositPlan { request, route }, - derive, - ) - .await?; - self.watch_funding_deposits(); - Ok(account) - } - - /// Try a failed session `intent` again from where its funds are: its - /// conversion if the deposit is still on Asset Hub, its crediting if the - /// CASH is on People. - pub async fn retry_funding(self: &Arc, intent: &str) -> Result<(), AssignDepositError> { - let intent = intent.to_string(); - let now_ms = current_unix_millis(); - self.funding() - .commit(self.platform.as_ref(), now_ms, move |sessions| { - let retried = sessions - .get_mut(&intent) - .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() }; - (retried, changed) - }) - .await??; - self.watch_funding_deposits(); - Ok(()) - } - - /// Convert what arrived of `asset` on session `intent`'s deposit account, - /// by the route that serves that much of it. - pub async fn accept_funding_deposit( - self: &Arc, - intent: &str, - asset: DepositAsset, - ) -> Result<(), AssignDepositError> { - let arrived = match self - .funding() - .get(intent) - .ok_or(AssignDepositError::NotFound)? - .deposit_mismatch() - { - Some(DepositMismatch::Short { asset: arrived, amount } | DepositMismatch::WrongAsset { asset: arrived, amount }) - if arrived == asset => - { - amount - } - Some(_) => return Err(AssignDepositError::Refused(AcceptRefusal::NotMismatched.to_string())), - None => return Err(AssignDepositError::Refused(AcceptRefusal::NothingArrived.to_string())), - }; - let chains = self - .funding_chains(self.funding_network()?, false) - .await - .map_err(AssignDepositError::from_conversion)?; - let route = within_chain_timeout(chains.choose_route(asset, arrived)) - .await - .map_err(AssignDepositError::Chain)? - .map_err(AssignDepositError::from_conversion)? - .ok_or(AssignDepositError::NoRoute)?; - let intent = intent.to_string(); - self.funding() - .commit(self.platform.as_ref(), current_unix_millis(), move |sessions| { - let accepted = sessions - .get_mut(&intent) - .ok_or(AssignDepositError::NotFound) - .and_then(|session| { - session - .accept_arrival(asset, arrived, route, current_unix_millis()) - .map_err(|refusal| AssignDepositError::Refused(refusal.to_string())) - }); - let changed = if accepted.is_ok() { vec![intent] } else { Vec::new() }; - (accepted, changed) - }) - .await??; - self.watch_funding_deposits(); - Ok(()) - } - - /// The deposit of `asset` a provider must deliver to credit the amount - /// session `intent` names. - pub async fn quote_funding_deposit( - self: &Arc, - intent: &str, - asset: DepositAsset, - ) -> Result { - let session = self - .funding() - .get(intent) - .ok_or(AssignDepositError::NotFound)?; - let target = session.amount.ok_or(AssignDepositError::NoAmount)?; - let chains = self - .funding_chains(self.funding_network()?, true) - .await - .map_err(AssignDepositError::from_conversion)?; - self.record_quote(intent, &chains, asset, target) - .await - .map(|quote| quote.deposit) - } - - /// Quote `target` in `asset` and freeze the quote on session `intent`, - /// so the deposit is later held to the figure the provider was given. - async fn record_quote( - &self, - intent: &str, - chains: &Chains, - asset: DepositAsset, - target: u128, - ) -> Result { - let quote = within_chain_timeout(chains.deposit_quote(asset, target)) - .await - .map_err(AssignDepositError::Chain)? - .map_err(AssignDepositError::from_conversion)? - .ok_or(AssignDepositError::NoRoute)?; - let intent = intent.to_string(); - self.funding() - .commit(self.platform.as_ref(), current_unix_millis(), move |sessions| { - if let Some(session) = sessions.get_mut(&intent) { - session.quote = Some(quote); - } - ((), Vec::new()) - }) - .await?; - Ok(quote) - } - - fn funding_network(&self) -> Result { - self.funding() - .conversion - .get() - .map(|conversion| conversion.network) - .ok_or(AssignDepositError::ConversionUnavailable) - } - - /// Keep one task polling the awaited deposits while any is awaited. The - /// task ends once none is, or the services are dropped. - pub fn watch_funding_deposits(self: &Arc) { - let registry = self.funding(); - if registry.watching.swap(true, Ordering::AcqRel) { - return; - } - let watched = Arc::downgrade(registry); - let services = Arc::downgrade(self); - (self.spawner)(Box::pin(async move { - while FundingRegistry::still_watching(&watched) { - futures_timer::Delay::new(DEPOSIT_POLL).await; - let Some(services) = services.upgrade() else { - return; - }; - if let Err(reason) = services.advance_chain_sessions().await { - tracing::warn!(%reason, "funding deposit watch failed"); - } - if let Err(reason) = services.advance_credits().await { - tracing::warn!(%reason, "funding credit pass failed"); - } - } - })); - } - - /// Asset Hub and People, pinned at their latest finalized blocks, with the - /// signed-extension metadata when `signing`. - async fn funding_chains( - &self, - network: FundingNetwork, - signing: bool, - ) -> Result { - within_chain_timeout(async { - let failed = |error: &dyn core::fmt::Display| ConversionError::Chain(error.to_string()); - let chains = features::supported_chains(self.platform.as_ref()) - .await - .map_err(|error| ConversionError::Chain(error.reason))?; - let genesis = |chain: ChainIdentifier| { - features::genesis_for(&chains, chain) - .ok_or_else(|| ConversionError::Chain(format!("the host serves no {chain:?}"))) - }; - let (asset_hub_genesis, people_genesis) = - (genesis(ChainIdentifier::AssetHub)?, genesis(ChainIdentifier::People)?); - let asset_hub = self - .chain - .online_client(&asset_hub_genesis) - .await - .map_err(|error| failed(&error))?; - let people = self - .chain - .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, - }; - Chains::at_finalized(&asset_hub, &people, network, extensions).await - }) - .await - .map_err(|error| ConversionError::Chain(error.reason))? - } - - /// Asset Hub'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 { - 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) - .await - .map_err(|error| failed(&error))?, - )); - self.chain_context - .get(&ChainClient::new(rpc, asset_hub_genesis)) - .await - .map_err(|error| failed(&error)) - } - - /// One pass over the sessions whose CASH landed: register top-ups and - /// record what they credited. Waits while the host has no top-up. - async fn advance_credits(self: &Arc) -> Result<(), String> { - let registry = self.funding(); - let (Some(conversion), Some(top_up)) = (registry.conversion.get(), self.top_up_platform()) - else { - return Ok(()); - }; - let crediting = registry.crediting_sessions(); - if crediting.is_empty() { - return Ok(()); - } - let product = ProductContext { - product_id: conversion.signer.funding_product_id(), - execution_kind: Default::default(), - }; - let credit = credit::Credit { - top_up: top_up.as_ref(), - signer: conversion.signer.as_ref(), - product: &product, - }; - // 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 { - Ok(chains) => Some(chains), - Err(error) => { - tracing::warn!(%error, "reading funding accounts on People failed"); - None - } - } - } else { - None - }; - for session in crediting { - let held = match &chains { - Some(chains) if session.sizing() => { - let read = within_chain_timeout(chains.landed(&session.deposit.account)).await; - match read - .map_err(|error| error.reason) - .and_then(|held| held.map_err(|error| error.to_string())) - { - Ok(held) => Some(held), - Err(reason) => { - tracing::warn!(intent = %session.intent, %reason, "reading a funding account on People failed"); - None - } - } - } - _ => None, - }; - let CreditingSession { - intent, - deposit, - progress, - } = session; - let now_ms = current_unix_millis(); - match credit.plan(&deposit, progress, held, now_ms).await { - Ok(Some(step)) => registry - .record_credit(self.platform.as_ref(), now_ms, &intent, step) - .await - .map_err(|error| error.to_string())?, - Ok(None) => {} - Err(error) => { - tracing::warn!(%intent, reason = %error.reason, "funding credit pass failed"); - } - } - } - Ok(()) - } - - /// One pass over the sessions that need the chains: read the awaited - /// deposits, then advance the conversions, against one pair of finalized - /// blocks. - async fn advance_chain_sessions(self: &Arc) -> Result<(), String> { - let registry = self.funding(); - let Some(conversion) = registry.conversion.get() else { - return Ok(()); - }; - let (pending, signing) = { - let sessions = registry.lock_sessions(); - let now_ms = current_unix_millis(); - let pending = sessions.values().any(|session| { - session.watched_deposit(now_ms).is_some() || session.converting().is_some() - }); - let signing = sessions - .values() - .any(|session| matches!(session.converting(), Some((_, None)))); - (pending, signing) - }; - if !pending { - return Ok(()); - } - let chains = self - .funding_chains(conversion.network, signing) - .await - .map_err(|error| error.to_string())?; - let observed = registry - .observe_deposits( - self.platform.as_ref(), - current_unix_millis(), - &chains, - &conversion.deposit_assets, - ) - .await; - if let Err(error) = observed { - tracing::warn!(%error, "recording funding deposits failed"); - } - self.advance_conversions(&chains, conversion).await - } - - /// One pass over the sessions being converted: record what landed, - /// what was dropped or stalled, and submit what is ready. - async fn advance_conversions( - self: &Arc, - chains: &Chains, - conversion: &Conversion, - ) -> Result<(), String> { - let registry = self.funding(); - let converting = registry.converting_sessions(); - let storage = self.platform.as_ref(); - for (intent, deposit, submission) in converting { - let now_ms = current_unix_millis(); - let planned = within_chain_timeout(plan_conversion( - chains, - conversion.signer.as_ref(), - &deposit, - submission, - now_ms, - )) - .await; - let planned = match planned { - Ok(Ok(planned)) => planned, - Ok(Err(ConversionError::Refused(reason))) => { - Some(PlannedStep::Record(ConversionStep::Refused { reason, psm: None })) - } - Ok(Err(ConversionError::PsmRefused { reason, refusal })) => { - Some(PlannedStep::Record(ConversionStep::Refused { - reason, - psm: Some(refusal), - })) - } - Ok(Err(ConversionError::Chain(reason))) | Err(GenericError { reason }) => { - tracing::warn!(%intent, %reason, "funding conversion pass failed"); - continue; - } - }; - let recorded = match planned { - None => Ok(()), - Some(PlannedStep::Record(step)) => { - registry.record_conversion(storage, now_ms, &intent, step).await - } - Some(PlannedStep::Submit { - submission, - extrinsic, - }) => { - let submitted = ConversionStep::Submitted(submission); - registry - .record_conversion(storage, now_ms, &intent, submitted) - .await - .map_err(|error| error.to_string())?; - match chains.submit(extrinsic).await { - Ok(()) => Ok(()), - Err(ConversionError::Refused(reason) | ConversionError::PsmRefused { reason, .. }) => { - let refused = ConversionStep::Refused { reason, psm: None }; - registry - .record_conversion(storage, current_unix_millis(), &intent, refused) - .await - } - // It may have reached the chain anyway; the - // submission stays, and its era or the nonce decides. - Err(ConversionError::Chain(reason)) => { - tracing::warn!(%intent, %reason, "submitting a funding conversion failed"); - Ok(()) - } - } - } - }; - recorded.map_err(|error| error.to_string())?; - } - Ok(()) - } - /// Open a session and show the host's funding overlay for it: the one /// path a product's `request` and the host's own Balance card both take. /// @@ -1397,7 +429,6 @@ mod tests { Some(FundingStage::Failed { reason: FundingFailure::Expired, settled_at_ms: NOW, - resume: None, }) ); } @@ -1420,567 +451,4 @@ mod tests { assert!(failed.is_err()); assert_eq!(registry.get("fs_1"), Some(session("fs_1", NOW))); } - - const USDT: DepositAsset = DepositAsset::Asset(1984); - - /// Balances keyed by account; any other account is empty. - struct Balances(HashMap<[u8; 32], u128>); - - impl DepositBalances for Balances { - fn balance<'a>( - &'a self, - _asset: DepositAsset, - account: &'a [u8; 32], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { Ok(self.0.get(account).copied().unwrap_or(0)) }) - } - } - - fn plan(expected: u128) -> DepositPlan { - DepositPlan { - request: request(expected), - route: ConversionRoute::Teleport, - } - } - - fn request(expected: u128) -> DepositRequest { - DepositRequest { - source_id: "usdt-assethub".to_string(), - asset: USDT, - expected, - } - } - - fn account(number: u32) -> [u8; 32] { - [u8::try_from(number).expect("small"); 32] - } - - fn assign( - registry: &FundingRegistry, - storage: &dyn CoreStorage, - balances: &Balances, - intent: &str, - ) -> Result<[u8; 32], String> { - block_on(registry.assign_empty_deposit(storage, balances, NOW, intent, plan(50), |n| { - Ok(account(n)) - })) - .map_err(|error| error.to_string()) - } - - // A restored seed starts its counters again, and a provider may still pay - // into an account handed out before, so assignment passes over any - // account that already holds the asset. - #[test] - fn assignment_skips_accounts_that_already_hold_funds() { - let storage = stub_platform(); - let registry = FundingRegistry::default(); - insert(®istry, storage.as_ref(), session("fs_1", NOW)); - let balances = Balances(HashMap::from([(account(1), 7), (account(2), 1)])); - - let assigned = assign(®istry, storage.as_ref(), &balances, "fs_1"); - - assert_eq!( - ( - assigned, - registry.get("fs_1").and_then(|session| session.deposit) - ), - ( - Ok(account(3)), - Some(FundingDeposit { - source_id: "usdt-assethub".to_string(), - number: 3, - asset: USDT, - account: account(3), - expected: 50, - route: ConversionRoute::Teleport, - target: Some(100), - holdings: Vec::new(), - }) - ) - ); - } - - // Numbers are never reused, so one burned on a session that cannot take - // an account is gone for good. - #[test] - fn assignment_refuses_without_spending_a_number() { - let storage = stub_platform(); - let registry = FundingRegistry::default(); - let outbound = FundingSession { - direction: FundingDirection::Out, - ..session("fs_out", NOW) - }; - insert(®istry, storage.as_ref(), outbound); - insert(®istry, storage.as_ref(), session("fs_in", NOW)); - let empty = Balances(HashMap::new()); - - let refused = [ - assign(®istry, storage.as_ref(), &empty, "fs_missing"), - assign(®istry, storage.as_ref(), &empty, "fs_out"), - ]; - let first = assign(®istry, storage.as_ref(), &empty, "fs_in"); - let again = assign(®istry, storage.as_ref(), &empty, "fs_in"); - - assert_eq!( - (refused, first, again), - ( - [ - Err("no such funding session".to_string()), - Err("funding session is not awaiting a deposit account".to_string()), - ], - Ok(account(1)), - Err("funding session is not awaiting a deposit account".to_string()), - ) - ); - } - - // Converting starts only once the whole expected balance is on chain, and - // the stage survives a restart, so a deposit is converted exactly once. - #[test] - fn a_covering_deposit_moves_the_session_to_converting() { - let storage = stub_platform(); - let registry = FundingRegistry::default(); - insert(®istry, storage.as_ref(), session("fs_1", NOW)); - assign( - ®istry, - storage.as_ref(), - &Balances(HashMap::new()), - "fs_1", - ) - .expect("assigned"); - let stream = registry.subscribe("fs_1").expect("session exists"); - - for held in [49, 50] { - let balances = Balances(HashMap::from([(account(1), held)])); - block_on(registry.observe_deposits(storage.as_ref(), NOW, &balances, &[])) - .expect("observed"); - } - let restarted = FundingRegistry::default(); - block_on(restarted.commit(storage.as_ref(), NOW, |_| ((), Vec::new()))).expect("loaded"); - - assert_eq!( - ( - block_on(stream.take(2).collect::>()), - restarted.get("fs_1").map(|session| session.stage), - ), - ( - vec![ - HostFundingStatusSubscribeItem::AwaitingDeposit { - expires_at: Some(NOW + DAY_MS), - }, - HostFundingStatusSubscribeItem::Converting, - ], - Some(FundingStage::Converting { - deposited: 50, - refusals: 0, - submission: None, - }), - ) - ); - } - - // A provider may pay moments before the deadline, and finality and the - // poll both lag, so a session with a deposit account ends only on a read - // taken after the deadline: converting if the funds made it, expired if - // not. The sweep alone never strands a payment. - #[test] - fn a_deposit_session_expires_only_on_a_read_after_its_deadline() { - let storage = stub_platform(); - let registry = FundingRegistry::default(); - let empty = Balances(HashMap::new()); - for intent in ["fs_late", "fs_never"] { - insert(®istry, storage.as_ref(), session(intent, NOW)); - assign(®istry, storage.as_ref(), &empty, intent).expect("assigned"); - } - let past_deadline = NOW + DAY_MS; - - block_on(registry.commit(storage.as_ref(), past_deadline, |_| ((), Vec::new()))) - .expect("swept"); - let after_sweep = [registry.get("fs_late"), registry.get("fs_never")] - .map(|session| session.map(|session| session.stage)); - let late_payment = Balances(HashMap::from([(account(1), 50)])); - block_on(registry.observe_deposits(storage.as_ref(), past_deadline, &late_payment, &[])) - .expect("observed"); - - assert_eq!( - ( - after_sweep, - [registry.get("fs_late"), registry.get("fs_never")] - .map(|session| session.map(|session| session.stage)), - ), - ( - [Some(FundingStage::Open), Some(FundingStage::Open)], - [ - Some(FundingStage::Converting { - deposited: 50, - refusals: 0, - submission: None, - }), - Some(FundingStage::Failed { - reason: FundingFailure::Expired, - settled_at_ms: past_deadline, - resume: None, - }), - ], - ) - ); - } - - // The deposit is already on chain, so the deposit window no longer - // applies, and the expiry sweep must not wait on a deadline that passed. - #[test] - fn a_converting_session_does_not_expire() { - let storage = stub_platform(); - let registry = FundingRegistry::default(); - let converting = FundingSession { - stage: FundingStage::Converting { - deposited: 50, - refusals: 0, - submission: None, - }, - ..session("fs_1", NOW - DAY_MS) - }; - insert(®istry, storage.as_ref(), converting.clone()); - - block_on(registry.commit(storage.as_ref(), NOW, |_| ((), Vec::new()))).expect("swept"); - - assert_eq!( - (registry.get("fs_1"), registry.next_deadline()), - (Some(converting), None) - ); - } - - /// Chains answering fixed reads, and preparing a fixed conversion. - struct Scripted { - landed: u128, - nonce: u32, - balance: u128, - block: u64, - } - - const PREPARED: Prepared = Prepared { - extrinsic: Vec::new(), - valid_until_block: 164, - landing: 40, - spent: 50, - }; - - impl DepositBalances for Scripted { - fn balance<'a>( - &'a self, - _: DepositAsset, - _: &'a [u8; 32], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { Ok(self.balance) }) - } - } - - impl ConversionChains for Scripted { - fn landed<'a>(&'a self, _: &'a [u8; 32]) -> BoxFuture<'a, Result> { - Box::pin(async move { Ok(self.landed) }) - } - - fn nonce<'a>(&'a self, _: &'a [u8; 32]) -> BoxFuture<'a, Result> { - Box::pin(async move { Ok(self.nonce) }) - } - - fn finalized_block(&self) -> u64 { - self.block - } - - fn prepare<'a>( - &'a self, - _: &'a FundingDeposit, - _: &'a schnorrkel::Keypair, - _: u32, - ) -> BoxFuture<'a, Result> { - Box::pin(async { Ok(PREPARED) }) - } - } - - struct Keys(Option); - - impl FundingSigner for Keys { - fn deposit_keypair( - &self, - _: &str, - _: u32, - ) -> Result, GenericError> { - Ok(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 converting_deposit() -> FundingDeposit { - FundingDeposit { - source_id: "usdt-assethub".to_string(), - number: 1, - asset: USDT, - account: keypair(1).public.to_bytes(), - expected: 50, - route: ConversionRoute::Teleport, - target: None, - holdings: Vec::new(), - } - } - - const SUBMITTED: ConversionSubmission = ConversionSubmission { - nonce: 4, - submitted_at_ms: NOW, - valid_until_block: 164, - people_before: 10, - landing: 40, - spent: 50, - }; - - fn next_step(chains: Scripted, submission: Option, now_ms: u64) -> Option { - block_on(plan_conversion( - &chains, - &Keys(Some(keypair(1))), - &converting_deposit(), - submission, - now_ms, - )) - .expect("planned") - } - - // Anyone can send CASH to the account on People, so only CASH on top of - // what was there before, and at least what the conversion lands, ends it. - #[test] - fn only_the_conversions_own_cash_counts_as_landed() { - let reading = |landed| Scripted { - landed, - nonce: 4, - balance: 50, - block: 100, - }; - - assert_eq!( - [ - next_step(reading(11), Some(SUBMITTED), NOW), - next_step(reading(55), Some(SUBMITTED), NOW), - ], - [ - None, - Some(PlannedStep::Record(ConversionStep::Landed { landed: 45 })), - ] - ); - } - - // Resubmitting while the first transaction can still land would convert - // twice, so a submission is dropped only once it provably cannot: its era - // passed unincluded, or it was included and left the deposit in place, - // which counts as a refusal so a failing conversion is not paid for - // forever. - #[test] - fn a_submission_is_dropped_only_once_it_cannot_convert() { - let chains = |nonce, balance, block| Scripted { - landed: 10, - nonce, - balance, - block, - }; - let late = NOW + STALL_AFTER_MS + 1; - - assert_eq!( - [ - next_step(chains(4, 50, 164), Some(SUBMITTED), late), - next_step(chains(4, 50, 165), Some(SUBMITTED), NOW), - next_step(chains(5, 50, 100), Some(SUBMITTED), NOW), - next_step(chains(5, 1, 100), Some(SUBMITTED), NOW), - next_step(chains(5, 1, 100), Some(SUBMITTED), late), - ], - [ - None, - Some(PlannedStep::Record(ConversionStep::Dropped)), - Some(PlannedStep::Record(ConversionStep::Refused { - reason: "the conversion was included and failed".into(), - psm: None, - })), - None, - Some(PlannedStep::Record(ConversionStep::Stalled)), - ] - ); - } - - /// Balances per asset and account; anything else is empty. - struct AssetBalances(Vec<(DepositAsset, [u8; 32], u128)>); - - impl DepositBalances for AssetBalances { - fn balance<'a>( - &'a self, - asset: DepositAsset, - account: &'a [u8; 32], - ) -> BoxFuture<'a, Result> { - let balance = self - .0 - .iter() - .find(|(held, holder, _)| *held == asset && holder == account) - .map_or(0, |(_, _, balance)| *balance); - Box::pin(async move { Ok(balance) }) - } - } - - // A wrong or short deposit is only seen if every asset a deposit can - // arrive in is read, and a late one only if an expired session keeps - // being read; past the watch window it is left alone. - #[test] - fn the_watch_reads_every_deposit_asset_and_late_sessions_within_the_window() { - let storage = stub_platform(); - let registry = FundingRegistry::default(); - let usdc = DepositAsset::Asset(1337); - let now = NOW + crate::host_logic::funding::LATE_WATCH_MS; - let expired = |intent: &str, settled_at_ms, holder| FundingSession { - stage: FundingStage::Failed { - reason: FundingFailure::Expired, - settled_at_ms, - resume: None, - }, - deposit: Some(FundingDeposit { - account: holder, - ..converting_deposit() - }), - ..session(intent, settled_at_ms - DAY_MS) - }; - insert(®istry, storage.as_ref(), expired("fs_recent", NOW, account(1))); - insert(®istry, storage.as_ref(), expired("fs_old", NOW - 1, account(2))); - let balances = AssetBalances(vec![ - (DepositAsset::Native, account(1), 3), - (usdc, account(1), 9), - (usdc, account(2), 9), - ]); - - block_on(registry.observe_deposits( - storage.as_ref(), - now, - &balances, - &[DepositAsset::Native, usdc], - )) - .expect("observed"); - let holdings = |intent| { - registry - .get(intent) - .and_then(|session| session.deposit) - .map(|deposit| deposit.holdings) - }; - - assert_eq!( - (holdings("fs_recent"), holdings("fs_old")), - ( - Some(vec![ - DepositHolding { - asset: usdc, - balance: 9 - }, - DepositHolding { - asset: DepositAsset::Native, - balance: 3 - }, - ]), - Some(Vec::new()) - ) - ); - } - - // An ended session is only read for a late deposit, which can wait, so - // its account is read at the slower cadence rather than every pass. - #[test] - fn an_ended_session_is_read_at_the_slower_cadence() { - let storage = stub_platform(); - let registry = FundingRegistry::default(); - let usdc = DepositAsset::Asset(1337); - insert( - ®istry, - storage.as_ref(), - FundingSession { - stage: FundingStage::Failed { - reason: FundingFailure::Expired, - settled_at_ms: NOW, - resume: None, - }, - deposit: Some(converting_deposit()), - ..session("fs_ended", NOW - DAY_MS) - }, - ); - let read = |at, balance| { - let balances = AssetBalances(vec![(usdc, converting_deposit().account, balance)]); - block_on(registry.observe_deposits(storage.as_ref(), at, &balances, &[usdc])).expect("observed"); - registry - .get("fs_ended") - .and_then(|session| session.deposit) - .map(|deposit| deposit.held(usdc)) - }; - - assert_eq!( - [read(NOW + 1, 9), read(NOW + 2, 7), read(NOW + 1 + LATE_READ_MS, 7)], - [Some(9), Some(9), Some(7)] - ); - } - - // What did land on People is the user's even when the conversion took - // the deposit and less arrived than it should have: it is credited - // rather than the session failing, as getcash fails it. - #[test] - fn a_stalled_conversion_credits_what_did_land() { - let late = NOW + STALL_AFTER_MS + 1; - let chains = |landed| Scripted { - landed, - nonce: 5, - balance: 1, - block: 100, - }; - - assert_eq!( - [ - next_step(chains(30), Some(SUBMITTED), late), - next_step(chains(10), Some(SUBMITTED), late), - ], - [ - Some(PlannedStep::Record(ConversionStep::Landed { landed: 20 })), - Some(PlannedStep::Record(ConversionStep::Stalled)), - ] - ); - } - - // A session outlives a sign-out, so after switching identity the key on - // hand is not the deposit account's; signing with it would dry-run one - // account and pay from another. - #[test] - fn a_conversion_is_signed_only_by_the_deposit_account() { - let chains = || Scripted { - landed: 10, - nonce: 7, - balance: 50, - block: 100, - }; - let planned = |keys: Keys| { - block_on(plan_conversion(&chains(), &keys, &converting_deposit(), None, NOW)) - .expect("planned") - }; - - assert_eq!( - [planned(Keys(None)), planned(Keys(Some(keypair(2)))), planned(Keys(Some(keypair(1))))], - [ - None, - None, - Some(PlannedStep::Submit { - submission: ConversionSubmission { - nonce: 7, - people_before: 10, - ..SUBMITTED - }, - extrinsic: Vec::new(), - }), - ] - ); - } } diff --git a/rust/crates/truapi/src/runtime/funding/conversion.rs b/rust/crates/truapi/src/runtime/funding/conversion.rs deleted file mode 100644 index 0268fada45..0000000000 --- a/rust/crates/truapi/src/runtime/funding/conversion.rs +++ /dev/null @@ -1,2344 +0,0 @@ -//! Turning a funding deposit into CASH on People, the way getcash does it. -//! -//! One Asset Hub transaction, signed by the deposit account, converts the -//! deposit (nothing to do for CASH, a PSM mint for an approved stablecoin) and -//! teleports the CASH to the same account on People. Every fee is paid in the -//! deposited asset, since that is all the account holds. Before submitting, -//! the transaction is dry-run on Asset Hub and the message it forwards is -//! dry-run on People, so a conversion that would trap funds is never sent. - -use std::sync::Arc; - -use parity_scale_codec::{Decode, Encode}; -use subxt::client::OnlineClientAtBlock; -use subxt::config::substrate::SubstrateConfig; -use subxt::dynamic::{self, Value}; -use subxt::ext::scale_value::{Composite, ValueDef}; -use subxt::tx::ValidationResult; -use subxt::utils::Era; -use futures::future::BoxFuture; -use truapi::latest::{GenericError, TxPayloadExtension}; - -use crate::host_internal::extrinsic::{Sr25519Signer, build_signed_extrinsic_v4}; -use super::DepositBalances; -use super::credit::CLAIM_UNIT; -use crate::host_logic::funding::{ConversionRoute, DepositAsset, DepositQuote, FundingDeposit, PsmRefusal}; -use crate::runtime::statement_allowance::ChainContext; -use crate::runtime::statement_allowance::extension::{ChainState, Metadata as ExtensionMetadata}; - -/// XCM version every program and dry run uses. -const XCM_VERSION: u32 = 5; -/// Margin added to every fee estimate, in percent. -const FEE_MARGIN_PERCENT: u128 = 10; -/// Least CASH set aside to pay for execution on People. -const REMOTE_FEE_FLOOR: u128 = 1_000; -/// Share of the teleported CASH set aside for execution on People, in -/// percent. -const REMOTE_FEE_PERCENT: u128 = 1; -/// Mortality of a conversion transaction, in blocks. -const MORTAL_PERIOD_BLOCKS: u64 = 64; -/// PSM capacity a mint must leave spare, in percent of the amount. -const PSM_CAPACITY_MARGIN_PERCENT: u128 = 10; -/// Least PSM capacity a mint must leave spare, in CASH units. -const PSM_CAPACITY_MARGIN_FLOOR: u128 = 1_000_000; -/// Times a stablecoin swap's dry run is retried with its fee allowance -/// doubled. -const SWAP_ALLOWANCE_RETRIES: u32 = 2; -/// Headroom a pool swap's least output leaves below its quote, in percent. -const SWAP_SLIPPAGE_PERCENT: u128 = 5; -/// Parts per million in a `Permill`. -const PARTS_PER_MILLION: u128 = 1_000_000; - -/// Network constants the conversion needs that the chains do not state. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct FundingNetwork { - /// `Assets` pallet id of CASH on Asset Hub. - pub cash_asset_id: u32, -} - -/// Signs conversions with funding deposit accounts. -pub trait FundingSigner: Send + Sync { - /// The keypair of the `number`th deposit account for `source_id`, or - /// `None` while no signing session is active. - fn deposit_keypair( - &self, - source_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; -} - -/// Asset Hub and People, each pinned to its latest finalized block, with the -/// facts about them the programs need. -pub struct Chains { - asset_hub: OnlineClientAtBlock, - people: OnlineClientAtBlock, - places: Places, - /// Signed-extension metadata, loaded only when a conversion is signed. - extensions: Option>, -} - -/// A signed conversion, with what tells later whether it worked. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Prepared { - /// The signed transaction. - pub extrinsic: Vec, - /// Last Asset Hub block its mortal era admits it in. - pub valid_until_block: u64, - /// Least CASH it lands on People. - pub landing: u128, - /// Deposit it takes from the account on Asset Hub. - pub spent: u128, -} - -/// Fees a conversion pays in its deposit asset, margins included. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct Fees { - /// For the transaction itself. - dispatch: u128, - /// For executing the program on Asset Hub and delivering it to People. - allowance: u128, -} - -/// What signing a conversion needs. -#[derive(Clone, Copy)] -struct Signing<'a> { - extensions: &'a ExtensionMetadata, - signer: &'a Sr25519Signer, - nonce: u32, -} - -/// A signer whose signature only gives a quote's transaction its length. -fn quoting_signer() -> Sr25519Signer { - Sr25519Signer::from_keypair( - &schnorrkel::MiniSecretKey::from_bytes(&[1; 32]) - .expect("32 bytes are a mini secret") - .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519), - ) -} - -/// What a conversion pass reads and does on the chains. -pub trait ConversionChains: DepositBalances { - /// CASH `account` holds on People. - fn landed<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result>; - /// `account`'s next nonce on Asset Hub. - fn nonce<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result>; - /// The finalized Asset Hub block these reads are pinned to. - fn finalized_block(&self) -> u64; - /// Size, dry-run and sign the conversion of `deposit` at `nonce`. - fn prepare<'a>( - &'a self, - deposit: &'a FundingDeposit, - keypair: &'a schnorrkel::Keypair, - nonce: u32, - ) -> BoxFuture<'a, Result>; -} - -/// Where CASH, the chains and the deposit account sit, as the programs name -/// them. -#[derive(Debug, Clone, Copy)] -struct Places { - network: FundingNetwork, - asset_hub_para: u32, - people_para: u32, - assets_pallet: u8, -} - -/// Why a conversion pass could not go ahead. -#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] -pub enum ConversionError { - /// A dry run or the transaction pool refused the conversion. Counted, - /// since retrying the same conversion keeps failing. - #[display("refused: {_0}")] - Refused(String), - /// The PSM refused the mint, as getcash classes the refusal. - #[display("refused by the PSM: {reason}")] - PsmRefused { - /// Why, as the chain reported it. - reason: String, - /// How the PSM refused it. - refusal: PsmRefusal, - }, - /// The chains could not be read or reached. Retried on the next pass. - #[display("{_0}")] - Chain(String), -} - -fn chain(reason: impl core::fmt::Display) -> ConversionError { - ConversionError::Chain(reason.to_string()) -} - -impl Chains { - /// Pin both chains at their latest finalized blocks. - pub async fn at_finalized( - asset_hub: &subxt::OnlineClient, - people: &subxt::OnlineClient, - network: FundingNetwork, - signing: Option, - ) -> Result { - let asset_hub = asset_hub.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 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 - .metadata_ref() - .pallet_by_name("Assets") - .ok_or_else(|| chain("Asset Hub has no Assets pallet"))? - .call_index(); - Ok(Self { - asset_hub, - people, - places: Places { - network, - asset_hub_para, - people_para, - assets_pallet, - }, - extensions, - }) - } - - /// The route for `expected` of `asset`: a teleport for CASH, a PSM mint - /// for a stablecoin the PSM serves with room to spare. - pub async fn choose_route( - &self, - asset: DepositAsset, - expected: u128, - ) -> Result, ConversionError> { - if asset == DepositAsset::Asset(self.places.network.cash_asset_id) { - return Ok(Some(ConversionRoute::Teleport)); - } - if let DepositAsset::Asset(id) = asset - && let Some(psm) = self.psm(id).await? - && psm.serves(psm.to_internal(expected)) - { - return Ok(Some(ConversionRoute::Psm { - fee_ppm: psm.fee_ppm, - })); - } - // A pool path counts only if it swaps enough to pay for execution on - // People and still land something. - match self.swap(asset, expected).await { - Ok(swap) if swap.min_cash > 2 * REMOTE_FEE_FLOOR => Ok(Some(ConversionRoute::Pool)), - Ok(_) | Err(ConversionError::Refused(_)) => Ok(None), - Err(error) => Err(error), - } - } - - /// `asset` the pools take to return `cash`, through the native token for - /// a stablecoin, with the slippage headroom on each hop, or `None` - /// without a pool path. - async fn swap_in(&self, asset: DepositAsset, cash: u128) -> Result, ConversionError> { - let Some(native_in) = self - .pool_quote("quote_price_tokens_for_exact_tokens", &native(), &self.places.cash(), with_slippage_room(cash)) - .await? - else { - return Ok(None); - }; - match asset { - DepositAsset::Native => Ok(Some(native_in)), - DepositAsset::Asset(id) => { - self.pool_quote( - "quote_price_tokens_for_exact_tokens", - &self.places.asset_location(id), - &native(), - with_slippage_room(native_in), - ) - .await - } - } - } - - /// One `AssetConversionApi` price between `give` and `want`. - async fn pool_quote( - &self, - method: &str, - give: &Value, - want: &Value, - amount: u128, - ) -> Result, ConversionError> { - let quoted = call_api( - &self.asset_hub, - "AssetConversionApi", - method, - vec![give.clone(), want.clone(), Value::u128(amount), Value::bool(true)], - ) - .await?; - match variant_name("ed) { - Some("Some") => variant_fields("ed) - .and_then(|fields| fields.values().next()) - .map(as_u128) - .transpose(), - _ => Ok(None), - } - } - - /// The pool swap for `give` of `asset`, its least outputs the quotes less - /// the slippage headroom. - async fn swap(&self, asset: DepositAsset, give: u128) -> Result { - let no_pool = || ConversionError::Refused("no pool swaps the deposit into CASH".into()); - let min_native = match asset { - DepositAsset::Native => None, - DepositAsset::Asset(id) => Some(less_slippage( - self.pool_quote("quote_price_exact_tokens_for_tokens", &self.places.asset_location(id), &native(), give) - .await? - .ok_or_else(no_pool)?, - )), - }; - let min_cash = less_slippage( - self.pool_quote( - "quote_price_exact_tokens_for_tokens", - &native(), - &self.places.cash(), - min_native.unwrap_or(give), - ) - .await? - .ok_or_else(no_pool)?, - ); - Ok(PoolSwap { - from: asset, - min_native, - min_cash, - }) - } - - /// The route for a deposit of `asset` that credits at least `target` - /// CASH, with the deposit it takes: the target rounded up to what a - /// top-up claims, plus the CASH set aside to execute on People, the PSM - /// fee, the transaction fees with their margin, and the account's - /// minimum balance. - /// - /// The set-aside on People is the quote's cushion: what execution there - /// does not spend is refunded to the account and lands as CASH, so fees - /// that rise between the quote and the conversion still credit the - /// target. - pub async fn deposit_quote( - &self, - asset: DepositAsset, - target: u128, - ) -> Result, ConversionError> { - let too_large = || ConversionError::Refused("the amount is too large to quote".into()); - if target == 0 { - return Err(ConversionError::Refused("the amount to credit is zero".into())); - } - let send = cash_to_teleport(target).ok_or_else(too_large)?; - let psm = match asset { - DepositAsset::Asset(id) if id != self.places.network.cash_asset_id => { - self.psm(id).await?.map(|terms| (id, terms)) - } - _ => None, - }; - let minted = psm.and_then(|(id, terms)| { - let internal = psm_mint_in(send, terms.fee_ppm)?; - terms.serves(internal).then_some((id, terms, internal)) - }); - let (route, converter, converted) = if asset == DepositAsset::Asset(self.places.network.cash_asset_id) { - (ConversionRoute::Teleport, Converter::Teleport, send) - } else if let Some((id, terms, internal)) = minted { - let mint = PsmMint { - id, - terms, - max_fee_ppm: terms.fee_ppm, - }; - ( - ConversionRoute::Psm { - fee_ppm: terms.fee_ppm, - }, - Converter::Mint(mint), - terms.to_external(internal), - ) - } else { - let Some(given) = self.swap_in(asset, send).await? else { - return Ok(None); - }; - let swap = PoolSwap { - from: asset, - min_native: matches!(asset, DepositAsset::Asset(_)).then_some(1), - min_cash: send, - }; - (ConversionRoute::Pool, Converter::Swap(swap), given) - }; - let extensions = self - .extensions - .as_deref() - .ok_or_else(|| chain("signing metadata was not loaded"))?; - let signer = quoting_signer(); - let signing = Signing { - extensions, - signer: &signer, - nonce: 0, - }; - let fees = self - .estimate_fees(signing, &[0; 32], asset, converted, converter, false) - .await?; - let kept = self.min_balance(asset).await?; - let deposit = [fees.allowance, fees.dispatch, kept] - .into_iter() - .try_fold(converted, u128::checked_add) - .ok_or_else(too_large)?; - Ok(Some(DepositQuote { - asset, - route, - deposit, - })) - } - - /// Fees a conversion of about `converted` from `account` pays in its - /// deposit asset, with their margins: local execution from the program's - /// weight, dispatch from the transaction's length, and delivery to People. - /// - /// Delivery is priced on the message the transfer forwards as the - /// executor builds it, so a quote needs no funded account. When `funded`, - /// the draft is also dry-run and its real forwarded message priced, and - /// the dearer of the two counts, so a change in what the runtime forwards - /// cannot leave a conversion short. - async fn estimate_fees( - &self, - signing: Signing<'_>, - account: &[u8; 32], - asset: DepositAsset, - converted: u128, - converter: Converter, - funded: bool, - ) -> Result { - let fee_asset = self.places.deposit_location(asset); - let (withdrawn, allowance) = match funded { - true => (converted, converted / 5), - false => (converted.saturating_mul(2), converted), - }; - let draft = self - .measured(self.places.conversion_call(account, withdrawn, allowance, converter.drafted())?) - .await?; - let local = self.local_fee(&draft.program, &fee_asset).await?; - let cash = match converter { - Converter::Teleport => converted, - Converter::Mint(mint) => psm_mint_out(mint.terms.to_internal(converted), mint.terms.fee_ppm), - Converter::Swap(swap) => swap.min_cash, - }; - let forwarded = self.places.forwarded_to_people(account, cash); - let mut delivery = self.delivery_fee(&forwarded, &fee_asset).await?; - if funded { - let real = self.dry_run(account, &draft).await?; - delivery = delivery.max(self.delivery_fee(&real, &fee_asset).await?); - } - let extrinsic = self.sign( - signing.extensions, - signing.signer, - &draft, - &fee_asset, - signing.nonce, - )?; - let dispatch = self.dispatch_fee(&extrinsic, &fee_asset).await?; - Ok(Fees { - dispatch: with_margin(dispatch), - allowance: with_margin(local.saturating_add(delivery)), - }) - } - - /// The PSM's terms for minting CASH against asset `id`, if it lists it. - async fn psm(&self, id: u32) -> Result, ConversionError> { - let cash = self.places.cash(); - let external = self.places.asset_location(id); - let storage = self.asset_hub.storage(); - let Some(instance) = fetch_value( - &self.asset_hub, - "Psm", - "Psm", - vec![cash.clone()], - ) - .await? - else { - return Ok(None); - }; - let Some(listing) = fetch_value( - &self.asset_hub, - "Psm", - "ExternalAssets", - vec![cash.clone(), external.clone()], - ) - .await? - else { - return Ok(None); - }; - let max_debt = u128_at(&instance, "max_debt")?; - // An unset fee is the pallet's default, not zero: reading it as zero - // would cap every mint's fee at nothing and have the PSM refuse it. - let fee_ppm = as_u128( - &fetch_or_default( - &self.asset_hub, - "Psm", - "MintingFee", - vec![cash.clone(), external.clone()], - ) - .await?, - )?; - - let suffix = external_key_suffix(&self.asset_hub, &external)?; - let mut total_weight = 0u128; - let mut weight = 0u128; - let mut weights = storage - .iter( - dynamic::storage::<(Value, Value), Value>("Psm", "AssetCeilingWeight"), - (cash.clone(),), - ) - .await - .map_err(chain)?; - while let Some(entry) = weights.next().await { - let entry = entry.map_err(chain)?; - let value = as_u128(&entry.value().decode().map_err(chain)?)?; - total_weight += value; - if entry.key_bytes().ends_with(&suffix) { - weight = value; - } - } - let mut total_debt = 0u128; - let mut debt = 0u128; - let mut debts = storage - .iter( - dynamic::storage::<(Value, Value), Value>("Psm", "PsmDebt"), - (cash.clone(),), - ) - .await - .map_err(chain)?; - while let Some(entry) = debts.next().await { - let entry = entry.map_err(chain)?; - let value = as_u128(&entry.value().decode().map_err(chain)?)?; - total_debt += value; - if entry.key_bytes().ends_with(&suffix) { - debt = value; - } - } - let ceiling = max_debt - .saturating_mul(weight) - .checked_div(total_weight) - .unwrap_or(0); - let headroom = max_debt - .saturating_sub(total_debt) - .min(ceiling.saturating_sub(debt)); - Ok(Some(PsmTerms { - minting_enabled: variant_name(field(&listing, "status")?) == Some("AllEnabled"), - min_swap_amount: u128_at(&instance, "min_swap_amount")?, - internal_decimals: u8::try_from(u128_at(&instance, "internal_decimals")?) - .map_err(chain)?, - external_decimals: u8::try_from(u128_at(&listing, "decimals")?).map_err(chain)?, - fee_ppm: u32::try_from(fee_ppm).map_err(chain)?, - headroom, - })) - } - - /// `account`'s balance of `asset` on Asset Hub. - async fn asset_hub_balance( - &self, - asset: DepositAsset, - account: &[u8; 32], - ) -> Result { - let (pallet, keys) = match asset { - DepositAsset::Native => ("System", vec![Value::from_bytes(account)]), - DepositAsset::Asset(id) => ( - "Assets", - vec![Value::u128(id.into()), Value::from_bytes(account)], - ), - }; - let Some(entry) = fetch_value(&self.asset_hub, pallet, "Account", keys).await? else { - return Ok(0); - }; - match asset { - DepositAsset::Native => u128_at(field(&entry, "data")?, "free"), - DepositAsset::Asset(_) => u128_at(&entry, "balance"), - } - } - - /// The least balance of `asset` an Asset Hub account must keep. - async fn min_balance(&self, asset: DepositAsset) -> Result { - let DepositAsset::Asset(id) = asset else { - let deposit = self - .asset_hub - .metadata_ref() - .pallet_by_name("Balances") - .and_then(|pallet| pallet.constant_by_name("ExistentialDeposit")) - .ok_or_else(|| chain("Asset Hub declares no existential deposit"))? - .value(); - return u128::decode(&mut &deposit[..]).map_err(chain); - }; - let details = fetch_value(&self.asset_hub, "Assets", "Asset", vec![Value::u128(id.into())]) - .await? - .ok_or_else(|| chain(format!("asset {id} does not exist")))?; - u128_at(&details, "min_balance") - } - - /// CASH `account` holds on People. - async fn people_cash(&self, account: &[u8; 32]) -> Result { - let Some(entry) = fetch_value( - &self.people, - "Assets", - "Account", - vec![self.places.cash_on_people(), Value::from_bytes(account)], - ) - .await? - else { - return Ok(0); - }; - u128_at(&entry, "balance") - } - - /// `account`'s next nonce on Asset Hub. - async fn account_nonce(&self, account: &[u8; 32]) -> Result { - let nonce = call_api( - &self.asset_hub, - "AccountNonceApi", - "account_nonce", - vec![Value::from_bytes(account)], - ) - .await?; - u32::try_from(as_u128(&nonce)?).map_err(chain) - } - - async fn prepare_conversion( - &self, - deposit: &FundingDeposit, - keypair: &schnorrkel::Keypair, - nonce: u32, - ) -> Result { - let signer = Sr25519Signer::from_keypair(keypair); - let account = deposit.account; - let fee_asset = self.places.deposit_location(deposit.asset); - let held = self.asset_hub_balance(deposit.asset, &account).await?; - let kept = self.min_balance(deposit.asset).await?; - let spendable = held - .checked_sub(kept) - .ok_or_else(|| ConversionError::Refused("the deposit is below the minimum balance".into()))?; - let extensions = self - .extensions - .as_deref() - .ok_or_else(|| chain("signing metadata was not loaded"))?; - let converter = match (deposit.route, deposit.asset) { - (ConversionRoute::Teleport, _) => Converter::Teleport, - (ConversionRoute::Psm { fee_ppm }, DepositAsset::Asset(id)) => Converter::Mint(PsmMint { - id, - terms: self - .psm(id) - .await? - .ok_or_else(|| ConversionError::Refused("the PSM no longer lists the asset".into()))?, - max_fee_ppm: fee_ppm, - }), - (ConversionRoute::Psm { .. }, DepositAsset::Native) => { - return Err(ConversionError::Refused("the PSM mints only from assets".into())); - } - (ConversionRoute::Pool, asset) => Converter::Swap(PoolSwap { - from: asset, - min_native: matches!(asset, DepositAsset::Asset(_)).then_some(1), - min_cash: 2 * REMOTE_FEE_FLOOR, - }), - }; - - let signing = Signing { - extensions, - signer: &signer, - nonce, - }; - let fees = self - .estimate_fees(signing, &account, deposit.asset, spendable, converter, true) - .await?; - // A stablecoin swap moves its own pool before delivery is charged in - // that stablecoin, so a large one can need more than the allowance - // quoted beforehand: it retries with the allowance doubled, and - // what goes unspent is refunded to the account. - let retries = match converter { - Converter::Swap(PoolSwap { - min_native: Some(_), .. - }) => SWAP_ALLOWANCE_RETRIES, - _ => 0, - }; - let mut allowance = fees.allowance; - let mut first_refusal = None; - let (call, forwarded) = loop { - let given = spendable - .checked_sub(fees.dispatch) - .and_then(|left| left.checked_sub(allowance)) - .filter(|left| *left > 0); - let Some(given) = given else { - return Err(ConversionError::Refused( - first_refusal.unwrap_or_else(|| "the deposit does not cover the fees".into()), - )); - }; - // A swap's least outputs are quoted for what it actually gives, - // and never below what credits the session's target. - let converter = match converter { - Converter::Swap(_) => { - let mut swap = self.swap(deposit.asset, given).await?; - if let Some(floor) = deposit.target.and_then(cash_to_teleport) { - swap.min_cash = swap.min_cash.max(floor); - } - Converter::Swap(swap) - } - other => other, - }; - let call = self - .measured(self.places.conversion_call(&account, given + allowance, allowance, converter)?) - .await?; - match self.dry_run_detailed(&account, &call).await? { - Ok(forwarded) => break (call, forwarded), - Err(refusal) if refusal.fees_short && allowance < fees.allowance << retries => { - first_refusal.get_or_insert(refusal.reason); - allowance *= 2; - } - Err(DryRunRefusal { - reason, - psm: Some(refusal), - .. - }) => return Err(ConversionError::PsmRefused { reason, refusal }), - Err(refusal) => { - return Err(ConversionError::Refused(first_refusal.unwrap_or(refusal.reason))); - } - } - }; - self.dry_run_on_people(&forwarded).await?; - Ok(Prepared { - extrinsic: self.sign(extensions, &signer, &call, &fee_asset, nonce)?, - valid_until_block: self.asset_hub.block_number() + MORTAL_PERIOD_BLOCKS, - landing: call.landing, - spent: call.spent, - }) - } - - /// Validate a signed conversion against Asset Hub's transaction pool, - /// which is where fee payment in the deposit asset is checked, then - /// submit it. Only the pool's refusal is a refusal: a failed submit may - /// still have reached the chain. - pub async fn submit(&self, extrinsic: Vec) -> Result<(), ConversionError> { - let transaction = self.asset_hub.tx().from_bytes(extrinsic); - match transaction.validate().await.map_err(chain)? { - ValidationResult::Valid(_) => {} - invalid => { - return Err(ConversionError::Refused(format!( - "Asset Hub rejects the transaction: {invalid:?}" - ))); - } - } - transaction.submit().await.map(|_| ()).map_err(chain) - } - - /// Dry-run `call` from `account` on Asset Hub, returning the message it - /// forwards to People. - async fn dry_run( - &self, - account: &[u8; 32], - call: &ConversionCall, - ) -> Result { - self.dry_run_detailed(account, call) - .await? - .map_err(|refusal| ConversionError::Refused(refusal.reason)) - } - - /// [`Self::dry_run`], telling a refusal for want of fees apart, which is - /// the one a larger fee allowance can cure. - async fn dry_run_detailed( - &self, - account: &[u8; 32], - call: &ConversionCall, - ) -> Result, ConversionError> { - let refused = |reason: String| { - Ok(Err(DryRunRefusal { - reason, - fees_short: false, - psm: None, - })) - }; - let origin = Value::unnamed_variant( - "system", - [Value::unnamed_variant("Signed", [Value::from_bytes(account)])], - ); - let effects = ok(call_api( - &self.asset_hub, - "DryRunApi", - "dry_run_call", - vec![origin, call.runtime_call().value(), Value::u128(XCM_VERSION.into())], - ) - .await?)?; - let execution = field(&effects, "execution_result")?; - if variant_name(execution) != Some("Ok") { - let names = module_error_names(self.asset_hub.metadata_ref(), execution); - return Ok(Err(DryRunRefusal { - reason: format!("dry run failed on Asset Hub: {} ({execution})", names.join(" / ")), - fees_short: names.iter().any(|name| name == "NotHoldingFees"), - psm: psm_refusal(&names), - })); - } - let events = field(&effects, "emitted_events")?; - if mentions_variant(events, "AssetsTrapped") { - return refused("the conversion would trap assets on Asset Hub".into()); - } - let forwarded = field(&effects, "forwarded_xcms")?; - let to_people = items(forwarded).into_iter().find_map(|entry| { - let [destination, messages] = items(entry)[..] else { - return None; - }; - (parachain_of(destination) == Some(self.places.people_para)) - .then(|| items(messages).first().map(|message| (*message).clone())) - .flatten() - }); - match to_people { - Some(message) => Ok(Ok(message)), - None => refused("the conversion forwards nothing to People".into()), - } - } - - /// Dry-run the forwarded `message` on People as Asset Hub sends it. - async fn dry_run_on_people(&self, message: &Value) -> Result<(), ConversionError> { - let origin = versioned(location( - 1, - vec![junction("Parachain", Value::u128(self.places.asset_hub_para.into()))], - )); - let effects = ok(call_api( - &self.people, - "DryRunApi", - "dry_run_xcm", - vec![origin, message.clone()], - ) - .await?)?; - let outcome = field(&effects, "execution_result")?; - if variant_name(outcome) != Some("Complete") { - return Err(ConversionError::Refused(format!( - "the message would not complete on People: {outcome}" - ))); - } - if mentions_variant(field(&effects, "emitted_events")?, "AssetsTrapped") { - return Err(ConversionError::Refused("the conversion would trap assets on People".into())); - } - Ok(()) - } - - /// `call` with its execute's weight limit set to what its program weighs. - async fn measured(&self, call: ConversionCall) -> Result { - let measured = self.program_weight(&call.program).await?; - Ok(ConversionCall { - max_weight: measured, - ..call - }) - } - - async fn program_weight(&self, program: &[Value]) -> Result { - ok(call_api( - &self.asset_hub, - "XcmPaymentApi", - "query_xcm_weight", - vec![versioned(Value::unnamed_composite(program.to_vec()))], - ) - .await?) - } - - /// What executing `program` locally costs, in `fee_asset`. - async fn local_fee(&self, program: &[Value], fee_asset: &Value) -> Result { - let weight = self.program_weight(program).await?; - as_u128(&ok(call_api( - &self.asset_hub, - "XcmPaymentApi", - "query_weight_to_asset_fee", - vec![weight, versioned(fee_asset.clone())], - ) - .await?)?) - } - - /// What delivering `message` to People costs, in `fee_asset`. - async fn delivery_fee(&self, message: &Value, fee_asset: &Value) -> Result { - let people = versioned(location( - 1, - vec![junction("Parachain", Value::u128(self.places.people_para.into()))], - )); - let fees = ok(call_api( - &self.asset_hub, - "XcmPaymentApi", - "query_delivery_fees", - vec![people, message.clone(), versioned(fee_asset.clone())], - ) - .await?)?; - Ok(fungible_total(unversioned(&fees)?)) - } - - /// What dispatching `extrinsic` costs, in `fee_asset`. - async fn dispatch_fee(&self, extrinsic: &[u8], fee_asset: &Value) -> 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.asset_hub, - "TransactionPaymentApi", - "query_info", - vec![Value::from_bytes(&unprefixed), Value::u128(length.into())], - ) - .await?; - let native_fee = u128_at(&info, "partial_fee")?; - if fee_asset == &native() { - return Ok(native_fee); - } - let quoted = call_api( - &self.asset_hub, - "AssetConversionApi", - "quote_price_tokens_for_exact_tokens", - vec![fee_asset.clone(), native(), Value::u128(native_fee), Value::bool(true)], - ) - .await?; - let quoted = variant_fields("ed) - .filter(|_| variant_name("ed) == Some("Some")) - .and_then(|fields| fields.values().next()) - .ok_or_else(|| ConversionError::Refused("no pool prices the fee asset".into()))?; - as_u128(quoted) - } - - /// Sign `call` with the deposit account at `nonce`, mortal from this - /// block, paying fees in `fee_asset`. - fn sign( - &self, - extensions: &ExtensionMetadata, - signer: &Sr25519Signer, - call: &ConversionCall, - 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)) - } -} - -impl Places { - /// CASH as Asset Hub names it. - fn cash(&self) -> Value { - self.asset_location(self.network.cash_asset_id) - } - - /// An `Assets` pallet asset as Asset Hub names it. - fn asset_location(&self, id: u32) -> Value { - location( - 0, - vec![ - junction("PalletInstance", Value::u128(self.assets_pallet.into())), - junction("GeneralIndex", Value::u128(id.into())), - ], - ) - } - - /// The deposited asset as Asset Hub names it. - fn deposit_location(&self, asset: DepositAsset) -> Value { - match asset { - DepositAsset::Native => native(), - DepositAsset::Asset(id) => self.asset_location(id), - } - } - - /// CASH as People names it. - fn cash_on_people(&self) -> Value { - location( - 1, - vec![ - junction("Parachain", Value::u128(self.asset_hub_para.into())), - junction("PalletInstance", Value::u128(self.assets_pallet.into())), - junction("GeneralIndex", Value::u128(self.network.cash_asset_id.into())), - ], - ) - } - - /// The call converting `withdrawn` of `asset`, of which `allowance` pays - /// for local execution and delivery. - fn conversion_call( - &self, - account: &[u8; 32], - withdrawn: u128, - allowance: u128, - converter: Converter, - ) -> Result { - let converted = withdrawn - .checked_sub(allowance) - .ok_or_else(|| ConversionError::Refused("the fee allowance exceeds the deposit".into()))?; - let mut exchanges = Vec::new(); - let (cash, withdrawn_assets) = match converter { - Converter::Teleport => (converted, vec![self.asset(&self.cash(), withdrawn)]), - Converter::Swap(swap) => { - let from = self.deposit_location(swap.from); - let exchange = |give: Value, want: Value| { - Value::named_variant( - "ExchangeAsset", - [ - ("give", give), - ("want", Value::unnamed_composite([want])), - ("maximal", Value::bool(true)), - ], - ) - }; - let definite = |asset: Value| { - Value::unnamed_variant("Definite", [Value::unnamed_composite([asset])]) - }; - let cash_out = self.asset(&self.cash(), swap.min_cash); - match swap.min_native { - None => exchanges.push(exchange(definite(self.asset(&from, converted)), cash_out)), - Some(min_native) => { - exchanges.push(exchange( - definite(self.asset(&from, converted)), - self.asset(&native(), min_native), - )); - let all_native = Value::unnamed_variant( - "Wild", - [Value::named_variant( - "AllOf", - [("id", native()), ("fun", Value::unnamed_variant("Fungible", []))], - )], - ); - exchanges.push(exchange(all_native, cash_out)); - } - } - (swap.min_cash, vec![self.asset(&from, withdrawn)]) - } - Converter::Mint(mint) => { - let internal = mint.terms.to_internal(converted); - if internal < mint.terms.min_swap_amount { - return Err(ConversionError::Refused( - "the deposit is below the PSM's minimum swap after fees".into(), - )); - } - let minted = psm_mint_out(internal, mint.terms.fee_ppm); - // `Assets` must be sorted, and both sit under one pallet, so - // the general index decides the order. - let mut assets = vec![ - (mint.id, self.asset(&self.asset_location(mint.id), allowance)), - (self.network.cash_asset_id, self.asset(&self.cash(), minted)), - ]; - assets.sort_by_key(|(index, _)| *index); - (minted, assets.into_iter().map(|(_, asset)| asset).collect()) - } - }; - let remote_fee = remote_fee(cash); - if remote_fee >= cash { - return Err(ConversionError::Refused( - "the deposit does not cover the fee on People".into(), - )); - } - let beneficiary = beneficiary(account); - let everything = || { - Value::unnamed_variant( - "Wild", - [Value::unnamed_variant("AllCounted", [Value::u128(1)])], - ) - }; - let deposit_everything = || { - Value::named_variant( - "DepositAsset", - [ - ("assets", everything()), - ("beneficiary", beneficiary.clone()), - ], - ) - }; - let teleport = |filter: Value| Value::unnamed_variant("Teleport", [filter]); - let fee_asset = match converter { - Converter::Teleport => self.asset(&self.cash(), allowance), - Converter::Mint(mint) => self.asset(&self.asset_location(mint.id), allowance), - Converter::Swap(swap) => self.asset(&self.deposit_location(swap.from), allowance), - }; - let mut program = vec![ - Value::unnamed_variant("WithdrawAsset", [Value::unnamed_composite(withdrawn_assets)]), - Value::named_variant("PayFees", [("asset", fee_asset)]), - ]; - program.extend(exchanges); - program.extend([ - Value::named_variant( - "InitiateTransfer", - [ - ( - "destination", - location(1, vec![junction("Parachain", Value::u128(self.people_para.into()))]), - ), - ( - "remote_fees", - Value::unnamed_variant( - "Some", - [teleport(Value::unnamed_variant( - "Definite", - [Value::unnamed_composite([self.asset(&self.cash(), remote_fee)])], - ))], - ), - ), - ("preserve_origin", Value::bool(false)), - ("assets", Value::unnamed_composite([teleport(everything())])), - ( - "remote_xcm", - Value::unnamed_composite([ - Value::unnamed_variant("RefundSurplus", []), - deposit_everything(), - ]), - ), - ], - ), - Value::unnamed_variant("RefundSurplus", []), - deposit_everything(), - ]); - let mint = match converter { - Converter::Mint(mint) => Some(mint), - Converter::Teleport | Converter::Swap(_) => None, - }; - let mint_call = mint.map(|mint| { - RuntimeCall::new( - "Psm", - "mint", - vec![ - ("internal_asset", self.cash()), - ("external_asset", self.asset_location(mint.id)), - ("external_amount", Value::u128(converted)), - ("max_fee", Value::u128(mint.max_fee_ppm.into())), - ], - ) - }); - Ok(ConversionCall { - program, - mint: mint_call, - max_weight: weight(0, 0), - landing: cash - remote_fee, - spent: withdrawn, - }) - } - - /// The message a teleport of `cash` forwards to People, as Asset Hub's - /// executor builds it from the program: what prices its delivery before - /// a dry run can produce the real one. - fn forwarded_to_people(&self, account: &[u8; 32], cash: u128) -> Value { - let remote_fee = remote_fee(cash); - let on_people = |amount| self.asset(&self.cash_on_people(), amount); - versioned(Value::unnamed_composite([ - Value::unnamed_variant( - "ReceiveTeleportedAsset", - [Value::unnamed_composite([on_people(remote_fee)])], - ), - Value::named_variant("PayFees", [("asset", on_people(remote_fee))]), - Value::unnamed_variant( - "ReceiveTeleportedAsset", - [Value::unnamed_composite([on_people(cash.saturating_sub(remote_fee))])], - ), - Value::unnamed_variant("ClearOrigin", []), - Value::unnamed_variant("RefundSurplus", []), - Value::named_variant( - "DepositAsset", - [ - ( - "assets", - Value::unnamed_variant( - "Wild", - [Value::unnamed_variant("AllCounted", [Value::u128(1)])], - ), - ), - ("beneficiary", beneficiary(account)), - ], - ), - Value::unnamed_variant("SetTopic", [Value::from_bytes([0; 32])]), - ])) - } - - fn asset(&self, id: &Value, amount: u128) -> Value { - Value::named_composite([ - ("id", id.clone()), - ("fun", Value::unnamed_variant("Fungible", [Value::u128(amount)])), - ]) - } -} - -/// How a conversion turns the deposit into CASH before the teleport. -#[derive(Debug, Clone, Copy)] -enum Converter { - /// The deposit is CASH. - Teleport, - /// A PSM mint, batched ahead of the program. - Mint(PsmMint), - /// Pool swaps inside the program. - Swap(PoolSwap), -} - -impl Converter { - /// The converter a fee draft runs: a swap's least outputs set just above - /// what executing on People needs, since a draft is weighed and - /// dry-run, not executed for value, and any real deposit swaps for more. - fn drafted(self) -> Self { - match self { - Self::Swap(swap) => Self::Swap(PoolSwap { - min_native: swap.min_native.map(|_| 1), - min_cash: 2 * REMOTE_FEE_FLOOR, - ..swap - }), - other => other, - } - } -} - -/// Pool swaps from the deposit asset to CASH: straight for the native token, -/// through it for a stablecoin, each giving all of what it holds. -#[derive(Debug, Clone, Copy)] -struct PoolSwap { - /// The deposit asset given. - from: DepositAsset, - /// Least native token the first hop returns, for a stablecoin. - min_native: Option, - /// Least CASH the last hop returns. - min_cash: u128, -} - -/// A PSM mint ahead of the teleport. -#[derive(Debug, Clone, Copy)] -struct PsmMint { - /// The stablecoin minted against. - id: u32, - /// The PSM's current terms, which size the mint. - terms: PsmTerms, - /// The fee the route was chosen at, the most the mint may charge. - max_fee_ppm: u32, -} - -impl DepositBalances for Chains { - fn balance<'a>( - &'a self, - asset: DepositAsset, - account: &'a [u8; 32], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - super::within_chain_timeout(self.asset_hub_balance(asset, account)) - .await? - .map_err(|error| GenericError { - reason: error.to_string(), - }) - }) - } -} - -impl ConversionChains for Chains { - fn landed<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result> { - Box::pin(self.people_cash(account)) - } - - fn nonce<'a>(&'a self, account: &'a [u8; 32]) -> BoxFuture<'a, Result> { - Box::pin(self.account_nonce(account)) - } - - fn finalized_block(&self) -> u64 { - self.asset_hub.block_number() - } - - fn prepare<'a>( - &'a self, - deposit: &'a FundingDeposit, - keypair: &'a schnorrkel::Keypair, - nonce: u32, - ) -> BoxFuture<'a, Result> { - Box::pin(self.prepare_conversion(deposit, keypair, nonce)) - } -} - -/// The PSM's terms for one stablecoin. -#[derive(Debug, Clone, Copy)] -struct PsmTerms { - minting_enabled: bool, - min_swap_amount: u128, - internal_decimals: u8, - external_decimals: u8, - fee_ppm: u32, - /// CASH the PSM can still mint against this stablecoin. - headroom: u128, -} - -impl PsmTerms { - /// Whether the PSM mints `amount` CASH: minting is on, it is at least the - /// minimum swap, and it leaves the capacity margin spare. - fn serves(self, amount: u128) -> bool { - let margin = (amount * PSM_CAPACITY_MARGIN_PERCENT / 100).max(PSM_CAPACITY_MARGIN_FLOOR); - self.minting_enabled - && amount >= self.min_swap_amount - && self.headroom >= amount.saturating_add(margin) - } - - /// `amount` CASH in the stablecoin's units, rounded up. - fn to_external(self, amount: u128) -> u128 { - let (internal, external) = (u32::from(self.internal_decimals), u32::from(self.external_decimals)); - if external >= internal { - amount.saturating_mul(10u128.pow(external - internal)) - } else { - amount.div_ceil(10u128.pow(internal - external)) - } - } - - /// `amount` of the stablecoin in CASH units, rounded down. - fn to_internal(self, amount: u128) -> u128 { - let (internal, external) = (u32::from(self.internal_decimals), u32::from(self.external_decimals)); - if internal >= external { - amount.saturating_mul(10u128.pow(internal - external)) - } else { - amount / 10u128.pow(external - internal) - } - } -} - -/// CASH the PSM must be given, in CASH units, to mint `out` at `fee_ppm`, -/// or `None` when no amount does. -fn psm_mint_in(out: u128, fee_ppm: u32) -> Option { - let kept = PARTS_PER_MILLION.checked_sub(u128::from(fee_ppm)).filter(|kept| *kept > 0)?; - let mut amount = out.checked_mul(PARTS_PER_MILLION)?.div_ceil(kept); - while psm_mint_out(amount, fee_ppm) < out { - amount = amount.checked_add(1)?; - } - Some(amount) -} - -/// The least CASH to teleport so that at least `target` lands on People -/// after the CASH set aside to execute there, or `None` on overflow. -fn teleported_for(target: u128) -> Option { - let lands = |send: u128| send.saturating_sub(remote_fee(send)) >= target; - let mut send = target.checked_add( - target - .div_ceil(100 / REMOTE_FEE_PERCENT - 1) - .max(REMOTE_FEE_FLOOR), - )?; - while !lands(send) { - send = send.checked_add(1)?; - } - while send > target && lands(send - 1) { - send -= 1; - } - Some(send) -} - -/// The native token, as Asset Hub names it. -fn native() -> Value { - location(1, Vec::new()) -} - -/// `amount` less the slippage headroom: a swap's least output. -fn less_slippage(amount: u128) -> u128 { - let kept = 100 - SWAP_SLIPPAGE_PERCENT; - amount / 100 * kept + amount % 100 * kept / 100 -} - -/// An output to ask the pools for so that, less the slippage headroom, it -/// still covers `amount`. -fn with_slippage_room(amount: u128) -> u128 { - amount.saturating_mul(100).div_ceil(100 - SWAP_SLIPPAGE_PERCENT) -} - -/// CASH to teleport so that a top-up of `target`, rounded up to what one -/// claims, lands on People, or `None` on overflow. -fn cash_to_teleport(target: u128) -> Option { - teleported_for(target.div_ceil(CLAIM_UNIT).checked_mul(CLAIM_UNIT)?) -} - -/// Why a dry run refused a conversion, whether more fees would cure it, and -/// how the PSM refused it when it was the PSM. -#[derive(Debug)] -struct DryRunRefusal { - reason: String, - fees_short: bool, - psm: Option, -} - -/// The names a failed dispatch's module error decodes to through -/// `metadata`: the pallet, its error, then any enum inside it, such as the -/// XCM error a failed local execution carries. -fn module_error_names(metadata: &subxt::Metadata, execution: &Value) -> Vec { - let Some(module) = find_variant(execution, "Module") else { - return Vec::new(); - }; - let Some(pallet_index) = field(module, "index").ok().and_then(|index| as_u128(index).ok()) else { - return Vec::new(); - }; - let bytes: Vec = field(module, "error") - .map(|error| { - items(unwrap_newtype(error)) - .into_iter() - .filter_map(|byte| as_u128(byte).ok().and_then(|byte| u8::try_from(byte).ok())) - .collect() - }) - .unwrap_or_default(); - let Some(pallet) = u8::try_from(pallet_index) - .ok() - .and_then(|index| metadata.pallet_by_error_index(index)) - else { - return Vec::new(); - }; - let Some(variant) = bytes.first().and_then(|byte| pallet.error_variant_by_index(*byte)) else { - return Vec::new(); - }; - let mut names = vec![pallet.name().to_string(), variant.name.clone()]; - let mut cursor = 1; - for field in &variant.fields { - let Some(ty) = metadata.types().resolve(field.ty.id) else { - break; - }; - match &ty.type_def { - scale_info::TypeDef::Variant(inner) => { - if let Some(name) = bytes - .get(cursor) - .and_then(|byte| inner.variants.iter().find(|variant| variant.index == *byte)) - { - names.push(name.name.clone()); - } - cursor += 1; - } - _ => cursor += 1, - } - } - names -} - -/// How the PSM refused a dispatch whose module error decodes to `names`, if -/// it was the PSM. -fn psm_refusal(names: &[String]) -> Option { - match names { - [pallet, error, ..] if pallet == "Psm" => PsmRefusal::of(error), - _ => None, - } -} - -/// The first variant named `name` anywhere in `value`. -fn find_variant<'a>(value: &'a Value, name: &str) -> Option<&'a Value> { - match &value.value { - ValueDef::Variant(variant) if variant.name == name => Some(value), - ValueDef::Variant(variant) => variant.values.values().find_map(|inner| find_variant(inner, name)), - ValueDef::Composite(composite) => composite.values().find_map(|inner| find_variant(inner, name)), - _ => None, - } -} - -/// CASH set aside for execution on People out of `cash` teleported. -fn remote_fee(cash: u128) -> u128 { - (cash / 100 * REMOTE_FEE_PERCENT).max(REMOTE_FEE_FLOOR) -} - -/// CASH the PSM mints for `amount` in CASH units at `fee_ppm`. -fn psm_mint_out(amount: u128, fee_ppm: u32) -> u128 { - let fee = (amount * u128::from(fee_ppm)).div_ceil(PARTS_PER_MILLION); - amount.saturating_sub(fee) -} - -/// `fee` plus the fee margin, rounded up. -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) - } -} - -/// A conversion: the XCM program it executes, the PSM mint ahead of it if -/// any, the program's weight limit, and what it moves. -struct ConversionCall { - program: Vec, - mint: Option, - max_weight: Value, - /// Least CASH it lands on People. - landing: u128, - /// Deposit it takes from the account on Asset Hub. - spent: u128, -} - -impl ConversionCall { - /// The call to sign: the execute alone, or batched after the mint. - fn runtime_call(&self) -> RuntimeCall { - let execute = RuntimeCall::new( - "PolkadotXcm", - "execute", - vec![ - ("message", versioned(Value::unnamed_composite(self.program.clone()))), - ("max_weight", self.max_weight.clone()), - ], - ); - match &self.mint { - None => execute, - Some(mint) => RuntimeCall::new( - "Utility", - "batch_all", - vec![( - "calls", - Value::unnamed_composite([mint.value(), execute.value()]), - )], - ), - } - } -} - -/// A runtime call built from metadata names. -#[derive(Clone)] -struct RuntimeCall { - pallet: &'static str, - name: &'static str, - fields: Vec<(&'static str, Value)>, -} - -impl RuntimeCall { - fn new(pallet: &'static str, name: &'static str, fields: Vec<(&'static str, Value)>) -> Self { - Self { pallet, name, fields } - } - - /// The call as a `RuntimeCall` value. - fn value(&self) -> Value { - Value::unnamed_variant( - self.pallet, - [Value::named_variant(self.name, self.fields.clone())], - ) - } - - /// The call's SCALE bytes, encoded against `metadata`. - fn encode(&self, metadata: &subxt::Metadata) -> Result, ConversionError> { - subxt::ext::frame_decode::extrinsics::encode_call_data( - self.pallet, - self.name, - &Value::named_composite(self.fields.clone()), - metadata, - metadata.types(), - ) - .map_err(chain) - } -} - -/// `account` on the chain a message executes on. -fn beneficiary(account: &[u8; 32]) -> Value { - location( - 0, - vec![Value::named_variant( - "AccountId32", - [ - ("network", Value::unnamed_variant("None", [])), - ("id", Value::from_bytes(account)), - ], - )], - ) -} - -/// A location `parents` up with `junctions` below. -fn location(parents: u8, junctions: Vec) -> Value { - let interior = match junctions.len() { - 0 => Value::unnamed_variant("Here", []), - count => Value::unnamed_variant(format!("X{count}"), [Value::unnamed_composite(junctions)]), - }; - Value::named_composite([("parents", Value::u128(parents.into())), ("interior", interior)]) -} - -fn junction(name: &'static str, value: Value) -> Value { - Value::unnamed_variant(name, [value]) -} - -fn weight(ref_time: u64, proof_size: u64) -> Value { - Value::named_composite([ - ("ref_time", Value::u128(ref_time.into())), - ("proof_size", Value::u128(proof_size.into())), - ]) -} - -/// `value` as XCM version 5. -fn versioned(value: Value) -> Value { - Value::unnamed_variant(format!("V{XCM_VERSION}"), [value]) -} - -/// The value inside a versioned XCM type. -fn unversioned(value: &subxt::ext::scale_value::Value) -> Result<&subxt::ext::scale_value::Value, ConversionError> { - variant_fields(value) - .and_then(|fields| fields.values().next()) - .ok_or_else(|| chain("expected a versioned XCM value")) -} - -/// The SCALE bytes of `value` as the type of the first key of `pallet.item`. -fn encode_as(at: &OnlineClientAtBlock, value: &Value, pallet: &str, item: &str) -> Result, ConversionError> { - use subxt::ext::scale_encode::EncodeAsType; - let metadata = at.metadata_ref(); - let entry = metadata - .pallet_by_name(pallet) - .and_then(|pallet| pallet.storage()) - .and_then(|storage| storage.entry_by_name(item)) - .ok_or_else(|| chain(format!("{pallet}.{item} not in metadata")))?; - let key_type = entry - .keys() - .next() - .ok_or_else(|| chain(format!("{pallet}.{item} has no key")))? - .key_id; - value.encode_as_type(key_type, metadata.types()).map_err(chain) -} - -/// The SCALE bytes `external` contributes at the end of a `Psm` double-map -/// key: its `Blake2_128Concat` hash. -fn external_key_suffix(at: &OnlineClientAtBlock, external: &Value) -> Result, ConversionError> { - let encoded = encode_as(at, external, "Psm", "Psm")?; - Ok(super::super::statement_allowance::blake2_128_concat(&encoded)) -} - -/// The parachain id a versioned location names, if it is `../Parachain(id)`. -fn parachain_of(location: &subxt::ext::scale_value::Value) -> Option { - let location = unversioned(location).ok()?; - let interior = field(location, "interior").ok()?; - let junctions = variant_fields(interior)?.values().next()?; - let parachain = *items(junctions).first()?; - (variant_name(parachain) == Some("Parachain")) - .then(|| variant_fields(parachain)?.values().next().and_then(|id| as_u128(id).ok())) - .flatten() - .and_then(|id| u32::try_from(id).ok()) -} - -async fn parachain_id(at: &OnlineClientAtBlock) -> Result { - let id = fetch_value(at, "ParachainInfo", "ParachainId", Vec::new()) - .await? - .ok_or_else(|| chain("the chain names no parachain id"))?; - u32::try_from(as_u128(unwrap_newtype(&id))?).map_err(chain) -} - -async fn fetch_value( - at: &OnlineClientAtBlock, - pallet: &str, - item: &str, - keys: Vec, -) -> Result, ConversionError> { - let address = dynamic::storage::, Value>(pallet, item); - match at.storage().try_fetch(address, keys).await.map_err(chain)? { - Some(value) => value.decode().map(Some).map_err(chain), - None => Ok(None), - } -} - -/// The value of `pallet.item` at `keys`, or the default the runtime declares -/// for it when unset. -async fn fetch_or_default( - at: &OnlineClientAtBlock, - pallet: &str, - item: &str, - keys: Vec, -) -> Result { - let address = dynamic::storage::, Value>(pallet, item); - at.storage() - .fetch(address, keys) - .await - .map_err(chain)? - .decode() - .map_err(chain) -} - -async fn call_api( - at: &OnlineClientAtBlock, - api: &str, - method: &str, - args: Vec, -) -> Result { - at.runtime_apis() - .call(dynamic::runtime_api_call::<_, Value>(api, method, args)) - .await - .map_err(chain) -} - -/// The `Ok` side of a `Result` value, or a refusal naming the error. -fn ok(value: Value) -> Result { - match &value.value { - ValueDef::Variant(result) if result.name == "Ok" => result - .values - .values() - .next() - .cloned() - .ok_or_else(|| chain("empty Ok")), - _ => Err(ConversionError::Refused(value.to_string())), - } -} - -fn field<'a, T>( - value: &'a subxt::ext::scale_value::Value, - name: &str, -) -> Result<&'a subxt::ext::scale_value::Value, ConversionError> { - use subxt::ext::scale_value::At; - value.at(name).ok_or_else(|| chain(format!("missing field {name}"))) -} - -fn u128_at(value: &subxt::ext::scale_value::Value, name: &str) -> Result { - as_u128(field(value, name)?) -} - -/// `value` read through any single-field wrappers. -fn unwrap_newtype(mut value: &subxt::ext::scale_value::Value) -> &subxt::ext::scale_value::Value { - while let ValueDef::Composite(composite) = &value.value { - let mut values = composite.values(); - match (values.next(), values.next()) { - (Some(inner), None) => value = inner, - _ => break, - } - } - value -} - -fn as_u128(value: &subxt::ext::scale_value::Value) -> Result { - unwrap_newtype(value) - .as_u128() - .ok_or_else(|| chain(format!("expected a number, got {value}"))) -} - -fn variant_name(value: &subxt::ext::scale_value::Value) -> Option<&str> { - match &value.value { - ValueDef::Variant(variant) => Some(variant.name.as_str()), - _ => None, - } -} - -fn variant_fields(value: &subxt::ext::scale_value::Value) -> Option<&Composite> { - match &value.value { - ValueDef::Variant(variant) => Some(&variant.values), - _ => None, - } -} - -/// The direct items of a sequence, tuple or composite value. -fn items(value: &subxt::ext::scale_value::Value) -> Vec<&subxt::ext::scale_value::Value> { - match &value.value { - ValueDef::Composite(composite) => composite.values().collect(), - _ => Vec::new(), - } -} - -/// The sum of every `Fungible` amount anywhere in `value`. -fn fungible_total(value: &subxt::ext::scale_value::Value) -> u128 { - match &value.value { - ValueDef::Variant(variant) if variant.name == "Fungible" => variant - .values - .values() - .next() - .and_then(|amount| as_u128(amount).ok()) - .unwrap_or(0), - ValueDef::Variant(variant) => variant.values.values().map(fungible_total).sum(), - ValueDef::Composite(composite) => composite.values().map(fungible_total).sum(), - _ => 0, - } -} - -/// Whether any variant named `name` appears anywhere in `value`. -fn mentions_variant(value: &subxt::ext::scale_value::Value, name: &str) -> bool { - match &value.value { - ValueDef::Variant(variant) => { - variant.name == name || variant.values.values().any(|inner| mentions_variant(inner, name)) - } - ValueDef::Composite(composite) => composite.values().any(|inner| mentions_variant(inner, name)), - _ => false, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use frame_metadata::RuntimeMetadataPrefixed; - - const CASH: u32 = 50_000_413; - const PLACES: Places = Places { - network: FundingNetwork { - cash_asset_id: CASH, - }, - asset_hub_para: 1500, - people_para: 1502, - assets_pallet: 50, - }; - - fn asset_hub_metadata() -> subxt::Metadata { - let bytes = include_bytes!("../../../tests/fixtures/paseo-next-asset-hub-metadata.scale"); - let prefixed = RuntimeMetadataPrefixed::decode(&mut &bytes[..]).expect("fixture decodes"); - subxt::Metadata::try_from(prefixed).expect("fixture converts") - } - - // The program is built from names, not indices, so a renamed pallet, - // call, instruction or field fails here instead of on chain. - #[test] - fn a_teleport_encodes_as_an_asset_hub_xcm_execute() { - let metadata = asset_hub_metadata(); - let call = PLACES - .conversion_call(&[1; 32], 1_000_000, 100_000, Converter::Teleport) - .expect("sized") - .runtime_call(); - let pallet = metadata.pallet_by_name("PolkadotXcm").expect("pallet"); - let execute = pallet.call_variant_by_name("execute").expect("call"); - - let encoded = call.encode(&metadata).expect("encodes"); - - assert_eq!(encoded[..2], [pallet.call_index(), execute.index]); - } - - // The swaps are XCM `ExchangeAsset`s built from names, as getcash builds - // them: one hop for the native token, two through it for a stablecoin. - // Each encodes as an Asset Hub `PolkadotXcm.execute`. - #[test] - fn pool_swaps_encode_as_asset_hub_xcm_executes() { - let metadata = asset_hub_metadata(); - let program = |from, min_native| { - let call = PLACES - .conversion_call( - &[1; 32], - 10_000_000_000, - 100_000_000, - Converter::Swap(PoolSwap { - from, - min_native, - min_cash: 1_000_000, - }), - ) - .expect("sized"); - let shape: Vec<_> = call - .program - .iter() - .map(|instruction| { - let name = variant_name(instruction).unwrap_or_default().to_string(); - let exchange = (name == "ExchangeAsset").then(|| { - let fields = variant_fields(instruction).expect("fields"); - let give = fields.values().next().and_then(variant_name).map(str::to_string); - let maximal = fields.values().nth(2).map(|maximal| maximal.to_string()); - (give, maximal) - }); - (name, exchange) - }) - .collect(); - (call.runtime_call().encode(&metadata).map(|bytes| bytes[..2].to_vec()), shape) - }; - let pallet = metadata.pallet_by_name("PolkadotXcm").expect("pallet"); - let execute = vec![ - pallet.call_index(), - pallet.call_variant_by_name("execute").expect("call").index, - ]; - let step = |name: &str| (name.to_string(), None); - let exchange = |give: &str| { - ( - "ExchangeAsset".to_string(), - Some((Some(give.to_string()), Some("true".to_string()))), - ) - }; - let tail = [step("InitiateTransfer"), step("RefundSurplus"), step("DepositAsset")]; - - assert_eq!( - [ - program(DepositAsset::Native, None), - program(DepositAsset::Asset(1984), Some(1_000_000_000)), - ], - [ - ( - Ok(execute.clone()), - [vec![step("WithdrawAsset"), step("PayFees"), exchange("Definite")], tail.to_vec()] - .concat(), - ), - ( - Ok(execute), - [ - vec![ - step("WithdrawAsset"), - step("PayFees"), - exchange("Definite"), - exchange("Wild"), - ], - tail.to_vec(), - ] - .concat(), - ), - ] - ); - } - - // A swap's least output is its quote less the headroom, and asking the - // pools for `with_slippage_room` gives an output that, less the headroom, - // still covers what was wanted. A fee draft keeps a swap able to pay for - // People whatever its real outputs. - #[test] - fn slippage_room_covers_the_least_output_and_drafts_stay_payable() { - let drafted = Converter::Swap(PoolSwap { - from: DepositAsset::Asset(1984), - min_native: Some(77), - min_cash: 5, - }) - .drafted(); - let Converter::Swap(drafted) = drafted else { - panic!("a swap drafts as a swap"); - }; - - assert_eq!( - ( - less_slippage(2_000_000), - less_slippage(with_slippage_room(2_000_000)) >= 2_000_000, - (drafted.min_native, drafted.min_cash), - ), - (1_900_000, true, (Some(1), 2 * REMOTE_FEE_FLOOR)) - ); - } - - // Only a dry run that ran out of fees is worth retrying with more; the - // XCM error inside a failed local execution is what says so. - #[test] - fn a_failed_execution_names_its_xcm_error() { - let metadata = asset_hub_metadata(); - let index = metadata.pallet_by_name("PolkadotXcm").expect("pallet").error_index(); - let incomplete = metadata - .pallet_by_name("PolkadotXcm") - .and_then(|pallet| pallet.error_variants()) - .and_then(|variants| { - variants - .iter() - .find(|variant| variant.name == "LocalExecutionIncompleteWithError") - }) - .expect("variant") - .index; - let execution = Value::unnamed_variant( - "Err", - [Value::named_composite([( - "error", - Value::named_variant( - "Module", - [ - ("index", Value::u128(index.into())), - ( - "error", - Value::unnamed_composite( - [incomplete, 4, 19, 0].map(|byte| Value::u128(byte.into())), - ), - ), - ], - ), - )])], - ); - - assert_eq!( - module_error_names(&metadata, &execution), - ["PolkadotXcm", "LocalExecutionIncompleteWithError", "NotHoldingFees"] - ); - } - - - - // Sized from the getcash formulas: the PSM keeps its fee, rounded up, - // and every fee estimate gets a tenth more, rounded up. - #[test] - fn psm_and_fee_sizing_round_against_the_user() { - let six_to_six = PsmTerms { - minting_enabled: true, - min_swap_amount: 0, - internal_decimals: 6, - external_decimals: 6, - fee_ppm: 5_000, - headroom: 0, - }; - let eighteen_to_six = PsmTerms { - external_decimals: 18, - ..six_to_six - }; - - assert_eq!( - ( - psm_mint_out(1_000_000, 5_000), - psm_mint_out(1_001, 5_000), - with_margin(1_001), - six_to_six.to_internal(1_234_567), - eighteen_to_six.to_internal(1_234_567_000_000_999_999), - ), - (995_000, 995, 1_102, 1_234_567, 1_234_567) - ); - } - - // A quoted deposit must credit at least what the session asked for, or - // the user is short; each inverse is checked against the forward rule - // the conversion applies. - #[test] - fn sizing_inverts_the_conversion_rounding_up() { - let terms = PsmTerms { - minting_enabled: true, - min_swap_amount: 0, - internal_decimals: 6, - external_decimals: 18, - fee_ppm: 5_000, - headroom: 0, - }; - let teleport = |target: u128| { - let send = teleported_for(target).expect("sized"); - (send, send - remote_fee(send), send - 1 - remote_fee(send - 1)) - }; - let mint = |out: u128| { - let given = psm_mint_in(out, 5_000).expect("sized"); - (given, psm_mint_out(given, 5_000), psm_mint_out(given - 1, 5_000)) - }; - - assert_eq!( - ( - [teleport(10_000), teleport(2_000_000)], - [mint(995), mint(1_990_000)], - (terms.to_external(1), terms.to_internal(terms.to_external(1_234_567))), - (psm_mint_in(1, 1_000_000), teleported_for(u128::MAX)), - ), - ( - [(11_000, 10_000, 9_999), (2_020_202, 2_000_000, 1_999_999)], - [(1_000, 995, 994), (2_000_000, 1_990_000, 1_989_999)], - (1_000_000_000_000, 1_234_567), - (None, None), - ) - ); - } - - - // Without CASH left for execution on People the teleport would land - // nothing, so a deposit that small is refused before any dry run. - #[test] - fn a_deposit_too_small_for_the_fee_on_people_is_refused() { - let refused = PLACES - .conversion_call(&[1; 32], 1_500, 600, Converter::Teleport) - .map(|_| ()); - - assert_eq!( - refused, - Err(ConversionError::Refused( - "the deposit does not cover the fee on People".into() - )) - ); - } -} - -#[cfg(all(test, not(target_arch = "wasm32")))] -mod live { - //! Dry runs against Paseo Next, from accounts that already hold the - //! assets. Run with `cargo test -p truapi --lib funding::conversion::live -- --ignored`. - - use super::*; - - 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, - }; - const USDT: u32 = 1984; - - 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 asset_hub = client(ASSET_HUB).await; - let rpc = crate::runtime::statement_allowance::rpc::RpcClient::connect(ASSET_HUB) - .await - .expect("node reachable"); - let context = crate::runtime::statement_allowance::ChainContextCache::default() - .get(&crate::runtime::statement_allowance::ChainClient::new( - rpc, - asset_hub.genesis_hash().0, - )) - .await - .expect("metadata"); - Chains::at_finalized(&asset_hub, &client(PEOPLE).await, NETWORK, Some(context)) - .await - .expect("chains pinned") - } - - // getcash holds a mint the PSM will not serve at once and counts one it - // cannot serve now; any other pallet's error is not a PSM refusal. - // Classed by name, so a renamed PSM error fails here instead of being - // retried as an unknown one. - #[tokio::test] - #[ignore = "reads Paseo Next"] - async fn a_psm_error_is_classed_as_getcash_classes_it() { - let chains = chains().await; - let metadata = chains.asset_hub.metadata_ref(); - let module_error = |pallet: &str, error: &str| { - let pallet = metadata.pallet_by_name(pallet).expect("pallet"); - let variant = pallet - .error_variants() - .and_then(|variants| variants.iter().find(|variant| variant.name == error)) - .expect("variant") - .index; - let execution = Value::unnamed_variant( - "Err", - [Value::named_variant( - "Module", - [ - ("index", Value::u128(pallet.error_index().into())), - ("error", Value::unnamed_composite([variant, 0, 0, 0].map(|byte| Value::u128(byte.into())))), - ], - )], - ); - psm_refusal(&module_error_names(metadata, &execution)) - }; - - assert_eq!( - [ - module_error("Psm", "MintingStopped"), - module_error("Psm", "ExceedsMaxPsmDebt"), - module_error("Psm", "FeeTooHigh"), - module_error("Psm", "BelowMinimumSwap"), - module_error("Psm", "AllSwapsStopped"), - module_error("Psm", "AmountTooSmallAfterConversion"), - module_error("PolkadotXcm", "LocalExecutionIncompleteWithError"), - ], - [ - Some(PsmRefusal::Unavailable), - Some(PsmRefusal::Unavailable), - Some(PsmRefusal::WillNotServe), - Some(PsmRefusal::WillNotServe), - Some(PsmRefusal::Unavailable), - Some(PsmRefusal::WillNotServe), - None, - ] - ); - } - - /// An account holding at least `least` of asset `id` on Asset Hub. - async fn holder(chains: &Chains, id: u32, least: u128) -> [u8; 32] { - holder_between(chains, id, least, u128::MAX).await - } - - /// An account holding between `least` and `most` of asset `id`: a - /// deposit-sized balance, which a swap does not move the pool much for. - async fn holder_between(chains: &Chains, id: u32, least: u128, most: u128) -> [u8; 32] { - let mut entries = chains - .asset_hub - .storage() - .iter( - dynamic::storage::<(Value, Value), Value>("Assets", "Account"), - (Value::u128(id.into()),), - ) - .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 (least..=most).contains(&balance) { - let key = entry.key_bytes(); - return key[key.len() - 32..].try_into().expect("account id"); - } - } - panic!("no account holds {least} of asset {id}"); - } - - fn deposit(asset: DepositAsset, account: [u8; 32], route: ConversionRoute) -> FundingDeposit { - FundingDeposit { - source_id: "live".into(), - number: 1, - asset, - account, - expected: 0, - route, - target: None, - holdings: Vec::new(), - } - } - - #[tokio::test] - #[ignore = "reaches Paseo Next"] - async fn a_cash_deposit_teleports_to_people() { - let chains = chains().await; - let account = holder(&chains, NETWORK.cash_asset_id, 5_000_000).await; - let route = chains - .choose_route(DepositAsset::Asset(NETWORK.cash_asset_id), 1_000_000) - .await - .expect("route"); - let keypair = schnorrkel::MiniSecretKey::from_bytes(&[7; 32]) - .expect("seed") - .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519); - let prepared = chains - .prepare(&deposit(DepositAsset::Asset(NETWORK.cash_asset_id), account, ConversionRoute::Teleport), &keypair, 0) - .await; - assert_eq!((route, prepared.map(|_| ())), (Some(ConversionRoute::Teleport), Ok(()))); - } - - // A quote is made before any deposit exists, so its delivery fee is - // priced on a stand-in for the message the transfer forwards; it must - // cost what the real one does. - #[tokio::test] - #[ignore = "reaches Paseo Next"] - async fn the_quoted_delivery_fee_is_the_real_ones() { - let chains = chains().await; - let account = holder(&chains, NETWORK.cash_asset_id, 5_000_000).await; - let cash = chains.places.cash(); - let call = chains - .measured(chains.places.conversion_call(&account, 2_000_000, 200_000, Converter::Teleport).expect("sized")) - .await - .expect("measured"); - let forwarded = chains.dry_run(&account, &call).await.expect("dry run"); - - assert_eq!( - chains - .delivery_fee(&chains.places.forwarded_to_people(&account, 1_800_000), &cash) - .await, - chains.delivery_fee(&forwarded, &cash).await - ); - } - - #[tokio::test] - #[ignore = "reaches Paseo Next"] - async fn deposits_are_quoted_for_cash_and_usdt() { - let chains = chains().await; - let quote = |id| chains.deposit_quote(DepositAsset::Asset(id), 2_000_000); - let (cash, usdt) = (quote(NETWORK.cash_asset_id).await, quote(USDT).await); - eprintln!("quotes for 2 CASH: {cash:?} {usdt:?}"); - - assert!( - matches!(cash, Ok(Some(DepositQuote { route: ConversionRoute::Teleport, deposit, .. })) if deposit > 2_000_000) - && matches!(usdt, Ok(Some(DepositQuote { route: ConversionRoute::Psm { .. }, deposit, .. })) if deposit > 2_000_000), - "{cash:?} {usdt:?}" - ); - } - - // The dry runs above never see the signed extensions. Signed by an - // account with nothing to pay with, a well-formed transaction fails on - // payment alone; a mis-encoded extension or signature fails before that. - #[tokio::test] - #[ignore = "reaches Paseo Next"] - async fn a_signed_conversion_is_well_formed_down_to_its_fee_payment() { - let chains = chains().await; - let account = holder(&chains, NETWORK.cash_asset_id, 5_000_000).await; - let keypair = schnorrkel::MiniSecretKey::from_bytes(&[7; 32]) - .expect("seed") - .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519); - let extrinsic = chains - .prepare( - &deposit(DepositAsset::Asset(NETWORK.cash_asset_id), account, ConversionRoute::Teleport), - &keypair, - 0, - ) - .await - .expect("prepared") - .extrinsic; - let validity = chains - .asset_hub - .tx() - .from_bytes(extrinsic) - .validate() - .await - .expect("validates"); - assert_eq!( - format!("{validity:?}"), - format!("{:?}", ValidationResult::Invalid(subxt::tx::TransactionInvalid::Payment)) - ); - } - - /// An account holding at least `least` of the native token. - async fn native_holder(chains: &Chains, least: u128) -> [u8; 32] { - let mut entries = chains - .asset_hub - .storage() - .iter(dynamic::storage::<(Value,), Value>("System", "Account"), ()) - .await - .expect("accounts iterate"); - while let Some(entry) = entries.next().await { - let entry = entry.expect("entry reads"); - let value = entry.value().decode().expect("decodes"); - let free = u128_at(field(&value, "data").expect("data"), "free").expect("free"); - if free >= least { - let key = entry.key_bytes(); - return key[key.len() - 32..].try_into().expect("account id"); - } - } - panic!("no account holds {least} of the native token"); - } - - // The native token has no PSM pair, so it always swaps through the - // pool; a stablecoin swaps the same way, through the native token, when - // the PSM cannot serve it. - #[tokio::test] - #[ignore = "reaches Paseo Next"] - async fn native_and_stable_deposits_swap_through_the_pools_and_teleport() { - let chains = chains().await; - let keypair = schnorrkel::MiniSecretKey::from_bytes(&[7; 32]) - .expect("seed") - .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519); - let native_account = native_holder(&chains, 100_000_000_000).await; - let usdt_account = holder_between(&chains, USDT, 5_000_000, 50_000_000).await; - let route = chains.choose_route(DepositAsset::Native, 10_000_000_000).await; - let quote = chains.deposit_quote(DepositAsset::Native, 2_000_000).await; - eprintln!("native route {route:?}, quote {quote:?}"); - let native = chains - .prepare(&deposit(DepositAsset::Native, native_account, ConversionRoute::Pool), &keypair, 0) - .await - .map(|_| ()); - let stable = chains - .prepare(&deposit(DepositAsset::Asset(USDT), usdt_account, ConversionRoute::Pool), &keypair, 0) - .await - .map(|_| ()); - - assert_eq!( - (route, quote.map(|quote| quote.map(|quote| (quote.asset, quote.route))), native, stable), - ( - Ok(Some(ConversionRoute::Pool)), - Ok(Some((DepositAsset::Native, ConversionRoute::Pool))), - Ok(()), - Ok(()) - ) - ); - } - - #[tokio::test] - #[ignore = "reaches Paseo Next"] - async fn a_usdt_deposit_mints_through_the_psm_and_teleports() { - let chains = chains().await; - let account = holder(&chains, USDT, 5_000_000).await; - let route = chains - .choose_route(DepositAsset::Asset(USDT), 2_000_000) - .await - .expect("route"); - let Some(route) = route else { - panic!("the PSM does not serve USDT"); - }; - let keypair = schnorrkel::MiniSecretKey::from_bytes(&[7; 32]) - .expect("seed") - .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519); - let prepared = chains - .prepare(&deposit(DepositAsset::Asset(USDT), account, route), &keypair, 0) - .await; - assert_eq!(prepared.map(|_| ()), Ok(())); - } -} diff --git a/rust/crates/truapi/src/runtime/funding/credit.rs b/rust/crates/truapi/src/runtime/funding/credit.rs deleted file mode 100644 index 242d67d713..0000000000 --- a/rust/crates/truapi/src/runtime/funding/credit.rs +++ /dev/null @@ -1,446 +0,0 @@ -//! Crediting landed CASH into the user's balance, the way getcash does it. -//! -//! The host's top-up claims the CASH on the deposit account on People, given -//! the account's secret key. Each attempt has its own id, so a retried call -//! for the same attempt is answered `AlreadyExists` and never claims twice, -//! and each is sized from what the account holds when it starts. A claim -//! that falls short moves on to the next attempt; after the last, crediting -//! settles for what was claimed, or fails with the CASH still on the account -//! when nothing was, for a retry or the same key to claim later. - -use core::time::Duration; - -use futures::StreamExt; -use truapi::latest::{ - GenericError, HostPaymentTopUpError, HostPaymentTopUpRequest, - HostPaymentTopUpStatusSubscribeError, HostPaymentTopUpStatusSubscribeItem, PaymentTopUpSource, -}; - -use super::FundingSigner; -use crate::host_logic::funding::{CreditProgress, CreditStep, FundingDeposit}; -use crate::platform::{ProductContext, TopUpPlatform}; - -/// Smallest amount a top-up claims, in CASH units: what the account holds is -/// claimed rounded down to it. -pub const CLAIM_UNIT: u128 = 10_000; -/// How long one registered top-up may run before crediting times out. -const ATTEMPT_WINDOW_MS: u64 = 90 * 60 * 1_000; -/// How long crediting may take in all before it times out, so a claim that -/// never finalizes or a host that keeps answering busy cannot hold a session -/// open for good. -const CREDIT_DEADLINE_MS: u64 = 4 * ATTEMPT_WINDOW_MS; -/// Longest the host may take to report a top-up's current status. -const STATUS_TIMEOUT: Duration = Duration::from_secs(10); - -/// What crediting one session needs. -pub struct Credit<'a> { - /// The host's top-up. - pub top_up: &'a dyn TopUpPlatform, - /// Holds the deposit account's key. - pub signer: &'a dyn FundingSigner, - /// The funding product the top-ups are made as. - pub product: &'a ProductContext, -} - -impl Credit<'_> { - /// Decide the next step for a session crediting `deposit`'s CASH, given - /// its `progress` so far. `held` is the account's CASH on People, read - /// when the next top-up has yet to be sized. - /// - /// A sized top-up is kept before it is registered, so one registered - /// just before a restart is registered again for the same amount under - /// the same id rather than sized from what is left. What the host - /// reports is applied before any time limit, so a claim that finished - /// late still counts. - pub async fn plan( - &self, - deposit: &FundingDeposit, - progress: Option, - held: Option, - now_ms: u64, - ) -> Result, GenericError> { - let attempt = progress.map_or(0, |progress| progress.attempt); - let past_deadline = - progress.is_some_and(|progress| now_ms.saturating_sub(progress.started_ms) > CREDIT_DEADLINE_MS); - let claim = match progress.and_then(|progress| progress.claim) { - None if past_deadline => return Ok(Some(CreditStep::TimedOut)), - None => { - let Some(held) = held else { - return Ok(None); - }; - let amount = held - held % CLAIM_UNIT; - return Ok(Some(if amount == 0 { - CreditStep::Drained - } else { - CreditStep::Sized { amount } - })); - } - Some(claim) if !claim.registered && past_deadline => return Ok(Some(CreditStep::TimedOut)), - Some(claim) if !claim.registered => return self.register(deposit, attempt, claim.amount).await, - Some(claim) => claim, - }; - let status = self.status(deposit, attempt).await; - let terminal = match status { - Some(Ok(HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true })) => Some(CreditStep::Claimed { - claimed: claim.amount, - }), - Some(Ok(HostPaymentTopUpStatusSubscribeItem::ClaimedPartially { actual_claimed })) => { - Some(CreditStep::Short { - claimed: actual_claimed, - }) - } - Some(Ok(HostPaymentTopUpStatusSubscribeItem::NotClaimed)) => Some(CreditStep::Short { claimed: 0 }), - _ => None, - }; - if terminal.is_some() { - return Ok(terminal); - } - // As getcash, an attempt the host has not finished in its window - // times out whatever it last reported. - if past_deadline || now_ms.saturating_sub(claim.since_ms) > ATTEMPT_WINDOW_MS { - return Ok(Some(CreditStep::TimedOut)); - } - match status { - // Registered again under the same id; recording that changes - // nothing, so the attempt's window keeps running. - Some(Err(HostPaymentTopUpStatusSubscribeError::NotFound)) => { - self.register(deposit, attempt, claim.amount).await - } - _ => Ok(None), - } - } - - /// Ask the host to claim `amount` from the deposit account as `attempt`. - async fn register( - &self, - deposit: &FundingDeposit, - attempt: u8, - amount: u128, - ) -> Result, GenericError> { - let keypair = self - .signer - .deposit_keypair(&deposit.source_id, deposit.number)? - .filter(|keypair| keypair.public.to_bytes() == deposit.account); - let Some(keypair) = keypair else { - return Ok(None); - }; - // Canonical schnorrkel bytes, the form getcash converts its burner - // key to before handing it to the host's top-up. - let request = HostPaymentTopUpRequest { - into: None, - amount, - source: PaymentTopUpSource::PrivateKey { - sr25519_secret_key: keypair.secret.to_bytes(), - }, - id: top_up_id(&deposit.account, attempt), - }; - match self.top_up.top_up(self.product, request).await { - Ok(()) | Err(HostPaymentTopUpError::AlreadyExists) => { - Ok(Some(CreditStep::Registered)) - } - Err(HostPaymentTopUpError::InvalidSource) => Ok(Some(CreditStep::Refused { - reason: "the host refused the deposit account as a top-up source".into(), - })), - Err(HostPaymentTopUpError::SourceBusy | HostPaymentTopUpError::Unknown { .. }) => { - Ok(None) - } - } - } - - /// The current status of `attempt`, or `None` if the host reports none - /// in time. - async fn status( - &self, - deposit: &FundingDeposit, - attempt: u8, - ) -> Option> - { - let mut statuses = self - .top_up - .subscribe_top_up_status(self.product, top_up_id(&deposit.account, attempt)); - super::within_timeout(STATUS_TIMEOUT, statuses.next()) - .await - .ok() - .flatten() - } -} - -/// 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; - - use futures::executor::block_on; - use futures::stream::{self, BoxStream}; - - use super::*; - use crate::host_logic::funding::{Claim, ConversionRoute, DepositAsset}; - use crate::platform::async_trait; - - const NOW: u64 = 1_700_000_000_000; - - /// A top-up that answers fixed results and records every request. - struct Host { - accepts: Result<(), HostPaymentTopUpError>, - status: Option>, - requests: Mutex>, - } - - impl Host { - fn new( - accepts: Result<(), HostPaymentTopUpError>, - status: Option>, - ) -> Self { - Self { - accepts, - status, - requests: Mutex::new(Vec::new()), - } - } - - fn requests(&self) -> Vec<(u128, [u8; 32])> { - self.requests - .lock() - .expect("requests") - .iter() - .map(|request| (request.amount, request.id)) - .collect() - } - } - - #[async_trait] - impl TopUpPlatform for Host { - async fn top_up( - &self, - _product: &ProductContext, - request: HostPaymentTopUpRequest, - ) -> Result<(), HostPaymentTopUpError> { - self.requests.lock().expect("requests").push(request); - self.accepts.clone() - } - - fn subscribe_top_up_status( - &self, - _product: &ProductContext, - _id: [u8; 32], - ) -> BoxStream< - 'static, - Result, - > { - stream::iter(self.status.clone()).boxed() - } - } - - struct Keys(schnorrkel::Keypair); - - impl FundingSigner for Keys { - fn deposit_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 deposit() -> FundingDeposit { - FundingDeposit { - source_id: "usdt-assethub".into(), - number: 1, - asset: DepositAsset::Asset(1984), - account: keypair(1).public.to_bytes(), - expected: 2_000_000, - route: ConversionRoute::Psm { fee_ppm: 5_000 }, - target: None, - holdings: Vec::new(), - } - } - - fn plan( - host: &Host, - key: u8, - progress: Option, - held: Option, - now_ms: u64, - ) -> Option { - let product = ProductContext { - product_id: "fund.dot".into(), - execution_kind: Default::default(), - }; - let keys = Keys(keypair(key)); - let credit = Credit { - top_up: host, - signer: &keys, - product: &product, - }; - block_on(credit.plan(&deposit(), progress, held, now_ms)).expect("planned") - } - - fn claim(attempt: u8, amount: u128, registered: bool) -> Option { - Some(CreditProgress { - credited: 0, - attempt, - claim: Some(Claim { - amount, - since_ms: NOW, - registered, - }), - started_ms: NOW, - }) - } - - // The first top-up is the one getcash would make: what the account holds - // rounded down to the claim unit, kept before it is registered, then - // registered under the account's id, so a top-up the host already holds - // is not made twice. - #[test] - fn landed_cash_is_sized_then_claimed_once_under_the_accounts_id() { - let fresh = Host::new(Ok(()), None); - let known = Host::new(Err(HostPaymentTopUpError::AlreadyExists), None); - let account = deposit().account; - let sized = claim(0, 1_980_000, false); - - assert_eq!( - ( - plan(&fresh, 1, None, Some(1_987_654), NOW), - plan(&fresh, 1, sized, Some(5), NOW), - fresh.requests(), - plan(&known, 1, sized, None, NOW), - ), - ( - Some(CreditStep::Sized { amount: 1_980_000 }), - Some(CreditStep::Registered), - vec![(1_980_000, account)], - Some(CreditStep::Registered), - ) - ); - } - - // What the host reports decides the step, even past a time limit, so a - // claim that finished late still counts: a final claim of everything, a - // short one whose remainder the next attempt claims. An attempt the host - // has not finished in its window times out rather than starting another - // top-up that could claim alongside it. - #[test] - fn each_top_up_status_is_settled_as_getcash_settles_it() { - let status = |item| Host::new(Ok(()), Some(Ok(item))); - let finalized = status(HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true }); - let unfinalized = status(HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: false }); - let detecting = status(HostPaymentTopUpStatusSubscribeItem::Detecting); - let overdue = NOW + ATTEMPT_WINDOW_MS + 1; - let running = claim(0, 1_980_000, true); - - assert_eq!( - [ - plan(&finalized, 1, running, None, NOW + CREDIT_DEADLINE_MS + 1), - plan( - &status(HostPaymentTopUpStatusSubscribeItem::ClaimedPartially { - actual_claimed: 1_000_000, - }), - 1, - running, - None, - NOW, - ), - plan(&status(HostPaymentTopUpStatusSubscribeItem::NotClaimed), 1, running, None, NOW), - plan(&detecting, 1, running, None, NOW), - plan(&detecting, 1, running, None, overdue), - plan(&unfinalized, 1, running, None, NOW), - plan(&unfinalized, 1, running, None, overdue), - ], - [ - Some(CreditStep::Claimed { claimed: 1_980_000 }), - Some(CreditStep::Short { claimed: 1_000_000 }), - Some(CreditStep::Short { claimed: 0 }), - None, - Some(CreditStep::TimedOut), - None, - Some(CreditStep::TimedOut), - ] - ); - } - - // A top-up the host lost is registered again with the same amount under - // the same id, never re-sized from what is left after it may have - // claimed part. - #[test] - fn a_lost_top_up_is_registered_again_as_it_was() { - let lost = Host::new(Ok(()), Some(Err(HostPaymentTopUpStatusSubscribeError::NotFound))); - - assert_eq!( - (plan(&lost, 1, claim(0, 1_980_000, true), Some(10_000), NOW), lost.requests()), - (Some(CreditStep::Registered), vec![(1_980_000, deposit().account)]) - ); - } - - // A later attempt claims what is left on the account, read again, under - // its own id; with less left than a top-up claims there is nothing more - // to try, and without a reading nothing is sized. - #[test] - fn a_later_attempt_claims_what_is_left_under_its_own_id() { - let host = Host::new(Ok(()), None); - let account = deposit().account; - let second_id = sp_crypto_hashing::blake2_256(&[account.as_slice(), &1u32.to_le_bytes()].concat()); - let next = Some(CreditProgress { - credited: 1_000_000, - attempt: 1, - claim: None, - started_ms: NOW, - }); - let sized = next.map(|progress| CreditProgress { - claim: Some(Claim { - amount: 980_000, - since_ms: NOW, - registered: false, - }), - ..progress - }); - - assert_eq!( - ( - plan(&host, 1, next, Some(987_654), NOW), - plan(&host, 1, next, Some(CLAIM_UNIT - 1), NOW), - plan(&host, 1, next, None, NOW), - plan(&host, 1, sized, None, NOW), - host.requests(), - ), - ( - Some(CreditStep::Sized { amount: 980_000 }), - Some(CreditStep::Drained), - None, - Some(CreditStep::Registered), - vec![(980_000, second_id)], - ) - ); - } - - // A session outlives a sign-out; another identity's key must not be - // handed to the host as this account's. - #[test] - fn only_the_deposit_accounts_key_is_handed_to_the_host() { - let host = Host::new(Ok(()), None); - - assert_eq!( - (plan(&host, 2, claim(0, 1_980_000, false), None, NOW), host.requests()), - (None, Vec::new()) - ); - } -} diff --git a/rust/crates/truapi/src/runtime/pairing_host.rs b/rust/crates/truapi/src/runtime/pairing_host.rs index bf354beae4..663940814d 100644 --- a/rust/crates/truapi/src/runtime/pairing_host.rs +++ b/rust/crates/truapi/src/runtime/pairing_host.rs @@ -2563,13 +2563,6 @@ impl PairingHost { if session.sso.is_none() { return Err(AuthorityError::Disconnected); } - let funding = normalize_product_identifier(product_id) - .map_or(super::is_funding_product(product_id), |id| { - super::is_funding_product(&id) - }); - if funding { - return Err(AuthorityError::Rejected); - } let root_entropy_source = session .root_entropy_source diff --git a/rust/crates/truapi/src/runtime/signing_host.rs b/rust/crates/truapi/src/runtime/signing_host.rs index e48d944e7c..a4a8f196fe 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -25,7 +25,7 @@ mod sso_service; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use truapi::latest::{ - ChainIdentifier, DerivationIndex, GenericError, HostAccountCreateProofRequest, HostAccountGetAliasRequest, + ChainIdentifier, DerivationIndex, HostAccountCreateProofRequest, HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, ProductAccountId, RingLocation, RingLocationJunction, }; @@ -56,12 +56,10 @@ use crate::host_internal::sso_messages::{OnExistingAllowancePolicy, ProductReque use crate::host_internal::transaction::sign_extrinsic_payload; use crate::host_logic::entropy::derive_product_entropy; use crate::host_logic::features::genesis_for; -use crate::host_logic::funding::{FundingAccountKind, funding_keypair, funding_mini_secret}; use crate::host_logic::product_account::{ ProductAccountError, SR25519_SIGNING_CONTEXT, derivation_index_bytes, derive_identity_keypair, - derive_product_keypair, derive_product_subtree_keypair, - derive_ring_vrf_entropy, derive_root_keypair_from_entropy, funding_product_id, - personhood_product_id, + derive_product_keypair, derive_product_subtree_keypair, derive_ring_vrf_entropy, + derive_root_keypair_from_entropy, personhood_product_id, }; use crate::host_logic::product_account::{ derive_full_person_ring_vrf_entropy, derive_lite_person_ring_vrf_entropy, @@ -369,12 +367,6 @@ impl SigningHost { reason: err.to_string(), } })?; - // The AutoSigning allocation hands this secret to the calling - // product; under the funding product it would open every deposit - // account. - if super::is_funding_product(&product_id) { - return Err(AuthorityError::Rejected); - } derive_product_subtree_keypair(&root, &product_id) .map(|keypair| keypair.secret.to_bytes()) .map_err(product_authority_error) @@ -513,84 +505,6 @@ impl SigningHost { Ok(Some(subtree.public.to_bytes())) } - /// Public key of the `number`th funding account of `kind` for - /// `source_id`, under the reserved funding product. `None` while no - /// signing session is active. - /// - /// The account is the one getcash derives for the same label: the - /// funding product's entropy for it, taken as a mini secret. The core - /// credits what lands on it by handing its key to the host's top-up as a - /// `PrivateKey` source. - pub fn derive_funding_account( - &self, - kind: FundingAccountKind, - source_id: &str, - number: u32, - ) -> Result, AuthorityError> { - self.funding_keypair(kind, source_id, number) - .map(|keypair| keypair.map(|keypair| keypair.public.to_bytes())) - } - - /// Raw seed of the `number`th funding account of `kind` for `source_id`, - /// which a wallet imports to move the account's funds by hand. `None` - /// while no signing session is active, or while the active one does not - /// derive `deposit_account` as that number's deposit account. - pub fn funding_account_secret( - &self, - kind: FundingAccountKind, - source_id: &str, - number: u32, - deposit_account: &[u8; 32], - ) -> Result, AuthorityError> { - let entropy = match self.root_entropy() { - Ok(entropy) => entropy, - Err(AuthorityError::Disconnected) => return Ok(None), - Err(err) => return Err(err), - }; - let deposit = self.funding_keypair(FundingAccountKind::Deposit, source_id, number)?; - if deposit.is_none_or(|keypair| keypair.public.to_bytes() != *deposit_account) { - return Ok(None); - } - funding_mini_secret( - &entropy, - &funding_product_id(&self.network_suffix), - kind, - source_id, - number, - ) - .map(Some) - .map_err(|err| AuthorityError::Unavailable { - reason: err.to_string(), - }) - } - - /// Keypair of a funding account, for the core's own conversion and - /// crediting. Products never reach it: `derive_entropy` refuses the - /// funding product. - fn funding_keypair( - &self, - kind: FundingAccountKind, - source_id: &str, - number: u32, - ) -> Result, AuthorityError> { - let entropy = match self.root_entropy() { - Ok(entropy) => entropy, - Err(AuthorityError::Disconnected) => return Ok(None), - Err(err) => return Err(err), - }; - funding_keypair( - &entropy, - &funding_product_id(&self.network_suffix), - kind, - source_id, - number, - ) - .map(Some) - .map_err(|err| AuthorityError::Unavailable { - reason: err.to_string(), - }) - } - /// Derive the product-account keypair for `account` from the root entropy. /// /// The root keypair is recomputed per call (PBKDF2, 2048 rounds, via @@ -603,13 +517,12 @@ impl SigningHost { let entropy = self.root_entropy()?; let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; let owner = root.public.to_bytes(); - let product_id = normalize_product_identifier(&account.dot_ns_identifier) - .map_err(|err| AuthorityError::Unavailable { - reason: err.to_string(), + let product_id = + normalize_product_identifier(&account.dot_ns_identifier).map_err(|err| { + AuthorityError::Unavailable { + reason: err.to_string(), + } })?; - if super::is_funding_product(&product_id) { - return Err(AuthorityError::Rejected); - } derive_product_keypair( &root, &product_id, @@ -1089,9 +1002,6 @@ impl ProductAuthority for SigningHost { reason: err.to_string(), } })?; - if super::is_funding_product(&product_id) { - return Err(AuthorityError::Rejected); - } let entropy = self.root_entropy()?; let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; derive_product_subtree_keypair(&root, &product_id) @@ -1714,15 +1624,6 @@ impl ProductAuthority for SigningHost { context: &[u8], ) -> Result<[u8; 32], AuthorityError> { self.require_current_session(session)?; - // The funding accounts are the funding product's entropy for their - // labels, so it is never derived on a request's behalf. - let funding = normalize_product_identifier(product_id) - .map_or(super::is_funding_product(product_id), |id| { - super::is_funding_product(&id) - }); - if funding { - return Err(AuthorityError::Rejected); - } let entropy = self.root_entropy()?; derive_product_entropy(&entropy, product_id, context).map_err(|err| { AuthorityError::Unknown { @@ -1756,23 +1657,6 @@ fn product_authority_error(err: ProductAccountError) -> AuthorityError { } } -impl super::FundingSigner for SigningHost { - fn deposit_keypair( - &self, - source_id: &str, - number: u32, - ) -> Result, GenericError> { - self.funding_keypair(FundingAccountKind::Deposit, source_id, number) - .map_err(|err| GenericError { - reason: err.to_string(), - }) - } - - fn funding_product_id(&self) -> String { - funding_product_id(&self.network_suffix) - } -} - #[cfg(test)] mod tests { mod allowance_keys; @@ -1792,7 +1676,6 @@ mod tests { use super::TEST_NETWORK_SUFFIX; use super::ring_vrf::{MemberCandidate, ResolvedRing, RingResolver}; use super::{LocalActivation, RingVrfError, SR25519_SIGNING_CONTEXT}; - use crate::host_logic::funding::FundingAccountKind; use crate::host_internal::extrinsic::tests::split_v4; use crate::host_internal::sso_messages::ProductRequest; use crate::host_internal::transaction::{ @@ -3343,35 +3226,6 @@ mod tests { ); } - // A funding account is what getcash's derivation gives for its label: - // the funding product's `deriveEntropy`, taken as a mini secret. Since - // that entropy is the key, no request may derive it. - #[test] - fn a_funding_account_is_the_funding_products_entropy_for_its_label() { - let (_services, authority) = signing_runtime(); - let before = authority.derive_funding_account(FundingAccountKind::Deposit, "usdt-assethub", 1); - futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) - .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); - let funding_product = format!("fund.{TEST_NETWORK_SUFFIX}"); - - let entropy = crate::host_logic::entropy::derive_product_entropy( - &ENTROPY, - &funding_product, - b"onramp:eph:usdt-assethub:1", - ) - .expect("entropy"); - let expected = derive_root_keypair_from_entropy(&entropy).expect("key").public.to_bytes(); - assert_eq!( - ( - before, - authority.derive_funding_account(FundingAccountKind::Deposit, "usdt-assethub", 1), - authority.derive_entropy(&session, &funding_product, b"onramp:eph:usdt-assethub:1"), - ), - (Ok(None), Ok(Some(expected)), Err(AuthorityError::Rejected)) - ); - } - #[test] fn local_activation_exposes_the_uid_dot_identity_account() { let (_services, authority) = signing_runtime(); @@ -4098,50 +3952,6 @@ mod tests { assert_eq!(err, AuthorityError::Disconnected); } - // Every product path, the SSO responder's included, derives keys through - // these calls, AutoSigning's subtree secret among them, so refusing here - // keeps the funding accounts host-only. - #[test] - fn no_request_derives_a_funding_key() { - let (_services, authority) = signing_runtime(); - futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) - .expect("activation"); - let session = authority.current_session().expect("connected"); - let cx = CallContext::default(); - let request = v01::HostSignRawRequest { - account: v01::ProductAccountId { - dot_ns_identifier: "app.fund.dot".to_string(), - derivation_index: v01::DerivationIndex::Index(0), - }, - payload: v01::RawPayload::Bytes { - bytes: vec![1, 2, 3], - }, - }; - assert_eq!( - ( - futures::executor::block_on(authority.product_subtree_public_key( - &cx, - &session, - "fund.dot".to_string(), - )), - futures::executor::block_on(authority.sign_raw( - &cx, - &session, - None, - SignRawAuthorityRequest::Product(request), - true, - )) - .map(|_| ()), - authority.product_subtree_secret("fund.dot").map(|_| ()), - ), - ( - Err(AuthorityError::Rejected), - Err(AuthorityError::Rejected), - Err(AuthorityError::Rejected) - ) - ); - } - #[test] fn disconnect_clears_local_session() { let (_services, authority) = signing_runtime(); diff --git a/rust/crates/truapi/src/runtime/statement_allowance.rs b/rust/crates/truapi/src/runtime/statement_allowance.rs index 70a05b01c3..876e453b28 100644 --- a/rust/crates/truapi/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi/src/runtime/statement_allowance.rs @@ -21,7 +21,6 @@ mod test_fixtures; mod view; mod view_cache; -pub use key_hash::blake2_128_concat; pub use view::ViewFunctionError; use std::collections::HashMap; diff --git a/rust/crates/truapi/src/runtime/statement_allowance/extension.rs b/rust/crates/truapi/src/runtime/statement_allowance/extension.rs index 0383667288..d033dc41dd 100644 --- a/rust/crates/truapi/src/runtime/statement_allowance/extension.rs +++ b/rust/crates/truapi/src/runtime/statement_allowance/extension.rs @@ -733,6 +733,7 @@ impl Metadata { } /// The signed-extension identifiers, in metadata order. + #[cfg(test)] pub fn extension_ids(&self) -> Vec<&str> { self.extensions .iter() diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index ddbdffe9b8..686845f1af 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -2685,49 +2685,6 @@ fn a_top_up_with_a_malformed_key_is_refused_before_the_host_sees_it() { ); } -// The core credits funding deposits with top-ups made as the funding -// product, under ids anyone can work out from the deposit address. A product -// under that name could otherwise register them first or read their status. -#[test] -fn no_product_tops_up_or_follows_top_ups_as_the_funding_product() { - let services = funding_services(); - let engine = Arc::new(RecordingTopUpPlatform::default()); - assert!(services.install_top_up_platform(engine.clone())); - let host = funding_host(&services, "fund.dot", true); - let secret = schnorrkel::MiniSecretKey::from_bytes(&[7; 32]) - .expect("seed") - .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519) - .secret - .to_bytes(); - - let started = top_up( - &host, - v01::PaymentTopUpSource::PrivateKey { - sr25519_secret_key: secret, - }, - ); - let followed = futures::executor::block_on( - futures::executor::block_on(truapi::api::Payment::top_up_status_subscribe( - &host, - &CallContext::default(), - truapi::versioned::payment::HostPaymentTopUpStatusSubscribeRequest::V1( - v01::HostPaymentTopUpStatusSubscribeRequest { id: [7; 32] }, - ), - )) - .collect::>(), - ); - - assert_eq!( - ( - started, - followed, - engine.started.lock().expect("started mutex poisoned").len(), - engine.followed.lock().expect("followed mutex poisoned").len(), - ), - (Err(CallError::Denied), vec![Err(CallError::Denied)], 0, 0) - ); -} - #[test] fn a_top_up_needs_a_session() { let services = funding_services(); @@ -2784,35 +2741,6 @@ fn bare_localhost_product_allows_dev_product_accounts() { ); } -/// The funding product's accounts hold deposits in transit, so no product -/// signs with them: not one that registers the name, and not a development -/// product that may otherwise reach any account. -#[test] -fn no_product_reaches_the_funding_accounts() { - let registered = ProductRuntimeHost::new(stub_platform(), runtime_config("fund.dot"), test_spawner()); - let localhost = - ProductRuntimeHost::new(stub_platform(), runtime_config("localhost"), test_spawner()); - // The user would allow it, so only the guard can refuse. - let platform = StubPlatform { - account_access_confirmed: true, - ..StubPlatform::default() - }; - assert_eq!( - ( - account_target(®istered, "fund.dot"), - account_target(&localhost, "fund.dot"), - account_target(&localhost, "app.fund.dot"), - futures::executor::block_on(crate::runtime::account_access_authorization( - &platform, - "wallet.dot", - "fund.dot", - )) - .ok(), - ), - (None, None, None, Some(PermissionAuthorizationStatus::Denied)) - ); -} - /// A product destination reaches the platform as a `polkadot://` URL, whatever /// the product spelled it as. Asserting only that the call succeeded would not /// notice it arriving as `https://`. @@ -4376,25 +4304,6 @@ fn derive_entropy_matches_dotli_vector() { ); } -// Funding accounts are the funding product's entropy for their getcash -// labels, so a product under that name deriving entropy would hold their -// keys; a paired host refuses it as the signing host does. -#[test] -fn no_product_derives_entropy_as_the_funding_product() { - let host = ProductRuntimeHost::new(stub_platform(), runtime_config("fund.dot"), test_spawner()); - let mut session = sso_session_info(); - session.root_entropy_source = session_info().root_entropy_source; - install_pairing_session(&host, session); - let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { - context: b"onramp:eph:usdt-assethub:1".to_vec(), - }); - - assert_eq!( - futures::executor::block_on(host.derive(&CallContext::default(), request)), - Err(CallError::Denied) - ); -} - #[test] fn derive_entropy_requires_session() { let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); diff --git a/rust/crates/truapi/src/v01/funding.rs b/rust/crates/truapi/src/v01/funding.rs index 9bbca7da7a..3aad5a61aa 100644 --- a/rust/crates/truapi/src/v01/funding.rs +++ b/rust/crates/truapi/src/v01/funding.rs @@ -129,9 +129,6 @@ pub enum HostFundingStatusSubscribeItem { /// Amount moved before the failure. May be non-zero. moved: u128, }, - /// Inbound: the deposit arrived on chain and the host is converting it - /// into the user's balance. - Converting, } /// Error from [`crate::api::Funding::status_subscribe`].