diff --git a/examples/x402-policy-daily-budget/README.md b/examples/x402-policy-daily-budget/README.md new file mode 100644 index 0000000..765cccc --- /dev/null +++ b/examples/x402-policy-daily-budget/README.md @@ -0,0 +1,63 @@ +# x402 policy: daily budget per agent + +A small, runnable policy engine for the SDK's [pre-sign policy hook](../../packages/sdk/README.md#pre-sign-policy-hook-experimental) (`initX402({ policy })`, experimental since 0.16.0). Every `x402Fetch()` asks it before signing, and it answers `ALLOW` or `DENY` based on a per-agent daily spend cap. + +**This is a reference, not a guarantee.** Read [What this does not solve](#what-this-does-not-solve) before relying on it. + +## What it does + +- **Daily cap per agent.** The agent is the signer address (`ctx.signer`); the day is the UTC calendar day. One default cap, optional per-agent overrides. Amounts are base units of the asset (`BigInt`, no floats). +- **Atomic check-and-commit.** `tryCommit` adds the amount only if `spent today + amount <= cap`, in one step. Two concurrent requests can never both pass on the same remaining budget: one gets `ALLOW`, the other `DENY`. +- **Bound, short-lived ALLOW.** An `ALLOW` echoes the exact `ctx.contextHash` and carries `expiresAt = now + ttlMs` (default 2 s). `requireExpiry: true` is set on the hook. +- **Fails closed on policy change.** `currentVersion()` is wired; `update()` bumps the version, so an `ALLOW` issued before a change is refused by the SDK with `STALE`. + +## Use + +```typescript +import { DailyBudgetPolicy } from './src/daily-budget-policy.ts'; + +const budget = new DailyBudgetPolicy({ + dailyCap: 10_000_000n, // 1 USDC per agent per day (7 decimals) + caps: { 'GAB...': 50_000_000n }, // optional overrides + ttlMs: 2_000, +}); + +agent.initX402({ signer, network: 'stellar:pubnet', policy: budget.hook() }); +``` + +Requires Node >= 22.12 (npm 10), the same as the SDK. No `--legacy-peer-deps` is needed here; with npm 9 / Node 20, `npm ci` rejects the lockfile. + +```bash +npm install +npm test # node:test via tsx, no network +npm run typecheck +npm start # offline demo with fake contexts +``` + +The SDK is referenced as `file:../../packages/sdk` and only its types are imported, so build it once (`cd ../../packages/sdk && npm install --legacy-peer-deps && npm run build`, as the SDK CI does; `@stellar/mpp` 0.7.1 still declares a peer of `@stellar/stellar-sdk` ^15) before `typecheck`. + +## Multi-process: map it to SQL + +The in-memory store is atomic only inside one process (no `await` between read and write). Across processes, make the database do the check in a single conditional write: + +```sql +INSERT INTO agent_daily_spend (agent, day, spent) VALUES ($1, $2, 0) ON CONFLICT DO NOTHING; + +UPDATE agent_daily_spend + SET spent = spent + $3 + WHERE agent = $1 AND day = $2 AND spent + $3 <= $4 +RETURNING spent; -- 0 rows => DENY +``` + +Never `SELECT` the spend and then `UPDATE` it: that reintroduces the race. + +## What this does not solve + +These are the limits listed in the SDK README (from [#96](https://github.com/nirium-protocol/nirium/issues/96)). This example does not remove them. + +- **L01, pending-capacity reservation.** The budget is committed when the policy says `ALLOW`, not when the payment settles. A payment that is allowed but then refused by a later SDK check (`STALE`, `EXPIRED`, ...), fails at the signer, or is never settled by the facilitator still counts against today's budget, and there is no release or reconciliation against on-chain settlement here. The opposite also holds: nothing ties the committed amount to what actually lands on-chain. Real reservation of capacity across signed-but-unsettled payments is not solved. +- **L02, same-process signer access.** This is a check on the agent side. Any code in the same process that holds the raw signer (or replaces its `signAuthEntry`) can sign without asking this policy. Nothing on-chain enforces the cap. +- **Version check is not atomic with signing.** As the SDK README says, `currentVersion()` narrows the stale-ALLOW window; it does not close it. +- **In-memory state.** Restarting the process resets the spend. Use a durable store (see SQL above) for anything real. + +For a fuller engine (reserve floor, per-action caps, approvals, hash-chained audit trail), see [AgentLedger](https://useagentledger.xyz). AgentLedger is built by Fernando Mendoza, who also contributes to Nirium. diff --git a/examples/x402-policy-daily-budget/package-lock.json b/examples/x402-policy-daily-budget/package-lock.json new file mode 100644 index 0000000..84fc031 --- /dev/null +++ b/examples/x402-policy-daily-budget/package-lock.json @@ -0,0 +1,567 @@ +{ + "name": "nirium-x402-policy-daily-budget", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nirium-x402-policy-daily-budget", + "version": "0.1.0", + "dependencies": { + "nirium": "file:../../packages/sdk" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.21.0", + "typescript": "^5.7.0" + } + }, + "../../packages/sdk": { + "name": "nirium", + "version": "0.16.1", + "license": "Apache-2.0", + "dependencies": { + "@stellar/mpp": "^0.7.1", + "@stellar/stellar-sdk": "^16.3.0", + "@x402/fetch": "^2.28.0", + "@x402/stellar": "^2.28.0", + "mppx": "^0.6.31", + "viem": "^2.55.19", + "ws": "^8.21.3" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^26.2.0", + "@types/ws": "^8.18.1", + "@x402/core": "^2.28.0", + "jest": "^30.4.2", + "ts-jest": "^29.4.12", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nirium": { + "resolved": "../../packages/sdk", + "link": true + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + } + } +} diff --git a/examples/x402-policy-daily-budget/package.json b/examples/x402-policy-daily-budget/package.json new file mode 100644 index 0000000..ebd7629 --- /dev/null +++ b/examples/x402-policy-daily-budget/package.json @@ -0,0 +1,19 @@ +{ + "name": "nirium-x402-policy-daily-budget", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/demo.ts", + "typecheck": "tsc --noEmit", + "test": "tsx --test test/*.test.ts" + }, + "dependencies": { + "nirium": "file:../../packages/sdk" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.21.0", + "typescript": "^5.7.0" + } +} diff --git a/examples/x402-policy-daily-budget/src/budget-store.ts b/examples/x402-policy-daily-budget/src/budget-store.ts new file mode 100644 index 0000000..65c2000 --- /dev/null +++ b/examples/x402-policy-daily-budget/src/budget-store.ts @@ -0,0 +1,48 @@ +// Daily spend store with an atomic check-and-commit. +// +// The whole point of this file is `tryCommit`: the read of today's spend and the +// write of the new total happen in one step, so two concurrent requests can never +// both pass on the same remaining budget. +// +// In-memory: tryCommit is synchronous (no await between read and write), and +// JavaScript runs it to completion before any other callback, so it is atomic +// within one process. For several processes, use one conditional write in your +// database instead, e.g. in SQL: +// +// INSERT INTO agent_daily_spend (agent, day, spent) VALUES ($agent, $day, 0) +// ON CONFLICT DO NOTHING; +// UPDATE agent_daily_spend +// SET spent = spent + $amt +// WHERE agent = $agent AND day = $day AND spent + $amt <= $cap +// RETURNING spent; -- 0 rows => DENY +// +// The row count of that UPDATE is the decision; never SELECT then UPDATE. + +export interface CommitResult { + ok: boolean; + /** Spend for (agent, day) after the call, in the asset's base units. */ + spent: bigint; +} + +export interface BudgetStore { + tryCommit(agent: string, day: string, amount: bigint, cap: bigint): CommitResult; + spent(agent: string, day: string): bigint; +} + +export class InMemoryBudgetStore implements BudgetStore { + private readonly rows = new Map(); + + tryCommit(agent: string, day: string, amount: bigint, cap: bigint): CommitResult { + const key = `${agent}|${day}`; + const current = this.rows.get(key) ?? 0n; + // Not a single `await` in here: that is what keeps it atomic. + if (amount <= 0n || current + amount > cap) return { ok: false, spent: current }; + const next = current + amount; + this.rows.set(key, next); + return { ok: true, spent: next }; + } + + spent(agent: string, day: string): bigint { + return this.rows.get(`${agent}|${day}`) ?? 0n; + } +} diff --git a/examples/x402-policy-daily-budget/src/daily-budget-policy.ts b/examples/x402-policy-daily-budget/src/daily-budget-policy.ts new file mode 100644 index 0000000..27490e4 --- /dev/null +++ b/examples/x402-policy-daily-budget/src/daily-budget-policy.ts @@ -0,0 +1,90 @@ +// Per-agent daily spend cap for the pre-sign policy hook of initX402({ policy }). +// Reference example for #127; see README for what it does NOT solve. + +import type { X402PolicyContext, X402PolicyDecision, X402PolicyHook } from 'nirium'; +import { InMemoryBudgetStore, type BudgetStore } from './budget-store.ts'; + +export interface DailyBudgetConfig { + /** Default daily cap per agent (signer address), in the asset's base units. */ + dailyCap: bigint; + /** Optional per-agent overrides. */ + caps?: Record; + /** How long an ALLOW stays valid, in ms. Keep it short. Default 2000. */ + ttlMs?: number; + store?: BudgetStore; + /** Injectable clock (epoch ms), for tests. */ + now?: () => number; +} + +/** UTC calendar day, e.g. "2026-10-13". */ +export function utcDay(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} + +export class DailyBudgetPolicy { + private version = 1; + private config: DailyBudgetConfig; + readonly store: BudgetStore; + private readonly now: () => number; + + constructor(config: DailyBudgetConfig) { + this.config = config; + this.store = config.store ?? new InMemoryBudgetStore(); + this.now = config.now ?? Date.now; + } + + /** Opaque version string; changes on every policy update. */ + currentVersion(): string { + return `daily-budget-v${this.version}`; + } + + /** Change caps. Bumps the version, so any ALLOW issued before fails closed (STALE). */ + update(patch: Partial>): void { + this.config = { ...this.config, ...patch }; + this.version += 1; + } + + capFor(agent: string): bigint { + return this.config.caps?.[agent] ?? this.config.dailyCap; + } + + async evaluate(ctx: X402PolicyContext): Promise { + const policyVersion = this.currentVersion(); + let amount: bigint; + try { + amount = BigInt(ctx.authorization.amount); + } catch { + return { decision: 'DENY', reason: 'amount is not an integer', policyVersion }; + } + const agent = ctx.signer; + const now = this.now(); + const day = utcDay(now); + const cap = this.capFor(agent); + + const res = this.store.tryCommit(agent, day, amount, cap); + if (!res.ok) { + return { + decision: 'DENY', + reason: `daily cap ${cap} exceeded: spent ${res.spent}, requested ${amount}`, + policyVersion, + }; + } + return { + decision: 'ALLOW', + contextHash: ctx.contextHash, // exact echo: binds this ALLOW to this authorization + policyVersion, + expiresAt: now + (this.config.ttlMs ?? 2_000), + decisionId: `${agent}:${day}:${ctx.authorization.nonce}`, + }; + } + + /** The object to pass as `initX402({ policy })`. */ + hook(extra: Partial> = {}): X402PolicyHook { + return { + evaluate: (ctx) => this.evaluate(ctx), + currentVersion: async () => this.currentVersion(), + requireExpiry: true, + ...extra, + }; + } +} diff --git a/examples/x402-policy-daily-budget/src/demo.ts b/examples/x402-policy-daily-budget/src/demo.ts new file mode 100644 index 0000000..dcf7fab --- /dev/null +++ b/examples/x402-policy-daily-budget/src/demo.ts @@ -0,0 +1,17 @@ +// Offline demo: runs the policy against fake contexts. No network, no signer. +import type { X402PolicyContext } from 'nirium'; +import { DailyBudgetPolicy } from './daily-budget-policy.ts'; + +const ctx = (amount: string, nonce: string) => ({ + contextHash: `hash-${nonce}`, + signer: 'GAGENT', + authorization: { amount, nonce }, +}) as unknown as X402PolicyContext; + +const policy = new DailyBudgetPolicy({ dailyCap: 1_000_000n }); // 0.1 USDC (7 decimals) +for (const [amt, n] of [['600000', 'a'], ['600000', 'b'], ['400000', 'c']] as const) { + const d = await policy.evaluate(ctx(amt, n)); + console.log(amt, '=>', d.decision, d.reason ?? ''); +} +// Real use: +// agent.initX402({ signer, network: 'stellar:pubnet', policy: policy.hook() }); diff --git a/examples/x402-policy-daily-budget/test/daily-budget-policy.test.ts b/examples/x402-policy-daily-budget/test/daily-budget-policy.test.ts new file mode 100644 index 0000000..8395082 --- /dev/null +++ b/examples/x402-policy-daily-budget/test/daily-budget-policy.test.ts @@ -0,0 +1,85 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { X402PolicyContext } from 'nirium'; +import { DailyBudgetPolicy } from '../src/daily-budget-policy.ts'; + +const T0 = Date.UTC(2026, 9, 13, 12, 0, 0); + +function ctx(amount: string, nonce: string, signer = 'GAGENT'): X402PolicyContext { + return { + contextVersion: 1, + url: 'https://api.example/paid', + method: 'GET', + requirements: { scheme: 'exact', network: 'stellar:testnet', asset: 'CUSDC', payTo: 'GMERCHANT', amount, maxTimeoutSeconds: 60 }, + authorization: { + preimageXdr: '', preimageType: 'legacy', payloadHash: `p-${nonce}`, networkPassphrase: 'Test SDF Network ; September 2015', + nonce, signatureExpirationLedger: 1, contractId: 'CUSDC', functionName: 'transfer', + from: signer, to: 'GMERCHANT', amount, + }, + signer, + contextHash: `ctx-${nonce}`, + }; +} + +test('ALLOW echoes the exact contextHash with a short expiresAt and the current version', async () => { + const p = new DailyBudgetPolicy({ dailyCap: 100n, ttlMs: 1500, now: () => T0 }); + const d = await p.evaluate(ctx('40', 'n1')); + assert.equal(d.decision, 'ALLOW'); + assert.equal(d.contextHash, 'ctx-n1'); + assert.equal(d.expiresAt, T0 + 1500); + assert.equal(d.policyVersion, p.currentVersion()); + assert.equal(p.store.spent('GAGENT', '2026-10-13'), 40n); +}); + +test('DENY when today spend + amount exceeds the cap, and nothing is committed', async () => { + const p = new DailyBudgetPolicy({ dailyCap: 100n, now: () => T0 }); + assert.equal((await p.evaluate(ctx('70', 'a'))).decision, 'ALLOW'); + const d = await p.evaluate(ctx('31', 'b')); + assert.equal(d.decision, 'DENY'); + assert.equal(d.contextHash, undefined); + assert.equal(p.store.spent('GAGENT', '2026-10-13'), 70n); + assert.equal((await p.evaluate(ctx('30', 'c'))).decision, 'ALLOW'); // exactly at cap is fine +}); + +test('concurrent pair on the same remaining budget: exactly one passes', async () => { + const p = new DailyBudgetPolicy({ dailyCap: 100n, now: () => T0 }); + await p.evaluate(ctx('40', 'pre')); // 60 left + const [a, b] = await Promise.all([p.evaluate(ctx('50', 'x')), p.evaluate(ctx('50', 'y'))]); + assert.deepEqual([a.decision, b.decision].sort(), ['ALLOW', 'DENY']); + assert.equal(p.store.spent('GAGENT', '2026-10-13'), 90n); +}); + +test('many concurrent requests never overspend the cap', async () => { + const p = new DailyBudgetPolicy({ dailyCap: 1000n, now: () => T0 }); + const out = await Promise.all(Array.from({ length: 50 }, (_, i) => p.evaluate(ctx('70', `r${i}`)))); + assert.equal(out.filter((d) => d.decision === 'ALLOW').length, 14); + assert.ok(p.store.spent('GAGENT', '2026-10-13') <= 1000n); +}); + +test('stale policy version: a change between decision and signing fails closed', async () => { + const p = new DailyBudgetPolicy({ dailyCap: 100n, now: () => T0 }); + const hook = p.hook(); + const c = ctx('10', 's'); + const d = await hook.evaluate(c, { signal: new AbortController().signal }); + assert.equal(d.decision, 'ALLOW'); + p.update({ dailyCap: 5n }); // policy tightened before signing + const live = await hook.currentVersion!(c, { signal: new AbortController().signal }); + assert.notEqual(live, d.policyVersion, 'the SDK compares these and refuses with STALE'); + assert.equal(hook.requireExpiry, true); +}); + +test('caps are per agent and per UTC day', async () => { + let now = T0; + const p = new DailyBudgetPolicy({ dailyCap: 100n, caps: { GBIG: 500n }, now: () => now }); + assert.equal((await p.evaluate(ctx('100', 'a'))).decision, 'ALLOW'); + assert.equal((await p.evaluate(ctx('300', 'b', 'GBIG'))).decision, 'ALLOW'); + assert.equal((await p.evaluate(ctx('1', 'c'))).decision, 'DENY'); + now = T0 + 24 * 3600_000; + assert.equal((await p.evaluate(ctx('100', 'd'))).decision, 'ALLOW'); +}); + +test('non-integer or non-positive amounts are denied', async () => { + const p = new DailyBudgetPolicy({ dailyCap: 100n, now: () => T0 }); + assert.equal((await p.evaluate(ctx('1.5', 'f'))).decision, 'DENY'); + assert.equal((await p.evaluate(ctx('0', 'z'))).decision, 'DENY'); +}); diff --git a/examples/x402-policy-daily-budget/tsconfig.json b/examples/x402-policy-daily-budget/tsconfig.json new file mode 100644 index 0000000..dacb45e --- /dev/null +++ b/examples/x402-policy-daily-budget/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +}