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

Funding sessions end `Delivered`: once the converted CASH lands on People, the core credits it through the host's top-up, with the deposit account's key as the source, and retries under a new id when a claim takes nothing. Products under `fund.<network suffix>` can neither start nor follow top-ups.
8 changes: 7 additions & 1 deletion rust/crates/truapi-host-cli/src/funding_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,13 @@ async fn follow(runtime: &SigningHostRuntime, intent: &str) -> Result<()> {
return Ok(());
}
FundingStage::Failed { reason, .. } => bail!("the session failed: {reason:?}"),
FundingStage::Open | FundingStage::Converting { .. } => {}
FundingStage::Delivered { credited, .. } => {
println!("credited {credited} CASH units to the balance");
return Ok(());
}
FundingStage::Open
| FundingStage::Converting { .. }
| FundingStage::Crediting { .. } => {}
}
tokio::time::sleep(POLL).await;
}
Expand Down
5 changes: 3 additions & 2 deletions rust/crates/truapi/RUNTIME.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,8 +411,9 @@ AutoSigning without approval. Legacy-account signing still asks the user.
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
account, paying fees in the deposited asset, after dry-running it on Asset
Hub and the message it forwards on People, and records the CASH that lands
on People.
Hub and the message it forwards on People. Once the CASH lands on People,
the core credits it through `TopUpPlatform` with the deposit account's key
as a `PrivateKey` source, and the session ends `Delivered`.
- `TopUpPlatform`: claim a top-up source's funds into the user's balance and
stream each top-up's status. Installed with `set_top_up_platform`. The core
requires a session and checks the source keys; the host owns claiming,
Expand Down
160 changes: 155 additions & 5 deletions rust/crates/truapi/src/host_logic/funding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ pub enum FundingStage {
/// CASH on People, in payment balance units.
landed: u128,
},
/// Inbound: the host's top-up is claiming the landed CASH into the
/// user's balance.
Crediting {
/// CASH on People, in payment balance units.
landed: u128,
/// Which top-up attempt is running, from 0.
attempt: u8,
/// When that attempt was registered, in Unix milliseconds.
since_ms: u64,
/// When the first attempt was registered, in Unix milliseconds.
started_ms: u64,
},
/// Inbound terminal success: the CASH is in the user's balance.
Delivered {
/// Amount credited, in payment balance units.
credited: u128,
/// When it was credited, in Unix milliseconds.
settled_at_ms: u64,
},
/// Ended without success.
Failed {
/// Why it ended.
Expand Down Expand Up @@ -176,9 +195,11 @@ impl FundingSession {
/// When the session ended, if it has.
pub fn settled_at_ms(&self) -> Option<u64> {
match self.stage {
FundingStage::Open | FundingStage::Converting { .. } | FundingStage::Converted { .. } => {
None
}
FundingStage::Open
| FundingStage::Converting { .. }
| FundingStage::Converted { .. }
| FundingStage::Crediting { .. } => None,
FundingStage::Delivered { settled_at_ms, .. } => Some(settled_at_ms),
FundingStage::Failed { settled_at_ms, .. } => Some(settled_at_ms),
}
}
Expand All @@ -194,8 +215,16 @@ impl FundingSession {
(FundingStage::Open, FundingDirection::Out) => {
HostFundingStatusSubscribeItem::AwaitingRelease
}
(FundingStage::Converting { .. } | FundingStage::Converted { .. }, _) => {
HostFundingStatusSubscribeItem::Converting
(
FundingStage::Converting { .. }
| FundingStage::Converted { .. }
| FundingStage::Crediting { .. },
_,
) => HostFundingStatusSubscribeItem::Converting,
(FundingStage::Delivered { credited, .. }, _) => {
HostFundingStatusSubscribeItem::Delivered {
credited: *credited,
}
}
(FundingStage::Failed { reason, .. }, _) => HostFundingStatusSubscribeItem::Failed {
reason: reason.clone(),
Expand Down Expand Up @@ -311,6 +340,100 @@ impl FundingSession {
}
}

/// A top-up attempt that is running.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CreditAttempt {
/// Which attempt, from 0.
pub attempt: u8,
/// When it was registered, in Unix milliseconds.
pub since_ms: u64,
/// When the first attempt was registered, in Unix milliseconds.
pub started_ms: u64,
}

impl FundingSession {
/// The deposit and landed CASH of a session awaiting or being credited,
/// with the attempt running, if any.
pub fn crediting(&self) -> Option<(&FundingDeposit, u128, Option<CreditAttempt>)> {
let deposit = self.deposit.as_ref()?;
match self.stage {
FundingStage::Converted { landed } => Some((deposit, landed, None)),
FundingStage::Crediting {
landed,
attempt,
since_ms,
started_ms,
} => Some((
deposit,
landed,
Some(CreditAttempt {
attempt,
since_ms,
started_ms,
}),
)),
_ => None,
}
}

/// Advance a session being credited by one step. Returns whether it
/// changed.
pub fn advance_credit(&mut self, step: CreditStep, now_ms: u64) -> bool {
let (landed, started_ms) = match self.stage {
FundingStage::Converted { landed } => (landed, now_ms),
FundingStage::Crediting {
landed, started_ms, ..
} => (landed, started_ms),
_ => return false,
};
match step {
CreditStep::Registered { attempt } => {
self.stage = FundingStage::Crediting {
landed,
attempt,
since_ms: now_ms,
started_ms,
};
true
}
CreditStep::Credited { credited } => {
self.stage = FundingStage::Delivered {
credited,
settled_at_ms: now_ms,
};
true
}
CreditStep::Abandoned { reason } => self.fail(
FundingFailure::Other {
code: "credit_failed".into(),
message: reason,
},
now_ms,
),
}
}
}

/// What one pass of crediting found or did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CreditStep {
/// The host accepted top-up `attempt`.
Registered {
/// Which attempt, from 0.
attempt: u8,
},
/// The top-up credited the user's balance.
Credited {
/// Amount credited, in payment balance units.
credited: u128,
},
/// Crediting cannot succeed; the CASH stays on the account on People.
Abandoned {
/// Why.
reason: String,
},
}

/// What one pass of a conversion found or did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConversionStep {
Expand Down Expand Up @@ -679,6 +802,33 @@ mod tests {
);
}

// Delivered is the one inbound success: it ends the session for
// subscribers and history, and the credited amount is what they see.
#[test]
fn a_credited_session_is_delivered() {
let mut session = converting();
session.advance_conversion(ConversionStep::Landed { landed: 49 }, NOW);
session.advance_credit(CreditStep::Registered { attempt: 0 }, NOW);
let crediting = session.crediting().map(|(_, landed, running)| (landed, running));
session.advance_credit(CreditStep::Credited { credited: 40 }, NOW + 1);

assert_eq!(
(crediting, session.wire_item(), session.settled_at_ms()),
(
Some((
49,
Some(CreditAttempt {
attempt: 0,
since_ms: NOW,
started_ms: NOW,
})
)),
HostFundingStatusSubscribeItem::Delivered { credited: 40 },
Some(NOW + 1),
)
);
}

// CASH on People is what the user is owed, so landing ends conversion
// whatever the submission state, and the subscriber keeps seeing
// converting until it is credited.
Expand Down
16 changes: 9 additions & 7 deletions rust/crates/truapi/src/runtime/capabilities/payment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,18 +177,18 @@ impl Payment for ProductRuntimeHost {
.services
.top_up_platform()
.ok_or(CallError::Unsupported)?;
if self.authority.current_session().is_none() {
// The core credits funding deposits through top-ups made as the
// funding product, so a product under that name could race or fake
// them.
if self.authority.current_session().is_none()
|| crate::runtime::is_funding_product(&self.product_id())
{
return Err(CallError::Denied);
}
let domain = |error| CallError::Domain(HostPaymentTopUpError::V1(error));
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 All @@ -209,7 +209,9 @@ impl Payment for ProductRuntimeHost {
let Some(platform) = self.services.top_up_platform() else {
return Subscription::interrupted(CallError::Unsupported);
};
if self.authority.current_session().is_none() {
if self.authority.current_session().is_none()
|| crate::runtime::is_funding_product(&self.product_id())
{
return Subscription::interrupted(CallError::Denied);
}
Subscription::new(Box::pin(
Expand Down
Loading
Loading