Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3aea961
feat(truapi): payments by id and balance through host platforms
filvecchiato Oct 7, 2026
9e16627
Merge remote-tracking branch 'origin/main' into payments/by-id
filvecchiato Oct 7, 2026
7080d49
fix(truapi): map undeclared payment callback errors, prune dropped ba…
filvecchiato Oct 7, 2026
132241d
Merge branch 'payments/by-id' into funding/worker
filvecchiato Oct 7, 2026
448ff4b
Merge branch 'main' into payments/by-id
filvecchiato Oct 7, 2026
bd2faf1
feat(truapi): funding provider trait, reports and settlement
filvecchiato Oct 7, 2026
81835f5
docs(rfc): payments by id in RFC 0006 and 0021
filvecchiato Oct 7, 2026
b0292b6
Merge remote-tracking branch 'origin/payments/by-id' into funding/worker
filvecchiato Oct 7, 2026
70d9b31
Merge branch 'funding/sessions' into funding/worker
filvecchiato Oct 7, 2026
040c6aa
feat(truapi): balance access as a core permission; refuse empty top-ups
filvecchiato Oct 8, 2026
a8491c6
Merge branch 'funding/sessions' into funding/worker
filvecchiato Oct 8, 2026
c798ea5
Merge remote-tracking branch 'origin/payments/by-id' into funding/worker
filvecchiato Oct 8, 2026
b44f7f3
Merge remote-tracking branch 'origin/main' into payments/by-id
filvecchiato Oct 8, 2026
de937c5
feat(truapi): hash the product into payment and top-up ids handed to …
filvecchiato Oct 8, 2026
660679f
docs(changeset): note product-hashed payment ids
filvecchiato Oct 8, 2026
75de054
refactor(truapi): share the host payment id between runtime callers
filvecchiato Oct 8, 2026
86ccd12
test(codegen): refresh host-callbacks golden for payment id docs
filvecchiato Oct 8, 2026
b43ca9f
Merge remote-tracking branch 'origin/payments/by-id' into funding/worker
filvecchiato Oct 8, 2026
4134f27
feat(truapi): follow provider top-ups and payments under the host pay…
filvecchiato Oct 8, 2026
4ee5de3
fix(truapi): FundingProvider takes wire id 24; only an unassigned fun…
filvecchiato Oct 8, 2026
c00e828
Merge branch 'funding/sessions' into tmp/worker-review
filvecchiato Oct 8, 2026
8c6a37f
test(cli): funding battery report
filvecchiato Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/funding-provider-battery.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/funding-provider.md
Original file line number Diff line number Diff line change
@@ -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`).
5 changes: 5 additions & 0 deletions .changeset/payment-balance-access.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@parity/truapi": major
---

New `BalanceAccess` remote permission. `payment.balanceSubscribe` asks for it on the first subscription and answers `PermissionDenied` when the user refuses; a payment request refused for a short balance reaches a product without it as `Rejected`. `payment.topUp` refuses an amount of zero.
5 changes: 5 additions & 0 deletions .changeset/payment-balance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@parity/truapi": minor
---

`payment.balanceSubscribe` streams the user's spendable balance from the host's new `BalancePlatform` (`set_balance_callbacks` and `notify_balance` natively): the host answers the current balance or `PermissionDenied`, then pushes each change. The core requires a session; without a balance view the call is `Unsupported`.
5 changes: 5 additions & 0 deletions .changeset/payment-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@parity/truapi": major
---

`payment.request` takes a caller-chosen 32-byte `id` and answers with nothing, adding `AlreadyExists`; `payment.statusSubscribe` follows that `id` and reports `PartiallyClaimed`. Hosts serve both through `PaymentPlatform` (`set_payment_callbacks` and `notify_payment_status` natively); without one they answer `Unsupported`. The host receives the id hashed with the calling product, so ids never collide across products.
5 changes: 5 additions & 0 deletions .changeset/payment-top-up.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@parity/truapi": major
---

`payment.topUp` takes a caller-chosen 32-byte `id`, answers `InvalidSource`, `AlreadyExists`, `SourceBusy` or `Unknown`, and is followed with the new `payment.topUpStatusSubscribe`. The core requires a session, validates the source keys, and hands the top-up to the host's `TopUpPlatform`, installed with `set_top_up_platform`; without one, both methods answer `Unsupported`. The host receives the id hashed with the calling product, so ids never collide across products.
59 changes: 58 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1313,6 +1369,7 @@ jobs:
[
rust,
headless-install,
funding-battery,
wasm-provider,
licenses,
codegen,
Expand Down Expand Up @@ -1370,7 +1427,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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 54 additions & 22 deletions docs/rfcs/0006-payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,16 @@ Rather than exposing these implementation details to products, this RFC defines

#### 1. Balance Subscription

Subscribe to the user's payment balance. The host must explicitly ask the user whether they want to grant the product access to their balance before emitting data.
Subscribe to the user's payment balance. The user must grant the product balance access before any value is emitted; the grant is a remote permission (`BalanceAccess`), asked for on the first subscription and kept like the others.

```rust
fn host_payment_balance_subscribe(
callback: fn(PaymentBalance)
) -> Result<Subscriber, PaymentBalanceErr>

