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-check-cli.md
Original file line number Diff line number Diff line change
@@ -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).
199 changes: 199 additions & 0 deletions rust/crates/truapi-host-cli/src/funding_check.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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<FundingPresentOutcome, GenericError> {
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<String>,
}

/// Run `check` until its session lands CASH on People or fails.
pub async fn run(
check: FundingCheck,
build_runtime: impl FnOnce(NetworkConfig, PathBuf) -> Result<Arc<SigningHostRuntime>>,
) -> 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<String> {
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;
}
}
55 changes: 55 additions & 0 deletions rust/crates/truapi-host-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ mod chat;
mod contacts;
mod dotns_read;
mod frame_server;
mod funding_check;
mod network;
mod platform;
mod pocket;
Expand Down Expand Up @@ -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<String>,
},
/// Install the current stable release over this one.
///
/// Only works for a binary the installer put in place; a `cargo install`
Expand Down Expand Up @@ -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
}
}
}

Expand Down
9 changes: 9 additions & 0 deletions rust/crates/truapi/src/host_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<crate::host_logic::funding::FundingSession> {
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.
Expand Down
Loading