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-account.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@parity/truapi": patch
---

Signing hosts derive funding accounts under the reserved `fund.<network suffix>` product, labelled as getcash labels them, with a persisted counter per source (Rust API: `funding_account`, `next_funding_account_number`). No product can use accounts under `fund.<network suffix>`.
8 changes: 7 additions & 1 deletion rust/crates/truapi-codegen/tests/golden/host-callbacks.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions rust/crates/truapi/src/host_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,37 @@ impl SigningHostRuntime {
self.signing_host.set_grant_allowances_unchecked(granted);
}

/// Public key of the `number`th funding account of `kind` for
/// `source_id`, or `None` while no signing session is active. Number
/// accounts with [`Self::next_funding_account_number`].
pub fn funding_account(
&self,
kind: crate::host_logic::funding::FundingAccountKind,
source_id: &str,
number: u32,
) -> Result<Option<[u8; 32]>, v01::GenericError> {
self.signing_host
.derive_funding_account(kind, source_id, number)
.map_err(|err| v01::GenericError {
reason: err.to_string(),
})
}

/// Reserve the next account number for `source_id`, counting up from 1,
/// so no two sessions share an account.
pub async fn next_funding_account_number(
&self,
source_id: &str,
) -> Result<u32, v01::GenericError> {
self.services
.funding()
.next_account_number(self.services.platform.as_ref(), source_id)
.await
.map_err(|err| v01::GenericError {
reason: err.to_string(),
})
}

/// The product's hard-subtree public key, derived from the active session
/// root, or `None` while no session is active.
///
Expand Down
121 changes: 121 additions & 0 deletions rust/crates/truapi/src/host_logic/funding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
//! `Funding::status_subscribe`. A session always terminates, because the core
//! expires it on its own clock.

use std::collections::BTreeMap;

use parity_scale_codec::{Decode, Encode};
use tracing::warn;
use truapi::latest::{FundingDirection, FundingFailure, HostFundingStatusSubscribeItem};
Expand Down Expand Up @@ -124,6 +126,47 @@ impl FundingSession {
}
}

/// Which of a session's accounts under the reserved funding product.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FundingAccountKind {
/// Where an inbound provider delivers.
Deposit,
/// Where a crypto rail returns funds it could not deliver.
Refund,
/// Where an outbound session stages funds before paying the provider.
Withdrawal,
}

/// Why a funding account label could not be built.
#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)]
#[display("funding account label is longer than 32 bytes")]
pub struct FundingAccountLabelTooLong;

/// Derivation index of the `number`th account of `kind` for `source_id`: the
/// label `onramp:eph:<source>:<n>`, `onramp:rf:<source>:<n>` or
/// `wd:eph:<source>:<n>`, zero-padded to 32 bytes.
///
/// The labels are the ones getcash uses, and `number` counts up from 1 per
/// source, so every account can be found again from the seed alone.
pub fn funding_account_index(
kind: FundingAccountKind,
source_id: &str,
number: u32,
) -> Result<[u8; 32], FundingAccountLabelTooLong> {
let prefix = match kind {
FundingAccountKind::Deposit => "onramp:eph",
FundingAccountKind::Refund => "onramp:rf",
FundingAccountKind::Withdrawal => "wd:eph",
};
let label = format!("{prefix}:{source_id}:{number}");
let mut index = [0u8; 32];
index
.get_mut(..label.len())
.ok_or(FundingAccountLabelTooLong)?
.copy_from_slice(label.as_bytes());
Ok(index)
}

