leash-monad 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,6 +48,18 @@ app.get("/api/forecast", async (req, res) => {
48
48
 
49
49
  A revoked, expired or over-cap agent gets a 403 with the reason before you've done any work.
50
50
 
51
+ **One payment, one response.** Pass `claim` so a payment can't be replayed: it receives the payment's tx hash and
52
+ returns `false` if you've seen it before (a unique row in your database is enough). Payments older than
53
+ `maxPaymentAgeSeconds` (default 300) are refused either way, and so are payments in any token outside
54
+ `acceptTokens` (default: USDC and the Leash test dollar), because a leash can name any ERC-20.
55
+
56
+ ```ts
57
+ const g = await leashGate(req, { seller: MY_ADDRESS, priceUsd: "1", claim: (tx) => db.insertIfNew("payments", tx) });
58
+ ```
59
+
60
+ Full examples, each runnable: [Express](../examples/express-seller.mjs), [Next.js route](../examples/next-route.ts),
61
+ [check only](../examples/check-before-serving.mjs), [agent side](../examples/agent-pays.mjs).
62
+
51
63
  ## Pay (agent)
52
64
 
53
65
  ```ts
@@ -61,6 +73,14 @@ const res = await fetchWithLeash("https://leash-monad.vercel.app/api/forecast",
61
73
  On a 402 it pays inside the leash and retries once. If the leash says no, the payment reverts on-chain and
62
74
  nothing leaves the account.
63
75
 
76
+ ## Getting a leashed agent
77
+
78
+ 1. `npx leash-monad-mcp --new-key` prints a fresh agent address and key (or use any key your agent already has).
79
+ 2. Open [leash-monad.vercel.app/own.html](https://leash-monad.vercel.app/own.html), sign in with a passkey and leash
80
+ that address: daily cap, sellers, expiry, demo dollars or USDC.
81
+ 3. Use the key with `fetchWithLeash` / `payWithLeash`, or give it to an MCP client through
82
+ [`leash-monad-mcp`](https://www.npmjs.com/package/leash-monad-mcp).
83
+
64
84
  ## Networks
65
85
 
66
86
  | | Chain | LeashHub |
package/dist/index.cjs CHANGED
@@ -43,7 +43,8 @@ var networks = {
43
43
  explorer: "https://monadvision.com",
44
44
  hub: "0x64a489074dd6a4b3b977e5f635a178366a8c12c3",
45
45
  identityRegistry: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432",
46
- testUsd: "0x4adf40e6e5113339635e6dc54ff638e7b63bbea3"
46
+ testUsd: "0x4adf40e6e5113339635e6dc54ff638e7b63bbea3",
47
+ usdc: "0x754704Bc059F8C67012fEd69BC8A327a5aafb603"
47
48
  },
48
49
  testnet: {
49
50
  name: "Monad Testnet",
@@ -124,6 +125,10 @@ async function leashGate(req, cfg) {
124
125
  const client = clientFor(cfg);
125
126
  const rc = await client.getTransactionReceipt({ hash: payment }).catch(() => null);
126
127
  if (!rc || rc.status !== "success") return { allow: false, status: 402, body: { error: "payment_not_found", ref } };
128
+ const mined = await client.getBlock({ blockNumber: rc.blockNumber });
129
+ if (Date.now() / 1e3 - Number(mined.timestamp) > (cfg.maxPaymentAgeSeconds ?? 300)) {
130
+ return { allow: false, status: 402, body: { error: "payment_expired", ref, reason: "That payment is too old to use again. Pay for this request." } };
131
+ }
127
132
  const d = cfg.decimals ?? 6, need = (0, import_viem2.parseUnits)(cfg.priceUsd, d);
128
133
  for (const log of rc.logs) {
129
134
  try {
@@ -131,7 +136,14 @@ async function leashGate(req, cfg) {
131
136
  if (ev.eventName !== "Paid") continue;
132
137
  const a = ev.args;
133
138
  const account = await client.readContract({ address: net(cfg).hub, abi: hubAbi, functionName: "accountOf", args: [agent] });
139
+ const accepted = (cfg.acceptTokens ?? [net(cfg).usdc, net(cfg).testUsd]).filter(Boolean).map((t) => t.toLowerCase());
140
+ if (!accepted.includes(a.token.toLowerCase())) {
141
+ return { allow: false, status: 402, body: { error: "token_not_accepted", ref, reason: "That payment was in a token this seller doesn't accept." } };
142
+ }
134
143
  if (log.address.toLowerCase() === account.toLowerCase() && a.agent.toLowerCase() === agent.toLowerCase() && a.seller.toLowerCase() === cfg.seller.toLowerCase() && a.amount >= need && a.ref === ref) {
144
+ if (cfg.claim && !await cfg.claim(payment)) {
145
+ return { allow: false, status: 402, body: { error: "payment_used", ref, reason: "That payment was already used. Pay for this request." } };
146
+ }
135
147
  const verdict = await checkAgent(agent, cfg.seller, "0", cfg);
136
148
  return { allow: true, agent, paid: (0, import_viem2.formatUnits)(a.amount, d), tx: payment, verdict };
137
149
  }
package/dist/index.d.ts CHANGED
@@ -60,11 +60,20 @@ export type GateResult = {
60
60
  * agent pays through its Leash (one tx), then retries with x-leash-payment: <txHash>
61
61
  * 2nd request: the gate checks the receipt's Paid event (agent, seller, amount >= price, ref) -> allow
62
62
  */
63
- export declare function leashGate(req: GateRequest, cfg: {
63
+ export type GateConfig = {
64
64
  seller: Address;
65
65
  priceUsd: string;
66
66
  resource?: string;
67
- } & Options): Promise<GateResult>;
67
+ /** Refuse payments mined longer ago than this (default 300 s). Limits how long one payment proof stays usable. */
68
+ maxPaymentAgeSeconds?: number;
69
+ /** Record a payment as used; return false if it was already used. Plug in your store (Redis, a DB row) so one
70
+ * payment buys exactly one response. Without it, a payment can be replayed until it is maxPaymentAgeSeconds old. */
71
+ claim?: (tx: Hex) => boolean | Promise<boolean>;
72
+ /** Tokens you accept as payment. Default: the network's USDC and the Leash test dollar. A leash can name any
73
+ * ERC-20, so without this check an agent could "pay" in a token worth nothing. */
74
+ acceptTokens?: Address[];
75
+ } & Options;
76
+ export declare function leashGate(req: GateRequest, cfg: GateConfig): Promise<GateResult>;
68
77
  /** Pay a seller from inside the leash. Reverts on-chain (and throws here) if it would break the leash. */
69
78
  export declare function payWithLeash(agentAccount: Account, seller: Address, amountUsd: string, ref: Hex, o?: Options & {
70
79
  gas?: bigint;
package/dist/index.mjs CHANGED
@@ -20,7 +20,8 @@ var networks = {
20
20
  explorer: "https://monadvision.com",
21
21
  hub: "0x64a489074dd6a4b3b977e5f635a178366a8c12c3",
22
22
  identityRegistry: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432",
23
- testUsd: "0x4adf40e6e5113339635e6dc54ff638e7b63bbea3"
23
+ testUsd: "0x4adf40e6e5113339635e6dc54ff638e7b63bbea3",
24
+ usdc: "0x754704Bc059F8C67012fEd69BC8A327a5aafb603"
24
25
  },
25
26
  testnet: {
26
27
  name: "Monad Testnet",
@@ -101,6 +102,10 @@ async function leashGate(req, cfg) {
101
102
  const client = clientFor(cfg);
102
103
  const rc = await client.getTransactionReceipt({ hash: payment }).catch(() => null);
103
104
  if (!rc || rc.status !== "success") return { allow: false, status: 402, body: { error: "payment_not_found", ref } };
105
+ const mined = await client.getBlock({ blockNumber: rc.blockNumber });
106
+ if (Date.now() / 1e3 - Number(mined.timestamp) > (cfg.maxPaymentAgeSeconds ?? 300)) {
107
+ return { allow: false, status: 402, body: { error: "payment_expired", ref, reason: "That payment is too old to use again. Pay for this request." } };
108
+ }
104
109
  const d = cfg.decimals ?? 6, need = parseUnits(cfg.priceUsd, d);
105
110
  for (const log of rc.logs) {
106
111
  try {
@@ -108,7 +113,14 @@ async function leashGate(req, cfg) {
108
113
  if (ev.eventName !== "Paid") continue;
109
114
  const a = ev.args;
110
115
  const account = await client.readContract({ address: net(cfg).hub, abi: hubAbi, functionName: "accountOf", args: [agent] });
116
+ const accepted = (cfg.acceptTokens ?? [net(cfg).usdc, net(cfg).testUsd]).filter(Boolean).map((t) => t.toLowerCase());
117
+ if (!accepted.includes(a.token.toLowerCase())) {
118
+ return { allow: false, status: 402, body: { error: "token_not_accepted", ref, reason: "That payment was in a token this seller doesn't accept." } };
119
+ }
111
120
  if (log.address.toLowerCase() === account.toLowerCase() && a.agent.toLowerCase() === agent.toLowerCase() && a.seller.toLowerCase() === cfg.seller.toLowerCase() && a.amount >= need && a.ref === ref) {
121
+ if (cfg.claim && !await cfg.claim(payment)) {
122
+ return { allow: false, status: 402, body: { error: "payment_used", ref, reason: "That payment was already used. Pay for this request." } };
123
+ }
112
124
  const verdict = await checkAgent(agent, cfg.seller, "0", cfg);
113
125
  return { allow: true, agent, paid: formatUnits(a.amount, d), tx: payment, verdict };
114
126
  }
@@ -7,6 +7,8 @@ export type LeashNetwork = {
7
7
  hub: Address;
8
8
  identityRegistry: Address;
9
9
  testUsd?: Address;
10
+ /** Circle's USDC on this network, when there is one. */
11
+ usdc?: Address;
10
12
  };
11
13
  /** Live deployments. Mainnet is the default everywhere in this package. */
12
14
  export declare const networks: {
@@ -18,6 +20,7 @@ export declare const networks: {
18
20
  hub: "0x64a489074dd6a4b3b977e5f635a178366a8c12c3";
19
21
  identityRegistry: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432";
20
22
  testUsd: "0x4adf40e6e5113339635e6dc54ff638e7b63bbea3";
23
+ usdc: "0x754704Bc059F8C67012fEd69BC8A327a5aafb603";
21
24
  };
22
25
  testnet: {
23
26
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leash-monad",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Spend limits for AI agents on Monad. One call tells a seller whose agent it is, what it may spend today, and whether a payment will go through.",
5
5
  "license": "MIT",
6
6
  "author": "Robert Moore",