@aroha-sdk/credentials
stable · v1.2.0

Spending Mandates

Cryptographically delegate exactly what each agent in your chain is authorized to spend — and have that authority enforced without trusting any intermediate agent.

How the chain works

Human
root grantor
intent mandate
($1000 max)
Orchestrator
personal agent
cart mandate
($450 airlines)
Flight Agent
service agent
payment mandate
($389.99 exact)
Payment
terminal agent

Each arrow is a signed token. The payment processor sees a cryptographic proof that the human authorized up to $389.99 at this airline — without ever seeing the human's private key or trusting any intermediate agent's claim.

Settlement is alpha — spending limits are not auto-enforced

Mandate constraints are enforced cryptographically during creation and attenuation — an agent cannot forge a mandate that exceeds its parent's limits. However, the protocol does not automatically block a payment if an agent ignores the mandate and attempts to charge more. You must read result.mandate.constraints from verifyMandate() and enforce limits in your own payment logic. See Step 07 below.

01

Install the credentials package

The mandate API lives in @aroha-sdk/credentials. You only need this package — it has no peer dependencies beyond @noble/ed25519.

npm install @aroha-sdk/credentials @noble/ed25519 @noble/hashes
02

Generate key pairs for each participant

Each participant in the chain needs an Ed25519 key pair. In production, private keys live in a secrets manager — never in source code.

import * as ed from "@noble/ed25519";
import { sha512 } from "@noble/hashes/sha512";

// Required for Node.js < 20 (noble needs a sync sha512 shim)
ed.etc.sha512Sync = (...m) => sha512(...m);

// One key pair per participant
const humanPrivKey   = ed.utils.randomPrivateKey();
const humanPubKey    = await ed.getPublicKeyAsync(humanPrivKey);

const agentPrivKey   = ed.utils.randomPrivateKey();
const agentPubKey    = await ed.getPublicKeyAsync(agentPrivKey);

const paymentPrivKey = ed.utils.randomPrivateKey();
const paymentPubKey  = await ed.getPublicKeyAsync(paymentPrivKey);

// DIDs are any URI — did:aroha: format used in examples
const humanDID   = "did:aroha:human:alice";
const agentDID   = "did:aroha:agent:orchestrator";
const paymentDID = "did:aroha:provider:payment-proc";
03

Issue an Intent Mandate (Human → Agent)

The human issues a root Intent Mandate to their orchestrator agent. This sets the maximum spend envelope — all downstream mandates must be equal or narrower.

import { issueIntentMandate } from "@aroha-sdk/credentials";

const intentMandate = await issueIntentMandate(
  humanDID,    // grantor — the human authorising spend
  agentDID,    // grantee — the orchestrator receiving authority
  {
    spendLimitUsd: 1000,       // hard ceiling for this session
    currency: "USD",
    allowedMerchants: null,    // null = any merchant
  },
  humanPrivKey,
  3_600_000    // TTL in ms (1 hour)
);

// intentMandate = { mandate, signature, token }
// token is a base64url blob — embed it in message credentialToken fields
04

Attenuate to a Cart Mandate (Agent → Service Agent)

The orchestrator narrows the mandate for a specific service agent. Constraints can only be narrowed — attempting to widen spendLimitUsd or change the merchant list to be broader throws synchronously.

import { attenuateToCart } from "@aroha-sdk/credentials";

const cartMandate = await attenuateToCart(
  intentMandate,      // the parent SignedMandate (from step 03)
  flightAgentDID,     // the service agent receiving this mandate
  {
    spendLimitUsd: 450,                    // narrowed from $1000
    currency: "USD",
    merchantCategory: "airlines",          // now restricted to airlines
    allowedMerchants: ["did:aroha:provider:united"],
  },
  agentPrivKey        // orchestrator signs this attenuation
);

// Attempting to widen throws immediately:
// attenuateToCart(intentMandate, ..., { spendLimitUsd: 9999 }, ...)
// → Error: spendLimitUsd 9999 exceeds parent limit 1000
05

