From f2498a1119c0e787f354b8f67aff23e382654b3d Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Sat, 21 Mar 2026 23:55:30 -0400 Subject: [PATCH 1/5] feat: draft solana session intent --- .../methods/solana/draft-solana-session-00.md | 1119 +++++++++++++++++ 1 file changed, 1119 insertions(+) create mode 100644 specs/methods/solana/draft-solana-session-00.md diff --git a/specs/methods/solana/draft-solana-session-00.md b/specs/methods/solana/draft-solana-session-00.md new file mode 100644 index 00000000..ae728027 --- /dev/null +++ b/specs/methods/solana/draft-solana-session-00.md @@ -0,0 +1,1119 @@ +--- +title: Solana Session Intent for HTTP Payment Authentication +abbrev: Solana Session +docname: draft-solana-session-00 +version: 00 +category: info +ipr: trust200902 +submissiontype: independent +consensus: false + +author: + - name: Ludo Galabru + ins: L. Galabru + email: ludo.galabru@solana.org + org: Solana Foundation + +normative: + RFC2119: + RFC3339: + RFC4648: + RFC8174: + RFC8259: + RFC8785: + RFC9457: + I-D.httpauth-payment: + title: "The 'Payment' HTTP Authentication Scheme" + target: https://datatracker.ietf.org/doc/draft-ietf-httpauth-payment/ + author: + - name: Jake Moxey + date: 2026-01 + +informative: + SOLANA-DOCS: + title: "Solana Documentation" + target: https://solana.com/docs + author: + - org: Solana Foundation + date: 2026 + SPL-TOKEN: + title: "SPL Token Program" + target: https://solana.com/docs/tokens + author: + - org: Solana Foundation + date: 2026 + BASE58: + title: "Base58 Encoding Scheme" + target: https://datatracker.ietf.org/doc/html/draft-msporny-base58-03 + author: + - name: Manu Sporny + date: 2021 +--- + +--- abstract + +This document defines the "session" intent for the "solana" +payment method within the Payment HTTP Authentication Scheme +{{I-D.httpauth-payment}}. Sessions enable metered, streaming, +or repeated-use access to resources through off-chain vouchers +backed by an on-chain escrow. The client opens a payment +channel by depositing into a channel program, authorizes +incremental spend via signed vouchers, and settles on-chain +when the session closes. + +--- middle + +# Introduction + +HTTP Payment Authentication {{I-D.httpauth-payment}} defines +a challenge-response mechanism that gates access to resources +behind payments. This document registers the "session" intent +for the "solana" payment method. + +The `session` intent establishes a unidirectional +streaming payment channel using on-chain escrow and +off-chain signed vouchers. This enables high-frequency, +low-cost payments by batching many off-chain voucher +updates into periodic on-chain settlement. + +Unlike the `charge` intent, which settles a full +on-chain transaction per request, the `session` intent +allows clients to pay incrementally as service is +consumed. This makes sessions suitable for streaming, +metered APIs, and any use case where per-request +on-chain settlement would be prohibitively expensive +or slow. + +## Solana-Specific Capabilities + +This specification leverages Solana-specific capabilities: + +- **Escrow via channel program**: Deposits are held by an + on-chain program (not the server), enabling trustless + settlement and client-initiated forced close. + +- **Atomic multi-instruction transactions**: Channel open + can include the deposit transfer, escrow initialization, + and initial voucher in a single transaction. Similarly, + close can settle and refund atomically. + +- **Fee payer separation**: The server can sponsor all + on-chain operations (open, topUp, settle, close) so the + client never needs SOL for transaction fees. + +- **Ed25519 native verification**: Voucher signatures can + be verified on-chain using Solana's native `ed25519` + program, enabling trustless settlement without + reimplementing signature verification in the channel + program. + +- **Passkey-compatible P256 verification**: + Implementations can support delegated voucher signers + using Solana's native `secp256r1` verification + program, enabling WebAuthn/passkey-backed session + authorization without requiring the funding key to + sign each voucher. + +## Session Flow + +~~~ + Client Server Solana + | | | + | (1) GET /resource | | + |-------------------------> | | + | | | + | (2) 402 (pricing, asset) | | + |<------------------------- | | + | | | + | (3) open (deposit tx | | + | + initial voucher) | | + |-------------------------> | | + | | (4) co-sign + | + | | broadcast | + | |----------------> | + | (5) 200 OK + Receipt | | + |<------------------------- | | + | | | + | (6) voucher (cumulative: | | + | 100) | no on-chain tx | + |-------------------------> | | + | (7) 200 OK + Receipt | | + |<------------------------- | | + | | | + | (8) voucher (cumulative: | | + | 200) | no on-chain tx | + |-------------------------> | | + | (9) 200 OK + Receipt | | + |<------------------------- | | + | ... | | + | | | + | (10) close (final | | + | voucher) | | + |-------------------------> | | + | | (11) settle + | + | | refund | + | |----------------> | + | (12) 204 + Receipt | | + |<------------------------- | | + | | | +~~~ + +Steps 6–9 are off-chain: the client signs a voucher +authorizing cumulative spend, the server verifies the +signature and serves the resource. No on-chain +transaction occurs per request. + +When fee sponsorship is enabled, the server co-signs +as fee payer on steps 4 and 11 — the client never +needs SOL for transaction fees. + +## Relationship to the Charge Intent + +The "charge" intent (defined separately) handles one-time +payments. The "session" intent handles metered, streaming, +or repeated-use payments within a single channel. Both +intents share the same `solana` method identifier and +encoding conventions. + +# Requirements Language + +{::boilerplate bcp14-tagged} + +# Terminology + +Payment Channel +: A unidirectional payment relationship between a payer + and payee, consisting of an on-chain escrow account + managed by a channel program and a sequence of + off-chain vouchers. The channel is identified by a + unique `channelId`. + +Channel Program +: A Solana program that manages channel escrow accounts. + It enforces deposit, settlement, and withdrawal rules. + The program address is declared in the challenge so + clients can verify they are interacting with the + expected program. + +Voucher +: A signed message authorizing a cumulative payment + amount for a specific channel. Vouchers are + monotonically increasing in amount. + +Cumulative Amount +: The total amount authorized from channel open, not a + per-request delta. For example, if the first voucher + authorizes 100 and the second authorizes 250, the + payee may claim up to 250 total, not 350. + +Authorized Signer +: The key permitted to sign vouchers for a channel. + Defaults to the payer unless the channel open binds a + delegated signer in channel state. + +Grace Period +: A time window after a client requests forced close, + during which the server can still settle outstanding + vouchers before funds are returned to the client. + +# Intent Identifier + +The intent identifier for this specification is "session". +It MUST be lowercase. + +# Encoding Conventions + +This specification uses the same encoding conventions +as the Solana charge intent: JCS-serialized {{RFC8785}} +JSON, base64url-encoded {{RFC4648}} without padding. + +# Channel Program Interface + +The channel program manages escrow accounts and +enforces settlement rules. This section defines the +logical interface that conforming channel programs +MUST implement. + +## Channel State + +Each channel is represented by an on-chain account +(typically a PDA derived from payer, payee, asset, +and a salt) with the following logical fields: + +| Field | Type | Description | +|-------|------|-------------| +| `payer` | Pubkey | Client who deposited funds | +| `payee` | Pubkey | Server authorized to settle | +| `token` | Pubkey | Token mint (or system program for SOL) | +| `authorizedSigner` | Pubkey | Voucher signer (payer if not delegated) | +| `deposit` | u64 | Total amount deposited | +| `settled` | u64 | Cumulative amount settled to payee | +| `closeRequestedAt` | i64 | Unix timestamp of close request (0 if none) | +| `finalized` | bool | Whether channel is closed | + +The `channelId` is the base58-encoded address of the +channel account (PDA). Channel programs MUST derive +the channel PDA deterministically from channel +parameters and the program ID. At minimum, the seed +set MUST bind the PDA to: + +- the payer public key; +- the payee public key; +- the asset identifier (SOL or mint address); +- a client-chosen salt or nonce; and +- the authorized signer public key (or payer if no + delegation is used). + +Clients and servers MUST derive the expected +`channelId` from the channel program ID and the seed +components above and MUST verify that the open +transaction creates and funds exactly that PDA. +Relying on a client-declared `channelId` string alone +is NOT sufficient. + +Channel programs MUST use Solana's canonical PDA +derivation procedure and MUST reject non-canonical +addresses or user-supplied bump values that do not +match the canonical derivation for the channel seeds. + +## Instructions + +### open + +Creates the channel account and transfers the initial +deposit from the payer to the escrow. + +Solana allows the channel creation, token transfer, +and any initial setup to be composed in a single +atomic transaction with multiple instructions. + +The payer authority for the funding transfer MUST be a +signer on the transaction. + +### settle + +Payee presents a signed voucher. The program verifies +the Ed25519 signature (via Solana's `ed25519` program +or in-program verification), checks that +`cumulativeAmount > settled` and +`cumulativeAmount <= deposit`, then transfers the +delta (`cumulativeAmount - settled`) to the payee. + +The server MAY call settle at any time to claim +accumulated funds without closing the channel. + +The payee authority for settlement MUST be a signer on +the transaction. + +### topUp + +Payer transfers additional funds to the escrow. The +program increases `deposit` accordingly. If +`closeRequestedAt > 0`, topUp MUST reset it to 0 +(cancelling any pending forced close). + +The payer authority for the additional funding transfer +MUST be a signer on the transaction. + +### requestClose + +Payer initiates a forced close. The program sets +`closeRequestedAt = Clock::get().unix_timestamp`. +This starts a grace period during which the payee +can still call settle or close. + +The payer authority requesting close MUST be a signer +on the transaction. + +### withdraw + +Payer recovers remaining funds after the grace +period has expired. The program verifies +`Clock::get().unix_timestamp >= closeRequestedAt + GRACE_PERIOD`, +transfers `deposit - settled` to the payer, and +marks the channel as finalized. + +The payer authority receiving the refund MUST be a +signer on the transaction. + +### close + +Payee closes the channel by settling any final delta +authorized by a voucher and refunding the remainder to +the payer in a single atomic transaction. If no new +delta exists beyond the on-chain `settled` watermark, +the close path MAY omit voucher verification and act +as a refund-only cooperative close. + +Solana's multi-instruction transactions allow the +settle + refund + account cleanup to happen +atomically, ensuring neither party can be cheated +during close. + +The payee authority initiating cooperative close MUST +be a signer on the transaction. Fee-payer signatures +MUST NOT be treated as satisfying payer or payee +authority checks. + +## Grace Period + +The grace period (RECOMMENDED: 15 minutes) protects +the payee. If the payer calls requestClose while the +payee has unsubmitted vouchers, the payee has until +the grace period expires to call settle or close. + +Without a grace period, the payer could withdraw +funds immediately after receiving service, before +the server has time to settle. + +## Access Control + +| Instruction | Caller | +|-------------|--------| +| open | Anyone (payer signs the deposit transfer) | +| settle | Payee only | +| topUp | Payer only | +| requestClose | Payer only | +| withdraw | Payer only (after grace period) | +| close | Payee only | + +# Request Schema + +## Shared Fields + +amount +: REQUIRED. Price per unit of service in base units, + encoded as a decimal string. For native SOL, the + amount is in lamports. For SPL tokens, the amount is + in the token's smallest unit. + +unitType +: OPTIONAL. Unit being priced (for example, + `"request"`, `"token"`, or `"byte"`). + +suggestedDeposit +: OPTIONAL. Suggested initial channel deposit in base + units. Clients MAY deposit less or more depending on + expected usage. + +recipient +: REQUIRED. Base58-encoded public key of the server's + account that will receive settlement funds. + +currency +: REQUIRED. `"sol"` for native SOL, or a base58-encoded + SPL token mint address. + +description +: OPTIONAL. Human-readable description of the service + or resource being paid for. + +externalId +: OPTIONAL. Merchant reference for reconciliation or + audit correlation. + +## Method Details + +network +: OPTIONAL. Solana cluster identifier. MUST be one of + "mainnet-beta", "devnet", or "localnet". Defaults to + "mainnet-beta". + +channelProgram +: REQUIRED. Base58-encoded address of the on-chain + channel program. Clients MUST verify this matches + their expected program before depositing funds. + +channelId +: OPTIONAL. Existing channel identifier to resume. When + present, clients SHOULD verify the referenced channel + is open and sufficiently funded before reuse. + +decimals +: Conditionally REQUIRED. Token decimal places (0–9). + MUST be present when `currency` is a mint address. + +tokenProgram +: OPTIONAL. Base58-encoded token program ID for the + mint in `currency`. MUST be either the SPL Token + Program or the Token-2022 Program when present. If + omitted for a mint-based `currency`, clients MUST + determine the correct token program from on-chain + state before constructing token instructions. + +feePayer +: OPTIONAL. If `true`, the server sponsors transaction + fees for open, topUp, and close operations. When + `true`, `feePayerKey` MUST also be present. + +feePayerKey +: Conditionally REQUIRED. Base58-encoded public key + of the server's fee payer account. + +minVoucherDelta +: OPTIONAL. Minimum amount increase between accepted + vouchers. + +ttlSeconds +: OPTIONAL. Suggested session duration in seconds. + +gracePeriodSeconds +: OPTIONAL. Grace period for forced close + (RECOMMENDED: 900, i.e. 15 minutes). + +For the `session` intent, `amount` specifies the price +per unit of service, not a total charge. When +`unitType` is present, clients can estimate cost +before a session begins: + +~~~ +total = amount × units_consumed +~~~ + +# Credential Schema + +The credential payload uses a discriminated union on +the `action` field. Four actions are defined. + +## Action: "open" + +Opens a new payment channel. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `action` | string | REQUIRED | `"open"` | +| `channelId` | string | REQUIRED | Base58 channel account address | +| `payer` | string | REQUIRED | Base58 public key of the depositor | +| `authorizationPolicy` | object | OPTIONAL | Voucher signer policy | +| `depositAmount` | string | REQUIRED | Initial deposit in base units | +| `transaction` | string | REQUIRED | Base64-encoded signed (or partially signed) transaction | +| `expiresAt` | string | OPTIONAL | Session expiration (ISO 8601) | +| `capabilities` | object | OPTIONAL | Implementation-specific extensions | +| `voucher` | object | REQUIRED | Signed initial voucher (see {{voucher-format}}) | + +The `transaction` contains the open instruction(s). +When `feePayer` is `true`, the client partially signs +(transfer authority only) and the server co-signs as +fee payer before broadcasting — same pattern as the +charge intent's pull mode. + +Servers MUST derive `payer`, `channelId`, +`depositAmount`, `authorizationPolicy`, delegated signer +settings, and all program-relevant open parameters +from the signed transaction and confirmed on-chain +state. Servers MUST NOT trust these values solely +because they appear in the HTTP payload. + +## Action: "voucher" + +Submits a new voucher authorizing additional spend. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `action` | string | REQUIRED | `"voucher"` | +| `channelId` | string | REQUIRED | Existing channel identifier | +| `voucher` | object | REQUIRED | Signed voucher (see {{voucher-format}}) | + +This action is entirely off-chain. No transaction +is broadcast. + +## Action: "topUp" + +Adds funds to an existing channel. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `action` | string | REQUIRED | `"topUp"` | +| `channelId` | string | REQUIRED | Existing channel identifier | +| `additionalAmount` | string | REQUIRED | Amount to add in base units | +| `transaction` | string | REQUIRED | Base64-encoded signed topUp transaction | + +## Action: "close" + +Requests cooperative close. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `action` | string | REQUIRED | `"close"` | +| `channelId` | string | REQUIRED | Existing channel identifier | +| `voucher` | object | OPTIONAL | Final signed voucher (see {{voucher-format}}) | + +If `voucher` is present, the server settles the final +delta on-chain and refunds the remainder atomically. +If the highest amount has already been settled on-chain, +the server MAY close without a new voucher. + +# Voucher Format {#voucher-format} + +## Voucher Data + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `channelId` | string | REQUIRED | Channel this voucher authorizes | +| `cumulativeAmount` | string | REQUIRED | Total authorized spend (base units) | +| `expiresAt` | string | OPTIONAL | Voucher expiration (ISO 8601) | + +All other channel context (payer, recipient, token, +network, program, and signer policy) is established +by the on-chain channel state and the deterministic +PDA derivation defined above. The voucher only needs +to identify the channel and authorize a cumulative +amount because `channelId` is already bound to that +context. Implementations MUST NOT accept vouchers for +channels whose identity cannot be recomputed from the +program ID and channel open parameters. + +## Signed Voucher + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `voucher` | object | REQUIRED | Voucher data (above) | +| `signer` | string | REQUIRED | Base58 public key of the voucher signer | +| `signature` | string | REQUIRED | Base58-encoded Ed25519 signature | +| `signatureType` | string | REQUIRED | `"ed25519"` | + +## Voucher Signing + +1. Serialize the voucher data object using JCS + {{RFC8785}} to produce deterministic bytes. + +2. Sign the bytes using Ed25519 with the payer's + keypair (or a delegated signer's keypair if the + channel's `authorizedSigner` is set). + +3. Encode the signature as base58. + +## Voucher Verification + +The server MUST verify each voucher: + +1. Deserialize and canonicalize the voucher data. + +2. Verify the Ed25519 signature against the `signer` + public key. + +3. Verify the `signer` matches the channel's + `authorizedSigner` (or `payer` if no delegation). + +4. Verify `channelId` matches the active channel. + +5. Verify `cumulativeAmount > acceptedCumulative` + (cumulative increase), unless the submission is an + idempotent retry handled per + "Concurrency and Idempotency". + +6. Verify the channel is not finalized. + +7. Verify `closeRequestedAt == 0`. Servers MUST reject + new voucher acceptance on channels with a pending + forced close unless the voucher is being used only + to settle or cooperatively close the channel. + +8. Verify `cumulativeAmount <= escrowedAmount` (does + not exceed deposit). + +9. If `expiresAt` is present, verify the voucher has + not expired (with configurable clock skew + tolerance). + +10. Persist the new `acceptedCumulative` amount to + durable storage BEFORE serving the resource. + +## On-Chain Voucher Verification + +When the server calls settle or close on the channel +program, the voucher signature MUST be verified +on-chain. On Solana, this can be done by: + +- Including an `ed25519` program instruction in the + same transaction that verifies the signature before + the settle instruction executes. + +- Or implementing Ed25519 verification directly in + the channel program (higher compute cost). + +The first approach is preferred as it uses Solana's +native signature verification at minimal compute +cost. + +When using instruction introspection to consume a +native signature-verification instruction, channel +programs MUST: + +- validate the Instructions sysvar account address; +- use checked instruction-loading helpers provided by + the Solana SDK; +- correlate the verified message bytes to the exact + `channelId`, `cumulativeAmount`, and signer accepted + by the `settle` or `close` instruction in the same + transaction; and +- reject signature-verification instructions that are + replayed, unrelated, or positioned such that the + channel program cannot unambiguously determine which + verified message they authorize. + +# Authorized Signer + +By default, the payer signs vouchers directly. This +matches the default channel model: the funding key is +also the voucher-signing key, and the deposit is the +hard cap enforced by the channel. + +Implementations MAY support delegated signing where +the payer authorizes a separate keypair (for example, +a session key) to sign vouchers on their behalf. The +`authorizedSigner` field in the channel state records +the delegated public key. The server verifies +vouchers against this key instead of the payer's. + +This enables use cases like browser sessions where an +ephemeral key signs vouchers without repeated wallet +confirmations. + +Implementations MAY additionally support delegated +signers on other curves that Solana can verify +through native programs, such as `secp256r1` for +passkeys. Such extensions MUST define: + +- a distinct `signatureType` value; +- the exact signed message format; +- the exact Solana verification program used on-chain; + and +- how the delegated signer is bound into the channel's + PDA derivation and open transaction. + +# Fee Sponsorship + +When `feePayer` is `true` in the challenge: + +- **Open**: The client builds the open transaction + with the server's `feePayerKey` as fee payer, + partially signs (deposit transfer authority only), + and sends via `transaction` in the open credential. + The server co-signs and broadcasts. + +- **TopUp**: Same pattern — client partially signs, + server co-signs. + +- **Settle/Close**: The server initiates these + operations and always pays the fee. + +This ensures clients never need SOL for transaction +fees during the entire session lifecycle. + +# Server State Management + +## Per-Channel State + +The server MUST maintain the following state for +each open channel: + +| Field | Description | +|-------|-------------| +| `channelId` | Channel account address | +| `status` | `"open"` or `"closed"` | +| `payer` | Payer public key | +| `authorizationPolicy` | Voucher signer policy | +| `escrowedAmount` | Total deposited (from on-chain) | +| `acceptedCumulative` | Highest voucher amount accepted | +| `spentAmount` | Cumulative amount charged for delivered service | +| `settledOnChain` | Highest cumulative amount already settled on-chain | +| `closeRequestedAt` | Pending forced-close timestamp, if any | + +The available off-chain balance is computed as: + +~~~ +available = acceptedCumulative - spentAmount +~~~ + +The on-chain settlement watermark is distinct: + +~~~ +unsettled = spentAmount - settledOnChain +~~~ + +## Debit Processing + +For each request on an open channel: + +1. Compute `cost` from the challenged `amount`, + `unitType`, and any implementation-specific metering + policy. +2. Compute `available = acceptedCumulative - spentAmount`. +3. If `available < cost`: return 402 requesting a + new voucher or topUp. +4. Persist `spentAmount += cost` BEFORE serving. +5. Serve the resource with a receipt. + +## Partial Settlement + +The server MAY call the channel program's settle +instruction at any time to claim accumulated funds +without closing the channel. This is useful for: + +- Reducing counterparty risk on long-running sessions +- Freeing up server working capital +- Periodic reconciliation + +After settlement, the channel account's `settled` +field on-chain reflects +the claimed amount. The server MUST update +`settledOnChain` after confirmation and continues +accepting vouchers for amounts above the new settled +baseline. + +## Crash Safety + +Servers MUST persist metering state increments +BEFORE delivering the response. Servers SHOULD +support idempotency keys for exactly-once delivery. +More precisely, servers MUST persist both: + +- `acceptedCumulative` BEFORE relying on new voucher + balance; and +- `spentAmount` BEFORE or atomically with delivering + the metered service. + +Servers SHOULD use transactional storage or +write-ahead logging to ensure recovery after process +or machine crashes. + +## Concurrency and Idempotency + +Servers MUST serialize voucher acceptance and debit +processing per `channelId`. Voucher updates arriving +on different HTTP connections or multiplexed streams +MUST be processed atomically with respect to: + +- `acceptedCumulative`; +- `spentAmount`; and +- `closeRequestedAt`. + +Servers MUST treat voucher submissions idempotently: + +- Resubmitting a voucher with the same + `cumulativeAmount` as the highest accepted voucher + MUST succeed and MUST NOT change channel state. +- Submitting a voucher with lower `cumulativeAmount` + than the highest accepted voucher SHOULD return the + current receipt state and MUST NOT reduce channel + state. +- Clients MAY safely retry voucher submissions after + network failures. + +Clients SHOULD include an `Idempotency-Key` header on +metered HTTP requests. Servers SHOULD cache +`(challengeId, idempotencyKey)` pairs and MUST NOT +increment `spentAmount` twice for a duplicate +idempotent request. + +# Settlement Procedure + +## Open + +1. Verify the open transaction contains the expected + channel program instructions (create PDA + + initialize channel + deposit transfer). +2. Recompute the expected PDA from the transaction's + payer, payee, asset, authorized signer, salt, and + channel program ID. Verify it equals the declared + `channelId`. +3. Verify the transaction's fee payer matches the + challenge policy: + - if `feePayer` is `true`, the fee payer MUST equal + `feePayerKey`; + - otherwise the payer funds the transaction. +4. Verify the transaction does not include unrelated + writable accounts or instructions that could + redirect funds or mutate channel parameters. + The server SHOULD reject transactions that route + value through unexpected external programs. +5. If fee payer mode: co-sign and broadcast. + Otherwise: broadcast as-is. +6. Verify channel state on-chain after confirmation: + - payer matches transaction signer; + - payee matches the challenged recipient; + - token/asset matches the challenge currency; + - deposit matches the requested amount; + - authorized signer matches the open parameters; + - channel is not finalized; and + - `closeRequestedAt == 0`. +7. Verify the initial voucher against the confirmed + channel state. +8. Create server-side channel state. +9. Return 200 with receipt. + +## Voucher Update (No Settlement) + +1. Verify voucher signature and monotonicity. +2. Verify the channel is open and has no pending + forced close. +3. Persist `acceptedCumulative`. +4. Debit `cost` from available balance by persisting + `spentAmount`. +5. Return 200 with receipt. + +## TopUp + +1. If fee payer mode: co-sign and broadcast. + Otherwise: broadcast as-is. +2. Verify the top-up transaction targets the expected + channel PDA and channel program and only increases + deposit for that channel. +3. Verify deposit increase on-chain. +4. Increase `escrowedAmount`. +5. If the program cleared `closeRequestedAt`, clear it + in server-side state as well. +6. Return 204 with receipt. + +## Close (Cooperative) + +1. If a final voucher is provided and authorizes an + amount above `settledOnChain`, verify it. +2. Build and broadcast a close transaction: + settle any final delta + refund remainder + (atomic). +3. Mark channel as `"closed"`. +4. Persist final `settledOnChain` and terminal + accounting state after confirmation. +5. Return 204 with receipt containing `txHash`. + +## Forced Close (Client-Initiated) + +If the server becomes unresponsive, the client can +force-close the channel: + +1. Client calls requestClose on the channel program. +2. Grace period begins (RECOMMENDED: 15 minutes). +3. During the grace period, the server MAY still + call settle with the latest voucher. +4. After the grace period, the client calls withdraw + to recover `deposit - settled`. + +This ensures the client can always recover unspent +funds, even if the server disappears. + +# Receipt Format + +Receipts are returned in the `Payment-Receipt` header. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `method` | string | REQUIRED | `"solana"` | +| `intent` | string | REQUIRED | `"session"` | +| `reference` | string | REQUIRED | Channel identifier | +| `status` | string | REQUIRED | `"success"` | +| `timestamp` | string | REQUIRED | RFC 3339 timestamp | +| `challengeId` | string | OPTIONAL | Challenge identifier for audit correlation | +| `acceptedCumulative` | string | REQUIRED | Highest voucher amount accepted | +| `spent` | string | REQUIRED | Total amount charged so far | + +For close actions, the receipt MAY additionally +include: + +| Field | Type | Description | +|-------|------|-------------| +| `txHash` | string | Settlement transaction signature | +| `spent` | string | Total amount settled | +| `refunded` | string | Amount refunded to client | + +For streaming responses, servers SHOULD include the +receipt in the initial response headers and SHOULD +emit a final receipt when the stream completes. When +balance is exhausted mid-stream, servers SHOULD pause +delivery and request a higher voucher or top-up +rather than serving beyond the authorized balance. + +## Voucher Submission Transport + +Voucher updates and top-up requests SHOULD be +submitted to the same resource URI that requires +payment. This allows session payment to compose with +arbitrary protected endpoints without a dedicated +payment control plane route. + +Clients MAY use `HEAD` for voucher-only or top-up-only +requests when no response body is required. Servers +SHOULD support such requests where practical. + +# Error Responses + +Servers MUST use the standard problem types defined +in {{I-D.httpauth-payment}}: `malformed-credential`, +`invalid-challenge`, and `verification-failed`. The +`detail` field SHOULD describe the specific failure +(e.g., "Amount exceeds +deposit", "Channel not found"). + +All error responses MUST include a fresh challenge in +`WWW-Authenticate`. + +# Security Considerations + +## Transport Security + +All communication MUST use TLS 1.2 or higher. + +## Escrow Safety + +Funds are held by the channel program, not the +server. The server can only claim funds by presenting +valid voucher signatures to the program. The client +can always recover unspent funds via forced close +after the grace period. + +## Voucher Replay Protection + +Vouchers are bound to a specific channel via +`channelId` and ordered by `cumulativeAmount`. A voucher +from one channel +cannot be replayed in another. + +This replay protection depends on deterministic PDA +derivation. The channel address MUST be bound to the +channel program ID and channel open parameters so that +vouchers cannot be replayed across different channel +program deployments or different Solana clusters. + +## Cumulative Amount Safety + +Vouchers authorize cumulative totals (not deltas). +A compromised voucher only authorizes up to its +stated amount. The channel program enforces that +settlements never exceed the deposit. + +## Grace Period Security + +The grace period prevents a race condition where the +payer withdraws before the server can settle. Without +it, a malicious payer could use the service, then +immediately withdraw. The server has the grace period +to submit any outstanding vouchers. + +TopUp cancels pending close requests, preventing a +grief attack where the payer requests close +repeatedly to disrupt the session. + +Servers MUST stop accepting new service vouchers once +`closeRequestedAt` is set. During the grace period, +the server MAY use the latest previously accepted +voucher to settle or cooperatively close the channel, +but SHOULD NOT continue serving new metered content +unless the close request is cancelled by a confirmed +top-up. + +## Delegated Signer Risks + +If delegated signing is used, a compromised delegated +key can authorize spend up to the delegation's limit. +Implementations SHOULD use short TTLs for delegated +keys and provide mechanisms to revoke them. + +## Channel Program Trust + +Clients MUST verify the `methodDetails.channelProgram` +in the challenge matches a known, audited program +before depositing funds. A malicious server could +specify a program that steals deposits. + +## CPI and Program-ID Validation + +Channel programs frequently rely on external Solana +programs, including the System Program, SPL Token or +Token-2022, Associated Token Program, and native +signature-verification programs. Implementations MUST +validate every external program account used in CPI +against the expected canonical program ID before +invocation. Implementations MUST NOT allow +user-controlled program accounts to influence escrow, +settlement, refund, or signature-verification CPIs. + +If multiple token-program variants are supported, +implementations MUST bind the chosen token-program +variant into channel creation and subsequent account +validation. A channel opened for one token-program +variant MUST NOT be settled or refunded through a +different token-program account. + +## Account Ownership Validation + +Before deserializing or mutating any account, +implementations MUST validate the expected owner for: + +- the channel PDA account; +- any escrow SOL or token-holding account; +- any mint account referenced by the channel; and +- any payer or payee token account used for settlement + or refund. + +Servers performing off-chain verification SHOULD also +verify account ownership and program ownership against +RPC state before accepting an open, top-up, settle, or +close flow as valid. + +## Channel Exhaustion + +A malicious client could open many channels with +small deposits, consuming on-chain storage. Channel +programs SHOULD require a minimum deposit that +covers the rent cost of the channel account. + +Servers SHOULD also enforce a minimum economically +useful deposit to avoid channel spam with balances too +small to justify signature verification, storage, and +settlement overhead. + +## Denial of Service + +To mitigate voucher flooding and channel griefing: + +- servers SHOULD rate-limit voucher submissions per + channel; +- servers SHOULD perform cheap format and monotonicity + checks before expensive signature verification; +- servers MAY enforce a minimum voucher delta; and +- servers SHOULD refuse channels with prolonged + inactivity or uneconomic deposit sizes. + +## Clock Skew + +Voucher expiration depends on timestamp comparison. +Servers MUST allow configurable clock skew tolerance +(RECOMMENDED: 30 seconds). + +## Solana Verification Programs + +This specification uses Solana-native verification +primitives where possible. The base interoperable path +is Ed25519, using either: + +- an `ed25519` verification instruction in the same + transaction as `settle` or `close`, with the channel + program reading the instruction sysvar to confirm + success; or +- direct in-program verification if compute budget and + implementation constraints permit. + +Implementations that support delegated `secp256r1` +passkey signers SHOULD use Solana's native +`Secp256r1SigVerify1111111111111111111111111` +verification program and MUST define a distinct +`signatureType` and wire format for that extension. + +# IANA Considerations + +## Payment Intent Registration + +This document requests registration of the following +entry in the "HTTP Payment Intents" registry: + +| Intent | Applicable Methods | Description | Reference | +|--------|-------------------|-------------|-----------| +| `session` | `solana` | Metered Solana payments via off-chain vouchers | This document | + +--- back + +# Acknowledgements + +The authors thank the Tempo team for their input on this +specification. From a64edb477cfcb5e071e4f73f4227cf329dd1c4b5 Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Wed, 25 Mar 2026 10:09:29 -0400 Subject: [PATCH 2/5] feat: consolidate spec with internal research Co-authored-by: jo <17280917+dev-jodee@users.noreply.github.com> --- .../methods/solana/draft-solana-session-00.md | 92 +++++++++++++++---- 1 file changed, 73 insertions(+), 19 deletions(-) diff --git a/specs/methods/solana/draft-solana-session-00.md b/specs/methods/solana/draft-solana-session-00.md index ae728027..803a1bfb 100644 --- a/specs/methods/solana/draft-solana-session-00.md +++ b/specs/methods/solana/draft-solana-session-00.md @@ -13,6 +13,10 @@ author: ins: L. Galabru email: ludo.galabru@solana.org org: Solana Foundation + - name: Jo + ins: Desormeaux + email: jo.desormeaux@solana.org + org: Solana Foundation normative: RFC2119: @@ -240,16 +244,16 @@ Each channel is represented by an on-chain account (typically a PDA derived from payer, payee, asset, and a salt) with the following logical fields: -| Field | Type | Description | -|-------|------|-------------| -| `payer` | Pubkey | Client who deposited funds | -| `payee` | Pubkey | Server authorized to settle | -| `token` | Pubkey | Token mint (or system program for SOL) | -| `authorizedSigner` | Pubkey | Voucher signer (payer if not delegated) | -| `deposit` | u64 | Total amount deposited | -| `settled` | u64 | Cumulative amount settled to payee | -| `closeRequestedAt` | i64 | Unix timestamp of close request (0 if none) | -| `finalized` | bool | Whether channel is closed | +| Field | Type | Storage | Description | +|-------|------|---------|-------------| +| `payer` | Pubkey | Seed + Account state | Client who deposited funds | +| `payee` | Pubkey | Seed + Account state | Server authorized to settle | +| `token` | Pubkey | Seed only | Token mint | +| `authorizedSigner` | Pubkey | Seed + Account state | Voucher signer (payer if not delegated) | +| `deposit` | u64 | Account state | Total amount deposited | +| `settled` | u64 | Account state | Cumulative amount settled to payee | +| `closeRequestedAt` | i64 | Account state | Unix timestamp of close request (0 if none) | +| `bump` | u8 | Account state | Canonical PDA bump | The `channelId` is the base58-encoded address of the channel account (PDA). Channel programs MUST derive @@ -290,6 +294,18 @@ atomic transaction with multiple instructions. The payer authority for the funding transfer MUST be a signer on the transaction. +Before initializing, the program MUST check whether the +target PDA already exists. If the account discriminator +matches `ClosedChannel`, the program MUST reject the +instruction. Reopening a previously finalized channel +PDA is forbidden regardless of seed inputs. + +If the token mint has a Token-2022 transfer hook +extension, the deposit transfer instruction MUST +include the extra accounts required by the hook program. +Clients MUST resolve hook extra accounts from on-chain +mint state before constructing the open transaction. + ### settle Payee presents a signed voucher. The program verifies @@ -305,6 +321,12 @@ accumulated funds without closing the channel. The payee authority for settlement MUST be a signer on the transaction. +If the token mint has a Token-2022 transfer hook +extension, the token transfer instruction MUST include +the extra accounts required by the hook program. +Servers MUST resolve current hook extra accounts from +on-chain mint state before building this transaction. + ### topUp Payer transfers additional funds to the escrow. The @@ -336,6 +358,13 @@ marks the channel as finalized. The payer authority receiving the refund MUST be a signer on the transaction. +On completion, the `withdraw` instruction MUST NOT +fully deallocate the channel account. The program MUST +realloc the account data to 8 bytes, write the +`ClosedChannel` discriminator, and return the difference +between the pre-close rent-exempt balance and the 8-byte +tombstone rent-exempt minimum to the payer. + ### close Payee closes the channel by settling any final delta @@ -355,6 +384,19 @@ be a signer on the transaction. Fee-payer signatures MUST NOT be treated as satisfying payer or payee authority checks. +On completion, the `close` instruction MUST NOT +fully deallocate the channel account. The program MUST +realloc the account data to 8 bytes, write the +`ClosedChannel` discriminator, and return the difference +between the pre-close rent-exempt balance and the 8-byte +tombstone rent-exempt minimum to the payer. + +If the token mint has a Token-2022 transfer hook +extension, the token transfer instruction MUST include +the extra accounts required by the hook program. +Servers MUST resolve current hook extra accounts from +on-chain mint state before building this transaction. + ## Grace Period The grace period (RECOMMENDED: 15 minutes) protects @@ -382,10 +424,8 @@ the server has time to settle. ## Shared Fields amount -: REQUIRED. Price per unit of service in base units, - encoded as a decimal string. For native SOL, the - amount is in lamports. For SPL tokens, the amount is - in the token's smallest unit. +: REQUIRED. Price per unit of service in the token's + smallest unit, encoded as a decimal string. unitType : OPTIONAL. Unit being priced (for example, @@ -401,8 +441,11 @@ recipient account that will receive settlement funds. currency -: REQUIRED. `"sol"` for native SOL, or a base58-encoded - SPL token mint address. +: REQUIRED. Base58-encoded SPL token mint address. + Native SOL is not supported; clients wishing to pay + in SOL MUST wrap it to wSOL + (`So11111111111111111111111111111111111111112`) + before opening a channel. description : OPTIONAL. Human-readable description of the service @@ -602,7 +645,9 @@ The server MUST verify each voucher: idempotent retry handled per "Concurrency and Idempotency". -6. Verify the channel is not finalized. +6. Verify the channel account discriminator is not + `ClosedChannel` (i.e., the channel has not been + finalized via close or withdraw). 7. Verify `closeRequestedAt == 0`. Servers MUST reject new voucher acceptance on channels with a pending @@ -1006,8 +1051,17 @@ top-up. If delegated signing is used, a compromised delegated key can authorize spend up to the delegation's limit. -Implementations SHOULD use short TTLs for delegated -keys and provide mechanisms to revoke them. +The `authorizedSigner` is bound into the PDA seed set +at open time and cannot be changed without closing and +reopening the channel. If a delegated signing key is +compromised, the payer's only recourse is to call +`requestClose`, but the attacker retains the ability +to sign vouchers up to the full deposit cap throughout +the entire grace period before funds can be recovered. +Implementations MUST treat delegated keys as +short-lived, single-session credentials with TTLs on +the order of minutes to bound exposure in the event +of a key compromise. ## Channel Program Trust From f92885923c4bbdbde2fe850cb9ae20d1d999c57a Mon Sep 17 00:00:00 2001 From: Michael Assaf <94772640+snowmead@users.noreply.github.com> Date: Mon, 18 May 2026 16:58:24 -0400 Subject: [PATCH 3/5] feat: align Solana session intent draft with reference impl (#4) --- .../methods/solana/draft-solana-session-00.md | 751 ++++++++++++------ 1 file changed, 530 insertions(+), 221 deletions(-) diff --git a/specs/methods/solana/draft-solana-session-00.md b/specs/methods/solana/draft-solana-session-00.md index 803a1bfb..051ca05d 100644 --- a/specs/methods/solana/draft-solana-session-00.md +++ b/specs/methods/solana/draft-solana-session-00.md @@ -17,6 +17,10 @@ author: ins: Desormeaux email: jo.desormeaux@solana.org org: Solana Foundation + - name: Michael Assaf + ins: M. Assaf + email: michael@moonsonglabs.com + org: Moonsong Labs normative: RFC2119: @@ -97,13 +101,20 @@ This specification leverages Solana-specific capabilities: settlement and client-initiated forced close. - **Atomic multi-instruction transactions**: Channel open - can include the deposit transfer, escrow initialization, - and initial voucher in a single transaction. Similarly, - close can settle and refund atomically. - -- **Fee payer separation**: The server can sponsor all - on-chain operations (open, topUp, settle, close) so the - client never needs SOL for transaction fees. + can include the channel-PDA creation, escrow ATA + creation, deposit transfer, and splits commitment in + a single transaction. Similarly, cooperative close + can bundle `settleAndFinalize` and `distribute` so + the merchant payout, payer refund, treasury sweep, + and PDA tombstone all land atomically. + +- **Fee payer separation**: The server can sponsor the + cooperative on-chain operations it submits (open, + topUp, settle, settleAndFinalize, distribute) so the + client never needs SOL for transaction fees during + the normal session lifecycle. Escape-route + instructions (requestClose, finalize, withdrawPayer) + are client-submitted and self-funded. - **Ed25519 native verification**: Voucher signatures can be verified on-chain using Solana's native `ed25519` @@ -126,11 +137,12 @@ This specification leverages Solana-specific capabilities: | (1) GET /resource | | |-------------------------> | | | | | - | (2) 402 (pricing, asset) | | + | (2) 402 (pricing, asset, | | + | splits, grace) | | |<------------------------- | | | | | - | (3) open (deposit tx | | - | + initial voucher) | | + | (3) open (deposit tx, | | + | no initial voucher) | | |-------------------------> | | | | (4) co-sign + | | | broadcast | @@ -152,12 +164,13 @@ This specification leverages Solana-specific capabilities: | ... | | | | | | (10) close (final | | - | voucher) | | + | voucher, optional) | | |-------------------------> | | - | | (11) settle + | - | | refund | + | | (11) settleAnd- | + | | Finalize + | + | | distribute | | |----------------> | - | (12) 204 + Receipt | | + | (12) 200 OK + Receipt | | |<------------------------- | | | | | ~~~ @@ -167,6 +180,11 @@ authorizing cumulative spend, the server verifies the signature and serves the resource. No on-chain transaction occurs per request. +Step 11 typically bundles `settleAndFinalize` and +`distribute` in the same transaction so the +merchant payout, payer refund, treasury sweep, and +PDA tombstone all land atomically. + When fee sponsorship is enabled, the server co-signs as fee payer on steps 4 and 11 — the client never needs SOL for transaction fees. @@ -227,9 +245,32 @@ It MUST be lowercase. # Encoding Conventions -This specification uses the same encoding conventions -as the Solana charge intent: JCS-serialized {{RFC8785}} -JSON, base64url-encoded {{RFC4648}} without padding. +This specification uses two distinct encoding regimes: + +1. **HTTP envelope canonicalization.** Challenge + payloads (`request` auth-param), credential + payloads (`Authorization: Payment` header bodies), + and receipts use the same encoding as the Solana + charge intent: JCS-serialized {{RFC8785}} JSON, + base64url-encoded {{RFC4648}} without padding. + +2. **On-chain signed-payload encoding.** The bytes + the payer's Ed25519 key signs to authorize spend + are produced by Borsh-encoding the on-chain + `Voucher` struct (see {{on-chain-voucher-encoding}}). + These bytes are the exact message verified by + Solana's native `ed25519` precompile and read back + by the channel program via the Instructions + sysvar. Using a fixed-layout binary encoding here + removes the need to repack between the HTTP JSON + shape and the precompile message, and makes the + on-chain verification a single byte-equality + check. + +JCS produces deterministic JSON bytes for header +canonicalization but is unnecessary for the inner +signed payload: the on-chain Borsh layout is +deterministic by construction. # Channel Program Interface @@ -241,19 +282,28 @@ MUST implement. ## Channel State Each channel is represented by an on-chain account -(typically a PDA derived from payer, payee, asset, -and a salt) with the following logical fields: +(typically a PDA derived from payer, payee, mint, +authorized signer, and a salt) with the following +logical fields: | Field | Type | Storage | Description | |-------|------|---------|-------------| +| `discriminator` | u8 | Account state | Non-zero account-type tag (`Channel`); rejected when 0 so zero-initialized PDAs cannot impersonate a channel | +| `version` | u8 | Account state | Account-layout version; lets implementations evolve fields without colliding with `ClosedChannel` | +| `bump` | u8 | Account state | Canonical PDA bump | +| `status` | u8 | Account state | `Open` / `Closing` / `Finalized` enum value | +| `salt` | u64 | Seed + Account state | PDA disambiguator. Persisted so the channel PDA can re-derive its own seeds for self-signed CPIs (refunds, distribution) without off-chain inputs | +| `deposit` | u64 | Account state | Total amount currently escrowed | +| `settled` | u64 | Account state | Cumulative amount authorized for distribution (voucher watermark) | +| `paidOut` | u64 | Account state | Cumulative amount already distributed to the merchant side; `paidOut <= settled` | +| `closureStartedAt` | i64 | Account state | Unix timestamp when `requestClose` was called (0 if not set; cleared on `Finalized`) | +| `payerWithdrawnAt` | i64 | Account state | Unix timestamp of the payer refund (0 if not yet); guards against double-refund when both `withdrawPayer` and `distribute` can pay the payer | +| `gracePeriod` | u32 | Account state | Seconds between `requestClose` and permissionless `finalize`. Per-channel, set at `open`, so a single program deployment can host channels with differing dispute windows | +| `distributionHash` | [u8;32] | Account state | Hash digest of the canonical splits preimage committed at `open`; `distribute` MUST re-verify this hash before paying recipients | | `payer` | Pubkey | Seed + Account state | Client who deposited funds | -| `payee` | Pubkey | Seed + Account state | Server authorized to settle | -| `token` | Pubkey | Seed only | Token mint | +| `payee` | Pubkey | Seed + Account state | Server authorized to settle; receives the implicit-remainder share on `distribute` | | `authorizedSigner` | Pubkey | Seed + Account state | Voucher signer (payer if not delegated) | -| `deposit` | u64 | Account state | Total amount deposited | -| `settled` | u64 | Account state | Cumulative amount settled to payee | -| `closeRequestedAt` | i64 | Account state | Unix timestamp of close request (0 if none) | -| `bump` | u8 | Account state | Canonical PDA bump | +| `mint` | Pubkey | Seed + Account state | SPL Token or Token-2022 mint. Stored (not seed-only) so refund / distribution CPIs can be validated without re-binding seeds | The `channelId` is the base58-encoded address of the channel account (PDA). Channel programs MUST derive @@ -263,7 +313,8 @@ set MUST bind the PDA to: - the payer public key; - the payee public key; -- the asset identifier (SOL or mint address); +- the mint address (native SOL is unsupported; see + {{native-sol}}); - a client-chosen salt or nonce; and - the authorized signer public key (or payer if no delegation is used). @@ -284,140 +335,182 @@ match the canonical derivation for the channel seeds. ### open -Creates the channel account and transfers the initial -deposit from the payer to the escrow. +Creates the channel account, transfers the initial +deposit from the payer, and commits a hash of the +distribution splits preimage. The payer MUST be a +signer. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `salt` | u64 | PDA disambiguator | +| `deposit` | u64 | Initial deposit in base units; MUST be non-zero | +| `gracePeriod` | u32 | Forced-close grace period in seconds; stored per-channel | +| `distributionSplits` | `(Pubkey, u16)[]` | Splits preimage; canonical encoding hashed into `distributionHash` (see {{splits-canonicalization}}) | + +`open` MUST reject the instruction when the target +PDA already exists with the `ClosedChannel` +discriminator; reopening a previously finalized +channel PDA is forbidden regardless of seed inputs. +`open` MUST reject any `distributionSplits` whose +preimage is malformed, whose total share exceeds +10000 bps, which contains duplicate recipients, or +which lists the derived channel PDA as a recipient. +Mints carrying Token-2022 extensions outside the +allow-list (see {{token-extension-policy}}) MUST be +rejected. + +`open` does NOT carry an initial voucher; the first +voucher is exchanged off-chain after confirmation. -Solana allows the channel creation, token transfer, -and any initial setup to be composed in a single -atomic transaction with multiple instructions. +### settle -The payer authority for the funding transfer MUST be a -signer on the transaction. +Advances the on-chain `settled` watermark using a +payer-signed voucher. Permissionless; authority is +the voucher signature. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `voucher` | `VoucherArgs` | On-chain voucher payload (see {{on-chain-voucher-encoding}}) | + +The submitter MUST bundle a Solana native `ed25519` +precompile instruction immediately before `settle` +in the same transaction. The program reads the +verified message bytes via the Instructions sysvar, +asserts they byte-equal the `voucher` argument, and +asserts the precompile-recorded signer equals +`authorizedSigner`. The program then verifies +`settled < voucher.cumulativeAmount <= deposit` and +writes `settled = voucher.cumulativeAmount`. No +token transfer occurs in `settle`. -Before initializing, the program MUST check whether the -target PDA already exists. If the account discriminator -matches `ClosedChannel`, the program MUST reject the -instruction. Reopening a previously finalized channel -PDA is forbidden regardless of seed inputs. +### topUp -If the token mint has a Token-2022 transfer hook -extension, the deposit transfer instruction MUST -include the extra accounts required by the hook program. -Clients MUST resolve hook extra accounts from on-chain -mint state before constructing the open transaction. +Payer transfers additional funds to the escrow. -### settle +| Parameter | Type | Description | +|-----------|------|-------------| +| `amount` | u64 | Amount to add in base units; MUST be non-zero | -Payee presents a signed voucher. The program verifies -the Ed25519 signature (via Solana's `ed25519` program -or in-program verification), checks that -`cumulativeAmount > settled` and -`cumulativeAmount <= deposit`, then transfers the -delta (`cumulativeAmount - settled`) to the payee. +`topUp` requires `status == Open` and MUST be +rejected when `status == Closing`. Implementations +of this specification do NOT clear `closureStartedAt` +via `topUp`. The payer MUST be a signer. -The server MAY call settle at any time to claim -accumulated funds without closing the channel. +### requestClose -The payee authority for settlement MUST be a signer on -the transaction. +Payer initiates a forced close. Sets +`closureStartedAt = Clock::get().unix_timestamp`, +`status = Closing`. Requires `status == Open`. The +payer MUST be a signer. -If the token mint has a Token-2022 transfer hook -extension, the token transfer instruction MUST include -the extra accounts required by the hook program. -Servers MUST resolve current hook extra accounts from -on-chain mint state before building this transaction. +### finalize -### topUp +Permissionless post-grace crank. Transitions +`Closing -> Finalized` once +`now >= closureStartedAt + gracePeriod`, clears +`closureStartedAt`, and freezes `settled`. No +token transfer occurs. -Payer transfers additional funds to the escrow. The -program increases `deposit` accordingly. If -`closeRequestedAt > 0`, topUp MUST reset it to 0 -(cancelling any pending forced close). +### settleAndFinalize -The payer authority for the additional funding transfer -MUST be a signer on the transaction. +Payee-initiated cooperative close. Optionally +applies one final voucher (using the same +precompile-verified path as `settle`), then +transitions the channel to `Finalized`. -### requestClose +| Parameter | Type | Description | +|-----------|------|-------------| +| `voucher` | `VoucherArgs` | Final voucher payload (used when `hasVoucher != 0`) | +| `hasVoucher` | u8 | `0` skips voucher verification; non-zero applies `voucher` | + +The payee MUST be a signer. Callable from `Open` +and from `Closing` while `now < closureStartedAt + gracePeriod`; +after the grace deadline use `finalize` instead. No +token transfer occurs. -Payer initiates a forced close. The program sets -`closeRequestedAt = Clock::get().unix_timestamp`. -This starts a grace period during which the payee -can still call settle or close. +### distribute -The payer authority requesting close MUST be a signer -on the transaction. +Pays the merchant-side pool out of escrow according +to the splits preimage committed at `open`. +Permissionless; authority is the on-chain hash +commitment. -### withdraw +| Parameter | Type | Description | +|-----------|------|-------------| +| `distributionSplits` | `(Pubkey, u16)[]` | Splits preimage (see {{splits-canonicalization}}); rehashed and MUST equal `distributionHash` | -Payer recovers remaining funds after the grace -period has expired. The program verifies -`Clock::get().unix_timestamp >= closeRequestedAt + GRACE_PERIOD`, +Recipient token accounts are supplied as the dynamic +account tail, in the same order as the preimage +entries. Each MUST be the canonical ATA for +`(recipient, channel.mint, channel.tokenProgram)`. + +For `pool = settled - paidOut`: + +- recipient `i`: `floor(pool * bps[i] / 10000)`; +- payee: `floor(pool * (10000 - Σ bps) / 10000)`. + +`paidOut` is incremented by the total distributed. + +From `Open`, `distribute` requires `pool > 0`, +leaves flooring residual in the escrow ATA, and +keeps the channel `Open`. From `Finalized`, +`distribute` additionally — when +`payerWithdrawnAt == 0` — transfers +`deposit - settled` to the payer, stamps +`payerWithdrawnAt`, sweeps residual to the treasury +ATA, closes the escrow ATA, and tombstones the +channel PDA (see {{tombstoning}}). `distribute` +MUST NOT be callable from `Closing`. + +### withdrawPayer + +One-shot payer refund in `Finalized` that does NOT +tombstone the PDA. The program requires +`status == Finalized` and `payerWithdrawnAt == 0`, transfers `deposit - settled` to the payer, and -marks the channel as finalized. - -The payer authority receiving the refund MUST be a -signer on the transaction. - -On completion, the `withdraw` instruction MUST NOT -fully deallocate the channel account. The program MUST -realloc the account data to 8 bytes, write the -`ClosedChannel` discriminator, and return the difference -between the pre-close rent-exempt balance and the 8-byte -tombstone rent-exempt minimum to the payer. - -### close - -Payee closes the channel by settling any final delta -authorized by a voucher and refunding the remainder to -the payer in a single atomic transaction. If no new -delta exists beyond the on-chain `settled` watermark, -the close path MAY omit voucher verification and act -as a refund-only cooperative close. - -Solana's multi-instruction transactions allow the -settle + refund + account cleanup to happen -atomically, ensuring neither party can be cheated -during close. - -The payee authority initiating cooperative close MUST -be a signer on the transaction. Fee-payer signatures -MUST NOT be treated as satisfying payer or payee -authority checks. - -On completion, the `close` instruction MUST NOT -fully deallocate the channel account. The program MUST -realloc the account data to 8 bytes, write the -`ClosedChannel` discriminator, and return the difference -between the pre-close rent-exempt balance and the 8-byte -tombstone rent-exempt minimum to the payer. - -If the token mint has a Token-2022 transfer hook -extension, the token transfer instruction MUST include -the extra accounts required by the hook program. -Servers MUST resolve current hook extra accounts from -on-chain mint state before building this transaction. +stamps `payerWithdrawnAt`. The payer MUST be a +signer. + +### Tombstoning {#tombstoning} + +The `Finalized` branch of `distribute` performs +tombstoning. The program MUST NOT fully deallocate +the channel account; it MUST realloc the account +data to 1 byte and write the `ClosedChannel` +discriminator at offset 0. The rent difference +between the pre-tombstone balance and the 1-byte +tombstone rent-exempt minimum MUST be returned to +the payer. `withdrawPayer` MUST NOT tombstone. + +Implementations MUST NOT treat a fee-payer +signature as satisfying payer or payee authority +checks on any authority-gated instruction above. ## Grace Period The grace period (RECOMMENDED: 15 minutes) protects -the payee. If the payer calls requestClose while the -payee has unsubmitted vouchers, the payee has until -the grace period expires to call settle or close. +the payee. If the payer calls `requestClose` while +the payee has unsubmitted vouchers, the payee has +until the grace period expires to call `settle` +followed by `settleAndFinalize` (or to bundle a +voucher into `settleAndFinalize` directly). -Without a grace period, the payer could withdraw -funds immediately after receiving service, before -the server has time to settle. +Without a grace period, the payer could +`requestClose`, immediately call `finalize`, and +sweep funds before the server has time to settle. ## Access Control -| Instruction | Caller | -|-------------|--------| -| open | Anyone (payer signs the deposit transfer) | -| settle | Payee only | -| topUp | Payer only | -| requestClose | Payer only | -| withdraw | Payer only (after grace period) | -| close | Payee only | +| Instruction | Caller | Gating | +|-------------|--------|--------| +| open | Payer | Payer signs the deposit transfer | +| settle | Anyone (permissionless crank) | Precompile-verified Ed25519 voucher from `authorizedSigner` | +| topUp | Payer | Payer signs the additional transfer; rejected when `status != Open` | +| requestClose | Payer | Payer signer equals channel `payer` | +| finalize | Anyone (permissionless crank) | `status == Closing` and elapsed grace period | +| settleAndFinalize | Payee | Payee signer equals channel `payee` | +| distribute | Anyone (permissionless crank) | On-chain hash commitment to splits preimage | +| withdrawPayer | Payer | Payer signer equals channel `payer` and `status == Finalized` | # Request Schema @@ -436,16 +529,25 @@ suggestedDeposit units. Clients MAY deposit less or more depending on expected usage. +minimumDeposit +: OPTIONAL. Hard floor on initial channel deposit in + base units. Enforced at the HTTP layer (not on + chain). Servers MUST reject `POST /channel/open` + payloads with `depositAmount < minimumDeposit`. + Implementations SHOULD set this above the rent + cost of the channel account plus a minimum + economically useful balance to avoid spam. + recipient : REQUIRED. Base58-encoded public key of the server's account that will receive settlement funds. currency : REQUIRED. Base58-encoded SPL token mint address. - Native SOL is not supported; clients wishing to pay - in SOL MUST wrap it to wSOL + Native SOL is not supported (see {{native-sol}}); + clients wishing to pay in SOL MUST wrap it to wSOL (`So11111111111111111111111111111111111111112`) - before opening a channel. + before opening a channel. {#native-sol} description : OPTIONAL. Human-readable description of the service @@ -502,7 +604,25 @@ ttlSeconds gracePeriodSeconds : OPTIONAL. Grace period for forced close - (RECOMMENDED: 900, i.e. 15 minutes). + (RECOMMENDED: 900). Stored per-channel in + `Channel.gracePeriod` at `open`. + +distributionSplits +: OPTIONAL. Ordered list of `{recipient, shareBps}` + entries the merchant proposes to bind into the + channel at `open`. The payee receives the implicit + remainder share `10000 − Σ shareBps`; the explicit + list therefore covers only co-recipients, not the + payee itself. + + Each entry MUST have `shareBps > 0`. The list MUST + satisfy `0 ≤ Σ shareBps ≤ 10000`. The list size is + bounded by an implementation-defined + `MAX_DISTRIBUTION_RECIPIENTS` (RECOMMENDED: 32). + + When omitted, the channel behaves as a vanilla + two-party channel in which the payee receives the + full distributed pool. For the `session` intent, `amount` specifies the price per unit of service, not a total charge. When @@ -527,12 +647,17 @@ Opens a new payment channel. | `action` | string | REQUIRED | `"open"` | | `channelId` | string | REQUIRED | Base58 channel account address | | `payer` | string | REQUIRED | Base58 public key of the depositor | -| `authorizationPolicy` | object | OPTIONAL | Voucher signer policy | -| `depositAmount` | string | REQUIRED | Initial deposit in base units | -| `transaction` | string | REQUIRED | Base64-encoded signed (or partially signed) transaction | -| `expiresAt` | string | OPTIONAL | Session expiration (ISO 8601) | +| `payee` | string | REQUIRED | Base58 public key of the channel payee (matches `recipient` in the 402 challenge) | +| `mint` | string | REQUIRED | Base58 SPL Token / Token-2022 mint (matches `currency` in the 402 challenge) | +| `authorizedSigner` | string | REQUIRED | Base58 public key bound into the PDA seeds as the voucher signer; equals `payer` when no delegation is used | +| `salt` | string | REQUIRED | Decimal u64 PDA disambiguator | +| `depositAmount` | string | REQUIRED | Initial deposit in base units; MUST satisfy `depositAmount >= methodDetails.minimumDeposit` | +| `gracePeriodSeconds` | integer | REQUIRED | Grace-period seconds bound into channel state at `open`; MUST match `methodDetails.gracePeriodSeconds` (or the server-policy default) | +| `distributionSplits` | array | OPTIONAL | Splits preimage (see `methodDetails.distributionSplits`); MUST byte-match the splits proposed in the 402 challenge | +| `authorizationPolicy` | object | OPTIONAL | Voucher signer policy. When present, MUST be consistent with `authorizedSigner` | +| `transaction` | string | REQUIRED | Base64-encoded (standard alphabet, padded) signed or partially signed transaction | +| `expiresAt` | string | OPTIONAL | Session expiration (RFC 3339) | | `capabilities` | object | OPTIONAL | Implementation-specific extensions | -| `voucher` | object | REQUIRED | Signed initial voucher (see {{voucher-format}}) | The `transaction` contains the open instruction(s). When `feePayer` is `true`, the client partially signs @@ -540,12 +665,38 @@ When `feePayer` is `true`, the client partially signs fee payer before broadcasting — same pattern as the charge intent's pull mode. +`Action: "open"` MUST NOT carry an initial voucher. +The first voucher is exchanged off-chain in a +subsequent metered request, after the channel is +confirmed on-chain. This keeps the open path +focused on channel construction and avoids burning +on-chain compute on a signature for a single +request's worth of authorization. + +`Action: "open"` MUST NOT carry a `bump` field. The +channel PDA's canonical bump is derived on-chain via +`find_program_address` and validated by the program's +direct address check, so any wire-supplied bump is +redundant. Servers MUST reject open envelopes that +include a `bump` field using the `malformed-credential` +problem type. Silently accepting and ignoring a wire +`bump` is forbidden because a client whose derivation +is buggy can compute a wrong bump that nonetheless +pairs with the canonical PDA address — a mismatch the +on-chain address check cannot catch. + Servers MUST derive `payer`, `channelId`, `depositAmount`, `authorizationPolicy`, delegated signer -settings, and all program-relevant open parameters -from the signed transaction and confirmed on-chain -state. Servers MUST NOT trust these values solely -because they appear in the HTTP payload. +settings, the distribution splits commitment, and +all other program-relevant open parameters from the +signed transaction and confirmed on-chain state. +Servers MUST NOT trust these values solely because +they appear in the HTTP payload. Servers MUST also +strictly validate that the on-chain `distributionSplits`, +`payee`, and `mint` exactly match what was proposed +in the 402 challenge; failing to do so allows a +malicious client to redirect the merchant-side +payout. ## Action: "voucher" @@ -581,10 +732,29 @@ Requests cooperative close. | `channelId` | string | REQUIRED | Existing channel identifier | | `voucher` | object | OPTIONAL | Final signed voucher (see {{voucher-format}}) | -If `voucher` is present, the server settles the final -delta on-chain and refunds the remainder atomically. -If the highest amount has already been settled on-chain, -the server MAY close without a new voucher. +`Action: "close"` is a request for the server to +broadcast `settleAndFinalize` (optionally bundled +with `distribute` in the same transaction). Unlike +`Action: "open"` and `Action: "topUp"`, the +close credential does NOT carry a pre-signed +transaction: cooperative close requires the payee +signature, which the server controls, and the +server constructs and broadcasts the transaction +itself. + +When `voucher` is present, it MUST strictly advance +the on-chain watermark +(`settled < voucher.cumulativeAmount`). A supplied +voucher at or below the current on-chain `settled` +is invalid and MUST cause `settleAndFinalize` to +reject; clients SHOULD omit `voucher` instead when +no additional settlement is needed. When `voucher` +is omitted, the server finalizes at the current +on-chain `settled` watermark. + +See {{close-cooperative}} for the full settlement +procedure, including how `settleAndFinalize` and +`distribute` are bundled. # Voucher Format {#voucher-format} @@ -615,16 +785,30 @@ program ID and channel open parameters. | `signature` | string | REQUIRED | Base58-encoded Ed25519 signature | | `signatureType` | string | REQUIRED | `"ed25519"` | -## Voucher Signing +## Voucher Signing {#on-chain-voucher-encoding} -1. Serialize the voucher data object using JCS - {{RFC8785}} to produce deterministic bytes. +The signed voucher payload is 48 bytes in fixed +Borsh layout: -2. Sign the bytes using Ed25519 with the payer's - keypair (or a delegated signer's keypair if the - channel's `authorizedSigner` is set). +| Offset | Length | Field | Encoding | +|--------|--------|-------|----------| +| 0 | 32 | `channelId` | Raw Solana address bytes | +| 32 | 8 | `cumulativeAmount` | u64 little-endian | +| 40 | 8 | `expiresAt` | i64 little-endian; `0` = no expiration | -3. Encode the signature as base58. +Signing: + +1. Serialize the voucher data into the layout above. +2. Sign with Ed25519 using `authorizedSigner`'s key. +3. Encode the signature as base58 for the HTTP + `signature` field. + +The Borsh bytes are authoritative for signature +verification. The HTTP JSON shape is a transport +view; clients and servers MUST NOT influence what +bytes are signed via the JSON. The same layout is +the on-chain argument for `settle` and (without the +`hasVoucher` byte) for `settleAndFinalize`. ## Voucher Verification @@ -647,12 +831,14 @@ The server MUST verify each voucher: 6. Verify the channel account discriminator is not `ClosedChannel` (i.e., the channel has not been - finalized via close or withdraw). + tombstoned by `distribute`). -7. Verify `closeRequestedAt == 0`. Servers MUST reject - new voucher acceptance on channels with a pending - forced close unless the voucher is being used only - to settle or cooperatively close the channel. +7. Verify `status == Open` (i.e., `closureStartedAt == 0` + and the channel has not yet been finalized). + Servers MUST reject new voucher acceptance on + channels with a pending forced close unless the + voucher is being used only to drive + `settleAndFinalize`. 8. Verify `cumulativeAmount <= escrowedAmount` (does not exceed deposit). @@ -666,20 +852,24 @@ The server MUST verify each voucher: ## On-Chain Voucher Verification -When the server calls settle or close on the channel -program, the voucher signature MUST be verified -on-chain. On Solana, this can be done by: +When the channel program executes `settle` or +`settleAndFinalize` (with a voucher), the voucher +signature MUST be verified on-chain. On Solana, this +can be done by: - Including an `ed25519` program instruction in the - same transaction that verifies the signature before - the settle instruction executes. + same transaction that verifies the signature + immediately before the channel instruction + executes. - Or implementing Ed25519 verification directly in the channel program (higher compute cost). The first approach is preferred as it uses Solana's native signature verification at minimal compute -cost. +cost. The precompile instruction MUST immediately +precede the channel instruction in the same +transaction. When using instruction introspection to consume a native signature-verification instruction, channel @@ -689,14 +879,69 @@ programs MUST: - use checked instruction-loading helpers provided by the Solana SDK; - correlate the verified message bytes to the exact - `channelId`, `cumulativeAmount`, and signer accepted - by the `settle` or `close` instruction in the same - transaction; and + on-chain voucher payload accepted by the channel + instruction in the same transaction + (see {{on-chain-voucher-encoding}}); the bytes the + precompile recorded MUST byte-equal the channel + instruction's voucher argument, and the precompile- + recorded signer MUST equal `authorizedSigner`; - reject signature-verification instructions that are replayed, unrelated, or positioned such that the channel program cannot unambiguously determine which verified message they authorize. +# Distribution Splits {#splits-canonicalization} + +Channels MAY commit a multi-recipient split of the +merchant-side pool at `open`. The split is a list +of `(recipient, shareBps)` entries; the payee +receives the implicit-remainder share +`10000 − Σ shareBps` and is NOT listed explicitly. + +## Canonical Preimage + +The byte layout hashed at `open` and re-hashed at +`distribute`: + +~~~ +count (u32 LE) || [ recipient (32 bytes) || shareBps (u16 LE) ] × count +~~~ + +- `count == 0` is legal; the payee receives 100% of + the pool. +- Every active entry MUST have `shareBps > 0`. +- `0 ≤ Σ shareBps ≤ 10000`. +- Recipients MUST be unique and MUST NOT equal the + channel PDA itself. +- The list size is bounded by an + implementation-defined `MAX_DISTRIBUTION_RECIPIENTS` + (RECOMMENDED: 32). + +## Hash Algorithm + +Implementations MUST use a collision-resistant hash +with a 32-byte digest. The chosen algorithm MUST be +fixed at deployment and documented for clients so +they can reproduce it. Blake3 is RECOMMENDED; the +specific hash implementation (e.g., the `sol_blake3` +syscall versus a bundled library) is an +implementation detail that does not affect wire +compatibility. + +## Distribution Math + +For `pool = settled − paidOut`: + +- recipient `i`: `floor(pool * shareBps[i] / 10000)`; +- payee: `floor(pool * (10000 − Σ shareBps) / 10000)`. + +Flooring residual remains in the escrow ATA while +`status == Open`. At the `Finalized` branch of +`distribute`, residual is swept to the protocol +treasury ATA before the escrow ATA is closed. +The treasury account is a deployment-level address +documented out of band by the channel program. + # Authorized Signer By default, the payer signs vouchers directly. This @@ -763,7 +1008,7 @@ each open channel: | `acceptedCumulative` | Highest voucher amount accepted | | `spentAmount` | Cumulative amount charged for delivered service | | `settledOnChain` | Highest cumulative amount already settled on-chain | -| `closeRequestedAt` | Pending forced-close timestamp, if any | +| `closureStartedAt` | Pending forced-close timestamp, if any | The available off-chain balance is computed as: @@ -832,7 +1077,7 @@ MUST be processed atomically with respect to: - `acceptedCumulative`; - `spentAmount`; and -- `closeRequestedAt`. +- `closureStartedAt`. Servers MUST treat voucher submissions idempotently: @@ -857,12 +1102,15 @@ idempotent request. ## Open 1. Verify the open transaction contains the expected - channel program instructions (create PDA + - initialize channel + deposit transfer). + channel program instruction (the reference + implementation composes channel-PDA creation, + escrow ATA creation, deposit transfer, and the + `distributionHash` commitment in a single `open` + instruction). 2. Recompute the expected PDA from the transaction's - payer, payee, asset, authorized signer, salt, and - channel program ID. Verify it equals the declared - `channelId`. + payer, payee, mint, authorized signer, and salt + plus the channel program ID. Verify it equals the + declared `channelId`. 3. Verify the transaction's fee payer matches the challenge policy: - if `feePayer` is `true`, the fee payer MUST equal @@ -873,18 +1121,22 @@ idempotent request. redirect funds or mutate channel parameters. The server SHOULD reject transactions that route value through unexpected external programs. -5. If fee payer mode: co-sign and broadcast. +5. Verify `depositAmount >= methodDetails.minimumDeposit` + (when set) and that the on-chain `distributionHash` + matches the digest of the canonical preimage of + the splits proposed in the 402 challenge. +6. If fee payer mode: co-sign and broadcast. Otherwise: broadcast as-is. -6. Verify channel state on-chain after confirmation: +7. Verify channel state on-chain after confirmation: - payer matches transaction signer; - payee matches the challenged recipient; - - token/asset matches the challenge currency; + - mint matches the challenge currency; - deposit matches the requested amount; - authorized signer matches the open parameters; + - `gracePeriod` matches the challenge policy; + - `distributionHash` matches the proposed splits; - channel is not finalized; and - - `closeRequestedAt == 0`. -7. Verify the initial voucher against the confirmed - channel state. + - `closureStartedAt` is `0`. 8. Create server-side channel state. 9. Return 200 with receipt. @@ -905,38 +1157,52 @@ idempotent request. 2. Verify the top-up transaction targets the expected channel PDA and channel program and only increases deposit for that channel. -3. Verify deposit increase on-chain. -4. Increase `escrowedAmount`. -5. If the program cleared `closeRequestedAt`, clear it - in server-side state as well. -6. Return 204 with receipt. - -## Close (Cooperative) - -1. If a final voucher is provided and authorizes an - amount above `settledOnChain`, verify it. -2. Build and broadcast a close transaction: - settle any final delta + refund remainder - (atomic). -3. Mark channel as `"closed"`. +3. Verify the on-chain deposit increase after + confirmation. +4. Increase `escrowedAmount` in server-side state. +5. Return 200 with receipt. + +`topUp` is callable only while `status == Open` and +MUST NOT clear `closureStartedAt`. Once forced close +is requested, the paths forward are +`settleAndFinalize` (within grace) or `finalize` +(after grace). + +## Close (Cooperative) {#close-cooperative} + +1. If a final voucher is provided, verify it (it + MUST strictly advance the on-chain watermark). +2. Build and broadcast `settleAndFinalize`. The + server SHOULD bundle `distribute` in the same + transaction so the merchant-side payout, payer + refund, treasury sweep, and PDA tombstone all + land atomically. +3. Mark the channel as `"closed"` in server-side + state. 4. Persist final `settledOnChain` and terminal accounting state after confirmation. -5. Return 204 with receipt containing `txHash`. +5. Return 200 with receipt containing `txHash` and + (if `distribute` ran) the refunded amount. ## Forced Close (Client-Initiated) If the server becomes unresponsive, the client can force-close the channel: -1. Client calls requestClose on the channel program. -2. Grace period begins (RECOMMENDED: 15 minutes). +1. Client submits `requestClose` directly to RPC. +2. Grace period begins (per-channel `gracePeriod`). 3. During the grace period, the server MAY still - call settle with the latest voucher. -4. After the grace period, the client calls withdraw - to recover `deposit - settled`. - -This ensures the client can always recover unspent -funds, even if the server disappears. + call `settleAndFinalize` with the latest + voucher. +4. After the grace period, any party submits + `finalize` (permissionless) to transition the + channel to `Finalized`. +5. The payer MAY submit `withdrawPayer` to recover + `deposit - settled` immediately. Independently, + any party MAY submit `distribute` with the + splits preimage; the merchant side is paid, any + pending payer refund is also paid, residual is + swept to treasury, and the PDA is tombstoned. # Receipt Format @@ -1035,17 +1301,18 @@ it, a malicious payer could use the service, then immediately withdraw. The server has the grace period to submit any outstanding vouchers. -TopUp cancels pending close requests, preventing a -grief attack where the payer requests close -repeatedly to disrupt the session. +Because `topUp` MUST NOT clear `closureStartedAt`, +servers MUST guard the equivalent grief vector at +the HTTP layer by rate-limiting `requestClose` +retries and refusing to extend service after a +forced-close broadcast. -Servers MUST stop accepting new service vouchers once -`closeRequestedAt` is set. During the grace period, -the server MAY use the latest previously accepted -voucher to settle or cooperatively close the channel, -but SHOULD NOT continue serving new metered content -unless the close request is cancelled by a confirmed -top-up. +Servers MUST stop accepting new service vouchers +once `closureStartedAt` is set. During the grace +period, the server MAY use the latest previously +accepted voucher to drive `settleAndFinalize` (and, +optionally, `distribute`). Servers MUST NOT resume +metered service after `closureStartedAt` is set. ## Delegated Signer Risks @@ -1089,6 +1356,48 @@ validation. A channel opened for one token-program variant MUST NOT be settled or refunded through a different token-program account. +## Token-2022 Extension Policy {#token-extension-policy} + +Implementations MUST enforce a closed allow-list of +permitted Token-2022 extensions at `open` and +re-validate it on every token-touching instruction. +Extension presence alone is disqualifying; +unlisted, unknown, or malformed extensions MUST be +rejected before any token movement. + +The RECOMMENDED mint allow-list: + +- `MetadataPointer` +- `TokenMetadata` +- `GroupPointer` +- `TokenGroup` +- `GroupMemberPointer` +- `TokenGroupMember` + +The RECOMMENDED token-account allow-list: + +- `ImmutableOwner` + +All other extensions MUST be rejected: + +| Extension | Reason | +|-----------|--------| +| `NonTransferable` | No transfer from escrow can succeed | +| `PermanentDelegate` | Delegate can move escrow arbitrarily | +| `DefaultAccountState` | Destination ATAs may be born non-`Initialized` | +| `ConfidentialTransferMint` | Channel program does not produce confidential-transfer proofs | +| `TransferFeeConfig` | Withheld fees desync `deposit` / `settled` from escrow | +| `TransferHook` | Hook program can revert any transfer | +| `InterestBearing` | Visible amount changes over time | +| `ScaledUiAmountConfig` | Display-vs-raw divergence breaks exact distribution | +| `Pausable` | Mint-level pause can block escrow release | +| `CpiGuard` / `MemoTransfer` (account) | Distribution CPIs use neither delegate flow nor memos | +| `MintCloseAuthority` | Mint identity can be recreated while channels reference it | + +Implementations MUST NOT resolve transfer-hook extra +accounts, route through fee withholding, or honor +pause flags. + ## Account Ownership Validation Before deserializing or mutating any account, @@ -1142,9 +1451,9 @@ primitives where possible. The base interoperable path is Ed25519, using either: - an `ed25519` verification instruction in the same - transaction as `settle` or `close`, with the channel - program reading the instruction sysvar to confirm - success; or + transaction as `settle` or `settleAndFinalize`, with + the channel program reading the Instructions sysvar + to confirm success; or - direct in-program verification if compute budget and implementation constraints permit. From e0e7960d665ca9de1b94977abfd121811f341f62 Mon Sep 17 00:00:00 2001 From: Michael Assaf <94772640+snowmead@users.noreply.github.com> Date: Thu, 28 May 2026 11:30:49 -0400 Subject: [PATCH 4/5] fix: solana session validation requirements (#6) --- .../methods/solana/draft-solana-session-00.md | 187 +++++++++++------- 1 file changed, 120 insertions(+), 67 deletions(-) diff --git a/specs/methods/solana/draft-solana-session-00.md b/specs/methods/solana/draft-solana-session-00.md index 051ca05d..c3669221 100644 --- a/specs/methods/solana/draft-solana-session-00.md +++ b/specs/methods/solana/draft-solana-session-00.md @@ -255,8 +255,8 @@ This specification uses two distinct encoding regimes: base64url-encoded {{RFC4648}} without padding. 2. **On-chain signed-payload encoding.** The bytes - the payer's Ed25519 key signs to authorize spend - are produced by Borsh-encoding the on-chain + the channel's `authorizedSigner` signs to authorize + spend are produced by Borsh-encoding the on-chain `Voucher` struct (see {{on-chain-voucher-encoding}}). These bytes are the exact message verified by Solana's native `ed25519` precompile and read back @@ -298,11 +298,11 @@ logical fields: | `paidOut` | u64 | Account state | Cumulative amount already distributed to the merchant side; `paidOut <= settled` | | `closureStartedAt` | i64 | Account state | Unix timestamp when `requestClose` was called (0 if not set; cleared on `Finalized`) | | `payerWithdrawnAt` | i64 | Account state | Unix timestamp of the payer refund (0 if not yet); guards against double-refund when both `withdrawPayer` and `distribute` can pay the payer | -| `gracePeriod` | u32 | Account state | Seconds between `requestClose` and permissionless `finalize`. Per-channel, set at `open`, so a single program deployment can host channels with differing dispute windows | +| `gracePeriod` | u32 | Account state | Non-zero seconds between `requestClose` and permissionless `finalize`. Per-channel, set at `open`, so a single program deployment can host channels with differing dispute windows | | `distributionHash` | [u8;32] | Account state | Hash digest of the canonical splits preimage committed at `open`; `distribute` MUST re-verify this hash before paying recipients | | `payer` | Pubkey | Seed + Account state | Client who deposited funds | | `payee` | Pubkey | Seed + Account state | Server authorized to settle; receives the implicit-remainder share on `distribute` | -| `authorizedSigner` | Pubkey | Seed + Account state | Voucher signer (payer if not delegated) | +| `authorizedSigner` | Pubkey | Seed + Account state | Voucher signer; MAY equal `payer` or a delegated signer | | `mint` | Pubkey | Seed + Account state | SPL Token or Token-2022 mint. Stored (not seed-only) so refund / distribution CPIs can be validated without re-binding seeds | The `channelId` is the base58-encoded address of the @@ -319,6 +319,10 @@ set MUST bind the PDA to: - the authorized signer public key (or payer if no delegation is used). +Once a channel is opened, vouchers for that channel +MUST verify under the channel's `authorizedSigner`. +No other signer is valid for that channel. + Clients and servers MUST derive the expected `channelId` from the channel program ID and the seed components above and MUST verify that the open @@ -344,7 +348,7 @@ signer. |-----------|------|-------------| | `salt` | u64 | PDA disambiguator | | `deposit` | u64 | Initial deposit in base units; MUST be non-zero | -| `gracePeriod` | u32 | Forced-close grace period in seconds; stored per-channel | +| `gracePeriod` | u32 | Forced-close grace period in seconds; stored per-channel; encoded as `grace_period`; MUST be non-zero | | `distributionSplits` | `(Pubkey, u16)[]` | Splits preimage; canonical encoding hashed into `distributionHash` (see {{splits-canonicalization}}) | `open` MUST reject the instruction when the target @@ -359,14 +363,17 @@ Mints carrying Token-2022 extensions outside the allow-list (see {{token-extension-policy}}) MUST be rejected. +The `gracePeriod` parameter MUST be non-zero. Channel +programs MUST reject `grace_period == 0`. + `open` does NOT carry an initial voucher; the first voucher is exchanged off-chain after confirmation. ### settle Advances the on-chain `settled` watermark using a -payer-signed voucher. Permissionless; authority is -the voucher signature. +voucher signed by `authorizedSigner`. Permissionless; +authority is the voucher signature. | Parameter | Type | Description | |-----------|------|-------------| @@ -544,8 +551,8 @@ recipient currency : REQUIRED. Base58-encoded SPL token mint address. - Native SOL is not supported (see {{native-sol}}); - clients wishing to pay in SOL MUST wrap it to wSOL + Native SOL is not supported; clients wishing to pay + in SOL MUST wrap it to wSOL (`So11111111111111111111111111111111111111112`) before opening a channel. {#native-sol} @@ -603,9 +610,10 @@ ttlSeconds : OPTIONAL. Suggested session duration in seconds. gracePeriodSeconds -: OPTIONAL. Grace period for forced close - (RECOMMENDED: 900). Stored per-channel in - `Channel.gracePeriod` at `open`. +: Conditionally REQUIRED. Grace period for forced close + when `channelId` is absent (RECOMMENDED: 900). + Stored per-channel in `Channel.gracePeriod` at + `open`. The value MUST be greater than zero. distributionSplits : OPTIONAL. Ordered list of `{recipient, shareBps}` @@ -649,10 +657,10 @@ Opens a new payment channel. | `payer` | string | REQUIRED | Base58 public key of the depositor | | `payee` | string | REQUIRED | Base58 public key of the channel payee (matches `recipient` in the 402 challenge) | | `mint` | string | REQUIRED | Base58 SPL Token / Token-2022 mint (matches `currency` in the 402 challenge) | -| `authorizedSigner` | string | REQUIRED | Base58 public key bound into the PDA seeds as the voucher signer; equals `payer` when no delegation is used | +| `authorizedSigner` | string | REQUIRED | Base58 public key bound into the PDA seeds as the voucher signer; MAY equal `payer` or a delegated signer | | `salt` | string | REQUIRED | Decimal u64 PDA disambiguator | -| `depositAmount` | string | REQUIRED | Initial deposit in base units; MUST satisfy `depositAmount >= methodDetails.minimumDeposit` | -| `gracePeriodSeconds` | integer | REQUIRED | Grace-period seconds bound into channel state at `open`; MUST match `methodDetails.gracePeriodSeconds` (or the server-policy default) | +| `depositAmount` | string | REQUIRED | Initial deposit in base units; MUST equal the decoded `open` deposit and satisfy `depositAmount >= methodDetails.minimumDeposit` | +| `gracePeriodSeconds` | integer | REQUIRED | Grace-period seconds bound into channel state at `open`; MUST be greater than zero and MUST match `methodDetails.gracePeriodSeconds` | | `distributionSplits` | array | OPTIONAL | Splits preimage (see `methodDetails.distributionSplits`); MUST byte-match the splits proposed in the 402 challenge | | `authorizationPolicy` | object | OPTIONAL | Voucher signer policy. When present, MUST be consistent with `authorizedSigner` | | `transaction` | string | REQUIRED | Base64-encoded (standard alphabet, padded) signed or partially signed transaction | @@ -685,18 +693,14 @@ is buggy can compute a wrong bump that nonetheless pairs with the canonical PDA address — a mismatch the on-chain address check cannot catch. -Servers MUST derive `payer`, `channelId`, -`depositAmount`, `authorizationPolicy`, delegated signer -settings, the distribution splits commitment, and -all other program-relevant open parameters from the -signed transaction and confirmed on-chain state. -Servers MUST NOT trust these values solely because -they appear in the HTTP payload. Servers MUST also -strictly validate that the on-chain `distributionSplits`, -`payee`, and `mint` exactly match what was proposed -in the 402 challenge; failing to do so allows a -malicious client to redirect the merchant-side -payout. +Servers MUST treat the decoded `transaction`, not the +HTTP envelope, as the authoritative open request +before signing, paying fees, or broadcasting. Servers +MUST reject `Action: "open"` credentials when the +challenge, HTTP payload, decoded transaction, derived +PDA, escrow ATA, token program, or confirmed on-chain +state disagree. See {{open-settlement}} for the +required decoding and validation sequence. ## Action: "voucher" @@ -810,24 +814,27 @@ bytes are signed via the JSON. The same layout is the on-chain argument for `settle` and (without the `hasVoucher` byte) for `settleAndFinalize`. -## Voucher Verification +## Voucher Verification {#voucher-verification} The server MUST verify each voucher: 1. Deserialize and canonicalize the voucher data. -2. Verify the Ed25519 signature against the `signer` - public key. +2. Verify the Ed25519 signature over the Borsh voucher + payload against the `signer` public key. 3. Verify the `signer` matches the channel's - `authorizedSigner` (or `payer` if no delegation). + `authorizedSigner`. -4. Verify `channelId` matches the active channel. +4. Verify `voucher.channelId` matches the active + channel PDA. 5. Verify `cumulativeAmount > acceptedCumulative` - (cumulative increase), unless the submission is an - idempotent retry handled per - "Concurrency and Idempotency". + using the server's durable watermark, even when + on-chain `settled` lags. Equal or lower amounts + MUST be rejected for metered voucher acceptance + unless they are exact idempotent replays handled + per "Concurrency and Idempotency". 6. Verify the channel account discriminator is not `ClosedChannel` (i.e., the channel has not been @@ -1079,17 +1086,17 @@ MUST be processed atomically with respect to: - `spentAmount`; and - `closureStartedAt`. -Servers MUST treat voucher submissions idempotently: +Servers MUST treat metered requests idempotently: -- Resubmitting a voucher with the same - `cumulativeAmount` as the highest accepted voucher - MUST succeed and MUST NOT change channel state. -- Submitting a voucher with lower `cumulativeAmount` - than the highest accepted voucher SHOULD return the - current receipt state and MUST NOT reduce channel - state. +- Replaying an already processed request MAY return + the cached receipt and MUST NOT change channel state + or deliver additional service. +- Voucher submissions with `cumulativeAmount <= + acceptedCumulative` and no matching cached + idempotent response MUST be rejected and MUST NOT + reduce channel state. - Clients MAY safely retry voucher submissions after - network failures. + network failures using the same idempotency key. Clients SHOULD include an `Idempotency-Key` header on metered HTTP requests. Servers SHOULD cache @@ -1099,46 +1106,69 @@ idempotent request. # Settlement Procedure -## Open +## Open {#open-settlement} -1. Verify the open transaction contains the expected - channel program instruction (the reference - implementation composes channel-PDA creation, - escrow ATA creation, deposit transfer, and the - `distributionHash` commitment in a single `open` +1. Decode the open transaction before signing, paying + fees, or broadcasting. Verify it contains the + expected channel program instruction and that the + instruction uses the `open` discriminator (the + reference implementation composes channel-PDA + creation, escrow ATA creation, deposit transfer, + and the `distributionHash` commitment in a single instruction). -2. Recompute the expected PDA from the transaction's - payer, payee, mint, authorized signer, and salt - plus the channel program ID. Verify it equals the - declared `channelId`. -3. Verify the transaction's fee payer matches the +2. Verify the instruction targets the challenged + channel program and encodes the challenged `payer`, + `payee`, `mint`, `authorizedSigner`, `salt`, + `deposit`, `grace_period`, and canonical + `distributionSplits` preimage. +3. Recompute the expected PDA from the decoded payer, + payee, mint, authorized signer, and salt plus the + channel program ID. Verify it equals both the + decoded channel account and the declared + `channelId`. +4. Verify the decoded escrow account is the associated + token account for `(channelId, mint, tokenProgram)`. + If the challenge supplied `tokenProgram`, the + decoded token program MUST match it; otherwise it + MUST be a supported token program for the mint. +5. Verify the credential's `gracePeriodSeconds` equals + the challenge policy and is greater than zero. + Decode the open instruction and verify its + `grace_period` equals the same value. +6. Verify the transaction's fee payer matches the challenge policy: - if `feePayer` is `true`, the fee payer MUST equal `feePayerKey`; - otherwise the payer funds the transaction. -4. Verify the transaction does not include unrelated +7. Verify the transaction does not include unrelated writable accounts or instructions that could redirect funds or mutate channel parameters. The server SHOULD reject transactions that route value through unexpected external programs. -5. Verify `depositAmount >= methodDetails.minimumDeposit` - (when set) and that the on-chain `distributionHash` - matches the digest of the canonical preimage of - the splits proposed in the 402 challenge. -6. If fee payer mode: co-sign and broadcast. +8. Verify the decoded `deposit` equals + `depositAmount`, satisfies + `methodDetails.minimumDeposit` (when set), and that + the resulting `distributionHash` matches the digest + of the canonical preimage of the splits proposed in + the 402 challenge. +9. Reject any disagreement between the challenge, + credential payload, decoded transaction, derived + PDA, escrow ATA, or token program. +10. If fee payer mode: co-sign and broadcast. Otherwise: broadcast as-is. -7. Verify channel state on-chain after confirmation: +11. Verify channel state on-chain after confirmation: - payer matches transaction signer; - payee matches the challenged recipient; - mint matches the challenge currency; - deposit matches the requested amount; + - `gracePeriod` is non-zero and matches the + challenge policy; - authorized signer matches the open parameters; - - `gracePeriod` matches the challenge policy; - `distributionHash` matches the proposed splits; - channel is not finalized; and - `closureStartedAt` is `0`. -8. Create server-side channel state. -9. Return 200 with receipt. +12. Create server-side channel state. +13. Return 200 with receipt. ## Voucher Update (No Settlement) @@ -1170,8 +1200,13 @@ is requested, the paths forward are ## Close (Cooperative) {#close-cooperative} -1. If a final voucher is provided, verify it (it - MUST strictly advance the on-chain watermark). +1. If a final voucher is provided, verify the + `SignedVoucher` against the active channel: + `voucher.channelId` equals the payload `channelId`, + `signer` equals the channel `authorizedSigner`, the + Ed25519 signature verifies over the Borsh payload, + freshness checks pass, and + `settled < cumulativeAmount <= deposit`. 2. Build and broadcast `settleAndFinalize`. The server SHOULD bundle `distribute` in the same transaction so the merchant-side payout, payer @@ -1286,6 +1321,17 @@ channel program ID and channel open parameters so that vouchers cannot be replayed across different channel program deployments or different Solana clusters. +## Open Transaction Binding + +Servers that sponsor or submit open transactions MUST +treat the decoded transaction contents as the +committed request. A malicious client can otherwise +present a benign HTTP envelope while embedding a +different payee, distribution split, deposit, signer, +channel PDA, or grace period. Such a mismatch can make +the server sponsor or meter a channel it did not +challenge. + ## Cumulative Amount Safety Vouchers authorize cumulative totals (not deltas). @@ -1301,6 +1347,13 @@ it, a malicious payer could use the service, then immediately withdraw. The server has the grace period to submit any outstanding vouchers. +Servers MUST verify that a new channel uses the +challenged `gracePeriodSeconds`. If the transaction +sets a zero, shorter, or envelope-disagreeing +`grace_period`, the payer could request close and +recover funds before the server has time to settle +accepted vouchers. + Because `topUp` MUST NOT clear `closureStartedAt`, servers MUST guard the equivalent grief vector at the HTTP layer by rate-limiting `requestClose` From 7c9728ea7bcfe06ac7fd03d655f588d56530225f Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Thu, 28 May 2026 12:36:12 -0400 Subject: [PATCH 5/5] fix: specs gaps --- .../methods/solana/draft-solana-session-00.md | 55 +++++++++++++------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/specs/methods/solana/draft-solana-session-00.md b/specs/methods/solana/draft-solana-session-00.md index c3669221..82d5dfbe 100644 --- a/specs/methods/solana/draft-solana-session-00.md +++ b/specs/methods/solana/draft-solana-session-00.md @@ -30,10 +30,11 @@ normative: RFC8259: RFC8785: RFC9457: - I-D.httpauth-payment: + I-D.ryan-httpauth-payment-01: title: "The 'Payment' HTTP Authentication Scheme" - target: https://datatracker.ietf.org/doc/draft-ietf-httpauth-payment/ + target: https://datatracker.ietf.org/doc/draft-ryan-httpauth-payment/01/ author: + - name: Brendan Ryan - name: Jake Moxey date: 2026-01 @@ -62,7 +63,7 @@ informative: This document defines the "session" intent for the "solana" payment method within the Payment HTTP Authentication Scheme -{{I-D.httpauth-payment}}. Sessions enable metered, streaming, +{{I-D.ryan-httpauth-payment-01}}. Sessions enable metered, streaming, or repeated-use access to resources through off-chain vouchers backed by an on-chain escrow. The client opens a payment channel by depositing into a channel program, authorizes @@ -73,7 +74,7 @@ when the session closes. # Introduction -HTTP Payment Authentication {{I-D.httpauth-payment}} defines +HTTP Payment Authentication {{I-D.ryan-httpauth-payment-01}} defines a challenge-response mechanism that gates access to resources behind payments. This document registers the "session" intent for the "solana" payment method. @@ -284,7 +285,11 @@ MUST implement. Each channel is represented by an on-chain account (typically a PDA derived from payer, payee, mint, authorized signer, and a salt) with the following -logical fields: +logical fields. Field names use camelCase; tag and +enum-variant values (`Channel`, `ClosedChannel`, +`Open`, `Closing`, `Finalized`) use PascalCase by +convention, matching how they appear in Rust +program source. | Field | Type | Storage | Description | |-------|------|---------|-------------| @@ -313,8 +318,9 @@ set MUST bind the PDA to: - the payer public key; - the payee public key; -- the mint address (native SOL is unsupported; see - {{native-sol}}); +- the mint address (native SOL is unsupported; clients + wishing to pay in SOL MUST wrap to wSOL before opening + a channel); - a client-chosen salt or nonce; and - the authorized signer public key (or payer if no delegation is used). @@ -554,7 +560,7 @@ currency Native SOL is not supported; clients wishing to pay in SOL MUST wrap it to wSOL (`So11111111111111111111111111111111111111112`) - before opening a channel. {#native-sol} + before opening a channel. description : OPTIONAL. Human-readable description of the service @@ -659,12 +665,11 @@ Opens a new payment channel. | `mint` | string | REQUIRED | Base58 SPL Token / Token-2022 mint (matches `currency` in the 402 challenge) | | `authorizedSigner` | string | REQUIRED | Base58 public key bound into the PDA seeds as the voucher signer; MAY equal `payer` or a delegated signer | | `salt` | string | REQUIRED | Decimal u64 PDA disambiguator | -| `depositAmount` | string | REQUIRED | Initial deposit in base units; MUST equal the decoded `open` deposit and satisfy `depositAmount >= methodDetails.minimumDeposit` | -| `gracePeriodSeconds` | integer | REQUIRED | Grace-period seconds bound into channel state at `open`; MUST be greater than zero and MUST match `methodDetails.gracePeriodSeconds` | -| `distributionSplits` | array | OPTIONAL | Splits preimage (see `methodDetails.distributionSplits`); MUST byte-match the splits proposed in the 402 challenge | +| `depositAmount` | string | REQUIRED | Initial deposit in base units; MUST equal the decoded `open` deposit and satisfy `depositAmount >= minimumDeposit` (when the challenge sets one) | +| `gracePeriodSeconds` | integer | REQUIRED | Grace-period seconds bound into channel state at `open`; MUST be greater than zero and MUST match the challenge's `methodDetails.gracePeriodSeconds` | +| `distributionSplits` | array | OPTIONAL | Splits preimage (see the challenge's `methodDetails.distributionSplits`); MUST byte-match the splits proposed in the 402 challenge | | `authorizationPolicy` | object | OPTIONAL | Voucher signer policy. When present, MUST be consistent with `authorizedSigner` | | `transaction` | string | REQUIRED | Base64-encoded (standard alphabet, padded) signed or partially signed transaction | -| `expiresAt` | string | OPTIONAL | Session expiration (RFC 3339) | | `capabilities` | object | OPTIONAL | Implementation-specific extensions | The `transaction` contains the open instruction(s). @@ -702,6 +707,24 @@ PDA, escrow ATA, token program, or confirmed on-chain state disagree. See {{open-settlement}} for the required decoding and validation sequence. +Example `open` credential: + +~~~json +{ + "action": "open", + "channelId": "C4HnVjA7WMUtSQzAv4G6T3qBjLwK5jM7PvE2nQ5sZ3kP", + "payer": "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin", + "payee": "FNvFqYn4yV7HsoZyHRsbsj1Vd2HFcUe2NMRJq3rJxg7c", + "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "authorizedSigner": + "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin", + "salt": "42", + "depositAmount": "10000000", + "gracePeriodSeconds": 900, + "transaction": "AQAB...base64..." +} +~~~ + ## Action: "voucher" Submits a new voucher authorizing additional spend. @@ -768,7 +791,7 @@ procedure, including how `settleAndFinalize` and |-------|------|----------|-------------| | `channelId` | string | REQUIRED | Channel this voucher authorizes | | `cumulativeAmount` | string | REQUIRED | Total authorized spend (base units) | -| `expiresAt` | string | OPTIONAL | Voucher expiration (ISO 8601) | +| `expiresAt` | integer | OPTIONAL | Voucher expiration as a Unix timestamp in seconds (i64); `0` or omitted means no expiration. Encoded verbatim into the signed Borsh payload (see {{on-chain-voucher-encoding}}); no string/timezone conversion is performed at sign or verify time. | All other channel context (payer, recipient, token, network, program, and signer policy) is established @@ -850,8 +873,8 @@ The server MUST verify each voucher: 8. Verify `cumulativeAmount <= escrowedAmount` (does not exceed deposit). -9. If `expiresAt` is present, verify the voucher has - not expired (with configurable clock skew +9. If `expiresAt` is present and non-zero, verify + `now < expiresAt` (with configurable clock skew tolerance). 10. Persist the new `acceptedCumulative` amount to @@ -1285,7 +1308,7 @@ SHOULD support such requests where practical. # Error Responses Servers MUST use the standard problem types defined -in {{I-D.httpauth-payment}}: `malformed-credential`, +in {{I-D.ryan-httpauth-payment-01}}: `malformed-credential`, `invalid-challenge`, and `verification-failed`. The `detail` field SHOULD describe the specific failure (e.g., "Amount exceeds