RFC: Funding Modality - #339
Conversation
Introduce a `Funding` trait defining the runtime contract for the funding modality: moving value across the boundary between a user's Polkadot balance and everything outside it, in both directions, over card/bank, cross-chain swap, and peer-to-peer cash rails. Four methods, wire ids 168-179: - `request` / `status_subscribe` for consumers declaring and watching an intent, with an opaque `resume` context so a session can resume the action an insufficient balance interrupted. - `serve_subscribe` / `report` for providers that declare the modality in their manifest, receive intents addressed to them, and report progress. The two directions carry different guarantees, and the status type says so: `Delivered` is emitted only on the host's own on-chain observation of arrival, while `Released` asserts only that funds left under the user's authorization. No host can verify that cash reached a hand or a bank account, so the protocol declines to offer a shared "done" state that would let the UI claim otherwise. Route declaration is static and lives in the product manifest, so the host builds the rail list from dotNS records without launching any provider. Eligibility resolves host-side and reaches products as reason codes rather than rules, keeping the operator's jurisdiction matrix out of the wire format. `ProductRuntimeHost` gains an empty `Funding` impl; every method defaults to `CallError::unavailable()` until a host implements the modality.
…mples Codegen emits fieldless SCALE enums as TypeScript string unions, so FundingDirection is "In" | "Out" rather than a tagged variant. The generated examples failed the playground's tsc pass over test/generated/examples.
|
claude I'm not reading that, if you want me to confirm the design works with coinage, give a list of concise requirements. |
Funding claimed 168-179, which collides head-on with the ring VRF key management methods landing in #360 (168-173). The RFC justified starting at 168 on the basis that #335 held 166-167; those are actually held by Chain::get_chain_info, so the rationale was wrong independently of the collision. Funding now takes 176-187, leaving 168-173 for #360 and 174-175 for #335. The id-allocation paragraph names the real holders of each preceding block.
filvecchiato
left a comment
There was a problem hiding this comment.
Overall
The Delivered / Released split is the right call and should not be softened — Gav confirmed delivered must be host-verified, not product-set. Host-side eligibility, fresh per-session targets, bounded non-capability resume, and opaque FundingAsset all hold up.
Three things to resolve; two change the types.
1. The UI-ownership table contradicts the mock. It maps Deposit QR to "Provider, framed" and excludes host-rendered provider surfaces. The mock renders the source list, keypad, deposit address, countdown, stamp bar and progress screens itself — getsome.dot supplies only data. Its "No UI until picked" note describes the framed model; the component doesn't implement it.
2. Three displayed values have no type. Rate (≈ 99 CASH), limits (min 10 · max 2,000, what makes BelowMinimum reachable), and confirmation progress (7 of 19, which drives the docked pill). Confirming is fieldless, so a minimized session can't show progress.
Also: flow order unstated (mock is dest→provider→asset→amount, discussed with Gav that it should be token→amount→source, and eligibility depends on amount);
2 belongs in this RFC. 1 is a prose fix — rewrite the ownership table and mark Framed as the trapdoor; the declarative level can follow.
| /// the user. Products generally pass `None`. | ||
| pub asset: Option<FundingAsset>, | ||
| /// Amount sought. `None` lets the user choose. | ||
| pub amount: Option<FundingAmount>, |
There was a problem hiding this comment.
should this be always up to the user or the product should be allowed to set a value?
|
|
||
| **Static is the default and covers the whole pre-selection surface.** The Host builds every row from manifests, so nothing provider-authored executes before the user picks a source. This is what the design's *"no UI until picked"* note already asserts, and it is why the sheet renders instantly on an empty wallet. | ||
|
|
||
| **Framed is a takeover, not an embed.** The provider's App executable runs in a frame the Host owns; the Host keeps the dismiss affordance and can reclaim the screen at any point. Verification, card entry, and deposit instructions all live here, because they are the provider's own surface and the Host must not reimplement them. |
There was a problem hiding this comment.
what if a provider is a bad actor? in this scenario the host would not be able to control the surface?
| latency_seconds: number; | ||
| /** Whether the user must hold an account with the provider. */ | ||
| requires_account: boolean; | ||
| }>; |
There was a problem hiding this comment.
How would the host derive min and max amounts if they are not specified in the manifest?
There was a problem hiding this comment.
Moreover, in the mock the amount is requested after selecting the provider, while in a conversation with Gav and Nova, it was quite clear that the flow of data would need to be asset -> amount -> provider/source
RFC: Funding Modality
Summary
Adds a
Fundingtrait defining the runtime contract for a new funding modality: the one surface for moving value across the boundary between a user's Polkadot balance and everything outside it, in both directions, over the rails the product needs — a card or bank transfer, a swap from another chain, and cash traded in person with a peer.Consumers declare an intent and watch it to completion. Providers declare the modality in their manifest, receive intents addressed to them, and report progress. Four methods, wire ids 176–187.
Checklist
docs/rfcs/funding-modality.md(no number — CI assigns one on merge)rust/crates/truapi/rfcMotivation
Two gaps, one in each direction.
In: the only funding path is
PaymentTopUpSource—ProductAccountandPrivateKeyfrom RFC 0006, plusCoinsfrom RFC 0021. All three mean the product already controls the money, so a product reachingHostPaymentError::InsufficientBalancehas nowhere to send the user.Out:
Payment::requestsends to an account, which moves value sideways rather than out. A user wanting cash and a business wanting collected funds in a bank account are the same gap reversed.Without a declared contract, each on-ramp arrives as bespoke host code with its own screens, credential handling, and idea of what "done" means — and three providers in, the host owns three fiat relationships it should never have touched.
Method surface
requeststatus_subscribeserve_subscribereportIds follow
Account::sign_vrf(164–165),Chain::get_chain_info(166–167) and the ring VRF key management methods (168–173, #360), with 174–175 reserved for #335.The direction asymmetry
The directions are deliberately asymmetric and the types say so. Inbound, the host proves the outcome and emits
Deliveredfrom its own on-chain observation. Outbound, it can prove only that funds left under the user's authorization and emitsReleased, because no host can verify that cash reached a hand or a bank account.Collapsing both into one "done" state would let the UI tell a user their money arrived when the protocol has no idea, so the protocol declines to offer it.
Scope, and what the host owns
The modality is mostly host UI. Twelve of the design's fourteen states are host-owned, and the RFC maps each one. A provider contributes at one of two levels: static manifest metadata for its row, or its own App executable framed in a surface the host can reclaim. A provider never draws the stamp bar, a terminal state, the rail list, the eligibility explanation, or the authorization to release funds.
There is deliberately no level where a provider supplies a UI fragment the host renders natively — that needs a constrained renderer profile first. See Out of Scope in the RFC.
Dependencies
fundingto a worker'sincludes, plus afunding.routesblock declaring rail, directions, assets, latency, and whether an account is required. That RFC states per-modality runtime contracts belong in their own RFCs; this is that contract.includes, and holder of wire ids 168–173.secrets.requestalready covers it. Itspersonhoodcaller tier is also the mechanism to evaluate first for reusable verification. cc @BigTavaFunding settles through Coinage: inbound delivery targets are
CoinPaymentreceivables, and theFriendroute rests oncreate_receivable+listen_for_payment. A review pass on whetherFundingDelivery::Receivableand the friend route match what Coinage actually does would be valuable. cc @gui1117Products consume the Product SDK rather than the host directly, so a new modality implies SDK work — at minimum a typed wrapper over
funding.request/statusSubscribe, and for provider products the serve/report loop. cc @TarikGulNot yet implemented
ProductRuntimeHostgains an emptyimpl Funding, matching the existingimpl Chat/impl CoinPaymentpattern. Every method defaults toCallError::unavailable(), so nothing is stubbed as working.Consequently the four generated TypeScript examples assert
isOk()and will fail against a live host until a host implements the modality. That is the codegen convention of one executable example per method rather than a broken test.Verification
cargo build --workspace --all-targets --all-features— cleancargo +nightly fmt --check— cleancargo clippy --workspace --all-targets --all-features -- -D warnings— cleancargo test --workspace --all-features— 667 passed, 0 failed./scripts/codegen.shre-run; dispatcher, wire table, TS client and golden fixtures regeneratedOpen questions
Recorded in the RFC rather than answered; the types are neutral to each answer.
Releasedis where this RFC stops. Closing it means escrow, a provider attestation, or a dispute path with a human — three trust models with different liability, so it is a decision about accountability before it is a protocol detail.