struct PaymentBalance {
/// Balance that can be spent right now
/// What a payment request can spend right now: the same figure the host
/// checks a payment against
available: Balance
}

Expand All @@ -67,48 +68,77 @@ Top up the user's payment balance from a product-controlled funding source. This
```rust
fn host_payment_top_up(
amount: Balance,
source: PaymentTopUpSource
source: PaymentTopUpSource,
id: PaymentTopUpId
) -> Result<(), PaymentTopUpErr>

fn host_payment_top_up_status_subscribe(
id: PaymentTopUpId
) -> Subscription<PaymentTopUpStatus, PaymentTopUpStatusErr>

enum PaymentTopUpSource {
/// Fund from one of the calling product's scoped accounts
ProductAccount(DerivationIndex),
/// Fund from a one-time account represented by its private key.
/// This is a standard account holding public funds -- not a coin key.
PrivateKey(Ed25519PrivateKey)
PrivateKey(Sr25519SecretKey)
}

/// Caller-chosen, 32 bytes. Reusing one is refused, which makes a retry safe.
type PaymentTopUpId = [u8; 32];

enum PaymentTopUpErr {
/// The source account does not hold sufficient funds
InsufficientFunds,
/// The source account was not found or is invalid
/// The source key is malformed, or the source was not found
InvalidSource,
/// A top-up with this id already exists
AlreadyExists,
/// Another top-up from the same source is still running
SourceBusy,
Unknown(GenericErr)
}

/// `Claimed { finalized: true }`, `ClaimedPartially` and `NotClaimed` are
/// terminal and stay readable after the top-up ends.
enum PaymentTopUpStatus {
Detecting,
Claiming,
Claimed { finalized: bool },
ClaimedPartially { actual_claimed: Balance },
NotClaimed,
}

enum PaymentTopUpStatusErr {
NotFound,
Unknown(GenericErr)
}
```

`host_payment_top_up` returns once the host accepts the top-up; the claim runs on, and its outcome arrives through the status subscription.

`PaymentTopUpSource::PrivateKey` refers to a regular account (e.g. holding DOT or pUSD) whose private key the product possesses -- for instance, a one-time deposit account. This is not a coinage coin key.

#### 3. Request Payment

Request a payment from the user's available balance to a destination account. The host should prompt the user to authorize the payment. Returns a `PaymentId` for tracking.
Request a payment from the user's available balance to a destination account. The host should prompt the user to authorize the payment. The caller chooses the `PaymentId` the payment is followed by.

```rust
fn host_payment_request(
amount: Balance,
destination: AccountId
) -> Result<PaymentReceipt, PaymentRequestErr>

type PaymentId = str;

struct PaymentReceipt {
destination: AccountId,
id: PaymentId
}
) -> Result<(), PaymentRequestErr>

/// Caller-chosen, 32 bytes. Reusing one is refused, which makes a retry safe.
type PaymentId = [u8; 32];

enum PaymentRequestErr {
/// A payment with this id already exists
AlreadyExists,
/// User denied the payment request
Denied,
/// User's available balance is not sufficient for the requested amount
Rejected,
/// User's available balance is not sufficient for the requested amount.
/// Only a product holding balance access receives it; to any other a
/// short balance reads as `Rejected`
InsufficientBalance,
Unknown(GenericErr)
}
Expand All @@ -118,11 +148,11 @@ A successful response means the user has authorized the payment and the host has

#### 4. Payment Status Subscription

Subscribe to status updates for a previously requested payment. The subscription emits status changes until the payment reaches a terminal state (`Completed` or `Failed`).
Subscribe to status updates for a previously requested payment. The subscription emits status changes until the payment reaches a terminal state (`Completed`, `Failed` or `PartiallyClaimed`), which stays readable after the payment ends.

```rust
fn host_payment_status_subscribe(
payment_id: PaymentId,
id: PaymentId,
callback: fn(PaymentStatus)
) -> Result<Subscriber, PaymentStatusErr>

Expand All @@ -132,7 +162,9 @@ enum PaymentStatus {
/// Payment has been settled successfully
Completed,
/// Payment has failed
Failed(str)
Failed(str),
/// Only this amount reached the destination, less than requested
PartiallyClaimed(Balance)
}

