@metalntwx/metal-pay 0.1.0 → 0.2.0
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 +10 -6
- package/dist/client.d.ts +6 -0
- package/dist/client.js +30 -1
- package/dist/mandates/account.d.ts +5 -21
- package/dist/mandates/account.js +28 -10
- package/dist/mandates/client.js +6 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,6 +22,8 @@ From 2.23 the stock client pays only tokens in its own table unless its spend
|
|
|
22
22
|
controls list others; `createMetalPaymentClient` lists `*USDZ` there, capped
|
|
23
23
|
at `maxAmount`. On a client of your own, add
|
|
24
24
|
`{ network, asset: USDZ.address }` to `setSpendControls({ allowedAssets })`.
|
|
25
|
+
A paid request is never redirected to another origin, so no other site
|
|
26
|
+
receives the payment. `createMandatePaymentClient` does the same.
|
|
25
27
|
|
|
26
28
|
```typescript
|
|
27
29
|
import { privateKeyToAccount } from "viem/accounts";
|
|
@@ -88,8 +90,10 @@ budget, how many payments, an expiry) as a Verifiable Intent mandate;
|
|
|
88
90
|
`ingestCredential` verifies one against the issuers the owner trusts, and
|
|
89
91
|
`bindCall`, `replaceCall` and `revokeCall` build the owner's transactions to its own
|
|
90
92
|
MetalAccount. Wrap those calls with `authorizeMandateCall(client, trustedFactory,
|
|
91
|
-
call, signQuorum)` to authorize an account self-call
|
|
92
|
-
|
|
93
|
+
call, signQuorum)` to authorize an account self-call, or a list of them as one
|
|
94
|
+
batch that runs whole or not at all, with its existing weighted quorum. The
|
|
95
|
+
client must name its chain: the digest the quorum signs is computed from it,
|
|
96
|
+
never read from the RPC endpoint. The callback supports the same EOA, P-256 and passkey signatures as
|
|
93
97
|
ordinary account execution. Factory-created clones share an immutable VI
|
|
94
98
|
verifier; budgets and replay protection live in each account. `createMandatePaymentClient({ credential, signDigest, network })`
|
|
95
99
|
is the payer, and `MandateExactScheme` the same payer as a scheme for an
|
|
@@ -98,11 +102,11 @@ owner's account whose signature is the agent's per-payment mandate, which
|
|
|
98
102
|
the account accepts or refuses through ERC-1271. Sellers and facilitators
|
|
99
103
|
see an ordinary `exact` payment. The client sets no limit of its own.
|
|
100
104
|
`readCheck` names the limit behind a refusal, `readMandateState` reads the
|
|
101
|
-
spend, and `pullCall` builds a direct payment with no seller.
|
|
102
|
-
|
|
105
|
+
spend, and `pullCall` builds a direct payment with no seller. A credential's amounts
|
|
106
|
+
are decimal, to the cent. `verifyMandate` and `verifyMandatePayment` trust the
|
|
103
107
|
MetalAccountFactory `METAL_NETWORKS` names for the chain, or the
|
|
104
|
-
`accountFactory` given,
|
|
105
|
-
|
|
108
|
+
`accountFactory` given, check the account's clone code against the
|
|
109
|
+
factory's current implementation, and match the successful token transfer to the seller's requirements.
|
|
106
110
|
The module runs on Node only.
|
|
107
111
|
|
|
108
112
|
**RPC.** Neither payers nor payees need one. Each network's `rpc` is its
|
package/dist/client.d.ts
CHANGED
|
@@ -46,6 +46,12 @@ export interface MetalPaymentClientConfig {
|
|
|
46
46
|
* `maxAmountPerPayment` base units when one is given.
|
|
47
47
|
*/
|
|
48
48
|
export declare function allowAsset(client: x402Client, network: string, asset: string, maxAmountPerPayment?: bigint): void;
|
|
49
|
+
/**
|
|
50
|
+
* `fetch` that follows the redirects of a request carrying a payment only
|
|
51
|
+
* within the origin it was made for, and answers a redirect to any other
|
|
52
|
+
* origin as it is, so no other origin receives the payment.
|
|
53
|
+
*/
|
|
54
|
+
export declare function keepPaymentOnOrigin(fetch: typeof globalThis.fetch): typeof globalThis.fetch;
|
|
49
55
|
export interface MetalPaymentClient {
|
|
50
56
|
/** `fetch` that answers a 402 by paying and retrying, once. */
|
|
51
57
|
fetchWithPayment: ReturnType<typeof wrapFetchWithPayment>;
|
package/dist/client.js
CHANGED
|
@@ -43,6 +43,35 @@ export function allowAsset(client, network, asset, maxAmountPerPayment) {
|
|
|
43
43
|
allowedAssets: [{ network, asset, ...(maxAmountPerPayment !== undefined && { maxAmountPerPayment: maxAmountPerPayment.toString() }) }],
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
|
+
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
|
47
|
+
/**
|
|
48
|
+
* `fetch` that follows the redirects of a request carrying a payment only
|
|
49
|
+
* within the origin it was made for, and answers a redirect to any other
|
|
50
|
+
* origin as it is, so no other origin receives the payment.
|
|
51
|
+
*/
|
|
52
|
+
export function keepPaymentOnOrigin(fetch) {
|
|
53
|
+
return async (input, init) => {
|
|
54
|
+
const request = new Request(input, init);
|
|
55
|
+
if (!request.headers.has("PAYMENT-SIGNATURE") && !request.headers.has("X-PAYMENT"))
|
|
56
|
+
return fetch(request);
|
|
57
|
+
const body = request.body === null ? undefined : await request.arrayBuffer();
|
|
58
|
+
let url = new URL(request.url);
|
|
59
|
+
let method = request.method;
|
|
60
|
+
for (let hops = 0;; hops++) {
|
|
61
|
+
const sendsBody = method !== "GET" && method !== "HEAD";
|
|
62
|
+
const response = await fetch(new Request(url, { method, headers: request.headers, ...(sendsBody && body !== undefined && { body }), signal: request.signal, redirect: "manual" }));
|
|
63
|
+
const location = response.headers.get("location");
|
|
64
|
+
if (!REDIRECTS.has(response.status) || location === null || hops === 20)
|
|
65
|
+
return response;
|
|
66
|
+
const next = new URL(location, url);
|
|
67
|
+
if (next.origin !== url.origin)
|
|
68
|
+
return response;
|
|
69
|
+
if (response.status === 303 || (response.status <= 302 && method === "POST"))
|
|
70
|
+
method = "GET";
|
|
71
|
+
url = next;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
46
75
|
/** One call to pay: `const { fetchWithPayment } = createMetalPaymentClient({ account, network: "devnet" })`. */
|
|
47
76
|
export function createMetalPaymentClient({ account, network, fetch = globalThis.fetch, maxAmount = DEFAULT_MAX_PAYMENT, clearing, }) {
|
|
48
77
|
const resolved = resolveNetwork(network);
|
|
@@ -64,7 +93,7 @@ export function createMetalPaymentClient({ account, network, fetch = globalThis.
|
|
|
64
93
|
};
|
|
65
94
|
});
|
|
66
95
|
return {
|
|
67
|
-
fetchWithPayment: wrapFetchWithPayment(clearing ? clearing.wrapFetch(fetch) : fetch, client),
|
|
96
|
+
fetchWithPayment: wrapFetchWithPayment(clearing ? clearing.wrapFetch(keepPaymentOnOrigin(fetch)) : keepPaymentOnOrigin(fetch), client),
|
|
68
97
|
client,
|
|
69
98
|
network: resolved,
|
|
70
99
|
};
|
|
@@ -57,23 +57,6 @@ export declare const metalAccountAbi: readonly [{
|
|
|
57
57
|
readonly outputs: readonly [{
|
|
58
58
|
readonly type: "uint256";
|
|
59
59
|
}];
|
|
60
|
-
}, {
|
|
61
|
-
readonly name: "executeDigest";
|
|
62
|
-
readonly type: "function";
|
|
63
|
-
readonly stateMutability: "view";
|
|
64
|
-
readonly inputs: readonly [{
|
|
65
|
-
readonly type: "bytes32";
|
|
66
|
-
readonly name: "mode";
|
|
67
|
-
}, {
|
|
68
|
-
readonly type: "bytes";
|
|
69
|
-
readonly name: "executionData";
|
|
70
|
-
}, {
|
|
71
|
-
readonly type: "uint256";
|
|
72
|
-
readonly name: "nonce";
|
|
73
|
-
}];
|
|
74
|
-
readonly outputs: readonly [{
|
|
75
|
-
readonly type: "bytes32";
|
|
76
|
-
}];
|
|
77
60
|
}, {
|
|
78
61
|
readonly name: "executeSigned";
|
|
79
62
|
readonly type: "function";
|
|
@@ -100,7 +83,8 @@ export declare function metalAccountCode(implementation: Address): Hex;
|
|
|
100
83
|
export declare function assertMetalAccount(client: PublicClient, account: Address, factory: Address, blockNumber?: bigint): Promise<void>;
|
|
101
84
|
/** The gas an executeSigned adds to the call it carries, by the quorum signature's signers. */
|
|
102
85
|
export declare function quorumGas(signature: Hex): bigint;
|
|
103
|
-
/**
|
|
104
|
-
*
|
|
105
|
-
* abi.encode(bytes[] signers,
|
|
106
|
-
|
|
86
|
+
/** Self-calls approved by the account's existing ERC-7913 quorum, in one batch
|
|
87
|
+
* that runs whole or not at all. The signing callback may collect mixed keys,
|
|
88
|
+
* weights and passkeys; it returns the normal abi.encode(bytes[] signers,
|
|
89
|
+
* bytes[] signatures) account signature. */
|
|
90
|
+
export declare function authorizeMandateCall(client: PublicClient, factory: Address, call: MandateCall | readonly MandateCall[], signQuorum: (digest: Hex) => Promise<Hex>): Promise<MandateCall>;
|
package/dist/mandates/account.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { concat, decodeAbiParameters, encodeAbiParameters, encodeFunctionData, bytesToHex, hexToBigInt, parseAbi, parseAbiParameters, size } from "viem";
|
|
1
|
+
import { concat, decodeAbiParameters, encodeAbiParameters, encodeFunctionData, bytesToHex, hashTypedData, hexToBigInt, isAddressEqual, parseAbi, parseAbiParameters, size } from "viem";
|
|
2
2
|
export const metalAccountFactoryAbi = parseAbi([
|
|
3
3
|
"function implementation() view returns (address)",
|
|
4
4
|
"function createAccount(bytes[] signers, uint64[] weights, uint64 threshold) returns (address)",
|
|
@@ -6,7 +6,6 @@ export const metalAccountFactoryAbi = parseAbi([
|
|
|
6
6
|
]);
|
|
7
7
|
export const metalAccountAbi = parseAbi([
|
|
8
8
|
"function nonces(address owner, uint192 key) view returns (uint256)",
|
|
9
|
-
"function executeDigest(bytes32 mode, bytes executionData, uint256 nonce) view returns (bytes32)",
|
|
10
9
|
"function executeSigned(bytes32 mode, bytes executionData, uint256 nonce, bytes signature)",
|
|
11
10
|
]);
|
|
12
11
|
export const ACCOUNT_BATCH_MODE = "0x0100000000000000000000000000000000000000000000000000000000000000";
|
|
@@ -56,18 +55,37 @@ export function quorumGas(signature) {
|
|
|
56
55
|
});
|
|
57
56
|
return gas;
|
|
58
57
|
}
|
|
59
|
-
/**
|
|
60
|
-
*
|
|
61
|
-
* abi.encode(bytes[] signers,
|
|
58
|
+
/** Self-calls approved by the account's existing ERC-7913 quorum, in one batch
|
|
59
|
+
* that runs whole or not at all. The signing callback may collect mixed keys,
|
|
60
|
+
* weights and passkeys; it returns the normal abi.encode(bytes[] signers,
|
|
61
|
+
* bytes[] signatures) account signature. */
|
|
62
62
|
export async function authorizeMandateCall(client, factory, call, signQuorum) {
|
|
63
|
-
|
|
63
|
+
const chainId = client.chain?.id;
|
|
64
|
+
if (chainId === undefined)
|
|
65
|
+
throw new Error("the RPC client names no chain");
|
|
66
|
+
const calls = Array.isArray(call) ? call : [call];
|
|
67
|
+
const [first] = calls;
|
|
68
|
+
if (first === undefined)
|
|
69
|
+
throw new Error("no call to authorize");
|
|
70
|
+
const account = first.to;
|
|
71
|
+
if (calls.some((c) => !isAddressEqual(c.to, account)))
|
|
72
|
+
throw new Error("every call in one batch goes to the same account");
|
|
73
|
+
await assertMetalAccount(client, account, factory);
|
|
64
74
|
// A fresh 192-bit nonce lane per call: two administration calls in flight,
|
|
65
75
|
// or one beside the owner's own operations on lane 0, never claim one nonce.
|
|
66
76
|
const lane = hexToBigInt(bytesToHex(crypto.getRandomValues(new Uint8Array(24))));
|
|
67
|
-
const sequence = await client.readContract({ address:
|
|
77
|
+
const sequence = await client.readContract({ address: account, abi: metalAccountAbi, functionName: "nonces", args: [account, lane] });
|
|
68
78
|
const nonce = (lane << 64n) | sequence;
|
|
69
|
-
const executionData = encodeAbiParameters(parseAbiParameters("(address target, uint256 value, bytes callData)[]"), [
|
|
70
|
-
|
|
79
|
+
const executionData = encodeAbiParameters(parseAbiParameters("(address target, uint256 value, bytes callData)[]"), [calls.map((c) => ({ target: account, value: 0n, callData: c.data }))]);
|
|
80
|
+
// The account's executeDigest, computed here: an RPC endpoint that answered
|
|
81
|
+
// it could have the quorum sign a batch of its own choosing.
|
|
82
|
+
const digest = hashTypedData({
|
|
83
|
+
domain: { name: "MetalAccount", version: "1", chainId, verifyingContract: account },
|
|
84
|
+
types: { Execute: [{ name: "mode", type: "bytes32" }, { name: "executionData", type: "bytes" }, { name: "nonce", type: "uint256" }] },
|
|
85
|
+
primaryType: "Execute",
|
|
86
|
+
message: { mode: ACCOUNT_BATCH_MODE, executionData, nonce },
|
|
87
|
+
});
|
|
71
88
|
const signature = await signQuorum(digest);
|
|
72
|
-
|
|
89
|
+
const gas = calls.reduce((sum, c) => sum + c.gas, 0n) + quorumGas(signature);
|
|
90
|
+
return { ...first, gas, data: encodeFunctionData({ abi: metalAccountAbi, functionName: "executeSigned", args: [ACCOUNT_BATCH_MODE, executionData, nonce, signature] }) };
|
|
73
91
|
}
|
package/dist/mandates/client.js
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
import { x402Client } from "@x402/core/client";
|
|
17
17
|
import { wrapFetchWithPayment } from "@x402/fetch";
|
|
18
18
|
import { getAddress } from "viem";
|
|
19
|
-
import { allowAsset } from "../client.js";
|
|
20
|
-
import { resolveNetwork } from "../networks.js";
|
|
19
|
+
import { allowAsset, keepPaymentOnOrigin } from "../client.js";
|
|
20
|
+
import { USDZ, formatUsdz, resolveNetwork } from "../networks.js";
|
|
21
21
|
import { mandateReference } from "./binding.js";
|
|
22
22
|
import { lineItemOf, signPayment, x402Offer } from "./checkout.js";
|
|
23
23
|
import { asksSignedOffer, mandateExtension } from "./extension.js";
|
|
@@ -63,6 +63,9 @@ export class MandateExactScheme {
|
|
|
63
63
|
throw new Error(`a mandate pays by ERC-3009 only, not ${String(method)}`);
|
|
64
64
|
if (getAddress(requirements.asset) !== this.token)
|
|
65
65
|
throw new Error(`asset ${requirements.asset} is not the mandate's token ${this.token}`);
|
|
66
|
+
if (BigInt(requirements.amount) % this.mandate.terms.unitsPerMinor !== 0n) {
|
|
67
|
+
throw new Error(`offer of ${formatUsdz(requirements.amount)} ${USDZ.symbol} is not a whole number of cents, which is all a mandate pays`);
|
|
68
|
+
}
|
|
66
69
|
const now = BigInt(Math.floor(Date.now() / 1000));
|
|
67
70
|
const transfer = {
|
|
68
71
|
chainId: chainIdOf(requirements.network),
|
|
@@ -115,7 +118,7 @@ export function createMandatePaymentClient({ network, fetch = globalThis.fetch,
|
|
|
115
118
|
const client = new x402Client().register(resolved.network, scheme);
|
|
116
119
|
allowAsset(client, resolved.network, scheme.token);
|
|
117
120
|
return {
|
|
118
|
-
fetchWithPayment: wrapFetchWithPayment(fetch, client),
|
|
121
|
+
fetchWithPayment: wrapFetchWithPayment(keepPaymentOnOrigin(fetch), client),
|
|
119
122
|
client,
|
|
120
123
|
network: resolved,
|
|
121
124
|
principal: scheme.principal,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metalntwx/metal-pay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Agent payments on Metal for the official x402 packages: the Metal devnet and testnet as `exact`-scheme networks priced in `*USDZ` for payers and sellers, a one-call payment client with a spending limit, Metal's seller token, the clearing lane for payers who pay per call without a transaction each time, and mandates, which let an agent pay from its owner's account within limits Metal enforces.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"viem": "^2"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@metalntwx/metal-ts": "0.3.
|
|
41
|
+
"@metalntwx/metal-ts": "0.3.2",
|
|
42
42
|
"@types/node": "^24.13.2",
|
|
43
43
|
"@x402/hono": "2.21.0",
|
|
44
44
|
"hono": "4.13.7",
|