Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/funding-deposit-sizing.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 10 additions & 6 deletions rust/crates/truapi-host-cli/src/funding_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand All @@ -137,14 +137,18 @@ async fn open_and_assign(
runtime: &SigningHostRuntime,
assets: &FundingAssets,
asset: FundingAsset,
expected: u128,
amount: u128,
) -> Result<String> {
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,
Expand All @@ -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!(
" {}",
Expand Down
9 changes: 5 additions & 4 deletions rust/crates/truapi-host-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -674,15 +675,15 @@ async fn dispatch(
mnemonic,
network,
asset,
expected,
amount,
state_dir,
intent,
} => {
let check = funding_check::FundingCheck {
mnemonic,
network,
asset,
expected,
amount,
state_dir,
intent,
};
Expand Down
4 changes: 3 additions & 1 deletion rust/crates/truapi/RUNTIME.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<network suffix>`;
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
Expand Down
15 changes: 15 additions & 0 deletions rust/crates/truapi/src/host_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u128, v01::GenericError> {
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
Expand Down
58 changes: 58 additions & 0 deletions rust/crates/truapi/src/host_logic/funding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ pub struct FundingSession {
/// Where an inbound session's provider delivers, once the source is
/// known.
pub deposit: Option<FundingDeposit>,
/// 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<DepositQuote>,
}

/// 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.
Expand Down Expand Up @@ -186,6 +201,7 @@ impl FundingSession {
opened_at_ms: now_ms,
deadline_ms: now_ms.saturating_add(SESSION_WINDOW_MS),
deposit: None,
quote: None,
}
}

Expand Down Expand Up @@ -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<Result<ConversionRoute, u128>> {
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)
Expand Down Expand Up @@ -825,6 +858,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]
Expand Down
127 changes: 110 additions & 17 deletions rust/crates/truapi/src/runtime/funding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
Expand All @@ -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<FundingSessionError> for AssignDepositError {
fn from(error: FundingSessionError) -> Self {
Self::Session(error)
Expand Down Expand Up @@ -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(
Expand All @@ -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<Self>,
intent: &str,
asset: DepositAsset,
) -> Result<u128, AssignDepositError> {
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<DepositQuote, AssignDepositError> {
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<FundingNetwork, AssignDepositError> {
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<Self>) {
Expand Down
Loading
Loading