agentwallet-mcp 1.13.0 → 1.13.2
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 +4 -3
- package/build/index.js +23 -10
- package/build/local-solana.js +21 -4
- package/build/local-wallet.js +26 -0
- package/build/x402-payment.d.ts +43 -0
- package/build/x402-payment.js +81 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ Use `AGENTWALLET_KEYFILE=/path/to/key` instead if you would rather keep the key
|
|
|
43
43
|
|
|
44
44
|
`AGENTWALLET_MAX_TX_NATIVE` is a per-transaction ceiling in native units (ETH, MATIC and so on). In hosted mode the server enforces limits; in local mode there is no server, so these guards are the only ones there are. Set them.
|
|
45
45
|
|
|
46
|
-
`AGENTWALLET_MAX_TX_TOKEN` is the equivalent ceiling for ERC-20 movement, in human units of the token. **Set this one too if you hold stablecoins.** The native cap cannot see a token transfer: an ERC-20 send carries `value = 0` with the amount in the calldata, so `AGENTWALLET_MAX_TX_NATIVE` alone leaves a USDC balance uncapped. `AGENTWALLET_MAX_TX_TOKEN` covers `transfer`, `transferFrom`, `approve`, `increaseAllowance`, Permit2 `approve`, and `deposit()` / `withdraw(uint256)` on the chain's own wrapped-native contract (so `wrap_eth` and `unwrap_eth` keep working under the cap), the approvals because an unbounded allowance is a drain waiting to happen. Any other calldata sent to a token contract or to Permit2 is refused while the cap is set, because the guard cannot price it; set `AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1` to allow such calls deliberately. Calls to other contracts (routers, bridges) pass, since they can only pull what an approval already allowed; a contract counts as "other" only when it demonstrably declines `decimals()`, so an absurd or malformed answer, or an RPC that cannot be reached, refuses rather than allows. Decimals are resolved locally from the trusted registry, then the token's own `decimals()`; a token that resolves neither way is evaluated at 0 decimals, the only value that cannot fail open.
|
|
46
|
+
`AGENTWALLET_MAX_TX_TOKEN` is the equivalent ceiling for ERC-20 movement, in human units of the token. **Set this one too if you hold stablecoins.** The native cap cannot see a token transfer: an ERC-20 send carries `value = 0` with the amount in the calldata, so `AGENTWALLET_MAX_TX_NATIVE` alone leaves a USDC balance uncapped. `AGENTWALLET_MAX_TX_TOKEN` covers `transfer`, `transferFrom`, `approve`, `increaseAllowance`, Permit2 `approve`, and `deposit()` / `withdraw(uint256)` on the chain's own wrapped-native contract (so `wrap_eth` and `unwrap_eth` keep working under the cap), the approvals because an unbounded allowance is a drain waiting to happen. Any other calldata sent to a token contract or to Permit2 is refused while the cap is set, because the guard cannot price it; set `AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1` to allow such calls deliberately. `increaseAllowance` is judged on the resulting total: the current allowance is read first and the call refuses if it cannot be, so two compliant increments cannot stack a standing allowance above the cap. Calls to other contracts (routers, bridges) pass, since they can only pull what an approval already allowed; a contract counts as "other" only when it demonstrably declines `decimals()`, so an absurd or malformed answer, or an RPC that cannot be reached, refuses rather than allows. Decimals are resolved locally from the trusted registry, then the token's own `decimals()`; a token that resolves neither way is evaluated at 0 decimals, the only value that cannot fail open. For a token outside the registry the RPC is the only source of decimals, and an RPC you do not control could scale the amount and the cap together; pin such tokens with `AGENTWALLET_TOKEN_DECIMALS` (for example `8453:0x<token>=6`) and the pin is used everywhere the RPC's answer would have been.
|
|
47
47
|
|
|
48
48
|
### Solana local signing
|
|
49
49
|
|
|
@@ -120,7 +120,7 @@ Add to your config:
|
|
|
120
120
|
|
|
121
121
|
> `AGENTWALLET_WALLET_ID` is optional. Set it to enable x402 auto-pay: when you exceed the free tier without a credit card, the MCP server automatically pays for operations with USDC from this wallet.
|
|
122
122
|
>
|
|
123
|
-
> **Auto-pay safety cap.** `AGENTWALLET_MAX_AUTOPAY` (optional, default `1`) is the most one x402 payment may authorize, in stablecoin units: `1` means one dollar. It prices registry stablecoins only (USDC, USDT, USDbC, DAI on the supported chains, USDC and USDT on Solana). A requirement in the chain's native asset or in any other token is refused, because "1" measured in ETH is a few thousand dollars; list such assets in `AGENTWALLET_AUTOPAY_ASSETS` (comma-separated addresses or mints, or the word `native`) to allow them, and the cap then applies in that asset's own units. Any requirement above the cap is rejected instead of paid, so a malformed or tampered payment requirement cannot drain the wallet. The cap is the operator's ceiling: a `max_payment` argument can lower it for one call but never raise it. Raise the variable itself if you genuinely need larger automatic payments (for example `"5"` for up to 5 USDC per call).
|
|
123
|
+
> **Auto-pay safety cap.** `AGENTWALLET_MAX_AUTOPAY` (optional, default `1`) is the most one x402 payment may authorize, in stablecoin units: `1` means one dollar. It prices registry stablecoins only (USDC, USDT, USDbC, DAI on the supported chains, USDC and USDT on Solana). A requirement in the chain's native asset or in any other token is refused, because "1" measured in ETH is a few thousand dollars; list such assets in `AGENTWALLET_AUTOPAY_ASSETS` (comma-separated addresses or mints, or the word `native`) to allow them, and the cap then applies in that asset's own units. Any requirement above the cap is rejected instead of paid, so a malformed or tampered payment requirement cannot drain the wallet. This automatic path settles `exact` requirements only; an `upto` offer from the API is refused before any wallet call, because paying a usage maximum upfront is not what the offer means (use `pay_x402`, which signs a Permit2 authorization for it). The cap is the operator's ceiling: a `max_payment` argument can lower it for one call but never raise it. Raise the variable itself if you genuinely need larger automatic payments (for example `"5"` for up to 5 USDC per call).
|
|
124
124
|
|
|
125
125
|
### Claude Code
|
|
126
126
|
|
|
@@ -245,7 +245,7 @@ pay_x402(
|
|
|
245
245
|
|
|
246
246
|
**Schemes.** `exact` (EIP-3009, gasless for the payer) is preferred and works on every EVM chain AgentWallet knows. `upto` (a Permit2 maximum the seller settles at actual usage) is signed as a `PermitWitnessTransferFrom` bound to the endpoint's facilitator; it needs a one-time `approve_permit2` per token (or `AGENTWALLET_PERMIT2_AUTO_APPROVE=1`). An `upto` option without `extra.facilitatorAddress` is refused, never approximated with an upfront transfer. Native-asset and Solana requests, and AgentWallet's own paywalls, are paid by an on-chain transfer proved by transaction hash, which is what those servers verify.
|
|
247
247
|
|
|
248
|
-
**Approvals above the cap.** On a hosted wallet, a payment above the cap does not have to fail. With `request_approval` (default on; `AGENTWALLET_APPROVALS=0` turns it off) the wallet owner gets an email with approve and deny links, `pay_x402` returns an `approval_id`, and the agent retries with it once `check_approval` says approved. An approval is bound to one wallet, chain, asset, recipient and maximum, expires in 24 hours, and is consumed by one signature. Local mode has no approval channel: the caps in your environment are the policy.
|
|
248
|
+
**Approvals above the cap.** On a hosted wallet, a payment above the cap does not have to fail. With `request_approval` (default on; `AGENTWALLET_APPROVALS=0` turns it off) the wallet owner gets an email with approve and deny links, `pay_x402` returns an `approval_id`, and the agent retries with it once `check_approval` says approved. An approval is bound to one wallet, chain, asset, recipient and maximum, expires in 24 hours, and is consumed by one signature. Local mode has no approval channel: the caps in your environment are the policy, and an `approval_id` passed there is refused.
|
|
249
249
|
|
|
250
250
|
**Asset risk.** `check_token_risk` (and every `approve_token` result) reports honeypot, tax, owner-power, verified-source, holder-concentration and liquidity flags from GoPlus Security, with an on-chain fallback. A warning for the caller to weigh, never a block.
|
|
251
251
|
|
|
@@ -332,6 +332,7 @@ Which guards apply depends on who holds the key.
|
|
|
332
332
|
- `AGENTWALLET_MAX_TX_NATIVE`: ceiling per transaction in native units (ETH, MATIC, and so on)
|
|
333
333
|
- `AGENTWALLET_MAX_TX_TOKEN`: ceiling per ERC-20 transfer or approval, in human units of the token
|
|
334
334
|
- `AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS`: set to `1` to let calldata the token cap cannot price reach a token contract or Permit2 (refused by default while the cap is set)
|
|
335
|
+
- `AGENTWALLET_TOKEN_DECIMALS`: pin decimals for tokens outside the registry (`8453:0x<token>=6,<mint>=9`); a pin is used for amounts and caps instead of asking the RPC
|
|
335
336
|
- `AGENTWALLET_MAX_TX_SOL`: ceiling per native SOL transfer (also charged for the rent of a recipient token account an SPL send has to create). It does not cover SPL token amounts: `AGENTWALLET_MAX_TX_TOKEN` does, on Solana as on EVM
|
|
336
337
|
- `AGENTWALLET_MAX_AUTOPAY`: ceiling per x402 auto-payment in stablecoin units (default `1`); `AGENTWALLET_AUTOPAY_ASSETS` opts native or non-stable assets in; `AGENTWALLET_X402_MAX_TIMEOUT` bounds how long a signed authorization stays valid (default 3600 seconds)
|
|
337
338
|
- `AGENTWALLET_SOLANA_RPC_<chainId>` (or `AGENTWALLET_SOLANA_RPC` for all clusters): the RPC's genesis hash is checked against the chain id before anything is sent, and an EVM RPC's `eth_chainId` likewise, so one URL cannot silently serve a different network than the one requested
|
package/build/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
13
13
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
14
|
import { z } from 'zod';
|
|
15
15
|
import { assertPublicUrl, safeFetch, FINAL_URL_HEADER } from './ssrf-guard.js';
|
|
16
|
-
import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, assetLabel, autopayAssetAllowed, autopayEnvCap, effectiveAutopayCap, maxAuthWindowSeconds, } from './x402-payment.js';
|
|
16
|
+
import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, assetLabel, autopayAssetAllowed, autopayEnvCap, effectiveAutopayCap, maxAuthWindowSeconds, parseDecimalPins, approvalRefusal, } from './x402-payment.js';
|
|
17
17
|
import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
|
|
18
18
|
import { localSignAuthorization, localEthCall, localGetCode, localSignPermit2Upto } from './local-wallet.js';
|
|
19
19
|
import { WRAPPED_NATIVE } from './wrapped-native.js';
|
|
@@ -292,7 +292,17 @@ async function handleX402Payment(x402Data, originalPath, originalMethod, origina
|
|
|
292
292
|
if (!accepts || accepts.length === 0) {
|
|
293
293
|
throw new Error('402 Payment Required but no payment options available.');
|
|
294
294
|
}
|
|
295
|
-
|
|
295
|
+
// Only an "exact" requirement can be settled by a transfer. An upto offer
|
|
296
|
+
// names the MAXIMUM of a usage authorization; paying that maximum upfront
|
|
297
|
+
// hands the endpoint the whole ceiling instead of what was used (2026-09-28
|
|
298
|
+
// report). Such offers are refused here, before any wallet call; pay_x402
|
|
299
|
+
// settles them with a Permit2 authorization. A missing scheme is our own
|
|
300
|
+
// legacy paywall shape and means exact.
|
|
301
|
+
const accept = accepts.find((a) => !a.scheme || a.scheme === 'exact');
|
|
302
|
+
if (!accept) {
|
|
303
|
+
throw new Error(`x402 auto-pay: no "exact" payment option was offered (schemes: ${accepts.map((a) => a.scheme || '?').join(', ')}); ` +
|
|
304
|
+
`an upto maximum is not paid upfront. Use pay_x402 for this endpoint.`);
|
|
305
|
+
}
|
|
296
306
|
const payTo = String(accept.payTo || '');
|
|
297
307
|
// Determine chain_id from network string (CAIP-2, plain name, or raw ID)
|
|
298
308
|
const network = accept.network || '';
|
|
@@ -432,6 +442,7 @@ async function verifiedDecimals(chainId, token, given) {
|
|
|
432
442
|
}
|
|
433
443
|
/** Cap variables are read at send time; a typo must fail at startup, not after funding. */
|
|
434
444
|
function validateGuardEnv() {
|
|
445
|
+
parseDecimalPins(process.env.AGENTWALLET_TOKEN_DECIMALS); // throws on a malformed pin
|
|
435
446
|
for (const name of ['AGENTWALLET_MAX_TX_NATIVE', 'AGENTWALLET_MAX_TX_TOKEN', 'AGENTWALLET_MAX_TX_SOL', 'AGENTWALLET_MAX_AUTOPAY']) {
|
|
436
447
|
const v = (process.env[name] || '').trim();
|
|
437
448
|
if (v && !/^\d+(\.\d+)?$/.test(v))
|
|
@@ -550,7 +561,7 @@ function resolveChainId(network) {
|
|
|
550
561
|
const AddressSchema = z.string().regex(/^(0x[a-fA-F0-9]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$/, 'Must be a 0x-prefixed 40-hex EVM address or a base58 Solana address');
|
|
551
562
|
const server = new McpServer({
|
|
552
563
|
name: 'agentwallet',
|
|
553
|
-
version: '1.13.
|
|
564
|
+
version: '1.13.2',
|
|
554
565
|
}, {
|
|
555
566
|
instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
|
|
556
567
|
? 'This server runs in LOCAL (self-custody) mode: the operator supplied the key, signing happens in this process, and the only spend guards are the AGENTWALLET_MAX_TX_* environment caps. There is no server-side pause, daily limit or approval email; pause_wallet, unpause_wallet and delete_wallet are refused.'
|
|
@@ -1359,13 +1370,14 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
|
|
|
1359
1370
|
};
|
|
1360
1371
|
const wantApproval = request_approval ?? (process.env.AGENTWALLET_APPROVALS !== '0');
|
|
1361
1372
|
if (approval_id) {
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
const
|
|
1367
|
-
|
|
1368
|
-
|
|
1373
|
+
// Local mode refuses before any lookup: only the hosted signer can consume
|
|
1374
|
+
// an approval, so here the same id would lift the cap on every call.
|
|
1375
|
+
const want = { walletId: wallet_id, chainId, asset: tokenAddress, payTo: option.payTo, rawAmount };
|
|
1376
|
+
const local = anyLocalMode();
|
|
1377
|
+
const a = local ? null : (await api(`/approvals/${encodeURIComponent(approval_id)}`, 'GET', undefined, skip));
|
|
1378
|
+
const refusal = approvalRefusal(approval_id, a, want, local);
|
|
1379
|
+
if (refusal) {
|
|
1380
|
+
return jsonResponse({ ...overCap, approval_id, ...refusal });
|
|
1369
1381
|
}
|
|
1370
1382
|
approvalUsed = String(approval_id);
|
|
1371
1383
|
}
|
|
@@ -1763,6 +1775,7 @@ server.tool('wallet_mode', 'Report whether this server is signing locally (self-
|
|
|
1763
1775
|
x402_max_timeout_seconds: maxAuthWindowSeconds(),
|
|
1764
1776
|
per_tx_cap_token: process.env.AGENTWALLET_MAX_TX_TOKEN || 'not set (ERC-20 and SPL transfers are uncapped)',
|
|
1765
1777
|
allow_unknown_token_calls: process.env.AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS === '1',
|
|
1778
|
+
token_decimals_pins: parseDecimalPins(process.env.AGENTWALLET_TOKEN_DECIMALS).size,
|
|
1766
1779
|
};
|
|
1767
1780
|
if (isLocalMode()) {
|
|
1768
1781
|
let rpc = 'default public endpoint';
|
package/build/local-solana.js
CHANGED
|
@@ -355,10 +355,19 @@ export async function localSplTransfer(mintStr, to, rawAmount, decimals, chainId
|
|
|
355
355
|
};
|
|
356
356
|
}
|
|
357
357
|
const TOKEN_ACCOUNT_SIZE = 165;
|
|
358
|
+
const MINT_SIZE = 82;
|
|
359
|
+
/** Token-2022 pads a mint to the token-account size and tags the next byte: 1 = mint, 2 = token account. */
|
|
360
|
+
const TOKEN_2022_TYPE_MINT = 1;
|
|
358
361
|
/**
|
|
359
362
|
* A recipient must be something that can own a token account: a wallet or a
|
|
360
|
-
* program-derived wallet (Squads and friends), never a program and never
|
|
361
|
-
* token
|
|
363
|
+
* program-derived wallet (Squads and friends), never a program and never an
|
|
364
|
+
* account the token programs own. SPL sent to ATA(ATA), ATA(mint) or a program
|
|
365
|
+
* id is gone for good, or belongs to whoever kept the mint's keypair.
|
|
366
|
+
*
|
|
367
|
+
* Ownership alone decides. A length gate (>= 165 bytes) used to sit here and
|
|
368
|
+
* let an 82-byte mint through as a "wallet" (2026-10-02 report): everything
|
|
369
|
+
* the token programs own is a mint, a token account or a multisig, and none
|
|
370
|
+
* of those is a wallet.
|
|
362
371
|
*/
|
|
363
372
|
async function assertRecipientIsWallet(conn, recipient) {
|
|
364
373
|
const info = await conn.getAccountInfo(recipient);
|
|
@@ -366,8 +375,16 @@ async function assertRecipientIsWallet(conn, recipient) {
|
|
|
366
375
|
return; // never seen on chain: an ordinary fresh wallet
|
|
367
376
|
if (info.executable)
|
|
368
377
|
throw new Error(`Refusing to send to ${recipient.toBase58()}: it is a program, not a wallet.`);
|
|
369
|
-
if (
|
|
370
|
-
|
|
378
|
+
if (info.owner.equals(TOKEN_PROGRAM) || info.owner.equals(TOKEN_2022_PROGRAM)) {
|
|
379
|
+
const isMint = info.data.length === MINT_SIZE
|
|
380
|
+
|| (info.data.length > TOKEN_ACCOUNT_SIZE && info.data[TOKEN_ACCOUNT_SIZE] === TOKEN_2022_TYPE_MINT);
|
|
381
|
+
if (isMint) {
|
|
382
|
+
throw new Error(`Refusing to send to ${recipient.toBase58()}: it is a token mint, not a wallet. Pass the recipient's wallet address; the mint belongs in token_mint.`);
|
|
383
|
+
}
|
|
384
|
+
if (info.data.length >= TOKEN_ACCOUNT_SIZE) {
|
|
385
|
+
throw new Error(`Refusing to send to ${recipient.toBase58()}: it is a token account, not a wallet. Pass the owner's wallet address instead.`);
|
|
386
|
+
}
|
|
387
|
+
throw new Error(`Refusing to send to ${recipient.toBase58()}: it is an account owned by the SPL token program, not a wallet.`);
|
|
371
388
|
}
|
|
372
389
|
}
|
|
373
390
|
export function solanaWalletRecord() {
|
package/build/local-wallet.js
CHANGED
|
@@ -368,6 +368,32 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
|
|
|
368
368
|
`AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals). ` +
|
|
369
369
|
`Raise the cap deliberately if this is intended.`);
|
|
370
370
|
}
|
|
371
|
+
// increaseAllowance ADDS to what a spender may already pull. Checking the
|
|
372
|
+
// increment alone let two individually compliant calls leave a standing
|
|
373
|
+
// allowance above the cap (2026-09-28 report), so the resulting total is
|
|
374
|
+
// what gets checked, and an allowance that cannot be read refuses rather
|
|
375
|
+
// than assumes zero. approve() is absolute and needs no read.
|
|
376
|
+
if (selector === '39509351') {
|
|
377
|
+
const spenderWord = wordAt(0);
|
|
378
|
+
if (spenderWord.length !== 64)
|
|
379
|
+
throw new Error(`Blocked by local guard: calldata for ${layout.what} is truncated (spender word has ${spenderWord.length / 2} bytes, expected 32).`);
|
|
380
|
+
const spender = ('0x' + spenderWord.slice(24));
|
|
381
|
+
let current;
|
|
382
|
+
try {
|
|
383
|
+
const { pub } = clients(chainId);
|
|
384
|
+
current = await pub.readContract({ address: token, abi: ERC20_ABI, functionName: 'allowance', args: [getLocalAddress(), spender] });
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
throw new Error(`Blocked by local guard: could not read the current allowance of ${spender} on ${token} ` +
|
|
388
|
+
`(${String(err.message).split('\n')[0]}), so the resulting total cannot be checked against AGENTWALLET_MAX_TX_TOKEN.`);
|
|
389
|
+
}
|
|
390
|
+
const total = current + amount;
|
|
391
|
+
if (total > capRaw) {
|
|
392
|
+
throw new Error(`Blocked by local guard: ${layout.what} of ${amount} base units would leave a standing allowance of ${total} base units ` +
|
|
393
|
+
`for ${spender}, above AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals). ` +
|
|
394
|
+
`Use approve with an absolute amount, or raise the cap deliberately.`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
371
397
|
}
|
|
372
398
|
export async function localSend(chainId, to, valueWei, data) {
|
|
373
399
|
assertWithinNativeCap(valueWei);
|
package/build/x402-payment.d.ts
CHANGED
|
@@ -98,6 +98,17 @@ export declare const TRUSTED_SPL_DECIMALS: Record<string, number>;
|
|
|
98
98
|
* Look up decimals we already know to be correct. Returns null when the asset
|
|
99
99
|
* is unknown, which means the caller must resolve it on-chain or refuse.
|
|
100
100
|
*/
|
|
101
|
+
/**
|
|
102
|
+
* Operator-pinned decimals: AGENTWALLET_TOKEN_DECIMALS="8453:0x<token>=6,<mint>=9".
|
|
103
|
+
*
|
|
104
|
+
* Consulted before the registry and before any RPC answer. The registry pins
|
|
105
|
+
* the major stablecoins; for any other token the only remaining source of
|
|
106
|
+
* decimals is the operator's RPC, and a compromised RPC scales the amount and
|
|
107
|
+
* the cap by the same false factor, so the cap never notices (2026-09-28
|
|
108
|
+
* report). A pin makes the token's decimals a fact the RPC cannot change.
|
|
109
|
+
*/
|
|
110
|
+
export declare function parseDecimalPins(raw: string | undefined): Map<string, number>;
|
|
111
|
+
export declare function pinnedDecimals(chainId: number, token: string): number | null;
|
|
101
112
|
export declare function lookupTrustedDecimals(chainId: number, token: string): number | null;
|
|
102
113
|
/**
|
|
103
114
|
* Guard the server's declared decimals against the trusted value.
|
|
@@ -152,3 +163,35 @@ export declare const SOLANA_CHAIN_IDS: ReadonlySet<number>;
|
|
|
152
163
|
*/
|
|
153
164
|
export declare function nativeDecimals(chainId: number): number;
|
|
154
165
|
export declare function isWithinCap(rawAmountRequired: string, trustedDecimals: number, capHuman: string): boolean;
|
|
166
|
+
/** The row GET /approvals/{id} answers with. Numbers arrive as strings from the server. */
|
|
167
|
+
export interface ApprovalRow {
|
|
168
|
+
status?: string;
|
|
169
|
+
used?: number | string;
|
|
170
|
+
wallet_id?: number | string;
|
|
171
|
+
chain_id?: number | string;
|
|
172
|
+
asset?: string;
|
|
173
|
+
pay_to?: string;
|
|
174
|
+
value?: string;
|
|
175
|
+
error?: string;
|
|
176
|
+
}
|
|
177
|
+
export interface ApprovalWant {
|
|
178
|
+
walletId: number;
|
|
179
|
+
chainId: number;
|
|
180
|
+
asset: string;
|
|
181
|
+
payTo: string;
|
|
182
|
+
rawAmount: string;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Why an approval cannot stand in for the cap on this payment, or null when it can.
|
|
186
|
+
*
|
|
187
|
+
* An approval is single-use because the hosted signer marks it used in the same
|
|
188
|
+
* request that signs. A local signer never talks to that server, so in local
|
|
189
|
+
* mode nothing could ever consume one and the same id would lift the cap again
|
|
190
|
+
* on every call (2026-10-02 report). Local mode therefore refuses approvals
|
|
191
|
+
* outright, before any lookup. For a hosted wallet every bound field is checked
|
|
192
|
+
* here, the wallet included, and the server checks them again when it signs.
|
|
193
|
+
*/
|
|
194
|
+
export declare function approvalRefusal(approvalId: string, row: ApprovalRow | null | undefined, want: ApprovalWant, localMode: boolean): {
|
|
195
|
+
error: string;
|
|
196
|
+
approval_status?: string | null;
|
|
197
|
+
} | null;
|
package/build/x402-payment.js
CHANGED
|
@@ -171,9 +171,56 @@ export const TRUSTED_SPL_DECIMALS = {
|
|
|
171
171
|
* Look up decimals we already know to be correct. Returns null when the asset
|
|
172
172
|
* is unknown, which means the caller must resolve it on-chain or refuse.
|
|
173
173
|
*/
|
|
174
|
+
/**
|
|
175
|
+
* Operator-pinned decimals: AGENTWALLET_TOKEN_DECIMALS="8453:0x<token>=6,<mint>=9".
|
|
176
|
+
*
|
|
177
|
+
* Consulted before the registry and before any RPC answer. The registry pins
|
|
178
|
+
* the major stablecoins; for any other token the only remaining source of
|
|
179
|
+
* decimals is the operator's RPC, and a compromised RPC scales the amount and
|
|
180
|
+
* the cap by the same false factor, so the cap never notices (2026-09-28
|
|
181
|
+
* report). A pin makes the token's decimals a fact the RPC cannot change.
|
|
182
|
+
*/
|
|
183
|
+
export function parseDecimalPins(raw) {
|
|
184
|
+
const pins = new Map();
|
|
185
|
+
for (const entry of (raw || '').split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
186
|
+
const m = /^(?:(\d+):)?([0-9a-zA-Z]+)=(\d{1,2})$/.exec(entry);
|
|
187
|
+
if (!m)
|
|
188
|
+
throw new Error(`AGENTWALLET_TOKEN_DECIMALS entry "${entry}" is not chainId:0xtoken=decimals or mint=decimals.`);
|
|
189
|
+
const [, chain, token, dec] = m;
|
|
190
|
+
const d = Number(dec);
|
|
191
|
+
if (d > 36)
|
|
192
|
+
throw new Error(`AGENTWALLET_TOKEN_DECIMALS entry "${entry}": decimals must be 0..36.`);
|
|
193
|
+
if (/^0x[0-9a-fA-F]{40}$/.test(token)) {
|
|
194
|
+
if (!chain)
|
|
195
|
+
throw new Error(`AGENTWALLET_TOKEN_DECIMALS entry "${entry}": an EVM token needs a chain id prefix (8453:0x...=6).`);
|
|
196
|
+
pins.set(`${Number(chain)}:${token.toLowerCase()}`, d);
|
|
197
|
+
}
|
|
198
|
+
else if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(token)) {
|
|
199
|
+
pins.set(`spl:${token}`, d);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
throw new Error(`AGENTWALLET_TOKEN_DECIMALS entry "${entry}": "${token}" is not an EVM address or an SPL mint.`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return pins;
|
|
206
|
+
}
|
|
207
|
+
let pinCache = null;
|
|
208
|
+
export function pinnedDecimals(chainId, token) {
|
|
209
|
+
const raw = process.env.AGENTWALLET_TOKEN_DECIMALS || '';
|
|
210
|
+
if (!pinCache || pinCache.raw !== raw)
|
|
211
|
+
pinCache = { raw, pins: parseDecimalPins(raw) };
|
|
212
|
+
const evm = pinCache.pins.get(`${chainId}:${token.toLowerCase()}`);
|
|
213
|
+
if (typeof evm === 'number')
|
|
214
|
+
return evm;
|
|
215
|
+
const spl = pinCache.pins.get(`spl:${token}`);
|
|
216
|
+
return typeof spl === 'number' ? spl : null;
|
|
217
|
+
}
|
|
174
218
|
export function lookupTrustedDecimals(chainId, token) {
|
|
175
219
|
if (!token)
|
|
176
220
|
return null;
|
|
221
|
+
const pinned = pinnedDecimals(chainId, token);
|
|
222
|
+
if (pinned !== null)
|
|
223
|
+
return pinned;
|
|
177
224
|
const spl = TRUSTED_SPL_DECIMALS[token];
|
|
178
225
|
if (typeof spl === 'number')
|
|
179
226
|
return spl;
|
|
@@ -277,3 +324,37 @@ export function isWithinCap(rawAmountRequired, trustedDecimals, capHuman) {
|
|
|
277
324
|
const capRaw = toBaseUnits(capHuman, trustedDecimals);
|
|
278
325
|
return BigInt(rawAmountRequired) <= BigInt(capRaw);
|
|
279
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Why an approval cannot stand in for the cap on this payment, or null when it can.
|
|
329
|
+
*
|
|
330
|
+
* An approval is single-use because the hosted signer marks it used in the same
|
|
331
|
+
* request that signs. A local signer never talks to that server, so in local
|
|
332
|
+
* mode nothing could ever consume one and the same id would lift the cap again
|
|
333
|
+
* on every call (2026-10-02 report). Local mode therefore refuses approvals
|
|
334
|
+
* outright, before any lookup. For a hosted wallet every bound field is checked
|
|
335
|
+
* here, the wallet included, and the server checks them again when it signs.
|
|
336
|
+
*/
|
|
337
|
+
export function approvalRefusal(approvalId, row, want, localMode) {
|
|
338
|
+
if (localMode) {
|
|
339
|
+
return {
|
|
340
|
+
error: `Approval ${approvalId} cannot be used in local mode: approvals are consumed by the hosted signer, and this server signs with your own key, so nothing could mark it used. Local mode has no approval channel: raise AGENTWALLET_MAX_AUTOPAY or AGENTWALLET_MAX_TX_TOKEN yourself.`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
const status = row?.status ?? null;
|
|
344
|
+
// "used" must read as exactly 0. A missing or unparseable field refuses rather than passes.
|
|
345
|
+
if (status !== 'approved' || Number(row?.used) !== 0) {
|
|
346
|
+
const state = status === 'approved' ? 'already used' : `${status ?? 'unknown'}, not approved`;
|
|
347
|
+
return { approval_status: status, error: `Approval ${approvalId} is ${state}.${row?.error ? ' ' + row.error : ''}` };
|
|
348
|
+
}
|
|
349
|
+
const value = String(row?.value ?? '');
|
|
350
|
+
const covers = Number(row?.wallet_id) === want.walletId
|
|
351
|
+
&& Number(row?.chain_id) === want.chainId
|
|
352
|
+
&& String(row?.asset ?? '').toLowerCase() === want.asset.toLowerCase()
|
|
353
|
+
&& String(row?.pay_to ?? '').toLowerCase() === want.payTo.toLowerCase()
|
|
354
|
+
&& /^\d+$/.test(value) && /^\d+$/.test(want.rawAmount)
|
|
355
|
+
&& BigInt(value) >= BigInt(want.rawAmount);
|
|
356
|
+
if (!covers) {
|
|
357
|
+
return { error: 'Approval does not cover this payment (wallet, asset, recipient, chain or amount differ).' };
|
|
358
|
+
}
|
|
359
|
+
return null;
|
|
360
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentwallet-mcp",
|
|
3
3
|
"mcpName": "io.github.hifriendbot/agentwallet",
|
|
4
|
-
"version": "1.13.
|
|
4
|
+
"version": "1.13.2",
|
|
5
5
|
"description": "MCP server for AgentWallet: self-custody local signing on EVM and Solana, or hosted wallets. Sign, broadcast, pay x402 invoices, and accept x402 payments.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "build/index.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsc",
|
|
18
18
|
"start": "node build/index.js",
|
|
19
|
-
"test": "node test/x402-payment.test.mjs && node test/eip3009.test.mjs && node test/permit2-risk.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/decimals-range-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs && node test/x402-policy.test.mjs && node test/local-guard-shape.test.mjs && node test/
|
|
19
|
+
"test": "node test/x402-payment.test.mjs && node test/eip3009.test.mjs && node test/permit2-risk.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/decimals-range-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs && node test/x402-policy.test.mjs && node test/local-guard-shape.test.mjs && node test/allowance-total-cap.test.mjs && node test/autopay-scheme.test.mjs && node test/spl-recipient-guard.test.mjs && node test/approval-binding.test.mjs",
|
|
20
20
|
"prepublishOnly": "npm run build"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|