/// Why a session operation failed.
#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)]
pub enum FundingSessionError {
Expand Down Expand Up @@ -190,6 +233,41 @@ pub async fn store_sessions(
written.map_err(|err| FundingSessionError::Storage { reason: err.reason })
}

/// Reserve the next account number for `source_id`, counting up from 1.
///
/// Counters are never reset, so no two sessions on this device share an
/// account. A blob that does not decode is an error rather than a reset for
/// the same reason. Counters are per device: the same seed on a new install
/// starts from 1 again, so whoever hands an account to a provider must first
/// check it is empty on chain.
pub async fn next_account_number(
storage: &(impl CoreStorage + ?Sized),
source_id: &str,
) -> Result<u32, FundingSessionError> {
let storage_error = |reason: String| FundingSessionError::Storage { reason };
let mut counters = match storage
.read_core_storage(CoreStorageKey::FundingAccountCounters)
.await
.map_err(|err| storage_error(err.reason))?
{
Some(blob) => BTreeMap::<String, u32>::decode(&mut blob.as_slice())
.ok()
.filter(|counters| counters.encoded_size() == blob.len())
.ok_or_else(|| storage_error("funding account counters do not decode".into()))?,
None => BTreeMap::new(),
};
let counter = counters.entry(source_id.to_string()).or_default();
*counter = counter
.checked_add(1)
.ok_or_else(|| storage_error(format!("funding accounts for {source_id} exhausted")))?;
let number = *counter;
storage
.write_core_storage(CoreStorageKey::FundingAccountCounters, counters.encode())
.await
.map_err(|err| storage_error(err.reason))?;
Ok(number)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -306,6 +384,49 @@ mod tests {
assert_eq!(block_on(load_sessions(storage.as_ref())), Ok(Vec::new()));
}

// A reused number hands a second session an account that may still hold
// the first one's funds, so counters only ever move up, per source, and a
// counter blob that cannot be read stops funding instead of restarting.
#[test]
fn account_numbers_count_up_per_source_and_never_restart() {
let storage = stub_platform();
let next = |source: &str| block_on(next_account_number(storage.as_ref(), source));
let issued = [next("usdt"), next("usdt"), next("btc"), next("usdt")];
block_on(storage.write_core_storage(CoreStorageKey::FundingAccountCounters, vec![0xff]))
.expect("written");

assert_eq!(
(issued, next("usdt").is_err()),
([Ok(1), Ok(2), Ok(1), Ok(3)], true)
);
}

// Funds sit in these accounts, so an index that drifts between releases
// strands them. The bytes are pinned to the labels getcash uses.
#[test]
fn funding_account_indices_are_the_padded_getcash_labels() {
let padded = |label: &str| {
let mut index = [0u8; 32];
index[..label.len()].copy_from_slice(label.as_bytes());
index
};

assert_eq!(
[
funding_account_index(FundingAccountKind::Deposit, "usdt-assethub", 1),
funding_account_index(FundingAccountKind::Refund, "btc", 2),
funding_account_index(FundingAccountKind::Withdrawal, "dot-assethub", 3),
funding_account_index(FundingAccountKind::Deposit, "x".repeat(40).as_str(), 1),
],
[
Ok(padded("onramp:eph:usdt-assethub:1")),
Ok(padded("onramp:rf:btc:2")),
Ok(padded("wd:eph:dot-assethub:3")),
Err(FundingAccountLabelTooLong),
]
);
}

#[test]
fn storing_nothing_clears_the_slot() {
let storage = stub_platform();
Expand Down
10 changes: 10 additions & 0 deletions rust/crates/truapi/src/host_logic/product_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ pub const IDENTITY_LABEL: &str = "uid";
/// domain holds the full and light person keys; the product id is
/// `peopl.<network suffix>`, see [`personhood_product_id`].
pub const PERSONHOOD_LABEL: &str = "peopl";
/// Reserved dotNS label of the funding modality, under whose subtree every
/// funding session's deposit account lives; the product id is
/// `fund.<network suffix>`, see [`funding_product_id`].
pub const FUNDING_LABEL: &str = "fund";
const RING_VRF_ROOT_KEY: &[u8] = b"ring-vrf";

/// The reserved identity product id on the network with `network_suffix`:
Expand All @@ -47,6 +51,12 @@ pub fn personhood_product_id(network_suffix: &str) -> String {
format!("{PERSONHOOD_LABEL}.{network_suffix}")
}

/// The reserved funding product id on the network with `network_suffix`:
/// `fund.dot` on Polkadot, `fund.paseo` on paseo-next-v2.
pub fn funding_product_id(network_suffix: &str) -> String {
format!("{FUNDING_LABEL}.{network_suffix}")
}

/// Substrate sr25519 signing-context string. Shared by every sr25519 signature
/// the core produces: statement store, product raw signing, dotNS gateway.
pub const SR25519_SIGNING_CONTEXT: &[u8] = b"substrate";
Expand Down
10 changes: 10 additions & 0 deletions rust/crates/truapi/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1460,6 +1460,10 @@ pub enum CoreStorageKey {
/// as one SCALE blob.
#[codec(index = 13)]
FundingSessions,
/// Last funding account number handed out per source, as one SCALE blob.
/// Never reset, so no account is reused.
#[codec(index = 14)]
FundingAccountCounters,
}

/// Stable metadata describing one strictly decoded [`CoreStorageKey`].
Expand Down Expand Up @@ -1514,6 +1518,7 @@ pub fn describe_core_storage_key(
CoreStorageKey::SsoResponderRequestLedger { .. } => ("SsoResponderRequestLedger", None),
CoreStorageKey::ProductManifest { product_id } => ("ProductManifest", Some(product_id)),
CoreStorageKey::FundingSessions => ("FundingSessions", None),
CoreStorageKey::FundingAccountCounters => ("FundingAccountCounters", None),
};
Ok(CoreStorageKeyDescription { kind, product_id })
}
Expand Down Expand Up @@ -2582,6 +2587,11 @@ mod tests {
None,
),
(CoreStorageKey::FundingSessions, "FundingSessions", None),
(
CoreStorageKey::FundingAccountCounters,
"FundingAccountCounters",
None,
),
] {
let description = describe_core_storage_key(&key.encode()).expect("valid key");
assert_eq!(description.kind, kind);
Expand Down
1 change: 1 addition & 0 deletions rust/crates/truapi/src/platform/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,7 @@ fn core_key(key: &CoreStorageKey) -> String {
format!("core:product-manifest:{product_id}")
}
CoreStorageKey::FundingSessions => "core:funding-sessions".to_string(),
CoreStorageKey::FundingAccountCounters => "core:funding-account-counters".to_string(),
CoreStorageKey::AllowanceKeys { session_id } => {
format!("core:allowance-keys:{session_id}")
}
Expand Down
18 changes: 15 additions & 3 deletions rust/crates/truapi/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,10 @@ use web_time::Instant;
use crate::chain_runtime::RuntimeFailure;
use crate::host_internal::bulletin::preimage_key;
use crate::host_internal::permissions::{PermissionsService, TemporaryPermissions};
use crate::host_internal::product_manifest::Granted;
use crate::host_internal::product_manifest::{Granted, bare_product_label};
use crate::host_internal::sso_messages::RingVrfError;
use crate::host_logic::product_account::{
derivation_index_bytes, derive_product_public_key, public_key_from_address,
FUNDING_LABEL, derivation_index_bytes, derive_product_public_key, public_key_from_address,
};
use crate::host_logic::session::SessionInfo;
#[cfg(test)]
Expand Down Expand Up @@ -540,7 +540,9 @@ impl ProductRuntimeHost {
// them. Production hosts must reject localhost products before creating
// the product runtime.
if crate::platform::is_localhost_product_identifier(&product_id) {
return normalize_product_identifier(dot_ns_identifier).ok();
return normalize_product_identifier(dot_ns_identifier)
.ok()
.filter(|target| !is_funding_product(target));
}
// Bounded here rather than left to the lookup: it can reach dotNS on
// the Asset Hub, and a caller's own deadline is what decides how long
Expand All @@ -549,6 +551,7 @@ impl ProductRuntimeHost {
let cx = remote_authority_context(cx);
self.bounded_cross_product_scope_target(dot_ns_identifier, Granted::Context, &cx)
.await
.filter(|target| !is_funding_product(target))
}

/// Resolve the grant under the caller's deadline and cancellation, answering
Expand Down Expand Up @@ -854,11 +857,20 @@ impl ProductRuntimeHost {
}
}

/// Whether `product_id` is the reserved funding product or a subname of it.
/// Its accounts hold users' funds in transit, so only the host derives them.
fn is_funding_product(product_id: &str) -> bool {
bare_product_label(product_id) == FUNDING_LABEL
}

async fn account_access_authorization(
platform: &dyn Platform,
requesting_product_id: &str,
target_product_id: &str,
) -> Result<PermissionAuthorizationStatus, AccountAccessAuthorizationError> {
if is_funding_product(target_product_id) {
return Ok(PermissionAuthorizationStatus::Denied);
}
if requesting_product_id == target_product_id
|| crate::platform::normalizes_to_trusted_remote_permissions(requesting_product_id)
{
Expand Down
5 changes: 5 additions & 0 deletions rust/crates/truapi/src/runtime/capabilities/payment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,11 @@ impl Payment for ProductRuntimeHost {
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
Expand Down
14 changes: 13 additions & 1 deletion rust/crates/truapi/src/runtime/funding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ use truapi::latest::{FundingDirection, GenericError, HostFundingStatusSubscribeI

use super::services::RuntimeServices;
use crate::host_logic::funding::{
FundingSession, FundingSessionError, load_sessions, retained, store_sessions,
FundingSession, FundingSessionError, load_sessions, next_account_number, retained,
store_sessions,
};
use crate::platform::{
CoreStorage, FundingPlatform, FundingPresentOutcome, FundingPresentation, Platform,
Expand Down Expand Up @@ -134,6 +135,17 @@ impl FundingRegistry {
Ok(result)
}

/// Reserve the next account number for `source_id`, serialised with every
/// other funding write.
pub async fn next_account_number(
&self,
storage: &(impl CoreStorage + ?Sized),
source_id: &str,
) -> Result<u32, FundingSessionError> {
let _writes = self.writes.lock().await;
next_account_number(storage, source_id).await
}

/// Keep one task waiting on the earliest open deadline while any session
/// is open, so a session expires on time whether or not anyone asks. The
/// task ends once no session is open or the registry is dropped.
Expand Down
Loading
Loading