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.