diff --git a/.changeset/funding-provider-battery.md b/.changeset/funding-provider-battery.md
new file mode 100644
index 0000000000..79bbc6b6d7
--- /dev/null
+++ b/.changeset/funding-provider-battery.md
@@ -0,0 +1,5 @@
+---
+"@parity/truapi": patch
+---
+
+`make e2e-funding-cli` also runs a provider worker: the scripted funding host hands `provide` sessions to the product that asked, completes the top-ups and payment requests it starts, and the battery resumes an inbound session after a host restart.
diff --git a/.changeset/funding-provider.md b/.changeset/funding-provider.md
new file mode 100644
index 0000000000..2e0350cf04
--- /dev/null
+++ b/.changeset/funding-provider.md
@@ -0,0 +1,5 @@
+---
+"@parity/truapi": major
+---
+
+New `FundingProvider` trait for a provider's worker: `serveSubscribe` receives the sessions assigned to it and cancel requests, `report` stores its progress on the session, and `presentFrame` shows one of its screens in a host frame. Sessions end as `Delivered` or `Released` from the claims of the top-ups, or the completion of the payment request, the provider names. Hosts assign a session with `select_funding_provider`, and must implement `present_provider_frame` on `FundingPlatform` (natively `NativeFundingCallbacks`).
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7f3646a2ea..a999387160 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -145,6 +145,56 @@ jobs:
cat "$RUNNER_TEMP/headless-smoke.log"
grep -q headless-install-ok "$RUNNER_TEMP/headless-smoke.log"
+ funding-battery:
+ name: Funding battery (CLI host)
+ needs: [changes, codegen]
+ if: needs.changes.outputs.funding_battery == 'true'
+ timeout-minutes: 30
+ runs-on: ubuntu-latest
+ env:
+ TRUAPI_HOST_NO_UPDATE: "1"
+ # A fixed development signer, so the host signs in without provisioning
+ # an account on chain. Funding needs a session, not a funded account.
+ HOST_CLI_SIGNER_MNEMONIC: "bottom drive obey lake curtain smoke basket hold race lonely fit walk"
+ BATTERY_PHASE_TIMEOUT: "600"
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # stable
+ with:
+ toolchain: stable
+
+ - uses: ./.github/actions/rust-cache
+
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 24
+
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ bun-version: "1.4.2"
+
+ - name: Download codegen output
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: codegen-output
+
+ - name: Isolate the host state
+ run: echo "TRUAPI_HOST_BASE_PATH=$RUNNER_TEMP/funding-host-state" >> "$GITHUB_ENV"
+
+ - name: Run the funding battery
+ run: make e2e-funding-cli
+
+ - name: Upload host logs and transcripts
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: funding-battery-logs
+ path: target/battery
+ retention-days: 14
+
wasm-provider:
name: truapi-provider (wasm browser tests)
timeout-minutes: 10
@@ -284,6 +334,7 @@ jobs:
sdk_provider_kotlin: ${{ steps.filter.outputs.sdk_provider_kotlin }}
host_android: ${{ steps.filter.outputs.host_android }}
cli_package: ${{ steps.filter.outputs.cli_package }}
+ funding_battery: ${{ steps.filter.outputs.funding_battery }}
workflows: ${{ steps.filter.outputs.workflows }}
needs_changeset: ${{ steps.filter.outputs.needs_changeset }}
adds_changeset: ${{ steps.filter.outputs.adds_changeset }}
@@ -322,6 +373,7 @@ jobs:
echo "sdk_provider_kotlin=true"
echo "host_android=true"
echo "cli_package=true"
+ echo "funding_battery=true"
echo "workflows=true"
} >> "$GITHUB_OUTPUT"
exit 0
@@ -357,6 +409,10 @@ jobs:
# source, and bundling it is a step no other job performs. A release
# is the wrong place to find out one of them moved.
gate cli_package '^(rust/crates/(truapi-host-cli|truapi|truapi-codegen|truapi-macros)/|js/container/|js/packages/truapi/(src/|package\.json$|tsconfig[^/]*\.json$)|Makefile$|nightly-toolchain$|Cargo\.toml$|Cargo\.lock$|package\.json$|package-lock\.json$|scripts/(codegen\.sh|build-cli-runner\.ts|cli-runner-package\.test\.ts)$|\.github/workflows/(ci|cli-package|release-cli)\.yml$)'
+ # The funding battery drives the core's funding and provider surface
+ # through the CLI host and the generated client, so it reads every
+ # crate the host is built from, the client, and the scripts it runs.
+ gate funding_battery '^(rust/crates/(truapi|truapi-host-cli|truapi-codegen|truapi-macros|truapi-provider|truapi-verifiable)/|js/packages/truapi/(src/|package\.json$|tsconfig[^/]*\.json$)|js/container/|playground/(package\.json|yarn\.lock)$|scripts/(battery|codegen)\.sh$|Makefile$|nightly-toolchain$|Cargo\.toml$|Cargo\.lock$|package\.json$|package-lock\.json$|\.github/workflows/ci\.yml$)'
# Include generators, dependency pins and package build configuration:
# these can change a published artifact without touching its sources.
@@ -1311,6 +1367,7 @@ jobs:
[
rust,
headless-install,
+ funding-battery,
wasm-provider,
licenses,
codegen,
@@ -1368,7 +1425,7 @@ jobs:
env:
NEEDS: ${{ toJSON(needs) }}
REQUIRED: >-
- rust headless-install wasm-provider licenses codegen ios-bindings changes ios-swift
+ rust headless-install funding-battery wasm-provider licenses codegen ios-bindings changes ios-swift
android-bindings provider-android-bindings host-android-bindings
host-android-detekt
cli-package ts-client
diff --git a/README.md b/README.md
index c6ffd30494..45749e9ba5 100644
--- a/README.md
+++ b/README.md
@@ -306,7 +306,7 @@ make e2e-signing-cli # same direct signing-host phase
make e2e-pairing-cli # same paired pairing-host phase
make e2e-chat-cli # chat content screening against a chat signing-host
make e2e-pocket-cli # Pocket protocol check against a Pocket signing-host
-make e2e-funding-cli # funding requests and statuses against a scripted funding host
+make e2e-funding-cli # funding requests and a provider worker against a scripted funding host
```
The Pocket phase runs its product as a Worker execution, the only execution
diff --git a/explorer/diagnosis-reports/funding/signing-host-cli.md b/explorer/diagnosis-reports/funding/signing-host-cli.md
index 549760394a..87b29f02a3 100644
--- a/explorer/diagnosis-reports/funding/signing-host-cli.md
+++ b/explorer/diagnosis-reports/funding/signing-host-cli.md
@@ -7,3 +7,11 @@
| `Funding/request_failed` | ✅ | |
| `Funding/request_dismissed` | ✅ | |
| `Funding/status_subscribe_unknown` | ✅ | |
+| `Funding/provider_assigned` | ✅ | |
+| `Funding/provider_present_frame` | ✅ | |
+| `Funding/provider_reports_forward_only` | ✅ | |
+| `Funding/provider_credits_through_top_up` | ✅ | |
+| `Funding/provider_out_released` | ✅ | |
+| `Funding/provider_cancel` | ✅ | |
+| `Funding/provider_resumes_after_restart` | ✅ | |
+| `Funding/provider_in_delivered` | ✅ | |
diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs
index 8ede859db6..eeb942a6b3 100644
--- a/rust/crates/truapi-codegen/src/rust.rs
+++ b/rust/crates/truapi-codegen/src/rust.rs
@@ -62,6 +62,7 @@ const TRAIT_MODULE_MAP: &[(&str, &str)] = &[
("Contacts", "contacts"),
("Entropy", "entropy"),
("Funding", "funding"),
+ ("FundingProvider", "funding_provider"),
("JsonRpc", "jsonrpc"),
("LocalStorage", "local_storage"),
("Payment", "payment"),
diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs
index 05e0c82801..3ba6ceddf3 100644
--- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs
+++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs
@@ -20,6 +20,7 @@ use truapi::api::{
Contacts,
Entropy,
Funding,
+ FundingProvider,
LocalStorage,
Locale,
Notifications,
@@ -58,6 +59,7 @@ where
register_contacts(dispatcher, host.clone());
register_entropy(dispatcher, host.clone());
register_funding(dispatcher, host.clone());
+ register_funding_provider(dispatcher, host.clone());
register_local_storage(dispatcher, host.clone());
register_locale(dispatcher, host.clone());
register_notifications(dispatcher, host.clone());
@@ -1401,6 +1403,117 @@ where
}
}
+fn register_funding_provider
(dispatcher: &mut Dispatcher, host: Arc
)
+where
+ P: FundingProvider + Send + Sync + 'static,
+{
+ {
+ let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker);
+ let host = host.clone();
+ dispatcher.on_subscription(wire_table::FUNDING_PROVIDER_SERVE_SUBSCRIBE, move |request_id: String, bytes: Vec| {
+ let host = host.clone();
+ Box::pin(async move {
+ let request: versioned::funding_provider::HostFundingServeSubscribeRequest = match DecodeAll::decode_all(&mut &bytes[..]) {
+ Ok(request) => request,
+ Err(err) => {
+ let error: truapi::CallError =
+ truapi::CallError::MalformedFrame { reason: err.to_string() };
+ return Err(subscription_interrupt(error));
+ }
+ };
+ let target_version = request.version();
+ let cx = CallContext::with_request_id(request_id);
+ if !execution_allowed {
+ let error: truapi::CallError = truapi::CallError::Denied;
+ return Err(subscription_interrupt(error));
+ }
+ let stream = host.serve_subscribe(&cx, request).await;
+ let stream = futures::StreamExt::map(
+ stream,
+ move |item: Result>| {
+ item.map(|item| {
+ ::from_latest(
+ truapi::versioned::IntoLatest::into_latest(item),
+ target_version,
+ )
+ })
+ .map_err(|error| downgrade_call_error(error, target_version))
+ },
+ );
+ Ok(subscription_stream(stream))
+ })
+ });
+ }
+ {
+ let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker);
+ let host = host.clone();
+ dispatcher.on_request(wire_table::FUNDING_PROVIDER_REPORT, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| {
+ let host = host.clone();
+ Box::pin(async move {
+ let request: versioned::funding_provider::HostFundingReportRequest = match DecodeAll::decode_all(&mut &bytes[..]) {
+ Ok(request) => request,
+ Err(err) => {
+ let error: truapi::CallError =
+ truapi::CallError::MalformedFrame { reason: err.to_string() };
+ let result: Result> = Err(error);
+ return result.encode();
+ }
+ };
+ let target_version = request.version();
+ let cx = CallContext::with_parts(request_id, cancel);
+ if !execution_allowed {
+ let error: truapi::CallError = truapi::CallError::Denied;
+ let result: Result> = Err(error);
+ return result.encode();
+ }
+ let result: Result> =
+ match host.report(&cx, request).await {
+ Ok(response) => Ok(::from_latest(
+ truapi::versioned::IntoLatest::into_latest(response),
+ target_version,
+ )),
+ Err(err) => Err(downgrade_call_error(err, target_version)),
+ };
+ result.encode()
+ })
+ });
+ }
+ {
+ let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker);
+ let host = host;
+ dispatcher.on_request(wire_table::FUNDING_PROVIDER_PRESENT_FRAME, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| {
+ let host = host.clone();
+ Box::pin(async move {
+ let request: versioned::funding_provider::HostFundingPresentFrameRequest = match DecodeAll::decode_all(&mut &bytes[..]) {
+ Ok(request) => request,
+ Err(err) => {
+ let error: truapi::CallError =
+ truapi::CallError::MalformedFrame { reason: err.to_string() };
+ let result: Result> = Err(error);
+ return result.encode();
+ }
+ };
+ let target_version = request.version();
+ let cx = CallContext::with_parts(request_id, cancel);
+ if !execution_allowed {
+ let error: truapi::CallError = truapi::CallError::Denied;
+ let result: Result> = Err(error);
+ return result.encode();
+ }
+ let result: Result> =
+ match host.present_frame(&cx, request).await {
+ Ok(response) => Ok(::from_latest(
+ truapi::versioned::IntoLatest::into_latest(response),
+ target_version,
+ )),
+ Err(err) => Err(downgrade_call_error(err, target_version)),
+ };
+ result.encode()
+ })
+ });
+ }
+}
+
fn register_local_storage(dispatcher: &mut Dispatcher, host: Arc
)
where
P: LocalStorage + Send + Sync + 'static,
diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts
index e9d62b8ef5..bf72b4ee56 100644
--- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts
+++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts
@@ -26,6 +26,7 @@ import {
} from "@parity/truapi";
import type {
+ FundingFrameOutcome,
GenericError,
HostChatCreateRoomRequest,
HostChatCreateRoomResponse,
@@ -1461,6 +1462,16 @@ export interface FundingPlatform {
session: FundingPresentation,
): Promise;
+ /**
+ * Show `provider`'s screen at `route` for session `intent`, such as its
+ * KYC or card entry, in a frame the host owns, and answer once it closes.
+ */
+ presentProviderFrame(
+ provider: ProductContext,
+ intent: string,
+ route: string,
+ ): Promise;
+
/**
* Observe a session's status change, for host UI such as the in-flight
* pill.
diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs
index 96976ddf4b..4ab1922e11 100644
--- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs
+++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs
@@ -42,7 +42,7 @@ pub enum WireKind {
/// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so
/// the debugger refuses to decode a frame whose contract differs from
/// its own, even when the coarse handshake codec version is unchanged.
-pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "8558b0cd19000411";
+pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "04b772501af2b00d";
/// Wire discriminants for `system_handshake`.
pub const SYSTEM_HANDSHAKE: MethodIds = MethodIds {
@@ -560,6 +560,24 @@ pub const FUNDING_STATUS_SUBSCRIBE: MethodIds = MethodIds {
method_id: 1,
};
+/// Wire discriminants for `funding_provider_serve_subscribe`.
+pub const FUNDING_PROVIDER_SERVE_SUBSCRIBE: MethodIds = MethodIds {
+ trait_id: 22,
+ method_id: 0,
+};
+
+/// Wire discriminants for `funding_provider_report`.
+pub const FUNDING_PROVIDER_REPORT: MethodIds = MethodIds {
+ trait_id: 22,
+ method_id: 1,
+};
+
+/// Wire discriminants for `funding_provider_present_frame`.
+pub const FUNDING_PROVIDER_PRESENT_FRAME: MethodIds = MethodIds {
+ trait_id: 22,
+ method_id: 2,
+};
+
/// The full wire table. Trait ids and per-trait method ordering are
/// part of the wire protocol; only ever append within a trait.
/// Removed methods leave their slot empty.
@@ -908,4 +926,16 @@ pub const WIRE_TABLE: &[WireEntry] = &[
method: "funding_status_subscribe",
kind: WireKind::Subscription(FUNDING_STATUS_SUBSCRIBE),
},
+ WireEntry {
+ method: "funding_provider_serve_subscribe",
+ kind: WireKind::Subscription(FUNDING_PROVIDER_SERVE_SUBSCRIBE),
+ },
+ WireEntry {
+ method: "funding_provider_report",
+ kind: WireKind::Request(FUNDING_PROVIDER_REPORT),
+ },
+ WireEntry {
+ method: "funding_provider_present_frame",
+ kind: WireKind::Request(FUNDING_PROVIDER_PRESENT_FRAME),
+ },
];
diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md
index d54ff85b5c..b8192506b1 100644
--- a/rust/crates/truapi-host-cli/README.md
+++ b/rust/crates/truapi-host-cli/README.md
@@ -774,10 +774,19 @@ Scripts under `js/scripts/` include:
The funding phase builds the host with `--features test-host` and installs
a scripted funding overlay: each request the product makes is answered with
the next outcome from `TRUAPI_FUNDING_OUTCOMES`
- (`deliver:900,release:500,fail,dismiss`), and a started session is settled
- through the core's test hook with no chain behind it. Every overlay request
- and session change is recorded in `TRUAPI_FUNDING_LOG`, which the cases read.
- The live on-ramp on Paseo Next is `truapi-host funding-check`.
+ (`deliver:900,release:500,fail,dismiss,provide,provide,provide-cancel`). A
+ `deliver`, `release` or `fail` session is settled through the core's test
+ hook with no chain behind it. A `provide` session is handed to the product
+ that asked, which runs as a Worker and serves it as the provider: it reports
+ progress, starts top-ups and payment requests, which scripted engines
+ complete in full and record in `TRUAPI_FUNDING_LEDGER`, and the core settles
+ the session from them. `provide-cancel` also cancels it. The phase runs the
+ host twice on the same storage, so the provider resumes an inbound session
+ after a restart. Every overlay request and session change is recorded in
+ `TRUAPI_FUNDING_LOG`, which the cases read. CI runs this phase on every
+ change to the core, the CLI or the client, with a fixed development signer
+ in `HOST_CLI_SIGNER_MNEMONIC` so the host needs no account provisioned on
+ chain.
Contacts are served on every phase, from `TRUAPI_CONTACTS`
(`alice=0x<32-byte account>;bob=0x…`) or, unset, from a two-name development
diff --git a/rust/crates/truapi-host-cli/js/funding-e2e.ts b/rust/crates/truapi-host-cli/js/funding-e2e.ts
index adc5c5ce00..07d1cd3a87 100644
--- a/rust/crates/truapi-host-cli/js/funding-e2e.ts
+++ b/rust/crates/truapi-host-cli/js/funding-e2e.ts
@@ -18,19 +18,20 @@ import type { DiagnosisRow } from "./diagnosis.ts";
// The cases make their requests in the order `scripts/battery.sh` scripts the
// host's outcomes: deliver:900, release:500, fail, dismiss.
-const WAIT_MS = 15_000;
+export const WAIT_MS = 15_000;
-interface TranscriptLine {
+export interface TranscriptLine {
kind: string;
intent?: string;
direction?: string;
amount?: string | null;
outcome?: string;
tag?: string;
+ route?: string;
}
/** What the host recorded. */
-function transcript(path: string): TranscriptLine[] {
+export function transcript(path: string): TranscriptLine[] {
if (!existsSync(path)) {
return [];
}
@@ -49,7 +50,7 @@ function transcript(path: string): TranscriptLine[] {
/** Every item a stream emits until it completes, or a rejection once
* `WAIT_MS` passes or the stream fails. */
-function untilComplete- (
+export function untilComplete
- (
stream: ObservableLike
- ,
what: string,
): Promise
- {
@@ -103,7 +104,7 @@ function interruption
- (
}
/** Wait until the host's transcript holds a line matching `predicate`. */
-async function waitForTranscript(
+export async function waitForTranscript(
path: string,
predicate: (line: TranscriptLine) => boolean,
what: string,
@@ -119,7 +120,7 @@ async function waitForTranscript(
}
}
-const stringify = (value: unknown) =>
+export const stringify = (value: unknown) =>
JSON.stringify(value, (_, inner) =>
typeof inner === "bigint" ? inner.toString() : inner,
);
diff --git a/rust/crates/truapi-host-cli/js/funding-provider-e2e.ts b/rust/crates/truapi-host-cli/js/funding-provider-e2e.ts
new file mode 100644
index 0000000000..34d8c2e980
--- /dev/null
+++ b/rust/crates/truapi-host-cli/js/funding-provider-e2e.ts
@@ -0,0 +1,338 @@
+// End-to-end check of a provider worker serving funding sessions, against a
+// host built with `--features test-host` whose scripted overlay answers
+// `provide` or `provide-cancel`: it hands the session to the product that
+// asked, so this script is both the product requesting funds and the
+// provider's worker serving the request.
+//
+// The run is split across a host restart. `runProviderStart` takes an
+// inbound session as far as its top-up, and `runProviderResume`, in a new host
+// process on the same storage, checks the session is handed over again and
+// lets the core deliver it from the claim. Top-ups and payments go to the
+// host's scripted engines, which complete them in full.
+import type {
+ ObservableLike,
+ TrUApiClient,
+} from "../../../../js/packages/truapi/src/index.ts";
+import type {
+ FundingUpdate,
+ HostFundingServeSubscribeItem,
+ HostFundingStatusSubscribeItem,
+} from "../../../../js/packages/truapi/src/generated/types.ts";
+import type { DiagnosisRow } from "./diagnosis.ts";
+import {
+ WAIT_MS,
+ stringify,
+ untilComplete,
+ waitForTranscript,
+} from "./funding-e2e.ts";
+
+/** What the first run leaves for the run after the restart. */
+export interface ProviderState {
+ intent: string;
+ topUpId: string;
+ amount: string;
+}
+
+type HexString = `0x${string}`;
+
+const IN_AMOUNT = 1_000n;
+const OUT_AMOUNT = 500n;
+const CANCEL_AMOUNT = 300n;
+
+function randomId(): HexString {
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
+ return `0x${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
+}
+
+/** Every item the provider's serve stream emits, kept as they arrive. */
+class Served {
+ private readonly items: HostFundingServeSubscribeItem[] = [];
+ private failure: unknown;
+ private readonly subscription: { unsubscribe(): void };
+
+ constructor(stream: ObservableLike) {
+ this.subscription = stream.subscribe({
+ next: (item) => {
+ this.items.push(item);
+ },
+ error: (reason: unknown) => {
+ this.failure = reason;
+ },
+ complete: () => {},
+ });
+ }
+
+ /** The first item matching `predicate`, waiting up to `WAIT_MS`. */
+ async next(
+ predicate: (item: HostFundingServeSubscribeItem) => boolean,
+ what: string,
+ ): Promise {
+ const deadline = Date.now() + WAIT_MS;
+ for (;;) {
+ const hit = this.items.find(predicate);
+ if (hit) return hit;
+ if (this.failure !== undefined) {
+ throw new Error(`serve stream failed: ${stringify(this.failure)}`);
+ }
+ if (Date.now() >= deadline) {
+ throw new Error(`never served ${what}`);
+ }
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ }
+
+ close() {
+ this.subscription.unsubscribe();
+ }
+}
+
+const assignedTo = (intent: string) => (item: HostFundingServeSubscribeItem) =>
+ item.tag === "Assigned" && item.value.session.intent === intent;
+
+function rowFactory(rows: DiagnosisRow[]) {
+ return async (methodName: string, check: () => Promise) => {
+ const startedAt = performance.now();
+ const row = (status: DiagnosisRow["status"], output: string) =>
+ rows.push({
+ id: `Funding/${methodName}`,
+ serviceName: "Funding",
+ methodName,
+ status,
+ output,
+ durationMs: Math.round(performance.now() - startedAt),
+ });
+ try {
+ row("pass", await check());
+ } catch (error) {
+ // One stuck case is recorded and the rest still run.
+ row("fail", error instanceof Error ? error.message : String(error));
+ }
+ };
+}
+
+async function report(
+ client: TrUApiClient,
+ intent: string,
+ update: FundingUpdate,
+): Promise {
+ const reported = await client.fundingProvider.report({ intent, update });
+ if (reported.isErr()) {
+ throw new Error(
+ `report ${update.tag} refused: ${stringify(reported.error)}`,
+ );
+ }
+}
+
+async function requestFunding(
+ client: TrUApiClient,
+ direction: "In" | "Out",
+ amount: bigint,
+): Promise {
+ const requested = await client.funding.request({ direction, amount });
+ if (requested.isErr()) {
+ throw new Error(`funding.request failed: ${stringify(requested.error)}`);
+ }
+ return requested.value.intent;
+}
+
+async function lastStatus(
+ client: TrUApiClient,
+ intent: string,
+): Promise {
+ const items = await untilComplete(
+ client.funding.statusSubscribe({ request: { intent } }),
+ `session ${intent}`,
+ );
+ return items.at(-1);
+}
+
+/** The first run: assignment, a provider screen, forward-only reports, the
+ * outbound and cancelled sessions, and an inbound one up to its top-up. */
+export async function runProviderStart(
+ client: TrUApiClient,
+ fundingLogPath: string,
+): Promise<{ rows: DiagnosisRow[]; state?: ProviderState }> {
+ const rows: DiagnosisRow[] = [];
+ const check = rowFactory(rows);
+ const served = new Served(client.fundingProvider.serveSubscribe());
+ let inbound = "";
+ let state: ProviderState | undefined;
+
+ await check("provider_assigned", async () => {
+ inbound = await requestFunding(client, "In", IN_AMOUNT);
+ const item = await served.next(
+ assignedTo(inbound),
+ `the session ${inbound}`,
+ );
+ if (item.tag !== "Assigned") throw new Error(stringify(item));
+ const { direction, amount, lastUpdate } = item.value.session;
+ if (
+ direction !== "In" ||
+ amount !== IN_AMOUNT ||
+ lastUpdate !== undefined
+ ) {
+ throw new Error(`unexpected assignment ${stringify(item)}`);
+ }
+ return "the provider's worker was handed the session it was chosen for";
+ });
+
+ await check("provider_present_frame", async () => {
+ const framed = await client.fundingProvider.presentFrame({
+ intent: inbound,
+ route: "/kyc",
+ });
+ if (framed.isErr() || framed.value.outcome !== "Closed") {
+ throw new Error(`presentFrame: ${stringify(framed)}`);
+ }
+ await waitForTranscript(
+ fundingLogPath,
+ (line) =>
+ line.kind === "frame" &&
+ line.intent === inbound &&
+ line.route === "/kyc",
+ "the provider frame",
+ );
+ return "the host showed the provider's screen and it closed";
+ });
+
+ await check("provider_reports_forward_only", async () => {
+ await report(client, inbound, { tag: "AwaitingPayment" });
+ await report(client, inbound, {
+ tag: "PaymentReceived",
+ value: { finalized: true },
+ });
+ await report(client, inbound, { tag: "Converting" });
+ const backwards = await client.fundingProvider.report({
+ intent: inbound,
+ update: { tag: "AwaitingPayment" },
+ });
+ const outOfOrder =
+ backwards.isErr() && stringify(backwards.error).includes("OutOfOrder");
+ if (!outOfOrder) {
+ throw new Error(
+ `a backwards report was not refused: ${stringify(backwards)}`,
+ );
+ }
+ return "updates moved forward and a backwards one was refused";
+ });
+
+ await check("provider_credits_through_top_up", async () => {
+ const topUpId = randomId();
+ const toppedUp = await client.payment.topUp({
+ amount: IN_AMOUNT,
+ source: {
+ tag: "ProductAccount",
+ value: { derivationIndex: { tag: "Index", value: 0 } },
+ },
+ id: topUpId,
+ });
+ if (toppedUp.isErr()) {
+ throw new Error(`payment.topUp failed: ${stringify(toppedUp.error)}`);
+ }
+ await report(client, inbound, {
+ tag: "Crediting",
+ value: { topUpId, amount: IN_AMOUNT },
+ });
+ state = { intent: inbound, topUpId, amount: IN_AMOUNT.toString() };
+ return "the top-up was started and named in a Crediting report";
+ });
+
+ await check("provider_out_released", async () => {
+ const outbound = await requestFunding(client, "Out", OUT_AMOUNT);
+ await served.next(assignedTo(outbound), `the session ${outbound}`);
+ const paymentId = randomId();
+ const paid = await client.payment.request({
+ amount: OUT_AMOUNT,
+ destination: `0x${"11".repeat(32)}`,
+ id: paymentId,
+ });
+ if (paid.isErr()) {
+ throw new Error(`payment.request failed: ${stringify(paid.error)}`);
+ }
+ await report(client, outbound, {
+ tag: "Collecting",
+ value: { paymentId, amount: OUT_AMOUNT },
+ });
+ const last = await lastStatus(client, outbound);
+ if (last?.tag !== "Released" || last.value.debited !== OUT_AMOUNT) {
+ throw new Error(`ended as ${stringify(last)}`);
+ }
+ return "the core released the session once the named payment completed";
+ });
+
+ await check("provider_cancel", async () => {
+ const cancelled = await requestFunding(client, "In", CANCEL_AMOUNT);
+ await served.next(assignedTo(cancelled), `the session ${cancelled}`);
+ await served.next(
+ (item) => item.tag === "Cancel" && item.value.intent === cancelled,
+ `a cancel of ${cancelled}`,
+ );
+ await report(client, cancelled, {
+ tag: "Failed",
+ value: { reason: { tag: "Cancelled" } },
+ });
+ const last = await lastStatus(client, cancelled);
+ if (last?.tag !== "Failed" || last.value.reason.tag !== "Cancelled") {
+ throw new Error(`ended as ${stringify(last)}`);
+ }
+ return "the user's cancel reached the provider, which ended the session";
+ });
+
+ served.close();
+ return { rows, state };
+}
+
+/** The run after the restart: the session is handed over again where it
+ * stopped, and the core delivers it from what its top-up claimed. */
+export async function runProviderResume(
+ client: TrUApiClient,
+ fundingLogPath: string,
+ state: ProviderState | undefined,
+): Promise {
+ const rows: DiagnosisRow[] = [];
+ const check = rowFactory(rows);
+ if (!state) {
+ await check("provider_resumes_after_restart", async () => {
+ throw new Error("the first run left no session to resume");
+ });
+ return rows;
+ }
+ const served = new Served(client.fundingProvider.serveSubscribe());
+
+ await check("provider_resumes_after_restart", async () => {
+ const item = await served.next(
+ assignedTo(state.intent),
+ `the session ${state.intent}`,
+ );
+ const last =
+ item.tag === "Assigned" ? item.value.session.lastUpdate : undefined;
+ if (last?.tag !== "Crediting" || last.value.topUpId !== state.topUpId) {
+ throw new Error(`replayed ${stringify(item)}`);
+ }
+ return "the restarted worker was handed the session with its last update";
+ });
+
+ await check("provider_in_delivered", async () => {
+ await report(client, state.intent, { tag: "Delivered" });
+ const last = await lastStatus(client, state.intent);
+ if (
+ last?.tag !== "Delivered" ||
+ last.value.credited !== BigInt(state.amount)
+ ) {
+ throw new Error(`ended as ${stringify(last)}`);
+ }
+ await waitForTranscript(
+ fundingLogPath,
+ (line) =>
+ line.kind === "status" &&
+ line.intent === state.intent &&
+ line.tag === "Delivered" &&
+ line.amount === state.amount,
+ `Delivered reaching the host for ${state.intent}`,
+ );
+ return "the core delivered the session from the claim of the named top-up";
+ });
+
+ served.close();
+ return rows;
+}
diff --git a/rust/crates/truapi-host-cli/js/scripts/funding-battery.ts b/rust/crates/truapi-host-cli/js/scripts/funding-battery.ts
index d911321d99..91171f3ded 100644
--- a/rust/crates/truapi-host-cli/js/scripts/funding-battery.ts
+++ b/rust/crates/truapi-host-cli/js/scripts/funding-battery.ts
@@ -7,10 +7,23 @@
// which builds the CLI with `--features test-host` and starts a signing host
// with a scripted funding overlay, so a product's requests are shown, started
// or dismissed, and settled with no chain behind them.
-import { mkdirSync, writeFileSync } from "node:fs";
+//
+// The battery runs this script twice, as a Worker, around a host restart.
+// `TRUAPI_FUNDING_STAGE=start` runs the product's cases and the provider's up
+// to an inbound top-up, and keeps its rows in `TRUAPI_FUNDING_STATE`;
+// `TRUAPI_FUNDING_STAGE=resume` finishes that session in the new process and
+// writes the report with every row. Without a stage it runs the product's
+// cases alone.
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
+import type { DiagnosisRow } from "../diagnosis.ts";
import { runFundingE2e } from "../funding-e2e.ts";
+import {
+ type ProviderState,
+ runProviderResume,
+ runProviderStart,
+} from "../funding-provider-e2e.ts";
import {
cliModalityDiagnosisReportMetadata,
renderDiagnosisReport,
@@ -39,36 +52,89 @@ if (
);
}
-const rows = await runFundingE2e(truapi, process.env.TRUAPI_FUNDING_LOG);
-for (const row of rows) {
- const mark = { pass: "✅", fail: "❌", skipped: "⏭️" }[row.status];
- console.log(`${mark} ${row.id} (${row.durationMs}ms) ${row.output}`);
-}
+const fundingLog = process.env.TRUAPI_FUNDING_LOG;
+const stage = process.env.TRUAPI_FUNDING_STAGE;
+const statePath = process.env.TRUAPI_FUNDING_STATE;
-// Committed, so a rerun overwrites it and the diff shows what changed. The
-// explorer's Funding matrix reads it from the `funding/` directory it lands in.
-mkdirSync(dirname(REPORT_PATH), { recursive: true });
-writeFileSync(REPORT_PATH, renderDiagnosisReport(report.title, rows));
-console.log(`funding battery: report saved to ${REPORT_PATH}`);
+const print = (rows: DiagnosisRow[]) => {
+ for (const row of rows) {
+ const mark = { pass: "✅", fail: "❌", skipped: "⏭️" }[row.status];
+ console.log(`${mark} ${row.id} (${row.durationMs}ms) ${row.output}`);
+ }
+};
-const skipped = rows.filter((row) => row.status === "skipped");
-if (skipped.length > 0) {
- // A skip here means the run could not read what the host observed, which is
- // half of what these cases assert.
- throw new Error(
- `funding battery skipped ${skipped.length} case(s): ${skipped
- .map((row) => row.output)
- .join("; ")}`,
- );
+interface SavedRun {
+ rows: DiagnosisRow[];
+ provider?: ProviderState;
}
-const failures = rows.filter((row) => row.status === "fail");
-if (failures.length > 0) {
- throw new Error(
- `funding battery failed: ${failures.length} of ${rows.length} cases\n${failures
- .map((row) => `${row.id}: ${row.output}`)
- .join("\n")}`,
- );
+/** The rows the report holds: the saved first run plus the resumed one, or
+ * the product's cases alone without a stage. */
+async function rowsToReport(): Promise {
+ if (stage !== "resume") {
+ const rows = await runFundingE2e(truapi, fundingLog);
+ print(rows);
+ return rows;
+ }
+ if (!statePath || !fundingLog) {
+ throw new Error(
+ "the resume stage needs TRUAPI_FUNDING_STATE and TRUAPI_FUNDING_LOG",
+ );
+ }
+ const saved: SavedRun = existsSync(statePath)
+ ? JSON.parse(readFileSync(statePath, "utf8"))
+ : { rows: [] };
+ const resumed = await runProviderResume(truapi, fundingLog, saved.provider);
+ print(resumed);
+ return [...saved.rows, ...resumed];
}
-console.log(`funding battery: ${rows.length} cases passed`);
+if (stage === "start") {
+ if (!statePath || !fundingLog) {
+ throw new Error(
+ "the start stage needs TRUAPI_FUNDING_STATE and TRUAPI_FUNDING_LOG",
+ );
+ }
+ const consumer = await runFundingE2e(truapi, fundingLog);
+ const provider = await runProviderStart(truapi, fundingLog);
+ const saved: SavedRun = {
+ rows: [...consumer, ...provider.rows],
+ provider: provider.state,
+ };
+ print(saved.rows);
+ writeFileSync(statePath, JSON.stringify(saved));
+ console.log(`funding battery: first run kept in ${statePath}`);
+} else {
+ finish(await rowsToReport());
+}
+
+/** Write the report, then fail on any skipped or failed case. */
+function finish(rows: DiagnosisRow[]) {
+ // Committed, so a rerun overwrites it and the diff shows what changed. The
+ // explorer's Funding matrix reads it from the `funding/` directory it lands in.
+ mkdirSync(dirname(REPORT_PATH), { recursive: true });
+ writeFileSync(REPORT_PATH, renderDiagnosisReport(report.title, rows));
+ console.log(`funding battery: report saved to ${REPORT_PATH}`);
+
+ const skipped = rows.filter((row) => row.status === "skipped");
+ if (skipped.length > 0) {
+ // A skip here means the run could not read what the host observed, which is
+ // half of what these cases assert.
+ throw new Error(
+ `funding battery skipped ${skipped.length} case(s): ${skipped
+ .map((row) => row.output)
+ .join("; ")}`,
+ );
+ }
+
+ const failures = rows.filter((row) => row.status === "fail");
+ if (failures.length > 0) {
+ throw new Error(
+ `funding battery failed: ${failures.length} of ${rows.length} cases\n${failures
+ .map((row) => `${row.id}: ${row.output}`)
+ .join("\n")}`,
+ );
+ }
+
+ console.log(`funding battery: ${rows.length} cases passed`);
+}
diff --git a/rust/crates/truapi-host-cli/src/funding_host.rs b/rust/crates/truapi-host-cli/src/funding_host.rs
index 88cf03b175..57c978f9b2 100644
--- a/rust/crates/truapi-host-cli/src/funding_host.rs
+++ b/rust/crates/truapi-host-cli/src/funding_host.rs
@@ -8,6 +8,12 @@
//! There is no chain behind it: this exists so a battery can drive the
//! product's side of funding headlessly.
//!
+//! `provide` and `provide-cancel` instead hand the session to the product that
+//! asked, so a provider worker can serve its own request; `provide-cancel`
+//! then cancels it, as a user would. Top-ups and payment requests that worker
+//! starts are accepted and complete in full, recorded in the ledger named by
+//! `TRUAPI_FUNDING_LEDGER` so their statuses outlive a restart.
+//!
//! Every overlay request and every session change the core reports is
//! appended to the transcript named by `TRUAPI_FUNDING_LOG`, one JSON object
//! per line, so a battery can assert what the host saw rather than only what
@@ -20,13 +26,19 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::Duration;
+use futures::stream::{self, BoxStream, StreamExt};
use truapi::SigningHostRuntime;
use truapi::host_logic::funding::FundingStage;
use truapi::latest::{
- FundingDirection, FundingFailure, GenericError, HostFundingStatusSubscribeItem,
+ FundingDirection, FundingFailure, FundingFrameOutcome, GenericError,
+ HostFundingStatusSubscribeItem, HostPaymentError, HostPaymentRequest,
+ HostPaymentStatusSubscribeError, HostPaymentStatusSubscribeItem, HostPaymentTopUpError,
+ HostPaymentTopUpRequest, HostPaymentTopUpStatusSubscribeError,
+ HostPaymentTopUpStatusSubscribeItem,
};
use truapi::platform::{
- FundingPlatform, FundingPresentOutcome, FundingPresentation, ProductContext, async_trait,
+ FundingPlatform, FundingPresentOutcome, FundingPresentation, PaymentPlatform, ProductContext,
+ TopUpPlatform, async_trait,
};
/// How long a started session stays in flight before it is settled, so a
@@ -44,6 +56,10 @@ enum Outcome {
Fail,
/// The user closes the overlay.
Dismiss,
+ /// The user picks the product that asked as the provider.
+ Provide,
+ /// As `Provide`, then the user cancels.
+ ProvideThenCancel,
}
/// Parse `deliver:900,release:500,fail,dismiss`.
@@ -55,6 +71,8 @@ fn parse_outcomes(spec: &str) -> VecDeque {
Some(("release", amount)) => amount.parse().ok().map(Outcome::Release),
None if entry == "fail" => Some(Outcome::Fail),
None if entry == "dismiss" => Some(Outcome::Dismiss),
+ None if entry == "provide" => Some(Outcome::Provide),
+ None if entry == "provide-cancel" => Some(Outcome::ProvideThenCancel),
_ => None,
})
.collect()
@@ -88,6 +106,11 @@ impl CliFundingHost {
});
let _ = host.runtime.set(Arc::downgrade(runtime));
runtime.set_funding_platform(host);
+ if let Some(ledger) = std::env::var_os("TRUAPI_FUNDING_LEDGER").map(PathBuf::from) {
+ let payments = Arc::new(CliPaymentHost { ledger });
+ runtime.set_top_up_platform(payments.clone());
+ runtime.set_payment_platform(payments);
+ }
}
/// Append one line to the transcript, in a single write so concurrent
@@ -106,6 +129,38 @@ impl CliFundingHost {
}
}
+ /// Hand session `intent` to the product that asked for it, and cancel it
+ /// shortly after when `cancel` is set.
+ async fn provide(
+ &self,
+ runtime: Option>,
+ product: Option<&ProductContext>,
+ intent: String,
+ cancel: bool,
+ ) -> Result {
+ let (Some(runtime), Some(product)) =
+ (runtime.and_then(|runtime| runtime.upgrade()), product)
+ else {
+ return Ok(FundingPresentOutcome::Dismissed);
+ };
+ runtime
+ .select_funding_provider(&intent, &product.product_id)
+ .await?;
+ if cancel {
+ let runtime = Arc::downgrade(&runtime);
+ tokio::spawn(async move {
+ tokio::time::sleep(SETTLE_AFTER).await;
+ let Some(runtime) = runtime.upgrade() else {
+ return;
+ };
+ if let Err(error) = runtime.cancel_funding(&intent).await {
+ tracing::warn!(%intent, reason = %error.reason, "cancelling a scripted funding session failed");
+ }
+ });
+ }
+ Ok(FundingPresentOutcome::Started)
+ }
+
fn next_outcome(&self) -> Outcome {
self.outcomes
.lock()
@@ -138,7 +193,7 @@ fn settled_stage(outcome: Outcome) -> Option {
},
settled_at_ms,
}),
- Outcome::Dismiss => None,
+ Outcome::Dismiss | Outcome::Provide | Outcome::ProvideThenCancel => None,
}
}
@@ -161,11 +216,21 @@ impl FundingPlatform for CliFundingHost {
"product": product.map(|product| product.product_id.clone()),
"outcome": format!("{outcome:?}"),
}));
+ let runtime = self.runtime.get().cloned();
+ let intent = session.intent;
+ if matches!(outcome, Outcome::Provide | Outcome::ProvideThenCancel) {
+ return self
+ .provide(
+ runtime,
+ product,
+ intent,
+ outcome == Outcome::ProvideThenCancel,
+ )
+ .await;
+ }
let Some(stage) = settled_stage(outcome) else {
return Ok(FundingPresentOutcome::Dismissed);
};
- let runtime = self.runtime.get().cloned();
- let intent = session.intent;
tokio::spawn(async move {
tokio::time::sleep(SETTLE_AFTER).await;
let Some(runtime) = runtime.and_then(|runtime| runtime.upgrade()) else {
@@ -184,6 +249,21 @@ impl FundingPlatform for CliFundingHost {
Ok(FundingPresentOutcome::Started)
}
+ async fn present_provider_frame(
+ &self,
+ provider: &ProductContext,
+ intent: String,
+ route: String,
+ ) -> Result {
+ self.record(serde_json::json!({
+ "kind": "frame",
+ "intent": intent,
+ "provider": provider.product_id,
+ "route": route,
+ }));
+ Ok(FundingFrameOutcome::Closed)
+ }
+
fn funding_session_changed(&self, intent: String, status: HostFundingStatusSubscribeItem) {
let (tag, amount) = match status {
HostFundingStatusSubscribeItem::AwaitingDeposit { .. } => ("AwaitingDeposit", None),
@@ -201,6 +281,116 @@ impl FundingPlatform for CliFundingHost {
}
}
+/// Scripted top-up and payment engines: every top-up claims in full and every
+/// payment completes, once started. What was started is kept in a ledger file,
+/// one JSON object per line, so a restarted host still answers for it.
+struct CliPaymentHost {
+ ledger: PathBuf,
+}
+
+impl CliPaymentHost {
+ /// Whether `kind` with `id` was started by `product`.
+ fn started(&self, kind: &str, product: &ProductContext, id: &[u8; 32]) -> bool {
+ let id = hex::encode(id);
+ std::fs::read_to_string(&self.ledger)
+ .unwrap_or_default()
+ .lines()
+ .filter_map(|line| serde_json::from_str::(line).ok())
+ .any(|line| {
+ line["kind"] == kind
+ && line["product"] == product.product_id.as_str()
+ && line["id"] == id.as_str()
+ })
+ }
+
+ /// Record a start; `false` when the id was already used.
+ fn start(&self, kind: &str, product: &ProductContext, id: &[u8; 32], amount: u128) -> bool {
+ if self.started(kind, product, id) {
+ return false;
+ }
+ let line = serde_json::json!({
+ "kind": kind,
+ "product": product.product_id,
+ "id": hex::encode(id),
+ "amount": amount.to_string(),
+ });
+ let appended = OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&self.ledger)
+ .and_then(|mut file| file.write_all(format!("{line}\n").as_bytes()));
+ if let Err(error) = appended {
+ tracing::warn!(path = ?self.ledger, %error, "funding ledger could not be appended to");
+ }
+ true
+ }
+}
+
+#[async_trait]
+impl TopUpPlatform for CliPaymentHost {
+ async fn top_up(
+ &self,
+ product: &ProductContext,
+ request: HostPaymentTopUpRequest,
+ ) -> Result<(), HostPaymentTopUpError> {
+ if self.start("top_up", product, &request.id, request.amount) {
+ Ok(())
+ } else {
+ Err(HostPaymentTopUpError::AlreadyExists)
+ }
+ }
+
+ fn subscribe_top_up_status(
+ &self,
+ product: &ProductContext,
+ id: [u8; 32],
+ ) -> BoxStream<
+ 'static,
+ Result,
+ > {
+ if !self.started("top_up", product, &id) {
+ return stream::iter([Err(HostPaymentTopUpStatusSubscribeError::NotFound)]).boxed();
+ }
+ stream::iter([
+ Ok(HostPaymentTopUpStatusSubscribeItem::Detecting),
+ Ok(HostPaymentTopUpStatusSubscribeItem::Claiming),
+ Ok(HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true }),
+ ])
+ .boxed()
+ }
+}
+
+#[async_trait]
+impl PaymentPlatform for CliPaymentHost {
+ async fn request_payment(
+ &self,
+ product: &ProductContext,
+ request: HostPaymentRequest,
+ ) -> Result<(), HostPaymentError> {
+ if self.start("payment", product, &request.id, request.amount) {
+ Ok(())
+ } else {
+ Err(HostPaymentError::AlreadyExists)
+ }
+ }
+
+ fn subscribe_payment_status(
+ &self,
+ product: &ProductContext,
+ id: [u8; 32],
+ ) -> BoxStream<'static, Result>
+ {
+ if !self.started("payment", product, &id) {
+ return stream::iter([Err(HostPaymentStatusSubscribeError::PaymentNotFound)]).boxed();
+ }
+ stream::iter([
+ Ok(HostPaymentStatusSubscribeItem::Processing),
+ Ok(HostPaymentStatusSubscribeItem::Completed),
+ ])
+ .boxed()
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -210,12 +400,16 @@ mod tests {
#[test]
fn outcomes_are_read_in_order_and_unknown_entries_dropped() {
assert_eq!(
- parse_outcomes("deliver:900, release:500,fail,bogus,dismiss,deliver:x"),
+ parse_outcomes(
+ "deliver:900, release:500,fail,bogus,dismiss,deliver:x,provide,provide-cancel"
+ ),
VecDeque::from([
Outcome::Deliver(900),
Outcome::Release(500),
Outcome::Fail,
Outcome::Dismiss,
+ Outcome::Provide,
+ Outcome::ProvideThenCancel,
])
);
}
diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md
index 908c55f596..f63ade8ad0 100644
--- a/rust/crates/truapi/RUNTIME.md
+++ b/rust/crates/truapi/RUNTIME.md
@@ -406,14 +406,27 @@ AutoSigning without approval. Legacy-account signing still asks the user.
Native hosts reach this through `NativeTrUApiHostRuntime`:
`set_funding_callbacks` (the overlay), `set_top_up_callbacks` with
`notify_top_up_status` (the top-up engine), `open_funding`,
- `funding_session`, `funding_sessions`, `cancel_funding` and
- `acknowledge_funding_session`. Amounts cross the FFI as decimal strings.
+ `funding_session`, `funding_sessions`, `select_funding_provider`,
+ `cancel_funding` and `acknowledge_funding_session`. Amounts cross the FFI as decimal strings.
`funding_sessions` lists sessions in flight first, then ended ones, each
newest first. An ended session is handed to the host through
`funding_session_changed` each time funding resumes until the host calls
`acknowledge_funding_session`, so its history writes every outcome once;
the core keeps the 50 newest recorded sessions and every unrecorded one
within the 200 newest ended.
+ `select_funding_provider` hands a session to the provider the user chose.
+ That provider's worker runs it through the `FundingProvider` trait:
+ `serveSubscribe` replays its sessions in flight and then streams new ones
+ and cancel requests, `report` stores each update on the session (only from
+ the assigned provider, only forward), and `presentFrame` asks the host to
+ show one of its screens through `present_provider_frame`. The core holds the
+ provider's worker while a session is assigned and open. It ends a session as
+ `Delivered` or `Released` itself, from the claims of the top-ups the
+ provider named (`Crediting`, then `Delivered`) or the completion of the
+ payment request it named (`Collecting`); once funds move, the provider can
+ no longer fail the session and its deadline no longer applies. A cancel ends
+ an unassigned session, asks the provider of an assigned one, and is refused
+ once the provider has seen the user's payment.
- `PaymentPlatform`: pay from the user's balance to an account once the user
approves, and stream each payment's status by its caller-chosen id.
Installed with `set_payment_platform`; native hosts use
diff --git a/rust/crates/truapi/src/api.rs b/rust/crates/truapi/src/api.rs
index 614f2c109d..a2cabf9b26 100644
--- a/rust/crates/truapi/src/api.rs
+++ b/rust/crates/truapi/src/api.rs
@@ -7,6 +7,7 @@ pub mod coin_payment;
pub mod contacts;
pub mod entropy;
pub mod funding;
+pub mod funding_provider;
pub mod local_storage;
pub mod locale;
pub mod notifications;
@@ -29,6 +30,7 @@ pub use coin_payment::CoinPayment;
pub use contacts::Contacts;
pub use entropy::Entropy;
pub use funding::Funding;
+pub use funding_provider::FundingProvider;
pub use local_storage::LocalStorage;
pub use locale::Locale;
pub use notifications::Notifications;
@@ -53,6 +55,7 @@ pub trait TrUApi:
+ Contacts
+ Entropy
+ Funding
+ + FundingProvider
+ LocalStorage
+ Locale
+ Notifications
@@ -80,6 +83,7 @@ impl TrUApi for T where
+ Contacts
+ Entropy
+ Funding
+ + FundingProvider
+ LocalStorage
+ Locale
+ Notifications
diff --git a/rust/crates/truapi/src/api/funding_provider.rs b/rust/crates/truapi/src/api/funding_provider.rs
new file mode 100644
index 0000000000..303387e0e8
--- /dev/null
+++ b/rust/crates/truapi/src/api/funding_provider.rs
@@ -0,0 +1,83 @@
+//! Unified [`FundingProvider`] trait.
+
+use crate::versioned::funding_provider::{
+ HostFundingPresentFrameError, HostFundingPresentFrameRequest, HostFundingPresentFrameResponse,
+ HostFundingReportError, HostFundingReportRequest, HostFundingReportResponse,
+ HostFundingServeSubscribeError, HostFundingServeSubscribeItem,
+ HostFundingServeSubscribeRequest,
+};
+use crate::{CallContext, CallError, Subscription};
+use crate::{wire, wire_trait};
+
+/// Serve the funding sessions the user chose this provider for.
+///
+/// The provider's worker runs each session it is assigned and reports its
+/// progress. The host stores every report, and decides `Delivered` and
+/// `Released` itself from the top-ups and payments the provider names.
+#[wire_trait(id = 22)]
+#[crate::service(required_execution = Worker)]
+#[crate::async_trait]
+pub trait FundingProvider: Send + Sync {
+ /// Receive the sessions assigned to the calling provider, and requests to
+ /// cancel them.
+ ///
+ /// Emits every session still in flight first, so a restarted worker picks
+ /// up where it stopped, then each new assignment and cancel request.
+ ///
+ /// ```ts
+ /// import { firstValueFrom, from } from "rxjs";
+ ///
+ /// const item = await firstValueFrom(
+ /// from(truapi.fundingProvider.serveSubscribe()),
+ /// );
+ /// console.log("funding provider item:", item);
+ /// ```
+ #[wire(id = 0)]
+ async fn serve_subscribe(
+ &self,
+ _cx: &CallContext,
+ _request: HostFundingServeSubscribeRequest,
+ ) -> Subscription>
+ {
+ Subscription::interrupted(CallError::unavailable())
+ }
+
+ /// Report progress on a session assigned to the calling provider.
+ ///
+ /// The host stores the update before answering.
+ ///
+ /// ```ts
+ /// const result = await truapi.fundingProvider.report({
+ /// intent: "fs_example",
+ /// update: { tag: "AwaitingPayment", value: undefined },
+ /// });
+ /// console.log("funding report:", result);
+ /// ```
+ #[wire(id = 1)]
+ async fn report(
+ &self,
+ _cx: &CallContext,
+ _request: HostFundingReportRequest,
+ ) -> Result> {
+ Err(CallError::unavailable())
+ }
+
+ /// Show one of the provider's screens, such as KYC or card entry, in a
+ /// frame the host owns. Answers once it closes.
+ ///
+ /// ```ts
+ /// const result = await truapi.fundingProvider.presentFrame({
+ /// intent: "fs_example",
+ /// route: "/kyc",
+ /// });
+ /// console.log("provider frame:", result);
+ /// ```
+ #[wire(id = 2)]
+ async fn present_frame(
+ &self,
+ _cx: &CallContext,
+ _request: HostFundingPresentFrameRequest,
+ ) -> Result> {
+ Err(CallError::unavailable())
+ }
+}
diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs
index 59cb762b14..60f940a74b 100644
--- a/rust/crates/truapi/src/host_core.rs
+++ b/rust/crates/truapi/src/host_core.rs
@@ -804,8 +804,9 @@ impl SigningHostRuntime {
})
}
- /// Cancel open funding session `intent`. Returns whether it was still
- /// open.
+ /// Cancel funding session `intent` at the user's request: it ends if no
+ /// provider serves it, otherwise its provider is asked to stop. Returns
+ /// whether the cancel was taken.
#[instrument(skip_all, fields(runtime.method = "signing_host_runtime.cancel_funding"))]
pub async fn cancel_funding(&self, intent: &str) -> Result {
self.services
@@ -816,6 +817,22 @@ impl SigningHostRuntime {
})
}
+ /// Hand open funding session `intent` to the provider the user chose,
+ /// by product id. Returns whether it was open and not yet assigned.
+ #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.select_funding_provider"))]
+ pub async fn select_funding_provider(
+ &self,
+ intent: &str,
+ provider_id: &str,
+ ) -> Result {
+ self.services
+ .select_funding_provider(intent, provider_id)
+ .await
+ .map_err(|err| v01::GenericError {
+ reason: err.to_string(),
+ })
+ }
+
/// One funding session as the core holds it, for the host's own status
/// and history views.
pub fn funding_session(
diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs
index e351fa3906..ac3dabf614 100644
--- a/rust/crates/truapi/src/host_logic/funding.rs
+++ b/rust/crates/truapi/src/host_logic/funding.rs
@@ -9,7 +9,10 @@
use parity_scale_codec::{Decode, Encode};
use tracing::warn;
-use truapi::latest::{FundingDirection, FundingFailure, HostFundingStatusSubscribeItem};
+use truapi::latest::{
+ FundingAssignment, FundingDirection, FundingFailure, FundingUpdate,
+ HostFundingStatusSubscribeItem,
+};
use crate::platform::{CoreStorage, CoreStorageKey};
@@ -21,6 +24,9 @@ const SETTLED_HISTORY_LIMIT: usize = 50;
/// How far back, counting every ended session newest first, those the host
/// has not recorded yet are kept.
const UNACKNOWLEDGED_LIMIT: usize = 200;
+/// Most top-ups a provider may start for one session, as getcash claims in
+/// up to three attempts.
+const TOP_UP_LIMIT: usize = 3;
/// What the core knows about one session, independent of any host surface.
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
@@ -48,6 +54,60 @@ pub struct FundingSession {
/// Whether the host has recorded the session's outcome in its own
/// history. An ended session is handed to the host until it has.
pub acknowledged: bool,
+ /// Product id of the provider the user chose. `None` until they choose.
+ pub provider_id: Option,
+ /// Whether the user asked to cancel and the provider has not answered.
+ pub cancel_requested: bool,
+ /// Every update the provider reported, oldest first.
+ pub updates: Vec,
+}
+
+/// One update a provider reported, and when the core stored it.
+#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
+#[cfg_attr(
+ all(feature = "runtime", not(target_arch = "wasm32")),
+ derive(uniffi::Record)
+)]
+pub struct FundingUpdateRecord {
+ /// The update.
+ pub update: FundingUpdate,
+ /// When it was stored, in Unix milliseconds.
+ pub at_ms: u64,
+}
+
+/// Why a provider's report was refused.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ReportRefusal {
+ /// The session is not open or not assigned to the reporting provider.
+ NotFound,
+ /// The update does not follow the last one, or does not fit the
+ /// session's direction.
+ OutOfOrder,
+ /// The top-up or payment request it names is already named by another
+ /// session of the same provider.
+ DuplicateId,
+}
+
+/// What a cancel did.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CancelOutcome {
+ /// No provider serves the session, so it ended as cancelled.
+ Ended,
+ /// The provider serving it was asked to stop.
+ Requested,
+ /// Too late: the session ended, or the user's payment already reached
+ /// the provider.
+ Refused,
+}
+
+/// What the core follows to decide a session's outcome once funds move.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Settlement {
+ /// The top-ups an inbound session credits through, with the amount each
+ /// asks for.
+ TopUps(Vec<([u8; 32], u128)>),
+ /// The payment an outbound session collects through, with its amount.
+ Payment([u8; 32], u128),
}
/// Stage of a session, as the core persists it.
@@ -100,9 +160,175 @@ impl FundingSession {
opened_at_ms: now_ms,
deadline_ms: now_ms.saturating_add(SESSION_WINDOW_MS),
acknowledged: false,
+ provider_id: None,
+ cancel_requested: false,
+ updates: Vec::new(),
+ }
+ }
+
+ /// Assign the session to the provider the user chose. Returns whether it
+ /// was open and not yet assigned.
+ pub fn assign(&mut self, provider_id: &str) -> bool {
+ if self.is_terminal() || self.provider_id.is_some() {
+ return false;
+ }
+ self.provider_id = Some(provider_id.to_string());
+ true
+ }
+
+ /// The session as the provider serving it receives it.
+ pub fn assignment(&self) -> FundingAssignment {
+ FundingAssignment {
+ intent: self.intent.clone(),
+ direction: self.direction,
+ amount: self.amount,
+ expires_at: self.deadline_ms,
+ last_update: self.last_update().cloned(),
+ }
+ }
+
+ /// The provider's most recent update.
+ pub fn last_update(&self) -> Option<&FundingUpdate> {
+ self.updates.last().map(|record| &record.update)
+ }
+
+ /// Store `update` from `provider_id`. A `Failed` update ends the session.
+ pub fn report(
+ &mut self,
+ provider_id: &str,
+ update: FundingUpdate,
+ now_ms: u64,
+ ) -> Result<(), ReportRefusal> {
+ if self.is_terminal() || self.provider_id.as_deref() != Some(provider_id) {
+ return Err(ReportRefusal::NotFound);
+ }
+ if !self.follows(&update) {
+ return Err(ReportRefusal::OutOfOrder);
+ }
+ if let FundingUpdate::Failed { reason } = &update {
+ self.stage = FundingStage::Failed {
+ reason: reason.clone(),
+ settled_at_ms: now_ms,
+ };
+ }
+ self.updates.push(FundingUpdateRecord {
+ update,
+ at_ms: now_ms,
+ });
+ Ok(())
+ }
+
+ /// Whether `update` may come next. Updates only move forward, a session
+ /// may credit through a few top-ups, and once funds move the provider
+ /// can no longer fail it: the top-ups or payment decide.
+ fn follows(&self, update: &FundingUpdate) -> bool {
+ let fits_direction = match update {
+ FundingUpdate::Collecting { .. } => self.direction == FundingDirection::Out,
+ FundingUpdate::Failed { .. } => true,
+ _ => self.direction == FundingDirection::In,
+ };
+ let last = self.last_update().map(update_rank);
+ let top_ups = self.top_ups();
+ fits_direction
+ && match update {
+ FundingUpdate::Failed { .. } => !self.funds_moving(),
+ FundingUpdate::Crediting { top_up_id, .. } => {
+ last <= Some(update_rank(update))
+ && top_ups.len() < TOP_UP_LIMIT
+ && top_ups.iter().all(|(id, _)| id != top_up_id)
+ }
+ FundingUpdate::Delivered => last < Some(update_rank(update)) && !top_ups.is_empty(),
+ _ => last < Some(update_rank(update)),
+ }
+ }
+
+ /// The top-up or payment request id `update` names, if any.
+ pub fn named_id(update: &FundingUpdate) -> Option<[u8; 32]> {
+ match update {
+ FundingUpdate::Crediting { top_up_id, .. } => Some(*top_up_id),
+ FundingUpdate::Collecting { payment_id, .. } => Some(*payment_id),
+ _ => None,
+ }
+ }
+
+ /// Whether any of this session's updates names `id`.
+ pub fn names(&self, id: &[u8; 32]) -> bool {
+ self.updates
+ .iter()
+ .any(|record| Self::named_id(&record.update).as_ref() == Some(id))
+ }
+
+ /// The top-ups the provider started, in order, with the amount each asks
+ /// for.
+ fn top_ups(&self) -> Vec<([u8; 32], u128)> {
+ self.updates
+ .iter()
+ .filter_map(|record| match record.update {
+ FundingUpdate::Crediting { top_up_id, amount } => Some((top_up_id, amount)),
+ _ => None,
+ })
+ .collect()
+ }
+
+ /// Whether funds are moving: the provider started a top-up or a payment
+ /// request. From then on the session neither expires nor cancels.
+ pub fn funds_moving(&self) -> bool {
+ self.updates.iter().any(|record| {
+ matches!(
+ record.update,
+ FundingUpdate::Crediting { .. } | FundingUpdate::Collecting { .. }
+ )
+ })
+ }
+
+ /// What the core must follow to end the session, once the provider has
+ /// handed the outcome over.
+ pub fn settlement_due(&self) -> Option {
+ if self.is_terminal() {
+ return None;
+ }
+ match self.last_update()? {
+ FundingUpdate::Delivered => Some(Settlement::TopUps(self.top_ups())),
+ FundingUpdate::Collecting { payment_id, amount } => {
+ Some(Settlement::Payment(*payment_id, *amount))
+ }
+ _ => None,
}
}
+ /// End the session with what its top-ups claimed or its payment moved.
+ /// Nothing moved fails it. Returns whether it changed.
+ pub fn settle(&mut self, moved: u128, now_ms: u64) -> bool {
+ if self.is_terminal() {
+ return false;
+ }
+ self.stage = match (moved, self.direction) {
+ (0, FundingDirection::In) => FundingStage::Failed {
+ reason: FundingFailure::Other {
+ code: "not_claimed".to_string(),
+ message: "Nothing reached your balance".to_string(),
+ },
+ settled_at_ms: now_ms,
+ },
+ (0, FundingDirection::Out) => FundingStage::Failed {
+ reason: FundingFailure::Other {
+ code: "payment_failed".to_string(),
+ message: "Your payment did not go through".to_string(),
+ },
+ settled_at_ms: now_ms,
+ },
+ (credited, FundingDirection::In) => FundingStage::Delivered {
+ credited,
+ settled_at_ms: now_ms,
+ },
+ (debited, FundingDirection::Out) => FundingStage::Released {
+ debited,
+ settled_at_ms: now_ms,
+ },
+ };
+ true
+ }
+
/// Whether the host still has to hear of the session when funding
/// resumes: it is in flight, or it ended and the host has not recorded
/// the outcome.
@@ -110,9 +336,28 @@ impl FundingSession {
!self.is_terminal() || !self.acknowledged
}
- /// End an open session as cancelled. Returns whether it changed.
- pub fn cancel(&mut self, now_ms: u64) -> bool {
- self.fail(FundingFailure::Cancelled, now_ms)
+ /// Cancel at the user's request: end the session if no provider serves
+ /// it, otherwise ask the provider to stop, which it answers by reporting.
+ /// Refused once the user's payment reached the provider, as getcash
+ /// refuses once anything arrived.
+ pub fn cancel(&mut self, now_ms: u64) -> CancelOutcome {
+ if self.is_terminal() {
+ return CancelOutcome::Refused;
+ }
+ if self.provider_id.is_none() {
+ self.fail(FundingFailure::Cancelled, now_ms);
+ return CancelOutcome::Ended;
+ }
+ let paid = self.funds_moving()
+ || self
+ .updates
+ .iter()
+ .any(|record| matches!(record.update, FundingUpdate::PaymentReceived { .. }));
+ if paid {
+ return CancelOutcome::Refused;
+ }
+ self.cancel_requested = true;
+ CancelOutcome::Requested
}
/// Whether the session has ended.
@@ -135,7 +380,7 @@ impl FundingSession {
match (&self.stage, self.direction) {
(FundingStage::Open, FundingDirection::In) => {
HostFundingStatusSubscribeItem::AwaitingDeposit {
- expires_at: Some(self.deadline_ms),
+ expires_at: (!self.funds_moving()).then_some(self.deadline_ms),
}
}
(FundingStage::Open, FundingDirection::Out) => {
@@ -172,7 +417,23 @@ impl FundingSession {
/// Expire the session if it is still open at its deadline. Returns whether
/// it expired.
pub fn expire_if_due(&mut self, now_ms: u64) -> bool {
- now_ms >= self.deadline_ms && self.fail(FundingFailure::Expired, now_ms)
+ now_ms >= self.deadline_ms
+ && !self.funds_moving()
+ && self.fail(FundingFailure::Expired, now_ms)
+ }
+}
+
+/// Order of an update within a session: a later update ranks higher, and a
+/// top-up and a payment request rank where funds start moving.
+fn update_rank(update: &FundingUpdate) -> u8 {
+ match update {
+ FundingUpdate::AwaitingPayment => 0,
+ FundingUpdate::PaymentReceived { finalized: false } => 1,
+ FundingUpdate::PaymentReceived { finalized: true } => 2,
+ FundingUpdate::Converting => 3,
+ FundingUpdate::Crediting { .. } | FundingUpdate::Collecting { .. } => 4,
+ FundingUpdate::Delivered => 5,
+ FundingUpdate::Failed { .. } => 6,
}
}
@@ -185,6 +446,12 @@ pub enum FundingSessionError {
/// Failure detail.
reason: String,
},
+ /// The chosen provider is not a valid product id.
+ #[display("invalid funding provider: {reason}")]
+ InvalidProvider {
+ /// Why the id was refused.
+ reason: String,
+ },
}
/// The sessions worth keeping: every open one, then the most recently ended
@@ -265,6 +532,7 @@ mod tests {
use crate::test_support::stub_platform;
const NOW: u64 = 1_700_000_000_000;
+ const PROVIDER: &str = "ramp.dot";
fn session(direction: FundingDirection) -> FundingSession {
FundingSession::new(
@@ -375,21 +643,170 @@ mod tests {
);
}
- // Cancel ends an open session once; an ended one keeps its outcome.
+ fn served(direction: FundingDirection) -> FundingSession {
+ let mut session = session(direction);
+ assert!(session.assign(PROVIDER));
+ session
+ }
+
+ fn reported(session: &mut FundingSession, updates: &[FundingUpdate]) {
+ for update in updates {
+ session
+ .report(PROVIDER, update.clone(), NOW)
+ .unwrap_or_else(|refusal| panic!("{update:?} refused: {refusal:?}"));
+ }
+ }
+
+ // With no provider to ask, a cancel ends the session itself; once it
+ // ended, its outcome stands.
#[test]
- fn a_session_is_cancelled_once() {
+ fn an_unserved_session_is_cancelled_once() {
let mut open = session(FundingDirection::In);
let mut ended = expired("fs_2", NOW);
assert_eq!(
(open.cancel(NOW + 1), open.wire_item(), ended.cancel(NOW + 1)),
(
- true,
+ CancelOutcome::Ended,
HostFundingStatusSubscribeItem::Failed {
reason: FundingFailure::Cancelled,
moved: 0,
},
- false
+ CancelOutcome::Refused
+ )
+ );
+ }
+
+ // The provider holds the user's money once it has seen the payment, so
+ // only it can stop the session before that, and nobody can after.
+ #[test]
+ fn a_served_session_asks_its_provider_until_the_payment_arrives() {
+ let mut before = served(FundingDirection::In);
+ let mut after = served(FundingDirection::In);
+ reported(&mut after, &[FundingUpdate::PaymentReceived { finalized: false }]);
+
+ assert_eq!(
+ (before.cancel(NOW), before.cancel_requested, before.is_terminal(), after.cancel(NOW)),
+ (CancelOutcome::Requested, true, false, CancelOutcome::Refused)
+ );
+ }
+
+ #[test]
+ fn a_session_is_assigned_once() {
+ let mut session = served(FundingDirection::In);
+
+ assert!(!session.assign("other.dot"));
+ assert_eq!(session.provider_id.as_deref(), Some(PROVIDER));
+ }
+
+ // Reports come only from the assigned provider and only move forward, so
+ // the stored timeline is one the host can draw in order.
+ #[test]
+ fn reports_come_from_the_provider_and_only_move_forward() {
+ let mut session = served(FundingDirection::In);
+ reported(&mut session, &[FundingUpdate::Converting]);
+
+ assert_eq!(
+ (
+ session.report("other.dot", FundingUpdate::Converting, NOW),
+ session.report(PROVIDER, FundingUpdate::AwaitingPayment, NOW),
+ session.report(PROVIDER, FundingUpdate::Converting, NOW),
+ session.report(PROVIDER, FundingUpdate::Collecting { payment_id: [1; 32], amount: 5 }, NOW),
+ session.report(PROVIDER, FundingUpdate::Delivered, NOW),
+ ),
+ (
+ Err(ReportRefusal::NotFound),
+ Err(ReportRefusal::OutOfOrder),
+ Err(ReportRefusal::OutOfOrder),
+ Err(ReportRefusal::OutOfOrder),
+ Err(ReportRefusal::OutOfOrder),
+ )
+ );
+ }
+
+ // A partial claim is retried with a new top-up, as getcash does in up to
+ // three attempts; the host follows all of them once the provider is done.
+ #[test]
+ fn an_inbound_session_settles_from_every_top_up_it_names() {
+ let mut session = served(FundingDirection::In);
+ reported(
+ &mut session,
+ &[
+ FundingUpdate::Crediting { top_up_id: [1; 32], amount: 70 },
+ FundingUpdate::Crediting { top_up_id: [2; 32], amount: 30 },
+ ],
+ );
+ let repeated = session.report(PROVIDER, FundingUpdate::Crediting { top_up_id: [1; 32], amount: 30 }, NOW);
+ let due_before_done = session.settlement_due();
+ reported(&mut session, &[FundingUpdate::Delivered]);
+
+ assert_eq!(
+ (repeated, due_before_done, session.settlement_due()),
+ (
+ Err(ReportRefusal::OutOfOrder),
+ None,
+ Some(Settlement::TopUps(vec![([1; 32], 70), ([2; 32], 30)]))
+ )
+ );
+ }
+
+ // Once a top-up or payment started, the money is in motion: the provider
+ // cannot fail the session and the deadline does not end it.
+ #[test]
+ fn moving_funds_are_settled_by_the_host_not_the_provider_or_the_clock() {
+ let mut session = served(FundingDirection::Out);
+ reported(&mut session, &[FundingUpdate::Collecting { payment_id: [3; 32], amount: 40 }]);
+
+ let failed = session.report(PROVIDER, FundingUpdate::Failed { reason: FundingFailure::ProviderTimeout }, NOW);
+ let expired = session.expire_if_due(NOW + SESSION_WINDOW_MS);
+
+ assert_eq!(
+ (failed, expired, session.settlement_due()),
+ (Err(ReportRefusal::OutOfOrder), false, Some(Settlement::Payment([3; 32], 40)))
+ );
+ }
+
+ #[test]
+ fn settling_records_what_moved_and_nothing_fails_the_session() {
+ let mut credited = served(FundingDirection::In);
+ let mut debited = served(FundingDirection::Out);
+ let mut unclaimed = served(FundingDirection::In);
+
+ credited.settle(90, NOW);
+ debited.settle(40, NOW);
+ unclaimed.settle(0, NOW);
+
+ assert_eq!(
+ (credited.wire_item(), debited.wire_item(), unclaimed.wire_item()),
+ (
+ HostFundingStatusSubscribeItem::Delivered { credited: 90 },
+ HostFundingStatusSubscribeItem::Released { debited: 40 },
+ HostFundingStatusSubscribeItem::Failed {
+ reason: FundingFailure::Other {
+ code: "not_claimed".to_string(),
+ message: "Nothing reached your balance".to_string(),
+ },
+ moved: 0,
+ },
+ )
+ );
+ }
+
+ #[test]
+ fn a_provider_failure_ends_the_session_and_is_kept() {
+ let mut session = served(FundingDirection::In);
+ let failure = FundingUpdate::Failed { reason: FundingFailure::VerificationRefused };
+
+ reported(&mut session, &[FundingUpdate::AwaitingPayment, failure.clone()]);
+
+ assert_eq!(
+ (session.wire_item(), session.last_update()),
+ (
+ HostFundingStatusSubscribeItem::Failed {
+ reason: FundingFailure::VerificationRefused,
+ moved: 0,
+ },
+ Some(&failure)
)
);
}
diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs
index bfa8d34695..fe33d86d62 100644
--- a/rust/crates/truapi/src/lib.rs
+++ b/rust/crates/truapi/src/lib.rs
@@ -98,13 +98,14 @@ pub mod latest {
ChatMessageContent, ChatReaction, ChatRichText, ChatRoom, ChatRoomParticipation,
ChatRoomRegistrationStatus, ColorToken, ColumnProps, ContactHandle, ContactPickOutcome,
ContentAlignment, ContextualAlias, DerivationIndex, Dimensions, Effect, EffectProps,
- FundingDirection, FundingFailure, GenericError, HorizontalAlignment,
- HostAccountCreateProofRequest, HostAccountGetAliasRequest,
- HostAccountListRingVrfKeysRequest, HostAccountRegisterRingVrfKeyRequest,
- HostAccountRingVrfSignRequest, HostAccountSignVrfError, HostAccountSignVrfRequest,
- HostPlatform, HostSignPayloadData, HostWorkerOperationError, ImageFit, ImageProps,
- ImageSource, Modifier, OperationStartedResult, PaymentTopUpSource, PocketCard,
- ProductAccountId, ProductProofContext, RawPayload, RegisteredRingVrfKey, RemotePermission,
+ FundingAssignment, FundingDirection, FundingFailure, FundingFrameOutcome, FundingUpdate,
+ GenericError, HorizontalAlignment, HostAccountCreateProofRequest,
+ HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest,
+ HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest,
+ HostAccountSignVrfError, HostAccountSignVrfRequest, HostPlatform, HostSignPayloadData,
+ HostWorkerOperationError, ImageFit, ImageProps, ImageSource, Modifier,
+ OperationStartedResult, PaymentTopUpSource, PocketCard, ProductAccountId,
+ ProductProofContext, RawPayload, RegisteredRingVrfKey, RemotePermission,
RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest,
RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem,
RemoteStatementStoreSubscribeRequest, RenderContext, RendererNode, RingLocation,
@@ -213,6 +214,26 @@ pub mod latest {
/// Failure watching a funding session.
pub type HostFundingStatusSubscribeError =
LatestOf;
+ /// A provider's assigned sessions and cancel requests.
+ pub type HostFundingServeSubscribeItem =
+ LatestOf;
+ /// Failure serving funding sessions.
+ pub type HostFundingServeSubscribeError =
+ LatestOf;
+ /// A provider's progress report.
+ pub type HostFundingReportRequest =
+ LatestOf;
+ /// Failure reporting progress.
+ pub type HostFundingReportError = LatestOf;
+ /// Request to show a provider screen.
+ pub type HostFundingPresentFrameRequest =
+ LatestOf;
+ /// How a provider screen closed.
+ pub type HostFundingPresentFrameResponse =
+ LatestOf;
+ /// Failure showing a provider screen.
+ pub type HostFundingPresentFrameError =
+ LatestOf;
/// Payment request.
pub type HostPaymentRequest = LatestOf;
/// Payment request failure.
diff --git a/rust/crates/truapi/src/native/callbacks.rs b/rust/crates/truapi/src/native/callbacks.rs
index ec1f418216..5d74a4d983 100644
--- a/rust/crates/truapi/src/native/callbacks.rs
+++ b/rust/crates/truapi/src/native/callbacks.rs
@@ -327,6 +327,15 @@ pub trait NativeFundingCallbacks: Send + Sync {
amount: Option,
) -> Result;
+ /// Show provider `provider_id`'s screen at `route` for session `intent`
+ /// in a frame the host owns, and report how it closed.
+ async fn present_provider_frame(
+ &self,
+ provider_id: String,
+ intent: String,
+ route: String,
+ ) -> Result;
+
/// A session's status changed, for host UI such as the in-flight pill.
fn funding_session_changed(&self, intent: String, status: v01::HostFundingStatusSubscribeItem);
}
diff --git a/rust/crates/truapi/src/native/platform.rs b/rust/crates/truapi/src/native/platform.rs
index 962d79187d..4cf2362529 100644
--- a/rust/crates/truapi/src/native/platform.rs
+++ b/rust/crates/truapi/src/native/platform.rs
@@ -658,6 +658,18 @@ impl crate::platform::FundingPlatform for FundingCallbackPlatform {
.map_err(v01::GenericError::from)
}
+ async fn present_provider_frame(
+ &self,
+ provider: &ProductContext,
+ intent: String,
+ route: String,
+ ) -> Result {
+ self.funding
+ .present_provider_frame(provider.product_id.clone(), intent, route)
+ .await
+ .map_err(v01::GenericError::from)
+ }
+
fn funding_session_changed(&self, intent: String, status: v01::HostFundingStatusSubscribeItem) {
self.funding.funding_session_changed(intent, status);
}
diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs
index 13a7e77fd4..7c74eda383 100644
--- a/rust/crates/truapi/src/native/runtime.rs
+++ b/rust/crates/truapi/src/native/runtime.rs
@@ -329,10 +329,24 @@ impl NativeTrUApiHostRuntime {
Ok(self.runtime.acknowledge_funding_session(&intent).await?)
}
- /// Cancel open funding session `intent`.
+ /// Cancel funding session `intent` at the user's request: it ends if no
+ /// provider serves it, otherwise its provider is asked to stop.
pub async fn cancel_funding(&self, intent: String) -> Result {
Ok(self.runtime.cancel_funding(&intent).await?)
}
+
+ /// Hand open funding session `intent` to the provider the user chose, by
+ /// product id.
+ pub async fn select_funding_provider(
+ &self,
+ intent: String,
+ provider_id: String,
+ ) -> Result {
+ Ok(self
+ .runtime
+ .select_funding_provider(&intent, &provider_id)
+ .await?)
+ }
}
#[uniffi::export]
diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs
index 291130960f..d0b7cdef89 100644
--- a/rust/crates/truapi/src/platform.rs
+++ b/rust/crates/truapi/src/platform.rs
@@ -3347,6 +3347,15 @@ pub trait FundingPlatform: Send + Sync {
session: FundingPresentation,
) -> Result;
+ /// Show `provider`'s screen at `route` for session `intent`, such as its
+ /// KYC or card entry, in a frame the host owns, and answer once it closes.
+ async fn present_provider_frame(
+ &self,
+ provider: &ProductContext,
+ intent: String,
+ route: String,
+ ) -> Result;
+
/// Observe a session's status change, for host UI such as the in-flight
/// pill.
fn funding_session_changed(&self, intent: String, status: HostFundingStatusSubscribeItem) {
diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs
index e6021beb48..76251246e7 100644
--- a/rust/crates/truapi/src/runtime.rs
+++ b/rust/crates/truapi/src/runtime.rs
@@ -91,7 +91,7 @@ pub use signing_host::StatementRenewalTarget;
#[cfg(not(target_arch = "wasm32"))]
pub use signing_host::TrackedStatementRenewalTarget;
use tracing::{instrument, warn};
-use truapi::api::{Chat, Contacts, Funding, Pocket, Renderer};
+use truapi::api::{Chat, Contacts, Funding, FundingProvider, Pocket, Renderer};
use truapi::versioned::account::{HostAccountGetError, HostAccountSignVrfError};
use truapi::versioned::chat::{
HostChatActionSubscribeError, HostChatActionSubscribeItem, HostChatActionSubscribeRequest,
@@ -107,6 +107,12 @@ use truapi::versioned::funding::{
HostFundingError, HostFundingRequest, HostFundingResponse, HostFundingStatusSubscribeError,
HostFundingStatusSubscribeItem, HostFundingStatusSubscribeRequest,
};
+use truapi::versioned::funding_provider::{
+ HostFundingPresentFrameError, HostFundingPresentFrameRequest, HostFundingPresentFrameResponse,
+ HostFundingReportError, HostFundingReportRequest, HostFundingReportResponse,
+ HostFundingServeSubscribeError, HostFundingServeSubscribeItem,
+ HostFundingServeSubscribeRequest,
+};
use truapi::versioned::pocket::{
HostPocketListSubscribeError, HostPocketListSubscribeItem, HostPocketListSubscribeRequest,
HostPocketRemoveCardError, HostPocketRemoveCardRequest, HostPocketRemoveCardResponse,
@@ -128,6 +134,7 @@ use crate::host_internal::sso_messages::RingVrfError;
use crate::host_logic::product_account::{
derivation_index_bytes, derive_product_public_key, public_key_from_address,
};
+use crate::host_logic::funding::ReportRefusal;
use crate::host_logic::session::SessionInfo;
#[cfg(test)]
use crate::host_logic::session::SessionState;
@@ -1267,6 +1274,18 @@ impl ProductRuntimeHost {
self.pocket_platform.clone().ok_or(CallError::Unsupported)
}
+ /// Funding provider access policy for this connection: a provider serves
+ /// sessions only from a Worker execution with an active session, and only
+ /// where the host has a funding surface.
+ fn funding_provider_access(&self) -> Result, CallError> {
+ if self.product.execution_kind != crate::platform::ProductExecutionKind::Worker
+ || self.authority.session_state().current().is_none()
+ {
+ return Err(CallError::Denied);
+ }
+ self.services.funding().platform().ok_or(CallError::Unsupported)
+ }
+
/// Replace the contact handles a call declares with the accounts they
/// name, before the call is shown to the user or signed.
///
@@ -1564,6 +1583,98 @@ impl Renderer for ProductRuntimeHost {
}
}
+#[truapi::async_trait]
+impl FundingProvider for ProductRuntimeHost {
+ #[instrument(skip_all, fields(runtime.method = "funding_provider.serve_subscribe"))]
+ async fn serve_subscribe(
+ &self,
+ _cx: &CallContext,
+ _request: HostFundingServeSubscribeRequest,
+ ) -> Subscription>
+ {
+ if let Err(error) = self.funding_provider_access() {
+ return Subscription::interrupted(error);
+ }
+ let registry = self.services.funding();
+ registry.bind(&self.services);
+ let loaded = registry
+ .commit(self.platform.as_ref(), current_unix_millis(), |_| {
+ ((), Vec::new())
+ })
+ .await;
+ if let Err(error) = loaded {
+ return Subscription::interrupted(CallError::HostFailure {
+ reason: error.to_string(),
+ });
+ }
+ registry.keep_expiring(&self.services);
+ Subscription::new(Box::pin(
+ registry
+ .serve(&self.product.product_id)
+ .map(|item| Ok(HostFundingServeSubscribeItem::V1(item))),
+ ))
+ }
+
+ #[instrument(skip_all, fields(runtime.method = "funding_provider.report"))]
+ async fn report(
+ &self,
+ _cx: &CallContext,
+ request: HostFundingReportRequest,
+ ) -> Result> {
+ let HostFundingReportRequest::V1(request) = request;
+ self.funding_provider_access()?;
+ let registry = self.services.funding();
+ registry.bind(&self.services);
+ let reported = registry
+ .report(
+ self.platform.as_ref(),
+ current_unix_millis(),
+ &self.product.product_id,
+ &request.intent,
+ request.update,
+ )
+ .await
+ .map_err(|error| CallError::HostFailure {
+ reason: error.to_string(),
+ })?;
+ let domain = |error| CallError::Domain(HostFundingReportError::V1(error));
+ match reported {
+ Ok(()) => Ok(HostFundingReportResponse::V1),
+ Err(ReportRefusal::NotFound) => Err(domain(v01::HostFundingReportError::NotFound)),
+ Err(ReportRefusal::OutOfOrder) => Err(domain(v01::HostFundingReportError::OutOfOrder)),
+ Err(ReportRefusal::DuplicateId) => Err(domain(v01::HostFundingReportError::DuplicateId)),
+ }
+ }
+
+ #[instrument(skip_all, fields(runtime.method = "funding_provider.present_frame"))]
+ async fn present_frame(
+ &self,
+ _cx: &CallContext,
+ request: HostFundingPresentFrameRequest,
+ ) -> Result> {
+ let HostFundingPresentFrameRequest::V1(request) = request;
+ let platform = self.funding_provider_access()?;
+ if !self
+ .services
+ .funding()
+ .is_serving(&self.product.product_id, &request.intent)
+ {
+ return Err(CallError::Domain(HostFundingPresentFrameError::V1(
+ v01::HostFundingPresentFrameError::NotFound,
+ )));
+ }
+ let outcome = platform
+ .present_provider_frame(&self.product, request.intent, request.route)
+ .await
+ .map_err(|error| CallError::HostFailure {
+ reason: error.reason,
+ })?;
+ Ok(HostFundingPresentFrameResponse::V1(
+ v01::HostFundingPresentFrameResponse { outcome },
+ ))
+ }
+}
+
#[truapi::async_trait]
impl Funding for ProductRuntimeHost {
#[instrument(skip_all, fields(runtime.method = "funding.request"))]
diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs
index c09fe8892f..fd9ded08d4 100644
--- a/rust/crates/truapi/src/runtime/funding.rs
+++ b/rust/crates/truapi/src/runtime/funding.rs
@@ -9,7 +9,7 @@
//! writes so persisted snapshots land in order, persists before anyone hears of
//! the change, and then notifies subscribers and the host.
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock, Weak};
@@ -18,15 +18,21 @@ use core::time::Duration;
use futures::channel::mpsc;
use futures::lock::Mutex as AsyncMutex;
use futures::stream::{self, BoxStream, StreamExt};
-use truapi::latest::{FundingDirection, GenericError, HostFundingStatusSubscribeItem};
+use truapi::latest::{
+ FundingDirection, FundingUpdate, GenericError, HostFundingServeSubscribeItem,
+ HostFundingStatusSubscribeItem, HostPaymentStatusSubscribeError,
+ HostPaymentStatusSubscribeItem, HostPaymentTopUpStatusSubscribeError,
+ HostPaymentTopUpStatusSubscribeItem,
+};
use super::services::RuntimeServices;
use crate::host_logic::funding::{
- FundingSession, FundingSessionError, load_sessions, retained, store_sessions,
+ CancelOutcome, FundingSession, FundingSessionError, ReportRefusal, Settlement, load_sessions,
+ retained, store_sessions,
};
use crate::platform::{
CoreStorage, FundingPlatform, FundingPresentOutcome, FundingPresentation, Platform,
- ProductContext,
+ ProductContext, ProductExecutionKind,
};
use crate::unix_time::current_unix_millis;
@@ -34,6 +40,7 @@ use crate::unix_time::current_unix_millis;
const SWEEP_RETRY: Duration = Duration::from_secs(30);
type Subscribers = HashMap>>;
+type Servers = HashMap>>;
/// Host-global funding sessions, their subscribers and the host surface.
#[derive(Default)]
@@ -46,6 +53,16 @@ pub struct FundingRegistry {
/// Whether a task is waiting on the next deadline.
sweeping: AtomicBool,
platform: OnceLock>,
+ /// Each provider's open `serve_subscribe` streams, by product id.
+ servers: Mutex,
+ /// Sessions holding a reference on their provider's worker, with that
+ /// provider.
+ holding: Mutex>,
+ /// Sessions whose top-ups or payment are being followed to an outcome.
+ following: Mutex>,
+ /// The services the registry belongs to, for worker references and
+ /// following top-ups and payments.
+ services: OnceLock>,
}
impl FundingRegistry {
@@ -60,6 +77,113 @@ impl FundingRegistry {
self.platform.get().cloned()
}
+ /// Attach the registry to the services it belongs to. Set-once.
+ pub fn bind(&self, services: &Arc) {
+ let _ = self.services.set(Arc::downgrade(services));
+ }
+
+ /// Assign open session `intent` to the provider the user chose, and hand
+ /// it to that provider. Returns whether it was open and unassigned.
+ pub async fn select_provider(
+ &self,
+ storage: &(impl CoreStorage + ?Sized),
+ now_ms: u64,
+ intent: &str,
+ provider_id: &str,
+ ) -> Result {
+ let intent = intent.to_string();
+ let provider = provider_id.to_string();
+ let assigned = self
+ .commit(storage, now_ms, move |sessions| {
+ let assigned = sessions
+ .get_mut(&intent)
+ .and_then(|session| session.assign(&provider).then(|| session.assignment()));
+ let changed = assigned.iter().map(|session| session.intent.clone()).collect();
+ (assigned, changed)
+ })
+ .await?;
+ let Some(session) = assigned else {
+ return Ok(false);
+ };
+ self.serve_item(provider_id, HostFundingServeSubscribeItem::Assigned { session });
+ Ok(true)
+ }
+
+ /// The sessions assigned to `provider_id` and requests to cancel them:
+ /// every one still in flight first, then each later one. An assignment
+ /// may arrive more than once.
+ pub fn serve(&self, provider_id: &str) -> BoxStream<'static, HostFundingServeSubscribeItem> {
+ let mut servers = self.lock_servers();
+ let mut assigned: Vec<_> = self
+ .lock_sessions()
+ .values()
+ .filter(|session| {
+ !session.is_terminal() && session.provider_id.as_deref() == Some(provider_id)
+ })
+ .cloned()
+ .collect();
+ assigned.sort_by_key(|session| session.opened_at_ms);
+ let replay: Vec<_> = assigned
+ .into_iter()
+ .flat_map(|session| {
+ let cancel = session
+ .cancel_requested
+ .then(|| HostFundingServeSubscribeItem::Cancel {
+ intent: session.intent.clone(),
+ });
+ std::iter::once(HostFundingServeSubscribeItem::Assigned {
+ session: session.assignment(),
+ })
+ .chain(cancel)
+ })
+ .collect();
+ let (sender, receiver) = mpsc::unbounded();
+ servers
+ .entry(provider_id.to_string())
+ .or_default()
+ .push(sender);
+ stream::iter(replay).chain(receiver).boxed()
+ }
+
+ /// Store `update` from `provider_id` on session `intent`.
+ pub async fn report(
+ &self,
+ storage: &(impl CoreStorage + ?Sized),
+ now_ms: u64,
+ provider_id: &str,
+ intent: &str,
+ update: FundingUpdate,
+ ) -> Result, FundingSessionError> {
+ let intent = intent.to_string();
+ let provider = provider_id.to_string();
+ self.commit(storage, now_ms, move |sessions| {
+ // Ids are the provider's own, so one named by another of its
+ // sessions would settle two sessions from a single claim.
+ let duplicate = FundingSession::named_id(&update).is_some_and(|id| {
+ sessions.values().any(|other| {
+ other.intent != intent
+ && other.provider_id.as_deref() == Some(provider.as_str())
+ && other.names(&id)
+ })
+ });
+ let reported = match sessions.get_mut(&intent) {
+ Some(_) if duplicate => Err(ReportRefusal::DuplicateId),
+ Some(session) => session.report(&provider, update, now_ms),
+ None => Err(ReportRefusal::NotFound),
+ };
+ let changed = if reported.is_ok() { vec![intent] } else { Vec::new() };
+ (reported, changed)
+ })
+ .await
+ }
+
+ /// Whether open session `intent` is assigned to `provider_id`.
+ pub fn is_serving(&self, provider_id: &str, intent: &str) -> bool {
+ self.get(intent).is_some_and(|session| {
+ !session.is_terminal() && session.provider_id.as_deref() == Some(provider_id)
+ })
+ }
+
/// Snapshot one session.
pub fn get(&self, intent: &str) -> Option {
self.lock_sessions().get(intent).cloned()
@@ -99,21 +223,35 @@ impl FundingRegistry {
.await
}
- /// Cancel open session `intent`. Returns whether it was still open.
+ /// Cancel session `intent` at the user's request: end it if no provider
+ /// serves it, otherwise ask the provider to stop.
pub async fn cancel(
&self,
storage: &(impl CoreStorage + ?Sized),
now_ms: u64,
intent: &str,
- ) -> Result {
- let intent = intent.to_string();
- self.commit(storage, now_ms, move |sessions| {
- let cancelled = sessions
- .get_mut(&intent)
- .is_some_and(|session| session.cancel(now_ms));
- (cancelled, if cancelled { vec![intent] } else { Vec::new() })
- })
- .await
+ ) -> Result {
+ let target = intent.to_string();
+ let (outcome, provider) = self
+ .commit(storage, now_ms, move |sessions| {
+ let Some(session) = sessions.get_mut(&target) else {
+ return ((CancelOutcome::Refused, None), Vec::new());
+ };
+ let outcome = session.cancel(now_ms);
+ let provider = session.provider_id.clone();
+ let changed = if outcome == CancelOutcome::Refused { Vec::new() } else { vec![target] };
+ ((outcome, provider), changed)
+ })
+ .await?;
+ if let (CancelOutcome::Requested, Some(provider)) = (outcome, provider) {
+ self.serve_item(
+ &provider,
+ HostFundingServeSubscribeItem::Cancel {
+ intent: intent.to_string(),
+ },
+ );
+ }
+ Ok(outcome)
}
/// Watch one session, receiving its current stage immediately. A terminal
@@ -182,9 +320,68 @@ impl FundingRegistry {
self.fan_out(&session);
}
}
+ drop(loaded);
+ self.follow_sessions();
Ok(result)
}
+ /// Bring worker references and followers in line with the sessions:
+ /// every assigned open session holds its provider's worker, and every
+ /// session whose outcome is due has its top-ups or payment followed.
+ fn follow_sessions(&self) {
+ let Some(services) = self.services.get().and_then(Weak::upgrade) else {
+ return;
+ };
+ let sessions = self.lock_sessions().clone();
+ let (acquire, release) = {
+ let mut holding = self.lock_holding();
+ let mut acquire = Vec::new();
+ for session in sessions.values().filter(|session| !session.is_terminal()) {
+ if let Some(provider) = &session.provider_id
+ && !holding.contains_key(&session.intent)
+ {
+ holding.insert(session.intent.clone(), provider.clone());
+ acquire.push(provider.clone());
+ }
+ }
+ let ended: Vec = holding
+ .keys()
+ .filter(|intent| sessions.get(*intent).is_none_or(FundingSession::is_terminal))
+ .cloned()
+ .collect();
+ let release: Vec = ended
+ .iter()
+ .filter_map(|intent| holding.remove(intent))
+ .collect();
+ (acquire, release)
+ };
+ for provider in &acquire {
+ services.worker_ledger.acquire(provider);
+ }
+ for provider in &release {
+ services.worker_ledger.release(provider);
+ }
+ for session in sessions.values() {
+ let (Some(settlement), Some(provider)) =
+ (session.settlement_due(), session.provider_id.clone())
+ else {
+ continue;
+ };
+ if self.lock_following().insert(session.intent.clone()) {
+ services
+ .clone()
+ .follow_settlement(session.intent.clone(), provider, settlement);
+ }
+ }
+ }
+
+ /// Send `item` to every open stream of `provider_id`.
+ fn serve_item(&self, provider_id: &str, item: HostFundingServeSubscribeItem) {
+ if let Some(senders) = self.lock_servers().get_mut(provider_id) {
+ senders.retain(|sender| sender.unbounded_send(item.clone()).is_ok());
+ }
+ }
+
/// 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.
@@ -267,6 +464,20 @@ impl FundingRegistry {
.lock()
.expect("funding subscribers mutex poisoned")
}
+
+ fn lock_servers(&self) -> std::sync::MutexGuard<'_, Servers> {
+ self.servers.lock().expect("funding servers mutex poisoned")
+ }
+
+ fn lock_holding(&self) -> std::sync::MutexGuard<'_, HashMap> {
+ self.holding.lock().expect("funding holding mutex poisoned")
+ }
+
+ fn lock_following(&self) -> std::sync::MutexGuard<'_, HashSet> {
+ self.following
+ .lock()
+ .expect("funding following mutex poisoned")
+ }
}
/// Why a session could not be opened.
@@ -290,6 +501,7 @@ impl RuntimeServices {
/// Load persisted sessions and arm expiry for them, so sessions from
/// before a restart end on time and the host hears of them.
pub fn resume_funding(self: &Arc) {
+ self.funding().bind(self);
let services = self.clone();
(self.spawner)(Box::pin(async move {
let registry = services.funding();
@@ -346,13 +558,150 @@ impl RuntimeServices {
.await
}
- /// Cancel open session `intent`. Returns whether it was still open.
+ /// Cancel session `intent` at the user's request. Returns whether the
+ /// cancel was taken: the session ended, or its provider was asked to
+ /// stop.
pub async fn cancel_funding(&self, intent: &str) -> Result {
- self.funding()
+ let outcome = self
+ .funding()
.cancel(self.platform.as_ref(), current_unix_millis(), intent)
+ .await?;
+ Ok(outcome != CancelOutcome::Refused)
+ }
+
+ /// Hand open session `intent` to the provider the user chose. Returns
+ /// whether it was open and not yet assigned.
+ pub async fn select_funding_provider(
+ self: &Arc,
+ intent: &str,
+ provider_id: &str,
+ ) -> Result {
+ let provider = ProductContext::new_with_execution(
+ provider_id.to_string(),
+ ProductExecutionKind::Worker,
+ )
+ .map_err(|error| FundingSessionError::InvalidProvider {
+ reason: error.to_string(),
+ })?;
+ let registry = self.funding();
+ registry.bind(self);
+ registry
+ .select_provider(
+ self.platform.as_ref(),
+ current_unix_millis(),
+ intent,
+ &provider.product_id,
+ )
.await
}
+ /// Follow a session's top-ups or payment to the end, then settle it with
+ /// what moved. A stream that ends without an outcome leaves the session
+ /// to be followed again on the next change to the sessions.
+ fn follow_settlement(self: Arc, intent: String, provider_id: String, settlement: Settlement) {
+ let spawner = self.spawner.clone();
+ spawner(Box::pin(async move {
+ let registry = self.funding();
+ let provider =
+ match ProductContext::new_with_execution(provider_id, ProductExecutionKind::Worker) {
+ Ok(provider) => provider,
+ Err(error) => {
+ tracing::warn!(%error, "a funding session names an invalid provider");
+ registry.lock_following().remove(&intent);
+ return;
+ }
+ };
+ let moved = match settlement {
+ Settlement::TopUps(top_ups) => self.claimed(&provider, top_ups).await,
+ Settlement::Payment(id, amount) => self.paid(&provider, id, amount).await,
+ };
+ if let Some(moved) = moved {
+ let settling = intent.clone();
+ let settled = registry
+ .commit(self.platform.as_ref(), current_unix_millis(), move |sessions| {
+ let now_ms = current_unix_millis();
+ let settled = sessions
+ .get_mut(&settling)
+ .is_some_and(|session| session.settle(moved, now_ms));
+ ((), if settled { vec![settling] } else { Vec::new() })
+ })
+ .await;
+ if let Err(error) = settled {
+ tracing::warn!(%error, "settling a funding session failed");
+ }
+ }
+ registry.lock_following().remove(&intent);
+ }));
+ }
+
+ /// What `top_ups` credited: the full amount of each one claimed, what a
+ /// partial claim reports, nothing for one not claimed or unknown to the
+ /// host. `None` if a status could not be read to its end.
+ async fn claimed(&self, provider: &ProductContext, top_ups: Vec<([u8; 32], u128)>) -> Option {
+ let Some(platform) = self.top_up_platform() else {
+ tracing::warn!("no top-up platform to follow a funding session's claims");
+ return None;
+ };
+ let mut credited: u128 = 0;
+ for (id, amount) in top_ups {
+ let mut claimed = None;
+ let mut statuses = platform.subscribe_top_up_status(provider, id);
+ while let Some(status) = statuses.next().await {
+ match status {
+ Ok(HostPaymentTopUpStatusSubscribeItem::Claimed { finalized }) => {
+ claimed = Some(amount);
+ if finalized {
+ break;
+ }
+ }
+ Ok(HostPaymentTopUpStatusSubscribeItem::ClaimedPartially { actual_claimed }) => {
+ claimed = Some(actual_claimed);
+ break;
+ }
+ Ok(HostPaymentTopUpStatusSubscribeItem::NotClaimed)
+ | Err(HostPaymentTopUpStatusSubscribeError::NotFound) => {
+ claimed = Some(0);
+ break;
+ }
+ Ok(_) => {}
+ Err(HostPaymentTopUpStatusSubscribeError::Unknown { reason }) => {
+ tracing::warn!(%reason, "reading a top-up's status failed");
+ return None;
+ }
+ }
+ }
+ credited = credited.saturating_add(claimed?);
+ }
+ Some(credited)
+ }
+
+ /// What payment `id` moved: its amount once completed, what a partial
+ /// claim reports, nothing once failed or unknown to the host. `None` if
+ /// its status could not be read to its end.
+ async fn paid(&self, provider: &ProductContext, id: [u8; 32], amount: u128) -> Option {
+ let Some(platform) = self.payment_platform() else {
+ tracing::warn!("no payment platform to follow a funding session's payment");
+ return None;
+ };
+ let mut statuses = platform.subscribe_payment_status(provider, id);
+ while let Some(status) = statuses.next().await {
+ match status {
+ Ok(HostPaymentStatusSubscribeItem::Completed) => return Some(amount),
+ Ok(HostPaymentStatusSubscribeItem::PartiallyClaimed { actual_claimed }) => {
+ return Some(actual_claimed);
+ }
+ Ok(HostPaymentStatusSubscribeItem::Failed { .. })
+ | Err(HostPaymentStatusSubscribeError::PaymentNotFound) => return Some(0),
+ Ok(HostPaymentStatusSubscribeItem::Processing) => {}
+ Err(HostPaymentStatusSubscribeError::Unknown { reason }) => {
+ tracing::warn!(%reason, "reading a payment's status failed");
+ return None;
+ }
+ }
+ }
+ None
+ }
+
/// Open a session and show the host's funding overlay for it: the one
/// path a product's `request` and the host's own Balance card both take.
///
@@ -365,6 +714,7 @@ impl RuntimeServices {
amount: Option,
) -> Result {
let registry = self.funding();
+ registry.bind(self);
let platform = registry.platform().ok_or(OpenFundingError::Unsupported)?;
let now_ms = current_unix_millis();
let session = FundingSession::new(
@@ -434,7 +784,7 @@ mod tests {
use futures::executor::block_on;
use truapi::latest::FundingFailure;
- use crate::host_logic::funding::FundingStage;
+ use crate::host_logic::funding::{FundingStage, ReportRefusal};
use crate::test_support::stub_platform;
const NOW: u64 = 1_700_000_000_000;
@@ -549,6 +899,44 @@ mod tests {
);
}
+ // A provider's top-up and payment ids are its own, so naming one in two
+ // sessions would settle both from a single claim and show the money twice
+ // in the user's history. Another provider's ids are a different namespace.
+ #[test]
+ fn a_provider_cannot_name_one_top_up_in_two_sessions() {
+ let storage = stub_platform();
+ let registry = FundingRegistry::default();
+ let served = |intent: &str, provider: &str| {
+ let mut session = session(intent, NOW);
+ assert!(session.assign(provider));
+ session
+ };
+ for session in [
+ served("fs_a", "ramp.dot"),
+ served("fs_b", "ramp.dot"),
+ served("fs_c", "other.dot"),
+ ] {
+ insert(®istry, storage.as_ref(), session);
+ }
+ let crediting = FundingUpdate::Crediting {
+ top_up_id: [9; 32],
+ amount: 100,
+ };
+ let report = |provider: &str, intent: &str| {
+ block_on(registry.report(storage.as_ref(), NOW, provider, intent, crediting.clone()))
+ .expect("stored")
+ };
+
+ assert_eq!(
+ (
+ report("ramp.dot", "fs_a"),
+ report("ramp.dot", "fs_b"),
+ report("other.dot", "fs_c"),
+ ),
+ (Ok(()), Err(ReportRefusal::DuplicateId), Ok(()))
+ );
+ }
+
#[test]
fn a_failed_save_changes_nothing_in_memory() {
let storage = stub_platform();
diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs
index 0cf130837a..8d5fb3da98 100644
--- a/rust/crates/truapi/src/runtime/tests.rs
+++ b/rust/crates/truapi/src/runtime/tests.rs
@@ -2266,6 +2266,15 @@ impl crate::platform::FundingPlatform for RecordingFundingPlatform {
Ok(self.outcome)
}
+ async fn present_provider_frame(
+ &self,
+ _provider: &ProductContext,
+ _intent: String,
+ _route: String,
+ ) -> Result {
+ Ok(truapi::latest::FundingFrameOutcome::Closed)
+ }
+
fn funding_session_changed(
&self,
intent: String,
@@ -2566,6 +2575,168 @@ fn a_test_host_settles_a_funding_session_once() {
);
}
+fn funding_services_over(platform: Arc) -> Arc {
+ let (host_config, _) = runtime_config("funding.dot");
+ RuntimeServices::new(
+ platform,
+ host_config.host.host_info.clone(),
+ host_config.people_chain_genesis_hash,
+ host_config.bulletin_chain_genesis_hash,
+ host_config.asset_hub_chain_genesis_hash,
+ test_spawner(),
+ )
+}
+
+/// A provider's worker connection, signed in.
+fn provider_worker(services: &Arc, provider_id: &str) -> ProductRuntimeHost {
+ let (host_config, _) = runtime_config(provider_id);
+ let product = ProductContext::new_with_execution(
+ provider_id.to_string(),
+ crate::platform::ProductExecutionKind::Worker,
+ )
+ .expect("test provider context is valid");
+ let pairing_host = PairingHost::new(services.clone(), host_config);
+ let host = ProductRuntimeHost::from_services(
+ services.clone(),
+ crate::host_core::ConnectionAdapters::from_services(services),
+ pairing_host,
+ product,
+ );
+ install_pairing_session(&host, session_info());
+ host
+}
+
+fn first_served(
+ host: &ProductRuntimeHost,
+) -> Option>> {
+ futures::executor::block_on(
+ futures::executor::block_on(truapi::api::FundingProvider::serve_subscribe(
+ host,
+ &CallContext::default(),
+ truapi::versioned::funding_provider::HostFundingServeSubscribeRequest::V1,
+ ))
+ .next(),
+ )
+ .map(|item| item.map(|truapi::versioned::funding_provider::HostFundingServeSubscribeItem::V1(item)| item))
+}
+
+fn report_funding(
+ host: &ProductRuntimeHost,
+ intent: &str,
+ update: v01::FundingUpdate,
+) -> Result<(), CallError> {
+ futures::executor::block_on(truapi::api::FundingProvider::report(
+ host,
+ &CallContext::default(),
+ truapi::versioned::funding_provider::HostFundingReportRequest::V1(
+ v01::HostFundingReportRequest {
+ intent: intent.to_string(),
+ update,
+ },
+ ),
+ ))
+ .map(|_| ())
+}
+
+fn wait_for_stage(
+ services: &RuntimeServices,
+ intent: &str,
+) -> Option {
+ for _ in 0..200 {
+ let session = services.funding().get(intent)?;
+ if session.is_terminal() {
+ return Some(session.wire_item());
+ }
+ std::thread::sleep(Duration::from_millis(10));
+ }
+ None
+}
+
+// The provider's worker takes a session from assignment to delivered, and a
+// restart in between loses nothing: the restarted worker is handed the
+// session again with its last update, and the host still decides delivery
+// from the claim of the top-up the provider named.
+#[test]
+fn a_provider_worker_delivers_a_session_across_a_restart() {
+ let storage = stub_platform();
+ let start = |storage: &Arc| {
+ let services = funding_services_over(storage.clone());
+ assert!(services.funding().install_platform(RecordingFundingPlatform::answering(
+ crate::platform::FundingPresentOutcome::Started,
+ )));
+ assert!(services.install_top_up_platform(Arc::new(RecordingTopUpPlatform::default())));
+ services.resume_funding();
+ services
+ };
+ let before = start(&storage);
+ let intent = futures::executor::block_on(before.open_funding(
+ None,
+ v01::FundingDirection::In,
+ Some(1_000),
+ ))
+ .expect("opened")
+ .intent;
+ assert!(futures::executor::block_on(before.select_funding_provider(&intent, "ramp.dot")).expect("selected"));
+ let worker = provider_worker(&before, "ramp.dot");
+ let assigned = first_served(&worker);
+ let crediting = v01::FundingUpdate::Crediting { top_up_id: [7; 32], amount: 1_000 };
+ report_funding(&worker, &intent, v01::FundingUpdate::Converting).expect("converting");
+ report_funding(&worker, &intent, crediting.clone()).expect("crediting");
+
+ let after = start(&storage);
+ let restarted = provider_worker(&after, "ramp.dot");
+ let replayed = first_served(&restarted);
+ report_funding(&restarted, &intent, v01::FundingUpdate::Delivered).expect("delivered");
+
+ let assignment = |last_update| {
+ Some(Ok(v01::HostFundingServeSubscribeItem::Assigned {
+ session: v01::FundingAssignment {
+ intent: intent.clone(),
+ direction: v01::FundingDirection::In,
+ amount: Some(1_000),
+ expires_at: after.funding().get(&intent).expect("kept").deadline_ms,
+ last_update,
+ },
+ }))
+ };
+ assert_eq!(
+ (assigned, replayed, wait_for_stage(&after, &intent)),
+ (
+ assignment(None),
+ assignment(Some(crediting)),
+ Some(v01::HostFundingStatusSubscribeItem::Delivered { credited: 1_000 }),
+ )
+ );
+}
+
+// Only the assigned provider's worker may serve or report on a session.
+#[test]
+fn only_the_assigned_provider_worker_reports() {
+ let services = funding_services();
+ assert!(services.funding().install_platform(RecordingFundingPlatform::answering(
+ crate::platform::FundingPresentOutcome::Started,
+ )));
+ let intent = futures::executor::block_on(services.open_funding(None, v01::FundingDirection::In, None))
+ .expect("opened")
+ .intent;
+ assert!(futures::executor::block_on(services.select_funding_provider(&intent, "ramp.dot")).expect("selected"));
+ let app = funding_host(&services, "ramp.dot", true);
+ let other = provider_worker(&services, "other.dot");
+
+ assert!(matches!(
+ (
+ report_funding(&app, &intent, v01::FundingUpdate::AwaitingPayment),
+ report_funding(&other, &intent, v01::FundingUpdate::AwaitingPayment),
+ ),
+ (
+ Err(CallError::Denied),
+ Err(CallError::Domain(truapi::versioned::funding_provider::HostFundingReportError::V1(
+ v01::HostFundingReportError::NotFound
+ ))),
+ )
+ ));
+}
+
#[derive(Default)]
struct RecordingTopUpPlatform {
started: Mutex>,
diff --git a/rust/crates/truapi/src/v01.rs b/rust/crates/truapi/src/v01.rs
index c3ddd88b55..9756a6eb6b 100644
--- a/rust/crates/truapi/src/v01.rs
+++ b/rust/crates/truapi/src/v01.rs
@@ -8,6 +8,7 @@ mod common;
mod contacts;
mod entropy;
mod funding;
+mod funding_provider;
mod local_storage;
mod locale;
mod notifications;
@@ -32,6 +33,7 @@ pub use common::*;
pub use contacts::*;
pub use entropy::*;
pub use funding::*;
+pub use funding_provider::*;
pub use local_storage::*;
pub use locale::*;
pub use notifications::*;
diff --git a/rust/crates/truapi/src/v01/funding_provider.rs b/rust/crates/truapi/src/v01/funding_provider.rs
new file mode 100644
index 0000000000..5d41a19084
--- /dev/null
+++ b/rust/crates/truapi/src/v01/funding_provider.rs
@@ -0,0 +1,164 @@
+use parity_scale_codec::{Decode, Encode};
+
+use super::funding::{FundingDirection, FundingFailure};
+
+/// A funding session as the provider serving it receives it.
+#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
+pub struct FundingAssignment {
+ /// Session id, used in every report about it.
+ pub intent: String,
+ /// Which way value moves.
+ pub direction: FundingDirection,
+ /// Amount sought, in the user's payment balance units. `None` when the
+ /// user left it to the provider's screens.
+ pub amount: Option,
+ /// When the session expires unless funds are already moving, in Unix
+ /// milliseconds.
+ pub expires_at: u64,
+ /// The last update the provider reported, so a restarted worker resumes
+ /// where it stopped.
+ pub last_update: Option,
+}
+
+/// Progress a provider reports for a session it serves.
+///
+/// Updates only move forward. Once funds are moving the host decides the
+/// outcome: an inbound session is delivered from the claims of the top-ups it
+/// names, an outbound one is released once the user's payment completes.
+#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
+#[cfg_attr(
+ all(feature = "runtime", not(target_arch = "wasm32")),
+ derive(uniffi::Enum)
+)]
+pub enum FundingUpdate {
+ /// Inbound: waiting for the user to pay the provider.
+ AwaitingPayment,
+ /// Inbound: the provider has seen the user's payment.
+ PaymentReceived {
+ /// Whether the payment can no longer be reversed.
+ finalized: bool,
+ },
+ /// Inbound: the provider is converting the payment to the user's balance
+ /// asset.
+ Converting,
+ /// Inbound: the provider started a top-up with `payment.topUp` to credit
+ /// the user. A partial claim may be followed by further top-ups.
+ Crediting {
+ /// Id the top-up was started with.
+ top_up_id: [u8; 32],
+ /// Amount the top-up asks to credit.
+ amount: u128,
+ },
+ /// Inbound: the provider started every top-up it will. The session is
+ /// delivered with what they claimed.
+ Delivered,
+ /// Outbound: the provider asked the user to pay it with
+ /// `payment.request`. The session is released once that payment
+ /// completes.
+ Collecting {
+ /// Id the payment request was made with.
+ payment_id: [u8; 32],
+ /// Amount the payment asks for.
+ amount: u128,
+ },
+ /// The session ended without success, before any funds moved.
+ Failed {
+ /// Why it ended.
+ reason: FundingFailure,
+ },
+}
+
+/// Item of [`crate::api::FundingProvider::serve_subscribe`].
+#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
+pub enum HostFundingServeSubscribeItem {
+ /// A session the user chose this provider for, or one still in flight
+ /// when the stream opened.
+ Assigned {
+ /// The session.
+ session: FundingAssignment,
+ },
+ /// The user asked to cancel a session. The provider reports `Failed`
+ /// with `Cancelled` if it can still stop.
+ Cancel {
+ /// Session to cancel.
+ intent: String,
+ },
+}
+
+/// Error from [`crate::api::FundingProvider::serve_subscribe`].
+#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
+pub enum HostFundingServeSubscribeError {
+ /// Catch-all.
+ Unknown {
+ /// Human-readable failure reason.
+ reason: String,
+ },
+}
+
+/// Request to report progress on a session.
+#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
+pub struct HostFundingReportRequest {
+ /// Session the update is about.
+ pub intent: String,
+ /// The update.
+ pub update: FundingUpdate,
+}
+
+/// Error from [`crate::api::FundingProvider::report`].
+#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
+pub enum HostFundingReportError {
+ /// No open session with this id is assigned to the caller.
+ NotFound,
+ /// The update does not follow the session's last one, or does not fit
+ /// its direction.
+ OutOfOrder,
+ /// The top-up or payment request it names is already named by another of
+ /// the caller's sessions.
+ DuplicateId,
+ /// Catch-all.
+ Unknown {
+ /// Human-readable failure reason.
+ reason: String,
+ },
+}
+
+/// Request to show one of the provider's screens for a session.
+#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
+pub struct HostFundingPresentFrameRequest {
+ /// Session the screen belongs to.
+ pub intent: String,
+ /// Route of the provider's App to show, such as its KYC or card entry.
+ pub route: String,
+}
+
+/// How a provider screen closed.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)]
+#[cfg_attr(
+ all(feature = "runtime", not(target_arch = "wasm32")),
+ derive(uniffi::Enum)
+)]
+pub enum FundingFrameOutcome {
+ /// The provider's screen finished and closed itself.
+ Closed,
+ /// The user closed it.
+ Dismissed,
+}
+
+/// Response of [`crate::api::FundingProvider::present_frame`].
+#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
+pub struct HostFundingPresentFrameResponse {
+ /// How the screen closed.
+ pub outcome: FundingFrameOutcome,
+}
+
+/// Error from [`crate::api::FundingProvider::present_frame`].
+#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
+pub enum HostFundingPresentFrameError {
+ /// No open session with this id is assigned to the caller.
+ NotFound,
+ /// Catch-all.
+ Unknown {
+ /// Human-readable failure reason.
+ reason: String,
+ },
+}
diff --git a/rust/crates/truapi/src/versioned.rs b/rust/crates/truapi/src/versioned.rs
index c24d74b70f..212f0b5113 100644
--- a/rust/crates/truapi/src/versioned.rs
+++ b/rust/crates/truapi/src/versioned.rs
@@ -41,6 +41,7 @@ pub mod coin_payment;
pub mod contacts;
pub mod entropy;
pub mod funding;
+pub mod funding_provider;
pub mod local_storage;
pub mod locale;
pub mod notifications;
diff --git a/rust/crates/truapi/src/versioned/funding_provider.rs b/rust/crates/truapi/src/versioned/funding_provider.rs
new file mode 100644
index 0000000000..70f34f8e02
--- /dev/null
+++ b/rust/crates/truapi/src/versioned/funding_provider.rs
@@ -0,0 +1,15 @@
+//! Versioned wrappers for [`FundingProvider`](crate::api::FundingProvider) methods.
+
+use crate::v01;
+
+truapi_macros::versioned_type! {
+ pub enum HostFundingServeSubscribeRequest { V1 }
+ pub enum HostFundingServeSubscribeItem { V1 => v01::HostFundingServeSubscribeItem }
+ pub enum HostFundingServeSubscribeError { V1 => v01::HostFundingServeSubscribeError }
+ pub enum HostFundingReportRequest { V1 => v01::HostFundingReportRequest }
+ pub enum HostFundingReportResponse { V1 }
+ pub enum HostFundingReportError { V1 => v01::HostFundingReportError }
+ pub enum HostFundingPresentFrameRequest { V1 => v01::HostFundingPresentFrameRequest }
+ pub enum HostFundingPresentFrameResponse { V1 => v01::HostFundingPresentFrameResponse }
+ pub enum HostFundingPresentFrameError { V1 => v01::HostFundingPresentFrameError }
+}
diff --git a/scripts/battery.sh b/scripts/battery.sh
index 7afcfb8c32..c2164b6e05 100755
--- a/scripts/battery.sh
+++ b/scripts/battery.sh
@@ -255,20 +255,37 @@ pocket_phase() {
funding_phase() {
local log="$LOG_DIR/funding-host-cli.log"
- echo "battery: funding phase (host transcript $LOG_DIR/funding-host-transcript.jsonl)"
+ echo "battery: funding phase (host transcripts $LOG_DIR/funding-host-transcript*.jsonl)"
# Overlay requests and session changes as the host saw them. The cases read
# it so a pass cannot rest on the product's word alone.
export TRUAPI_FUNDING_LOG="$ROOT/$LOG_DIR/funding-host-transcript.jsonl"
- # One outcome per request the cases make, in order; see funding-e2e.ts.
- export TRUAPI_FUNDING_OUTCOMES="deliver:900,release:500,fail,dismiss"
+ # One outcome per request the cases make, in order; see funding-e2e.ts and
+ # funding-provider-e2e.ts.
+ export TRUAPI_FUNDING_OUTCOMES="deliver:900,release:500,fail,dismiss,provide,provide,provide-cancel"
+ # The scripted top-up and payment engines' record, kept across the restart.
+ export TRUAPI_FUNDING_LEDGER="$ROOT/$LOG_DIR/funding-host-ledger.jsonl"
+ export TRUAPI_FUNDING_STATE="$ROOT/$LOG_DIR/funding-battery-state.json"
+ rm -f "$TRUAPI_FUNDING_LOG" "$TRUAPI_FUNDING_LEDGER" "$TRUAPI_FUNDING_STATE"
+ # Run as a Worker, so the script can also serve the sessions it asks for as
+ # the provider; then once more on the same storage, as a restarted host.
+ local rc=0
+ funding_run start "$log" || rc=$?
+ [ "$rc" = 0 ] || return "$rc"
+ export TRUAPI_FUNDING_LOG="$ROOT/$LOG_DIR/funding-host-transcript-resumed.jsonl"
rm -f "$TRUAPI_FUNDING_LOG"
- "$HOST" signing-host \
+ funding_run resume "$LOG_DIR/funding-host-cli-resumed.log"
+}
+
+funding_run() {
+ local stage="$1" log="$2"
+ TRUAPI_FUNDING_STAGE="$stage" "$HOST" signing-host \
--product-id "$PRODUCT_ID" \
+ --execution-kind worker \
--script "$FUNDING_SCRIPT" \
--auto-accept \
${HOST_ARGS[@]+"${HOST_ARGS[@]}"} > >(tee "$log") 2>&1 &
local host_pid=$! rc=0
- start_watchdog "$host_pid" "funding phase"
+ start_watchdog "$host_pid" "funding phase ($stage)"
wait "$host_pid" || rc=$?
stop_watchdog
return "$rc"