From bc0ea7a4c7b1cc30ce297bd36cd0495358a8d07a Mon Sep 17 00:00:00 2001 From: Tom Rowbotham Date: Tue, 28 Apr 2026 19:19:13 +0100 Subject: [PATCH 1/5] Add Hedera payment method: charge + session intents First native Machine Payments Protocol method for Hedera. Charge intent (draft-hedera-charge-00): - Native Hedera TransferTransaction with Attribution memo - Push mode (type="hash") + pull mode (type="transaction") - Challenge-bound replay protection (same 32-byte memo as Tempo) - Verification via Mirror Node REST API - Splits (atomic multi-recipient, up to 9) Session intent (draft-hedera-session-00): - HederaStreamChannel.sol escrow (ERC-20 payment channels) - EIP-712 cumulative voucher signatures - Open, voucher, topUp, close, requestClose, withdraw - SSE transport for metered streaming (LLM token billing) - 15-minute close grace period Reference implementation: https://github.com/tomrowbo/mppx-hedera npm: mppx-hedera@0.2.1 Deployed contracts (both Sourcify-verified): Testnet: 0x401b6dc30221823361E4876f5C502e37249D84C3 (296) Mainnet: 0x401b6dc30221823361E4876f5C502e37249D84C3 (295) --- .../methods/hedera/draft-hedera-charge-00.md | 1343 ++++++++++ .../methods/hedera/draft-hedera-session-00.md | 2382 +++++++++++++++++ 2 files changed, 3725 insertions(+) create mode 100644 specs/methods/hedera/draft-hedera-charge-00.md create mode 100644 specs/methods/hedera/draft-hedera-session-00.md diff --git a/specs/methods/hedera/draft-hedera-charge-00.md b/specs/methods/hedera/draft-hedera-charge-00.md new file mode 100644 index 00000000..6a6c2d19 --- /dev/null +++ b/specs/methods/hedera/draft-hedera-charge-00.md @@ -0,0 +1,1343 @@ +--- +title: Hedera Charge Intent for HTTP Payment Authentication +abbrev: Hedera Charge +docname: draft-hedera-charge-00 +version: 00 +category: info +ipr: trust200902 +submissiontype: independent +consensus: false + +author: + - name: Tom Rowbotham + ins: T. Rowbotham + email: tom@xeno.money + +normative: + RFC2119: + RFC3339: + RFC4648: + RFC8174: + RFC8259: + RFC8785: + RFC9457: + I-D.payment-intent-charge: + title: > + 'charge' Intent for HTTP Payment Authentication + target: > + https://datatracker.ietf.org/doc/draft-payment-intent-charge/ + author: + - name: Jake Moxey + - name: Brendan Ryan + - name: Tom Meagher + date: 2026 + 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: + HEDERA-DOCS: + title: "Hedera Documentation" + target: https://docs.hedera.com + author: + - org: Hedera + date: 2026 + HIP-218: + title: "HIP-218: Smart Contract Verification" + target: > + https://hips.hedera.com/hip/hip-218 + author: + - org: Hedera + date: 2022 + HIP-376: + title: "HIP-376: Approve/Allowance API for Tokens" + target: > + https://hips.hedera.com/hip/hip-376 + author: + - org: Hedera + date: 2022 + MIRROR-NODE: + title: "Hedera Mirror Node REST API" + target: > + https://docs.hedera.com/hedera/sdks-and-apis/rest-api + author: + - org: Hedera + date: 2026 + CIRCLE-USDC-HEDERA: + title: "Circle USDC on Hedera" + target: > + https://www.circle.com/multi-chain-usdc/hedera + author: + - org: Circle + date: 2026 +--- + +--- abstract + +This document defines the "charge" intent for the "hedera" +payment method within the Payment HTTP Authentication Scheme +{{I-D.httpauth-payment}}. The client constructs and signs a +native Hedera Token Service (HTS) transfer; the server +verifies the payment via the Mirror Node REST API and +presents the transaction ID as proof of payment. + +Two credential types are supported: `type="hash"` (default), +where the client broadcasts the transaction itself and +presents the transaction ID for server verification, and +`type="transaction"` (pull mode), where the client signs and +serializes the transaction for the server to broadcast. + +--- 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 "charge" intent +for the "hedera" payment method. + +Hedera is a distributed ledger with asynchronous Byzantine +Fault Tolerant (aBFT) consensus, deterministic finality in +3-5 seconds, and fixed transaction fees {{HEDERA-DOCS}}. +This specification supports payments in Hedera Token Service +(HTS) tokens, including Circle USDC +{{CIRCLE-USDC-HEDERA}}, making it suitable for micropayment +use cases where fast confirmation and predictable costs are +important. + +Challenge binding and replay protection are achieved through +an Attribution memo embedded in the transaction's native +memo field (see {{attribution-memo}}). + +## Push Mode (Default) {#push-mode} + +The default flow, called "push mode", uses `type="hash"` +credentials. The client "pushes" the transaction to the +Hedera network itself and presents the confirmed +transaction ID: + +~~~ + Client Server Hedera Network + | | | + | (1) GET /resource | | + |-------------------> | | + | | | + | (2) 402 Payment | | + | Required | | + | (recipient, | | + | amount, memo) | | + |<------------------- | | + | | | + | (3) Build tx with | | + | Attribution | | + | memo, sign | | + | | | + | (4) Execute tx | | + |--------------------------------------> | + | (5) Receipt | | + |<-------------------------------------- | + | | | + | (6) Authorization: | | + | Payment | | + | | | + | (transaction ID)| | + |-------------------> | | + | | (7) Mirror Node | + | | GET /api/v1/ | + | | transactions/ | + | | {txId} | + | |-----------------> | + | | (8) Tx data | + | |<----------------- | + | | | + | (9) 200 OK +Receipt | | + |<------------------- | | + | | | +~~~ + +This flow is useful when the client has its own Hedera +account and operator key. The server verifies the payment +by querying the Mirror Node REST API {{MIRROR-NODE}}. + +## Pull Mode {#pull-mode} + +The pull mode flow uses `type="transaction"` credentials. +The client signs the transaction and the server "pulls" it +for broadcast to the Hedera network: + +~~~ + Client Server Hedera Network + | | | + | (1) GET /resource | | + |-------------------> | | + | | | + | (2) 402 Payment | | + | Required | | + | (recipient, | | + | amount) | | + |<------------------- | | + | | | + | (3) Build tx with | | + | Attribution | | + | memo, freeze, | | + | sign | | + | | | + | (4) Authorization: | | + | Payment | | + | | | + | (serialized tx) | | + |-------------------> | | + | | (5) Verify memo, | + | | execute tx | + | |-----------------> | + | | (6) Receipt | + | |<----------------- | + | | | + | (7) 200 OK +Receipt | | + |<------------------- | | + | | | +~~~ + +In this model the server controls transaction broadcast, +enabling server-side retry logic and future fee delegation +(see {{fee-delegation}}). + +## Relationship to the Charge Intent + +This document inherits the shared request semantics of the +"charge" intent from {{I-D.payment-intent-charge}}. It +defines only the Hedera-specific `methodDetails`, `payload`, +and verification procedures for the "hedera" payment method. + +# Requirements Language + +{::boilerplate bcp14-tagged} + +# Terminology + +Transaction ID +: A unique identifier for a Hedera transaction in the + format `shard.realm.num@seconds.nanoseconds` (e.g., + `0.0.12345@1681234567.123456789`). Composed of the + payer account ID and the transaction's valid-start + timestamp. + +Account ID +: A Hedera account identifier in the format + `shard.realm.num` (e.g., `0.0.12345`). The shard and + realm are typically `0.0` on the public Hedera network. + +Token ID +: A Hedera Token Service (HTS) token identifier in the + format `shard.realm.num` (e.g., `0.0.456858` for Circle + USDC on mainnet). Uniquely identifies a fungible or + non-fungible token on the Hedera network. + +Token Association +: A one-time operation that associates a Hedera account + with an HTS token, enabling the account to hold and + receive that token. Unlike Solana's Associated Token + Accounts, token association is a single on-chain + operation that does not create a separate account. + +Base Units +: The smallest transferable unit of an HTS token, + determined by the token's decimal precision. For + example, USDC uses 6 decimals, so 1 USDC = 1,000,000 + base units. + +Mirror Node +: A read-only node that archives Hedera network data + and exposes it via a REST API {{MIRROR-NODE}}. Used + by servers to verify transaction details after + consensus. + +Attribution Memo +: A 32-byte challenge-bound memo embedded in the + Hedera transaction's native memo field. Encodes the + MPP tag, version, server identity, optional client + identity, and a challenge-specific nonce. See + {{attribution-memo}} for the full byte layout. + +Push Mode +: The default settlement flow where the client + broadcasts the transaction itself and presents the + confirmed transaction ID (`type="hash"`). The client + "pushes" the transaction to the network directly. + +Pull Mode +: The alternative settlement flow where the client + signs and serializes the transaction and the server + broadcasts it (`type="transaction"`). The server + "pulls" the signed transaction from the credential. + +# Intent Identifier + +The intent identifier for this specification is "charge". +It MUST be lowercase. + +# Intent: "charge" + +The "charge" intent represents a one-time payment gating +access to a resource. The client builds and signs a Hedera +`TransferTransaction` with an Attribution memo, then either +broadcasts the transaction itself and sends the transaction +ID (`type="hash"`) or sends the serialized signed +transaction bytes to the server for broadcast +(`type="transaction"`). The server verifies the transfer +details and returns a receipt. + +# Attribution Memo {#attribution-memo} + +Every Hedera charge transaction MUST include an Attribution +memo in the transaction's native memo field. The memo +provides challenge binding (replay protection) and server +identity verification. + +## Byte Layout + +The Attribution memo is exactly 32 bytes, stored in the +Hedera transaction memo as a `0x`-prefixed hex string +(66 characters: `0x` + 64 hex digits = 66 bytes UTF-8). +This fits well within Hedera's 100-byte memo limit. + +~~~ +Offset Size Field +------ ---- ----------------------------------- +0..3 4 TAG = keccak256("mpp")[0..3] +4 1 VERSION = 0x01 +5..14 10 SERVER_ID = + keccak256(realm)[0..9] +15..24 10 CLIENT_ID = + keccak256(clientId)[0..9] + or zero bytes if anonymous +25..31 7 NONCE = + keccak256(challengeId)[0..6] +~~~ + +TAG (bytes 0-3) +: The first 4 bytes of `keccak256("mpp")`. Identifies + this memo as an MPP attribution memo. Implementations + MUST reject memos where these bytes do not match. + +VERSION (byte 4) +: Protocol version. MUST be `0x01` for this + specification. Implementations MUST reject memos + with an unrecognized version. + +SERVER_ID (bytes 5-14) +: The first 10 bytes of `keccak256(realm)`, where + `realm` is the challenge's `realm` auth-param. + Binds the memo to a specific server. Servers MUST + verify this fingerprint matches their own realm. + +CLIENT_ID (bytes 15-24) +: The first 10 bytes of `keccak256(clientId)`, where + `clientId` is an optional client identifier. If the + client is anonymous, all 10 bytes MUST be zero. + +NONCE (bytes 25-31) +: The first 7 bytes of `keccak256(challengeId)`, where + `challengeId` is the challenge `id` auth-param from + the `WWW-Authenticate` header. Binds the memo to a + specific challenge instance, preventing replay. + +## Encoding + +The 32-byte memo MUST be hex-encoded with a `0x` prefix +and stored as the Hedera transaction memo via +`setTransactionMemo()`. The resulting string is exactly +66 characters (`0x` + 64 hex digits) and 66 bytes +UTF-8, which is within Hedera's 100-byte memo limit. + +Example memo (hex): + +~~~ +0xef1ed71201a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7 + f8a9b0c1d2e3f4a5b6c7 +~~~ + +## Compatibility + +This byte layout is identical to the attribution memo +used by the Tempo payment method, ensuring compatibility +across the MPP ecosystem. The only difference is the +transport: Tempo embeds the memo in a smart contract +call (`transferWithMemo`), while Hedera uses the native +transaction memo field. + +# Encoding Conventions {#encoding} + +All JSON {{RFC8259}} objects carried in auth-params or HTTP +headers in this specification MUST be serialized using the +JSON Canonicalization Scheme (JCS) {{RFC8785}} before +encoding. JCS produces a deterministic byte sequence, which +is required for any digest or signature operations defined +by the base spec {{I-D.httpauth-payment}}. + +The resulting bytes MUST then be encoded using base64url +{{RFC4648}} Section 5 without padding characters (`=`). +Implementations MUST NOT append `=` padding when encoding, +and MUST accept input with or without padding when decoding. + +This encoding convention applies to: the `request` +auth-param in `WWW-Authenticate`, the credential token in +`Authorization`, and the receipt token in `Payment-Receipt`. + +# Request Schema + +## Shared Fields + +The `request` auth-param of the `WWW-Authenticate: Payment` +header contains a JCS-serialized, base64url-encoded JSON +object (see {{encoding}}). The following shared fields are +included in that object: + +amount +: REQUIRED. The payment amount in base units, encoded as + a decimal string. For HTS tokens, the amount is in the + token's smallest unit (e.g., for USDC with 6 decimals, + "1000000" represents 1 USDC). The value MUST be a + positive integer that fits in a 64-bit signed integer + (max 9,223,372,036,854,775,807), consistent with + Hedera's `int64` transfer amounts. + +currency +: REQUIRED. The HTS token ID string identifying the + payment asset (e.g., `"0.0.456858"` for Circle USDC + on mainnet). The token ID uniquely identifies the + token on the Hedera network and is used by the client + to construct the `TransferTransaction`. MUST be a + valid Hedera entity ID in the format + `shard.realm.num`. + +description +: OPTIONAL. A human-readable memo describing the + resource or service being paid for. MUST NOT exceed + 256 characters. + +recipient +: REQUIRED. The Hedera account ID of the account + receiving the payment (e.g., `"0.0.12345"`). MUST + be a valid Hedera account ID in the format + `shard.realm.num`. + +externalId +: OPTIONAL. Merchant's reference (e.g., order ID, + invoice number), per {{I-D.payment-intent-charge}}. + May be used for reconciliation or idempotency. MUST + NOT exceed 34 bytes (100-byte Hedera memo limit minus + the 66-byte Attribution memo). When the Attribution + memo is present, there is no remaining memo capacity + for an on-chain external ID; the `externalId` is + therefore carried only in the credential's challenge + echo and is not written on-chain. + +splits +: OPTIONAL. An array of at most 9 additional payment + splits. Each entry is a JSON object with the + following fields: + + - `recipient` (REQUIRED): Hedera account ID of the + split recipient (e.g., `"0.0.67890"`). + - `amount` (REQUIRED): Amount in the same base units + and token as the primary `amount`. + + When present, the client MUST include a token + transfer entry for each split in addition to the + primary transfer to `recipient`. All splits use the + same token as the primary payment (the `currency` + token ID). + + Hedera's `TransferTransaction` natively supports + atomic multi-party transfers (up to 10 token + transfer entries per transaction), making splits + straightforward: the client adds one debit from the + payer and one credit per recipient in a single + atomic transaction. + + The top-level `amount` is the total the client pays. + The sum of all split amounts MUST NOT exceed + `amount`. The primary `recipient` receives `amount` + minus the sum of all split amounts; this remainder + MUST be greater than zero. Servers MUST reject + challenges where splits consume the entire amount. + Servers MUST verify each split transfer on-chain + during credential verification. If the same + recipient appears more than once in `splits`, each + occurrence is a distinct payment leg and MUST be + verified separately; servers MUST NOT implicitly + aggregate such entries. + + This mechanism is a Hedera-specific extension to the + base `charge` intent. It can be used for platform + fees, revenue sharing, or referral commissions. + + Note: The `splits` field is at the top level of the + request object (alongside `amount`, `currency`, + `recipient`, etc.), not nested under + `methodDetails`. The mppx framework's schema + transform outputs `splits` at the top level. + +## Method Details + +The following fields are nested under `methodDetails` in +the request JSON: + +chainId +: OPTIONAL. The EIP-155 chain ID for the Hedera + network: 295 for mainnet, 296 for testnet. + Implementations SHOULD document their default + network. The reference implementation defaults to + testnet (296) for safety. Clients MUST reject + challenges whose `chainId` does not match their + configured network. + +## Client Configuration Fields + +The following fields are used during request +construction by the mppx framework's schema transform +but are NOT present in the serialized wire-format +challenge. They are consumed by `parseUnits()` to +convert human-readable amounts to base units before +the request is serialized. + +decimals +: OPTIONAL. The number of decimal places for the + token (0-18). Used by `parseUnits()` during + request construction to convert a human-readable + amount (e.g., "1.00") into base units (e.g., + "1000000"). This field is consumed by the schema + transform and does NOT appear in the serialized + challenge sent over the wire. Clients that + construct requests manually MUST provide `amount` + in base units directly and do not need this field. + +### HTS Token Example + +~~~json +{ + "amount": "1000000", + "currency": "0.0.456858", + "recipient": "0.0.12345", + "description": "Weather API access", + "methodDetails": { + "chainId": 295 + } +} +~~~ + +This requests a transfer of 1 USDC (1,000,000 base +units) on Hedera mainnet (chain ID 295). + +### Testnet Example + +~~~json +{ + "amount": "500000", + "currency": "0.0.5449", + "recipient": "0.0.67890", + "description": "Premium API call", + "methodDetails": { + "chainId": 296 + } +} +~~~ + +This requests a transfer of 0.50 USDC on Hedera +testnet (chain ID 296). Note that `decimals` is not +present in the wire format; it is only used during +request construction by the mppx schema transform. + +### Payment Splits Example + +~~~json +{ + "amount": "1050000", + "currency": "0.0.456858", + "recipient": "0.0.12345", + "description": "Marketplace purchase", + "splits": [ + { + "recipient": "0.0.67890", + "amount": "50000" + } + ], + "methodDetails": { + "chainId": 295 + } +} +~~~ + +This requests a total payment of 1.05 USDC. The platform +receives 0.05 USDC and the primary recipient (seller) +receives 1.00 USDC. + +# Credential Schema + +The `Authorization` header carries a single base64url- +encoded JSON token (no auth-params). The decoded object +contains the following top-level fields: + +challenge +: REQUIRED. An echo of the challenge auth-params from + the `WWW-Authenticate` header: `id`, `realm`, + `method`, `intent`, `request`, and (if present) + `expires`. This binds the credential to the exact + challenge that was issued. + +source +: OPTIONAL. A payer identifier string, as defined by + {{I-D.httpauth-payment}}. Hedera implementations MAY + use a DID in the format + `did:pkh:hedera:{network}:{accountId}`. + +payload +: REQUIRED. A JSON object containing the Hedera-specific + credential fields. The `type` field determines which + additional fields are present. Two payload types are + defined: `"hash"` (default) and `"transaction"` + (pull mode). + +## Hash Payload -- Push Mode {#hash-payload} + +In push mode (`type="hash"`), the client has already +broadcast the transaction to the Hedera network. The +`transactionId` field contains the Hedera transaction ID +for the server to verify via the Mirror Node. + +| Field | Type | Req | Description | +|-------|------|-----|-------------| +| `type` | string | Y | `"hash"` | +| `transactionId` | string | Y | Hedera transaction ID | + +The `transactionId` MUST be in the standard Hedera format +`shard.realm.num@seconds.nanoseconds` (e.g., +`"0.0.12345@1681234567.123456789"`). + +Example (decoded): + +~~~json +{ + "challenge": { + "id": "kM9xPqWvT2nJrHsY4aDfEb", + "realm": "api.example.com", + "method": "hedera", + "intent": "charge", + "request": "eyJ...", + "expires": "2026-03-15T12:05:00Z" + }, + "payload": { + "type": "hash", + "transactionId": + "0.0.12345@1681234567.123456789" + } +} +~~~ + +## Transaction Payload -- Pull Mode {#transaction-payload} + +In pull mode (`type="transaction"`), the client sends the +signed transaction bytes to the server for broadcast. The +`transaction` field contains the base64-encoded serialized +signed transaction. + +| Field | Type | Req | Description | +|-------|------|-----|-------------| +| `type` | string | Y | `"transaction"` | +| `transaction` | string | Y | Base64-encoded signed tx bytes | + +The transaction MUST be a valid Hedera transaction that +has been frozen and signed by the payer. The server +deserializes the transaction via `Transaction.fromBytes()`, +verifies the Attribution memo, and executes it. + +Example (decoded): + +~~~json +{ + "challenge": { + "id": "kM9xPqWvT2nJrHsY4aDfEb", + "realm": "api.example.com", + "method": "hedera", + "intent": "charge", + "request": "eyJ...", + "expires": "2026-03-15T12:05:00Z" + }, + "payload": { + "type": "transaction", + "transaction": "CgMA...base64-encoded..." + } +} +~~~ + +# Verification Procedure {#verification} + +Upon receiving a request with a credential, the server +MUST: + +1. Decode the base64url credential and parse the JSON. + +2. Verify that `payload.type` is present and is either + `"hash"` or `"transaction"`. + +3. Look up the stored challenge using + `credential.challenge.id`. If no matching challenge + is found, reject the request. + +4. Verify that all fields in `credential.challenge` + exactly match the stored challenge auth-params. + +5. Proceed with type-specific verification: + - For `type="hash"`: see {{hash-verification}}. + - For `type="transaction"`: see + {{transaction-verification}}. + +## Push Mode Verification {#hash-verification} + +For credentials with `type="hash"`: + +1. Verify that `payload.transactionId` is present and + is a valid Hedera transaction ID string. + +2. Verify the transaction ID has not been previously + consumed (see {{replay-protection}}). + +3. Fetch the transaction from the Mirror Node REST API + at `/api/v1/transactions/{txId}`, where `{txId}` is + the transaction ID with `@` replaced by `-` and `.` + in the timestamp replaced by `-` (Mirror Node URL + format). The server MUST poll with retry to account + for the 3-5 second lag between consensus and Mirror + Node indexing (see {{mirror-node-lag}}). + +4. Verify the transaction was successful: the `result` + field MUST be `"SUCCESS"`. + +5. Verify the Attribution memo: decode the + `memo_base64` field from the Mirror Node response + (base64 to UTF-8 to hex string), then verify: + - The memo is a valid MPP attribution memo + (TAG and VERSION match). + - The SERVER_ID fingerprint matches the server's + realm. + - The NONCE matches + `keccak256(challengeId)[0..6]`. + +6. Verify the token transfers match the challenge + request (see {{transfer-verification}}). + +7. Mark the transaction ID as consumed to prevent + replay. + +8. Return the resource with a `Payment-Receipt` header. + +## Pull Mode Verification {#transaction-verification} + +For credentials with `type="transaction"`: + +1. Decode the base64 `payload.transaction` value. + +2. Deserialize the transaction using + `Transaction.fromBytes()`. + +3. Extract the transaction memo and verify it is a + valid MPP attribution memo: + - The memo string starts with `0x` and is 66 + characters. + - TAG and VERSION match. + - SERVER_ID fingerprint matches the server's realm. + - NONCE matches + `keccak256(challengeId)[0..6]`. + +4. Verify the serialized transaction bytes have not + been previously submitted (see {{replay-protection}}). + +5. Execute the transaction on the Hedera network using + the server's operator credentials. + +6. Verify the transaction receipt status is `SUCCESS`. + +7. Fetch the transaction from the Mirror Node and + verify the token transfers match the challenge + request (see {{transfer-verification}}). + +8. Mark the transaction ID as consumed to prevent + replay. + +9. Return the resource with a `Payment-Receipt` header. + +## Transfer Verification {#transfer-verification} + +For all credential types, the server MUST verify the +token transfers from the Mirror Node response: + +1. Compute the primary payment amount as the top-level + `amount` minus the sum of all `splits`, if any. + +2. Locate a token transfer entry in the Mirror Node + response's `token_transfers` array where: + - `token_id` matches the `currency` from the + challenge request. + - `account` matches the top-level `recipient`. + - `amount` is greater than or equal to the computed + primary payment amount. + +3. For each split in `splits`, if any, locate an + additional token transfer entry where: + - `token_id` matches the `currency`. + - `account` matches the split `recipient`. + - `amount` is greater than or equal to the split + `amount`. + + Each required payment leg MUST be matched to a + distinct token transfer entry. A single entry MUST + NOT satisfy more than one required payment leg, + even if multiple legs share the same recipient. + +If any required token transfer entry is missing, the +server MUST reject the credential. + +## Replay Protection {#replay-protection} + +Servers MUST maintain a set of consumed transaction +identifiers. Before accepting a credential, the server +MUST check whether the identifier has already been +consumed. After successful verification, the server +MUST atomically mark the identifier as consumed. + +For `type="hash"` credentials, the transaction ID is +provided directly by the client. For +`type="transaction"` credentials, the transaction ID +is derived after the server executes the transaction. + +The Attribution memo's NONCE field provides an +additional layer of replay protection: even if a +transaction ID were somehow reusable, the +challenge-bound nonce ensures the memo can only satisfy +the specific challenge it was created for. + +A transaction ID that has been consumed MUST NOT be +accepted again, even if presented with a different +challenge ID. + +## Mirror Node Lag {#mirror-node-lag} + +Hedera achieves consensus in approximately 3-5 seconds, +but the Mirror Node REST API may take an additional 3-5 +seconds to index the transaction. Servers MUST implement +retry logic when fetching transactions from the Mirror +Node: + +- Servers SHOULD retry up to 10 times with a 2-second + delay between attempts. +- A 404 response from the Mirror Node during the retry + window is expected and MUST NOT be treated as a + permanent failure. +- After exhausting retries, the server MUST reject the + credential with a `verification-failed` error. + +# Settlement Procedure + +Two settlement flows are supported, corresponding to +the two credential types. + +## Push Mode Settlement (type="hash") + +For `type="hash"` credentials, the client broadcasts +the transaction and presents the transaction ID: + +~~~ + Client Server Mirror Node + | | | + | (1) Build tx with | | + | Attribution | | + | memo, sign, | | + | execute | | + | | | + | (2) Authorization: | | + | Payment | | + | | | + | (transaction ID)| | + |-------------------> | | + | | | + | | (3) GET | + | | /api/v1/ | + | | transactions/ | + | | {txId} | + | | (with retry) | + | |--------------> | + | | (4) Tx data | + | |<-------------- | + | | | + | | (5) Verify: | + | | - memo | + | | - transfers | + | | - result | + | | | + | (6) 200 OK +Receipt | | + |<------------------- | | + | | | +~~~ + +1. Client builds a `TransferTransaction` with the + Attribution memo, signs it, and executes it on + the Hedera network. +2. Client presents the transaction ID as the + credential. +3. Server fetches the transaction from the Mirror + Node REST API, retrying to account for indexing + lag. +4. Server verifies the Attribution memo (challenge + binding, server identity) and token transfers + (amount, recipient, splits). +5. Server records the transaction ID as consumed and + returns the resource with a `Payment-Receipt` + header. + +## Pull Mode Settlement (type="transaction") + +For `type="transaction"` credentials, the client signs +the transaction and sends it to the server: + +~~~ + Client Server Hedera Network + | | | + | (1) Authorization: | | + | Payment | | + | | | + | (signed tx | | + | bytes) | | + |-------------------> | | + | | | + | | (2) Deserialize, | + | | verify memo | + | | | + | | (3) Execute tx | + | |-----------------> | + | | (4) Receipt | + | |<----------------- | + | | | + | | (5) Mirror Node | + | | verify | + | | transfers | + | | | + | (6) 200 OK +Receipt | | + |<------------------- | | + | | | +~~~ + +1. Client submits credential containing signed + transaction bytes. +2. Server deserializes the transaction, verifies the + Attribution memo (challenge binding, server + identity). +3. Server executes the transaction on the Hedera + network. +4. Server verifies the receipt status is `SUCCESS`. +5. Server fetches the transaction from the Mirror + Node and verifies token transfers match the + challenge request. +6. Server records the transaction ID as consumed and + returns the resource with a `Payment-Receipt` + header. + +## Client Transaction Construction + +The client MUST construct a `TransferTransaction` with: + +1. A debit of the full `amount` from the client's + account for the specified `currency` token. + +2. A credit of the primary payment amount (total + `amount` minus sum of splits) to the `recipient` + account for the `currency` token. + +3. For each split, a credit of the split `amount` to + the split `recipient` for the `currency` token. + +4. The Attribution memo set via + `setTransactionMemo()` (see {{attribution-memo}}). + +All debit and credit entries MUST sum to zero within +the `TransferTransaction`, as required by Hedera's +transfer semantics. + +The recipient account(s) MUST have previously +associated with the `currency` token. Unlike Solana's +Associated Token Accounts, Hedera token association is +a one-time operation and does not require rent or +account creation by the payer. If the recipient has +not associated with the token, the transaction will +fail with `TOKEN_NOT_ASSOCIATED_TO_ACCOUNT`. + +## Finality + +Hedera provides asynchronous Byzantine Fault Tolerant +(aBFT) consensus with deterministic finality in +approximately 3-5 seconds. Once a transaction reaches +consensus, it cannot be rolled back or reversed. + +This is in contrast to probabilistic finality models +(e.g., proof-of-work chains) where transactions can +theoretically be reversed. Hedera's deterministic +finality means that once the Mirror Node reports a +transaction as `SUCCESS`, the payment is irreversible. + +Servers MAY accept the credential immediately upon +Mirror Node confirmation without waiting for additional +confirmations. + +## Receipt Generation + +Upon successful verification, the server MUST include +a `Payment-Receipt` header in the 200 response. + +The receipt payload for Hedera charge: + +| Field | Type | Description | +|-------|------|-------------| +| `method` | string | `"hedera"` | +| `reference` | string | The transaction ID | +| `status` | string | `"success"` | +| `timestamp` | string | {{RFC3339}} time | + +Example (decoded): + +~~~json +{ + "method": "hedera", + "reference": + "0.0.12345@1681234567.123456789", + "status": "success", + "timestamp": "2026-03-10T21:00:00Z" +} +~~~ + +# Error Responses + +When rejecting a credential, the server MUST return +HTTP 402 (Payment Required) with a fresh +`WWW-Authenticate: Payment` challenge per +{{I-D.httpauth-payment}}. The server SHOULD include a +response body conforming to RFC 9457 {{RFC9457}} Problem +Details, with `Content-Type: application/problem+json`. +Servers MUST use the standard problem types defined in +{{I-D.httpauth-payment}}: `malformed-credential`, +`invalid-challenge`, and `verification-failed`. The +`detail` field SHOULD contain a human-readable +description of the specific failure (e.g., "Transaction +not found on Mirror Node", "Attribution memo mismatch", +"Transaction ID already consumed"). + +All error responses MUST include a fresh challenge in +`WWW-Authenticate`. + +Example error response body: + +~~~json +{ + "type": "https://paymentauth.org/problems/verification-failed", + "title": "Attribution Memo Mismatch", + "status": 402, + "detail": "Memo challenge nonce does not match" +} +~~~ + +# Security Considerations + +## Transport Security + +All communication MUST use TLS 1.2 or higher. Hedera +credentials MUST only be transmitted over HTTPS +connections. + +## Replay Protection Considerations + +Servers MUST track consumed transaction IDs and reject +any transaction ID that has already been accepted. The +check-and-consume operation MUST be atomic to prevent +race conditions where concurrent requests present the +same transaction ID. + +The Attribution memo's NONCE field (derived from the +challenge ID) provides cryptographic challenge binding: +even if an attacker obtains a valid transaction ID, +they cannot construct a valid credential without the +matching challenge. However, the consumed-set check +remains essential because a single transaction could +theoretically match multiple challenges with identical +terms. + +## Attribution Memo Security + +The Attribution memo provides challenge binding but is +not a cryptographic signature over the challenge +parameters. It binds the transaction to a specific +challenge ID and server realm via keccak256 +fingerprints, which provides collision resistance +(~2^56 for the 7-byte nonce, ~2^80 for the 10-byte +server and client fingerprints). + +An attacker would need to find a challenge ID whose +keccak256 prefix collides with the target nonce to +forge a memo. At 7 bytes (56 bits), this requires +approximately 2^56 hash operations, which is +computationally infeasible for real-time attacks. + +## Client-Side Verification + +Clients MUST verify the challenge before signing: + +1. `amount` is reasonable for the service. +2. `currency` matches the expected token ID. +3. `recipient` is the expected party. +4. `splits`, if present, contain expected recipients + and amounts -- malicious servers could add splits + to redirect funds. +5. The `chainId` matches the client's configured + network. + +Malicious servers could request excessive amounts, +direct payments to unexpected recipients, or add +hidden splits. + +## Mirror Node Trust + +The server relies on the Hedera Mirror Node REST API +to provide accurate transaction data for on-chain +verification. A compromised Mirror Node could return +fabricated transaction data, causing the server to +accept payments that were never made. Servers SHOULD +use trusted Mirror Node providers or run their own +Mirror Node instance. + +## Front-running (Push Mode) + +In push mode, the client broadcasts the transaction +before presenting the credential, making it visible +on the Hedera network. A party monitoring the network +could attempt to present the same transaction ID to +the server. The challenge binding (the credential +echoes the challenge `id`, which is HMAC-verified by +the server) and the Attribution memo (which binds the +transaction to a specific challenge nonce) mitigate +this: only the party that received the challenge can +construct a valid credential with a matching memo. + +Unlike the Solana method's push mode, Hedera's +Attribution memo provides stronger on-chain challenge +binding. The memo's NONCE field cryptographically ties +the transaction to a specific challenge instance, +preventing a single transaction from satisfying +multiple challenges even if they have identical terms. + +## Transaction Payload Security (Pull Mode) + +In pull mode, the server receives raw transaction bytes +from the client. A malicious client could craft a +transaction that performs unexpected operations. + +Servers MUST verify that the deserialized transaction: +- Contains only the expected token transfer entries. +- Has a valid Attribution memo bound to the current + challenge. +- Does not include unexpected operations beyond the + token transfer. + +## Fee Delegation (Future) {#fee-delegation} + +Hedera natively supports fee delegation via the +`feePayerAccountId` field on transactions. This allows +a third party (e.g., the server) to pay the transaction +fee on behalf of the client. + +This specification does not define fee delegation +semantics in this version. A future revision MAY add +`feePayer` and `feePayerAccountId` fields to +`methodDetails`, following a pattern similar to the +Solana method's fee sponsorship mechanism. When +implemented, fee delegation would pair naturally with +pull mode (`type="transaction"`), where the server +can add its fee payer signature before broadcasting. + +# IANA Considerations + +## Payment Method Registration + +This document requests registration of the following +entry in the "HTTP Payment Methods" registry +established by {{I-D.httpauth-payment}}: + +| Method Identifier | Description | Reference | +|-------------------|-------------|-----------| +| `hedera` | Hedera Token Service (HTS) token transfer | This document | + +## Payment Intent Registration + +This document requests registration of the following +entry in the "HTTP Payment Intents" registry +established by {{I-D.httpauth-payment}}: + +| Intent | Applicable Methods | Description | Reference | +|--------|-------------------|-------------|-----------| +| `charge` | `hedera` | One-time HTS token transfer | This document | + +--- back + +# Examples + +The following examples illustrate the complete HTTP +exchange for each flow. Base64url values are shown with +their decoded JSON below. + +## USDC Charge (Push Mode) + +A 1 USDC charge for weather API access on mainnet. + +**1. Challenge (402 response):** + +~~~http +HTTP/1.1 402 Payment Required +WWW-Authenticate: Payment + id="kM9xPqWvT2nJrHsY4aDfEb", + realm="api.example.com", + method="hedera", + intent="charge", + request="", + expires="2026-03-15T12:05:00Z" +Cache-Control: no-store +~~~ + +Decoded `request`: + +~~~json +{ + "amount": "1000000", + "currency": "0.0.456858", + "recipient": "0.0.12345", + "description": "Weather API access", + "methodDetails": { + "chainId": 295 + } +} +~~~ + +**2. Credential (retry with transaction ID):** + +~~~http +GET /weather HTTP/1.1 +Host: api.example.com +Authorization: Payment +~~~ + +Decoded credential: + +~~~json +{ + "challenge": { + "id": "kM9xPqWvT2nJrHsY4aDfEb", + "realm": "api.example.com", + "method": "hedera", + "intent": "charge", + "request": "", + "expires": "2026-03-15T12:05:00Z" + }, + "payload": { + "type": "hash", + "transactionId": + "0.0.12345@1681234567.123456789" + } +} +~~~ + +**3. Response (with receipt):** + +~~~http +HTTP/1.1 200 OK +Payment-Receipt: +Content-Type: application/json + +{"temperature": 72, "condition": "sunny"} +~~~ + +Decoded receipt: + +~~~json +{ + "method": "hedera", + "reference": + "0.0.12345@1681234567.123456789", + "status": "success", + "timestamp": "2026-03-15T12:04:58Z" +} +~~~ + +## Pull Mode (type="transaction") + +The client signs and serializes the transaction; the +server deserializes, verifies, and executes it. + +Decoded credential: + +~~~json +{ + "challenge": { + "id": "kM9xPqWvT2nJrHsY4aDfEb", + "realm": "api.example.com", + "method": "hedera", + "intent": "charge", + "request": "", + "expires": "2026-03-15T12:05:00Z" + }, + "payload": { + "type": "transaction", + "transaction": "CgMA...base64-encoded..." + } +} +~~~ + +## Payment Splits + +A marketplace charge of 1.05 USDC where 0.05 USDC goes +to the platform as a fee. + +Decoded `request`: + +~~~json +{ + "amount": "1050000", + "currency": "0.0.456858", + "recipient": "0.0.12345", + "description": "Marketplace purchase", + "splits": [ + { + "recipient": "0.0.67890", + "amount": "50000" + } + ], + "methodDetails": { + "chainId": 295 + } +} +~~~ + +The client builds a `TransferTransaction` with three +token transfer entries: +- Debit 1,050,000 from the payer (`0.0.PAYER`) +- Credit 1,000,000 to the seller (`0.0.12345`) +- Credit 50,000 to the platform (`0.0.67890`) + +All three entries are atomic within a single +transaction, leveraging Hedera's native multi-party +transfer support. + +# Acknowledgements + +The author thanks the Tempo team for the MPP attribution +memo design and the mppx ecosystem architecture that +this specification builds upon. diff --git a/specs/methods/hedera/draft-hedera-session-00.md b/specs/methods/hedera/draft-hedera-session-00.md new file mode 100644 index 00000000..a6a29301 --- /dev/null +++ b/specs/methods/hedera/draft-hedera-session-00.md @@ -0,0 +1,2382 @@ +--- +title: > + Hedera Session Intent for HTTP Payment Authentication +abbrev: Hedera Session +docname: draft-hedera-session-00 +version: 00 +category: info +ipr: trust200902 +submissiontype: independent +consensus: false + +author: + - name: Tom Rowbotham + ins: T. Rowbotham + email: tom@xeno.money + +normative: + RFC2119: + RFC3339: + RFC4648: + RFC8174: + RFC8259: + RFC9110: + RFC9111: + 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: + RFC8610: + EIP-712: + title: "Typed structured data hashing and signing" + target: https://eips.ethereum.org/EIPS/eip-712 + author: + - name: Remco Bloemen + date: 2017-09 + SSE: + title: "Server-Sent Events" + target: > + https://html.spec.whatwg.org/multipage/server-sent-events.html + author: + - org: WHATWG + HEDERA-DOCS: + title: "Hedera Documentation" + target: https://docs.hedera.com + author: + - org: Hedera + date: 2026 + HIP-218: + title: > + HIP-218: Smart Contract Verification + target: > + https://hips.hedera.com/hip/hip-218 + author: + - org: Hedera + date: 2022 + CIRCLE-USDC-HEDERA: + title: "Circle USDC on Hedera" + target: > + https://www.circle.com/multi-chain-usdc/hedera + author: + - org: Circle + date: 2026 +--- + +--- abstract + +This document defines the "session" intent for the +"hedera" payment method in the Payment HTTP Authentication +Scheme. It specifies unidirectional streaming payment +channels for incremental, voucher-based payments suitable +for low-cost metered services on the Hedera network. + +--- middle + +# Introduction + +This document is published as Informational but contains +normative requirements using BCP 14 keywords {{RFC2119}} +{{RFC8174}} to ensure interoperability between +implementations. Payment method specifications that +reference this document inherit these requirements. + +The `session` intent establishes a unidirectional streaming +payment channel using on-chain escrow and off-chain +{{EIP-712}} vouchers. This enables high-frequency, low-cost +payments by batching many off-chain voucher signatures into +periodic on-chain settlements. + +Unlike the `charge` intent which requires the full payment +amount upfront, the `session` intent allows clients to pay +incrementally as they consume services, paying exactly for +resources received. + +The escrow contract (HederaStreamChannel.sol) is deployed +on Hedera's EVM layer and uses standard ERC-20 token +transfers. Hedera Token Service (HTS) tokens are exposed +as ERC-20 via {{HIP-218}}, enabling payment channels with +native HTS tokens such as Circle USDC +{{CIRCLE-USDC-HEDERA}}. + +## Use Case: LLM Token Streaming + +Consider an LLM inference API that charges per output +token: + +1. Client requests a streaming completion (SSE response) +2. Server returns 402 with a `session` challenge +3. Client opens a payment channel on-chain, depositing + funds into the HederaStreamChannel escrow +4. Server begins streaming response +5. As response streams, or over incremental requests, + client signs vouchers with increasing amounts +6. Server settles periodically or at stream completion + +The client pays exactly for tokens received, with no +worst-case reservation. + +## Session Flow + +The following diagram illustrates the Hedera session flow: + +~~~ + Client Server Hedera EVM + | | | + | (1) GET /resource | | + |----------------------> | | + | | | + | (2) 402 Payment | | + | Required | | + | intent="session" | | + | (includes | | + | challengeId) | | + |<---------------------- | | + | | | + | (3) approve() + | | + | open() on-chain | | + |---------------------------------------> | + | | | + | (4) GET /resource | | + | Authorization: | | + | Payment | | + | action="open" | | + | (channelId, | | + | txHash, voucher) | | + |----------------------> | | + | | | + | | (5) verify | + | | on-chain state | + | |----------------> | + | | | + | (6) 200 OK + Receipt | | + | (streaming | | + | response) | | + |<---------------------- | | + | | | + | (7) HEAD /resource | | + | action="voucher" | | + | (top-up, same | | + | URI) | | + |----------------------> | | + | | | + | (8) 200 OK + Receipt | | + |<---------------------- | | + | | | + | (9) GET /resource | | + | action="voucher" | | + | (incremental | | + | request) | | + |----------------------> | | + | | | + | (10) 200 OK + Receipt | | + | (additional | | + | response) | | + |<---------------------- | | + | | | + | (11) GET /resource | | + | action="close" | | + |----------------------> | | + | | (12) close() | + | |----------------> | + | | | + | (13) 200 OK + Receipt | | + | (includes | | + | txHash) | | + |<---------------------- | | + | | | +~~~ + +Unlike Tempo session where the client sends a signed +transaction for the server to broadcast, in the Hedera +session the client broadcasts the `open` (and `topUp`) +transactions directly via Hashio JSON-RPC and presents +the transaction hash to the server. The server verifies +the on-chain state. + +Voucher updates and close requests are submitted to the +**same resource URI** that requires payment. This allows +sessions to work on any endpoint without dedicated +payment control plane routes. Servers SHOULD support +voucher updates via any HTTP method; clients MAY use +`HEAD` for pure voucher top-ups when no response body +is needed. + +## Concurrency Model {#concurrency} + +A channel supports one active session at a time. The +cumulative voucher semantics ensure correctness -- each +voucher advances a single monotonic counter. The channel +is the unit of concurrency; no additional session locking +is required. + +When a client sends a new streaming request on a channel +that already has an active session, servers SHOULD +terminate the previous session and start a new one. +Voucher updates MAY arrive on separate HTTP connections +(including HTTP/2 streams) and MUST be processed +atomically with respect to balance updates. + +Servers MUST ensure that voucher acceptance and balance +deduction are serialized per channel to prevent race +conditions. + +# Requirements Language + +{::boilerplate bcp14-tagged} + +# Terminology + +Streaming Payment Channel +: A unidirectional off-chain payment mechanism where the + payer deposits funds into an escrow contract and signs + cumulative vouchers authorizing increasing payment + amounts. + +Voucher +: An {{EIP-712}} signed message authorizing a cumulative + payment amount for a specific channel. Vouchers are + monotonically increasing in amount. + +Channel +: A payment relationship between a payer and payee, + identified by a unique `channelId`. The channel holds + deposited funds and tracks cumulative settlements. + +Settlement +: The on-chain ERC-20 transfer that converts off-chain + voucher authorizations into actual token movement. HTS + tokens are transferred via standard ERC-20 interfaces + exposed through {{HIP-218}}. + +Authorized Signer +: An address delegated to sign vouchers on behalf of the + payer. Defaults to the payer if not specified. + +Base Units +: The smallest indivisible unit of an HTS token. For + example, Circle USDC on Hedera uses 6 decimal places; + one million base units equals 1.00 USDC. + +Hashio JSON-RPC +: Hedera's EVM-compatible JSON-RPC relay that enables + standard Ethereum tooling (e.g., viem, ethers.js) to + interact with smart contracts deployed on Hedera's EVM + layer. + +# Encoding Conventions {#encoding} + +This section defines normative encoding rules for +interoperability. + +## Hexadecimal Values + +All byte arrays (addresses, hashes, signatures, +channelId) use: + +- Lowercase hexadecimal encoding +- `0x` prefix +- No padding or truncation + +| Type | Length | Example | +|------|--------|---------| +| address | 42 chars (0x + 40 hex) | `0x742d...f8fe00` | +| bytes32 | 66 chars (0x + 64 hex) | `0x6d0f...8e9f` | +| signature | 130-132 chars | 65-byte r||s||v | + +Implementations MUST use lowercase hex. Implementations +SHOULD accept mixed-case input but normalize to lowercase +before comparison. + +Note: Hedera "long-zero" EVM addresses (e.g., +`0x0000000000000000000000000000000000001549` for HTS token +0.0.5449) are valid 20-byte addresses and MUST be handled +correctly. Implementations MUST use case-insensitive +comparison for all address fields. + +## Numeric Values + +Integer values (amounts, timestamps) are encoded as +decimal strings in JSON to avoid precision loss with +large numbers: + +| Field | Encoding | Example | +|-------|----------|---------| +| `cumulativeAmount` | Decimal string | `"250000"` | +| `requestedAt` | Decimal string | `"1736165100"` | +| `chainId` | JSON number | `296` | + +The `chainId` uses JSON number encoding as values are +small enough to avoid precision issues. + +## Timestamp Format + +HTTP headers and receipt fields use {{RFC3339}} formatted +timestamps: `2026-04-12T12:05:00Z`. Timestamps in +EIP-712 signed data use Unix seconds as decimal strings. + +# Channel Escrow Contract + +Streaming payment channels require an on-chain escrow +contract that holds user deposits and enforces +voucher-based withdrawals. On Hedera, this contract is +deployed on the EVM layer and interacts with HTS tokens +via their ERC-20 interface {{HIP-218}}. + +## Channel State {#channel-state} + +Each channel is identified by a unique `channelId` and +stores: + +| Field | Type | Description | +|-------|------|-------------| +| `payer` | address | User who deposited funds | +| `payee` | address | Server authorized to withdraw | +| `token` | address | ERC-20 token address (HTS via HIP-218) | +| `authorizedSigner` | address | Authorized signer (0 = payer) | +| `deposit` | uint128 | Total amount deposited | +| `settled` | uint128 | Cumulative amount withdrawn by payee | +| `closeRequestedAt` | uint64 | Timestamp when close was requested (0 if not) | +| `finalized` | bool | Whether channel is closed | + +The `channelId` MUST be computed deterministically using +the escrow contract's `computeChannelId()` function: + +~~~ +channelId = keccak256(abi.encode( + payer, + payee, + token, + salt, + authorizedSigner, + address(this), + block.chainid +)) +~~~ + +Note: The `channelId` includes `address(this)` (the +escrow contract address) and `block.chainid`, explicitly +binding the channel to a specific contract deployment and +chain. Clients MUST use the contract's +`computeChannelId()` function or equivalent logic to +ensure interoperability. + +## Channel Lifecycle + +Channels have no expiry -- they remain open until +explicitly closed. + +~~~ ++-------------------------------------------------+ +| CHANNEL OPEN | +| Client approves ERC-20 + calls open() | +| on HederaStreamChannel via Hashio JSON-RPC | ++-------------------------------------------------+ + | + v ++-------------------------------------------------+ +| SESSION PAYMENTS | +| Client signs EIP-712 vouchers off-chain | +| Server may periodically settle() on-chain | ++-------------------------------------------------+ + | + +-----------+-----------+ + v v ++---------------------+ +-----------------------+ +| COOPERATIVE CLOSE | | FORCED CLOSE | +| Server calls | | 1. Client calls | +| close() with | | requestClose() | +| final voucher | | 2. Wait 15 min grace | +| | | 3. Client calls | +| | | withdraw() | ++---------------------+ +-----------------------+ + | | + +-----------+-----------+ + v ++-------------------------------------------------+ +| CHANNEL CLOSED | +| Funds distributed, channel finalized | ++-------------------------------------------------+ +~~~ + +## Contract Functions + +Compliant escrow contracts MUST implement the following +functions. The signatures shown are the reference +HederaStreamChannel.sol implementation. + +### open + +Opens a new channel with escrowed funds. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `payee` | address | Server's withdrawal address | +| `token` | address | ERC-20 token contract address | +| `deposit` | uint128 | Amount to deposit in base units | +| `salt` | bytes32 | Random value for channelId | +| `authorizedSigner` | address | Delegated signer; `0x0` = payer | + +Returns the computed `channelId`. + +~~~solidity +function open( + address payee, + address token, + uint128 deposit, + bytes32 salt, + address authorizedSigner +) external returns (bytes32 channelId); +~~~ + +The client MUST approve the escrow contract to spend +`deposit` tokens before calling `open()`. On Hedera, +HTS token approvals via the ERC-20 interface require +higher gas limits (approximately 1,000,000 gas) due to +the HTS precompile overhead. + +### settle + +Server withdraws funds using a signed voucher without +closing the channel. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `channelId` | bytes32 | Channel identifier | +| `cumulativeAmount` | uint128 | Cumulative total authorized | +| `signature` | bytes | EIP-712 signature | + +The contract computes +`delta = cumulativeAmount - channel.settled` and +transfers `delta` tokens to the payee. + +~~~solidity +function settle( + bytes32 channelId, + uint128 cumulativeAmount, + bytes calldata signature +) external; +~~~ + +### topUp + +User adds more funds to an existing channel. If a close +request is pending (`closeRequestedAt != 0`), calling +`topUp()` MUST cancel it by resetting +`closeRequestedAt` to zero and emitting a +`CloseRequestCancelled` event. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `channelId` | bytes32 | Existing channel identifier | +| `additionalDeposit` | uint256 | Additional amount in base units | + +~~~solidity +function topUp( + bytes32 channelId, + uint256 additionalDeposit +) external; +~~~ + +Note: The `additionalDeposit` parameter is `uint256` +(not `uint128`) in HederaStreamChannel.sol; the contract +checks for overflow internally. + +### close + +Server closes the channel, settling any outstanding +voucher and refunding the remainder to the payer. Only +callable by the payee. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `channelId` | bytes32 | Channel to close | +| `cumulativeAmount` | uint128 | Final cumulative amount | +| `signature` | bytes | EIP-712 signature | + +Transfers `cumulativeAmount - channel.settled` to payee, +refunds `channel.deposit - cumulativeAmount` to payer, +and marks channel finalized. + +~~~solidity +function close( + bytes32 channelId, + uint128 cumulativeAmount, + bytes calldata signature +) external; +~~~ + +### requestClose + +User requests channel closure, starting a grace period +of at least 15 minutes. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `channelId` | bytes32 | Channel to request closure for | + +Sets `channel.closeRequestedAt` to current block +timestamp. The grace period allows the payee time to +submit any outstanding vouchers before forced closure. + +~~~solidity +function requestClose( + bytes32 channelId +) external; +~~~ + +### withdraw + +User withdraws remaining funds after the grace period +expires. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `channelId` | bytes32 | Channel to withdraw from | + +Requires `block.timestamp >= channel.closeRequestedAt + +CLOSE_GRACE_PERIOD`. Refunds all remaining deposit to +payer and marks channel finalized. + +~~~solidity +function withdraw(bytes32 channelId) external; +~~~ + +### associateSelf {#associate-self} + +Associates the escrow contract with an HTS token so it +can receive transfers. This is a Hedera-specific +function with no Tempo equivalent. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `token` | address | HTS token to associate | + +~~~solidity +function associateSelf( + address token +) external returns (int256 responseCode); +~~~ + +This function calls the HTS precompile at address +`0x167` to perform token association. Anyone can call +it. The escrow contract MUST be associated with the +payment token before channels using that token can be +opened. + +## Access Control + +The escrow contract MUST enforce the following access +control: + +| Function | Caller | Description | +|----------|--------|-------------| +| `open` | Anyone | Creates channel; caller = payer | +| `settle` | Payee only | Withdraws with voucher | +| `topUp` | Payer only | Adds funds | +| `close` | Payee only | Closes with final voucher | +| `requestClose` | Payer only | Initiates forced close | +| `withdraw` | Payer only | Withdraws after grace | +| `associateSelf` | Anyone | HTS token association | + +## Signature Verification + +The escrow contract MUST perform the following signature +verification for all functions that accept voucher +signatures (`settle`, `close`): + +1. **Canonical signatures**: The contract MUST reject + ECDSA signatures with non-canonical (high-s) values. + Signatures MUST have + `s <= secp256k1_order / 2` where the half-order is + `0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E73` + `57A4501DDFE92F46681B20A0`. + See {{signature-malleability}} for rationale. + +2. **Authorized signer verification**: The contract MUST + recover the signer address from the EIP-712 signature + and verify it matches the expected signer: + - If `channel.authorizedSigner` is non-zero, the + recovered signer MUST equal + `channel.authorizedSigner` + - Otherwise, the recovered signer MUST equal + `channel.payer` + +3. **Domain binding**: The contract MUST use its own + address as the `verifyingContract` in the EIP-712 + domain separator, ensuring vouchers cannot be + replayed across different escrow deployments. + +Failure to enforce these requirements on-chain would +allow attackers to bypass server-side validation by +submitting transactions directly to the contract. + +# Request Schema + +The `request` parameter in the `WWW-Authenticate` +challenge contains a base64url-encoded JSON object. + +## Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `amount` | string | REQUIRED | Price per unit in base units | +| `unitType` | string | OPTIONAL | Unit being priced (e.g., `"llm_token"`) | +| `suggestedDeposit` | string | OPTIONAL | Suggested deposit in base units | +| `currency` | string | REQUIRED | ERC-20 token address (HTS via HIP-218) | +| `recipient` | string | REQUIRED | Payee address (server's withdrawal address) | + +For the `session` intent, `amount` specifies the price +per unit of service in base units (e.g., 6 decimals for +USDC), not a total charge. When `unitType` is present, +clients can use it together with `amount` to estimate +costs before streaming begins. The total cost depends on +consumption: `total = amount * units_consumed`. + +The optional `suggestedDeposit` indicates the server's +recommended channel deposit for typical usage. Clients +MAY deposit less (if they expect limited usage) or more +(for extended sessions). The minimum viable deposit is +implementation-defined but SHOULD be at least `amount` +to cover one unit of service. + +Challenge expiry is specified via the `expires` +auth-param in the `WWW-Authenticate` header per +{{I-D.httpauth-payment}}, using {{RFC3339}} timestamp +format. Unlike the `charge` intent, the session request +JSON does not include an `expires` field -- expiry is +conveyed solely via the HTTP header. + +## Method Details + +As of version 00, session-specific request fields are +placed in `methodDetails`. A future high-level "session" +intent definition may promote common fields to the core +schema. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `methodDetails.escrowContract` | string | REQUIRED | Escrow contract address | +| `methodDetails.channelId` | string | OPTIONAL | Channel ID if resuming | +| `methodDetails.minVoucherDelta` | string | OPTIONAL | Minimum voucher increment | +| `methodDetails.chainId` | number | OPTIONAL | Hedera chain ID (default: 295) | + +Note: Unlike the Tempo session spec, there is no +`feePayer` field in this version. Hedera supports native +fee delegation via `feePayerAccountId` but this is +deferred to a future revision (see {{fee-delegation}}). + +Channel reuse is OPTIONAL. Servers MAY include +`channelId` to suggest resuming an existing channel: + +- **New channel** (no `channelId`): Client generates a + random salt locally, computes `channelId` using the + formula in {{channel-state}}, opens the channel + on-chain, and returns the `channelId` in the + credential. +- **Existing channel** (`channelId` provided): Client + MUST verify + `channel.deposit - channel.settled >= amount` before + resuming. If insufficient, client SHOULD either call + `topUp()` with the difference or open a new channel. + +Servers MAY cache +`(payer address, payee address, token) -> channelId` +mappings to suggest channel reuse, reducing on-chain +transactions. + +**Example (new channel):** + +~~~json +{ + "amount": "25", + "unitType": "llm_token", + "suggestedDeposit": "10000000", + "currency": "0x000000000000000000000000000000000006f89a", + "recipient": "0x742d35cc6634c0532925a3b844bc9e7595f8fe00", + "methodDetails": { + "escrowContract": + "0x401b6dc30221823361E4876f5C502e37249D84C3", + "chainId": 295 + } +} +~~~ + +This requests a price of 0.000025 USDC per LLM token, +with a suggested deposit of 10.00 USDC (10000000 base +units). The `currency` is Circle USDC on Hedera mainnet +(HTS token 0.0.456858, exposed as ERC-20 via HIP-218). + +**Example (existing channel):** + +~~~json +{ + "amount": "25", + "unitType": "llm_token", + "currency": "0x000000000000000000000000000000000006f89a", + "recipient": "0x742d35cc6634c0532925a3b844bc9e7595f8fe00", + "methodDetails": { + "escrowContract": + "0x401b6dc30221823361E4876f5C502e37249D84C3", + "channelId": + "0x6d0f4fdf1f2f6a1f6c1b0fbd6a7d5c2c0a8d3d7b" + "1f6a9c1b3e2d4a5b6c7d8e9f", + "chainId": 295 + } +} +~~~ + +For existing channels, `suggestedDeposit` is omitted +since the channel already has funds. The `channelId` +tells the client to resume this channel. + +# Fee Payment {#fee-payment} + +## Client-Paid Fees (Default) + +In this version, the client pays all transaction fees for +channel operations (`open`, `topUp`, ERC-20 `approve`). +The client broadcasts these transactions directly via +Hashio JSON-RPC. + +Hedera's EVM layer has predictable, low transaction fees. +However, HTS precompile interactions require higher gas +limits than standard ERC-20 operations: + +| Operation | Recommended Gas Limit | +|-----------|-----------------------| +| ERC-20 `approve` (HTS) | 1,000,000 | +| `open` | 1,500,000 | +| `topUp` | 1,500,000 | +| `settle` | 1,500,000 | +| `close` | 1,500,000 | + +Clients MUST set gas limits appropriate for HTS +precompile operations. The default gas estimates from +Hashio JSON-RPC may be insufficient. + +## Server-Initiated Operations + +The `settle` and `close` contract functions are +server-originated on-chain transactions. The server pays +transaction fees for these operations: + +- **Voucher updates** (`action="voucher"`) are off-chain + and incur no transaction fees. +- **Settlement** (`settle()`) and channel **close** + (`close()`) are initiated by the server using the + highest valid voucher. The server covers the fees. +- Servers MAY recover settlement costs through pricing + or other business logic. + +## Fee Delegation (Future) {#fee-delegation} + +Hedera natively supports fee delegation via the +`feePayerAccountId` field on transactions. A future +revision of this specification MAY add `feePayer` support +to `methodDetails`, enabling the server to pay +transaction fees on behalf of the client. This would pair +naturally with a pull-mode open flow where the client +signs the transaction and the server broadcasts it. + +# Credential Schema + +The credential in the `Authorization` header contains a +base64url-encoded JSON object per +{{I-D.httpauth-payment}}. + +## Credential Structure + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `challenge` | object | REQUIRED | Echo of the challenge parameters | +| `payload` | object | REQUIRED | Session-specific payload | + +Implementations MUST ignore unknown fields in credential +payloads, request objects, and receipts to allow +forward-compatible extensions. + +## Credential Lifecycle + +A streaming payment session progresses through distinct +phases, each corresponding to a payload action: + +1. **Open**: Client deposits funds on-chain (broadcasting + the transaction directly) and presents the `open` + action with the transaction hash. The server verifies + the on-chain deposit and validates the initial + voucher. + +2. **Streaming**: Client submits `voucher` actions with + increasing cumulative amounts as service is consumed. + The server may periodically settle vouchers on-chain. + +3. **Close**: Client sends the `close` action with the + final voucher. The server settles on-chain and + returns a receipt. + +Each action carries action-specific fields directly in +the `payload` object, with the `action` field +discriminating between phases. + +## Payload Actions + +The `payload` object uses an `action` discriminator with +action-specific fields at the same level: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `action` | string | REQUIRED | One of the actions below | + +| Action | Description | +|--------|-------------| +| `open` | Confirms channel is open on-chain | +| `topUp` | Adds funds to an existing channel | +| `voucher` | Submits updated cumulative voucher | +| `close` | Requests server to close channel | + +### Open Payload {#open-payload} + +The `open` action confirms an on-chain channel opening +and begins the streaming session. Unlike the Tempo +session where the client sends a signed transaction for +server broadcast, the Hedera client broadcasts the +`open()` transaction itself and presents the transaction +hash. + +**Payload fields (in addition to `action`):** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `channelId` | string | REQUIRED | Channel identifier (hex bytes32) | +| `txHash` | string | REQUIRED | Transaction hash from open() | +| `cumulativeAmount` | string | REQUIRED | Initial authorized amount (see below) | +| `signature` | string | REQUIRED | EIP-712 voucher signature | + +The client broadcasts the `open()` transaction via +Hashio JSON-RPC, waits for the transaction receipt, and +presents the `txHash` for server verification. + +The server uses the `txHash` to verify the on-chain +channel state: deposit amount, payee, token, and that +the channel is not finalized. + +The initial voucher (`cumulativeAmount` and `signature`) +proves the client controls the signing key and +establishes the voucher chain. Implementations MAY set +`cumulativeAmount` to zero or to the first request's +cost; both are valid starting points for the +cumulative voucher sequence. + +**Example:** + +~~~json +{ + "challenge": { + "id": "kM9xPqWvT2nJrHsY4aDfEb", + "realm": "api.llm-service.com", + "method": "hedera", + "intent": "session", + "request": "eyJ...", + "expires": "2026-04-12T12:05:00Z" + }, + "payload": { + "action": "open", + "channelId": + "0x6d0f4fdf1f2f6a1f6c1b0fbd6a7d5c2c" + "0a8d3d7b1f6a9c1b3e2d4a5b6c7d8e9f", + "txHash": + "0x1a2b3c4d5e6f7890abcdef12345678" + "90abcdef1234567890abcdef12345678", + "cumulativeAmount": "2500", + "signature": "0xabcdef1234567890..." + } +} +~~~ + +Note: `cumulativeAmount` here is `"2500"` (the cost +of the first request at 25 base units per token for +100 tokens). Implementations MAY also send `"0"`. + +The `challenge` object MUST echo the challenge +parameters from the server's `WWW-Authenticate` header +per {{I-D.httpauth-payment}}. + +### TopUp Payload {#topup-payload} + +The `topUp` action adds funds to an existing channel +during a streaming session. The client broadcasts the +`topUp()` transaction itself and presents the +transaction hash. + +Clients MUST include a `challenge` object in the Payment +credential for `topUp` actions. To obtain a challenge +for a top-up outside an active streaming response, +clients MAY send a `HEAD` request to the protected +resource; the server returns 402 with a +`WWW-Authenticate` challenge (no body). Servers MUST +reject `topUp` actions referencing an unknown or expired +challenge `id` with problem type `challenge-not-found`. + +**Payload fields (in addition to `action`):** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `channelId` | string | REQUIRED | Channel ID | +| `txHash` | string | REQUIRED | Transaction hash from topUp() | +| `additionalDeposit` | string | REQUIRED | Additional amount in base units | + +**Example:** + +~~~json +{ + "challenge": { + "id": "kM9xPqWvT2nJrHsY4aDfEb", + "realm": "api.llm-service.com", + "method": "hedera", + "intent": "session", + "request": "eyJ...", + "expires": "2026-04-12T12:05:00Z" + }, + "payload": { + "action": "topUp", + "channelId": + "0x6d0f4fdf1f2f6a1f6c1b0fbd6a7d5c2c" + "0a8d3d7b1f6a9c1b3e2d4a5b6c7d8e9f", + "txHash": + "0x2b3c4d5e6f7890abcdef1234567890ab" + "cdef1234567890abcdef1234567890ab", + "additionalDeposit": "5000000" + } +} +~~~ + +Upon successful verification, the server updates the +channel's available balance. The new deposit is +immediately available for voucher authorization. + +### Voucher Payload {#voucher-payload} + +The `voucher` action submits an updated cumulative +voucher during streaming. + +**Payload fields (in addition to `action`):** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `channelId` | string | REQUIRED | Channel identifier | +| `cumulativeAmount` | string | REQUIRED | Cumulative amount authorized | +| `signature` | string | REQUIRED | EIP-712 voucher signature | + +**Example:** + +~~~json +{ + "challenge": { + "id": "kM9xPqWvT2nJrHsY4aDfEb", + "realm": "api.llm-service.com", + "method": "hedera", + "intent": "session", + "request": "eyJ...", + "expires": "2026-04-12T12:05:00Z" + }, + "payload": { + "action": "voucher", + "channelId": + "0x6d0f4fdf1f2f6a1f6c1b0fbd6a7d5c2c" + "0a8d3d7b1f6a9c1b3e2d4a5b6c7d8e9f", + "cumulativeAmount": "250000", + "signature": "0xabcdef1234567890..." + } +} +~~~ + +### Close Payload {#close-payload} + +The `close` action requests the server to close the +channel and settle on-chain. + +**Payload fields (in addition to `action`):** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `channelId` | string | REQUIRED | Channel identifier | +| `cumulativeAmount` | string | REQUIRED | Final cumulative amount | +| `signature` | string | REQUIRED | EIP-712 voucher signature | + +The server uses the voucher fields to call +`close(channelId, cumulativeAmount, signature)` on-chain +via Hashio JSON-RPC. + +**Example:** + +~~~json +{ + "challenge": { + "id": "kM9xPqWvT2nJrHsY4aDfEb", + "realm": "api.llm-service.com", + "method": "hedera", + "intent": "session", + "request": "eyJ...", + "expires": "2026-04-12T12:05:00Z" + }, + "payload": { + "action": "close", + "channelId": + "0x6d0f4fdf1f2f6a1f6c1b0fbd6a7d5c2c" + "0a8d3d7b1f6a9c1b3e2d4a5b6c7d8e9f", + "cumulativeAmount": "500000", + "signature": "0xabcdef1234567890..." + } +} +~~~ + +# Voucher Signing Format {#voucher-format} + +Vouchers use typed structured data signing compatible +with {{EIP-712}}. This section normatively defines the +signing procedure; {{EIP-712}} is referenced for +background only. + +## Wire Format + +Voucher fields are placed directly in the credential +`payload` object (alongside `action`) rather than in a +nested structure: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `channelId` | string | REQUIRED | Channel ID (hex bytes32) | +| `cumulativeAmount` | string | REQUIRED | Cumulative amount (decimal) | +| `signature` | string | REQUIRED | EIP-712 signature (hex) | + +The EIP-712 domain and type definitions are fixed by +this specification. Implementations MUST reconstruct the +full typed data structure using the domain parameters +from the challenge (`chainId`, `escrowContract`) before +signature verification. + +## Type Definitions + +The `types` object MUST contain exactly: + +~~~json +{ + "Voucher": [ + { "name": "channelId", "type": "bytes32" }, + { + "name": "cumulativeAmount", + "type": "uint128" + } + ] +} +~~~ + +Note: The `EIP712Domain` type is implicit per EIP-712 +and SHOULD NOT be included in the `types` object. + +## Domain Separator + +The `domain` object MUST contain: + +| Field | Type | Value | +|-------|------|-------| +| `name` | string | `"Hedera Stream Channel"` | +| `version` | string | `"1"` | +| `chainId` | number | Hedera chain ID (295 or 296) | +| `verifyingContract` | string | Escrow contract address | + +## Signing Procedure + +To sign a voucher, implementations MUST: + +1. Construct the domain separator hash: + + ~~~ + domainSeparator = keccak256( + abi.encode( + keccak256( + "EIP712Domain(string name," + "string version," + "uint256 chainId," + "address verifyingContract)" + ), + keccak256(bytes(name)), + keccak256(bytes(version)), + chainId, + verifyingContract + ) + ) + ~~~ + +2. Construct the struct hash: + + ~~~ + structHash = keccak256( + abi.encode( + keccak256( + "Voucher(bytes32 channelId," + "uint128 cumulativeAmount)" + ), + channelId, + cumulativeAmount + ) + ) + ~~~ + +3. Compute the signing hash: + + ~~~ + signingHash = keccak256( + "\x19\x01" || domainSeparator || structHash + ) + ~~~ + +4. Sign with ECDSA using secp256k1 curve + +5. Encode signature as 65-byte `r || s || v` where + `v` is 27 or 28 + +## Cumulative Semantics + +Vouchers specify cumulative totals, not incremental +deltas: + +- Voucher #1: `cumulativeAmount = 100` (100 total) +- Voucher #2: `cumulativeAmount = 250` (250 total) +- Voucher #3: `cumulativeAmount = 400` (400 total) + +When settling, the contract computes: +`delta = cumulativeAmount - settled` + +# Verification Procedure + +## Open Verification + +On `action="open"`, servers MUST: + +1. **Transaction verification**: Wait for the + transaction receipt using `txHash`. Verify the + transaction succeeded (receipt status = `success`). + +2. **On-chain state verification**: Query the escrow + contract's `getChannel(channelId)` to verify: + - Channel exists (deposit > 0) + - `channel.payee` matches server's address + - `channel.token` matches `request.currency` + - `channel.deposit - channel.settled >= amount` + - Channel is not finalized + - `channel.closeRequestedAt == 0` + +3. **Voucher verification**: If `cumulativeAmount` and + `signature` are provided, verify the initial voucher: + - Recover signer from EIP-712 signature + - Verify canonical low-s values + - Signer matches `channel.payer` or + `channel.authorizedSigner` + - `voucher.channelId` matches + - `voucher.cumulativeAmount >= channel.settled` + +4. **Initialize** server-side channel state + +## TopUp Verification + +On `action="topUp"`, servers MUST: + +1. **Transaction verification**: Wait for the + transaction receipt using `txHash`. Verify the + transaction succeeded. + +2. **On-chain state verification**: Query the escrow + contract to verify: + - `channel.deposit` increased + - Channel is not finalized + +3. **Update** server-side accounting: increase + available balance by `additionalDeposit`. + +## Voucher Verification {#voucher-verification} + +On `action="voucher"`, servers MUST: + +1. Verify voucher signature using EIP-712 recovery +2. Verify canonical low-s values (see + {{signature-malleability}}) +3. Recover signer and MUST verify it matches expected + signer from on-chain state +4. Verify `channel.closeRequestedAt == 0`. Servers + MUST reject vouchers on channels with a pending + forced close. +5. Verify monotonicity: + - `cumulativeAmount > highestVoucherAmount` + - `(cumulativeAmount - highestVoucherAmount) >= + minVoucherDelta` +6. Verify `cumulativeAmount <= channel.deposit` +7. Persist voucher to durable storage before providing + service +8. Update `highestVoucherAmount = cumulativeAmount` + +Servers MUST derive the expected signer from on-chain +channel state by querying the escrow contract. The +expected signer is `channel.authorizedSigner` if +non-zero, otherwise `channel.payer`. Servers MUST NOT +trust signer claims in HTTP payloads. + +Servers MUST persist the highest voucher to durable +storage before providing the corresponding service. +Failure to do so may result in unrecoverable fund loss +if the server crashes after service delivery. + +## Idempotency {#idempotency} + +Servers MUST treat voucher submissions idempotently: + +- Resubmitting a voucher with the same + `cumulativeAmount` as the highest accepted MUST + return 200 OK with the current `highestAmount` +- Submitting a voucher with lower `cumulativeAmount` + than highest accepted MUST return 200 OK with the + current `highestAmount` (not an error) +- Clients MAY safely retry voucher submissions after + network failures + +## Rejection and Error Responses {#error-responses} + +If verification fails, servers MUST return an +appropriate HTTP status code with a Problem Details +{{RFC9457}} response body: + +| Status | When | +|--------|------| +| 400 Bad Request | Malformed payload or missing fields | +| 402 Payment Required | Invalid signature or signer mismatch | +| 410 Gone | Channel finalized or not found | + +Error responses use Problem Details format: + +~~~json +{ + "type": + "https://paymentauth.org/problems/" + "session/invalid-signature", + "title": "Invalid Signature", + "status": 402, + "detail": "Voucher signature could not " + "be verified", + "channelId": "0x6d0f4fdf..." +} +~~~ + +Problem type URIs: + +| Type URI | Description | +|----------|-------------| +| `.../session/invalid-signature` | Voucher signature invalid | +| `.../session/signer-mismatch` | Signer not authorized | +| `.../session/amount-exceeds-deposit` | Exceeds deposit | +| `.../session/delta-too-small` | Below minVoucherDelta | +| `.../session/channel-not-found` | No such channel | +| `.../session/channel-finalized` | Channel closed | +| `.../session/challenge-not-found` | Challenge expired | +| `.../session/insufficient-balance` | Insufficient balance | + +All problem type URIs above are prefixed with +`https://paymentauth.org/problems`. + +For errors on the Payment Auth protected resource, +servers MUST return 402 with a fresh +`WWW-Authenticate: Payment` challenge per +{{I-D.httpauth-payment}}. + +# Server-Side Accounting {#server-accounting} + +Servers MUST maintain per-session accounting state to +track authorized funds versus consumed service. + +## Accounting State + +For each active session identified by +`(challengeId, channelId)`, servers MUST maintain: + +| Field | Type | Description | +|-------|------|-------------| +| `acceptedCumulative` | uint128 | Highest valid voucher accepted | +| `spent` | uint128 | Cumulative amount charged | +| `settledOnChain` | uint128 | Last settled amount (informational) | + +The `available` balance is computed as: + +~~~ +available = acceptedCumulative - spent +~~~ + +## Per-Request Processing + +For each request carrying a Payment credential with +`intent="session"`, servers MUST follow this procedure: + +1. **Voucher acceptance** (if provided in credential): + - Verify signature and monotonicity per + {{voucher-verification}} + - If valid, persist the new `acceptedCumulative` + - If invalid, return 402 with a fresh challenge + +2. **Balance check**: + - Compute `available = acceptedCumulative - spent` + - Compute `cost` for this request + - If `available < cost`: return 402 with Problem + Details including + `requiredTopUp = cost - available` + +3. **Charge and deliver** (if `available >= cost`): + - **MUST persist** `spent := spent + cost` BEFORE + or atomically with delivering service + - Deliver the response (or next chunk for streaming) + - Return `Payment-Receipt` header + +4. **Receipt generation**: + - Include balance state in receipt + +## Crash Safety + +To prevent fund loss from server crashes: + +- Servers MUST persist `spent` increments BEFORE + delivering corresponding service. + +- Servers MUST persist `acceptedCumulative` BEFORE + relying on the new balance for service authorization. + +- Implementations SHOULD use transactional storage or + write-ahead logging to ensure atomicity. + +## Request Idempotency {#request-idempotency} + +To prevent double-charging on retries: + +- Clients SHOULD include an `Idempotency-Key` header +- Servers SHOULD track `(challengeId, idempotencyKey)` + pairs and return cached responses for duplicates +- Servers MUST NOT increment `spent` for duplicate + idempotent requests + +**Example idempotent request:** + +~~~http +GET /api/chat HTTP/1.1 +Host: api.example.com +Idempotency-Key: req_a1b2c3d4e5f6 +Authorization: Payment eyJ... +~~~ + +## Cost Calculation {#cost-calculation} + +The `cost` for a request depends on the pricing model +declared in the challenge. Servers MUST support at least +one of: + +- **Fixed cost**: A predetermined amount per request +- **Usage-based fees**: Pricing proportional to resource + consumption + +For streaming responses (SSE, chunked), servers SHOULD: + +1. Reserve an estimated cost before starting delivery +2. Adjust `spent` as actual consumption is measured +3. Pause delivery if `available` is exhausted + +## Insufficient Balance During Streaming + +When a streaming response exhausts `available` balance: + +1. Server MUST stop delivering additional content +2. Server MAY hold the connection open awaiting a + voucher top-up +3. Server MAY close the response; client retries with + a higher voucher +4. If client submits a voucher update, server SHOULD + resume delivery if the connection is still open + +For SSE responses, servers MUST emit a +`payment-need-voucher` event when balance is exhausted: + +~~~ +event: payment-need-voucher +data: {"channelId":"0x6d0f4fdf...", + "requiredCumulative":"250025", + "acceptedCumulative":"250000", + "deposit":"500000"} +~~~ + +The `payment-need-voucher` event data MUST be a JSON +object containing: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `acceptedCumulative` | string | REQUIRED | Current highest voucher | +| `channelId` | string | REQUIRED | Channel identifier | +| `deposit` | string | REQUIRED | Current on-chain deposit | +| `requiredCumulative` | string | REQUIRED | Minimum next voucher | + +The `deposit` field allows the client to determine the +correct recovery action. When `requiredCumulative` +exceeds `deposit`, the client MUST submit +`action="topUp"` before sending a new voucher. When +`requiredCumulative` is within `deposit`, the client +can submit `action="voucher"` directly. + +After emitting `payment-need-voucher`, the server MUST +pause delivery until a valid voucher is accepted. +Servers SHOULD close the stream if no voucher is +received within a reasonable timeout (e.g., 60 seconds). + +Servers SHOULD NOT deliver service beyond the authorized +balance under any circumstances. See +{{dos-mitigation}} for rate limiting requirements. + +# Settlement Procedure + +## Settlement Timing + +Servers MAY settle at any time using their own criteria: + +- Periodically (e.g., every N seconds or M base units) +- When `action="close"` is received +- When accumulated unsettled amount exceeds a threshold +- Based on gas cost optimization + +Settlement frequency is an implementation detail left to +servers. + +The `close()` function settles any delta between the +provided `cumulativeAmount` and `channel.settled`. If +the server has already settled the highest voucher via +`settle()`, calling `close()` with the same amount will +only refund the payer the remaining deposit. + +## Cooperative Close + +When the client sends `action="close"`: + +1. Server receives the signed close request +2. Server calls + `close(channelId, cumulativeAmount, signature)` + on-chain via Hashio JSON-RPC +3. Contract settles any delta and refunds remainder +4. Server returns receipt with transaction hash + +Servers SHOULD close promptly when clients request -- +the economic incentive is to claim earned funds +immediately. + +The server MUST set a gas limit of at least 1,500,000 +for the `close()` call due to HTS precompile overhead. + +## Forced Close + +If the server does not respond to close requests: + +1. Client calls `requestClose(channelId)` on-chain +2. 15-minute grace period begins +3. Server can still `settle()` or `close()` during + the grace period +4. After grace period, client calls + `withdraw(channelId)` +5. Client receives all remaining (unsettled) funds + +Clients SHOULD wait at least 16 minutes after +`requestClose()` before calling `withdraw()` to account +for block time variance. + +## Sequential Sessions + +A single channel supports sequential sessions. Each +session uses the same cumulative voucher counter. When a +new session begins on a channel, the previous session's +spending state is irrelevant -- the channel's +`highestVoucherAmount` is the source of truth for the +next voucher's minimum value. + +## Voucher Submission Transport + +Vouchers are submitted via HTTP requests to the **same +resource URI** that requires payment. There is no +separate session endpoint. Clients SHOULD use HTTP/2 +multiplexing or maintain separate connections for voucher +updates and content streaming when topping up during a +long-lived response. + +For voucher-only updates (no response body needed), +clients MAY use `HEAD` requests. Servers SHOULD support +voucher credentials on `HEAD` requests for resources +that require session payment. + +## Receipt Generation {#receipt-generation} + +Servers MUST return a `Payment-Receipt` header on +**every successful paid request**. For streaming +responses (SSE, chunked transfer), servers MUST include +the receipt in the initial response headers AND in the +final message of the stream. + +For SSE responses, the final receipt SHOULD be delivered +as an event: + +~~~ +event: payment-receipt +data: {"method":"hedera","intent":"session", + "status":"success",...} +~~~ + +The session intent extends the receipt with balance +tracking: + +| Field | Type | Description | +|-------|------|-------------| +| `method` | string | `"hedera"` | +| `intent` | string | `"session"` | +| `status` | string | `"success"` | +| `timestamp` | string | {{RFC3339}} response time | +| `challengeId` | string | Challenge identifier | +| `channelId` | string | Channel identifier | +| `acceptedCumulative` | string | Highest voucher accepted | +| `spent` | string | Total charged so far | +| `reference` | string | Transaction or channel ref | +| `units` | number | OPTIONAL: Units consumed | +| `txHash` | string | OPTIONAL: Transaction hash | + +The `reference` field satisfies the core MPP receipt +`reference` requirement. It is set to `txHash` when a +transaction was broadcast (open, close), otherwise +set to `channelId` (voucher). + +The `txHash` field is OPTIONAL because not every +response involves an on-chain settlement -- voucher +updates are off-chain. + +**Example receipt (per-request with metering):** + +~~~json +{ + "method": "hedera", + "intent": "session", + "status": "success", + "timestamp": "2026-04-12T12:08:30Z", + "challengeId": "c_8d0e3b5a9f2c1d4e", + "channelId": + "0x6d0f4fdf1f2f6a1f6c1b0fbd6a7d5c2c" + "0a8d3d7b1f6a9c1b3e2d4a5b6c7d8e9f", + "reference": + "0x6d0f4fdf1f2f6a1f6c1b0fbd6a7d5c2c" + "0a8d3d7b1f6a9c1b3e2d4a5b6c7d8e9f", + "acceptedCumulative": "250000", + "spent": "237500", + "units": 500 +} +~~~ + +**Example receipt (on close with settlement):** + +~~~json +{ + "method": "hedera", + "intent": "session", + "status": "success", + "timestamp": "2026-04-12T12:10:00Z", + "challengeId": "c_8d0e3b5a9f2c1d4e", + "channelId": + "0x6d0f4fdf1f2f6a1f6c1b0fbd6a7d5c2c" + "0a8d3d7b1f6a9c1b3e2d4a5b6c7d8e9f", + "reference": + "0x1a2b3c4d5e6f7890abcdef12345678" + "90abcdef1234567890abcdef12345678", + "acceptedCumulative": "250000", + "spent": "250000", + "txHash": + "0x1a2b3c4d5e6f7890abcdef12345678" + "90abcdef1234567890abcdef12345678" +} +~~~ + +# Security Considerations + +## Replay Prevention + +Vouchers are bound to a specific channel and contract +via: + +- `channelId` in the voucher message +- `verifyingContract` in EIP-712 domain +- `chainId` in EIP-712 domain +- Cumulative amount semantics (can only increase) + +The escrow contract enforces: + +- `cumulativeAmount > channel.settled` (monotonicity) +- `cumulativeAmount <= channel.deposit` (cap) + +## No Voucher Expiry + +Vouchers have no `validUntil` field. This simplifies +the protocol: + +- Channels have no expiry -- closed explicitly +- Vouchers remain valid until the channel closes +- The close grace period protects against clients + disappearing + +**Operational guidance:** Servers SHOULD settle and close +channels inactive for extended periods (e.g., 30+ days). + +## Denial of Service {#dos-mitigation} + +To mitigate voucher flooding, servers MUST implement +rate limiting: + +- Servers SHOULD limit voucher submissions to 10 per + second per session +- Servers MAY implement additional IP-based rate + limiting +- Servers MUST enforce `minVoucherDelta` when present +- Servers SHOULD skip expensive signature verification + for vouchers that do not advance state (return 200 OK + with current `highestAmount` per {{idempotency}}) + +Servers SHOULD perform format validation before +expensive ECDSA signature recovery. + +To mitigate channel griefing via dust deposits: + +- Servers SHOULD enforce a minimum deposit (e.g., + 1 USDC equivalent) +- Servers MAY reject channels below this threshold + +## Front-Running Protection + +Cumulative voucher semantics prevent front-running +attacks. If a client submits a higher voucher while a +server's `settle()` transaction is pending, the +settlement will still succeed -- it merely leaves +additional unsettled funds. + +## Cross-Contract Replay Prevention + +The EIP-712 domain includes `verifyingContract`, binding +vouchers to a specific escrow contract address. + +## Escrow Guarantees + +The escrow contract provides: + +- **Payer protection**: Funds only withdrawn with valid + voucher signature +- **Payee protection**: Deposited funds guaranteed +- **Forced close**: 15-minute grace period protects + both parties + +## Authorized Signer + +The `authorizedSigner` field allows delegation of +signing authority to a hot wallet while the main wallet +only deposits funds. + +**Security considerations for delegated signing:** + +- Clients using `authorizedSigner` delegation SHOULD + limit channel deposits to acceptable loss amounts +- Clients SHOULD rotate authorized signers periodically +- Clients SHOULD NOT reuse signers across multiple + high-value channels +- If the authorized signer key is compromised, an + attacker can drain the entire channel deposit + +## Signature Malleability {#signature-malleability} + +ECDSA signatures are malleable: for any valid signature +`(r, s)`, the signature `(r, -s mod n)` is also valid. +To prevent signature substitution attacks, +implementations MUST enforce canonical signatures: + +- Signatures MUST use "low-s" values with + `s <= secp256k1_order / 2` +- The secp256k1 half-order is: + `0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E73` + `57A4501DDFE92F46681B20A0` +- Servers MUST reject signatures with `s` values + exceeding this threshold + +Accepted signature formats: + +- 65-byte `(r, s, v)` format where `v` is 27 or 28 +- 64-byte EIP-2098 compact format + +The HederaStreamChannel.sol contract uses Solady's +`SignatureCheckerLib` which enforces these requirements. + +## Voucher Context and User Experience + +The voucher message contains only `channelId` and +`cumulativeAmount`. Wallet implementations are +encouraged to: + +- Decode `channelId` components when the derivation + formula is known +- Display the payee address and token in human-readable + form +- Show cumulative vs. incremental amounts clearly + +## Session Attribution + +Vouchers are bound to channels but not to specific HTTP +sessions or API requests. The `challengeId` provides +correlation across requests. Servers MUST implement +challenge-to-voucher mapping for: + +- Dispute resolution +- Usage accounting +- Audit trails + +## Cross-Session Replay Prevention {#session-binding} + +Vouchers use cumulative amount semantics: each voucher +authorizes a total payment up to `cumulativeAmount`, and +the on-chain contract enforces strict monotonicity +(`cumulativeAmount > channel.settled`). A voucher can +only ever advance the channel state forward. + +A separate `sessionHash` binding is unnecessary: + +- **Cross-session replay is harmless**: If a voucher + from session A is presented in session B, it can only + authorize funds up to the amount already committed. +- **Cross-resource replay**: Vouchers authorize + cumulative payment on a channel, not access to + specific resources. Resource authorization is handled + at the application layer via `challengeId`. + +## Chain Finality {#chain-finality} + +Hedera achieves asynchronous Byzantine Fault Tolerant +(aBFT) consensus with deterministic finality in +approximately 3-5 seconds {{HEDERA-DOCS}}. Once a +transaction reaches consensus, it cannot be reversed. + +For high-value channels, servers SHOULD: + +1. Re-verify channel state periodically during + long-lived sessions +2. Monitor for `ChannelClosed` or `CloseRequested` + events +3. Cease service delivery if the channel becomes + invalid + +## HTS Token Association + +Before an escrow contract can receive HTS tokens, it +MUST be associated with the token via the +`associateSelf()` function (see {{associate-self}}). +This is a one-time operation per token. If the escrow +contract is not associated with the payment token, +`open()` will fail with a transfer error. + +Servers deploying escrow contracts MUST ensure the +contract is associated with all supported payment tokens +before advertising session challenges. + +## Gas Limit Considerations + +Hedera's EVM layer routes HTS token operations through +the HTS precompile at address `0x167`. This precompile +has higher gas requirements than standard ERC-20 +operations. Implementations MUST set appropriate gas +limits (see {{fee-payment}}) to avoid transaction +failures. + +The default gas estimates from Hashio JSON-RPC +(`eth_estimateGas`) may underestimate gas for HTS +precompile calls. Implementations SHOULD use hardcoded +minimum gas limits for escrow operations. + +## Grace Period Rationale + +The 15-minute forced close grace period balances +competing concerns: + +- **Payer protection**: Ensures timely fund recovery +- **Payee protection**: Provides time to detect close + requests and submit final settlements +- **Block time variance**: Allows margin for timestamp + variations + +# IANA Considerations + +## Payment Intent Registration + +This document registers the following payment intent in +the "HTTP Payment Intents" registry established by +{{I-D.httpauth-payment}}: + +| Intent | Methods | Description | Reference | +|--------|---------|-------------|-----------| +| `session` | `hedera` | Streaming payment channel | This document | + +Contact: Tom Rowbotham () + +## Problem Type Registration + +This document registers the following problem types in +the "HTTP Problem Types" registry established by +{{RFC9457}}: + +| Type URI | Title | Status | Ref | +|----------|-------|--------|-----| +| `.../session/invalid-signature` | Invalid Signature | 402 | This document | +| `.../session/signer-mismatch` | Signer Mismatch | 402 | This document | +| `.../session/amount-exceeds-deposit` | Amount Exceeds Deposit | 402 | This document | +| `.../session/delta-too-small` | Delta Too Small | 402 | This document | +| `.../session/channel-not-found` | Channel Not Found | 410 | This document | +| `.../session/channel-finalized` | Channel Finalized | 410 | This document | +| `.../session/challenge-not-found` | Challenge Not Found | 402 | This document | +| `.../session/insufficient-balance` | Insufficient Balance | 402 | This document | + +All type URIs above are prefixed with +`https://paymentauth.org/problems`. + +Each problem type is defined in {{error-responses}}. + +--- back + +# Example + +Note: In examples throughout this appendix, hex values +shown with `...` (e.g., `"0x6d0f4fdf..."`) are +abbreviated. Actual values MUST be full-length as +specified in {{encoding}}. + +## Challenge + +~~~http +HTTP/1.1 402 Payment Required +WWW-Authenticate: Payment + id="kM9xPqWvT2nJrHsY4aDfEb", + realm="api.llm-service.com", + method="hedera", + intent="session", + expires="2026-04-12T12:05:00Z", + request="" +~~~ + +The `request` decodes to: + +~~~json +{ + "amount": "25", + "unitType": "llm_token", + "suggestedDeposit": "10000000", + "currency": + "0x000000000000000000000000000000000006f89a", + "recipient": + "0x742d35cc6634c0532925a3b844bc9e7595f8fe00", + "methodDetails": { + "escrowContract": + "0x401b6dc30221823361E4876f5C502e37249D84C3", + "chainId": 295 + } +} +~~~ + +Note: Challenge expiry is in the header `expires` +auth-param, not in the request JSON. The client +generates a random salt locally for new channels. + +This requests 0.000025 USDC per LLM token, with a +suggested deposit of 10.00 USDC (10000000 base units). + +## Open Credential + +The client first broadcasts `approve()` and `open()` to +Hedera EVM via Hashio JSON-RPC, then retries the **same +resource URI** with the open credential: + +~~~http +GET /api/chat HTTP/1.1 +Host: api.llm-service.com +Authorization: Payment +~~~ + +The credential payload for an open action: + +~~~json +{ + "challenge": { + "id": "kM9xPqWvT2nJrHsY4aDfEb", + "realm": "api.llm-service.com", + "method": "hedera", + "intent": "session", + "request": "eyJ...", + "expires": "2026-04-12T12:05:00Z" + }, + "payload": { + "action": "open", + "channelId": "0x6d0f4fdf...", + "txHash": "0x1a2b3c4d...", + "cumulativeAmount": "2500", + "signature": "0xabcdef1234567890..." + } +} +~~~ + +## Voucher Top-Up (Same Resource URI) + +During streaming, clients submit updated vouchers to +the **same resource URI**. `HEAD` is recommended for +pure top-ups when no response body is needed: + +~~~http +HEAD /api/chat HTTP/1.1 +Host: api.llm-service.com +Authorization: Payment +~~~ + +Or with a regular request: + +~~~http +GET /api/chat HTTP/1.1 +Host: api.llm-service.com +Authorization: Payment +~~~ + +The credential payload for a voucher update: + +~~~json +{ + "challenge": { + "id": "kM9xPqWvT2nJrHsY4aDfEb", + "realm": "api.llm-service.com", + "method": "hedera", + "intent": "session", + "request": "eyJ...", + "expires": "2026-04-12T12:05:00Z" + }, + "payload": { + "action": "voucher", + "channelId": "0x6d0f4fdf...", + "cumulativeAmount": "250000", + "signature": "0x1234567890abcdef..." + } +} +~~~ + +## Close Request (Same Resource URI) + +~~~http +GET /api/chat HTTP/1.1 +Host: api.llm-service.com +Authorization: Payment +~~~ + +The credential payload for a close request: + +~~~json +{ + "challenge": { + "id": "kM9xPqWvT2nJrHsY4aDfEb", + "realm": "api.llm-service.com", + "method": "hedera", + "intent": "session", + "request": "eyJ...", + "expires": "2026-04-12T12:05:00Z" + }, + "payload": { + "action": "close", + "channelId": "0x6d0f4fdf...", + "cumulativeAmount": "500000", + "signature": "0xabcdef1234567890..." + } +} +~~~ + +The voucher fields contain the final cumulative amount +for on-chain settlement. + +# Reference Implementation + +This appendix provides reference implementation details. +These are informative and not normative. + +## Solidity Interface + +~~~solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IHederaStreamChannel { + struct Channel { + bool finalized; + uint64 closeRequestedAt; + address payer; + address payee; + address token; + address authorizedSigner; + uint128 deposit; + uint128 settled; + } + + function CLOSE_GRACE_PERIOD() + external view returns (uint64); + function VOUCHER_TYPEHASH() + external view returns (bytes32); + + function open( + address payee, + address token, + uint128 deposit, + bytes32 salt, + address authorizedSigner + ) external returns (bytes32 channelId); + + function settle( + bytes32 channelId, + uint128 cumulativeAmount, + bytes calldata signature + ) external; + + function topUp( + bytes32 channelId, + uint256 additionalDeposit + ) external; + + function close( + bytes32 channelId, + uint128 cumulativeAmount, + bytes calldata signature + ) external; + + function requestClose( + bytes32 channelId + ) external; + + function withdraw( + bytes32 channelId + ) external; + + function getChannel( + bytes32 channelId + ) external view returns (Channel memory); + + function getChannelsBatch( + bytes32[] calldata channelIds + ) external view returns (Channel[] memory); + + function computeChannelId( + address payer, + address payee, + address token, + bytes32 salt, + address authorizedSigner + ) external view returns (bytes32); + + function getVoucherDigest( + bytes32 channelId, + uint128 cumulativeAmount + ) external view returns (bytes32); + + function domainSeparator() + external view returns (bytes32); + + function associateSelf( + address token + ) external returns (int256 responseCode); + + event ChannelOpened( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + address token, + address authorizedSigner, + bytes32 salt, + uint256 deposit + ); + + event Settled( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint256 cumulativeAmount, + uint256 deltaPaid, + uint256 newSettled + ); + + event CloseRequested( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint256 closeGraceEnd + ); + + event CloseRequestCancelled( + bytes32 indexed channelId, + address indexed payer, + address indexed payee + ); + + event TopUp( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint256 additionalDeposit, + uint256 newDeposit + ); + + event ChannelClosed( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + uint256 settledToPayee, + uint256 refundedToPayer + ); + + event ChannelExpired( + bytes32 indexed channelId, + address indexed payer, + address indexed payee + ); + + error ChannelAlreadyExists(); + error ChannelNotFound(); + error ChannelFinalized(); + error InvalidSignature(); + error InvalidToken(); + error InvalidPayee(); + error AmountExceedsDeposit(); + error AmountNotIncreasing(); + error DepositOverflow(); + error ZeroDeposit(); + error NotPayer(); + error NotPayee(); + error TransferFailed(); + error CloseNotReady(); +} +~~~ + +## Deployed Contracts + +| Network | Chain ID | Contract Address | +|---------|----------|------------------| +| Hedera Testnet | 296 | `0x401b6dc30221823361E4876f5C502e37249D84C3` | +| Hedera Mainnet | 295 | `0x401b6dc30221823361E4876f5C502e37249D84C3` | + +Both deployments are fully verified on Sourcify. + +## Supported Tokens + +| Token | Network | HTS Token ID | EVM Address | +|-------|---------|-------------|-------------| +| USDC | Testnet | 0.0.5449 | `0x00...1549` | +| USDC | Mainnet | 0.0.456858 | `0x00...06f89a` | + +HTS tokens are exposed as ERC-20 via {{HIP-218}}. + +## Contract Source + +The reference implementation is available at: +`contracts/src/HederaStreamChannel.sol` in the +mppx-hedera repository. + +## TypeScript Client Library + +The `mppx-hedera` npm package provides client and server +implementations: + +- `mppx-hedera/client` -- `hederaSession()` client +- `mppx-hedera/server` -- `hedera.session()` server +- `mppx-hedera/server/sse` -- SSE transport for + metered streaming + +# Schema Definitions (JSON Schema) + +## Session Request Schema + +~~~json +{ + "$schema": + "https://json-schema.org/draft/2020-12/schema", + "$id": + "https://paymentauth.org/schemas/" + "hedera-session-request.json", + "title": "Hedera Session Request", + "type": "object", + "required": [ + "amount", "currency", + "recipient", "methodDetails" + ], + "properties": { + "amount": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "unitType": { + "type": "string" + }, + "suggestedDeposit": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "currency": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$" + }, + "recipient": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$" + }, + "methodDetails": { + "$ref": "#/$defs/methodDetails" + } + }, + "$defs": { + "methodDetails": { + "type": "object", + "required": ["escrowContract"], + "properties": { + "escrowContract": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$" + }, + "channelId": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{64}$" + }, + "minVoucherDelta": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "chainId": { + "type": "integer", + "enum": [295, 296] + } + } + } + } +} +~~~ + +## Session Payload Schema + +~~~json +{ + "$schema": + "https://json-schema.org/draft/2020-12/schema", + "$id": + "https://paymentauth.org/schemas/" + "hedera-session-payload.json", + "title": "Hedera Session Payload", + "type": "object", + "required": ["action"], + "properties": { + "action": { + "enum": [ + "open", "topUp", "voucher", "close" + ] + }, + "txHash": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{64}$" + }, + "channelId": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{64}$" + }, + "cumulativeAmount": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "signature": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{128,130}$" + }, + "additionalDeposit": { + "type": "string", + "pattern": "^[0-9]+$", + "description": + "Additional deposit amount in base units " + "(topUp action only)" + } + } +} +~~~ + +## Session Receipt Schema + +Servers MUST include `Payment-Receipt` only on +successful processing of a session action (2xx). + +~~~json +{ + "$schema": + "https://json-schema.org/draft/2020-12/schema", + "$id": + "https://paymentauth.org/schemas/" + "hedera-session-receipt.json", + "title": "Hedera Session Receipt", + "type": "object", + "required": [ + "method", "intent", "status", + "timestamp", "challengeId", + "channelId", "reference", + "acceptedCumulative", "spent" + ], + "properties": { + "method": { "const": "hedera" }, + "intent": { "const": "session" }, + "status": { "const": "success" }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "challengeId": { "type": "string" }, + "channelId": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{64}$" + }, + "reference": { + "type": "string", + "description": + "txHash when a tx was broadcast " + "(open, close); channelId otherwise" + }, + "acceptedCumulative": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "spent": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "units": { + "type": "integer" + }, + "txHash": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{64}$" + } + } +} +~~~ + +# Acknowledgements + +The author thanks the Tempo team for the MPP session +payment channel design and the mppx ecosystem +architecture that this specification builds upon. +HederaStreamChannel.sol is a port of Tempo's +TempoStreamChannel.sol adapted for Hedera's EVM layer +and HTS token ecosystem. From e4c16ad57eeeeaabdf3fdbbe0cf04bf558a40f56 Mon Sep 17 00:00:00 2001 From: Tom Rowbotham Date: Tue, 28 Apr 2026 21:50:58 +0100 Subject: [PATCH 2/5] fix: update contract address to 0x8Aaf...daE --- specs/methods/hedera/draft-hedera-session-00.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/specs/methods/hedera/draft-hedera-session-00.md b/specs/methods/hedera/draft-hedera-session-00.md index a6a29301..bf65305b 100644 --- a/specs/methods/hedera/draft-hedera-session-00.md +++ b/specs/methods/hedera/draft-hedera-session-00.md @@ -701,7 +701,7 @@ transactions. "recipient": "0x742d35cc6634c0532925a3b844bc9e7595f8fe00", "methodDetails": { "escrowContract": - "0x401b6dc30221823361E4876f5C502e37249D84C3", + "0x8Aaf6690C2a6397d595F97E224fC19759De6fdaE", "chainId": 295 } } @@ -722,7 +722,7 @@ units). The `currency` is Circle USDC on Hedera mainnet "recipient": "0x742d35cc6634c0532925a3b844bc9e7595f8fe00", "methodDetails": { "escrowContract": - "0x401b6dc30221823361E4876f5C502e37249D84C3", + "0x8Aaf6690C2a6397d595F97E224fC19759De6fdaE", "channelId": "0x6d0f4fdf1f2f6a1f6c1b0fbd6a7d5c2c0a8d3d7b" "1f6a9c1b3e2d4a5b6c7d8e9f", @@ -1884,7 +1884,7 @@ The `request` decodes to: "0x742d35cc6634c0532925a3b844bc9e7595f8fe00", "methodDetails": { "escrowContract": - "0x401b6dc30221823361E4876f5C502e37249D84C3", + "0x8Aaf6690C2a6397d595F97E224fC19759De6fdaE", "chainId": 295 } } @@ -2171,8 +2171,8 @@ interface IHederaStreamChannel { | Network | Chain ID | Contract Address | |---------|----------|------------------| -| Hedera Testnet | 296 | `0x401b6dc30221823361E4876f5C502e37249D84C3` | -| Hedera Mainnet | 295 | `0x401b6dc30221823361E4876f5C502e37249D84C3` | +| Hedera Testnet | 296 | `0x8Aaf6690C2a6397d595F97E224fC19759De6fdaE` | +| Hedera Mainnet | 295 | `0x8Aaf6690C2a6397d595F97E224fC19759De6fdaE` | Both deployments are fully verified on Sourcify. From 382562cfe259b0c59f5e8409a88c910f2e4c9a79 Mon Sep 17 00:00:00 2001 From: Tom Rowbotham Date: Mon, 11 May 2026 10:00:22 +0100 Subject: [PATCH 3/5] fix: resolve duplicate anchor "encoding" in charge spec Renamed subsection "Encoding" to "Memo Encoding" with explicit anchor {#memo-encoding} to avoid collision with top-level "Encoding Conventions" section {#encoding}. Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/methods/hedera/draft-hedera-charge-00.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/methods/hedera/draft-hedera-charge-00.md b/specs/methods/hedera/draft-hedera-charge-00.md index 6a6c2d19..c903de11 100644 --- a/specs/methods/hedera/draft-hedera-charge-00.md +++ b/specs/methods/hedera/draft-hedera-charge-00.md @@ -346,7 +346,7 @@ NONCE (bytes 25-31) the `WWW-Authenticate` header. Binds the memo to a specific challenge instance, preventing replay. -## Encoding +## Memo Encoding {#memo-encoding} The 32-byte memo MUST be hex-encoded with a `0x` prefix and stored as the Hedera transaction memo via From 93a50150605760d76fd63c2cc5785b8d7e139649 Mon Sep 17 00:00:00 2001 From: Tom Rowbotham Date: Tue, 12 May 2026 13:25:29 +0100 Subject: [PATCH 4/5] refactor: session-only branch (charge is in separate PR #251) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../methods/hedera/draft-hedera-charge-00.md | 1343 ----------------- 1 file changed, 1343 deletions(-) delete mode 100644 specs/methods/hedera/draft-hedera-charge-00.md diff --git a/specs/methods/hedera/draft-hedera-charge-00.md b/specs/methods/hedera/draft-hedera-charge-00.md deleted file mode 100644 index c903de11..00000000 --- a/specs/methods/hedera/draft-hedera-charge-00.md +++ /dev/null @@ -1,1343 +0,0 @@ ---- -title: Hedera Charge Intent for HTTP Payment Authentication -abbrev: Hedera Charge -docname: draft-hedera-charge-00 -version: 00 -category: info -ipr: trust200902 -submissiontype: independent -consensus: false - -author: - - name: Tom Rowbotham - ins: T. Rowbotham - email: tom@xeno.money - -normative: - RFC2119: - RFC3339: - RFC4648: - RFC8174: - RFC8259: - RFC8785: - RFC9457: - I-D.payment-intent-charge: - title: > - 'charge' Intent for HTTP Payment Authentication - target: > - https://datatracker.ietf.org/doc/draft-payment-intent-charge/ - author: - - name: Jake Moxey - - name: Brendan Ryan - - name: Tom Meagher - date: 2026 - 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: - HEDERA-DOCS: - title: "Hedera Documentation" - target: https://docs.hedera.com - author: - - org: Hedera - date: 2026 - HIP-218: - title: "HIP-218: Smart Contract Verification" - target: > - https://hips.hedera.com/hip/hip-218 - author: - - org: Hedera - date: 2022 - HIP-376: - title: "HIP-376: Approve/Allowance API for Tokens" - target: > - https://hips.hedera.com/hip/hip-376 - author: - - org: Hedera - date: 2022 - MIRROR-NODE: - title: "Hedera Mirror Node REST API" - target: > - https://docs.hedera.com/hedera/sdks-and-apis/rest-api - author: - - org: Hedera - date: 2026 - CIRCLE-USDC-HEDERA: - title: "Circle USDC on Hedera" - target: > - https://www.circle.com/multi-chain-usdc/hedera - author: - - org: Circle - date: 2026 ---- - ---- abstract - -This document defines the "charge" intent for the "hedera" -payment method within the Payment HTTP Authentication Scheme -{{I-D.httpauth-payment}}. The client constructs and signs a -native Hedera Token Service (HTS) transfer; the server -verifies the payment via the Mirror Node REST API and -presents the transaction ID as proof of payment. - -Two credential types are supported: `type="hash"` (default), -where the client broadcasts the transaction itself and -presents the transaction ID for server verification, and -`type="transaction"` (pull mode), where the client signs and -serializes the transaction for the server to broadcast. - ---- 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 "charge" intent -for the "hedera" payment method. - -Hedera is a distributed ledger with asynchronous Byzantine -Fault Tolerant (aBFT) consensus, deterministic finality in -3-5 seconds, and fixed transaction fees {{HEDERA-DOCS}}. -This specification supports payments in Hedera Token Service -(HTS) tokens, including Circle USDC -{{CIRCLE-USDC-HEDERA}}, making it suitable for micropayment -use cases where fast confirmation and predictable costs are -important. - -Challenge binding and replay protection are achieved through -an Attribution memo embedded in the transaction's native -memo field (see {{attribution-memo}}). - -## Push Mode (Default) {#push-mode} - -The default flow, called "push mode", uses `type="hash"` -credentials. The client "pushes" the transaction to the -Hedera network itself and presents the confirmed -transaction ID: - -~~~ - Client Server Hedera Network - | | | - | (1) GET /resource | | - |-------------------> | | - | | | - | (2) 402 Payment | | - | Required | | - | (recipient, | | - | amount, memo) | | - |<------------------- | | - | | | - | (3) Build tx with | | - | Attribution | | - | memo, sign | | - | | | - | (4) Execute tx | | - |--------------------------------------> | - | (5) Receipt | | - |<-------------------------------------- | - | | | - | (6) Authorization: | | - | Payment | | - | | | - | (transaction ID)| | - |-------------------> | | - | | (7) Mirror Node | - | | GET /api/v1/ | - | | transactions/ | - | | {txId} | - | |-----------------> | - | | (8) Tx data | - | |<----------------- | - | | | - | (9) 200 OK +Receipt | | - |<------------------- | | - | | | -~~~ - -This flow is useful when the client has its own Hedera -account and operator key. The server verifies the payment -by querying the Mirror Node REST API {{MIRROR-NODE}}. - -## Pull Mode {#pull-mode} - -The pull mode flow uses `type="transaction"` credentials. -The client signs the transaction and the server "pulls" it -for broadcast to the Hedera network: - -~~~ - Client Server Hedera Network - | | | - | (1) GET /resource | | - |-------------------> | | - | | | - | (2) 402 Payment | | - | Required | | - | (recipient, | | - | amount) | | - |<------------------- | | - | | | - | (3) Build tx with | | - | Attribution | | - | memo, freeze, | | - | sign | | - | | | - | (4) Authorization: | | - | Payment | | - | | | - | (serialized tx) | | - |-------------------> | | - | | (5) Verify memo, | - | | execute tx | - | |-----------------> | - | | (6) Receipt | - | |<----------------- | - | | | - | (7) 200 OK +Receipt | | - |<------------------- | | - | | | -~~~ - -In this model the server controls transaction broadcast, -enabling server-side retry logic and future fee delegation -(see {{fee-delegation}}). - -## Relationship to the Charge Intent - -This document inherits the shared request semantics of the -"charge" intent from {{I-D.payment-intent-charge}}. It -defines only the Hedera-specific `methodDetails`, `payload`, -and verification procedures for the "hedera" payment method. - -# Requirements Language - -{::boilerplate bcp14-tagged} - -# Terminology - -Transaction ID -: A unique identifier for a Hedera transaction in the - format `shard.realm.num@seconds.nanoseconds` (e.g., - `0.0.12345@1681234567.123456789`). Composed of the - payer account ID and the transaction's valid-start - timestamp. - -Account ID -: A Hedera account identifier in the format - `shard.realm.num` (e.g., `0.0.12345`). The shard and - realm are typically `0.0` on the public Hedera network. - -Token ID -: A Hedera Token Service (HTS) token identifier in the - format `shard.realm.num` (e.g., `0.0.456858` for Circle - USDC on mainnet). Uniquely identifies a fungible or - non-fungible token on the Hedera network. - -Token Association -: A one-time operation that associates a Hedera account - with an HTS token, enabling the account to hold and - receive that token. Unlike Solana's Associated Token - Accounts, token association is a single on-chain - operation that does not create a separate account. - -Base Units -: The smallest transferable unit of an HTS token, - determined by the token's decimal precision. For - example, USDC uses 6 decimals, so 1 USDC = 1,000,000 - base units. - -Mirror Node -: A read-only node that archives Hedera network data - and exposes it via a REST API {{MIRROR-NODE}}. Used - by servers to verify transaction details after - consensus. - -Attribution Memo -: A 32-byte challenge-bound memo embedded in the - Hedera transaction's native memo field. Encodes the - MPP tag, version, server identity, optional client - identity, and a challenge-specific nonce. See - {{attribution-memo}} for the full byte layout. - -Push Mode -: The default settlement flow where the client - broadcasts the transaction itself and presents the - confirmed transaction ID (`type="hash"`). The client - "pushes" the transaction to the network directly. - -Pull Mode -: The alternative settlement flow where the client - signs and serializes the transaction and the server - broadcasts it (`type="transaction"`). The server - "pulls" the signed transaction from the credential. - -# Intent Identifier - -The intent identifier for this specification is "charge". -It MUST be lowercase. - -# Intent: "charge" - -The "charge" intent represents a one-time payment gating -access to a resource. The client builds and signs a Hedera -`TransferTransaction` with an Attribution memo, then either -broadcasts the transaction itself and sends the transaction -ID (`type="hash"`) or sends the serialized signed -transaction bytes to the server for broadcast -(`type="transaction"`). The server verifies the transfer -details and returns a receipt. - -# Attribution Memo {#attribution-memo} - -Every Hedera charge transaction MUST include an Attribution -memo in the transaction's native memo field. The memo -provides challenge binding (replay protection) and server -identity verification. - -## Byte Layout - -The Attribution memo is exactly 32 bytes, stored in the -Hedera transaction memo as a `0x`-prefixed hex string -(66 characters: `0x` + 64 hex digits = 66 bytes UTF-8). -This fits well within Hedera's 100-byte memo limit. - -~~~ -Offset Size Field ------- ---- ----------------------------------- -0..3 4 TAG = keccak256("mpp")[0..3] -4 1 VERSION = 0x01 -5..14 10 SERVER_ID = - keccak256(realm)[0..9] -15..24 10 CLIENT_ID = - keccak256(clientId)[0..9] - or zero bytes if anonymous -25..31 7 NONCE = - keccak256(challengeId)[0..6] -~~~ - -TAG (bytes 0-3) -: The first 4 bytes of `keccak256("mpp")`. Identifies - this memo as an MPP attribution memo. Implementations - MUST reject memos where these bytes do not match. - -VERSION (byte 4) -: Protocol version. MUST be `0x01` for this - specification. Implementations MUST reject memos - with an unrecognized version. - -SERVER_ID (bytes 5-14) -: The first 10 bytes of `keccak256(realm)`, where - `realm` is the challenge's `realm` auth-param. - Binds the memo to a specific server. Servers MUST - verify this fingerprint matches their own realm. - -CLIENT_ID (bytes 15-24) -: The first 10 bytes of `keccak256(clientId)`, where - `clientId` is an optional client identifier. If the - client is anonymous, all 10 bytes MUST be zero. - -NONCE (bytes 25-31) -: The first 7 bytes of `keccak256(challengeId)`, where - `challengeId` is the challenge `id` auth-param from - the `WWW-Authenticate` header. Binds the memo to a - specific challenge instance, preventing replay. - -## Memo Encoding {#memo-encoding} - -The 32-byte memo MUST be hex-encoded with a `0x` prefix -and stored as the Hedera transaction memo via -`setTransactionMemo()`. The resulting string is exactly -66 characters (`0x` + 64 hex digits) and 66 bytes -UTF-8, which is within Hedera's 100-byte memo limit. - -Example memo (hex): - -~~~ -0xef1ed71201a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7 - f8a9b0c1d2e3f4a5b6c7 -~~~ - -## Compatibility - -This byte layout is identical to the attribution memo -used by the Tempo payment method, ensuring compatibility -across the MPP ecosystem. The only difference is the -transport: Tempo embeds the memo in a smart contract -call (`transferWithMemo`), while Hedera uses the native -transaction memo field. - -# Encoding Conventions {#encoding} - -All JSON {{RFC8259}} objects carried in auth-params or HTTP -headers in this specification MUST be serialized using the -JSON Canonicalization Scheme (JCS) {{RFC8785}} before -encoding. JCS produces a deterministic byte sequence, which -is required for any digest or signature operations defined -by the base spec {{I-D.httpauth-payment}}. - -The resulting bytes MUST then be encoded using base64url -{{RFC4648}} Section 5 without padding characters (`=`). -Implementations MUST NOT append `=` padding when encoding, -and MUST accept input with or without padding when decoding. - -This encoding convention applies to: the `request` -auth-param in `WWW-Authenticate`, the credential token in -`Authorization`, and the receipt token in `Payment-Receipt`. - -# Request Schema - -## Shared Fields - -The `request` auth-param of the `WWW-Authenticate: Payment` -header contains a JCS-serialized, base64url-encoded JSON -object (see {{encoding}}). The following shared fields are -included in that object: - -amount -: REQUIRED. The payment amount in base units, encoded as - a decimal string. For HTS tokens, the amount is in the - token's smallest unit (e.g., for USDC with 6 decimals, - "1000000" represents 1 USDC). The value MUST be a - positive integer that fits in a 64-bit signed integer - (max 9,223,372,036,854,775,807), consistent with - Hedera's `int64` transfer amounts. - -currency -: REQUIRED. The HTS token ID string identifying the - payment asset (e.g., `"0.0.456858"` for Circle USDC - on mainnet). The token ID uniquely identifies the - token on the Hedera network and is used by the client - to construct the `TransferTransaction`. MUST be a - valid Hedera entity ID in the format - `shard.realm.num`. - -description -: OPTIONAL. A human-readable memo describing the - resource or service being paid for. MUST NOT exceed - 256 characters. - -recipient -: REQUIRED. The Hedera account ID of the account - receiving the payment (e.g., `"0.0.12345"`). MUST - be a valid Hedera account ID in the format - `shard.realm.num`. - -externalId -: OPTIONAL. Merchant's reference (e.g., order ID, - invoice number), per {{I-D.payment-intent-charge}}. - May be used for reconciliation or idempotency. MUST - NOT exceed 34 bytes (100-byte Hedera memo limit minus - the 66-byte Attribution memo). When the Attribution - memo is present, there is no remaining memo capacity - for an on-chain external ID; the `externalId` is - therefore carried only in the credential's challenge - echo and is not written on-chain. - -splits -: OPTIONAL. An array of at most 9 additional payment - splits. Each entry is a JSON object with the - following fields: - - - `recipient` (REQUIRED): Hedera account ID of the - split recipient (e.g., `"0.0.67890"`). - - `amount` (REQUIRED): Amount in the same base units - and token as the primary `amount`. - - When present, the client MUST include a token - transfer entry for each split in addition to the - primary transfer to `recipient`. All splits use the - same token as the primary payment (the `currency` - token ID). - - Hedera's `TransferTransaction` natively supports - atomic multi-party transfers (up to 10 token - transfer entries per transaction), making splits - straightforward: the client adds one debit from the - payer and one credit per recipient in a single - atomic transaction. - - The top-level `amount` is the total the client pays. - The sum of all split amounts MUST NOT exceed - `amount`. The primary `recipient` receives `amount` - minus the sum of all split amounts; this remainder - MUST be greater than zero. Servers MUST reject - challenges where splits consume the entire amount. - Servers MUST verify each split transfer on-chain - during credential verification. If the same - recipient appears more than once in `splits`, each - occurrence is a distinct payment leg and MUST be - verified separately; servers MUST NOT implicitly - aggregate such entries. - - This mechanism is a Hedera-specific extension to the - base `charge` intent. It can be used for platform - fees, revenue sharing, or referral commissions. - - Note: The `splits` field is at the top level of the - request object (alongside `amount`, `currency`, - `recipient`, etc.), not nested under - `methodDetails`. The mppx framework's schema - transform outputs `splits` at the top level. - -## Method Details - -The following fields are nested under `methodDetails` in -the request JSON: - -chainId -: OPTIONAL. The EIP-155 chain ID for the Hedera - network: 295 for mainnet, 296 for testnet. - Implementations SHOULD document their default - network. The reference implementation defaults to - testnet (296) for safety. Clients MUST reject - challenges whose `chainId` does not match their - configured network. - -## Client Configuration Fields - -The following fields are used during request -construction by the mppx framework's schema transform -but are NOT present in the serialized wire-format -challenge. They are consumed by `parseUnits()` to -convert human-readable amounts to base units before -the request is serialized. - -decimals -: OPTIONAL. The number of decimal places for the - token (0-18). Used by `parseUnits()` during - request construction to convert a human-readable - amount (e.g., "1.00") into base units (e.g., - "1000000"). This field is consumed by the schema - transform and does NOT appear in the serialized - challenge sent over the wire. Clients that - construct requests manually MUST provide `amount` - in base units directly and do not need this field. - -### HTS Token Example - -~~~json -{ - "amount": "1000000", - "currency": "0.0.456858", - "recipient": "0.0.12345", - "description": "Weather API access", - "methodDetails": { - "chainId": 295 - } -} -~~~ - -This requests a transfer of 1 USDC (1,000,000 base -units) on Hedera mainnet (chain ID 295). - -### Testnet Example - -~~~json -{ - "amount": "500000", - "currency": "0.0.5449", - "recipient": "0.0.67890", - "description": "Premium API call", - "methodDetails": { - "chainId": 296 - } -} -~~~ - -This requests a transfer of 0.50 USDC on Hedera -testnet (chain ID 296). Note that `decimals` is not -present in the wire format; it is only used during -request construction by the mppx schema transform. - -### Payment Splits Example - -~~~json -{ - "amount": "1050000", - "currency": "0.0.456858", - "recipient": "0.0.12345", - "description": "Marketplace purchase", - "splits": [ - { - "recipient": "0.0.67890", - "amount": "50000" - } - ], - "methodDetails": { - "chainId": 295 - } -} -~~~ - -This requests a total payment of 1.05 USDC. The platform -receives 0.05 USDC and the primary recipient (seller) -receives 1.00 USDC. - -# Credential Schema - -The `Authorization` header carries a single base64url- -encoded JSON token (no auth-params). The decoded object -contains the following top-level fields: - -challenge -: REQUIRED. An echo of the challenge auth-params from - the `WWW-Authenticate` header: `id`, `realm`, - `method`, `intent`, `request`, and (if present) - `expires`. This binds the credential to the exact - challenge that was issued. - -source -: OPTIONAL. A payer identifier string, as defined by - {{I-D.httpauth-payment}}. Hedera implementations MAY - use a DID in the format - `did:pkh:hedera:{network}:{accountId}`. - -payload -: REQUIRED. A JSON object containing the Hedera-specific - credential fields. The `type` field determines which - additional fields are present. Two payload types are - defined: `"hash"` (default) and `"transaction"` - (pull mode). - -## Hash Payload -- Push Mode {#hash-payload} - -In push mode (`type="hash"`), the client has already -broadcast the transaction to the Hedera network. The -`transactionId` field contains the Hedera transaction ID -for the server to verify via the Mirror Node. - -| Field | Type | Req | Description | -|-------|------|-----|-------------| -| `type` | string | Y | `"hash"` | -| `transactionId` | string | Y | Hedera transaction ID | - -The `transactionId` MUST be in the standard Hedera format -`shard.realm.num@seconds.nanoseconds` (e.g., -`"0.0.12345@1681234567.123456789"`). - -Example (decoded): - -~~~json -{ - "challenge": { - "id": "kM9xPqWvT2nJrHsY4aDfEb", - "realm": "api.example.com", - "method": "hedera", - "intent": "charge", - "request": "eyJ...", - "expires": "2026-03-15T12:05:00Z" - }, - "payload": { - "type": "hash", - "transactionId": - "0.0.12345@1681234567.123456789" - } -} -~~~ - -## Transaction Payload -- Pull Mode {#transaction-payload} - -In pull mode (`type="transaction"`), the client sends the -signed transaction bytes to the server for broadcast. The -`transaction` field contains the base64-encoded serialized -signed transaction. - -| Field | Type | Req | Description | -|-------|------|-----|-------------| -| `type` | string | Y | `"transaction"` | -| `transaction` | string | Y | Base64-encoded signed tx bytes | - -The transaction MUST be a valid Hedera transaction that -has been frozen and signed by the payer. The server -deserializes the transaction via `Transaction.fromBytes()`, -verifies the Attribution memo, and executes it. - -Example (decoded): - -~~~json -{ - "challenge": { - "id": "kM9xPqWvT2nJrHsY4aDfEb", - "realm": "api.example.com", - "method": "hedera", - "intent": "charge", - "request": "eyJ...", - "expires": "2026-03-15T12:05:00Z" - }, - "payload": { - "type": "transaction", - "transaction": "CgMA...base64-encoded..." - } -} -~~~ - -# Verification Procedure {#verification} - -Upon receiving a request with a credential, the server -MUST: - -1. Decode the base64url credential and parse the JSON. - -2. Verify that `payload.type` is present and is either - `"hash"` or `"transaction"`. - -3. Look up the stored challenge using - `credential.challenge.id`. If no matching challenge - is found, reject the request. - -4. Verify that all fields in `credential.challenge` - exactly match the stored challenge auth-params. - -5. Proceed with type-specific verification: - - For `type="hash"`: see {{hash-verification}}. - - For `type="transaction"`: see - {{transaction-verification}}. - -## Push Mode Verification {#hash-verification} - -For credentials with `type="hash"`: - -1. Verify that `payload.transactionId` is present and - is a valid Hedera transaction ID string. - -2. Verify the transaction ID has not been previously - consumed (see {{replay-protection}}). - -3. Fetch the transaction from the Mirror Node REST API - at `/api/v1/transactions/{txId}`, where `{txId}` is - the transaction ID with `@` replaced by `-` and `.` - in the timestamp replaced by `-` (Mirror Node URL - format). The server MUST poll with retry to account - for the 3-5 second lag between consensus and Mirror - Node indexing (see {{mirror-node-lag}}). - -4. Verify the transaction was successful: the `result` - field MUST be `"SUCCESS"`. - -5. Verify the Attribution memo: decode the - `memo_base64` field from the Mirror Node response - (base64 to UTF-8 to hex string), then verify: - - The memo is a valid MPP attribution memo - (TAG and VERSION match). - - The SERVER_ID fingerprint matches the server's - realm. - - The NONCE matches - `keccak256(challengeId)[0..6]`. - -6. Verify the token transfers match the challenge - request (see {{transfer-verification}}). - -7. Mark the transaction ID as consumed to prevent - replay. - -8. Return the resource with a `Payment-Receipt` header. - -## Pull Mode Verification {#transaction-verification} - -For credentials with `type="transaction"`: - -1. Decode the base64 `payload.transaction` value. - -2. Deserialize the transaction using - `Transaction.fromBytes()`. - -3. Extract the transaction memo and verify it is a - valid MPP attribution memo: - - The memo string starts with `0x` and is 66 - characters. - - TAG and VERSION match. - - SERVER_ID fingerprint matches the server's realm. - - NONCE matches - `keccak256(challengeId)[0..6]`. - -4. Verify the serialized transaction bytes have not - been previously submitted (see {{replay-protection}}). - -5. Execute the transaction on the Hedera network using - the server's operator credentials. - -6. Verify the transaction receipt status is `SUCCESS`. - -7. Fetch the transaction from the Mirror Node and - verify the token transfers match the challenge - request (see {{transfer-verification}}). - -8. Mark the transaction ID as consumed to prevent - replay. - -9. Return the resource with a `Payment-Receipt` header. - -## Transfer Verification {#transfer-verification} - -For all credential types, the server MUST verify the -token transfers from the Mirror Node response: - -1. Compute the primary payment amount as the top-level - `amount` minus the sum of all `splits`, if any. - -2. Locate a token transfer entry in the Mirror Node - response's `token_transfers` array where: - - `token_id` matches the `currency` from the - challenge request. - - `account` matches the top-level `recipient`. - - `amount` is greater than or equal to the computed - primary payment amount. - -3. For each split in `splits`, if any, locate an - additional token transfer entry where: - - `token_id` matches the `currency`. - - `account` matches the split `recipient`. - - `amount` is greater than or equal to the split - `amount`. - - Each required payment leg MUST be matched to a - distinct token transfer entry. A single entry MUST - NOT satisfy more than one required payment leg, - even if multiple legs share the same recipient. - -If any required token transfer entry is missing, the -server MUST reject the credential. - -## Replay Protection {#replay-protection} - -Servers MUST maintain a set of consumed transaction -identifiers. Before accepting a credential, the server -MUST check whether the identifier has already been -consumed. After successful verification, the server -MUST atomically mark the identifier as consumed. - -For `type="hash"` credentials, the transaction ID is -provided directly by the client. For -`type="transaction"` credentials, the transaction ID -is derived after the server executes the transaction. - -The Attribution memo's NONCE field provides an -additional layer of replay protection: even if a -transaction ID were somehow reusable, the -challenge-bound nonce ensures the memo can only satisfy -the specific challenge it was created for. - -A transaction ID that has been consumed MUST NOT be -accepted again, even if presented with a different -challenge ID. - -## Mirror Node Lag {#mirror-node-lag} - -Hedera achieves consensus in approximately 3-5 seconds, -but the Mirror Node REST API may take an additional 3-5 -seconds to index the transaction. Servers MUST implement -retry logic when fetching transactions from the Mirror -Node: - -- Servers SHOULD retry up to 10 times with a 2-second - delay between attempts. -- A 404 response from the Mirror Node during the retry - window is expected and MUST NOT be treated as a - permanent failure. -- After exhausting retries, the server MUST reject the - credential with a `verification-failed` error. - -# Settlement Procedure - -Two settlement flows are supported, corresponding to -the two credential types. - -## Push Mode Settlement (type="hash") - -For `type="hash"` credentials, the client broadcasts -the transaction and presents the transaction ID: - -~~~ - Client Server Mirror Node - | | | - | (1) Build tx with | | - | Attribution | | - | memo, sign, | | - | execute | | - | | | - | (2) Authorization: | | - | Payment | | - | | | - | (transaction ID)| | - |-------------------> | | - | | | - | | (3) GET | - | | /api/v1/ | - | | transactions/ | - | | {txId} | - | | (with retry) | - | |--------------> | - | | (4) Tx data | - | |<-------------- | - | | | - | | (5) Verify: | - | | - memo | - | | - transfers | - | | - result | - | | | - | (6) 200 OK +Receipt | | - |<------------------- | | - | | | -~~~ - -1. Client builds a `TransferTransaction` with the - Attribution memo, signs it, and executes it on - the Hedera network. -2. Client presents the transaction ID as the - credential. -3. Server fetches the transaction from the Mirror - Node REST API, retrying to account for indexing - lag. -4. Server verifies the Attribution memo (challenge - binding, server identity) and token transfers - (amount, recipient, splits). -5. Server records the transaction ID as consumed and - returns the resource with a `Payment-Receipt` - header. - -## Pull Mode Settlement (type="transaction") - -For `type="transaction"` credentials, the client signs -the transaction and sends it to the server: - -~~~ - Client Server Hedera Network - | | | - | (1) Authorization: | | - | Payment | | - | | | - | (signed tx | | - | bytes) | | - |-------------------> | | - | | | - | | (2) Deserialize, | - | | verify memo | - | | | - | | (3) Execute tx | - | |-----------------> | - | | (4) Receipt | - | |<----------------- | - | | | - | | (5) Mirror Node | - | | verify | - | | transfers | - | | | - | (6) 200 OK +Receipt | | - |<------------------- | | - | | | -~~~ - -1. Client submits credential containing signed - transaction bytes. -2. Server deserializes the transaction, verifies the - Attribution memo (challenge binding, server - identity). -3. Server executes the transaction on the Hedera - network. -4. Server verifies the receipt status is `SUCCESS`. -5. Server fetches the transaction from the Mirror - Node and verifies token transfers match the - challenge request. -6. Server records the transaction ID as consumed and - returns the resource with a `Payment-Receipt` - header. - -## Client Transaction Construction - -The client MUST construct a `TransferTransaction` with: - -1. A debit of the full `amount` from the client's - account for the specified `currency` token. - -2. A credit of the primary payment amount (total - `amount` minus sum of splits) to the `recipient` - account for the `currency` token. - -3. For each split, a credit of the split `amount` to - the split `recipient` for the `currency` token. - -4. The Attribution memo set via - `setTransactionMemo()` (see {{attribution-memo}}). - -All debit and credit entries MUST sum to zero within -the `TransferTransaction`, as required by Hedera's -transfer semantics. - -The recipient account(s) MUST have previously -associated with the `currency` token. Unlike Solana's -Associated Token Accounts, Hedera token association is -a one-time operation and does not require rent or -account creation by the payer. If the recipient has -not associated with the token, the transaction will -fail with `TOKEN_NOT_ASSOCIATED_TO_ACCOUNT`. - -## Finality - -Hedera provides asynchronous Byzantine Fault Tolerant -(aBFT) consensus with deterministic finality in -approximately 3-5 seconds. Once a transaction reaches -consensus, it cannot be rolled back or reversed. - -This is in contrast to probabilistic finality models -(e.g., proof-of-work chains) where transactions can -theoretically be reversed. Hedera's deterministic -finality means that once the Mirror Node reports a -transaction as `SUCCESS`, the payment is irreversible. - -Servers MAY accept the credential immediately upon -Mirror Node confirmation without waiting for additional -confirmations. - -## Receipt Generation - -Upon successful verification, the server MUST include -a `Payment-Receipt` header in the 200 response. - -The receipt payload for Hedera charge: - -| Field | Type | Description | -|-------|------|-------------| -| `method` | string | `"hedera"` | -| `reference` | string | The transaction ID | -| `status` | string | `"success"` | -| `timestamp` | string | {{RFC3339}} time | - -Example (decoded): - -~~~json -{ - "method": "hedera", - "reference": - "0.0.12345@1681234567.123456789", - "status": "success", - "timestamp": "2026-03-10T21:00:00Z" -} -~~~ - -# Error Responses - -When rejecting a credential, the server MUST return -HTTP 402 (Payment Required) with a fresh -`WWW-Authenticate: Payment` challenge per -{{I-D.httpauth-payment}}. The server SHOULD include a -response body conforming to RFC 9457 {{RFC9457}} Problem -Details, with `Content-Type: application/problem+json`. -Servers MUST use the standard problem types defined in -{{I-D.httpauth-payment}}: `malformed-credential`, -`invalid-challenge`, and `verification-failed`. The -`detail` field SHOULD contain a human-readable -description of the specific failure (e.g., "Transaction -not found on Mirror Node", "Attribution memo mismatch", -"Transaction ID already consumed"). - -All error responses MUST include a fresh challenge in -`WWW-Authenticate`. - -Example error response body: - -~~~json -{ - "type": "https://paymentauth.org/problems/verification-failed", - "title": "Attribution Memo Mismatch", - "status": 402, - "detail": "Memo challenge nonce does not match" -} -~~~ - -# Security Considerations - -## Transport Security - -All communication MUST use TLS 1.2 or higher. Hedera -credentials MUST only be transmitted over HTTPS -connections. - -## Replay Protection Considerations - -Servers MUST track consumed transaction IDs and reject -any transaction ID that has already been accepted. The -check-and-consume operation MUST be atomic to prevent -race conditions where concurrent requests present the -same transaction ID. - -The Attribution memo's NONCE field (derived from the -challenge ID) provides cryptographic challenge binding: -even if an attacker obtains a valid transaction ID, -they cannot construct a valid credential without the -matching challenge. However, the consumed-set check -remains essential because a single transaction could -theoretically match multiple challenges with identical -terms. - -## Attribution Memo Security - -The Attribution memo provides challenge binding but is -not a cryptographic signature over the challenge -parameters. It binds the transaction to a specific -challenge ID and server realm via keccak256 -fingerprints, which provides collision resistance -(~2^56 for the 7-byte nonce, ~2^80 for the 10-byte -server and client fingerprints). - -An attacker would need to find a challenge ID whose -keccak256 prefix collides with the target nonce to -forge a memo. At 7 bytes (56 bits), this requires -approximately 2^56 hash operations, which is -computationally infeasible for real-time attacks. - -## Client-Side Verification - -Clients MUST verify the challenge before signing: - -1. `amount` is reasonable for the service. -2. `currency` matches the expected token ID. -3. `recipient` is the expected party. -4. `splits`, if present, contain expected recipients - and amounts -- malicious servers could add splits - to redirect funds. -5. The `chainId` matches the client's configured - network. - -Malicious servers could request excessive amounts, -direct payments to unexpected recipients, or add -hidden splits. - -## Mirror Node Trust - -The server relies on the Hedera Mirror Node REST API -to provide accurate transaction data for on-chain -verification. A compromised Mirror Node could return -fabricated transaction data, causing the server to -accept payments that were never made. Servers SHOULD -use trusted Mirror Node providers or run their own -Mirror Node instance. - -## Front-running (Push Mode) - -In push mode, the client broadcasts the transaction -before presenting the credential, making it visible -on the Hedera network. A party monitoring the network -could attempt to present the same transaction ID to -the server. The challenge binding (the credential -echoes the challenge `id`, which is HMAC-verified by -the server) and the Attribution memo (which binds the -transaction to a specific challenge nonce) mitigate -this: only the party that received the challenge can -construct a valid credential with a matching memo. - -Unlike the Solana method's push mode, Hedera's -Attribution memo provides stronger on-chain challenge -binding. The memo's NONCE field cryptographically ties -the transaction to a specific challenge instance, -preventing a single transaction from satisfying -multiple challenges even if they have identical terms. - -## Transaction Payload Security (Pull Mode) - -In pull mode, the server receives raw transaction bytes -from the client. A malicious client could craft a -transaction that performs unexpected operations. - -Servers MUST verify that the deserialized transaction: -- Contains only the expected token transfer entries. -- Has a valid Attribution memo bound to the current - challenge. -- Does not include unexpected operations beyond the - token transfer. - -## Fee Delegation (Future) {#fee-delegation} - -Hedera natively supports fee delegation via the -`feePayerAccountId` field on transactions. This allows -a third party (e.g., the server) to pay the transaction -fee on behalf of the client. - -This specification does not define fee delegation -semantics in this version. A future revision MAY add -`feePayer` and `feePayerAccountId` fields to -`methodDetails`, following a pattern similar to the -Solana method's fee sponsorship mechanism. When -implemented, fee delegation would pair naturally with -pull mode (`type="transaction"`), where the server -can add its fee payer signature before broadcasting. - -# IANA Considerations - -## Payment Method Registration - -This document requests registration of the following -entry in the "HTTP Payment Methods" registry -established by {{I-D.httpauth-payment}}: - -| Method Identifier | Description | Reference | -|-------------------|-------------|-----------| -| `hedera` | Hedera Token Service (HTS) token transfer | This document | - -## Payment Intent Registration - -This document requests registration of the following -entry in the "HTTP Payment Intents" registry -established by {{I-D.httpauth-payment}}: - -| Intent | Applicable Methods | Description | Reference | -|--------|-------------------|-------------|-----------| -| `charge` | `hedera` | One-time HTS token transfer | This document | - ---- back - -# Examples - -The following examples illustrate the complete HTTP -exchange for each flow. Base64url values are shown with -their decoded JSON below. - -## USDC Charge (Push Mode) - -A 1 USDC charge for weather API access on mainnet. - -**1. Challenge (402 response):** - -~~~http -HTTP/1.1 402 Payment Required -WWW-Authenticate: Payment - id="kM9xPqWvT2nJrHsY4aDfEb", - realm="api.example.com", - method="hedera", - intent="charge", - request="", - expires="2026-03-15T12:05:00Z" -Cache-Control: no-store -~~~ - -Decoded `request`: - -~~~json -{ - "amount": "1000000", - "currency": "0.0.456858", - "recipient": "0.0.12345", - "description": "Weather API access", - "methodDetails": { - "chainId": 295 - } -} -~~~ - -**2. Credential (retry with transaction ID):** - -~~~http -GET /weather HTTP/1.1 -Host: api.example.com -Authorization: Payment -~~~ - -Decoded credential: - -~~~json -{ - "challenge": { - "id": "kM9xPqWvT2nJrHsY4aDfEb", - "realm": "api.example.com", - "method": "hedera", - "intent": "charge", - "request": "", - "expires": "2026-03-15T12:05:00Z" - }, - "payload": { - "type": "hash", - "transactionId": - "0.0.12345@1681234567.123456789" - } -} -~~~ - -**3. Response (with receipt):** - -~~~http -HTTP/1.1 200 OK -Payment-Receipt: -Content-Type: application/json - -{"temperature": 72, "condition": "sunny"} -~~~ - -Decoded receipt: - -~~~json -{ - "method": "hedera", - "reference": - "0.0.12345@1681234567.123456789", - "status": "success", - "timestamp": "2026-03-15T12:04:58Z" -} -~~~ - -## Pull Mode (type="transaction") - -The client signs and serializes the transaction; the -server deserializes, verifies, and executes it. - -Decoded credential: - -~~~json -{ - "challenge": { - "id": "kM9xPqWvT2nJrHsY4aDfEb", - "realm": "api.example.com", - "method": "hedera", - "intent": "charge", - "request": "", - "expires": "2026-03-15T12:05:00Z" - }, - "payload": { - "type": "transaction", - "transaction": "CgMA...base64-encoded..." - } -} -~~~ - -## Payment Splits - -A marketplace charge of 1.05 USDC where 0.05 USDC goes -to the platform as a fee. - -Decoded `request`: - -~~~json -{ - "amount": "1050000", - "currency": "0.0.456858", - "recipient": "0.0.12345", - "description": "Marketplace purchase", - "splits": [ - { - "recipient": "0.0.67890", - "amount": "50000" - } - ], - "methodDetails": { - "chainId": 295 - } -} -~~~ - -The client builds a `TransferTransaction` with three -token transfer entries: -- Debit 1,050,000 from the payer (`0.0.PAYER`) -- Credit 1,000,000 to the seller (`0.0.12345`) -- Credit 50,000 to the platform (`0.0.67890`) - -All three entries are atomic within a single -transaction, leveraging Hedera's native multi-party -transfer support. - -# Acknowledgements - -The author thanks the Tempo team for the MPP attribution -memo design and the mppx ecosystem architecture that -this specification builds upon. From f9e3227948eee1844f4d9ec3e50b7473ecd7df63 Mon Sep 17 00:00:00 2001 From: Tom Rowbotham Date: Thu, 14 May 2026 11:21:31 +0100 Subject: [PATCH 5/5] docs: add Lindsay Walker (Hedera) as co-author Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/methods/hedera/draft-hedera-session-00.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/specs/methods/hedera/draft-hedera-session-00.md b/specs/methods/hedera/draft-hedera-session-00.md index bf65305b..a3698589 100644 --- a/specs/methods/hedera/draft-hedera-session-00.md +++ b/specs/methods/hedera/draft-hedera-session-00.md @@ -13,6 +13,10 @@ author: - name: Tom Rowbotham ins: T. Rowbotham email: tom@xeno.money + - name: Lindsay Walker + ins: L. Walker + email: lindsay.w@swirldslabs.com + org: Hedera / Hashgraph normative: RFC2119: