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/.changeset/payment-balance-access.md b/.changeset/payment-balance-access.md new file mode 100644 index 0000000000..9cce6a81e8 --- /dev/null +++ b/.changeset/payment-balance-access.md @@ -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. diff --git a/.changeset/payment-balance.md b/.changeset/payment-balance.md new file mode 100644 index 0000000000..67e41de9f8 --- /dev/null +++ b/.changeset/payment-balance.md @@ -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`. diff --git a/.changeset/payment-request.md b/.changeset/payment-request.md new file mode 100644 index 0000000000..b4233e4b31 --- /dev/null +++ b/.changeset/payment-request.md @@ -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. diff --git a/.changeset/payment-top-up.md b/.changeset/payment-top-up.md new file mode 100644 index 0000000000..9ec6cd8f4b --- /dev/null +++ b/.changeset/payment-top-up.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c284971e5f..d2b1412a42 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. @@ -1313,6 +1369,7 @@ jobs: [ rust, headless-install, + funding-battery, wasm-provider, licenses, codegen, @@ -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 diff --git a/README.md b/README.md index 25293581d7..07ee67cbc6 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/docs/rfcs/0006-payments.md b/docs/rfcs/0006-payments.md index a343cd7f0e..0f745f0a67 100644 --- a/docs/rfcs/0006-payments.md +++ b/docs/rfcs/0006-payments.md @@ -39,7 +39,7 @@ 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( @@ -47,7 +47,8 @@ fn host_payment_balance_subscribe( ) -> Result 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 } @@ -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 + 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 - -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) } @@ -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 @@ -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 { @@ -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. diff --git a/docs/rfcs/0021-payment-topup-coins.md b/docs/rfcs/0021-payment-topup-coins.md index 2a992e34b8..c379b849b5 100644 --- a/docs/rfcs/0021-payment-topup-coins.md +++ b/docs/rfcs/0021-payment-topup-coins.md @@ -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 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/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/HostApiInteractor.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/HostApiInteractor.kt index 5a708807bf..ff563f9716 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/HostApiInteractor.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/HostApiInteractor.kt @@ -315,6 +315,10 @@ class HostApiInteractor @Inject constructor( permissionRequester.promptBatched(callingProductId, request.toDomainPermissions()) } + suspend fun requestBalanceAccessDecision(callingProductId: ProductId): Result = runCatching { + permissionRequester.prompt(callingProductId, ProductPermission.BalanceAccess) + } + suspend fun allowWebRtcAccess(callingProductId: ProductId): Result { val permission = ProductPermission.RemotePermission.WebRtcAccess return Result.success(permissionGuard.consumePermission(callingProductId, permission)) diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt index 52262f838a..0e76be83da 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt @@ -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() @@ -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) { diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift index fb48f44a84..59fa7dbb19 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift @@ -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 } @@ -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] } } } diff --git a/playground/tests/unit/chat-diagnosis.test.ts b/playground/tests/unit/chat-diagnosis.test.ts index 5e2f646d1d..55e9e30a52 100644 --- a/playground/tests/unit/chat-diagnosis.test.ts +++ b/playground/tests/unit/chat-diagnosis.test.ts @@ -5,14 +5,17 @@ import { WORKER_DIAGNOSIS_METHODS, ChatDiagnosis } from "../../worker/diagnosis" /** * Worker-pinned methods the playground deliberately leaves out of its - * diagnosis. The CLI battery drives Pocket over the real wire instead. Naming - * each method rather than the whole service keeps the guard on the rest of it, - * so a third Pocket method fails the check below until it is either diagnosed - * or listed here. + * diagnosis. The CLI battery drives Pocket and the funding provider over the + * real wire instead. Naming each method rather than the whole service keeps + * the guard on the rest of it, so a new method fails the check below until it + * is either diagnosed or listed here. */ const UNDIAGNOSED_WORKER_METHODS: ReadonlySet = new Set([ "Pocket/list_subscribe", "Pocket/remove_card", + "Funding Provider/serve_subscribe", + "Funding Provider/report", + "Funding Provider/present_frame", ]); describe("ChatDiagnosis", () => { 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 d82a8d176f..aa69d1b5e2 100644 --- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs +++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs @@ -21,6 +21,7 @@ use truapi::api::{ Entropy, Funding, Game, + FundingProvider, LocalStorage, Locale, Notifications, @@ -61,6 +62,7 @@ where register_entropy(dispatcher, host.clone()); register_funding(dispatcher, host.clone()); register_game(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()); @@ -1467,6 +1469,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, @@ -1786,7 +1899,7 @@ where }); } { - let host = host; + let host = host.clone(); dispatcher.on_request(wire_table::PAYMENT_TOP_UP, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { let host = host.clone(); Box::pin(async move { @@ -1813,6 +1926,38 @@ where }) }); } + { + let host = host; + dispatcher.on_subscription(wire_table::PAYMENT_TOP_UP_STATUS_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::payment::HostPaymentTopUpStatusSubscribeRequest = 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); + let stream = host.top_up_status_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)) + }) + }); + } } fn register_permissions

(dispatcher: &mut Dispatcher, host: Arc

) diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 2f5b8914e9..f6b9f77d4f 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, @@ -39,6 +40,14 @@ import type { HostFundingStatusSubscribeItem, HostLocalStorageChangeItem, HostLocaleSubscribeItem, + HostPaymentBalanceSubscribeError, + HostPaymentBalanceSubscribeItem, + HostPaymentRequest, + HostPaymentStatusSubscribeError, + HostPaymentStatusSubscribeItem, + HostPaymentTopUpRequest, + HostPaymentTopUpStatusSubscribeError, + HostPaymentTopUpStatusSubscribeItem, HostPocketListSubscribeItem, HostPocketRemoveCardRequest, HostPushNotificationRequest, @@ -1156,6 +1165,26 @@ export interface AuthPresenter { authStateChanged?(state: AuthState): void; } +/** + * Host-implemented balance view: what a payment request can spend right now, + * the figure the host checks a payment against. Optional: a host that omits + * it leaves balance subscriptions answered `Unsupported`. + * + * The core asks for the product's balance access before calling here. + */ +export interface BalancePlatform { + /** + * Emit the balance of `purse` (``undefined`` for the main purse) now and on + * every change. + */ + subscribeBalance( + product: ProductContext, + purse: number | undefined, + ): AsyncIterable< + Result + >; +} + /** * JSON-RPC provider factory for chain access. * @@ -1455,6 +1484,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. @@ -1580,6 +1619,38 @@ export interface PairingHostAdmin { notifySessionStoreChanged(): void; } +/** + * Host-implemented payment engine: pays from the user's balance to an + * account, once the user approves. Optional: a host that omits it leaves + * payment requests answered `Unsupported`. + * + * The core hashes the product into the id before calling, so ids never + * collide across products. The host owns the approval sheet, the transfer + * and its persistence. + */ +export interface PaymentPlatform { + /** + * Ask the user to approve `request`. Returns once the user has decided: + * `Ok` when they authorized it and the host took it on; the payment's + * outcome arrives through its status. + */ + requestPayment( + product: ProductContext, + request: HostPaymentRequest, + ): Promise; + + /** + * Emit a payment's current status and every later one, ending after a + * terminal status. + */ + subscribePaymentStatus( + product: ProductContext, + id: Uint8Array, + ): AsyncIterable< + Result + >; +} + /** * Live OS permission state, read without prompting. * @@ -1754,6 +1825,39 @@ export interface ThemeHost { subscribeTheme(): AsyncIterable>; } +/** + * Host-implemented top-up engine: claims a source's funds into the user's + * balance through the host's coinage onboarding. Optional: a host that omits + * it leaves top-ups answered `Unsupported`. + * + * The core validates the source keys and hashes the product into the id + * before calling, so ids never collide across products. The host owns + * retries, partial claims and persistence. + */ +export interface TopUpPlatform { + /** + * Start a top-up. Returns once the host has accepted it. + */ + topUp( + product: ProductContext, + request: HostPaymentTopUpRequest, + ): Promise; + + /** + * Emit a top-up's current status and every later one, ending after a + * terminal status. + */ + subscribeTopUpStatus( + product: ProductContext, + id: Uint8Array, + ): AsyncIterable< + Result< + HostPaymentTopUpStatusSubscribeItem, + HostPaymentTopUpStatusSubscribeError + > + >; +} + /** * Local user confirmation UI for sensitive core-owned operations. */ diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index e4e0c34c3b..9878482fb8 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 = "98af8dd560c1444d"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "6eb45e246b61dfbc"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: MethodIds = MethodIds { @@ -374,6 +374,12 @@ pub const PAYMENT_STATUS_SUBSCRIBE: MethodIds = MethodIds { method_id: 3, }; +/// Wire discriminants for `payment_top_up_status_subscribe`. +pub const PAYMENT_TOP_UP_STATUS_SUBSCRIBE: MethodIds = MethodIds { + trait_id: 9, + method_id: 4, +}; + /// Wire discriminants for `permissions_request_device_permission`. pub const PERMISSIONS_REQUEST_DEVICE_PERMISSION: MethodIds = MethodIds { trait_id: 10, @@ -566,6 +572,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: 24, + method_id: 0, +}; + +/// Wire discriminants for `funding_provider_report`. +pub const FUNDING_PROVIDER_REPORT: MethodIds = MethodIds { + trait_id: 24, + method_id: 1, +}; + +/// Wire discriminants for `funding_provider_present_frame`. +pub const FUNDING_PROVIDER_PRESENT_FRAME: MethodIds = MethodIds { + trait_id: 24, + method_id: 2, +}; + /// Wire discriminants for `scanner_scan`. pub const SCANNER_SCAN: MethodIds = MethodIds { trait_id: 25, @@ -796,6 +820,10 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "payment_status_subscribe", kind: WireKind::Subscription(PAYMENT_STATUS_SUBSCRIBE), }, + WireEntry { + method: "payment_top_up_status_subscribe", + kind: WireKind::Subscription(PAYMENT_TOP_UP_STATUS_SUBSCRIBE), + }, WireEntry { method: "permissions_request_device_permission", kind: WireKind::Request(PERMISSIONS_REQUEST_DEVICE_PERMISSION), @@ -924,6 +952,18 @@ 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), + }, WireEntry { method: "scanner_scan", kind: WireKind::Request(SCANNER_SCAN), diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index b09bc35e63..48504b8a9c 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. The CLI accepts and logs Game reminders on every execution kind, without holding or firing them. The core serves Game to the game product (`dim2`) diff --git a/rust/crates/truapi-host-cli/js/funding-e2e.ts b/rust/crates/truapi-host-cli/js/funding-e2e.ts index 1a5c727156..437aa0a73c 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 b228440795..970cdd9541 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::InProgress { .. } => ("InProgress", None), @@ -200,6 +280,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::*; @@ -209,12 +399,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 fecf7750c4..ea701f5fcc 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -405,8 +405,8 @@ AutoSigning without approval. Legacy-account signing still asks the user. overlay is installed. Native hosts reach this through `NativeTrUApiHostRuntime`: `set_funding_callbacks` (the overlay), `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. The host owns the funding history. A session is stored only once the user starts it in the overlay. `funding_sessions` lists sessions in flight first, then ended ones the host has not recorded, each newest first. An ended @@ -415,6 +415,42 @@ AutoSigning without approval. Legacy-account signing still asks the user. which the core drops it, so the host's history writes every outcome once. The core keeps at most the 200 newest unrecorded sessions. Products see a session in flight as `InProgress`. + `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. Only a session with no provider expires: once + assigned, it waits for its provider or a cancel. 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. +- `BalancePlatform`: stream what a payment request can spend right now, the + current value first and then each change. Installed with + `set_balance_platform`; native hosts use `set_balance_callbacks` with + `notify_balance`. The core requires a session and the product's + `BalanceAccess` remote permission, asking for it on the first subscription + and answering `PermissionDenied` when the user refuses. A host + `InsufficientBalance` reaches a product without that permission as + `Rejected`. Without a balance view, `balanceSubscribe` answers + `Unsupported`. +- `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 + `set_payment_callbacks` with `notify_payment_status`. The core requires a + session. Without it, `request` and + `statusSubscribe` answer `Unsupported`. +- `TopUpPlatform`: claim a top-up source's funds into the user's balance and + stream each top-up's status. Installed with `set_top_up_platform`; native + hosts use `set_top_up_callbacks` with `notify_top_up_status`. The core + requires a session and checks the source keys; a `ProductAccount` source is + passed through for the host to derive. The host owns claiming, retries, + partial claims, persistence and scoping ids to the product. Without it, + `topUp` and `topUpStatusSubscribe` answer `Unsupported`. - `ContactsPlatform`: resolve the handles a transaction names to contacts, and render the picker that selects one. `contacts` is the only required method; `pick_contact` defaults to `Unsupported`, so a host serving no picker says so rather than diff --git a/rust/crates/truapi/src/api.rs b/rust/crates/truapi/src/api.rs index a4a04c1158..a9be9e5460 100644 --- a/rust/crates/truapi/src/api.rs +++ b/rust/crates/truapi/src/api.rs @@ -1,4 +1,37 @@ //! Unified TrUAPI trait set. +//! +//! Each trait's wire id, set by `#[wire_trait(id = N)]`, is permanent once +//! released. Codegen rejects two traits with one id in a tree; this table +//! also holds ids taken by work not merged yet, so a collision shows in +//! review instead of after both land. Claim the next free id here. +//! +//! | Id | Trait | +//! |----|-------| +//! | 1 | `System` | +//! | 2 | `Account` | +//! | 3 | `Chain` | +//! | 4 | `Chat` | +//! | 5 | `CoinPayment` | +//! | 6 | `Entropy` | +//! | 7 | `LocalStorage` | +//! | 8 | `Notifications` | +//! | 9 | `Payment` | +//! | 10 | `Permissions` | +//! | 11 | `Preimage` | +//! | 12 | `ResourceAllocation` | +//! | 13 | `Signing` | +//! | 14 | `StatementStore` | +//! | 15 | `Theme` | +//! | 16 | `Locale` | +//! | 17 | `Renderer` | +//! | 18 | `Pocket` | +//! | 19 | `Worker` | +//! | 20 | `Contacts` | +//! | 21 | `Game` | +//! | 22 | `Funding` | +//! | 23 | reserved: expanded card face | +//! | 24 | `FundingProvider` | +//! | 25 | `Scanner` | pub mod account; pub mod chain; @@ -7,6 +40,7 @@ pub mod coin_payment; pub mod contacts; pub mod entropy; pub mod funding; +pub mod funding_provider; pub mod game; pub mod local_storage; pub mod locale; @@ -31,6 +65,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 game::Game; pub use local_storage::LocalStorage; pub use locale::Locale; @@ -58,6 +93,7 @@ pub trait TrUApi: + Entropy + Funding + Game + + FundingProvider + LocalStorage + Locale + Notifications @@ -87,6 +123,7 @@ impl TrUApi for T where + Entropy + Funding + Game + + 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..83d561ee55 --- /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 = 24)] +#[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/api/payment.rs b/rust/crates/truapi/src/api/payment.rs index 001cdffa20..978b3280c9 100644 --- a/rust/crates/truapi/src/api/payment.rs +++ b/rust/crates/truapi/src/api/payment.rs @@ -5,7 +5,8 @@ use crate::versioned::payment::{ HostPaymentBalanceSubscribeRequest, HostPaymentError, HostPaymentRequest, HostPaymentResponse, HostPaymentStatusSubscribeError, HostPaymentStatusSubscribeItem, HostPaymentStatusSubscribeRequest, HostPaymentTopUpError, HostPaymentTopUpRequest, - HostPaymentTopUpResponse, + HostPaymentTopUpResponse, HostPaymentTopUpStatusSubscribeError, + HostPaymentTopUpStatusSubscribeItem, HostPaymentTopUpStatusSubscribeRequest, }; use crate::{CallContext, CallError, Subscription}; use crate::{wire, wire_trait}; @@ -34,13 +35,15 @@ pub trait Payment: Send + Sync { Subscription::interrupted(CallError::unavailable()) } - /// Request a payment from the user. + /// Request a payment from the user, followed by `id` through + /// `statusSubscribe`. Returns once the host has accepted it. /// /// ```ts /// // Fund the balance first so the request is not rejected for lack of funds. /// const topUp = await truapi.payment.topUp({ /// amount: 1000n, /// source: { tag: "ProductAccount", value: { derivationIndex: { tag: "Index", value: 0 } } }, + /// id: "0x0000000000000000000000000000000000000000000000000000000000000002", /// }); /// assert(topUp.isOk(), "topUp failed:", topUp); /// @@ -48,9 +51,10 @@ pub trait Payment: Send + Sync { /// amount: 1000n, /// destination: /// "0x0000000000000000000000000000000000000000000000000000000000000000", + /// id: "0x0000000000000000000000000000000000000000000000000000000000000004", /// }); /// assert(result.isOk(), "request failed:", result); - /// console.log("payment requested:", result.value); + /// console.log("payment requested"); /// ``` #[wire(id = 2)] async fn request( @@ -61,7 +65,8 @@ pub trait Payment: Send + Sync { Err(CallError::unavailable()) } - /// Subscribe to payment lifecycle updates for a specific payment. + /// Subscribe to payment lifecycle updates for a specific payment. Emits + /// the current status first, so a caller that reloads re-attaches. /// /// ```ts /// import { firstValueFrom, from } from "rxjs"; @@ -70,22 +75,21 @@ pub trait Payment: Send + Sync { /// const topUp = await truapi.payment.topUp({ /// amount: 1000n, /// source: { tag: "ProductAccount", value: { derivationIndex: { tag: "Index", value: 0 } } }, + /// id: "0x0000000000000000000000000000000000000000000000000000000000000003", /// }); /// assert(topUp.isOk(), "topUp failed:", topUp); /// + /// const id = "0x0000000000000000000000000000000000000000000000000000000000000005"; /// const requested = await truapi.payment.request({ /// amount: 1000n, /// destination: /// "0x0000000000000000000000000000000000000000000000000000000000000000", + /// id, /// }); /// assert(requested.isOk(), "request failed:", requested); /// /// const status = await firstValueFrom( - /// from( - /// truapi.payment.statusSubscribe({ - /// request: { paymentId: requested.value.id }, - /// }), - /// ), + /// from(truapi.payment.statusSubscribe({ request: { id } })), /// ); /// console.log("payment status received:", status); /// ``` @@ -99,12 +103,14 @@ pub trait Payment: Send + Sync { Subscription::interrupted(CallError::unavailable()) } - /// Top up the user's payment balance. + /// Top up the user's payment balance, followed by `id` through + /// `topUpStatusSubscribe`. /// /// ```ts /// const result = await truapi.payment.topUp({ /// amount: 1000n, /// source: { tag: "ProductAccount", value: { derivationIndex: { tag: "Index", value: 0 } } }, + /// id: "0x0000000000000000000000000000000000000000000000000000000000000001", /// }); /// assert(result.isOk(), "topUp failed:", result); /// console.log("balance topped up"); @@ -117,4 +123,32 @@ pub trait Payment: Send + Sync { ) -> Result> { Err(CallError::unavailable()) } + + /// Follow one top-up to its end. + /// + /// Emits the current status first, so a caller that reloads re-attaches. + /// + /// ```ts + /// import { firstValueFrom, from } from "rxjs"; + /// + /// const status = await firstValueFrom( + /// from( + /// truapi.payment.topUpStatusSubscribe({ + /// request: { id: "0x0000000000000000000000000000000000000000000000000000000000000001" }, + /// }), + /// ), + /// ); + /// console.log("top-up status:", status); + /// ``` + #[wire(id = 4)] + async fn top_up_status_subscribe( + &self, + _cx: &CallContext, + _request: HostPaymentTopUpStatusSubscribeRequest, + ) -> Subscription< + HostPaymentTopUpStatusSubscribeItem, + CallError, + > { + Subscription::interrupted(CallError::unavailable()) + } } diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index 9cc763ce7d..acb9c201e7 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -15,8 +15,8 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use crate::platform::{ - ChatPlatform, ContactsPlatform, FundingPlatform, GamePlatform, PermissionStatusHost, - PocketPlatform, + BalancePlatform, ChatPlatform, ContactsPlatform, FundingPlatform, GamePlatform, + PaymentPlatform, PermissionStatusHost, PocketPlatform, TopUpPlatform, }; use crate::platform::{ CoreAdmin, PairingHostAdmin, PairingHostConfig, PermissionAuthorizationRequest, @@ -31,7 +31,6 @@ use tracing::{instrument, warn}; use truapi::v01; use truapi::{CallContext, CancellationReason}; -use crate::truapi_core::TrUApiCore; use crate::frame::ProtocolMessage; use crate::host_internal::sso_messages::{RemoteMessage, SsoRequestOutcome}; use crate::host_logic::worker::WorkerLedger; @@ -45,6 +44,7 @@ use crate::runtime::{ }; use crate::subscription::{HostInitiatedSubscriptionManager, Spawner}; use crate::transport::Transport; +use crate::truapi_core::TrUApiCore; /// Outgoing frame sink owned by a host adapter. /// @@ -318,6 +318,29 @@ impl PairingHostRuntime { self.services.install_game_platform(platform) } + /// Install the host's [`TopUpPlatform`], which claims funds into the + /// balance. Set-once. Returns whether this call installed it. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.set_top_up_platform"))] + pub fn set_top_up_platform(&self, platform: Arc) -> bool { + self.services.install_top_up_platform(platform) + } + + /// Install the host's [`PaymentPlatform`], which pays from the user's + /// balance once the user approves. Set-once; returns whether this call + /// installed it. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.set_payment_platform"))] + pub fn set_payment_platform(&self, platform: Arc) -> bool { + self.services.install_payment_platform(platform) + } + + /// Install the host's [`BalancePlatform`], which shares the user's + /// balance with products. Set-once; returns whether this call installed + /// it. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.set_balance_platform"))] + pub fn set_balance_platform(&self, platform: Arc) -> bool { + self.services.install_balance_platform(platform) + } + /// Install the host's [`ContactsPlatform`], which owns the contact list and /// draws the picker. /// @@ -792,7 +815,10 @@ impl SigningHostRuntime { /// the full record with `funding_session`. Call it outside that /// callback, which runs while the core holds its session lock. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.acknowledge_funding_session"))] - pub async fn acknowledge_funding_session(&self, intent: &str) -> Result { + pub async fn acknowledge_funding_session( + &self, + intent: &str, + ) -> Result { self.services .acknowledge_funding_session(intent) .await @@ -801,8 +827,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 @@ -813,6 +840,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( @@ -851,6 +894,29 @@ impl SigningHostRuntime { self.services.install_game_platform(platform) } + /// Install the host's [`TopUpPlatform`], which claims funds into the + /// balance. Set-once. Returns whether this call installed it. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.set_top_up_platform"))] + pub fn set_top_up_platform(&self, platform: Arc) -> bool { + self.services.install_top_up_platform(platform) + } + + /// Install the host's [`PaymentPlatform`], which pays from the user's + /// balance once the user approves. Set-once; returns whether this call + /// installed it. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.set_payment_platform"))] + pub fn set_payment_platform(&self, platform: Arc) -> bool { + self.services.install_payment_platform(platform) + } + + /// Install the host's [`BalancePlatform`], which shares the user's + /// balance with products. Set-once; returns whether this call installed + /// it. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.set_balance_platform"))] + pub fn set_balance_platform(&self, platform: Arc) -> bool { + self.services.install_balance_platform(platform) + } + /// Install the host's [`ContactsPlatform`], which owns the contact list and /// draws the picker. /// @@ -1140,10 +1206,7 @@ impl SigningHostRuntime { /// even while that request is still being answered by another call, and a /// withdrawn request is answered `Ignored`. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.answer_sso_request"))] - pub async fn answer_sso_request( - &self, - message: RemoteMessage, - ) -> SsoRequestOutcome { + pub async fn answer_sso_request(&self, message: RemoteMessage) -> SsoRequestOutcome { let service = SigningHostSsoService::new(self.signing_host.clone()); match service.answer(message).await { Dispatch::Response(answer) => SsoRequestOutcome::Response { @@ -3666,9 +3729,9 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn the_core_database_is_installed_once_and_reports_when_missing() { + use crate::platform::{HostInfo, PlatformInfo, SigningHostConfig}; use crate::store::{Db, DbConfig, DbError, DbLocation}; use futures::executor::block_on; - use crate::platform::{HostInfo, PlatformInfo, SigningHostConfig}; let config = SigningHostConfig::new( HostInfo { @@ -3701,7 +3764,10 @@ mod tests { assert!(runtime.set_core_db(installed)); assert!(!runtime.set_core_db(other)); - let db = runtime.services.core_db().expect("installed database is served"); + let db = runtime + .services + .core_db() + .expect("installed database is served"); let answer: i64 = block_on(db.write(|tx| Ok(tx.query_row("SELECT 42", [], |row| row.get(0))?))) .expect("installed database serves writes"); diff --git a/rust/crates/truapi/src/host_internal/permissions.rs b/rust/crates/truapi/src/host_internal/permissions.rs index 5292274de8..ba14fe7cdd 100644 --- a/rust/crates/truapi/src/host_internal/permissions.rs +++ b/rust/crates/truapi/src/host_internal/permissions.rs @@ -1767,6 +1767,7 @@ mod tests { RemotePermission::ChainSubmit, RemotePermission::PreimageSubmit, RemotePermission::StatementSubmit, + RemotePermission::BalanceAccess, ] } @@ -1965,14 +1966,15 @@ mod tests { #[test] fn an_untrusted_product_still_prompts_for_every_remote_permission() { let storage = MemStorage::default(); - let prompt = ScriptedPrompt::new(vec![], vec![true; 5]); + let permissions = every_remote_permission(); + let prompt = ScriptedPrompt::new(vec![], vec![true; permissions.len()]); let service = PermissionsService::new(&storage, &prompt, &PRODUCT); - for permission in every_remote_permission() { + for permission in permissions.iter().cloned() { futures::executor::block_on(service.check_or_prompt_remote(remote(permission))) .unwrap(); } - assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), 5); + assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), permissions.len()); } #[test] diff --git a/rust/crates/truapi/src/host_logic/funding.rs b/rust/crates/truapi/src/host_logic/funding.rs index 67ea0340e8..3338a6d281 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}; @@ -19,6 +22,9 @@ const SESSION_WINDOW_MS: u64 = 24 * 60 * 60 * 1_000; /// newest first, so a host that never records them cannot grow the store /// without bound. 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)] @@ -46,6 +52,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. @@ -98,9 +158,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. @@ -108,9 +334,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. @@ -132,7 +377,7 @@ impl FundingSession { pub fn wire_item(&self) -> HostFundingStatusSubscribeItem { match (&self.stage, self.direction) { (FundingStage::Open, _) => HostFundingStatusSubscribeItem::InProgress { - expires_at: Some(self.deadline_ms), + expires_at: self.can_expire().then_some(self.deadline_ms), }, (FundingStage::Failed { reason, .. }, _) => HostFundingStatusSubscribeItem::Failed { reason: reason.clone(), @@ -162,10 +407,31 @@ impl FundingSession { true } - /// Expire the session if it is still open at its deadline. Returns whether - /// it expired. + /// Expire the session if it is still open at its deadline with no + /// provider assigned. An assigned session waits for its provider or a + /// cancel: a bank transfer can take days. 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.can_expire() && self.fail(FundingFailure::Expired, now_ms) + } + + /// Whether the session's deadline still applies: it is open and no + /// provider has taken it. + pub fn can_expire(&self) -> bool { + !self.is_terminal() && self.provider_id.is_none() + } +} + +/// 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, } } @@ -178,6 +444,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 ended ones the host @@ -243,6 +515,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( @@ -298,6 +571,19 @@ mod tests { ); } + // A bank transfer can take days, so once a provider has taken a session it + // waits for the provider or a cancel instead of failing on day two, and + // products are no longer shown a deadline. + #[test] + fn only_a_session_no_provider_has_taken_expires() { + let mut assigned = served(FundingDirection::In); + + assert_eq!( + (assigned.expire_if_due(NOW + SESSION_WINDOW_MS), assigned.wire_item()), + (false, HostFundingStatusSubscribeItem::InProgress { expires_at: None }) + ); + } + #[test] fn an_ended_session_keeps_its_first_outcome() { let mut session = expired("fs_1", NOW); @@ -333,21 +619,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 88314f3287..118d8d7df4 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -66,6 +66,17 @@ uniffi::custom_type!(Bytes32, Vec, { try_lift: |bytes| Ok(bytes.as_slice().try_into()?), }); +/// A 64-byte value, such as an sr25519 secret key, passed as plain bytes on +/// FFI surfaces. +pub type Bytes64 = [u8; 64]; + +#[cfg(all(feature = "runtime", not(target_arch = "wasm32")))] +uniffi::custom_type!(Bytes64, Vec, { + remote, + lower: |bytes| bytes.to_vec(), + try_lift: |bytes| Ok(bytes.as_slice().try_into()?), +}); + // Swift and Kotlin have no 128-bit integer, so amounts cross FFI surfaces as // decimal strings. #[cfg(all(feature = "runtime", not(target_arch = "wasm32")))] @@ -87,12 +98,13 @@ pub mod latest { ChatMessageContent, ChatReaction, ChatRichText, ChatRoom, ChatRoomParticipation, ChatRoomRegistrationStatus, CodeFormat, 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, PocketCard, ProductAccountId, + EffectProps, 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, @@ -214,6 +226,53 @@ pub mod latest { /// Failure watching a funding session. pub type HostFundingStatusSubscribeError = LatestOf; + + /// Payment balance subscription item. + pub type HostPaymentBalanceSubscribeItem = + LatestOf; + /// Payment balance subscription failure. + pub type HostPaymentBalanceSubscribeError = + 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. + pub type HostPaymentError = LatestOf; + /// Progress of a payment. + pub type HostPaymentStatusSubscribeItem = + LatestOf; + /// Failure following a payment. + pub type HostPaymentStatusSubscribeError = + LatestOf; + /// Payment top-up request. + pub type HostPaymentTopUpRequest = LatestOf; + /// Payment top-up failure. + pub type HostPaymentTopUpError = LatestOf; + /// Progress of a payment top-up. + pub type HostPaymentTopUpStatusSubscribeItem = + LatestOf; + /// Failure following a payment top-up. + pub type HostPaymentTopUpStatusSubscribeError = + LatestOf; /// The calling product's Pocket cards. pub type HostPocketListSubscribeItem = LatestOf; /// Pocket card removal request. diff --git a/rust/crates/truapi/src/native.rs b/rust/crates/truapi/src/native.rs index d959a199f2..4de733b41a 100644 --- a/rust/crates/truapi/src/native.rs +++ b/rust/crates/truapi/src/native.rs @@ -29,12 +29,14 @@ mod ws_bridge; pub use crate::host_internal::sso_messages::SsoRequestOutcome; pub use crate::host_logic::dotns::{NavigateDecision, PocketDeeplinkAction}; pub use callbacks::{ - HostCallbacks, NativeChatCallbacks, NativeContactsCallbacks, NativeFundingCallbacks, - NativeGameCallbacks, - NativePocketCallbacks, NativePocketRemoval, + HostCallbacks, NativeBalanceCallbacks, NativeChatCallbacks, NativeContactsCallbacks, + NativeFundingCallbacks, NativeGameCallbacks, NativePaymentCallbacks, NativePocketCallbacks, + NativePocketRemoval, NativeTopUpCallbacks, }; pub use config::{HostRuntimeConfig, NativeRuntimeConfigError, ProductExecutionConfig}; -pub use errors::{HostRejection, NativeChatFieldError, NativeCoreDatabaseError, NativeRendererError}; +pub use errors::{ + HostRejection, NativeChatFieldError, NativeCoreDatabaseError, NativeRendererError, +}; pub use renderer::{NativeRendererObserver, NativeRendererSubscription}; pub use runtime::{ NativeAnnouncedPairing, NativePairingError, NativeProductExecution, NativeTrUApiHostRuntime, @@ -46,9 +48,9 @@ use serde::Deserialize; use truapi::latest; use crate::PairingProposal; -use crate::host_logic::dotns; #[cfg(doc)] use crate::SigningHostRuntime; +use crate::host_logic::dotns; /// Classify a navigation input exactly like the core's internal navigate host /// call: dotNS first, then `localhost`, then normalized external, with diff --git a/rust/crates/truapi/src/native/callbacks.rs b/rust/crates/truapi/src/native/callbacks.rs index 751d48f3b4..111ba8ce46 100644 --- a/rust/crates/truapi/src/native/callbacks.rs +++ b/rust/crates/truapi/src/native/callbacks.rs @@ -6,12 +6,12 @@ use truapi::v01; use crate::PairedSsoPeer; use crate::host_logic::worker::WorkerTransition; +#[cfg(doc)] +use super::NativeTrUApiHostRuntime; use super::config::ProductExecutionConfig; use super::errors::HostRejection; #[cfg(doc)] use crate::platform::CoreStorageKey; -#[cfg(doc)] -use super::NativeTrUApiHostRuntime; /// Callback surface that iOS and Android implement. /// @@ -346,6 +346,92 @@ 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); } + +/// Native payment engine, which pays from the user's balance to an account. +/// A host passes an implementation to +/// [`NativeTrUApiHostRuntime::set_payment_callbacks`] and reports each later +/// status with [`NativeTrUApiHostRuntime::notify_payment_status`]. +/// +/// [`NativeTrUApiHostRuntime::set_payment_callbacks`]: super::NativeTrUApiHostRuntime::set_payment_callbacks +/// [`NativeTrUApiHostRuntime::notify_payment_status`]: super::NativeTrUApiHostRuntime::notify_payment_status +#[uniffi::export(rust, foreign)] +#[async_trait::async_trait] +pub trait NativePaymentCallbacks: Send + Sync { + /// Ask the user to approve payment `request` for `product_id`, returning + /// once the user has decided: `Ok` when they authorized it and the host + /// took it on. Its id is unique across products. Its amount is a decimal + /// string of CASH units. + async fn request_payment( + &self, + product_id: String, + request: v01::HostPaymentRequest, + ) -> Result<(), v01::HostPaymentError>; + + /// The current status of `product_id`'s payment `id`, or `None` when the + /// host holds no such payment. + fn payment_status( + &self, + product_id: String, + id: crate::Bytes32, + ) -> Result, HostRejection>; +} + +/// Native top-up engine, which claims a source's funds into the user's +/// balance. A host passes an implementation to +/// [`NativeTrUApiHostRuntime::set_top_up_callbacks`] and reports each later +/// status with [`NativeTrUApiHostRuntime::notify_top_up_status`]. +/// +/// [`NativeTrUApiHostRuntime::set_top_up_callbacks`]: super::NativeTrUApiHostRuntime::set_top_up_callbacks +/// [`NativeTrUApiHostRuntime::notify_top_up_status`]: super::NativeTrUApiHostRuntime::notify_top_up_status +#[uniffi::export(rust, foreign)] +#[async_trait::async_trait] +pub trait NativeTopUpCallbacks: Send + Sync { + /// Start top-up `request` for `product_id`, returning once the host has + /// accepted it. Its amount is a decimal string of CASH units. + async fn top_up( + &self, + product_id: String, + request: v01::HostPaymentTopUpRequest, + ) -> Result<(), v01::HostPaymentTopUpError>; + + /// The current status of `product_id`'s top-up `id`, or `None` when the + /// host holds no such top-up. + fn top_up_status( + &self, + product_id: String, + id: crate::Bytes32, + ) -> Result, HostRejection>; +} + +/// Native balance view, which shares the user's spendable balance with a +/// product. A host passes an implementation to +/// [`NativeTrUApiHostRuntime::set_balance_callbacks`] and reports each change +/// with [`NativeTrUApiHostRuntime::notify_balance`]. +/// +/// [`NativeTrUApiHostRuntime::set_balance_callbacks`]: super::NativeTrUApiHostRuntime::set_balance_callbacks +/// [`NativeTrUApiHostRuntime::notify_balance`]: super::NativeTrUApiHostRuntime::notify_balance +#[uniffi::export(rust, foreign)] +#[async_trait::async_trait] +pub trait NativeBalanceCallbacks: Send + Sync { + /// The current balance of `purse` (`None` for the main purse) for + /// `product_id`, as a decimal string of CASH units: what a payment + /// request can spend right now. The core has already checked the + /// product's balance access. + async fn balance( + &self, + product_id: String, + purse: Option, + ) -> Result; +} diff --git a/rust/crates/truapi/src/native/config.rs b/rust/crates/truapi/src/native/config.rs index 69c6576409..08d231b9d7 100644 --- a/rust/crates/truapi/src/native/config.rs +++ b/rust/crates/truapi/src/native/config.rs @@ -6,8 +6,6 @@ use crate::platform::{ }; use truapi::latest::HostPlatform; - - /// Process-owned native host configuration shared by every product execution. #[derive(Debug, Clone, uniffi::Record)] pub struct HostRuntimeConfig { @@ -139,10 +137,14 @@ impl TryFrom for NativeResolvedHostRuntimeConfig { type Error = NativeRuntimeConfigError; fn try_from(config: HostRuntimeConfig) -> Result { - let people_chain_genesis_hash = - genesis_hash("people_chain_genesis_hash", &config.people_chain_genesis_hash)?; - let bulletin_chain_genesis_hash = - genesis_hash("bulletin_chain_genesis_hash", &config.bulletin_chain_genesis_hash)?; + let people_chain_genesis_hash = genesis_hash( + "people_chain_genesis_hash", + &config.people_chain_genesis_hash, + )?; + let bulletin_chain_genesis_hash = genesis_hash( + "bulletin_chain_genesis_hash", + &config.bulletin_chain_genesis_hash, + )?; let asset_hub_chain_genesis_hash = genesis_hash( "asset_hub_chain_genesis_hash", &config.asset_hub_chain_genesis_hash, diff --git a/rust/crates/truapi/src/native/errors.rs b/rust/crates/truapi/src/native/errors.rs index c75aa99e18..2ddec5a970 100644 --- a/rust/crates/truapi/src/native/errors.rs +++ b/rust/crates/truapi/src/native/errors.rs @@ -2,8 +2,6 @@ use truapi::v01; use crate::platform::ChatFieldError; - - /// Native-friendly rejection error returned by callback methods that map onto /// [`truapi::v01::GenericError`]. /// @@ -52,6 +50,36 @@ impl From for v01::HostLocalStorageReadEr } } +impl From for v01::HostPaymentError { + fn from(err: uniffi::UnexpectedUniFFICallbackError) -> Self { + tracing::warn!( + reason = %err.reason, + "host callback threw an undeclared error; reporting it as a rejection" + ); + v01::HostPaymentError::Unknown { reason: err.reason } + } +} + +impl From for v01::HostPaymentTopUpError { + fn from(err: uniffi::UnexpectedUniFFICallbackError) -> Self { + tracing::warn!( + reason = %err.reason, + "host callback threw an undeclared error; reporting it as a rejection" + ); + v01::HostPaymentTopUpError::Unknown { reason: err.reason } + } +} + +impl From for v01::HostPaymentBalanceSubscribeError { + fn from(err: uniffi::UnexpectedUniFFICallbackError) -> Self { + tracing::warn!( + reason = %err.reason, + "host callback threw an undeclared error; reporting it as a rejection" + ); + v01::HostPaymentBalanceSubscribeError::Unknown { reason: err.reason } + } +} + impl From for v01::HostNavigateToError { fn from(err: uniffi::UnexpectedUniFFICallbackError) -> Self { tracing::warn!( diff --git a/rust/crates/truapi/src/native/events.rs b/rust/crates/truapi/src/native/events.rs index 007d8faaa5..35d40481c9 100644 --- a/rust/crates/truapi/src/native/events.rs +++ b/rust/crates/truapi/src/native/events.rs @@ -255,7 +255,10 @@ impl NativeEventBus { } /// Send one Pocket stream item to every live subscriber, dropping closed ones. - pub fn send_pocket_cards(&self, item: Result) { + pub fn send_pocket_cards( + &self, + item: Result, + ) { self.pocket_card_changes .lock() .expect("native Pocket card subscribers mutex poisoned") diff --git a/rust/crates/truapi/src/native/platform.rs b/rust/crates/truapi/src/native/platform.rs index 18a8ac7989..b2875eb01f 100644 --- a/rust/crates/truapi/src/native/platform.rs +++ b/rust/crates/truapi/src/native/platform.rs @@ -16,9 +16,9 @@ use crate::host_logic::worker::WorkerTransition; use crate::{DevicePairingObserver, PairedSsoPeer}; use super::callbacks::{ - HostCallbacks, NativeChatCallbacks, NativeContactsCallbacks, NativeFundingCallbacks, - NativeGameCallbacks, - NativePocketCallbacks, NativePocketRemoval, + HostCallbacks, NativeBalanceCallbacks, NativeChatCallbacks, NativeContactsCallbacks, + NativeFundingCallbacks, NativeGameCallbacks, NativePaymentCallbacks, NativePocketCallbacks, + NativePocketRemoval, NativeTopUpCallbacks, }; use super::errors::HostRejection; use super::events::NativeEventBus; @@ -690,7 +690,634 @@ 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); } } + +/// One subscription to a host-pushed status. +struct Follower { + /// Tells this subscription apart from others on the same id. + subscription: u64, + sender: mpsc::UnboundedSender, +} + +/// Who follows which status, per product and id. +struct Followers { + next: u64, + by_id: std::collections::HashMap<(String, crate::Bytes32), Vec>>, +} + +impl Default for Followers { + fn default() -> Self { + Self { + next: 0, + by_id: std::collections::HashMap::new(), + } + } +} + +impl Followers { + /// Forget followers whose streams were dropped. + fn prune(&mut self) { + self.by_id.retain(|_, senders| { + senders.retain(|follower| !follower.sender.is_closed()); + !senders.is_empty() + }); + } +} + +/// Rank of a terminal status. +const TERMINAL_RANK: u8 = 3; + +/// Statuses a host answers once and pushes after, relayed to every stream +/// following an id: each starts at the host's current status and shows every +/// later one once, never stepping back, and ends at a terminal one. +struct StatusRelay { + followers: Mutex>>, + /// How far along a status is; [`TERMINAL_RANK`] ends a stream. + rank: fn(&T) -> u8, +} + +impl StatusRelay { + fn new(rank: fn(&T) -> u8) -> Self { + Self { + followers: Mutex::new(Followers::default()), + rank, + } + } + + /// Deliver a later `status` of `product_id`'s `id` to everyone following + /// it; a terminal one ends their streams. + fn notify(&self, product_id: String, id: crate::Bytes32, status: T) { + let terminal = (self.rank)(&status) == TERMINAL_RANK; + let mut followers = self.lock_followers(); + let key = (product_id, id); + if let Some(senders) = followers.by_id.get_mut(&key) { + for follower in senders.iter() { + let _ = follower.sender.unbounded_send(Ok(status.clone())); + } + } + if terminal { + followers.by_id.remove(&key); + } + followers.prune(); + } + + /// Follow `product_id`'s `id` from `current`, the host's answer for it + /// now; an error ends the stream there. + fn follow( + &self, + product_id: String, + id: crate::Bytes32, + current: impl FnOnce() -> Result, + ) -> BoxStream<'static, Result> { + let key = (product_id, id); + // Registered before the current status is read, so a status the host + // pushes in between is not lost; the stream below drops it if it + // repeats or precedes the current one. + let (sender, changes) = mpsc::unbounded(); + let follower = { + let mut followers = self.lock_followers(); + followers.prune(); + followers.next += 1; + let follower = followers.next; + followers + .by_id + .entry(key.clone()) + .or_default() + .push(Follower { + subscription: follower, + sender, + }); + follower + }; + let rank = self.rank; + let first = current(); + let last_rank = first.as_ref().map_or(TERMINAL_RANK, rank); + if last_rank == TERMINAL_RANK { + let mut followers = self.lock_followers(); + if let Some(senders) = followers.by_id.get_mut(&key) { + senders.retain(|registered| registered.subscription != follower); + } + followers.prune(); + return stream::iter([first]).boxed(); + } + let later = changes.scan(last_rank, move |shown, status: Result| { + let next = status.as_ref().map_or(TERMINAL_RANK, rank); + let fresh = next > *shown || (next == *shown && next == TERMINAL_RANK); + if fresh { + *shown = next; + } + futures::future::ready(Some(fresh.then_some(status))) + }); + stream::iter([first]) + .chain(later.filter_map(futures::future::ready)) + .boxed() + } + + /// How many ids are followed. + #[cfg(test)] + fn followed(&self) -> usize { + self.lock_followers().by_id.len() + } + + fn lock_followers(&self) -> std::sync::MutexGuard<'_, Followers>> { + self.followers + .lock() + .expect("status followers mutex poisoned") + } +} + +/// [`crate::platform::TopUpPlatform`] served by host-provided +/// [`NativeTopUpCallbacks`]: the host answers a top-up's current status, and +/// pushes each later one through [`Self::notify_status`]. +pub struct TopUpCallbackPlatform { + top_up: Arc, + statuses: StatusRelay< + v01::HostPaymentTopUpStatusSubscribeItem, + v01::HostPaymentTopUpStatusSubscribeError, + >, +} + +impl TopUpCallbackPlatform { + /// Serve top-ups from `top_up`. + pub fn new(top_up: Arc) -> Self { + Self { + top_up, + statuses: StatusRelay::new(top_up_rank), + } + } + + /// Deliver a later status of `product_id`'s top-up `id` to everyone + /// following it; a terminal one ends their streams. + pub fn notify_status( + &self, + product_id: String, + id: crate::Bytes32, + status: v01::HostPaymentTopUpStatusSubscribeItem, + ) { + self.statuses.notify(product_id, id, status); + } +} + +/// How far along a top-up `status` is, so a stream never steps back. +fn top_up_rank(status: &v01::HostPaymentTopUpStatusSubscribeItem) -> u8 { + match status { + v01::HostPaymentTopUpStatusSubscribeItem::Detecting => 0, + v01::HostPaymentTopUpStatusSubscribeItem::Claiming => 1, + v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: false } => 2, + v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true } + | v01::HostPaymentTopUpStatusSubscribeItem::ClaimedPartially { .. } + | v01::HostPaymentTopUpStatusSubscribeItem::NotClaimed => TERMINAL_RANK, + } +} + +#[async_trait] +impl crate::platform::TopUpPlatform for TopUpCallbackPlatform { + async fn top_up( + &self, + product: &ProductContext, + request: v01::HostPaymentTopUpRequest, + ) -> Result<(), v01::HostPaymentTopUpError> { + self.top_up + .top_up(product.product_id.clone(), request) + .await + } + + fn subscribe_top_up_status( + &self, + product: &ProductContext, + id: crate::Bytes32, + ) -> BoxStream< + 'static, + Result, + > { + let product_id = product.product_id.clone(); + self.statuses.follow(product_id.clone(), id, || { + match self.top_up.top_up_status(product_id, id) { + Ok(Some(status)) => Ok(status), + Ok(None) => Err(v01::HostPaymentTopUpStatusSubscribeError::NotFound), + Err(error) => Err(v01::HostPaymentTopUpStatusSubscribeError::Unknown { + reason: error.to_string(), + }), + } + }) + } +} + +/// [`crate::platform::PaymentPlatform`] served by host-provided +/// [`NativePaymentCallbacks`]: the host answers a payment's current status, +/// and pushes each later one through [`Self::notify_status`]. +pub struct PaymentCallbackPlatform { + payments: Arc, + statuses: + StatusRelay, +} + +impl PaymentCallbackPlatform { + /// Serve payments from `payments`. + pub fn new(payments: Arc) -> Self { + Self { + payments, + statuses: StatusRelay::new(payment_rank), + } + } + + /// Deliver a later status of `product_id`'s payment `id` to everyone + /// following it; a terminal one ends their streams. + pub fn notify_status( + &self, + product_id: String, + id: crate::Bytes32, + status: v01::HostPaymentStatusSubscribeItem, + ) { + self.statuses.notify(product_id, id, status); + } +} + +/// How far along a payment `status` is, so a stream never steps back. +fn payment_rank(status: &v01::HostPaymentStatusSubscribeItem) -> u8 { + match status { + v01::HostPaymentStatusSubscribeItem::Processing => 0, + v01::HostPaymentStatusSubscribeItem::Completed + | v01::HostPaymentStatusSubscribeItem::Failed { .. } + | v01::HostPaymentStatusSubscribeItem::PartiallyClaimed { .. } => TERMINAL_RANK, + } +} + +#[async_trait] +impl crate::platform::PaymentPlatform for PaymentCallbackPlatform { + async fn request_payment( + &self, + product: &ProductContext, + request: v01::HostPaymentRequest, + ) -> Result<(), v01::HostPaymentError> { + self.payments + .request_payment(product.product_id.clone(), request) + .await + } + + fn subscribe_payment_status( + &self, + product: &ProductContext, + id: crate::Bytes32, + ) -> BoxStream< + 'static, + Result, + > { + let product_id = product.product_id.clone(); + self.statuses.follow(product_id.clone(), id, || { + match self.payments.payment_status(product_id, id) { + Ok(Some(status)) => Ok(status), + Ok(None) => Err(v01::HostPaymentStatusSubscribeError::PaymentNotFound), + Err(error) => Err(v01::HostPaymentStatusSubscribeError::Unknown { + reason: error.to_string(), + }), + } + }) + } +} + +/// One product following the balance of a purse. +struct BalanceFollower { + purse: Option, + sender: mpsc::UnboundedSender, +} + +/// [`crate::platform::BalancePlatform`] served by host-provided +/// [`NativeBalanceCallbacks`]: the host answers a product's current balance, +/// and pushes each change through [`Self::notify_balance`]. +pub struct BalanceCallbackPlatform { + balance: Arc, + followers: Arc>>, +} + +impl BalanceCallbackPlatform { + /// Serve balances from `balance`. + pub fn new(balance: Arc) -> Self { + Self { + balance, + followers: Default::default(), + } + } + + /// Deliver the new balance of `purse` to every product following it. + pub fn notify_balance(&self, purse: Option, available: u128) { + let mut followers = self + .followers + .lock() + .expect("balance followers mutex poisoned"); + followers.retain(|follower| { + follower.purse != purse || follower.sender.unbounded_send(available).is_ok() + }); + } +} + +impl crate::platform::BalancePlatform for BalanceCallbackPlatform { + fn subscribe_balance( + &self, + product: &ProductContext, + purse: Option, + ) -> BoxStream< + 'static, + Result, + > { + // Registered before the host answers, so a change it pushes in + // between is not lost. + let (sender, changes) = mpsc::unbounded(); + let mut followers = self + .followers + .lock() + .expect("balance followers mutex poisoned"); + // Forget followers whose streams were dropped, so re-subscribing + // while the balance holds still does not grow the list. + followers.retain(|follower| !follower.sender.is_closed()); + followers.push(BalanceFollower { purse, sender }); + drop(followers); + let balance = self.balance.clone(); + let product_id = product.product_id.clone(); + stream::once(async move { (balance.balance(product_id, purse).await, changes) }) + .flat_map(|(first, changes)| match first { + Ok(available) => stream::iter([available]) + .chain(changes) + .scan(None, |shown, available| { + let fresh = *shown != Some(available); + *shown = Some(available); + futures::future::ready(Some(fresh.then_some(available))) + }) + .filter_map(futures::future::ready) + .map(|available| Ok(v01::HostPaymentBalanceSubscribeItem { available })) + .boxed(), + Err(error) => stream::iter([Err(error)]).boxed(), + }) + .boxed() + } +} + +#[cfg(test)] +mod status_relay_tests { + use super::*; + + use futures::executor::block_on; + + use crate::platform::TopUpPlatform; + + /// A top-up engine holding one status per id, and nothing else. + struct Engine( + Mutex>, + ); + + #[async_trait::async_trait] + impl NativeTopUpCallbacks for Engine { + async fn top_up( + &self, + _product_id: String, + _request: v01::HostPaymentTopUpRequest, + ) -> Result<(), v01::HostPaymentTopUpError> { + Ok(()) + } + + fn top_up_status( + &self, + _product_id: String, + id: crate::Bytes32, + ) -> Result, HostRejection> { + Ok(self.0.lock().expect("statuses").get(&id).cloned()) + } + } + + fn product() -> ProductContext { + ProductContext { + product_id: "fund.dot".into(), + execution_kind: Default::default(), + } + } + + // A follower sees the status the host holds now, then every status the + // host pushes, and its stream ends with the claim's verdict, so core's + // credit step and a product's subscription both learn how it ended. + #[test] + fn a_top_up_is_followed_from_its_current_status_to_its_verdict() { + let engine = Arc::new(Engine(Mutex::new(std::collections::HashMap::from([( + [1; 32], + v01::HostPaymentTopUpStatusSubscribeItem::Detecting, + )])))); + let platform = TopUpCallbackPlatform::new(engine); + let followed = platform.subscribe_top_up_status(&product(), [1; 32]); + let missing = platform.subscribe_top_up_status(&product(), [2; 32]); + + platform.notify_status( + "fund.dot".into(), + [1; 32], + v01::HostPaymentTopUpStatusSubscribeItem::Claiming, + ); + platform.notify_status( + "fund.dot".into(), + [1; 32], + v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true }, + ); + + assert_eq!( + ( + block_on(followed.collect::>()), + block_on(missing.collect::>()), + platform.statuses.followed(), + ), + ( + vec![ + Ok(v01::HostPaymentTopUpStatusSubscribeItem::Detecting), + Ok(v01::HostPaymentTopUpStatusSubscribeItem::Claiming), + Ok(v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true }), + ], + vec![Err(v01::HostPaymentTopUpStatusSubscribeError::NotFound)], + 0, + ) + ); + } + + // Core's credit step subscribes afresh on every pass. Its subscription + // seeing a verdict in the host's store must not end a product's stream + // that still waits for the host to push that verdict. + #[test] + fn a_subscriber_that_sees_the_verdict_leaves_other_followers_waiting() { + let engine = Arc::new(Engine(Mutex::new(std::collections::HashMap::from([( + [1; 32], + v01::HostPaymentTopUpStatusSubscribeItem::Claiming, + )])))); + let platform = TopUpCallbackPlatform::new(engine.clone()); + let product_stream = platform.subscribe_top_up_status(&product(), [1; 32]); + engine.0.lock().expect("statuses").insert( + [1; 32], + v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true }, + ); + let core_pass = platform.subscribe_top_up_status(&product(), [1; 32]); + let verdict = v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true }; + platform.notify_status("fund.dot".into(), [1; 32], verdict.clone()); + + assert_eq!( + ( + block_on(core_pass.collect::>()), + block_on(product_stream.collect::>()), + ), + ( + vec![Ok(verdict.clone())], + vec![ + Ok(v01::HostPaymentTopUpStatusSubscribeItem::Claiming), + Ok(verdict) + ], + ) + ); + } + + // A status pushed while the snapshot is read reaches the follower too; + // a stream shows each status once and never steps back, and a follower + // that stops listening is forgotten. + #[test] + fn a_stream_never_repeats_or_steps_back_and_dropped_followers_are_forgotten() { + let engine = Arc::new(Engine(Mutex::new(std::collections::HashMap::from([( + [1; 32], + v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: false }, + )])))); + let platform = TopUpCallbackPlatform::new(engine); + let followed = platform.subscribe_top_up_status(&product(), [1; 32]); + let dropped = platform.subscribe_top_up_status(&product(), [1; 32]); + drop(dropped); + for status in [ + v01::HostPaymentTopUpStatusSubscribeItem::Claiming, + v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: false }, + v01::HostPaymentTopUpStatusSubscribeItem::NotClaimed, + ] { + platform.notify_status("fund.dot".into(), [1; 32], status); + } + let _ = platform.subscribe_top_up_status(&product(), [2; 32]); + + assert_eq!( + ( + block_on(followed.collect::>()), + platform.statuses.followed(), + ), + ( + vec![ + Ok(v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: false }), + Ok(v01::HostPaymentTopUpStatusSubscribeItem::NotClaimed), + ], + 0, + ) + ); + } + + /// A payment engine holding one status per id, and nothing else. + struct Payments( + Mutex>, + ); + + #[async_trait::async_trait] + impl NativePaymentCallbacks for Payments { + async fn request_payment( + &self, + _product_id: String, + _request: v01::HostPaymentRequest, + ) -> Result<(), v01::HostPaymentError> { + Ok(()) + } + + fn payment_status( + &self, + _product_id: String, + id: crate::Bytes32, + ) -> Result, HostRejection> { + Ok(self.0.lock().expect("statuses").get(&id).cloned()) + } + } + + // A payment is followed from the host's current status to the verdict + // it pushes, and one the host does not hold is reported as not found, + // so its caller knows to request it again. + #[test] + fn a_payment_is_followed_to_its_verdict_and_an_unknown_one_is_not_found() { + use crate::platform::PaymentPlatform; + + let payments = Arc::new(Payments(Mutex::new(std::collections::HashMap::from([( + [1; 32], + v01::HostPaymentStatusSubscribeItem::Processing, + )])))); + let platform = PaymentCallbackPlatform::new(payments); + let followed = platform.subscribe_payment_status(&product(), [1; 32]); + let missing = platform.subscribe_payment_status(&product(), [2; 32]); + platform.notify_status( + "fund.dot".into(), + [1; 32], + v01::HostPaymentStatusSubscribeItem::Completed, + ); + + assert_eq!( + ( + block_on(followed.collect::>()), + block_on(missing.collect::>()) + ), + ( + vec![ + Ok(v01::HostPaymentStatusSubscribeItem::Processing), + Ok(v01::HostPaymentStatusSubscribeItem::Completed), + ], + vec![Err(v01::HostPaymentStatusSubscribeError::PaymentNotFound)], + ) + ); + } + + /// A balance view that shares a fixed balance. + struct Shared(u128); + + #[async_trait::async_trait] + impl NativeBalanceCallbacks for Shared { + async fn balance( + &self, + _product_id: String, + _purse: Option, + ) -> Result { + Ok(self.0) + } + } + + // A follower gets the current balance and then each change to its own + // purse, never the same value twice and never another purse's. + #[test] + fn a_balance_is_followed_through_each_change_to_its_purse() { + use crate::platform::BalancePlatform; + let platform = BalanceCallbackPlatform::new(Arc::new(Shared(10))); + let mut followed = platform.subscribe_balance(&product(), None); + let first = block_on(followed.next()); + platform.notify_balance(None, 10); + platform.notify_balance(Some(1), 99); + platform.notify_balance(None, 25); + let next = block_on(followed.next()); + let available = |available| Some(Ok(v01::HostPaymentBalanceSubscribeItem { available })); + assert_eq!((first, next), (available(10), available(25))); + } + + // Dropped subscriptions are forgotten on the next subscribe, so a product + // re-subscribing while the balance holds still keeps one follower. + #[test] + fn a_dropped_balance_follower_is_forgotten_on_resubscribe() { + use crate::platform::BalancePlatform; + let platform = BalanceCallbackPlatform::new(Arc::new(Shared(10))); + for _ in 0..3 { + drop(platform.subscribe_balance(&product(), None)); + } + let _live = platform.subscribe_balance(&product(), None); + assert_eq!(platform.followers.lock().unwrap().len(), 1); + } +} diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index ebef944b26..102edd6675 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, Weak}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; use crate::platform::{ CoreAdmin, PermissionAuthorizationRequest, PermissionAuthorizationStatus, ProductContext, @@ -25,26 +25,26 @@ use crate::subscription::Spawner; use crate::{PairedSsoPeer, ResponderExit, SigningHostRuntime}; use super::callbacks::{ - HostCallbacks, NativeChatCallbacks, NativeContactsCallbacks, NativeFundingCallbacks, - NativeGameCallbacks, - NativePocketCallbacks, + HostCallbacks, NativeBalanceCallbacks, NativeChatCallbacks, NativeContactsCallbacks, + NativeFundingCallbacks, NativeGameCallbacks, NativePaymentCallbacks, NativePocketCallbacks, + NativeTopUpCallbacks, }; use super::config::{ HostRuntimeConfig, NativeResolvedHostRuntimeConfig, NativeRuntimeConfigError, ProductExecutionConfig, }; use super::errors::{HostRejection, NativeCoreDatabaseError}; -use super::executor::shared_native_executor; use super::events::NativeEventBus; +use super::executor::shared_native_executor; +#[cfg(doc)] +use super::parse_pairing_deeplink; use super::platform::{ - CallbackPlatform, ChatCallbackPlatform, ContactsCallbackPlatform, FundingCallbackPlatform, - GameCallbackPlatform, - PocketCallbackPlatform, + BalanceCallbackPlatform, CallbackPlatform, ChatCallbackPlatform, ContactsCallbackPlatform, + FundingCallbackPlatform, GameCallbackPlatform, PaymentCallbackPlatform, PocketCallbackPlatform, + TopUpCallbackPlatform, }; #[cfg(doc)] use crate::WorkerTransition; -#[cfg(doc)] -use super::parse_pairing_deeplink; /// Process-owned native TrUAPI runtime shared by all executable connections. #[derive(uniffi::Object)] @@ -55,6 +55,13 @@ pub struct NativeTrUApiHostRuntime { ws_bridge: Arc, /// The one Worker execution per product; opening another replaces it. worker_executions: Mutex>>, + /// The host's top-up engine, once installed, which later statuses are + /// pushed through. + top_up: OnceLock>, + /// The payment engine statuses are pushed to, once installed. + payments: OnceLock>, + /// The balance view changes are pushed to, once installed. + balance: OnceLock>, } impl NativeTrUApiHostRuntime { @@ -72,11 +79,12 @@ impl NativeTrUApiHostRuntime { } })?; let directory = &runtime_config.database_directory; - let core_db = futures::executor::block_on(Db::open(core_db_config(directory))).map_err( - |err| NativeRuntimeConfigError::DatabaseUnavailable { - reason: format!("{}: {err}", directory.display()), - }, - )?; + let core_db = + futures::executor::block_on(Db::open(core_db_config(directory))).map_err(|err| { + NativeRuntimeConfigError::DatabaseUnavailable { + reason: format!("{}: {err}", directory.display()), + } + })?; let events = Arc::new(NativeEventBus::default()); let platform = Arc::new(CallbackPlatform { callbacks: callbacks.clone(), @@ -120,6 +128,9 @@ impl NativeTrUApiHostRuntime { callbacks.on_core_log(marker.to_string(), detail.to_string()); }))), worker_executions: Mutex::new(HashMap::new()), + top_up: OnceLock::new(), + payments: OnceLock::new(), + balance: OnceLock::new(), })) } @@ -288,10 +299,81 @@ 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?) } + + /// Install the host's top-up engine. Set-once; answers whether this call + /// installed it. Report each later status with + /// [`Self::notify_top_up_status`]. + pub fn set_top_up_callbacks(&self, callbacks: Arc) -> bool { + let platform = Arc::new(TopUpCallbackPlatform::new(callbacks)); + self.runtime.set_top_up_platform(platform.clone()) && self.top_up.set(platform).is_ok() + } + + /// Report a later status of `product_id`'s top-up `id` to the products + /// following it. + pub fn notify_top_up_status( + &self, + product_id: String, + id: crate::Bytes32, + status: v01::HostPaymentTopUpStatusSubscribeItem, + ) { + if let Some(platform) = self.top_up.get() { + platform.notify_status(product_id, id, status); + } + } + + /// Install the host's payment engine. Set-once; answers whether this + /// call installed it. Report each later status with + /// [`Self::notify_payment_status`]. + pub fn set_payment_callbacks(&self, callbacks: Arc) -> bool { + let platform = Arc::new(PaymentCallbackPlatform::new(callbacks)); + self.runtime.set_payment_platform(platform.clone()) && self.payments.set(platform).is_ok() + } + + /// Report a later status of `product_id`'s payment `id` to the products + /// following it. + pub fn notify_payment_status( + &self, + product_id: String, + id: crate::Bytes32, + status: v01::HostPaymentStatusSubscribeItem, + ) { + if let Some(platform) = self.payments.get() { + platform.notify_status(product_id, id, status); + } + } + + /// Install the host's balance view. Set-once; answers whether this call + /// installed it. Report each change with [`Self::notify_balance`]. + pub fn set_balance_callbacks(&self, callbacks: Arc) -> bool { + let platform = Arc::new(BalanceCallbackPlatform::new(callbacks)); + self.runtime.set_balance_platform(platform.clone()) && self.balance.set(platform).is_ok() + } + + /// Report the new balance of `purse` (`None` for the main purse), a + /// decimal string of CASH units, to the products following it. + pub fn notify_balance(&self, purse: Option, available: u128) { + if let Some(platform) = self.balance.get() { + platform.notify_balance(purse, available); + } + } + + /// 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/native/tests.rs b/rust/crates/truapi/src/native/tests.rs index 936c15855d..fe3af3a8ca 100644 --- a/rust/crates/truapi/src/native/tests.rs +++ b/rust/crates/truapi/src/native/tests.rs @@ -48,10 +48,7 @@ pub fn pocket_card(card_id: &str, privileged: bool) -> v01::PocketCard { /// Everything a Pocket stream has already queued, so a missing item reads /// as pending here rather than hanging the test. pub fn drain_pocket( - stream: &mut BoxStream< - 'static, - Result, - >, + stream: &mut BoxStream<'static, Result>, ) -> Vec> { let mut seen = Vec::new(); while let Some(Some(item)) = stream.next().now_or_never() { @@ -338,11 +335,7 @@ impl HostCallbacks for EventCallbacks { async fn core_storage_read(&self, key: Vec) -> Result>, HostRejection> { Ok(self.core_storage.lock().unwrap().get(&key).cloned()) } - async fn core_storage_write( - &self, - key: Vec, - value: Vec, - ) -> Result<(), HostRejection> { + async fn core_storage_write(&self, key: Vec, value: Vec) -> Result<(), HostRejection> { self.core_storage.lock().unwrap().insert(key, value); Ok(()) } @@ -748,9 +741,8 @@ fn the_deeplink_entry_points_reject_what_they_cannot_decode() { let host = native_host_runtime_no_session(); for (deeplink, expected) in UNDECODABLE_DEEPLINKS { - let answered = - futures::executor::block_on(host.establish_pairing(deeplink.to_string())) - .expect_err("an undecodable deeplink cannot be answered"); + let answered = futures::executor::block_on(host.establish_pairing(deeplink.to_string())) + .expect_err("an undecodable deeplink cannot be answered"); let announced = futures::executor::block_on( host.notify_pairing_allowance_allocation(deeplink.to_string()), ) @@ -788,10 +780,9 @@ fn a_decodable_deeplink_that_fails_is_not_reported_as_undecodable() { let answered = futures::executor::block_on(host.establish_pairing(deeplink.clone())) .expect_err("no session means no handshake to answer"); - let announced = - futures::executor::block_on(host.notify_pairing_allowance_allocation(deeplink)) - .err() - .expect("no session means no notice to sign"); + let announced = futures::executor::block_on(host.notify_pairing_allowance_allocation(deeplink)) + .err() + .expect("no session means no notice to sign"); for failure in [answered, announced] { assert!( @@ -954,8 +945,7 @@ fn native_pocket_removal_outcomes_are_decided_by_the_host() { }, )) }; - let mut cards = - crate::platform::PocketPlatform::subscribe_pocket_cards(&platform, &product); + let mut cards = crate::platform::PocketPlatform::subscribe_pocket_cards(&platform, &product); let first = futures::executor::block_on(cards.next()) .expect("the current list arrives on subscribe") .expect("no stream error"); @@ -1258,11 +1248,9 @@ fn native_chat_room_subscription_emits_current_then_notified_replacement() { chat: callbacks, events: events.clone(), }; - let product = ProductContext::new_with_execution( - "chat.dot".to_string(), - ProductExecutionKind::Worker, - ) - .unwrap(); + let product = + ProductContext::new_with_execution("chat.dot".to_string(), ProductExecutionKind::Worker) + .unwrap(); let mut stream = crate::platform::ChatPlatform::subscribe_chat_rooms(&platform, &product); let first = ready_rooms(stream.as_mut(), "initial room list"); @@ -1311,11 +1299,9 @@ fn native_chat_adapter_forwards_every_message_variant() { chat: callbacks.clone(), events: Arc::new(NativeEventBus::default()), }; - let product = ProductContext::new_with_execution( - "chat.dot".to_string(), - ProductExecutionKind::Worker, - ) - .unwrap(); + let product = + ProductContext::new_with_execution("chat.dot".to_string(), ProductExecutionKind::Worker) + .unwrap(); let reaction = v01::ChatReaction { message_id: "message-1".to_string(), emoji: "\u{1f3b2}".to_string(), @@ -1395,11 +1381,9 @@ fn a_posted_action_set_round_trips_to_the_product_that_posted_it() { chat: callbacks.clone(), events: Arc::new(NativeEventBus::default()), }; - let product = ProductContext::new_with_execution( - "chat.dot".to_string(), - ProductExecutionKind::Worker, - ) - .unwrap(); + let product = + ProductContext::new_with_execution("chat.dot".to_string(), ProductExecutionKind::Worker) + .unwrap(); let connection = crate::runtime::ActionChannel::chat(); let posted = futures::executor::block_on(crate::platform::ChatPlatform::post_chat_message( @@ -1439,8 +1423,7 @@ fn a_posted_action_set_round_trips_to_the_product_that_posted_it() { core::task::Poll::Ready(Some(item)) => item, other => panic!("a published trigger must be ready, got {other:?}"), }; - let Ok(truapi::versioned::chat::HostChatActionSubscribeItem::V1(delivered)) = delivered - else { + let Ok(truapi::versioned::chat::HostChatActionSubscribeItem::V1(delivered)) = delivered else { panic!("expected a chat action item") }; let v01::ChatActionPayload::ActionTriggered(trigger) = delivered.payload else { @@ -1471,16 +1454,13 @@ fn native_chat_adapter_surfaces_a_message_rejection() { chat: callbacks.clone(), events: Arc::new(NativeEventBus::default()), }; - let product = ProductContext::new_with_execution( - "chat.dot".to_string(), - ProductExecutionKind::Worker, - ) - .unwrap(); + let product = + ProductContext::new_with_execution("chat.dot".to_string(), ProductExecutionKind::Worker) + .unwrap(); *callbacks .chat_post_rejection .lock() - .expect("post rejection mutex poisoned") = - Some("cannot render a file card".to_string()); + .expect("post rejection mutex poisoned") = Some("cannot render a file card".to_string()); let error = futures::executor::block_on(crate::platform::ChatPlatform::post_chat_message( &platform, @@ -1523,11 +1503,9 @@ fn native_chat_adapter_surfaces_a_bot_registration_rejection() { chat: callbacks.clone(), events: Arc::new(NativeEventBus::default()), }; - let product = ProductContext::new_with_execution( - "chat.dot".to_string(), - ProductExecutionKind::Worker, - ) - .unwrap(); + let product = + ProductContext::new_with_execution("chat.dot".to_string(), ProductExecutionKind::Worker) + .unwrap(); *callbacks .chat_bot_rejection .lock() @@ -1569,11 +1547,9 @@ fn native_chat_adapter_preserves_bot_status_and_leaves_rooms_alone() { chat: callbacks.clone(), events: events.clone(), }; - let product = ProductContext::new_with_execution( - "chat.dot".to_string(), - ProductExecutionKind::Worker, - ) - .unwrap(); + let product = + ProductContext::new_with_execution("chat.dot".to_string(), ProductExecutionKind::Worker) + .unwrap(); let request = v01::HostChatRegisterBotRequest { bot_id: "flipper".to_string(), name: "Flipper".to_string(), @@ -1587,18 +1563,20 @@ fn native_chat_adapter_preserves_bot_status_and_leaves_rooms_alone() { .is_empty() ); - let registered = futures::executor::block_on( - crate::platform::ChatPlatform::register_chat_bot(&platform, &product, request.clone()), - ) + let registered = futures::executor::block_on(crate::platform::ChatPlatform::register_chat_bot( + &platform, + &product, + request.clone(), + )) .unwrap(); *callbacks .chat_bot_status .lock() .expect("bot status mutex poisoned") = v01::ChatBotRegistrationStatus::Exists; - let existing = futures::executor::block_on( - crate::platform::ChatPlatform::register_chat_bot(&platform, &product, request), - ) + let existing = futures::executor::block_on(crate::platform::ChatPlatform::register_chat_bot( + &platform, &product, request, + )) .unwrap(); assert_eq!(registered.status, v01::ChatBotRegistrationStatus::New); @@ -1643,11 +1621,9 @@ fn native_chat_adapter_preserves_room_status_and_message_room() { chat: callbacks.clone(), events: Arc::new(NativeEventBus::default()), }; - let product = ProductContext::new_with_execution( - "chat.dot".to_string(), - ProductExecutionKind::Worker, - ) - .unwrap(); + let product = + ProductContext::new_with_execution("chat.dot".to_string(), ProductExecutionKind::Worker) + .unwrap(); let request = v01::HostChatCreateRoomRequest { room_id: "support".to_string(), name: "Support".to_string(), @@ -1671,9 +1647,9 @@ fn native_chat_adapter_preserves_room_status_and_message_room() { .chat_room_status .lock() .expect("room status mutex poisoned") = v01::ChatRoomRegistrationStatus::Exists; - let existing = futures::executor::block_on( - crate::platform::ChatPlatform::create_chat_room(&platform, &product, request), - ) + let existing = futures::executor::block_on(crate::platform::ChatPlatform::create_chat_room( + &platform, &product, request, + )) .unwrap(); let posted = futures::executor::block_on(crate::platform::ChatPlatform::post_chat_message( &platform, @@ -1755,8 +1731,7 @@ fn native_chain_provider_rejects_an_early_close_and_allows_a_later_retry() { fn native_chain_provider_keeps_new_setup_when_an_active_connection_closes() { let (callbacks, events, platform) = event_platform(); *callbacks.chain_id.lock().unwrap() = Some(41); - let previous = - futures::executor::block_on(ChainProvider::connect(&platform, [9; 32])).unwrap(); + let previous = futures::executor::block_on(ChainProvider::connect(&platform, [9; 32])).unwrap(); let mut previous_responses = previous.responses(); *callbacks.chain_id.lock().unwrap() = Some(42); let closing_events = events.clone(); @@ -2009,10 +1984,7 @@ fn start_ws_bridge_twice_returns_already_running() { Ok(PermissionDecision::Deny) } fn auth_state_changed(&self, _state: AuthState) {} - async fn core_storage_read( - &self, - _key: Vec, - ) -> Result>, HostRejection> { + async fn core_storage_read(&self, _key: Vec) -> Result>, HostRejection> { Ok(None) } async fn core_storage_write( @@ -2028,11 +2000,7 @@ fn start_ws_bridge_twice_returns_already_running() { fn chain_connect(&self, _genesis_hash: Vec) -> Result, HostRejection> { Ok(None) } - fn chain_send( - &self, - _connection_id: u32, - _request: String, - ) -> Result<(), HostRejection> { + fn chain_send(&self, _connection_id: u32, _request: String) -> Result<(), HostRejection> { Ok(()) } fn chain_close(&self, _connection_id: u32) -> Result<(), HostRejection> { @@ -2044,10 +2012,7 @@ fn start_ws_bridge_twice_returns_already_running() { ) -> Result { Ok(false) } - async fn lookup_preimage( - &self, - _key: Vec, - ) -> Result>, HostRejection> { + async fn lookup_preimage(&self, _key: Vec) -> Result>, HostRejection> { Ok(None) } fn current_theme(&self) -> Result { @@ -2099,11 +2064,7 @@ fn start_ws_bridge_twice_returns_already_running() { ) -> Result { Ok(1) } - async fn end_operation( - &self, - _product_id: String, - _id: u32, - ) -> Result<(), HostRejection> { + async fn end_operation(&self, _product_id: String, _id: u32) -> Result<(), HostRejection> { Ok(()) } } @@ -2191,10 +2152,7 @@ fn pending_permission_decision_does_not_stall_bridge() { Ok(PermissionDecision::Deny) } fn auth_state_changed(&self, _state: AuthState) {} - async fn core_storage_read( - &self, - _key: Vec, - ) -> Result>, HostRejection> { + async fn core_storage_read(&self, _key: Vec) -> Result>, HostRejection> { Ok(None) } async fn core_storage_write( @@ -2210,11 +2168,7 @@ fn pending_permission_decision_does_not_stall_bridge() { fn chain_connect(&self, _genesis_hash: Vec) -> Result, HostRejection> { Ok(None) } - fn chain_send( - &self, - _connection_id: u32, - _request: String, - ) -> Result<(), HostRejection> { + fn chain_send(&self, _connection_id: u32, _request: String) -> Result<(), HostRejection> { Ok(()) } fn chain_close(&self, _connection_id: u32) -> Result<(), HostRejection> { @@ -2226,10 +2180,7 @@ fn pending_permission_decision_does_not_stall_bridge() { ) -> Result { Ok(false) } - async fn lookup_preimage( - &self, - _key: Vec, - ) -> Result>, HostRejection> { + async fn lookup_preimage(&self, _key: Vec) -> Result>, HostRejection> { Ok(None) } fn current_theme(&self) -> Result { @@ -2281,11 +2232,7 @@ fn pending_permission_decision_does_not_stall_bridge() { ) -> Result { Ok(1) } - async fn end_operation( - &self, - _product_id: String, - _id: u32, - ) -> Result<(), HostRejection> { + async fn end_operation(&self, _product_id: String, _id: u32) -> Result<(), HostRejection> { Ok(()) } } @@ -2361,43 +2308,39 @@ fn pending_permission_decision_does_not_stall_bridge() { .await .expect("send feature_supported"); - let feature_response = - tokio::time::timeout(std::time::Duration::from_secs(10), async { - loop { - match ws.next().await { - Some(Ok(WsMessage::Binary(bytes))) => { - break ProtocolMessage::decode(&mut &bytes[..]) - .expect("decode response"); - } - Some(Ok(_)) => continue, - Some(Err(err)) => panic!("ws error: {err}"), - None => panic!("connection closed before response"), + let feature_response = tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + match ws.next().await { + Some(Ok(WsMessage::Binary(bytes))) => { + break ProtocolMessage::decode(&mut &bytes[..]).expect("decode response"); } + Some(Ok(_)) => continue, + Some(Err(err)) => panic!("ws error: {err}"), + None => panic!("connection closed before response"), } - }) - .await - .expect("feature_supported must answer while the permission decision is pending"); + } + }) + .await + .expect("feature_supported must answer while the permission decision is pending"); release_tx .send(()) .await .expect("release permission callback"); - let permission_response = - tokio::time::timeout(std::time::Duration::from_secs(10), async { - loop { - match ws.next().await { - Some(Ok(WsMessage::Binary(bytes))) => { - break ProtocolMessage::decode(&mut &bytes[..]) - .expect("decode response"); - } - Some(Ok(_)) => continue, - Some(Err(err)) => panic!("ws error: {err}"), - None => panic!("connection closed before response"), + let permission_response = tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + match ws.next().await { + Some(Ok(WsMessage::Binary(bytes))) => { + break ProtocolMessage::decode(&mut &bytes[..]).expect("decode response"); } + Some(Ok(_)) => continue, + Some(Err(err)) => panic!("ws error: {err}"), + None => panic!("connection closed before response"), } - }) - .await - .expect("released permission must answer"); + } + }) + .await + .expect("released permission must answer"); (feature_response, permission_response) }); @@ -2602,15 +2545,13 @@ fn two_executions_share_one_bridge_through_the_native_api() { }; async fn answer(ws: &mut S) -> ProtocolMessage where - S: futures::Stream> - + Unpin, + S: futures::Stream> + Unpin, { tokio::time::timeout(std::time::Duration::from_secs(10), async { loop { match ws.next().await { Some(Ok(WsMessage::Binary(bytes))) => { - break ProtocolMessage::decode(&mut &bytes[..]) - .expect("decode response"); + break ProtocolMessage::decode(&mut &bytes[..]).expect("decode response"); } Some(Ok(_)) => continue, Some(Err(err)) => panic!("ws error: {err}"), @@ -2688,8 +2629,7 @@ pub fn native_host_runtime_no_session() -> Arc { #[test] fn handle_sso_request_rejects_undecodable_bytes() { let runtime = native_host_runtime_no_session(); - let result = - futures::executor::block_on(runtime.handle_sso_request(vec![0xFF, 0xFF, 0xFF])); + let result = futures::executor::block_on(runtime.handle_sso_request(vec![0xFF, 0xFF, 0xFF])); assert!(result.is_err(), "garbage bytes must be a decode error"); } @@ -2725,32 +2665,26 @@ fn bytes32_widens_to_plain_bytes_on_the_wire() { fn bytes32_lift_rejects_wrong_length() { let mut buf = Vec::new(); as uniffi::Lower>::write(vec![7; 31], &mut buf); - assert!( - >::try_read(&mut buf.as_slice()).is_err() - ); + assert!(>::try_read(&mut buf.as_slice()).is_err()); } #[test] fn bytes32_fields_survive_the_ffi_roundtrip() { - let review = UserConfirmationReview::CreateTransaction( - CreateTransactionReview::LegacyAccount(LegacyAccountTxPayload { + let review = UserConfirmationReview::CreateTransaction(CreateTransactionReview::LegacyAccount( + LegacyAccountTxPayload { signer: [13; 32], genesis_hash: [14; 32], call_data: vec![15], extensions: vec![], tx_ext_version: 0, - }), - ); + }, + )); let mut buf = Vec::new(); - >::write( - review.clone(), - &mut buf, - ); - let lifted = >::try_read( - &mut buf.as_slice(), - ) - .expect("review must lift back"); + >::write(review.clone(), &mut buf); + let lifted = + >::try_read(&mut buf.as_slice()) + .expect("review must lift back"); assert_eq!(lifted, review); } @@ -2790,8 +2724,7 @@ fn native_remote_authorization_uses_the_execution_permission_callback() { }, }; let response = - futures::executor::block_on(execution.authorize_remote_permission(request)) - .unwrap(); + futures::executor::block_on(execution.authorize_remote_permission(request)).unwrap(); assert_eq!( ( response, @@ -2921,13 +2854,12 @@ fn native_remote_authorization_rejects_closed_and_closing_executions() { ) .unwrap(); futures::executor::block_on(async { - let request = execution.authorize_remote_permission( - truapi::latest::RemotePermissionRequest { + let request = + execution.authorize_remote_permission(truapi::latest::RemotePermissionRequest { permission: truapi::latest::RemotePermission::Remote { domains: vec!["api.example.com".to_string()], }, - }, - ); + }); futures::pin_mut!(request); if pending { assert!(futures::poll!(&mut request).is_pending()); diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index f55d22401f..d5dddbb1e5 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -33,13 +33,17 @@ use truapi::latest::{ HostChatPostMessageResponse, HostChatRegisterBotError, HostChatRegisterBotRequest, HostChatRegisterBotResponse, HostDevicePermissionRequest, HostFeatureSupportedRequest, HostFeatureSupportedResponse, HostFundingStatusSubscribeItem, HostLocalStorageChangeItem, - HostLocaleSubscribeItem, HostNavigateToError, HostPlatform, - HostPocketListSubscribeItem, HostPocketRemoveCardError, HostPocketRemoveCardRequest, - HostPushNotificationRequest, HostPushNotificationResponse, HostSignPayloadRequest, - HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, - HostSignRawWithLegacyAccountRequest, HostThemeSubscribeItem, HostWorkerBeginOperationResponse, - HostWorkerOperationError, LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, - ProductProofContext, RemotePermission, RemotePermissionRequest, RingLocation, + HostLocaleSubscribeItem, HostNavigateToError, HostPaymentBalanceSubscribeError, + HostPaymentBalanceSubscribeItem, HostPaymentError, HostPaymentRequest, + HostPaymentStatusSubscribeError, HostPaymentStatusSubscribeItem, HostPaymentTopUpError, + HostPaymentTopUpRequest, HostPaymentTopUpStatusSubscribeError, + HostPaymentTopUpStatusSubscribeItem, HostPlatform, HostPocketListSubscribeItem, + HostPocketRemoveCardError, HostPocketRemoveCardRequest, HostPushNotificationRequest, + HostPushNotificationResponse, HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, + HostSignRawRequest, HostSignRawWithLegacyAccountRequest, HostThemeSubscribeItem, + HostWorkerBeginOperationResponse, HostWorkerOperationError, LegacyAccountTxPayload, + ProductAccountId, ProductAccountTxPayload, ProductProofContext, RemotePermission, + RemotePermissionRequest, RingLocation, }; use truapi::v01::HostAccountSignVrfRequest; use url::{Host, Url}; @@ -3276,6 +3280,76 @@ pub trait ChatPlatform: Send + Sync { ) -> BoxStream<'static, Result>; } +/// Host-implemented balance view: what a payment request can spend right now, +/// the figure the host checks a payment against. Optional: a host that omits +/// it leaves balance subscriptions answered `Unsupported`. +/// +/// The core asks for the product's balance access before calling here. +pub trait BalancePlatform: Send + Sync { + /// Emit the balance of `purse` (`None` for the main purse) now and on + /// every change. + fn subscribe_balance( + &self, + product: &ProductContext, + purse: Option, + ) -> BoxStream<'static, Result>; +} + +/// Host-implemented top-up engine: claims a source's funds into the user's +/// balance through the host's coinage onboarding. Optional: a host that omits +/// it leaves top-ups answered `Unsupported`. +/// +/// The core validates the source keys and hashes the product into the id +/// before calling, so ids never collide across products. The host owns +/// retries, partial claims and persistence. +#[async_trait] +pub trait TopUpPlatform: Send + Sync { + /// Start a top-up. Returns once the host has accepted it. + async fn top_up( + &self, + product: &ProductContext, + request: HostPaymentTopUpRequest, + ) -> Result<(), HostPaymentTopUpError>; + + /// Emit a top-up's current status and every later one, ending after a + /// terminal status. + fn subscribe_top_up_status( + &self, + product: &ProductContext, + id: [u8; 32], + ) -> BoxStream< + 'static, + Result, + >; +} + +/// Host-implemented payment engine: pays from the user's balance to an +/// account, once the user approves. Optional: a host that omits it leaves +/// payment requests answered `Unsupported`. +/// +/// The core hashes the product into the id before calling, so ids never +/// collide across products. The host owns the approval sheet, the transfer +/// and its persistence. +#[async_trait] +pub trait PaymentPlatform: Send + Sync { + /// Ask the user to approve `request`. Returns once the user has decided: + /// `Ok` when they authorized it and the host took it on; the payment's + /// outcome arrives through its status. + async fn request_payment( + &self, + product: &ProductContext, + request: HostPaymentRequest, + ) -> Result<(), HostPaymentError>; + + /// Emit a payment's current status and every later one, ending after a + /// terminal status. + fn subscribe_payment_status( + &self, + product: &ProductContext, + id: [u8; 32], + ) -> BoxStream<'static, Result>; +} + /// A funding session as the host overlay needs it to open on the right screen. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FundingPresentation { @@ -3311,6 +3385,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/platform/mock.rs b/rust/crates/truapi/src/platform/mock.rs index c212ba7e3e..1b92d422ff 100644 --- a/rust/crates/truapi/src/platform/mock.rs +++ b/rust/crates/truapi/src/platform/mock.rs @@ -1059,6 +1059,7 @@ fn remote_permission_key(permission: &latest::RemotePermission) -> &'static str Permission::ChainSubmit => "ChainSubmit", Permission::PreimageSubmit => "PreimageSubmit", Permission::StatementSubmit => "StatementSubmit", + Permission::BalanceAccess => "BalanceAccess", } } diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index b1229ed933..b56dd535d8 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -24,6 +24,7 @@ pub use funding::OpenFundingError; mod identity; pub mod login_failure; mod pairing_host; +mod payment_id; pub mod product_manifest; mod product_subtree; mod renderer; @@ -91,7 +92,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 +108,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 +135,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; @@ -1096,8 +1104,6 @@ fn transaction_call_error( })) } -const PAYMENTS_NOT_IMPLEMENTED: &str = "Payments are not supported in dot.li"; - impl ProductRuntimeHost { /// Chat access policy for this connection; see [`chat_platform_for`]. pub fn native_chat_platform( @@ -1271,6 +1277,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. /// @@ -1568,6 +1586,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/capabilities/payment.rs b/rust/crates/truapi/src/runtime/capabilities/payment.rs index e994ad240e..4ab7ef9986 100644 --- a/rust/crates/truapi/src/runtime/capabilities/payment.rs +++ b/rust/crates/truapi/src/runtime/capabilities/payment.rs @@ -2,6 +2,7 @@ //! //! Payment returns typed domain errors; CoinPayment returns Unsupported. +use futures::StreamExt; use tracing::instrument; use truapi::api::{CoinPayment, Payment}; use truapi::versioned::coin_payment::{ @@ -23,11 +24,14 @@ use truapi::versioned::payment::{ HostPaymentBalanceSubscribeRequest, HostPaymentError, HostPaymentRequest, HostPaymentResponse, HostPaymentStatusSubscribeError, HostPaymentStatusSubscribeItem, HostPaymentStatusSubscribeRequest, HostPaymentTopUpError, HostPaymentTopUpRequest, - HostPaymentTopUpResponse, + HostPaymentTopUpResponse, HostPaymentTopUpStatusSubscribeError, + HostPaymentTopUpStatusSubscribeItem, HostPaymentTopUpStatusSubscribeRequest, }; use truapi::{CallContext, CallError, Subscription, v01}; -use crate::runtime::{PAYMENTS_NOT_IMPLEMENTED, ProductRuntimeHost}; +use crate::host_internal::extrinsic::sr25519_secret_from_bytes; +use crate::runtime::ProductRuntimeHost; +use crate::runtime::payment_id::host_payment_id; #[truapi::async_trait] impl CoinPayment for ProductRuntimeHost { @@ -128,51 +132,181 @@ impl Payment for ProductRuntimeHost { async fn balance_subscribe( &self, _cx: &CallContext, - _request: HostPaymentBalanceSubscribeRequest, + request: HostPaymentBalanceSubscribeRequest, ) -> Subscription> { - Subscription::interrupted(CallError::Domain(HostPaymentBalanceSubscribeError::V1( - v01::HostPaymentBalanceSubscribeError::PermissionDenied, - ))) + let HostPaymentBalanceSubscribeRequest::V1(request) = request; + let Some(platform) = self.services.balance_platform() else { + return Subscription::interrupted(CallError::Unsupported); + }; + if self.authority.current_session().is_none() { + return Subscription::interrupted(CallError::Denied); + } + if let Err(error) = self + .require_remote_permission( + v01::RemotePermission::BalanceAccess, + HostPaymentBalanceSubscribeError::V1( + v01::HostPaymentBalanceSubscribeError::PermissionDenied, + ), + ) + .await + { + return Subscription::interrupted(error); + } + Subscription::new(Box::pin( + platform + .subscribe_balance(&self.product, request.purse) + .map(|item| { + item.map(HostPaymentBalanceSubscribeItem::V1).map_err(|error| { + CallError::Domain(HostPaymentBalanceSubscribeError::V1(error)) + }) + }), + )) } #[instrument(skip_all, fields(runtime.method = "payment.request"))] async fn request( &self, _cx: &CallContext, - _request: HostPaymentRequest, + request: HostPaymentRequest, ) -> Result> { - Err(CallError::Domain(HostPaymentError::V1( - v01::HostPaymentError::Unknown { - reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), - }, - ))) + let HostPaymentRequest::V1(mut request) = request; + let platform = self + .services + .payment_platform() + .ok_or(CallError::Unsupported)?; + if self.authority.current_session().is_none() { + return Err(CallError::Denied); + } + request.id = host_payment_id(&self.product, request.id); + match platform.request_payment(&self.product, request).await { + Ok(()) => Ok(HostPaymentResponse::V1), + // Only a product the user lets see the balance may learn that the + // balance was short; to any other it reads as a refusal. + Err(v01::HostPaymentError::InsufficientBalance) + if !self.holds_balance_access().await => + { + Err(CallError::Domain(HostPaymentError::V1( + v01::HostPaymentError::Rejected, + ))) + } + Err(error) => Err(CallError::Domain(HostPaymentError::V1(error))), + } } #[instrument(skip_all, fields(runtime.method = "payment.status_subscribe"))] async fn status_subscribe( &self, _cx: &CallContext, - _request: HostPaymentStatusSubscribeRequest, + request: HostPaymentStatusSubscribeRequest, ) -> Subscription> { - Subscription::interrupted(CallError::Domain(HostPaymentStatusSubscribeError::V1( - v01::HostPaymentStatusSubscribeError::Unknown { - reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), - }, - ))) + let HostPaymentStatusSubscribeRequest::V1(request) = request; + let Some(platform) = self.services.payment_platform() else { + return Subscription::interrupted(CallError::Unsupported); + }; + if self.authority.current_session().is_none() { + return Subscription::interrupted(CallError::Denied); + } + Subscription::new(Box::pin( + platform + .subscribe_payment_status(&self.product, host_payment_id(&self.product, request.id)) + .map(|item| { + item.map(HostPaymentStatusSubscribeItem::V1).map_err(|error| { + CallError::Domain(HostPaymentStatusSubscribeError::V1(error)) + }) + }), + )) } #[instrument(skip_all, fields(runtime.method = "payment.top_up"))] async fn top_up( &self, _cx: &CallContext, - _request: HostPaymentTopUpRequest, + request: HostPaymentTopUpRequest, ) -> Result> { - Err(CallError::Domain(HostPaymentTopUpError::V1( - v01::HostPaymentTopUpError::Unknown { - reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), - }, - ))) + let HostPaymentTopUpRequest::V1(mut request) = request; + let platform = self + .services + .top_up_platform() + .ok_or(CallError::Unsupported)?; + if self.authority.current_session().is_none() { + return Err(CallError::Denied); + } + let domain = |error| CallError::Domain(HostPaymentTopUpError::V1(error)); + if request.amount == 0 { + return Err(domain(v01::HostPaymentTopUpError::Unknown { + reason: "amount must be positive".to_string(), + })); + } + if !source_keys_are_valid(&request.source) { + return Err(domain(v01::HostPaymentTopUpError::InvalidSource)); + } + request.id = host_payment_id(&self.product, request.id); + platform + .top_up(&self.product, request) + .await + .map(|()| HostPaymentTopUpResponse::V1) + .map_err(domain) + } + + #[instrument(skip_all, fields(runtime.method = "payment.top_up_status_subscribe"))] + async fn top_up_status_subscribe( + &self, + _cx: &CallContext, + request: HostPaymentTopUpStatusSubscribeRequest, + ) -> Subscription< + HostPaymentTopUpStatusSubscribeItem, + CallError, + > { + let HostPaymentTopUpStatusSubscribeRequest::V1(request) = request; + let Some(platform) = self.services.top_up_platform() else { + return Subscription::interrupted(CallError::Unsupported); + }; + if self.authority.current_session().is_none() { + return Subscription::interrupted(CallError::Denied); + } + Subscription::new(Box::pin( + platform + .subscribe_top_up_status(&self.product, host_payment_id(&self.product, request.id)) + .map(|item| { + item.map(HostPaymentTopUpStatusSubscribeItem::V1).map_err(|error| { + CallError::Domain(HostPaymentTopUpStatusSubscribeError::V1(error)) + }) + }), + )) + } +} + +impl ProductRuntimeHost { + /// Whether the product already holds balance access, without asking. + async fn holds_balance_access(&self) -> bool { + matches!( + self.permissions_service() + .peek_remote(&v01::RemotePermissionRequest { + permission: v01::RemotePermission::BalanceAccess, + }) + .await, + Ok(crate::platform::PermissionAuthorizationStatus::Authorized) + ) + } +} + +/// Whether the secret keys a source carries are usable sr25519 keys. A product +/// account carries none, and a coin source must name at least one coin. +fn source_keys_are_valid(source: &v01::PaymentTopUpSource) -> bool { + match source { + v01::PaymentTopUpSource::ProductAccount { .. } => true, + v01::PaymentTopUpSource::PrivateKey { sr25519_secret_key } => { + sr25519_secret_from_bytes(sr25519_secret_key).is_ok() + } + v01::PaymentTopUpSource::Coins { + sr25519_secret_keys, + } => { + !sr25519_secret_keys.is_empty() + && sr25519_secret_keys + .iter() + .all(|key| sr25519_secret_from_bytes(key).is_ok()) + } } } diff --git a/rust/crates/truapi/src/runtime/funding.rs b/rust/crates/truapi/src/runtime/funding.rs index 241d503d5a..fa46da35a0 100644 --- a/rust/crates/truapi/src/runtime/funding.rs +++ b/rust/crates/truapi/src/runtime/funding.rs @@ -18,22 +18,30 @@ 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::runtime::payment_id::host_payment_id; use crate::unix_time::current_unix_millis; /// Wait before retrying an expiry sweep whose write failed. 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)] @@ -50,6 +58,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 { @@ -64,6 +82,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() @@ -110,21 +235,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 @@ -205,12 +344,71 @@ impl FundingRegistry { self.fan_out(&session); } } + drop(loaded); + self.follow_sessions(); Ok(result) } - /// 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. + /// 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 deadline while any session can + /// expire, so a session expires on time whether or not anyone asks. The + /// task ends once none can or the registry is dropped. pub fn keep_expiring(self: &Arc, services: &RuntimeServices) { if self.sweeping.swap(true, Ordering::AcqRel) { return; @@ -256,7 +454,7 @@ impl FundingRegistry { fn next_deadline(&self) -> Option { self.lock_sessions() .values() - .filter(|session| !session.is_terminal()) + .filter(|session| session.can_expire()) .map(|session| session.deadline_ms) .min() } @@ -296,6 +494,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. @@ -324,6 +536,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(); @@ -390,13 +603,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, host_payment_id(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, host_payment_id(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. /// @@ -409,6 +759,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( @@ -483,7 +834,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; @@ -598,6 +949,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/payment_id.rs b/rust/crates/truapi/src/runtime/payment_id.rs new file mode 100644 index 0000000000..7df6337d38 --- /dev/null +++ b/rust/crates/truapi/src/runtime/payment_id.rs @@ -0,0 +1,11 @@ +//! The ids a host sees for the top-ups and payments a product starts. + +use parity_scale_codec::Encode; + +use crate::platform::ProductContext; + +/// The id the host sees for a top-up or payment id `product` chose. Hashing in +/// the product means no product can name another's. +pub fn host_payment_id(product: &ProductContext, id: [u8; 32]) -> [u8; 32] { + sp_crypto_hashing::blake2_256(&(b"truapi/payment-id", product.product_id.as_str(), id).encode()) +} diff --git a/rust/crates/truapi/src/runtime/services.rs b/rust/crates/truapi/src/runtime/services.rs index 8fccf41c68..c894c66d06 100644 --- a/rust/crates/truapi/src/runtime/services.rs +++ b/rust/crates/truapi/src/runtime/services.rs @@ -46,6 +46,14 @@ pub struct RuntimeServices { pocket_platform: OnceLock>, /// Funding sessions shared by every product runtime of this host. funding: Arc, + + /// Host top-up engine, installed once at startup by a host that claims + /// funds into the balance. Unset leaves every top-up `Unsupported`. + top_up_platform: OnceLock>, + /// The host's payment engine, once installed. + payment_platform: OnceLock>, + /// The host's balance view, once installed. + balance_platform: OnceLock>, /// Host contacts adapter, installed once at startup by a host with a /// contact picker. Unset leaves every product contacts call `Unsupported`. contacts_platform: OnceLock>, @@ -128,6 +136,10 @@ impl RuntimeServices { permission_status: OnceLock::new(), pocket_platform: OnceLock::new(), funding: Default::default(), + + top_up_platform: OnceLock::new(), + payment_platform: OnceLock::new(), + balance_platform: OnceLock::new(), contacts_platform: OnceLock::new(), contact_handles: Default::default(), game_platform: OnceLock::new(), @@ -223,6 +235,39 @@ impl RuntimeServices { &self.funding } + /// Install the host's top-up engine. Set-once; returns whether this call + /// installed it. + pub fn install_top_up_platform(&self, platform: Arc) -> bool { + self.top_up_platform.set(platform).is_ok() + } + + /// The host's top-up engine, when one is installed. + pub fn top_up_platform(&self) -> Option> { + self.top_up_platform.get().cloned() + } + + /// Install the host's payment engine. Set-once; returns whether this + /// call installed it. + pub fn install_payment_platform(&self, platform: Arc) -> bool { + self.payment_platform.set(platform).is_ok() + } + + /// The host's payment engine, when one is installed. + pub fn payment_platform(&self) -> Option> { + self.payment_platform.get().cloned() + } + + /// Install the host's balance view. Set-once; returns whether this call + /// installed it. + pub fn install_balance_platform(&self, platform: Arc) -> bool { + self.balance_platform.set(platform).is_ok() + } + + /// The host's balance view, when one is installed. + pub fn balance_platform(&self) -> Option> { + self.balance_platform.get().cloned() + } + /// The host's Pocket adapter, when one is installed. pub fn pocket_platform(&self) -> Option> { self.pocket_platform.get().cloned() diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index f90a4a5f82..b3879d57bd 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -2605,6 +2605,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, @@ -2683,6 +2692,14 @@ impl crate::platform::FundingPlatform for ObservingFundingPlatform { *self.stored_while_shown.lock().expect("stored mutex poisoned") = Some(stored); Ok(crate::platform::FundingPresentOutcome::Started) } + async fn present_provider_frame( + &self, + _provider: &ProductContext, + _intent: String, + _route: String, + ) -> Result { + Ok(truapi::latest::FundingFrameOutcome::Closed) + } } // An app killed while the overlay is up must not come back to a session the @@ -8175,3 +8192,779 @@ fn a_pairing_test_host_keeps_a_submitted_preimage_and_serves_it_back() { )) ); } + +fn payment_services() -> Arc { + payment_services_over(stub_platform()) +} + +fn payment_services_over(platform: Arc) -> Arc { + let (host_config, _) = runtime_config("payments.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(), + ) +} + +fn payment_host(services: &Arc, product_id: &str, with_session: bool) -> ProductRuntimeHost { + let (host_config, product) = runtime_config(product_id); + 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, + ); + if with_session { + install_pairing_session(&host, session_info()); + } + host +} + +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 }), + ) + ); +} + +// The core follows a provider's top-up under the id the host received it +// with, so delivery is decided from the top-up the provider actually started. +#[test] +fn a_session_settles_from_the_top_up_the_provider_started() { + let services = funding_services(); + assert!(services.funding().install_platform(RecordingFundingPlatform::answering( + crate::platform::FundingPresentOutcome::Started, + ))); + let engine = Arc::new(RecordingTopUpPlatform::default()); + assert!(services.install_top_up_platform(engine.clone())); + let intent = futures::executor::block_on(services.open_funding(None, v01::FundingDirection::In, Some(1_000))) + .expect("opened") + .intent; + assert!(futures::executor::block_on(services.select_funding_provider(&intent, "ramp.dot")).expect("selected")); + let worker = provider_worker(&services, "ramp.dot"); + first_served(&worker); + top_up(&worker, product_account_source()).expect("top-up accepted"); + report_funding(&worker, &intent, v01::FundingUpdate::Crediting { top_up_id: [7; 32], amount: 1_000 }) + .expect("crediting"); + report_funding(&worker, &intent, v01::FundingUpdate::Delivered).expect("delivered"); + + let stage = wait_for_stage(&services, &intent); + let started_id = engine.started.lock().expect("started mutex poisoned")[0].1.id; + assert_eq!( + (stage, engine.followed.lock().expect("followed mutex poisoned").clone()), + ( + Some(v01::HostFundingStatusSubscribeItem::Delivered { credited: 1_000 }), + vec![("ramp.dot".to_string(), started_id)], + ) + ); +} + +// 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>, + followed: Mutex>, +} + +#[truapi::async_trait] +impl crate::platform::TopUpPlatform for RecordingTopUpPlatform { + async fn top_up( + &self, + product: &ProductContext, + request: truapi::latest::HostPaymentTopUpRequest, + ) -> Result<(), truapi::latest::HostPaymentTopUpError> { + self.started + .lock() + .expect("started mutex poisoned") + .push((product.product_id.clone(), request)); + Ok(()) + } + + fn subscribe_top_up_status( + &self, + product: &ProductContext, + id: [u8; 32], + ) -> futures::stream::BoxStream< + 'static, + Result< + truapi::latest::HostPaymentTopUpStatusSubscribeItem, + truapi::latest::HostPaymentTopUpStatusSubscribeError, + >, + > { + self.followed + .lock() + .expect("followed mutex poisoned") + .push((product.product_id.clone(), id)); + Box::pin(futures::stream::iter([ + Ok(v01::HostPaymentTopUpStatusSubscribeItem::Claiming), + Ok(v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true }), + ])) + } +} + +fn top_up( + host: &ProductRuntimeHost, + source: v01::PaymentTopUpSource, +) -> Result< + truapi::versioned::payment::HostPaymentTopUpResponse, + CallError, +> { + futures::executor::block_on(truapi::api::Payment::top_up( + host, + &CallContext::default(), + truapi::versioned::payment::HostPaymentTopUpRequest::V1(v01::HostPaymentTopUpRequest { + into: None, + amount: 1_000, + source, + id: [7; 32], + }), + )) +} + +fn product_account_source() -> v01::PaymentTopUpSource { + v01::PaymentTopUpSource::ProductAccount { + derivation_index: v01::DerivationIndex::Index(0), + } +} + +#[test] +fn a_top_up_reaches_the_host_engine_scoped_to_its_product() { + let services = payment_services(); + let engine = Arc::new(RecordingTopUpPlatform::default()); + assert!(services.install_top_up_platform(engine.clone())); + + top_up(&payment_host(&services, "wallet.dot", true), product_account_source()) + .expect("top-up accepted"); + + let started = engine.started.lock().expect("started mutex poisoned").clone(); + let host_id = started[0].1.id; + assert_ne!(host_id, [7; 32]); + assert_eq!( + started.as_slice(), + [( + "wallet.dot".to_string(), + v01::HostPaymentTopUpRequest { + into: None, + amount: 1_000, + source: product_account_source(), + id: host_id, + }, + )] + ); +} + +// A coin source with no coins can never claim anything, so it is refused in +// the core rather than handed to the host. +#[test] +fn a_top_up_without_coins_is_refused_before_the_host_sees_it() { + let services = payment_services(); + let engine = Arc::new(RecordingTopUpPlatform::default()); + assert!(services.install_top_up_platform(engine.clone())); + + let refused = top_up( + &payment_host(&services, "wallet.dot", true), + v01::PaymentTopUpSource::Coins { + sr25519_secret_keys: Vec::new(), + }, + ); + + assert_eq!( + ( + refused, + engine.started.lock().expect("started mutex poisoned").len() + ), + ( + Err(CallError::Domain( + truapi::versioned::payment::HostPaymentTopUpError::V1( + v01::HostPaymentTopUpError::InvalidSource + ) + )), + 0 + ) + ); +} + +#[test] +fn top_up_status_is_forwarded_from_the_host_engine() { + let services = payment_services(); + let engine = Arc::new(RecordingTopUpPlatform::default()); + assert!(services.install_top_up_platform(engine.clone())); + let host = payment_host(&services, "wallet.dot", true); + top_up(&host, product_account_source()).expect("top-up accepted"); + let host_id = engine.started.lock().expect("started mutex poisoned")[0].1.id; + + let statuses = futures::executor::block_on( + futures::executor::block_on(truapi::api::Payment::top_up_status_subscribe( + &host, + &CallContext::default(), + truapi::versioned::payment::HostPaymentTopUpStatusSubscribeRequest::V1( + v01::HostPaymentTopUpStatusSubscribeRequest { id: [7; 32] }, + ), + )) + .collect::>(), + ); + + assert_eq!( + ( + statuses, + engine.followed.lock().expect("followed mutex poisoned").clone() + ), + ( + vec![ + Ok(truapi::versioned::payment::HostPaymentTopUpStatusSubscribeItem::V1( + v01::HostPaymentTopUpStatusSubscribeItem::Claiming + )), + Ok(truapi::versioned::payment::HostPaymentTopUpStatusSubscribeItem::V1( + v01::HostPaymentTopUpStatusSubscribeItem::Claimed { finalized: true } + )), + ], + vec![("wallet.dot".to_string(), host_id)] + ) + ); +} + +// The source key is spent by the host, so a key that is not a usable sr25519 +// secret is refused before any claim starts. +#[test] +fn a_top_up_with_a_malformed_key_is_refused_before_the_host_sees_it() { + let services = payment_services(); + let engine = Arc::new(RecordingTopUpPlatform::default()); + assert!(services.install_top_up_platform(engine.clone())); + + let refused = top_up( + &payment_host(&services, "wallet.dot", true), + v01::PaymentTopUpSource::PrivateKey { + sr25519_secret_key: [0xff; 64], + }, + ); + + assert_eq!( + ( + refused, + engine.started.lock().expect("started mutex poisoned").len() + ), + ( + Err(CallError::Domain( + truapi::versioned::payment::HostPaymentTopUpError::V1( + v01::HostPaymentTopUpError::InvalidSource + ) + )), + 0 + ) + ); +} + +#[test] +fn a_top_up_needs_a_session() { + let services = payment_services(); + assert!(services.install_top_up_platform(Arc::new(RecordingTopUpPlatform::default()))); + + assert_eq!( + top_up(&payment_host(&services, "wallet.dot", false), product_account_source()), + Err(CallError::Denied) + ); +} + +#[derive(Default)] +struct RecordingPaymentPlatform { + requested: Mutex>, + followed: Mutex>, +} + +#[truapi::async_trait] +impl crate::platform::PaymentPlatform for RecordingPaymentPlatform { + async fn request_payment( + &self, + product: &ProductContext, + request: truapi::latest::HostPaymentRequest, + ) -> Result<(), truapi::latest::HostPaymentError> { + self.requested + .lock() + .expect("requested mutex poisoned") + .push((product.product_id.clone(), request)); + Ok(()) + } + + fn subscribe_payment_status( + &self, + product: &ProductContext, + id: [u8; 32], + ) -> futures::stream::BoxStream< + 'static, + Result, + > { + self.followed + .lock() + .expect("followed mutex poisoned") + .push((product.product_id.clone(), id)); + Box::pin(futures::stream::iter([ + Ok(v01::HostPaymentStatusSubscribeItem::Processing), + Ok(v01::HostPaymentStatusSubscribeItem::PartiallyClaimed { actual_claimed: 600 }), + ])) + } +} + +fn payment_request() -> v01::HostPaymentRequest { + v01::HostPaymentRequest { + from: None, + amount: 1_000, + destination: [3; 32], + id: [9; 32], + } +} + +fn request_payment( + host: &ProductRuntimeHost, +) -> Result> { + futures::executor::block_on(truapi::api::Payment::request( + host, + &CallContext::default(), + truapi::versioned::payment::HostPaymentRequest::V1(payment_request()), + )) +} + +fn follow_payment( + host: &ProductRuntimeHost, +) -> Vec< + Result< + truapi::versioned::payment::HostPaymentStatusSubscribeItem, + CallError, + >, +> { + futures::executor::block_on( + futures::executor::block_on(truapi::api::Payment::status_subscribe( + host, + &CallContext::default(), + truapi::versioned::payment::HostPaymentStatusSubscribeRequest::V1( + v01::HostPaymentStatusSubscribeRequest { id: [9; 32] }, + ), + )) + .collect::>(), + ) +} + +// The host owns the approval sheet and the transfer; core hands it the +// request under the id the host sees for it, follows the payment under that +// same id, and relays the status the host reports, a partial payment included. +#[test] +fn a_payment_reaches_the_host_engine_and_its_status_is_relayed() { + let services = payment_services(); + let engine = Arc::new(RecordingPaymentPlatform::default()); + assert!(services.install_payment_platform(engine.clone())); + let host = payment_host(&services, "wallet.dot", true); + + let requested = request_payment(&host); + let statuses = follow_payment(&host); + let followed = engine.followed.lock().expect("followed mutex poisoned").clone(); + let host_id = followed[0].1; + assert_ne!(host_id, payment_request().id); + assert_eq!( + ( + requested, + statuses, + engine.requested.lock().expect("requested mutex poisoned").clone(), + followed, + ), + ( + Ok(truapi::versioned::payment::HostPaymentResponse::V1), + vec![ + Ok(truapi::versioned::payment::HostPaymentStatusSubscribeItem::V1( + v01::HostPaymentStatusSubscribeItem::Processing + )), + Ok(truapi::versioned::payment::HostPaymentStatusSubscribeItem::V1( + v01::HostPaymentStatusSubscribeItem::PartiallyClaimed { actual_claimed: 600 } + )), + ], + vec![( + "wallet.dot".to_string(), + v01::HostPaymentRequest { id: host_id, ..payment_request() } + )], + vec![("wallet.dot".to_string(), host_id)], + ) + ); +} + +// A host cannot mix up two products' payments, even when both chose the +// same id: each reaches the host under its own. +#[test] +fn two_products_naming_one_id_reach_the_host_under_different_ids() { + let services = payment_services(); + let engine = Arc::new(RecordingPaymentPlatform::default()); + assert!(services.install_payment_platform(engine.clone())); + follow_payment(&payment_host(&services, "wallet.dot", true)); + follow_payment(&payment_host(&services, "shop.dot", true)); + + let followed = engine.followed.lock().expect("followed mutex poisoned").clone(); + assert_ne!(followed[0].1, followed[1].1); +} + +// Payments move the user's balance, so a product without a session cannot +// reach the host's payments. +#[test] +fn no_product_pays_or_follows_payments_without_a_session() { + let services = payment_services(); + let engine = Arc::new(RecordingPaymentPlatform::default()); + assert!(services.install_payment_platform(engine.clone())); + let signed_out = payment_host(&services, "wallet.dot", false); + + assert_eq!( + ( + request_payment(&signed_out), + follow_payment(&signed_out), + engine.requested.lock().expect("requested mutex poisoned").len(), + engine.followed.lock().expect("followed mutex poisoned").len(), + ), + (Err(CallError::Denied), vec![Err(CallError::Denied)], 0, 0) + ); +} + +/// A balance view sharing one balance with every product but `private.dot`, +/// and the products it was asked for. +#[derive(Default)] +struct RecordingBalancePlatform { + asked: Mutex)>>, +} + +impl crate::platform::BalancePlatform for RecordingBalancePlatform { + fn subscribe_balance( + &self, + product: &crate::platform::ProductContext, + purse: Option, + ) -> futures::stream::BoxStream< + 'static, + Result, + > { + self.asked + .lock() + .expect("asked mutex poisoned") + .push((product.product_id.clone(), purse)); + let shared = if product.product_id == "private.dot" { + Err(v01::HostPaymentBalanceSubscribeError::PermissionDenied) + } else { + Ok(v01::HostPaymentBalanceSubscribeItem { available: 4_000_000 }) + }; + futures::stream::iter([shared]).boxed() + } +} + +fn first_balance( + host: &ProductRuntimeHost, +) -> Option>> { + futures::executor::block_on( + futures::executor::block_on(truapi::api::Payment::balance_subscribe( + host, + &CallContext::default(), + truapi::versioned::payment::HostPaymentBalanceSubscribeRequest::V1( + v01::HostPaymentBalanceSubscribeRequest { purse: None }, + ), + )) + .next(), + ) + .map(|item| item.map(|truapi::versioned::payment::HostPaymentBalanceSubscribeItem::V1(item)| item)) +} + +// A product sees the balance the host shares with it, the host can refuse +// one product, a product without a session is refused before the host is +// asked, and a host with no balance view answers Unsupported. +#[test] +fn the_balance_comes_from_the_host_for_each_product() { + let services = payment_services(); + let unsupported = first_balance(&payment_host(&services, "wallet.dot", true)); + let platform = Arc::new(RecordingBalancePlatform::default()); + assert!(services.install_balance_platform(platform.clone())); + let shared = first_balance(&payment_host(&services, "wallet.dot", true)); + let refused = first_balance(&payment_host(&services, "private.dot", true)); + let no_session = first_balance(&payment_host(&services, "wallet.dot", false)); + + let denied = |error| CallError::Domain(truapi::versioned::payment::HostPaymentBalanceSubscribeError::V1(error)); + assert_eq!( + (unsupported, shared, refused, no_session, platform.asked.lock().expect("asked mutex poisoned").clone()), + ( + Some(Err(CallError::Unsupported)), + Some(Ok(v01::HostPaymentBalanceSubscribeItem { available: 4_000_000 })), + Some(Err(denied(v01::HostPaymentBalanceSubscribeError::PermissionDenied))), + Some(Err(CallError::Denied)), + vec![("wallet.dot".to_string(), None), ("private.dot".to_string(), None)], + ) + ); +} + +// A product sees the balance only once the user lets it: a refusal answers +// PermissionDenied, and the host's balance view is never asked. +#[test] +fn the_balance_needs_the_users_permission_before_the_host_is_asked() { + let platform = Arc::new(crate::test_support::StubPlatform { + remote_permission_denied: true, + ..Default::default() + }); + let services = payment_services_over(platform.clone()); + let balance = Arc::new(RecordingBalancePlatform::default()); + assert!(services.install_balance_platform(balance.clone())); + + let refused = first_balance(&payment_host(&services, "wallet.dot", true)); + + assert_eq!( + ( + refused, + balance.asked.lock().expect("asked mutex poisoned").len(), + platform.remote_permission_requests.lock().expect("requests mutex poisoned").clone(), + ), + ( + Some(Err(CallError::Domain( + truapi::versioned::payment::HostPaymentBalanceSubscribeError::V1( + v01::HostPaymentBalanceSubscribeError::PermissionDenied, + ), + ))), + 0, + vec![v01::RemotePermissionRequest { permission: v01::RemotePermission::BalanceAccess }], + ) + ); +} + +/// A payment engine whose user never has enough. +struct ShortPaymentPlatform; + +#[truapi::async_trait] +impl crate::platform::PaymentPlatform for ShortPaymentPlatform { + async fn request_payment( + &self, + _product: &ProductContext, + _request: truapi::latest::HostPaymentRequest, + ) -> Result<(), truapi::latest::HostPaymentError> { + Err(v01::HostPaymentError::InsufficientBalance) + } + + fn subscribe_payment_status( + &self, + _product: &ProductContext, + _id: [u8; 32], + ) -> futures::stream::BoxStream< + 'static, + Result, + > { + Box::pin(futures::stream::empty()) + } +} + +// A short balance is the balance's business: a product that may not see the +// balance learns only that the payment was refused, and asking does not +// prompt the user. +#[test] +fn a_short_balance_reads_as_a_refusal_unless_the_product_may_see_it() { + let platform = Arc::new(crate::test_support::StubPlatform::default()); + let services = payment_services_over(platform.clone()); + assert!(services.install_payment_platform(Arc::new(ShortPaymentPlatform))); + let host = payment_host(&services, "wallet.dot", true); + + let without_access = request_payment(&host); + futures::executor::block_on(host.set_permission_authorization_status( + PermissionAuthorizationRequest::Remote(v01::RemotePermissionRequest { + permission: v01::RemotePermission::BalanceAccess, + }), + PermissionAuthorizationStatus::Authorized, + )) + .expect("grant stored"); + let with_access = request_payment(&host); + + let refused = |error| CallError::Domain(truapi::versioned::payment::HostPaymentError::V1(error)); + assert_eq!( + ( + without_access, + with_access, + platform.remote_permission_requests.lock().expect("requests mutex poisoned").len(), + ), + ( + Err(refused(v01::HostPaymentError::Rejected)), + Err(refused(v01::HostPaymentError::InsufficientBalance)), + 0, + ) + ); +} + +// Nothing to claim is not a top-up: the host never sees it. +#[test] +fn a_top_up_of_nothing_is_refused_before_the_host_sees_it() { + let services = payment_services(); + let engine = Arc::new(RecordingTopUpPlatform::default()); + assert!(services.install_top_up_platform(engine.clone())); + let host = payment_host(&services, "wallet.dot", true); + + let refused = futures::executor::block_on(truapi::api::Payment::top_up( + &host, + &CallContext::default(), + truapi::versioned::payment::HostPaymentTopUpRequest::V1(v01::HostPaymentTopUpRequest { + into: None, + amount: 0, + source: product_account_source(), + id: [7; 32], + }), + )); + + assert_eq!( + (refused, engine.started.lock().expect("started mutex poisoned").len()), + ( + Err(CallError::Domain(truapi::versioned::payment::HostPaymentTopUpError::V1( + v01::HostPaymentTopUpError::Unknown { reason: "amount must be positive".to_string() }, + ))), + 0, + ) + ); +} diff --git a/rust/crates/truapi/src/v01.rs b/rust/crates/truapi/src/v01.rs index 1e1001cca9..dced17f112 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 game; mod local_storage; mod locale; @@ -34,6 +35,7 @@ pub use common::*; pub use contacts::*; pub use entropy::*; pub use funding::*; +pub use funding_provider::*; pub use game::*; pub use local_storage::*; pub use locale::*; 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/v01/payment.rs b/rust/crates/truapi/src/v01/payment.rs index 5acd987653..7e21647775 100644 --- a/rust/crates/truapi/src/v01/payment.rs +++ b/rust/crates/truapi/src/v01/payment.rs @@ -26,6 +26,10 @@ pub struct HostPaymentBalanceSubscribeItem { /// /// [RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94 #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum PaymentTopUpSource { /// Fund from one of the calling product's scoped accounts. ProductAccount { @@ -51,6 +55,10 @@ pub enum PaymentTopUpSource { /// Request to top up the product payment balance. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct HostPaymentTopUpRequest { /// Optional purse selector. `None` means MAIN_PURSE. pub into: Option, @@ -58,10 +66,17 @@ pub struct HostPaymentTopUpRequest { pub amount: u128, /// Funding source for the top-up. pub source: PaymentTopUpSource, + /// Caller-chosen id the top-up's status is followed by. Reusing one is + /// refused with `AlreadyExists`, which makes a retried call safe. + pub id: [u8; 32], } /// Request to initiate a payment to another account. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] pub struct HostPaymentRequest { /// Optional purse selector. `None` means MAIN_PURSE. pub from: Option, @@ -69,38 +84,39 @@ pub struct HostPaymentRequest { pub amount: u128, /// Destination account. pub destination: [u8; 32], -} - -/// Receipt returned after a successful payment request. -/// -/// See [RFC 0006]. -/// -/// [RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94 -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HostPaymentResponse { - /// The assigned payment identifier. - pub id: String, + /// Caller-chosen id the payment's status is followed by. Reusing one is + /// refused with `AlreadyExists`, which makes a retried call safe. + pub id: [u8; 32], } /// Payment lifecycle status pushed to subscribers. /// -/// Once a terminal state (`Completed` or `Failed`) is reached, the host -/// delivers it and may close the subscription. +/// `Completed`, `Failed` and `PartiallyClaimed` are terminal, and stay +/// readable after the payment ends. /// /// See [RFC 0006]. /// /// [RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94 #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] pub enum HostPaymentStatusSubscribeItem { /// Payment is being processed. Processing, - /// Payment has been settled successfully. + /// The full amount reached the destination. Completed, /// Payment has failed. Failed { /// Failure reason. reason: String, }, + /// Less than the requested amount reached the destination. + PartiallyClaimed { + /// Amount that did. + actual_claimed: u128, + }, } /// Error from [`crate::api::Payment::balance_subscribe`]. @@ -108,11 +124,17 @@ pub enum HostPaymentStatusSubscribeItem { /// See [RFC 0006]. /// /// [RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94 -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::Display)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Error) +)] pub enum HostPaymentBalanceSubscribeError { /// User denied the balance disclosure request. + #[display("balance not shared")] PermissionDenied, /// Catch-all. + #[display("{reason}")] Unknown { /// Human-readable failure reason. reason: String, @@ -124,18 +146,23 @@ pub enum HostPaymentBalanceSubscribeError { /// See [RFC 0006]. /// /// [RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94 -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::Display)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Error) +)] pub enum HostPaymentTopUpError { - /// 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. + #[display("invalid top-up source")] InvalidSource, - /// Some coins were claimed but the total fell short of the requested amount. - PartialPayment { - /// Amount that was successfully credited. - credited: u128, - }, + /// A top-up with this id already exists. + #[display("a top-up with this id already exists")] + AlreadyExists, + /// Another top-up from the same source is still running. + #[display("another top-up from this source is running")] + SourceBusy, /// Catch-all. + #[display("{reason}")] Unknown { /// Human-readable failure reason. reason: String, @@ -147,13 +174,23 @@ pub enum HostPaymentTopUpError { /// See [RFC 0006]. /// /// [RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94 -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::Display)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Error) +)] pub enum HostPaymentError { + /// A payment with this id already exists. + #[display("a payment with this id already exists")] + AlreadyExists, /// User rejected the payment request. + #[display("the payment was declined")] Rejected, /// User's available balance is not sufficient for the requested amount. + #[display("the balance does not cover the payment")] InsufficientBalance, /// Catch-all. + #[display("{reason}")] Unknown { /// Human-readable failure reason. reason: String, @@ -179,6 +216,52 @@ pub enum HostPaymentStatusSubscribeError { /// Request to subscribe to a payment status. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostPaymentStatusSubscribeRequest { - /// Payment identifier to watch. - pub payment_id: String, + /// Id the payment was requested with. + pub id: [u8; 32], +} + +/// Request to follow one top-up. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostPaymentTopUpStatusSubscribeRequest { + /// Id the top-up was started with. + pub id: [u8; 32], +} + +/// Progress of a top-up. `Claimed { finalized: true }`, `ClaimedPartially` and +/// `NotClaimed` are terminal, and stay readable after the top-up ends. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] +pub enum HostPaymentTopUpStatusSubscribeItem { + /// Waiting for the source's funds to be seen. + Detecting, + /// Claiming the funds into the balance. + Claiming, + /// The full amount was claimed. + Claimed { + /// Whether the claim is finalized on chain. + finalized: bool, + }, + /// Less than the requested amount was claimed, such as when an amount + /// below the smallest coin denomination remains. + ClaimedPartially { + /// Amount actually credited. + actual_claimed: u128, + }, + /// Nothing was claimed. + NotClaimed, +} + +/// Error from [`crate::api::Payment::top_up_status_subscribe`]. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostPaymentTopUpStatusSubscribeError { + /// No top-up with this id for the calling product. + NotFound, + /// Catch-all. + Unknown { + /// Human-readable failure reason. + reason: String, + }, } diff --git a/rust/crates/truapi/src/v01/permissions.rs b/rust/crates/truapi/src/v01/permissions.rs index 5ee1cf5a0b..64fd9ce03a 100644 --- a/rust/crates/truapi/src/v01/permissions.rs +++ b/rust/crates/truapi/src/v01/permissions.rs @@ -51,8 +51,9 @@ pub enum HostDevicePermissionRequest { /// One remote-operation permission requested by the product (RFC 0002). /// -/// `ChainSubmit`, `PreimageSubmit`, and `StatementSubmit` are also triggered -/// implicitly by the corresponding business calls when not yet granted. +/// `ChainSubmit`, `PreimageSubmit`, `StatementSubmit` and `BalanceAccess` +/// are also triggered implicitly by the corresponding business calls when not +/// yet granted. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, Display)] #[cfg_attr( all(feature = "runtime", not(target_arch = "wasm32")), @@ -88,6 +89,11 @@ pub enum RemotePermission { /// Submitting statements on behalf of the user via `remote_statement_store_submit`. #[display("submit statements")] StatementSubmit, + /// Seeing the user's payment balance through `payment.balanceSubscribe`, + /// and learning that a payment request was refused for the balance rather + /// than by the user. + #[display("see your balance")] + BalanceAccess, } /// remote-permission request (RFC 0002). diff --git a/rust/crates/truapi/src/versioned.rs b/rust/crates/truapi/src/versioned.rs index 805105d327..209c9cefbf 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 game; pub mod local_storage; pub mod locale; 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/rust/crates/truapi/src/versioned/payment.rs b/rust/crates/truapi/src/versioned/payment.rs index d252cf4ecc..72f77f98df 100644 --- a/rust/crates/truapi/src/versioned/payment.rs +++ b/rust/crates/truapi/src/versioned/payment.rs @@ -9,8 +9,11 @@ truapi_macros::versioned_type! { pub enum HostPaymentTopUpRequest { V1 => v01::HostPaymentTopUpRequest } pub enum HostPaymentTopUpResponse { V1 } pub enum HostPaymentTopUpError { V1 => v01::HostPaymentTopUpError } + pub enum HostPaymentTopUpStatusSubscribeRequest { V1 => v01::HostPaymentTopUpStatusSubscribeRequest } + pub enum HostPaymentTopUpStatusSubscribeItem { V1 => v01::HostPaymentTopUpStatusSubscribeItem } + pub enum HostPaymentTopUpStatusSubscribeError { V1 => v01::HostPaymentTopUpStatusSubscribeError } pub enum HostPaymentRequest { V1 => v01::HostPaymentRequest } - pub enum HostPaymentResponse { V1 => v01::HostPaymentResponse } + pub enum HostPaymentResponse { V1 } pub enum HostPaymentError { V1 => v01::HostPaymentError } pub enum HostPaymentStatusSubscribeRequest { V1 => v01::HostPaymentStatusSubscribeRequest } pub enum HostPaymentStatusSubscribeItem { V1 => v01::HostPaymentStatusSubscribeItem } diff --git a/rust/crates/truapi/tests/wire_result_shape.rs b/rust/crates/truapi/tests/wire_result_shape.rs index 635760f120..5beaa059e6 100644 --- a/rust/crates/truapi/tests/wire_result_shape.rs +++ b/rust/crates/truapi/tests/wire_result_shape.rs @@ -40,8 +40,6 @@ use truapi::frame::{ mod common; use common::{RecordingTransport, WireShapePlatform, test_runtime_config, test_spawner}; -const PAYMENTS_NOT_IMPLEMENTED: &str = "Payments are not supported in dot.li"; - fn dispatch(core: &TrUApiCore, frame: ProtocolMessage) -> ProtocolMessage { let encoded = frame.encode(); let response_bytes = futures::executor::block_on(core.receive_from_product(&encoded)) @@ -200,36 +198,6 @@ fn versioned_interrupt_err_payload(wrapped_error: Wrapper) -> V expected } -fn assert_request_returns_domain_error( - core: &TrUApiCore, - request_id: &str, - method: &str, - value: Vec, - wrapped_error: Wrapper, -) { - let ids = request_ids(method).expect("known request method"); - let response = dispatch( - core, - ProtocolMessage { - request_id: request_id.into(), - payload: Payload { - trait_id: ids.trait_id, - method_id: ids.method_id, - message_type: MESSAGE_TYPE_REQUEST, - value, - }, - }, - ); - assert_eq!(response.request_id, request_id); - assert_eq!(response.payload.trait_id, ids.trait_id); - assert_eq!(response.payload.method_id, ids.method_id); - assert_eq!(response.payload.message_type, MESSAGE_TYPE_RESPONSE); - assert_eq!( - response.payload.value, - versioned_result_err_payload(wrapped_error) - ); -} - fn assert_subscription_start_interrupts_error( core: &TrUApiCore, request_id: &str, @@ -328,72 +296,99 @@ fn foreign_account_proof_encodes_a_domain_refusal() { assert_eq!(response.payload.value, expected); } +/// A host with no payment engine answers `Unsupported`, so a product can tell +/// "this host never pays" from a payment that failed. #[test] -fn deferred_payment_requests_return_dotli_not_implemented_errors() { +fn payment_request_reports_unsupported_without_a_host_engine() { let core = make_core(); let request = v01::HostPaymentRequest { from: None, amount: 1, destination: [0u8; 32], + id: [7; 32], }; - - assert_request_returns_domain_error( - &core, - "p:payment", - "payment_request", - truapi::versioned::payment::HostPaymentRequest::V1(request).encode(), - truapi::versioned::payment::HostPaymentError::V1(v01::HostPaymentError::Unknown { - reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), - }), - ); - - let top_up = v01::HostPaymentTopUpRequest { - into: None, - amount: 1, - source: v01::PaymentTopUpSource::ProductAccount { - derivation_index: v01::DerivationIndex::Index(0), + let ids = request_ids("payment_request").expect("known request method"); + let frame = ProtocolMessage { + request_id: "p:payment".into(), + payload: Payload { + trait_id: ids.trait_id, + method_id: ids.method_id, + message_type: MESSAGE_TYPE_REQUEST, + value: truapi::versioned::payment::HostPaymentRequest::V1(request).encode(), }, }; - assert_request_returns_domain_error( - &core, - "p:top-up", - "payment_top_up", - truapi::versioned::payment::HostPaymentTopUpRequest::V1(top_up).encode(), - truapi::versioned::payment::HostPaymentTopUpError::V1( - v01::HostPaymentTopUpError::Unknown { - reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + let response = dispatch(&core, frame); + assert_eq!(response.payload.value, vec![0x01u8, 0x02u8]); +} + +/// Following a payment on a host with no payment engine interrupts with +/// `Unsupported`, as requesting one answers. +#[test] +fn payment_status_subscription_interrupts_unsupported_without_a_host_engine() { + let core = make_core(); + let status = v01::HostPaymentStatusSubscribeRequest { id: [7; 32] }; + let ids = subscription_ids("payment_status_subscribe").expect("known subscription method"); + let transport = Arc::new(RecordingTransport::default()); + futures::executor::block_on( + core.dispatch( + ProtocolMessage { + request_id: "p:status".into(), + payload: Payload { + trait_id: ids.trait_id, + method_id: ids.method_id, + message_type: MESSAGE_TYPE_START, + value: truapi::versioned::payment::HostPaymentStatusSubscribeRequest::V1( + status, + ) + .encode(), + }, }, + transport.clone(), ), ); + transport.wait_for(1, std::time::Duration::from_secs(5)); + + let sent = transport.sent.lock().unwrap(); + assert_eq!( + (sent[0].payload.message_type, sent[0].payload.value.clone()), + // [Result::Err=0x01][CallError::Unsupported=0x02], as for a request. + (MESSAGE_TYPE_INTERRUPT, vec![0x01u8, 0x02u8]) + ); } +/// Following the balance on a host with no balance view interrupts with +/// `Unsupported`, so a product can tell "this host never shares a balance" +/// from a user who declined. #[test] -fn deferred_payment_subscriptions_interrupt_dotli_not_implemented_errors() { +fn payment_balance_subscription_interrupts_unsupported_without_a_host_view() { let core = make_core(); let balance = v01::HostPaymentBalanceSubscribeRequest { purse: None }; - assert_subscription_start_interrupts_error( - &core, - "p:balance", - "payment_balance_subscribe", - truapi::versioned::payment::HostPaymentBalanceSubscribeRequest::V1(balance).encode(), - truapi::versioned::payment::HostPaymentBalanceSubscribeError::V1( - v01::HostPaymentBalanceSubscribeError::PermissionDenied, + let ids = subscription_ids("payment_balance_subscribe").expect("known subscription method"); + let transport = Arc::new(RecordingTransport::default()); + futures::executor::block_on( + core.dispatch( + ProtocolMessage { + request_id: "p:balance".into(), + payload: Payload { + trait_id: ids.trait_id, + method_id: ids.method_id, + message_type: MESSAGE_TYPE_START, + value: truapi::versioned::payment::HostPaymentBalanceSubscribeRequest::V1( + balance, + ) + .encode(), + }, + }, + transport.clone(), ), ); + transport.wait_for(1, std::time::Duration::from_secs(5)); - let status = v01::HostPaymentStatusSubscribeRequest { - payment_id: "payment-id".to_string(), - }; - assert_subscription_start_interrupts_error( - &core, - "p:status", - "payment_status_subscribe", - truapi::versioned::payment::HostPaymentStatusSubscribeRequest::V1(status).encode(), - truapi::versioned::payment::HostPaymentStatusSubscribeError::V1( - v01::HostPaymentStatusSubscribeError::Unknown { - reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), - }, - ), + let sent = transport.sent.lock().unwrap(); + assert_eq!( + (sent[0].payload.message_type, sent[0].payload.value.clone()), + // [Result::Err=0x01][CallError::Unsupported=0x02] + (MESSAGE_TYPE_INTERRUPT, vec![0x01u8, 0x02u8]) ); } @@ -680,3 +675,30 @@ fn coin_payment_request_reports_unsupported_on_the_wire() { // `Unsupported` carries no domain payload, so no wrapper tag follows. assert_eq!(response.payload.value, vec![0x01u8, 0x02u8]); } + +/// A host with no top-up engine answers `Unsupported`, so a product can tell +/// "this host never tops up" from a top-up that failed. +#[test] +fn top_up_reports_unsupported_without_a_host_engine() { + let core = make_core(); + let request = v01::HostPaymentTopUpRequest { + into: None, + amount: 1, + source: v01::PaymentTopUpSource::ProductAccount { + derivation_index: v01::DerivationIndex::Index(0), + }, + id: [7; 32], + }; + let ids = request_ids("payment_top_up").expect("known request method"); + let frame = ProtocolMessage { + request_id: "p:top-up".into(), + payload: Payload { + trait_id: ids.trait_id, + method_id: ids.method_id, + message_type: MESSAGE_TYPE_REQUEST, + value: truapi::versioned::payment::HostPaymentTopUpRequest::V1(request).encode(), + }, + }; + let response = dispatch(&core, frame); + assert_eq!(response.payload.value, vec![0x01u8, 0x02u8]); +} 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"