Payment Objects
UTXO-inspired one-time payment tokens with a 22-state lifecycle, funding cells, and cryptographic merchant verification.
Key insight: Traditional payment systems rely on account balances and mutable state, leading to race conditions and double-spend risks in concurrent agent environments. Sardis uses a UTXO-inspired model where each payment is an atomic, signed, single-use token backed by claimed funding cells -- making double-spend structurally impossible.
Overview
A Payment Object is the atomic unit of payment in Sardis. Inspired by Bitcoin's UTXO (Unspent Transaction Output) model, each payment object is a one-time-use, cryptographically signed token that bridges an off-chain spending mandate to an on-chain settlement.
The UTXO model provides three critical properties for AI agent payments:
- Double-spend prevention -- Each funding cell can only be consumed once. Once spent, it is gone.
- Concurrent safety -- Multiple agents can claim cells simultaneously without deadlocks using
SELECT ... FOR UPDATE SKIP LOCKED. - Deterministic audit trails -- Every cent is traceable from funding commitment through cell claim to payment object to settlement.
Unlike account-balance systems where concurrent withdrawals can overdraw, the cell-based model ensures that if an agent claims a cell, no other agent can claim the same cell -- even under heavy concurrent load.
Core Concepts
Payment Object Dataclass
A PaymentObject is an immutable-by-design dataclass with the following fields:
| Field | Type | Description |
|---|---|---|
object_id | str | Unique identifier (e.g., po_a1b2c3d4e5f6g7h8) |
mandate_id | str | Source spending mandate that authorized minting |
merchant_id | str | Merchant this payment is payable to |
cell_ids | list[str] | Funding cells claimed from the mandate budget |
exact_amount | Decimal | Exact payment amount (never float) |
currency | str | Currency code (default: USDC) |
one_time_use | bool | Always True -- enforced invariant |
signature_chain | list[str] | Signatures from principal, issuer, and agent |
session_hash | str | SHA-256 replay protection hash |
expires_at | datetime | Expiration timestamp |
status | PaymentObjectStatus | Current lifecycle state |
privacy_tier | PrivacyTier | On-chain data exposure level |
created_at | datetime | Creation timestamp |
metadata | dict | Extensible key-value metadata |
Privacy Tiers
Payment objects support three levels of on-chain data exposure:
| Tier | Description |
|---|---|
transparent | Full payment details visible on-chain |
hybrid | Amount visible, parties hashed |
full_zk | Zero-knowledge proof, no details exposed |
When hybrid or full_zk is selected, the minter generates a ZKP proof (via sardis_zkp) that proves mandate compliance without revealing the underlying data.
Integrity Hash
The compute_hash() method produces a deterministic SHA-256 digest of the payment object's immutable fields (identity, financial, and security fields). Mutable fields like status and metadata are intentionally excluded so that the hash remains stable across state transitions.
canonical = {
"object_id": self.object_id,
"mandate_id": self.mandate_id,
"merchant_id": self.merchant_id,
"cell_ids": sorted(self.cell_ids),
"exact_amount": str(self.exact_amount),
"currency": self.currency,
"one_time_use": self.one_time_use,
"session_hash": self.session_hash,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
"privacy_tier": self.privacy_tier.value,
"created_at": self.created_at.isoformat(),
}
hash = sha256(json.dumps(canonical, sort_keys=True).encode()).hexdigest()Payment Object Status Enum
Payment objects progress through 13 statuses defined in PaymentObjectStatus:
| Status | Description | Terminal? |
|---|---|---|
minted | Created and signed | No |
presented | Shown to merchant for verification | No |
verified | Merchant verified signatures | No |
locked | Funds locked for settlement | No |
settling | On-chain settlement in progress | No |
settled | Settlement confirmed on-chain | No |
fulfilled | Delivery confirmed, payment complete | Yes |
escrowed | Funds held in escrow | No |
disputing | Under dispute | No |
revoked | Cancelled before settlement | Yes |
expired | Past expiration without settlement | Yes |
failed | Settlement failed on-chain | Yes |
refunded | Refund processed | Yes |
Funding Cells
A FundingCell is an indivisible (or splittable) unit of value carved from a funding commitment. Cells follow UTXO semantics:
- Each cell holds a fixed
valuein a givencurrency(alwaysDecimal, never float) - Cells are consumed (spent) by payment objects
- When a cell exceeds the required amount, it is split and change is returned as a new cell
- Cells can be merged to reduce fragmentation
Cell Status Lifecycle
AVAILABLE --> CLAIMED --> SPENT
\--> RETURNED --> AVAILABLE (re-entry)
AVAILABLE --> MERGED (combined into a larger cell)
* --> EXPIRED (commitment expired)| Status | Description |
|---|---|
available | Ready to be claimed |
claimed | Reserved for a mandate |
spent | Used in a payment object |
returned | Released back to available pool |
merged | Combined into another cell |
expired | Parent commitment expired |
Valid Cell Transitions
| From | To | Transition |
|---|---|---|
available | claimed | claim |
claimed | spent | spend |
claimed | returned | release |
returned | available | recycle |
available | merged | merge |
available | expired | expire |
claimed | expired | expire |
22-State Lifecycle
The full payment state machine defines 22 states with three primary paths and multiple terminal branches:
┌──────────────────────────────────────────────────────┐
│ HAPPY PATH │
│ │
┌────────┐ ┌───────────┐ ┌──────────┐ ┌────────┐ ┌──────────┐ │
│ MINTED ├──>│ PRESENTED ├──>│ VERIFIED ├──>│ LOCKED ├──>│ SETTLING │ │
└───┬──┬─┘ └─────┬───┬─┘ └────┬───┬─┘ └──┬──┬──┘ └────┬──┬──┘ │
│ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ ┌────▼──┐│ │
│ │ │ │ │ │ │ │ │SETTLED├┘ │
│ │ │ │ │ │ │ │ └──┬──┬─┘ │
│ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ ┌────▼──┐│ │
│ │ │ │ │ │ │ │ │FULFLLD││ │
│ │ │ │ │ │ │ │ └───────┘│ │
│ │ │ │ │ │ │ │ │ │
│ └──────────┘───┘─────────┘ │ │ │ ┌────▼───┐ │
│ ▼ │ │ │ │REFUNDED│ │
│ ┌─────────┐ │ │ │ └────────┘ │
│ │ REVOKED │ │ │ │ │
│ └─────────┘ │ │ │ │
│ │ │ │ │
└──────────────────────────────┘ │ │ │
▼ │ │ │
┌─────────┐ │ │ │
│ EXPIRED │ <──────────────────────────────┘ │ │
└─────────┘ │ │
│ │
┌──────────────────────────────────┘ │
▼ │
┌─────────┐ │
│ FAILED │ │
└─────────┘ │
│
│ ESCROW PATH │
│ │
│ VERIFIED ──> ESCROWED ──> CONFIRMING │
│ │ │ │
│ ▼ ▼ │
│ AUTO_RELEASING RELEASED │
│ │ │
│ ▼ │
│ RELEASED │
│ │
│ DISPUTE PATH │
│ │
│ ESCROWED/CONFIRMING ──> DISPUTING │
│ │ │
│ ▼ │
│ ARBITRATING │
│ / | \ │
│ ▼ ▼ ▼ │
│ RESOLVED_ RESOLVED_ RESOLVED_ │
│ REFUND RELEASE SPLIT │
│ │
│ SPECIAL │
│ │
│ SETTLING ──> PARTIAL_SETTLED │
│ LOCKED ──> UNLOCKING ──> CANCELLED │
│ MINTED ──> CANCELLED │
└──────────────────────────────────────────────────┘State Categories
| Category | States | Description |
|---|---|---|
| Happy path | minted, presented, verified, locked, settling, settled, fulfilled | Standard payment completion |
| Escrow path | escrowed, confirming, auto_releasing, released | Funds held pending delivery |
| Dispute path | disputing, arbitrating, resolved_refund, resolved_release, resolved_split | Conflict resolution |
| Terminal | revoked, expired, failed, refunded, cancelled | Final states, no further transitions |
| Special | partial_settled, unlocking | Edge cases for partial settlement and fund release |
Key Transition Rules
- Revocation is only possible from
mintedorpresented(before the merchant has verified) - Expiration can happen from any pre-settlement state (
minted,presented,verified,locked) - Refund is only possible after settlement (
settledorfulfilled) - Disputes can be raised from
escrowedorconfirming - Arbitration resolves to one of three outcomes: full refund, full release, or split
Cell Claim Algorithm
The CellClaimAlgorithm is the hot path for every agent payment. It uses PostgreSQL row-level locking to prevent concurrent claims on the same cells:
1. Query AVAILABLE cells by currency, ordered by value DESC
2. Lock rows with FOR UPDATE SKIP LOCKED (non-blocking)
3. Greedily select cells until the requested amount is covered
4. If the last cell exceeds the remaining amount, split it
5. Mark selected cells as CLAIMED with the mandate_id
6. Return the claimed cellsWhy Largest-First Selection?
The algorithm intentionally selects the largest cells first so that fewer cells are consumed per payment. This reduces row-lock contention under high-throughput agent workloads.
Cell Splitting
When the last selected cell exceeds the remaining amount needed, it is split:
- The claimed portion is reduced to the exact remaining amount
- A new
AVAILABLEcell is created with the overshoot value - Both operations happen within the same database transaction
-- Lock available cells, largest first, skip already-locked rows
SELECT cell_id, commitment_id, value, currency, status
FROM funding_cells
WHERE status = 'available' AND currency = $1
ORDER BY value DESC
FOR UPDATE SKIP LOCKEDThe SKIP LOCKED clause is essential -- it means concurrent agents competing for cells will never block each other. If an agent locks a cell, other agents simply skip it and claim the next available one.
Additional Operations
| Operation | Description |
|---|---|
release_cells | Return claimed cells back to available (idempotent) |
spend_cells | Mark claimed cells as spent with the payment object ID |
split_cell | Split an available cell into smaller cells (sum must equal original) |
merge_cells | Combine multiple available cells into one (same commitment and currency) |
PaymentObjectMinter
The PaymentObjectMinter is the only authorized path for creating payment objects. It implements a 3-port architecture with Protocol-based dependency injection:
┌─────────────────────────────┐
│ PaymentObjectMinter │
│ │
│ ┌───────────────────────┐ │
│ │ SpendingMandateLookup │ │ Port 1: Fetch and validate the mandate
│ │ Port │ │
│ └───────────┬───────────┘ │
│ │ │
│ ┌───────────▼───────────┐ │
│ │ CellClaimPort │ │ Port 2: Claim funding cells from budget
│ └───────────┬───────────┘ │
│ │ │
│ ┌───────────▼───────────┐ │
│ │ SigningPort │ │ Port 3: Sign the payment object
│ └───────────────────────┘ │
│ │
└─────────────────────────────┘Minting Pipeline
The mint() method executes six steps:
- Lookup mandate -- Fetch the
SpendingMandateby ID via the lookup port - Validate mandate -- Check status (must be
active), expiration, valid-from date, amount limits (per-tx, daily, total budget remaining), and merchant scope (allowed/blocked lists with wildcard support) - Claim funding cells -- Reserve budget cells covering the requested amount
- Compute session hash -- SHA-256 of mandate_id + merchant + amount + timestamp + nonce for replay protection
- Sign -- Build a canonical JSON payload (sorted keys, no whitespace) and sign it via the signing port. If the privacy tier is
hybridorfull_zk, generate a ZKP proof viasardis_zkp - Return minted PaymentObject -- Ready for presentation to a merchant
Error Codes
| Error Code | HTTP Status | Description |
|---|---|---|
MANDATE_NOT_FOUND | 404 | No mandate with that ID exists |
MANDATE_NOT_ACTIVE | 409 | Mandate status is not active |
MANDATE_EXPIRED | 410 | Mandate has passed its expiration |
MANDATE_NOT_YET_VALID | 422 | Mandate's valid_from is in the future |
INVALID_AMOUNT | 422 | Amount is zero or negative |
PER_TX_LIMIT_EXCEEDED | 422 | Amount exceeds per-transaction limit |
TOTAL_BUDGET_EXCEEDED | 422 | Amount exceeds remaining mandate budget |
MERCHANT_BLOCKED | 422 | Merchant is on the mandate's blocked list |
MERCHANT_NOT_ALLOWED | 422 | Merchant is not on the mandate's allowed list |
INSUFFICIENT_BUDGET | 422 | Not enough funding cells to cover the amount |
SIGNING_FAILED | 500 | Cryptographic signing failed |
Merchant Countersignature
Merchants sign payment verification data to prevent agents from fabricating consumption data. The system supports a 3-tier verification fallback:
| Tier | Method | Use Case |
|---|---|---|
| Primary | EIP-712 typed data | Production -- structured, chain-aware signing |
| Fallback | EIP-191 personal_sign | Transitional -- one release deprecation cycle |
| Dev | HMAC-SHA256 | Development/testing -- shared secret signing |
EIP-712 Typed Data Structure
{
"types": {
"EIP712Domain": [
{ "name": "name", "type": "string" },
{ "name": "version", "type": "string" }
],
"PaymentVerification": [
{ "name": "objectId", "type": "string" },
{ "name": "objectHash", "type": "string" },
{ "name": "merchantId", "type": "string" },
{ "name": "amount", "type": "string" },
{ "name": "currency", "type": "string" }
]
},
"primaryType": "PaymentVerification",
"domain": { "name": "Sardis", "version": "1" }
}The merchant signs the typed data with their settlement wallet private key. The API recovers the signer address and compares it against the merchant's registered settlement_address.
Usage Report Countersignature
For metered/usage-based billing, merchants also countersign usage reports:
verifier = MerchantCountersignature(shared_secret="merchant_hmac_key")
result = verifier.verify(
meter_id="meter_abc",
usage_delta=Decimal("150"),
timestamp=1711200000,
signature="a1b2c3...",
)Timestamp drift is validated to prevent replay attacks (maximum 5 minutes clock skew).
API Reference
All endpoints require authentication via Bearer token. Payment objects are scoped to the authenticated principal's organization.
Mint a Payment Object
POST /api/v2/payment-objects/mintRequest body:
{
"mandate_id": "mandate_abc123",
"merchant_id": "merchant_openai",
"amount": "49.99",
"currency": "USDC",
"privacy_tier": "transparent",
"memo": "GPT-4 API usage for March",
"expires_in_seconds": 3600,
"metadata": { "invoice_id": "inv_001" }
}Response (201 Created):
{
"object_id": "po_a1b2c3d4e5f6g7h8",
"mandate_id": "mandate_abc123",
"merchant_id": "merchant_openai",
"exact_amount": "49.99",
"currency": "USDC",
"status": "minted",
"privacy_tier": "transparent",
"session_hash": "e3b0c44298fc1c149afbf4c8996fb924...",
"cell_ids": ["cell_001a2b3c4d5e", "cell_006f7g8h9i0j"],
"signature_chain": ["sha256_abc123..."],
"object_hash": "9f86d081884c7d659a2feaa0c55ad015...",
"expires_at": "2026-03-25T14:00:00+00:00",
"created_at": "2026-03-25T13:00:00+00:00",
"metadata": { "invoice_id": "inv_001" }
}Get a Payment Object
GET /api/v2/payment-objects/{object_id}Returns the full payment object including current status, cell IDs, and signature chain.
List Payment Objects
GET /api/v2/payment-objects?mandate_id=...&merchant_id=...&status=...&offset=0&limit=50Supports filtering by mandate_id, merchant_id, and status. Results are ordered by created_at DESC with pagination.
Present to Merchant
POST /api/v2/payment-objects/{object_id}/presentTransitions minted to presented. Validates merchant ID match and checks expiration.
Request body:
{
"merchant_id": "merchant_openai",
"merchant_signature": null
}Verify (Merchant-Side)
POST /api/v2/payment-objects/{object_id}/verifyTransitions presented to verified. Requires a valid merchant signature over the object hash using EIP-712 typed data, EIP-191, or HMAC fallback.
Request body:
{
"merchant_id": "merchant_openai",
"merchant_signature": "0xa1b2c3..."
}Code Examples
Python SDK
from sardis import Sardis
sardis = Sardis(api_key="sk_live_...")
# Mint a payment object from a spending mandate
po = await sardis.payment_objects.mint(
mandate_id="mandate_abc123",
merchant_id="merchant_openai",
amount="49.99",
currency="USDC",
expires_in_seconds=3600,
metadata={"invoice_id": "inv_001"},
)
print(f"Minted: {po.object_id}, status={po.status}")
# Minted: po_a1b2c3d4e5f6g7h8, status=minted
# Check status
obj = await sardis.payment_objects.get(po.object_id)
print(f"Status: {obj.status}, cells: {obj.cell_ids}")
# List all payment objects for a mandate
objects = await sardis.payment_objects.list(
mandate_id="mandate_abc123",
status="minted",
)
for po in objects:
print(f" {po.object_id}: {po.exact_amount} {po.currency}")TypeScript SDK
import { Sardis } from "@sardis/sdk";
const sardis = new Sardis({ apiKey: "sk_live_..." });
// Mint a payment object
const po = await sardis.paymentObjects.mint({
mandateId: "mandate_abc123",
merchantId: "merchant_openai",
amount: "49.99",
currency: "USDC",
expiresInSeconds: 3600,
metadata: { invoiceId: "inv_001" },
});
console.log(`Minted: ${po.objectId}, status=${po.status}`);
// Present to merchant
const presented = await sardis.paymentObjects.present(po.objectId, {
merchantId: "merchant_openai",
});
// Merchant verifies (merchant-side)
const verified = await sardis.paymentObjects.verify(po.objectId, {
merchantId: "merchant_openai",
merchantSignature: await wallet.signTypedData(typedData),
});Direct API (cURL)
# Mint a payment object
curl -X POST https://api.sardis.sh/api/v2/payment-objects/mint \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"mandate_id": "mandate_abc123",
"merchant_id": "merchant_openai",
"amount": "49.99",
"currency": "USDC"
}'
# Get payment object status
curl https://api.sardis.sh/api/v2/payment-objects/po_a1b2c3d4e5f6g7h8 \
-H "Authorization: Bearer sk_live_..."Architecture Diagram
The full flow from spending mandate to settlement:
┌─────────────────────────────────────────────────────────────────┐
│ AGENT REQUEST │
│ │
│ "Pay $49.99 to OpenAI for GPT-4 API usage" │
└───────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ SPENDING MANDATE LOOKUP │
│ │
│ mandate_abc123: active, $500/day limit, OpenAI allowed │
│ Remaining budget: $342.50 │
│ Per-tx limit: $100 │
└───────────────────────────┬─────────────────────────────────────┘
│ validated
▼
┌─────────────────────────────────────────────────────────────────┐
│ CELL CLAIM (UTXO) │
│ │
│ Available cells (largest first): │
│ cell_001: $30.00 ──> CLAIMED │
│ cell_002: $25.00 ──> CLAIMED ($19.99) + SPLIT ($5.01 avail) │
│ │
│ SELECT ... FOR UPDATE SKIP LOCKED │
│ Total claimed: $49.99 │
└───────────────────────────┬─────────────────────────────────────┘
│ cells claimed
▼
┌─────────────────────────────────────────────────────────────────┐
│ SESSION HASH + SIGNING │
│ │
│ session_hash = SHA-256(mandate + merchant + amount + ts + nonce│
│ signature = sign(canonical_json_payload) │
│ [Optional: ZKP proof for hybrid/full_zk privacy] │
└───────────────────────────┬─────────────────────────────────────┘
│ signed
▼
┌─────────────────────────────────────────────────────────────────┐
│ PAYMENT OBJECT (MINTED) │
│ │
│ po_a1b2c3d4e5f6g7h8 │
│ Amount: $49.99 USDC │ Merchant: merchant_openai │
│ Cells: [cell_001, cell_002] │
│ Session hash: e3b0c44... │ Expires: +1h │
└───────────────────────────┬─────────────────────────────────────┘
│
┌─────────────┼──────────────┐
▼ ▼ ▼
PRESENTED PRESENTED (expired/revoked)
│ │
▼ ▼
VERIFIED VERIFIED
│ │
▼ ▼
LOCKED ──> ESCROWED ──> DISPUTING
│ │
▼ ▼
SETTLING ARBITRATING
│ / | \
▼ ▼ ▼ ▼
SETTLED REFUND RELEASE SPLIT
│
▼
FULFILLEDKey Invariants
- One-time use --
one_time_useis alwaysTrue. A payment object can never be reused. - Signature chain -- Must contain signatures from the principal, issuer, and agent.
- Session hash uniqueness -- No two payment objects can share the same session hash (replay protection via random nonce + timestamp).
- Cell accounting -- The sum of claimed cell values always equals
exact_amount(enforced by the split algorithm). - State machine enforcement -- Every transition is validated against
VALID_TRANSITIONS. Invalid transitions raiseValueError. - Immutable identity -- Once minted, the
object_id,mandate_id,merchant_id,exact_amount,cell_ids, andsession_hashcannot change.
Related Documentation
- Spending Mandates -- The authorization primitive that controls what agents can spend
- Payments -- Payment execution pipeline (policy checks, compliance, chain execution)
- Policy Engine -- Time-based policies, merchant categories, and runtime guardrails
- Wallets -- Safe Smart Account infrastructure for agent wallets
- Security -- Cryptographic signing, MPC wallets, and audit trails