Attenuate to a Payment Mandate (Service Agent → Payment Processor)

The flight agent narrows the mandate one final time to the exact ticket price before handing it to the payment processor.

import { attenuateToPayment } from "@aroha-sdk/credentials";

const paymentMandate = await attenuateToPayment(
  cartMandate,        // the parent Cart Mandate
  paymentDID,         // the payment processor's DID
  {
    spendLimitUsd: 389.99,                 // the actual ticket price
    currency: "USD",
    merchantCategory: "airlines",
    allowedMerchants: ["did:aroha:provider:united"],
  },
  flightAgentPrivKey  // flight agent signs this attenuation
);
06

Enforce at the payment gate (single call)

Use enforceSpendingConstraints() at the payment gate — it verifies the mandate AND checks the proposed amount in one call. This is the function that closes the enforcement gap: the protocol ensures a mandate cannot be forged or widened, and this call ensures the actual proposed charge is within the authorized limit.

import { enforceSpendingConstraints, SpendingConstraintViolation } from "@aroha-sdk/credentials";

try {
  const mandate = await enforceSpendingConstraints(
    paymentMandate.token,   // base64url token from the message body
    flightAgentPubKey,      // public key of whoever signed this mandate
    {
      expectedGrantee:    paymentDID,                           // my own DID
      merchantDID:        "did:aroha:provider:united",          // who I am
      proposedAmountUsd:  389.99,                               // what I intend to charge
    }
  );
  // mandate.constraints is now the verified, authorized constraint set.
  // Safe to proceed with payment.
  console.log(`Authorized: $${mandate.constraints.spendLimitUsd}`);
  // → Authorized: $389.99

} catch (err) {
  if (err instanceof SpendingConstraintViolation) {
    // err.code: "MANDATE_INVALID" | "LIMIT_EXCEEDED" | "MERCHANT_NOT_ALLOWED"
    console.error(`Rejected [${err.code}]: ${err.message}`);
    // → e.g. "Rejected [LIMIT_EXCEEDED]: Proposed $500 exceeds mandate limit $389.99"
  }
}

// Lower-level alternative — use verifyMandate() if you need finer control:
// const result = await verifyMandate(token, pubKey, myDID);
// if (!result.valid) throw new Error(result.reason);
// // then check result.mandate.constraints yourself
07

What the protocol enforces vs. what you enforce

Understanding the enforcement boundary is critical before going to production.

// ✓ ENFORCED BY THE PROTOCOL (cryptographic, automatic):
//   - spendLimitUsd cannot increase during attenuation
//   - merchantCategory and allowedMerchants can only narrow, never broaden
//   - Signature verification — tampered tokens are rejected
//   - Expiry — expired mandates are rejected
//   - Grantee lock — mandate can only be used by the intended DID

// ✗ NOT ENFORCED BY THE PROTOCOL (your responsibility):
//   - Actually checking the mandate before processing a payment
//     → you must call verifyMandate() yourself
//   - Blocking a payment if spendLimitUsd is exceeded in your system
//     → read result.mandate.constraints and enforce in your logic
//   - Tracking cumulative spend across multiple mandates
//     → maintain a spend ledger in your database
//   - Settlement / fund movement
//     → use IArohaSettlement (alpha) or your own payment backend

// The mandate proves intent and authorization.
// Enforcement of actual money movement is your application's job.

Run the end-to-end demo

The SDK repo includes a working demo script that runs the full mandate chain, including forgery attempts, over-scope rejections, and expired mandate detection. No network required.

git clone https://github.com/aroha-labs/aroha-sdk.git
cd aroha-sdk
npm install
node scripts/demo-mandate-chain.mjs

Need AP2 payment credentials?

AP2Settlement bridges your Aroha spending mandates to Google's Agent Payments Protocol (AP2) — generating ES256-signed SD-JWT verifiable credentials that any AP2-compatible payment processor can verify. The aroha_mandate_ref field in every AP2 mandate links back to the Aroha chain above for full end-to-end traceability.

AP2 Payment Settlement docs