Covenant Spend Authorization
Beside the server-side spending policy, the SDK can ask a Covenant daemon to authorize each spend before it is signed. The daemon checks the caller's capability, a per-call cap, and the payer's budget, records the verdict in its audit chain, and returns approve or deny with a decision_id. No funds move — it is a decision, not a payment. The decision_id is forwarded to the orbserv API so a later settlement can be correlated back to the authorization.
This is fully optional. Omit the covenant config and the SDK behaves exactly as before, relying only on the server-side policy guardrails described in Policies.
How it works
- Your app calls
wallet.send()orwallet.fetch()(when the target returns HTTP 402). - The SDK calls the Covenant daemon's
POST /spend/authorize. - On approval, the SDK submits the spend to orbserv with
spendAuthorization.decisionId. - After a successful on-chain broadcast, the SDK calls
POST /spend/settleto record the spend against the authorization.
If the daemon denies the spend, the SDK throws OrbSpendDeniedError and no transaction is submitted.
Enable the daemon
The Covenant operator opts in at boot:
# In the daemon environment
export COVENANT_SPEND_AUTHZ_ENABLED=1
# Grant the calling identity the capability
covenant capabilities grant wallet.spend.authorizeSmoke-test the contract before pointing the SDK at it:
curl -sS -X POST http://localhost:<COVENANT_PORT>/spend/authorize \
-H "Authorization: Bearer $COVENANT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"provider": "orbserv",
"network": "eip155:8453",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"amount": "80000",
"per_call_cap": "100000",
"credits": 8,
"destination": "0xPayee"
}'Configure the SDK
import { OrbWallet } from '@orbserv-labs/orb-wallet'
const orb = new OrbWallet({
apiKey: process.env.ORB_API_KEY!,
covenant: {
gatewayUrl: 'http://localhost:<COVENANT_PORT>', // daemon base URL
token: process.env.COVENANT_TOKEN!, // daemon bearer token
// perCallCap is an atomic decimal string; when omitted, the wallet
// policy's maxPerTx is used as the per-call cap instead.
perCallCap: '100000',
// Settlement retry config — defaults shown below
settlementRetryAttempts: 3, // retries after first failure
settlementRetryDelayMs: 100, // ms between retries
},
})What is gated
With the gate enabled:
wallet.send(...)authorizes the transfer before submitting it.wallet.fetch(url)probes the URL, and on a402it parses the x402 challenge, authorizes, then pays. Non-paid requests are returned untouched.
import { OrbSpendDeniedError } from '@orbserv-labs/orb-wallet'
const wallet = await orb.wallet.get(walletId)
try {
const tx = await wallet.send({
to: '0xPayee',
amount: 0.08,
token: 'USDC',
chain: 'base',
})
console.log('authorized + sent', tx.spendDecisionId, tx.txHash)
} catch (err) {
if (err instanceof OrbSpendDeniedError) {
// Policy deny — abort and surface the reason to the user.
console.error('spend denied:', err.reason, 'decision:', err.decisionId)
} else {
throw err
}
}Per-call cap
The perCallCap field is an atomic decimal string (for example, "100000" for 0.10 USDC on Base). When omitted, the SDK reads the wallet's policy.maxPerTx and converts it to the atomic cap automatically. After a policy update, the cached cap is invalidated so authorizations always use the current bound.
Set the wallet's policy.maxPerTx to mirror perCallCap as a hard backstop, so a spend can never exceed the bound even if a call skips the pre-flight.
Pre-obtained decision ID
If your application already holds a Covenant decision id (e.g. obtained out-of-band), you can pass it directly to skip the SDK's own pre-flight authorization call:
const tx = await wallet.send({
to: '0xPayee',
amount: 0.08,
token: 'USDC',
chain: 'base',
spendDecisionId: 'dec_external_abc', // skips daemon call, forwarded to API
})Settlement lifecycle
After a successful broadcast the SDK automatically tries to settle the Covenant authorization via POST /spend/settle. Settlement uses the same authorization facts (network, asset, amount, credits) that were sent during authorize. If all retries fail (daemon downtime, network blip), the SDK logs the failure and stores the record internally — the payment result is unaffected.
Failed settlement recovery
// List authorizations that broadcast succeeded but settlement failed
const failures = orb.covenant!.listFailedSettlements()
// [{ decisionId, txHash, context, lastError, attempts, failedAt }, ...]
// Retry a specific failed settlement by decisionId (no rebroadcast, no reauthorize)
const ok = await orb.covenant!.retryFailedSettlement('dec_ok_1')
console.log(ok) // true on success
// Retry the most recently failed settlement
const okLatest = await orb.covenant!.retryLatestFailedSettlement()
console.log(okLatest) // true on successCustom settlement logger
By default, settlement failures are written to console.warn. Override this to integrate with your own logging pipeline:
import {
setCovenantSettlementLogger,
resetCovenantSettlementLogger,
} from '@orbserv-labs/orb-wallet'
setCovenantSettlementLogger((log) => {
// log is a CovenantSettlementFailureLog:
// { decisionId, provider, network, asset, amount, credits, txHash, error, attempts }
myLogger.error('covenant settlement failed', log)
})
// Restore the default console.warn logger
resetCovenantSettlementLogger()Error handling
Distinguish a policy deny from a transport or configuration failure:
import {
OrbSpendDeniedError,
OrbCovenantError,
} from '@orbserv-labs/orb-wallet'
try {
await wallet.send({ to: '0xPayee', amount: 5, token: 'USDC', chain: 'base' })
} catch (err) {
if (err instanceof OrbSpendDeniedError) {
// Covenant daemon returned approved: false — policy verdict, not a crash.
// err.decisionId is still valid for audit-chain correlation.
console.error('spend denied:', err.reason, 'decision:', err.decisionId)
} else if (err instanceof OrbCovenantError) {
// Transport or configuration failure: daemon unreachable, surface not enabled,
// missing capability, or malformed response. Distinct from a policy deny.
console.error('covenant error:', err.message, err.statusCode)
} else {
throw err
}
}| Class | Extends | When thrown |
|-------|---------|-------------|
| OrbSpendDeniedError | OrbError | Covenant daemon returned approved: false |
| OrbCovenantError | OrbError | Daemon unreachable, misconfigured, or returned malformed response |
API reference
CovenantSpendAuthzConfig
Passed as covenant on the OrbWallet constructor.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| gatewayUrl | string | Yes | Daemon base URL, e.g. http://127.0.0.1:8421 |
| token | string | Yes | Daemon bearer token |
| provider | string | No | Provider tag on the audit row (default: "orbserv") |
| perCallCap | string | No | Atomic per-call cap as decimal string; falls back to wallet maxPerTx when omitted |
| settlementRetryAttempts | number | No | Automatic retry attempts after first settlement failure (default: 3) |
| settlementRetryDelayMs | number | No | Delay in ms between settlement retries (default: 100) |
orb.covenant (SpendGate)
Present only when the SDK was constructed with a covenant config. Exposes settlement recovery helpers for operations that succeeded on-chain but failed to settle with the daemon.
| Method | Returns | Description |
|--------|---------|-------------|
| listFailedSettlements() | FailedSettlementRecord[] | List authorizations whose post-broadcast settlement failed after all retries |
| retryFailedSettlement(decisionId) | Promise<boolean> | Retry settlement for a specific decision id; returns true on success. Does not rebroadcast or reauthorize |
| retryLatestFailedSettlement() | Promise<boolean> | Retry the most recently failed settlement; returns true on success |
FailedSettlementRecord
| Field | Type | Description |
|-------|------|-------------|
| decisionId | string | Covenant decision id |
| txHash | string | On-chain transaction hash from the broadcast |
| context | CovenantSettlementContext | Full authorization facts needed for settlement |
| lastError | string | Error message from the last failed attempt |
| attempts | number | Total attempts made (initial + retries) |
| failedAt | string | ISO 8601 timestamp of the final failure |
Local test checklist
- Start the daemon with
COVENANT_SPEND_AUTHZ_ENABLED=1. - Grant the capability:
covenant capabilities grant wallet.spend.authorize. - Verify the contract with the curl example above.
- Point the SDK at the daemon via the
covenantconfig. - Run a send within the cap (approve) and one above it (deny).
- Inspect the audit chain:
covenant audit recentshowsspend_authorization_decidedrows.
Advanced exports
The following are exported for advanced use cases such as dependency injection, testing, or building custom authorization flows:
import {
CovenantSpendAuthzClient, // low-level daemon client
SpendGate, // SDK glue layer wrapping the client
logSettlementFailure, // manually invoke the active settlement logger
} from '@orbserv-labs/orb-wallet'
import type {
SpendAuthorizeRequest,
SpendAuthorizationResult,
SpendSettleRequest,
CovenantSettlementContext,
FailedSettlementRecord,
} from '@orbserv-labs/orb-wallet'logSettlementFailure calls whatever logger is currently registered (default or overridden via setCovenantSettlementLogger). It is useful when building custom settlement flows or when you want to emit a structured failure record manually.