@goassay-ai/sdk 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +252 -2
- package/dist/client.d.ts +88 -0
- package/dist/client.js +158 -0
- package/dist/core/binding.d.ts +56 -0
- package/dist/core/binding.js +132 -0
- package/dist/core/caip.d.ts +15 -0
- package/dist/core/caip.js +31 -0
- package/dist/core/chain.d.ts +37 -0
- package/dist/core/chain.js +148 -0
- package/dist/core/clock.d.ts +12 -0
- package/dist/core/clock.js +25 -0
- package/dist/core/errors.d.ts +9 -0
- package/dist/core/errors.js +13 -0
- package/dist/core/guard-types.d.ts +174 -0
- package/dist/core/guard-types.js +69 -0
- package/dist/core/guards.d.ts +54 -0
- package/dist/core/guards.js +408 -0
- package/dist/core/hex.d.ts +14 -0
- package/dist/core/hex.js +42 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.js +16 -0
- package/dist/core/meta.d.ts +38 -0
- package/dist/core/meta.js +79 -0
- package/dist/core/voucher.d.ts +116 -0
- package/dist/core/voucher.js +137 -0
- package/dist/errors.d.ts +149 -0
- package/dist/errors.js +205 -0
- package/dist/http.d.ts +52 -0
- package/dist/http.js +139 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +4 -0
- package/dist/mcp-url.d.ts +17 -0
- package/dist/mcp-url.js +98 -0
- package/dist/mcp.d.ts +23 -0
- package/dist/mcp.js +121 -0
- package/dist/networks.d.ts +5 -0
- package/dist/networks.js +46 -0
- package/dist/resources/context.d.ts +48 -0
- package/dist/resources/context.js +28 -0
- package/dist/resources/paid-calls.d.ts +82 -0
- package/dist/resources/paid-calls.js +156 -0
- package/dist/resources/receipts.d.ts +25 -0
- package/dist/resources/receipts.js +21 -0
- package/dist/resources/tools.d.ts +96 -0
- package/dist/resources/tools.js +241 -0
- package/dist/rest.d.ts +17 -0
- package/dist/rest.js +43 -0
- package/dist/schemes/eip155/bind.d.ts +17 -0
- package/dist/schemes/eip155/bind.js +59 -0
- package/dist/schemes/eip155/index.d.ts +71 -0
- package/dist/schemes/eip155/index.js +264 -0
- package/dist/schemes/types.d.ts +54 -0
- package/dist/schemes/types.js +1 -0
- package/dist/signer.d.ts +39 -0
- package/dist/signer.js +29 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +67 -4
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { SignerRefusal } from "./errors.js";
|
|
2
|
+
import { getAddress, isAddress } from "./hex.js";
|
|
3
|
+
/** The binding proof's own EIP-712 domain (spec section 5). Not the escrow's. */
|
|
4
|
+
export const DELEGATE_BINDING_DOMAIN_NAME = "GoAssay Agent Binding";
|
|
5
|
+
export const DELEGATE_BINDING_DOMAIN_VERSION = "1";
|
|
6
|
+
/** The longest challenge life a client accepts: the server's challenge TTL. */
|
|
7
|
+
export const MAX_BINDING_CHALLENGE_SECONDS = 300;
|
|
8
|
+
/** Exactly these fields, in this order. The order is part of the type hash. */
|
|
9
|
+
export const DELEGATE_BINDING_TYPES = {
|
|
10
|
+
DelegateBinding: [
|
|
11
|
+
{ name: "delegate", type: "address" },
|
|
12
|
+
{ name: "credentialId", type: "string" },
|
|
13
|
+
{ name: "nonce", type: "string" },
|
|
14
|
+
{ name: "expiresAt", type: "uint64" },
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
/** The typed data for checked values. */
|
|
18
|
+
export function bindingTypedData(chainId, message) {
|
|
19
|
+
return {
|
|
20
|
+
domain: { name: DELEGATE_BINDING_DOMAIN_NAME, version: DELEGATE_BINDING_DOMAIN_VERSION, chainId },
|
|
21
|
+
types: DELEGATE_BINDING_TYPES,
|
|
22
|
+
primaryType: "DelegateBinding",
|
|
23
|
+
message,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const DOMAIN_KEYS = ["name", "version", "chainId"];
|
|
27
|
+
const MESSAGE_KEYS = ["delegate", "credentialId", "nonce", "expiresAt"];
|
|
28
|
+
const MAX_UINT64 = 2n ** 64n - 1n;
|
|
29
|
+
const MAX_NONCE_LENGTH = 256;
|
|
30
|
+
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
31
|
+
function refuse(field, reason) {
|
|
32
|
+
throw new SignerRefusal("the binding challenge", field, reason);
|
|
33
|
+
}
|
|
34
|
+
function isRecord(value) {
|
|
35
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
/** A non-negative integer given as a JSON number or a decimal string. */
|
|
38
|
+
function toInteger(value) {
|
|
39
|
+
if (typeof value === "number")
|
|
40
|
+
return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null;
|
|
41
|
+
if (typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value))
|
|
42
|
+
return BigInt(value);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Checks the server's `DelegateBinding` challenge (the `data` of the challenge answer) field by
|
|
47
|
+
* field (spec section 5.2) and returns the typed data to sign, rebuilt from the client's own
|
|
48
|
+
* constants. Throws `SignerRefusal` naming the first field that fails.
|
|
49
|
+
*/
|
|
50
|
+
export function checkBindingChallenge(challenge, ctx) {
|
|
51
|
+
if (!Number.isSafeInteger(ctx.chainId) || ctx.chainId < 1)
|
|
52
|
+
refuse("context.chainId", "is not a chain id");
|
|
53
|
+
if (!Number.isFinite(ctx.nowSeconds) || ctx.nowSeconds < 0)
|
|
54
|
+
refuse("context.nowSeconds", "is not a usable time");
|
|
55
|
+
if (!isAddress(ctx.localAddress))
|
|
56
|
+
refuse("context.localAddress", "is not an address");
|
|
57
|
+
if (typeof ctx.credentialId !== "string" || ctx.credentialId.length === 0)
|
|
58
|
+
refuse("context.credentialId", "is empty");
|
|
59
|
+
if (!isRecord(challenge))
|
|
60
|
+
refuse("challenge", "is not an object");
|
|
61
|
+
const t = challenge.typedData;
|
|
62
|
+
if (!isRecord(t))
|
|
63
|
+
refuse("typedData", "is not an object");
|
|
64
|
+
const domain = t.domain;
|
|
65
|
+
if (!isRecord(domain))
|
|
66
|
+
refuse("domain", "is not an object");
|
|
67
|
+
for (const key of Object.keys(domain)) {
|
|
68
|
+
if (!DOMAIN_KEYS.includes(key))
|
|
69
|
+
refuse(`domain.${key}`, "must not be present");
|
|
70
|
+
}
|
|
71
|
+
if (domain.name !== DELEGATE_BINDING_DOMAIN_NAME)
|
|
72
|
+
refuse("domain.name", `must be "${DELEGATE_BINDING_DOMAIN_NAME}"`);
|
|
73
|
+
if (domain.version !== DELEGATE_BINDING_DOMAIN_VERSION) {
|
|
74
|
+
refuse("domain.version", `must be "${DELEGATE_BINDING_DOMAIN_VERSION}"`);
|
|
75
|
+
}
|
|
76
|
+
const chainId = toInteger(domain.chainId);
|
|
77
|
+
if (chainId === null || chainId !== BigInt(ctx.chainId))
|
|
78
|
+
refuse("domain.chainId", `must be ${ctx.chainId}`);
|
|
79
|
+
if (t.primaryType !== "DelegateBinding")
|
|
80
|
+
refuse("primaryType", 'must be "DelegateBinding"');
|
|
81
|
+
const types = t.types;
|
|
82
|
+
if (!isRecord(types))
|
|
83
|
+
refuse("types", "is not an object");
|
|
84
|
+
for (const key of Object.keys(types)) {
|
|
85
|
+
if (key !== "DelegateBinding")
|
|
86
|
+
refuse(`types.${key}`, "must not be present");
|
|
87
|
+
}
|
|
88
|
+
const fields = types.DelegateBinding;
|
|
89
|
+
const expected = DELEGATE_BINDING_TYPES.DelegateBinding;
|
|
90
|
+
const fieldsMatch = Array.isArray(fields) &&
|
|
91
|
+
fields.length === expected.length &&
|
|
92
|
+
fields.every((f, i) => isRecord(f) && Object.keys(f).length === 2 && f.name === expected[i].name && f.type === expected[i].type);
|
|
93
|
+
if (!fieldsMatch) {
|
|
94
|
+
refuse("types.DelegateBinding", "must be exactly delegate:address, credentialId:string, nonce:string, expiresAt:uint64");
|
|
95
|
+
}
|
|
96
|
+
const message = t.message;
|
|
97
|
+
if (!isRecord(message))
|
|
98
|
+
refuse("message", "is not an object");
|
|
99
|
+
for (const key of Object.keys(message)) {
|
|
100
|
+
if (!MESSAGE_KEYS.includes(key))
|
|
101
|
+
refuse(`message.${key}`, "must not be present");
|
|
102
|
+
}
|
|
103
|
+
const delegate = message.delegate;
|
|
104
|
+
if (!isAddress(delegate))
|
|
105
|
+
refuse("message.delegate", "is not an address");
|
|
106
|
+
if (getAddress(delegate) !== getAddress(ctx.localAddress))
|
|
107
|
+
refuse("message.delegate", "is not the local key");
|
|
108
|
+
if (message.credentialId !== ctx.credentialId)
|
|
109
|
+
refuse("message.credentialId", "is not this key's credential");
|
|
110
|
+
const nonce = message.nonce;
|
|
111
|
+
if (typeof nonce !== "string" || nonce.length === 0 || nonce.length > MAX_NONCE_LENGTH) {
|
|
112
|
+
refuse("message.nonce", "is not a usable nonce");
|
|
113
|
+
}
|
|
114
|
+
const expiresAt = toInteger(message.expiresAt);
|
|
115
|
+
if (expiresAt === null || expiresAt > MAX_UINT64)
|
|
116
|
+
refuse("message.expiresAt", "is not a uint64");
|
|
117
|
+
const now = BigInt(Math.floor(ctx.nowSeconds));
|
|
118
|
+
if (expiresAt <= now)
|
|
119
|
+
refuse("message.expiresAt", "is not in the future");
|
|
120
|
+
if (expiresAt > now + BigInt(MAX_BINDING_CHALLENGE_SECONDS)) {
|
|
121
|
+
refuse("message.expiresAt", `is more than ${MAX_BINDING_CHALLENGE_SECONDS}s away`);
|
|
122
|
+
}
|
|
123
|
+
if (typeof challenge.challengeId !== "string" || !UUID.test(challenge.challengeId)) {
|
|
124
|
+
refuse("challengeId", "is not a UUID");
|
|
125
|
+
}
|
|
126
|
+
return bindingTypedData(ctx.chainId, {
|
|
127
|
+
delegate: getAddress(delegate),
|
|
128
|
+
credentialId: ctx.credentialId,
|
|
129
|
+
nonce,
|
|
130
|
+
expiresAt,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Address } from "./hex.js";
|
|
2
|
+
/** The namespace of a CAIP-2 chain id (`eip155` of `eip155:1`), or `null`. */
|
|
3
|
+
export declare function namespaceOf(caip2: unknown): string | null;
|
|
4
|
+
/** The chain id of an `eip155:<id>` CAIP-2 chain, or `null`. */
|
|
5
|
+
export declare function eip155ChainId(caip2: unknown): number | null;
|
|
6
|
+
/** A CAIP-10 account on eip155 with a checksummed address, or `null`. */
|
|
7
|
+
export declare function parseCaip10(value: string): {
|
|
8
|
+
chainId: number;
|
|
9
|
+
address: Address;
|
|
10
|
+
} | null;
|
|
11
|
+
/** A CAIP-19 erc20 asset on eip155 with a checksummed address, or `null`. */
|
|
12
|
+
export declare function parseCaip19Erc20(value: string): {
|
|
13
|
+
chainId: number;
|
|
14
|
+
address: Address;
|
|
15
|
+
} | null;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { isChecksummed } from "./hex.js";
|
|
2
|
+
const CAIP2_EIP155 = /^eip155:([1-9][0-9]*)$/;
|
|
3
|
+
const CAIP10 = /^eip155:([1-9][0-9]*):(0x[0-9a-fA-F]{40})$/;
|
|
4
|
+
const CAIP19_ERC20 = /^eip155:([1-9][0-9]*)\/erc20:(0x[0-9a-fA-F]{40})$/;
|
|
5
|
+
/** The namespace of a CAIP-2 chain id (`eip155` of `eip155:1`), or `null`. */
|
|
6
|
+
export function namespaceOf(caip2) {
|
|
7
|
+
const m = typeof caip2 === "string" ? /^([-a-z0-9]{3,8}):[-_a-zA-Z0-9]{1,32}$/.exec(caip2) : null;
|
|
8
|
+
return m ? m[1] : null;
|
|
9
|
+
}
|
|
10
|
+
/** The chain id of an `eip155:<id>` CAIP-2 chain, or `null`. */
|
|
11
|
+
export function eip155ChainId(caip2) {
|
|
12
|
+
const m = typeof caip2 === "string" ? CAIP2_EIP155.exec(caip2) : null;
|
|
13
|
+
if (!m)
|
|
14
|
+
return null;
|
|
15
|
+
const id = Number(m[1]);
|
|
16
|
+
return Number.isSafeInteger(id) ? id : null;
|
|
17
|
+
}
|
|
18
|
+
/** A CAIP-10 account on eip155 with a checksummed address, or `null`. */
|
|
19
|
+
export function parseCaip10(value) {
|
|
20
|
+
const m = CAIP10.exec(value);
|
|
21
|
+
if (!m || !isChecksummed(m[2]))
|
|
22
|
+
return null;
|
|
23
|
+
return { chainId: Number(m[1]), address: m[2] };
|
|
24
|
+
}
|
|
25
|
+
/** A CAIP-19 erc20 asset on eip155 with a checksummed address, or `null`. */
|
|
26
|
+
export function parseCaip19Erc20(value) {
|
|
27
|
+
const m = CAIP19_ERC20.exec(value);
|
|
28
|
+
if (!m || !isChecksummed(m[2]))
|
|
29
|
+
return null;
|
|
30
|
+
return { chainId: Number(m[1]), address: m[2] };
|
|
31
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only chain reads of the payment key's grant and caps (G4, G6): three escrow views at one
|
|
3
|
+
* pinned block, on every RPC the caller listed for the network. Every RPC must answer the same
|
|
4
|
+
* chain id and the same bytes, or there is no read (the guards then refuse: fail closed).
|
|
5
|
+
*
|
|
6
|
+
* `delegationOf(payer, delegate)`, `effectiveCapsOf(payer, delegate)` and `escrowOf(payer)`. Each
|
|
7
|
+
* cap is the lower of the effective envelope and the current value: strictest wins.
|
|
8
|
+
*/
|
|
9
|
+
import type { ChainState } from "./guard-types.js";
|
|
10
|
+
import { type Address } from "./hex.js";
|
|
11
|
+
/** How long an agreed read stands (G4 accepts a read at most 600 s old). */
|
|
12
|
+
export declare const CHAIN_CACHE_MS: number;
|
|
13
|
+
/** The most two RPCs' latest heights may differ before the read refuses. */
|
|
14
|
+
export declare const MAX_BLOCK_SPREAD = 5n;
|
|
15
|
+
export type FetchFn = (input: string, init: RequestInit) => Promise<Response>;
|
|
16
|
+
export interface ChainReaderOptions {
|
|
17
|
+
fetch: FetchFn;
|
|
18
|
+
/** Milliseconds clock. */
|
|
19
|
+
now: () => number;
|
|
20
|
+
}
|
|
21
|
+
export interface ChainNetwork {
|
|
22
|
+
/** CAIP-2, `eip155:<id>`. */
|
|
23
|
+
id: string;
|
|
24
|
+
chainId: number;
|
|
25
|
+
escrow: Address;
|
|
26
|
+
rpc: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
/** https, or http to a loopback host; no credentials, query or fragment. */
|
|
29
|
+
export declare function isUsableRpcUrl(value: string): boolean;
|
|
30
|
+
export declare class ChainReader {
|
|
31
|
+
#private;
|
|
32
|
+
constructor(options: ChainReaderOptions);
|
|
33
|
+
/** The agreed read for `payer` and `delegate` (checksummed), or `null` (fail closed). */
|
|
34
|
+
read(net: ChainNetwork, payer: Address, delegate: Address): Promise<ChainState | null>;
|
|
35
|
+
/** Drops a cached read, so the next payment reads the chain again (after a revocation). */
|
|
36
|
+
invalidate(net: string, payer: string, delegate: string): void;
|
|
37
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { getAddress, keccakUtf8 } from "./hex.js";
|
|
2
|
+
/** How long an agreed read stands (G4 accepts a read at most 600 s old). */
|
|
3
|
+
export const CHAIN_CACHE_MS = 10 * 60 * 1000;
|
|
4
|
+
/** The most two RPCs' latest heights may differ before the read refuses. */
|
|
5
|
+
export const MAX_BLOCK_SPREAD = 5n;
|
|
6
|
+
const MAX_RESPONSE_CHARS = 16 * 1024;
|
|
7
|
+
const RPC_TIMEOUT_MS = 5_000;
|
|
8
|
+
const selector = (signature) => keccakUtf8(signature).slice(2, 10);
|
|
9
|
+
const DELEGATION_OF = selector("delegationOf(address,address)");
|
|
10
|
+
const EFFECTIVE_CAPS_OF = selector("effectiveCapsOf(address,address)");
|
|
11
|
+
const ESCROW_OF = selector("escrowOf(address)");
|
|
12
|
+
const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
13
|
+
/** https, or http to a loopback host; no credentials, query or fragment. */
|
|
14
|
+
export function isUsableRpcUrl(value) {
|
|
15
|
+
let url;
|
|
16
|
+
try {
|
|
17
|
+
url = new URL(value);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
if (url.username || url.password || url.search || url.hash)
|
|
23
|
+
return false;
|
|
24
|
+
return url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK.has(url.hostname));
|
|
25
|
+
}
|
|
26
|
+
const word = (address) => address.slice(2).toLowerCase().padStart(64, "0");
|
|
27
|
+
/** Splits canonical ABI return data into words, checking each word's bit width. */
|
|
28
|
+
function words(data, widths) {
|
|
29
|
+
if (!/^0x[0-9a-f]*$/.test(data) || data.length !== 2 + widths.length * 64)
|
|
30
|
+
return null;
|
|
31
|
+
const out = [];
|
|
32
|
+
for (let i = 0; i < widths.length; i++) {
|
|
33
|
+
const n = BigInt(`0x${data.slice(2 + i * 64, 2 + (i + 1) * 64)}`);
|
|
34
|
+
if (n >> BigInt(widths[i]) !== 0n)
|
|
35
|
+
return null;
|
|
36
|
+
out.push(n);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
const DELEGATION_WIDTHS = [64, 64, 64, 64, 64, 32, 1];
|
|
41
|
+
const CAPS_WIDTHS = [64, 64, 64, 64];
|
|
42
|
+
const ESCROW_WIDTHS = [128, 64, 64, 64, 64, 64, 64, 64, 64, 128, 128, 128];
|
|
43
|
+
const min = (a, b) => (a < b ? a : b);
|
|
44
|
+
export class ChainReader {
|
|
45
|
+
#fetch;
|
|
46
|
+
#now;
|
|
47
|
+
#cache = new Map();
|
|
48
|
+
#id = 1;
|
|
49
|
+
constructor(options) {
|
|
50
|
+
this.#fetch = options.fetch;
|
|
51
|
+
this.#now = options.now;
|
|
52
|
+
}
|
|
53
|
+
/** The agreed read for `payer` and `delegate` (checksummed), or `null` (fail closed). */
|
|
54
|
+
async read(net, payer, delegate) {
|
|
55
|
+
const key = JSON.stringify([net.id, payer, delegate]);
|
|
56
|
+
const hit = this.#cache.get(key);
|
|
57
|
+
const t = this.#now();
|
|
58
|
+
if (hit && t >= hit.at && t - hit.at < CHAIN_CACHE_MS)
|
|
59
|
+
return hit.value;
|
|
60
|
+
this.#cache.delete(key);
|
|
61
|
+
let value;
|
|
62
|
+
try {
|
|
63
|
+
value = await this.#readOnce(net, payer, delegate, Math.floor(t / 1000));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
value = null;
|
|
67
|
+
}
|
|
68
|
+
if (value !== null)
|
|
69
|
+
this.#cache.set(key, { at: t, value });
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
/** Drops a cached read, so the next payment reads the chain again (after a revocation). */
|
|
73
|
+
invalidate(net, payer, delegate) {
|
|
74
|
+
this.#cache.delete(JSON.stringify([net, payer, delegate]));
|
|
75
|
+
}
|
|
76
|
+
async #readOnce(net, payer, delegate, readAt) {
|
|
77
|
+
const rpcs = net.rpc;
|
|
78
|
+
if (rpcs.length < 1 || new Set(rpcs).size !== rpcs.length || !rpcs.every(isUsableRpcUrl))
|
|
79
|
+
return null;
|
|
80
|
+
const heads = await Promise.all(rpcs.map(async (url) => {
|
|
81
|
+
const [id, height] = await Promise.all([
|
|
82
|
+
this.#call(url, "eth_chainId", []),
|
|
83
|
+
this.#call(url, "eth_blockNumber", []),
|
|
84
|
+
]);
|
|
85
|
+
return { id: quantity(id), height: quantity(height) };
|
|
86
|
+
}));
|
|
87
|
+
if (heads.some((h) => h.id === null || h.height === null || h.id !== BigInt(net.chainId)))
|
|
88
|
+
return null;
|
|
89
|
+
const heights = heads.map((h) => h.height);
|
|
90
|
+
const low = heights.reduce((x, y) => (y < x ? y : x));
|
|
91
|
+
const high = heights.reduce((x, y) => (y > x ? y : x));
|
|
92
|
+
if (high - low > MAX_BLOCK_SPREAD || low < 1n)
|
|
93
|
+
return null;
|
|
94
|
+
// One block behind the lowest head, so every RPC has it.
|
|
95
|
+
const block = `0x${(low - 1n).toString(16)}`;
|
|
96
|
+
const calls = [
|
|
97
|
+
`0x${DELEGATION_OF}${word(payer)}${word(delegate)}`,
|
|
98
|
+
`0x${EFFECTIVE_CAPS_OF}${word(payer)}${word(delegate)}`,
|
|
99
|
+
`0x${ESCROW_OF}${word(payer)}`,
|
|
100
|
+
];
|
|
101
|
+
const answers = await Promise.all(rpcs.map((url) => Promise.all(calls.map(async (data) => String(await this.#call(url, "eth_call", [{ to: net.escrow, data }, block])).toLowerCase()))));
|
|
102
|
+
const first = answers[0];
|
|
103
|
+
if (answers.some((a) => a.some((x, i) => x !== first[i])))
|
|
104
|
+
return null;
|
|
105
|
+
const d = words(first[0], DELEGATION_WIDTHS);
|
|
106
|
+
const caps = words(first[1], CAPS_WIDTHS);
|
|
107
|
+
const e = words(first[2], ESCROW_WIDTHS);
|
|
108
|
+
if (!d || !caps || !e)
|
|
109
|
+
return null;
|
|
110
|
+
const [dMax, , , , validUntil, , revoked] = d;
|
|
111
|
+
return {
|
|
112
|
+
payer: getAddress(payer),
|
|
113
|
+
delegate: getAddress(delegate),
|
|
114
|
+
readAt,
|
|
115
|
+
active: validUntil !== 0n && revoked === 0n,
|
|
116
|
+
validUntil: validUntil.toString(),
|
|
117
|
+
maxVoucherAmount: min(caps[0], e[3]).toString(),
|
|
118
|
+
delegateMaxVoucherAmount: min(caps[2], dMax).toString(),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
async #call(url, method, params) {
|
|
122
|
+
const id = this.#id++;
|
|
123
|
+
const signal = AbortSignal.timeout(RPC_TIMEOUT_MS);
|
|
124
|
+
const res = await this.#fetch(url, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
127
|
+
body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
|
|
128
|
+
redirect: "error",
|
|
129
|
+
signal,
|
|
130
|
+
});
|
|
131
|
+
const text = await res.text();
|
|
132
|
+
if (res.status !== 200 || text.length > MAX_RESPONSE_CHARS)
|
|
133
|
+
throw new Error("rpc failed");
|
|
134
|
+
const msg = JSON.parse(text);
|
|
135
|
+
if (msg === null ||
|
|
136
|
+
typeof msg !== "object" ||
|
|
137
|
+
msg.jsonrpc !== "2.0" ||
|
|
138
|
+
msg.id !== id ||
|
|
139
|
+
"error" in msg ||
|
|
140
|
+
!("result" in msg)) {
|
|
141
|
+
throw new Error("rpc failed");
|
|
142
|
+
}
|
|
143
|
+
return msg.result;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function quantity(value) {
|
|
147
|
+
return typeof value === "string" && /^0x(?:0|[1-9a-f][0-9a-f]{0,15})$/i.test(value) ? BigInt(value) : null;
|
|
148
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The largest correction the HTTP `Date` header may apply to the local clock (spec section 7). */
|
|
2
|
+
export declare const MAX_CLOCK_CORRECTION_SECONDS = 120;
|
|
3
|
+
/**
|
|
4
|
+
* The server's clock minus the local one, in whole seconds, from an HTTP `Date` header. A missing
|
|
5
|
+
* or unreadable header gives 0. Not clamped here: {@link correctedNow} clamps.
|
|
6
|
+
*/
|
|
7
|
+
export declare function dateHeaderOffset(dateHeader: string | null | undefined, localNowSeconds: number): number;
|
|
8
|
+
/**
|
|
9
|
+
* The local time corrected by a `Date` offset clamped to +-120 s, so a server (or anyone on the
|
|
10
|
+
* path) can move the client's clock by at most 120 s either way.
|
|
11
|
+
*/
|
|
12
|
+
export declare function correctedNow(localNowSeconds: number, offsetSeconds: number): number;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** The largest correction the HTTP `Date` header may apply to the local clock (spec section 7). */
|
|
2
|
+
export const MAX_CLOCK_CORRECTION_SECONDS = 120;
|
|
3
|
+
/**
|
|
4
|
+
* The server's clock minus the local one, in whole seconds, from an HTTP `Date` header. A missing
|
|
5
|
+
* or unreadable header gives 0. Not clamped here: {@link correctedNow} clamps.
|
|
6
|
+
*/
|
|
7
|
+
export function dateHeaderOffset(dateHeader, localNowSeconds) {
|
|
8
|
+
if (!dateHeader)
|
|
9
|
+
return 0;
|
|
10
|
+
const serverMs = Date.parse(dateHeader);
|
|
11
|
+
if (!Number.isFinite(serverMs) || !Number.isFinite(localNowSeconds))
|
|
12
|
+
return 0;
|
|
13
|
+
return Math.floor(serverMs / 1000) - Math.floor(localNowSeconds);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The local time corrected by a `Date` offset clamped to +-120 s, so a server (or anyone on the
|
|
17
|
+
* path) can move the client's clock by at most 120 s either way.
|
|
18
|
+
*/
|
|
19
|
+
export function correctedNow(localNowSeconds, offsetSeconds) {
|
|
20
|
+
if (!Number.isFinite(localNowSeconds) || localNowSeconds < 0)
|
|
21
|
+
throw new Error("the local clock is not a usable time");
|
|
22
|
+
const offset = Number.isFinite(offsetSeconds) ? offsetSeconds : 0;
|
|
23
|
+
const clamped = Math.max(-MAX_CLOCK_CORRECTION_SECONDS, Math.min(MAX_CLOCK_CORRECTION_SECONDS, offset));
|
|
24
|
+
return Math.floor(localNowSeconds) + Math.trunc(clamped);
|
|
25
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A refusal to sign. `field` names the first value that failed its check, so a caller (and a
|
|
3
|
+
* test) can tell which check refused without parsing the message. The message never carries a
|
|
4
|
+
* key or a signature.
|
|
5
|
+
*/
|
|
6
|
+
export declare class SignerRefusal extends Error {
|
|
7
|
+
readonly field: string;
|
|
8
|
+
constructor(what: string, field: string, reason: string);
|
|
9
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A refusal to sign. `field` names the first value that failed its check, so a caller (and a
|
|
3
|
+
* test) can tell which check refused without parsing the message. The message never carries a
|
|
4
|
+
* key or a signature.
|
|
5
|
+
*/
|
|
6
|
+
export class SignerRefusal extends Error {
|
|
7
|
+
field;
|
|
8
|
+
constructor(what, field, reason) {
|
|
9
|
+
super(`refusing to sign ${what}: ${field} ${reason}`);
|
|
10
|
+
this.name = "SignerRefusal";
|
|
11
|
+
this.field = field;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inputs and results of the local guards (spec section 9). Every value the guards read is passed in:
|
|
3
|
+
* the guards do no I/O, read no clock and keep no state, so the same vectors (`vectors/guards.json`)
|
|
4
|
+
* can drive every implementation.
|
|
5
|
+
*
|
|
6
|
+
* Integers that can exceed 2^53 (amounts, caps, spend) are decimal strings. Addresses the client
|
|
7
|
+
* holds itself (`localKey`, `recordedPayer`, `chain.payer`, `limits.providers`) may be a bare
|
|
8
|
+
* `0x` address in any case or a CAIP-10 account; values from the server's requirement must be
|
|
9
|
+
* checksummed CAIP-10 accounts on the requirement's network.
|
|
10
|
+
*/
|
|
11
|
+
/** The guard that refused. */
|
|
12
|
+
export type GuardId = "G1" | "G2" | "G3" | "G4" | "G5" | "G6" | "G7" | "G8" | "G9" | "G10" | "G11" | "G12";
|
|
13
|
+
/**
|
|
14
|
+
* Stable refusal codes, shared with every implementation through the vectors. Each code names
|
|
15
|
+
* one check; never rename or reuse one.
|
|
16
|
+
*/
|
|
17
|
+
export declare const REFUSAL_CODES: {
|
|
18
|
+
readonly G1_NOT_A_REQUIREMENT: "G1";
|
|
19
|
+
readonly G1_RAIL: "G1";
|
|
20
|
+
readonly G1_SCHEME: "G1";
|
|
21
|
+
readonly G1_NOT_FORWARDED: "G1";
|
|
22
|
+
readonly G1_FORWARD_TOO_OLD: "G1";
|
|
23
|
+
readonly G2_NETWORK: "G2";
|
|
24
|
+
readonly G2_PAY_TO: "G2";
|
|
25
|
+
readonly G2_ASSET: "G2";
|
|
26
|
+
readonly G2_DECIMALS: "G2";
|
|
27
|
+
readonly G2_AMOUNT: "G2";
|
|
28
|
+
readonly G3_NO_QUOTE: "G3";
|
|
29
|
+
readonly G3_QUOTE_ASSET: "G3";
|
|
30
|
+
readonly G3_ABOVE_QUOTE: "G3";
|
|
31
|
+
readonly G4_NO_CHAIN_READ: "G4";
|
|
32
|
+
readonly G4_PAYER_INVALID: "G4";
|
|
33
|
+
readonly G4_PAYER_CHANGED: "G4";
|
|
34
|
+
readonly G4_NOT_DELEGATED: "G4";
|
|
35
|
+
readonly G4_OTHER_KEY: "G4";
|
|
36
|
+
readonly G4_STALE_READ: "G4";
|
|
37
|
+
readonly G4_INACTIVE: "G4";
|
|
38
|
+
readonly G4_EXPIRED: "G4";
|
|
39
|
+
readonly G5_LOCAL_KEY_INVALID: "G5";
|
|
40
|
+
readonly G5_NO_DELEGATE_SIGNER: "G5";
|
|
41
|
+
readonly G5_DELEGATE_MISMATCH: "G5";
|
|
42
|
+
readonly G5_LOCAL_KEY_IS_PAYER: "G5";
|
|
43
|
+
readonly G6_CAPS_UNREADABLE: "G6";
|
|
44
|
+
readonly G6_DELEGATE_CAP: "G6";
|
|
45
|
+
readonly G6_ESCROW_CAP: "G6";
|
|
46
|
+
readonly G7_LIMITS_UNREADABLE: "G7";
|
|
47
|
+
readonly G7_PER_CALL: "G7";
|
|
48
|
+
readonly G7_STATE_UNREADABLE: "G7";
|
|
49
|
+
readonly G7_PER_DAY: "G7";
|
|
50
|
+
readonly G7_CONFIRMATION_REQUIRED: "G7";
|
|
51
|
+
readonly G7_CONFIRMATION_MISMATCH: "G7";
|
|
52
|
+
readonly G8_PROVIDER_INVALID: "G8";
|
|
53
|
+
readonly G8_PROVIDERS_UNREADABLE: "G8";
|
|
54
|
+
readonly G8_PROVIDER_NOT_ALLOWED: "G8";
|
|
55
|
+
readonly G9_SEQ: "G9";
|
|
56
|
+
readonly G10_REQUEST_HASH: "G10";
|
|
57
|
+
readonly G10_RESOURCE: "G10";
|
|
58
|
+
readonly G10_MAX_TIMEOUT: "G10";
|
|
59
|
+
/** App-only (a per-URL paid list, as in assay-mcp); the SDK never raises it. */
|
|
60
|
+
readonly G11_URL_NOT_PAID: "G11";
|
|
61
|
+
readonly G12_DEBT_OR_SEQ_ENDPOINT: "G12";
|
|
62
|
+
readonly G12_NOT_A_TOOL_CALL: "G12";
|
|
63
|
+
readonly G12_OTHER_TOOL: "G12";
|
|
64
|
+
};
|
|
65
|
+
export type RefusalCode = keyof typeof REFUSAL_CODES;
|
|
66
|
+
/** The client's own entry for a network (never from the server), with its CAIP-2 id. */
|
|
67
|
+
export interface BuiltInNetwork {
|
|
68
|
+
id: string;
|
|
69
|
+
escrow: string;
|
|
70
|
+
asset: string;
|
|
71
|
+
decimals: number;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* `assay/price` for the forwarded tool (`forwardedCall.tool`, never a name taken from the
|
|
75
|
+
* requirement) in the same exchange (G3). `maxAmount` is in atomic units of `asset`.
|
|
76
|
+
*/
|
|
77
|
+
export interface Quote {
|
|
78
|
+
maxAmount: string;
|
|
79
|
+
/** CAIP-19; must be the requirement's asset. */
|
|
80
|
+
asset: string;
|
|
81
|
+
}
|
|
82
|
+
/** The agreed chain read of the payment key's grant and caps (G4, G6). */
|
|
83
|
+
export interface ChainState {
|
|
84
|
+
/** The payer the grant was read for. */
|
|
85
|
+
payer: string;
|
|
86
|
+
/** The key the grant was read for; must be the local key. */
|
|
87
|
+
delegate: string;
|
|
88
|
+
/** When the read was made, Unix seconds on the same clock as `nowSeconds`; at most 600 s old. */
|
|
89
|
+
readAt: number;
|
|
90
|
+
delegateMaxVoucherAmount: string;
|
|
91
|
+
maxVoucherAmount: string;
|
|
92
|
+
/** The grant exists and is not revoked. */
|
|
93
|
+
active: boolean;
|
|
94
|
+
/** `delegationOf(...).validUntil`, Unix seconds. */
|
|
95
|
+
validUntil: string | number;
|
|
96
|
+
}
|
|
97
|
+
/** The local limits (G7, G8). Dollar values are decimal strings. */
|
|
98
|
+
export interface LocalLimits {
|
|
99
|
+
/** Dollars per call, e.g. "0.50". */
|
|
100
|
+
perCall: string;
|
|
101
|
+
/** Dollars per rolling 24 h, e.g. "5". */
|
|
102
|
+
perDay: string;
|
|
103
|
+
/** Dollars; above this the user must confirm. `null` is off. */
|
|
104
|
+
confirmAbove: string | null;
|
|
105
|
+
/** Provider allow-list (G8). `null` is off (any provider). */
|
|
106
|
+
providers: string[] | null;
|
|
107
|
+
}
|
|
108
|
+
/** The default local limits (G7): $0.50 a call, $5 per rolling 24 h. */
|
|
109
|
+
export declare const DEFAULT_LIMITS: Readonly<LocalLimits>;
|
|
110
|
+
/**
|
|
111
|
+
* The `tools/call` this process forwarded, whose answer carried the requirement (G1, G12).
|
|
112
|
+
* `tool` is the original tool name the call reached (after alias mapping; on `call_tool`, the
|
|
113
|
+
* inner `name`).
|
|
114
|
+
*/
|
|
115
|
+
export interface ForwardedCall {
|
|
116
|
+
tool: string;
|
|
117
|
+
/** When the call was forwarded, Unix seconds on the same clock as `nowSeconds`. */
|
|
118
|
+
at: number;
|
|
119
|
+
}
|
|
120
|
+
/** One signed voucher: when it was signed and its atomic amount. */
|
|
121
|
+
export interface SignedVoucher {
|
|
122
|
+
at: number;
|
|
123
|
+
amount: string;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* What the user was shown and approved through MCP elicitation (G7 `confirmAbove`). It approves
|
|
127
|
+
* only a requirement with exactly these values; build it with `confirmationFor`.
|
|
128
|
+
*/
|
|
129
|
+
export interface Confirmation {
|
|
130
|
+
tool: string;
|
|
131
|
+
provider: string;
|
|
132
|
+
amount: string;
|
|
133
|
+
asset: string;
|
|
134
|
+
network: string;
|
|
135
|
+
payTo: string;
|
|
136
|
+
seq: string;
|
|
137
|
+
}
|
|
138
|
+
export interface GuardContext {
|
|
139
|
+
/** `_meta["x402/paymentRequired"]` as received; untrusted, so not typed. */
|
|
140
|
+
requirement: unknown;
|
|
141
|
+
/** The call this requirement answers, or `null` when there is none (G1). */
|
|
142
|
+
forwardedCall: ForwardedCall | null;
|
|
143
|
+
/** The built-in entry for `requirement.network`, or `null` when the table has none (G2). */
|
|
144
|
+
network: BuiltInNetwork | null;
|
|
145
|
+
/** The quote for this tool (cached ≤ 60 s, or a fresh `get_tool`), or `null` (G3). */
|
|
146
|
+
quote: Quote | null;
|
|
147
|
+
/** The agreed chain read, or `null` when there is none or the RPCs disagree (G4, G6). */
|
|
148
|
+
chain: ChainState | null;
|
|
149
|
+
/** The local delegate key's address (G5). */
|
|
150
|
+
localKey: string;
|
|
151
|
+
/** The payer recorded after a chain-confirmed first voucher, or `null` (G4). */
|
|
152
|
+
recordedPayer: string | null;
|
|
153
|
+
limits: LocalLimits;
|
|
154
|
+
/**
|
|
155
|
+
* Every voucher this key signed (G7). Each one signed after
|
|
156
|
+
* `nowSeconds - 86400` counts, including any dated in the future.
|
|
157
|
+
*/
|
|
158
|
+
signed: SignedVoucher[];
|
|
159
|
+
/** The user's approval of this exact payment, or `null` (G7 `confirmAbove`). */
|
|
160
|
+
confirmation: Confirmation | null;
|
|
161
|
+
/**
|
|
162
|
+
* Unix seconds from the local clock, never the `Date`-corrected one, for G1, G4 and the G7
|
|
163
|
+
* window. (The `Date` correction applies only to the voucher's `issuedAt`.)
|
|
164
|
+
*/
|
|
165
|
+
nowSeconds: number;
|
|
166
|
+
}
|
|
167
|
+
export type GuardResult = {
|
|
168
|
+
ok: true;
|
|
169
|
+
} | {
|
|
170
|
+
ok: false;
|
|
171
|
+
guard: GuardId;
|
|
172
|
+
code: RefusalCode;
|
|
173
|
+
reason: string;
|
|
174
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inputs and results of the local guards (spec section 9). Every value the guards read is passed in:
|
|
3
|
+
* the guards do no I/O, read no clock and keep no state, so the same vectors (`vectors/guards.json`)
|
|
4
|
+
* can drive every implementation.
|
|
5
|
+
*
|
|
6
|
+
* Integers that can exceed 2^53 (amounts, caps, spend) are decimal strings. Addresses the client
|
|
7
|
+
* holds itself (`localKey`, `recordedPayer`, `chain.payer`, `limits.providers`) may be a bare
|
|
8
|
+
* `0x` address in any case or a CAIP-10 account; values from the server's requirement must be
|
|
9
|
+
* checksummed CAIP-10 accounts on the requirement's network.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Stable refusal codes, shared with every implementation through the vectors. Each code names
|
|
13
|
+
* one check; never rename or reuse one.
|
|
14
|
+
*/
|
|
15
|
+
export const REFUSAL_CODES = {
|
|
16
|
+
G1_NOT_A_REQUIREMENT: "G1",
|
|
17
|
+
G1_RAIL: "G1",
|
|
18
|
+
G1_SCHEME: "G1",
|
|
19
|
+
G1_NOT_FORWARDED: "G1",
|
|
20
|
+
G1_FORWARD_TOO_OLD: "G1",
|
|
21
|
+
G2_NETWORK: "G2",
|
|
22
|
+
G2_PAY_TO: "G2",
|
|
23
|
+
G2_ASSET: "G2",
|
|
24
|
+
G2_DECIMALS: "G2",
|
|
25
|
+
G2_AMOUNT: "G2",
|
|
26
|
+
G3_NO_QUOTE: "G3",
|
|
27
|
+
G3_QUOTE_ASSET: "G3",
|
|
28
|
+
G3_ABOVE_QUOTE: "G3",
|
|
29
|
+
G4_NO_CHAIN_READ: "G4",
|
|
30
|
+
G4_PAYER_INVALID: "G4",
|
|
31
|
+
G4_PAYER_CHANGED: "G4",
|
|
32
|
+
G4_NOT_DELEGATED: "G4",
|
|
33
|
+
G4_OTHER_KEY: "G4",
|
|
34
|
+
G4_STALE_READ: "G4",
|
|
35
|
+
G4_INACTIVE: "G4",
|
|
36
|
+
G4_EXPIRED: "G4",
|
|
37
|
+
G5_LOCAL_KEY_INVALID: "G5",
|
|
38
|
+
G5_NO_DELEGATE_SIGNER: "G5",
|
|
39
|
+
G5_DELEGATE_MISMATCH: "G5",
|
|
40
|
+
G5_LOCAL_KEY_IS_PAYER: "G5",
|
|
41
|
+
G6_CAPS_UNREADABLE: "G6",
|
|
42
|
+
G6_DELEGATE_CAP: "G6",
|
|
43
|
+
G6_ESCROW_CAP: "G6",
|
|
44
|
+
G7_LIMITS_UNREADABLE: "G7",
|
|
45
|
+
G7_PER_CALL: "G7",
|
|
46
|
+
G7_STATE_UNREADABLE: "G7",
|
|
47
|
+
G7_PER_DAY: "G7",
|
|
48
|
+
G7_CONFIRMATION_REQUIRED: "G7",
|
|
49
|
+
G7_CONFIRMATION_MISMATCH: "G7",
|
|
50
|
+
G8_PROVIDER_INVALID: "G8",
|
|
51
|
+
G8_PROVIDERS_UNREADABLE: "G8",
|
|
52
|
+
G8_PROVIDER_NOT_ALLOWED: "G8",
|
|
53
|
+
G9_SEQ: "G9",
|
|
54
|
+
G10_REQUEST_HASH: "G10",
|
|
55
|
+
G10_RESOURCE: "G10",
|
|
56
|
+
G10_MAX_TIMEOUT: "G10",
|
|
57
|
+
/** App-only (a per-URL paid list, as in assay-mcp); the SDK never raises it. */
|
|
58
|
+
G11_URL_NOT_PAID: "G11",
|
|
59
|
+
G12_DEBT_OR_SEQ_ENDPOINT: "G12",
|
|
60
|
+
G12_NOT_A_TOOL_CALL: "G12",
|
|
61
|
+
G12_OTHER_TOOL: "G12",
|
|
62
|
+
};
|
|
63
|
+
/** The default local limits (G7): $0.50 a call, $5 per rolling 24 h. */
|
|
64
|
+
export const DEFAULT_LIMITS = Object.freeze({
|
|
65
|
+
perCall: "0.50",
|
|
66
|
+
perDay: "5",
|
|
67
|
+
confirmAbove: null,
|
|
68
|
+
providers: null,
|
|
69
|
+
});
|