enum PaymentStatusErr {
Expand All @@ -148,9 +180,9 @@ enum PaymentStatusErr {

2. **Payment authorization**: Each `host_payment_request` call must trigger a user-facing confirmation prompt showing the amount and destination. The host must not auto-approve payments.

3. **Payment ID scoping**: A `PaymentId` is scoped to the product that created it. A product cannot query or subscribe to payment status for another product's payments.
3. **Payment ID scoping**: A `PaymentId` is scoped to the product that created it. A product cannot query or subscribe to payment status for another product's payments. The core enforces this: the id a host receives is the product's id hashed with the product.

4. **Terminal status delivery**: Once a payment reaches `Completed` or `Failed`, the host must deliver that status to any active subscriber and may then close the subscription. The host should make a best effort to deliver terminal status even across session restarts.
4. **Terminal status delivery**: Once a payment reaches `Completed`, `Failed` or `PartiallyClaimed`, the host must deliver that status to any active subscriber and may then close the subscription. The host should make a best effort to deliver terminal status even across session restarts.

5. **Top-up idempotency**: If the same top-up is submitted multiple times (e.g. due to a retry), the host should ensure funds are only transferred once where possible. However, this is a best-effort guarantee -- products should implement their own idempotency checks for critical flows.

Expand Down
2 changes: 1 addition & 1 deletion docs/rfcs/0021-payment-topup-coins.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ enum PaymentTopUpSource {

`host_payment_top_up` is unchanged; the host validates each key, claims the coins, and credits the target purse. Spent or sniped coins are skipped. No user consent required (top-ups are always in the user's favour).

A `PartialPayment { credited: Balance }` variant is added to `HostPaymentTopUpError` so the caller knows how much was credited when some coins could not be claimed.
When some coins cannot be claimed, the top-up ends in `PaymentTopUpStatus::ClaimedPartially { actual_claimed }` (RFC 0006), so the caller knows how much was credited.

## Drawbacks

Expand Down
8 changes: 8 additions & 0 deletions explorer/diagnosis-reports/funding/signing-host-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | ✅ | |
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,10 @@ class HostApiInteractor @Inject constructor(
permissionRequester.promptBatched(callingProductId, request.toDomainPermissions())
}

suspend fun requestBalanceAccessDecision(callingProductId: ProductId): Result<PermissionDecision> = runCatching {
permissionRequester.prompt(callingProductId, ProductPermission.BalanceAccess)
}

suspend fun allowWebRtcAccess(callingProductId: ProductId): Result<Boolean> {
val permission = ProductPermission.RemotePermission.WebRtcAccess
return Result.success(permissionGuard.consumePermission(callingProductId, permission))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,10 @@ class ProductTrUAPIHostBridge @AssistedInject constructor(
product: ProductExecutionConfig,
request: RemotePermission,
): TrUAPIPermissionDecision =
hostApiInteractor
.requestRemotePermissionDecision(callingProductId, request.toDomain())
when (val domain = request.toDomain()) {
null -> hostApiInteractor.requestBalanceAccessDecision(callingProductId)
else -> hostApiInteractor.requestRemotePermissionDecision(callingProductId, domain)
}
.getOrElse { throw it }
.toNative()

Expand Down Expand Up @@ -375,12 +377,14 @@ private fun HostDevicePermissionRequest.toCapability(): DeviceCapabilityType = w
HostDevicePermissionRequest.BIOMETRICS -> DeviceCapabilityType.Biometrics
}

private fun RemotePermission.toDomain(): RemotePermissionRequest = when (this) {
/** `null` for balance access, which this host asks about as a product permission. */
private fun RemotePermission.toDomain(): RemotePermissionRequest? = when (this) {
is RemotePermission.Remote -> RemotePermissionRequest.Remote(domains)
RemotePermission.WebRtc -> RemotePermissionRequest.WebRtc
RemotePermission.ChainSubmit -> RemotePermissionRequest.ChainSubmit
RemotePermission.PreimageSubmit -> RemotePermissionRequest.PreimageSubmit
RemotePermission.StatementSubmit -> RemotePermissionRequest.StatementSubmit
RemotePermission.BalanceAccess -> null
}

private fun PermissionDecision.toNative(): TrUAPIPermissionDecision = when (this) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ class RustProductExecutionBridge: HostBridge, @unchecked Sendable {
) async throws -> TrUAPIPermissionDecision {
try await dependencies.permissionGuard.requestPermissionsDecision(
productId: dependencies.productId,
permissions: request.toDomainRequest().toDomainPermissions()
permissions: request.toDomainPermissions()
).hostDecision
}

Expand Down Expand Up @@ -259,14 +259,15 @@ extension HostDevicePermissionRequest {
}

extension RemotePermission {
/// Maps the TrUAPI remote permission to the Products domain request.
func toDomainRequest() -> Products.RemotePermissionRequest {
/// The Products permissions the guard asks the user about.
func toDomainPermissions() -> [ProductPermission] {
switch self {
case let .remote(domains): .remote(domains: domains)
case .webRtc: .webRTC
case .chainSubmit: .chainSubmit
case .preimageSubmit: .preimageSubmit
case .statementSubmit: .statementSubmit
case let .remote(domains): Products.RemotePermissionRequest.remote(domains: domains).toDomainPermissions()
case .webRtc: Products.RemotePermissionRequest.webRTC.toDomainPermissions()
case .chainSubmit: Products.RemotePermissionRequest.chainSubmit.toDomainPermissions()
case .preimageSubmit: Products.RemotePermissionRequest.preimageSubmit.toDomainPermissions()
case .statementSubmit: Products.RemotePermissionRequest.statementSubmit.toDomainPermissions()
case .balanceAccess: [.balanceAccess]
}
}
}
Expand Down
Loading
Loading