Kaspa x402 Batch Settlement Binding v1
Status: draft
This document defines the Kaspa network binding for x402 v2 batch-settlement.
Summary
batch-settlement is for repeated low-value requests where the client provides a cryptographic payment commitment at request time and value moves later. The Kaspa v0.1 binding is capital-backed: the client funds a native KAS escrow/channel UTXO and signs cumulative vouchers against that exact UTXO.
Use batch-settlement for:
- repeated API requests against one service;
- high-frequency MCP tool calls;
- metered agent sessions where per-request on-chain settlement would be wasteful.
Use exact for fixed-price one-shot purchases. Use batch-settlement for repeated or variable-cost requests where the server can settle from a funded escrow/channel.
Scheme and Network Pair
{
"scheme": "batch-settlement",
"network": "kaspa:<network>",
"asset": "KAS",
"extra": {
"binding": "kaspa-escrow-v1"
}
}
Recognized draft network identifiers:
kaspa:mainnet
kaspa:testnet-10
kaspa:testnet-10 is the current alpha validation target. kaspa:mainnet is a reserved profile name, not a readiness claim.
extra.binding is "kaspa-escrow-v1" because the initial Kaspa batch-settlement binding is a covenant escrow/channel profile. Future batch-settlement bindings may use a different binding label.
PaymentRequirements
{
"scheme": "batch-settlement",
"network": "kaspa:testnet-10",
"amount": "1000000",
"asset": "KAS",
"payTo": "kaspatest:...",
"maxTimeoutSeconds": 60,
"extra": {
"binding": "kaspa-escrow-v1",
"templateId": "kaspa-x402-escrow-v1",
"serverPublicKey": "<32-byte x-only hex>",
"minDepositSompi": "90000000",
"refundTimeoutDaa": "123456789",
"claimPolicy": {
"claimWhenUnclaimedAmountExceeds": "100000000"
}
}
}
| Field | Required | Rule |
|---|---|---|
scheme | yes | Must equal "batch-settlement". |
network | yes | Must be kaspa:mainnet or kaspa:testnet-10. |
amount | yes | Decimal string in sompi. This is the maximum per-request charge. |
asset | yes | Must equal "KAS". |
payTo | yes | Non-empty server payout address. This is not the client-specific escrow address. |
maxTimeoutSeconds | yes | Positive maximum time, in seconds, that the client may take to provide a payment commitment. |
extra.binding | yes | Must equal "kaspa-escrow-v1". |
extra.templateId | yes | Must equal "kaspa-x402-escrow-v1" for this profile. |
extra.serverPublicKey | yes | Server key allowed to verify vouchers and authorize claim transactions according to the covenant rules. Refunds require the client key. |
extra.minDepositSompi | yes | Minimum initial escrow deposit. |
extra.refundTimeoutDaa | yes | Absolute DAA score after which unilateral refund is available. |
extra.claimPolicy | no | Server policy for when it intends to claim vouchers on-chain. |
extra.channelState | no | Corrective-only server channel snapshot for client resynchronization. |
extra.voucherState | no | Corrective-only latest signed voucher proof for client resynchronization. |
amount is a ceiling. The server may charge less after executing the request. The actual charge is returned as top-level SettlementResponse.amount and echoed in SettlementResponse.extensions.kaspa.chargedAmount.
extra.refundTimeoutDaa is always an absolute DAA score. Relative refund policy is out of scope for this binding; servers that want relative timeouts must calculate and advertise the resulting absolute DAA score before the client constructs ChannelConfig.
ChannelConfig
A channel is identified by an immutable configuration.
{
"network": "kaspa:testnet-10",
"asset": "KAS",
"templateId": "kaspa-x402-escrow-v1",
"clientPublicKey": "<32-byte x-only hex>",
"serverPublicKey": "<32-byte x-only hex>",
"payTo": "kaspatest:...",
"refundAddress": "kaspatest:...",
"refundTimeoutDaa": "123456789",
"salt": "<32-byte hex>"
}
The channel id is:
sha256(
sha256("kaspa:x402:channel:v1") ||
sha256(network) ||
sha256("KAS") ||
sha256(templateId) ||
clientPublicKey32 ||
serverPublicKey32 ||
sha256(payTo utf8) ||
sha256(refundAddress utf8) ||
refundTimeoutDaa_le64 ||
salt32
)
Digest rules:
- strings are UTF-8 before hashing;
- integers are unsigned little-endian values of the stated byte width;
- public keys and salts are hex decoded to raw bytes;
- implementations must reject values that cannot be represented in the required integer width.
Escrow script construction derives constructor slots from this config:
clientPublicKeyandserverPublicKeyare decoded as 32-byte x-only public keys;networkHash = sha256(network utf8);payoutScriptPublicKeyHash = sha256(serialized script public key for payTo);refundScriptPublicKeyHash = sha256(serialized script public key for refundAddress);timeout = refundTimeoutDaa.
Address decoding must verify the address network prefix before deriving payout or refund script-public-key hashes. The serialized script public key format is uint16_le version || script bytes; this profile requires version 0.
Domain Tags
Implementations must domain-separate every signed digest.
Initial domain tags:
kaspa:x402:channel:v1
kaspa:x402:escrow-voucher:v1
kaspa:x402:batch-commitment:v1
kaspa:x402:claim:v1
kaspa:x402:refund:v1
PaymentPayload
The x402 v2 PaymentPayload.payload object is Kaspa-specific and uses a type discriminator.
Supported types:
deposit-vouchervoucherclaimrefund
Deposit Voucher
deposit-voucher opens or tops up a channel and commits to the current request.
{
"type": "deposit-voucher",
"channelConfig": {
"network": "kaspa:testnet-10",
"asset": "KAS",
"templateId": "kaspa-x402-escrow-v1",
"clientPublicKey": "<32-byte x-only hex>",
"serverPublicKey": "<32-byte x-only hex>",
"payTo": "kaspatest:...",
"refundAddress": "kaspatest:...",
"refundTimeoutDaa": "123456789",
"salt": "<32-byte hex>"
},
"channelId": "<32-byte channel id hex>",
"escrowAddress": "kaspatest:...",
"fundingTransaction": "<optional serialized funding transaction hex>",
"fundingOutpoint": {
"txid": "<active escrow txid>",
"index": 0
},
"fundingAmountSompi": "90000000",
"activeScriptPublicKey": "<serialized active input script public key hex>",
"voucher": {
"amount": "1000000",
"signature": "<64-byte Schnorr signature hex>"
}
}
Voucher
voucher commits against an existing active channel UTXO.
{
"type": "voucher",
"channelId": "<32-byte channel id hex>",
"clientPublicKey": "<32-byte x-only hex>",
"fundingOutpoint": {
"txid": "<active escrow txid>",
"index": 0
},
"activeScriptPublicKey": "<serialized active input script public key hex>",
"voucher": {
"amount": "2000000",
"signature": "<64-byte Schnorr signature hex>"
}
}
Claim
claim is a server or facilitator operation that redeems the latest voucher on-chain.
{
"type": "claim",
"channelId": "<32-byte channel id hex>",
"fundingOutpoint": {
"txid": "<active escrow txid>",
"index": 0
},
"activeScriptPublicKey": "<serialized active input script public key hex>",
"claimAmount": "2500000",
"voucher": {
"amount": "2500000",
"signature": "<64-byte Schnorr signature hex>"
}
}
Refund
refund cooperatively returns unclaimed escrow or initiates the template-defined unilateral refund path.
{
"type": "refund",
"channelId": "<32-byte channel id hex>",
"fundingOutpoint": {
"txid": "<active escrow txid>",
"index": 0
},
"activeScriptPublicKey": "<serialized active input script public key hex>",
"refundAddress": "kaspatest:...",
"refundAmount": "87500000",
"clientSignature": "<65-byte transaction signature hex>"
}
Voucher Digest
The voucher signs a cumulative claimable ceiling:
sha256(
sha256("kaspa:x402:escrow-voucher:v1") ||
sha256(network) ||
sha256(serialized active input scriptPublicKey) ||
outpointTxid32 ||
outpointIndex_le32 ||
voucherAmount_le64
)
Digest rules:
voucher.amountis cumulative, not the current request charge;voucher.amountis the maximum amount the server can claim from the active escrow UTXO;- txids are hex decoded from their canonical display order;
- integers are unsigned little-endian values of the stated byte width;
- the digest binds the exact active escrow outpoint and active input script public key;
- a voucher for one escrow UTXO must not verify for a continuation, top-up, refund, or replacement UTXO.
Request Processing
The server must serialize request processing per channel. It must not update request charge or request commitment state until the protected handler succeeds. Accepted funding, top-up, claim, refund, and continuation transition state may be recorded independently of handler success when needed to keep channel state aligned with accepted on-chain state.
Per-channel state:
| State Field | Rule |
|---|---|
channelId | Canonical channel id derived from ChannelConfig. |
channelConfig | Immutable channel configuration. |
activeOutpoint | Current escrow UTXO that future vouchers must bind. |
activeScriptPublicKey | Script public key for activeOutpoint. |
fundingAmount | Current spendable escrow value, in sompi. |
chargedCumulativeAmount | Lifetime actual accumulated charges for the channel. |
claimedCumulativeAmount | Lifetime actual charges already claimed on-chain before the current active outpoint. |
activeChargedAmount | chargedCumulativeAmount - claimedCumulativeAmount. This is the actual charge accumulated against the current active outpoint. |
signedMaxClaimable | Latest client-signed voucher amount for the current active outpoint. |
voucherSignature | Signature for signedMaxClaimable on the current active outpoint. |
lastCommitmentId | Latest stored request commitment id. |
Processing steps for deposit-voucher and voucher:
1. Verify the payload and signature. 2. Reject state where claimedCumulativeAmount > chargedCumulativeAmount. 3. Calculate activeChargedAmount = chargedCumulativeAmount - claimedCumulativeAmount. 4. Calculate requiredVoucherAmount = max(signedMaxClaimable, activeChargedAmount + PaymentRequirements.amount) for the current active outpoint. 5. Check that voucher.amount == requiredVoucherAmount. 6. Check that voucher.amount does not exceed available escrow value after required fee or reserve policy. 7. Execute the protected handler. 8. Calculate actualCharge, where actualCharge <= PaymentRequirements.amount. 9. Derive and durably store the commitment, then update chargedCumulativeAmount += actualCharge. 10. Store signedMaxClaimable = voucher.amount and voucherSignature = voucher.signature. 11. Return SettlementResponse.amount = actualCharge and SettlementResponse.extensions.kaspa.chargedAmount = actualCharge.
On handler failure:
voucherpayload state is unchanged and the client may retry the same voucher;deposit-voucherpayloads must preserve any funding or top-up transition that has already become live on-chain;chargedCumulativeAmountis not increased;- no request
commitmentIdis stored for the failed handler; - protected content is not released.
If a deposit-voucher funding or top-up transaction is accepted before the handler succeeds, the server or facilitator must durably record the new activeOutpoint, activeScriptPublicKey, fundingAmount, claimedCumulativeAmount, signedMaxClaimable, and voucherSignature before returning. This preserves the ability to claim prior active charges after the previous active outpoint has been consumed.
This monotonic rule is required because old vouchers remain valid for the same active outpoint. A server must not record a newer signedMaxClaimable that is lower than a still-valid prior voucher ceiling.
For refund payloads, the client signature authorizes the refund spend. The protected handler is not invoked.
Claims are full-epoch in v0.1. Before a claim spend:
- reject state where
claimedCumulativeAmount > chargedCumulativeAmount; - calculate
activeChargedAmount = chargedCumulativeAmount - claimedCumulativeAmount; - require
claimAmount == activeChargedAmount; - require
voucher.amount >= claimAmount; - reject partial claims where
0 < claimAmount < activeChargedAmount.
When an accepted claim spend creates a continuation outpoint, the continuation starts a new active-outpoint voucher epoch:
- set
claimedCumulativeAmount = chargedCumulativeAmount; - set
signedMaxClaimable = "0"unless the continuation transaction also carries a freshly signed voucher for the new active outpoint; - clear
voucherSignatureunless a fresh continuation voucher is present; - set
activeOutpointandactiveScriptPublicKeyto the continuation UTXO.
Old vouchers are invalid for the continuation because voucher signatures bind the prior active outpoint and script public key.
Claim and refund transactions are successful only after they are accepted by the selected Kaspa network according to the server or facilitator finality policy. Broadcast-only or mempool-only claim/refund transactions are pending state; they must not mutate active channel state and must not be reported as successful settlement.
Deposit and Top-Up Transition
deposit-voucher has two modes:
- initial deposit, when no channel state exists for
channelId; - top-up, when a channel exists and the client moves value into a new active escrow outpoint.
A funding or top-up transaction is live only after it is accepted by the selected Kaspa network according to the server or facilitator finality policy. Broadcast-only or mempool-only funding is pending state; it must not release protected content and must not replace the active channel outpoint.
For an initial deposit:
fundingOutpointbecomesactiveOutpoint;activeScriptPublicKeybecomes the script public key forfundingOutpoint;fundingAmountis initialized fromfundingAmountSompi;chargedCumulativeAmount = "0";claimedCumulativeAmount = "0";signedMaxClaimable = "0"before the request voucher is verified.
For a top-up:
- request processing must be serialized for the existing channel;
- the top-up transaction must spend the previous
activeOutpointinto the newfundingOutpointor otherwise prove, according to the covenant template, that the newfundingOutpointis the successor active outpoint for the samechannelId; - the new
activeScriptPublicKeymust matchtemplateIdand preserve the sameChannelConfig; claimedCumulativeAmountis unchanged;chargedCumulativeAmountis unchanged before the protected handler executes;fundingAmountis updated to the new active escrow value;signedMaxClaimable = "0"for the new active outpoint before the request voucher is verified, because old vouchers do not verify against the new outpoint.
After this transition, the normal request-processing rule applies against the new active outpoint. The required voucher amount is therefore based on activeChargedAmount = chargedCumulativeAmount - claimedCumulativeAmount for the new active outpoint, plus the current PaymentRequirements.amount.
Protected content must not be released for a deposit-voucher top-up until the top-up transaction is accepted, the top-up transition is verified, and the new voucher commitment is durably stored.
If the protected handler fails after the top-up transition is live, the top-up state remains active but the request charge is not committed. The next paid retry must use the new active outpoint and the preserved signedMaxClaimable for that outpoint.
Commitment Identifier
A stored batch commitment records one successful paid request whose value has not necessarily moved on-chain yet.
The commitmentId is:
sha256(
sha256("kaspa:x402:batch-commitment:v1") ||
channelId32 ||
sha256(normalized request fingerprint bytes) ||
paymentRequirementsHash32 ||
activeOutpointTxid32 ||
activeOutpointIndex_le32 ||
voucherAmount_le64 ||
sha256(voucherSignature64) ||
actualCharge_le64 ||
chargedCumulativeBefore_le64 ||
chargedCumulativeAfter_le64 ||
claimedCumulativeAmount_le64
)
Digest rules:
channelId32is the canonical channel id bytes;normalized request fingerprint bytesare the same bytes used for payment-identifier idempotency;paymentRequirementsHash32is the structured hash defined below;voucherSignature64is the raw 64-byte signature decoded from hex;chargedCumulativeAftermust equalchargedCumulativeBefore + actualCharge;claimedCumulativeAmountis the lifetime claimed base for the active outpoint when the commitment is stored;- integers are unsigned little-endian values of the stated byte width.
paymentRequirementsHash32 is:
sha256(
sha256("kaspa:x402:batch-payment-requirements:v1") ||
sha256("batch-settlement") ||
sha256(network) ||
sha256("KAS") ||
amount_le64 ||
sha256(payTo utf8) ||
maxTimeoutSeconds_le64 ||
sha256("kaspa-escrow-v1") ||
sha256(templateId) ||
serverPublicKey32 ||
minDepositSompi_le64 ||
refundTimeoutDaa_le64
)
Unknown extra fields are not part of paymentRequirementsHash32 unless a future binding marks them as critical. A client or server that relies on a critical extension must define that extension's hash contribution before using it in a commitment.
A commitment is stored only when the server or facilitator has durably recorded, keyed by commitmentId:
- channel id;
- normalized request fingerprint hash;
- selected
PaymentRequirementshash; - active outpoint;
- voucher amount and signature;
- actual charge;
- cumulative charge before and after the request;
- claimed cumulative base for the active outpoint;
- payment identifier, if present;
- response receipt metadata, if the offer/receipt extension is used.
Protected content must not be released until this durable record exists. Retrying the same payment identifier and request fingerprint must return the same commitmentId and cached result.
Trust Model
The Kaspa batch binding is capital-backed. For the current active outpoint, the client's incremental risk is bounded by the latest signed voucher.amount, not by the actual charge reported for the last request. Across the full channel lifetime, claimed value is tracked by claimedCumulativeAmount.
The server must claim no more than activeChargedAmount from the current active outpoint. If the server claims above the actual active charge but within the signed voucher ceiling, that is a trust violation detectable from receipts and channel state, not necessarily a covenant-level rejection.
Clients must stop signing additional vouchers and should refund or withdraw if:
SettlementResponse.amountorSettlementResponse.extensions.kaspa.chargedAmountexceedsPaymentRequirements.amount;SettlementResponse.extensions.kaspa.channelState.chargedCumulativeAmountdoes not equal the previous value pluschargedAmount;- the returned channel state uses an unexpected active outpoint;
- the server cannot produce the latest signed voucher in a corrective response.
SettlementResponse
Successful voucher-only response:
{
"success": true,
"transaction": "<commitment id hex>",
"network": "kaspa:testnet-10",
"payer": "kaspatest:...",
"amount": "700000",
"extensions": {
"kaspa": {
"commitmentId": "<commitment id hex>",
"chargedAmount": "700000",
"channelState": {
"channelId": "<32-byte channel id hex>",
"activeOutpoint": {
"txid": "<active escrow txid>",
"index": 0
},
"activeScriptPublicKey": "<serialized active input script public key hex>",
"fundingAmount": "90000000",
"chargedCumulativeAmount": "1700000",
"claimedCumulativeAmount": "0",
"signedMaxClaimable": "2000000"
}
}
}
}
Successful deposit response:
{
"success": true,
"transaction": "<commitment id hex>",
"network": "kaspa:testnet-10",
"payer": "kaspatest:...",
"amount": "700000",
"extensions": {
"kaspa": {
"commitmentId": "<commitment id hex>",
"fundingAmount": "90000000",
"chargedAmount": "700000",
"channelState": {
"channelId": "<32-byte channel id hex>",
"activeOutpoint": {
"txid": "<active escrow txid>",
"index": 0
},
"activeScriptPublicKey": "<serialized active input script public key hex>",
"fundingAmount": "90000000",
"chargedCumulativeAmount": "700000",
"claimedCumulativeAmount": "0",
"signedMaxClaimable": "1000000"
}
}
}
}
For deposit-voucher, top-level amount is the actual request charge. Escrow funding is not the resource price and is reported separately in extensions.kaspa.fundingAmount.
Successful claim response:
{
"success": true,
"transaction": "<claim transaction id>",
"network": "kaspa:testnet-10",
"payer": "kaspatest:...",
"amount": "1700000",
"extensions": {
"kaspa": {
"claimOutpoint": {
"txid": "<claim transaction id>",
"index": 0
},
"continuationOutpoint": {
"txid": "<claim transaction id>",
"index": 1
},
"channelState": {
"channelId": "<32-byte channel id hex>",
"activeOutpoint": {
"txid": "<claim transaction id>",
"index": 1
},
"activeScriptPublicKey": "<serialized continuation script public key hex>",
"fundingAmount": "88300000",
"chargedCumulativeAmount": "1700000",
"claimedCumulativeAmount": "1700000",
"signedMaxClaimable": "0"
}
}
}
}
Successful refund response:
{
"success": true,
"transaction": "<refund transaction id>",
"network": "kaspa:testnet-10",
"payer": "kaspatest:...",
"amount": "87500000",
"extensions": {
"kaspa": {
"channelId": "<32-byte channel id hex>",
"refundAddress": "kaspatest:..."
}
}
}
Failure response:
{
"success": false,
"errorReason": "invalid_transaction_state",
"transaction": "",
"network": "kaspa:testnet-10",
"payer": "kaspatest:..."
}
For batch-settlement, a successful voucher-only response must include a non-empty extensions.kaspa.commitmentId. transaction is that commitment id because value has not moved on-chain yet.
Corrective 402
If a paid retry fails because the client and server disagree about channel state, the server should return a corrective x402 PaymentRequired response.
Corrective responses use the normal accepts array and include extra.channelState. If the server wants the client to adopt a newer cumulative value, it must also include extra.voucherState containing the latest signed voucher amount and signature.
The client must verify extra.voucherState before adopting server-provided state. If verification fails, the client must not sign a new voucher against that state.
Corrective responses are appropriate for:
- stale active outpoint;
- mismatched cumulative voucher amount;
- missing local channel session;
- channel busy lock;
- insufficient escrow balance;
- server state recovery after restart.
Recovery
Stale Outpoint
The client must refresh the active channel state, verify the server-provided latest voucher if present, and sign the next voucher against the exact current active outpoint.
Channel Busy
The client should retry after the server-provided delay. The server should use a short lock and must not execute two paid handlers concurrently for the same channel.
Insufficient Balance
The client should send a deposit-voucher top-up payload. The new voucher must bind the new active outpoint created by the deposit or continuation transaction.
Server State Loss
If the server loses local unclaimed voucher state, it may recover from on-chain state and the latest client-provided voucher. Any unclaimed charges not represented by a recoverable voucher are server risk.
Toccata Requirements
The mainnet profile targets transaction v1 and Toccata covenants:
- v1 inputs use
compute_budget; - transaction builders must estimate script units from the generated script path;
- scripts must validate successor outputs, not only inputs;
- covenant IDs should be used for production lineage and indexability once wallet and builder support is ready;
- covenant IDs do not replace script-level transition validation;
- funding, voucher, claim, continuation, and refund flows must preserve exact outpoint identity;
- claim spends must validate the payout and continuation output;
- refund spends must validate timeout and refund address;
- SilverScript source plus generated byte fixtures should be the reviewable covenant source of truth.
The v1 SilverScript shape is a script-level escrow covenant: it binds the active script public key into vouchers, validates the continuation output, and validates payout/refund destinations by script-public-key hash. Stateful Toccata wrappers may add covenant IDs and validateOutputState(...) lineage checks later, but those IDs do not replace the v1 script-level payout, refund, continuation, and full-outpoint checks.
Security Requirements
Implementations must reject:
- unsupported x402 version;
- unsupported scheme, network, asset, or binding;
- network mismatch;
- invalid public keys;
- invalid channel id;
- channel config that does not hash to
channelId; - invalid funding outpoint;
- deposit below
minDepositSompi; - script public key that does not match
templateId; - voucher signed for the wrong network;
- voucher signed for the wrong script public key;
- voucher signed for the wrong txid or output index;
- voucher amount below required cumulative ceiling;
- voucher amount above available escrow balance after required fee or reserve policy;
claimedCumulativeAmountgreater thanchargedCumulativeAmount;- claim amount above voucher amount;
- claim amount different from
activeChargedAmount; - refund before
refundTimeoutDaafor unilateral refund paths; - idempotency key reuse with a different request fingerprint.
Local Diagnostics
Public wire responses use the mapped reasons in errors.md. Implementations may use common invalid_kaspa_x402_* diagnostics plus:
invalid_kaspa_batch_template
invalid_kaspa_batch_channel_id
invalid_kaspa_batch_channel_state
invalid_kaspa_batch_corrective_state
invalid_kaspa_batch_funding_outpoint
invalid_kaspa_batch_funding_amount
invalid_kaspa_batch_voucher_signature
invalid_kaspa_batch_voucher_network
invalid_kaspa_batch_voucher_script
invalid_kaspa_batch_voucher_outpoint
invalid_kaspa_batch_cumulative_amount_mismatch
invalid_kaspa_batch_cumulative_below_claimed
invalid_kaspa_batch_insufficient_channel_balance
invalid_kaspa_batch_channel_busy
invalid_kaspa_batch_commitment
invalid_kaspa_batch_handler_failed
invalid_kaspa_batch_refund_not_mature
invalid_kaspa_batch_claim_dust
invalid_kaspa_batch_compute_budget