diff --git a/.changeset/funding-credit.md b/.changeset/funding-credit.md new file mode 100644 index 000000000..bb29c44a9 --- /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 3ee90f37a..884864f0f 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 e7136448b..0b43a08b6 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 28d5cba44..32aa0ed3e 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 ffa9a8fb7..b7de6cadb 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 834f1fa1b..b00717bfa 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 0911536f5..1fb81e7c3 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 000000000..d1a182d61 --- /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 6d196d7c9..1b1c7691c 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -517,9 +517,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, @@ -1735,6 +1735,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 00ca63d81..e9d00be04 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();