From 95a0e3d85e2191dec97874c0b02027ec0e3b4095 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 5 Oct 2026 16:24:44 +0200 Subject: [PATCH] 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.