Skip to content

RFC: Funding Modality - #339

Open
CoachCoe wants to merge 6 commits into
mainfrom
rfc/funding-modality
Open

RFC: Funding Modality#339
CoachCoe wants to merge 6 commits into
mainfrom
rfc/funding-modality

Conversation

@CoachCoe

@CoachCoe CoachCoe commented Aug 4, 2026

Copy link
Copy Markdown

RFC: Funding Modality

Summary

Adds a Funding trait 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

  • Added docs/rfcs/funding-modality.md (no number — CI assigns one on merge)
  • Filled all RFC sections (Summary, Motivation, Detailed Design, Drawbacks, Alternatives, Unresolved Questions)
  • Updated Rust traits in rust/crates/truapi/
  • Added label: rfc

Motivation

Two gaps, one in each direction.

In: the only funding path is PaymentTopUpSourceProductAccount and PrivateKey from RFC 0006, plus Coins from RFC 0021. All three mean the product already controls the money, so a product reaching HostPaymentError::InsufficientBalance has nowhere to send the user.

Out: Payment::request sends 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

Method Wire Caller
request 176/177 consumer — open a session
status_subscribe 178–181 consumer — watch it to a terminal state
serve_subscribe 182–185 provider — receive intents addressed to it
report 186/187 provider — report progress

Ids 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 Delivered from its own on-chain observation. Outbound, it can prove only that funds left under the user's authorization and emits Released, 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

Funding settles through Coinage: inbound delivery targets are CoinPayment receivables, and the Friend route rests on create_receivable + listen_for_payment. A review pass on whether FundingDelivery::Receivable and the friend route match what Coinage actually does would be valuable. cc @gui1117

Products 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 @TarikGul

Not yet implemented

ProductRuntimeHost gains an empty impl Funding, matching the existing impl Chat / impl CoinPayment pattern. Every method defaults to CallError::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 — clean
  • cargo +nightly fmt --check — clean
  • cargo clippy --workspace --all-targets --all-features -- -D warnings — clean
  • cargo test --workspace --all-features — 667 passed, 0 failed
  • ./scripts/codegen.sh re-run; dispatcher, wire table, TS client and golden fixtures regenerated

Open questions

Recorded in the RFC rather than answered; the types are neutral to each answer.

  1. Who confirms an outbound off-chain leg, and what happens when it does not complete? The largest question here. Released is 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.
  2. Is a privacy-preserving dollar a distinct asset or a mode of the ordinary one? Decides whether the sheet offers one dollar destination or two.
  3. Where does the operator's jurisdiction and amount matrix live, and how is it versioned? Required before the modality ships; provider-declared routes do not cover it.
  4. Is verification host-held and reused across providers, or per-provider? Decides whether it is a first-run property or a mid-flow surprise.
  5. What does a verified badge on a provider row assert, and who is liable for asserting it?
  6. Who is accountable for a failed transfer or a stuck swap, and is there a human path? Needs a per-route matrix with named owners before any partner 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.
@CoachCoe
CoachCoe requested review from a team August 4, 2026 13:23
@CoachCoe CoachCoe added the rfc label Aug 4, 2026
…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.
@gui1117

gui1117 commented Aug 5, 2026

Copy link
Copy Markdown

claude I'm not reading that, if you want me to confirm the design works with coinage, give a list of concise requirements.

CoachCoe and others added 4 commits August 5, 2026 15:01
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants