@agentfromzero/agentpassport-sdk 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 +162 -0
- package/dist/abis.d.ts +1708 -0
- package/dist/abis.js +2221 -0
- package/dist/abis.js.map +1 -0
- package/dist/addresses.d.ts +74 -0
- package/dist/addresses.js +25 -0
- package/dist/addresses.js.map +1 -0
- package/dist/client.d.ts +179 -0
- package/dist/client.js +411 -0
- package/dist/client.js.map +1 -0
- package/dist/gasless.d.ts +55 -0
- package/dist/gasless.js +73 -0
- package/dist/gasless.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +123 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +40 -0
- package/dist/utils.js +80 -0
- package/dist/utils.js.map +1 -0
- package/package.json +38 -0
- package/src/abis.ts +2225 -0
- package/src/addresses.ts +46 -0
- package/src/client.ts +496 -0
- package/src/gasless.ts +102 -0
- package/src/index.ts +10 -0
- package/src/types.ts +127 -0
- package/src/utils.ts +87 -0
package/src/addresses.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { defineChain, type Address } from "viem";
|
|
2
|
+
|
|
3
|
+
/** Monad testnet (chain id 10143). Public RPC by QuickNode; 400 ms blocks, 800 ms finality. */
|
|
4
|
+
export const monadTestnet = defineChain({
|
|
5
|
+
id: 10143,
|
|
6
|
+
name: "Monad Testnet",
|
|
7
|
+
nativeCurrency: { name: "Monad", symbol: "MON", decimals: 18 },
|
|
8
|
+
rpcUrls: { default: { http: ["https://testnet-rpc.monad.xyz"], webSocket: ["wss://testnet-rpc.monad.xyz"] } },
|
|
9
|
+
blockExplorers: { default: { name: "MonadVision", url: "https://testnet.monadvision.com" } },
|
|
10
|
+
contracts: { multicall3: { address: "0xcA11bde05977b3631167028862bE2a173976CA11" } },
|
|
11
|
+
testnet: true,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/** Every contract the SDK talks to, for one deployment. */
|
|
15
|
+
export interface Deployment {
|
|
16
|
+
chainId: number;
|
|
17
|
+
/** AgentPassport: per-agent settled-work record + `meets(policy)`. */
|
|
18
|
+
agentPassport: Address;
|
|
19
|
+
/** JobEscrow: USDC escrow for hiring ERC-8004 agents; the passport's attester. */
|
|
20
|
+
jobEscrow: Address;
|
|
21
|
+
/** ERC-8004 IdentityRegistry (agent = ERC-721 token). */
|
|
22
|
+
identityRegistry: Address;
|
|
23
|
+
/** ERC-8004 ReputationRegistry (the passport mirrors settled jobs here). */
|
|
24
|
+
reputationRegistry: Address;
|
|
25
|
+
/** Settlement token pinned by the escrow (Circle USDC). */
|
|
26
|
+
usdc: Address;
|
|
27
|
+
/** EIP-712 domain of the settlement token, used for EIP-3009 signatures. */
|
|
28
|
+
usdcDomain: { name: string; version: string };
|
|
29
|
+
/** First block worth scanning for escrow events. */
|
|
30
|
+
fromBlock: bigint;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The live AgentPassport deployment on Monad testnet (Sourcify exact-match verified). */
|
|
34
|
+
export const MONAD_TESTNET: Deployment = {
|
|
35
|
+
chainId: 10143,
|
|
36
|
+
agentPassport: "0xd01EC5Fd5A9A4335D64600aDA4E010AA6fAF9d0A",
|
|
37
|
+
jobEscrow: "0x5b197edD258572DEe7C923A6D38D6Db268A266BC",
|
|
38
|
+
identityRegistry: "0x8004A818BFB912233c491871b3d84c89A494BD9e",
|
|
39
|
+
reputationRegistry: "0x8004B663056A597Dffe9eCcC1965A193B7388713",
|
|
40
|
+
usdc: "0x534b2f3A21130d7a60830c2Df862319e593943A3",
|
|
41
|
+
usdcDomain: { name: "USDC", version: "2" },
|
|
42
|
+
fromBlock: 64403471n,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** agentfromzero, the AI agent that built AgentPassport and is its first hired user. */
|
|
46
|
+
export const AGENTFROMZERO_AGENT_ID = 1908n;
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseEventLogs,
|
|
3
|
+
type Abi,
|
|
4
|
+
type Account,
|
|
5
|
+
type Address,
|
|
6
|
+
type Chain,
|
|
7
|
+
type ContractEventName,
|
|
8
|
+
type Hex,
|
|
9
|
+
type ParseEventLogsReturnType,
|
|
10
|
+
type PublicClient,
|
|
11
|
+
type TransactionReceipt,
|
|
12
|
+
type Transport,
|
|
13
|
+
type WalletClient,
|
|
14
|
+
} from "viem";
|
|
15
|
+
import { agentPassportAbi, identityRegistryAbi, jobEscrowAbi, reputationRegistryAbi, usdcAbi } from "./abis.js";
|
|
16
|
+
import { MONAD_TESTNET, type Deployment } from "./addresses.js";
|
|
17
|
+
import { openNonce, signOpenAuthorization } from "./gasless.js";
|
|
18
|
+
import {
|
|
19
|
+
JobStatus,
|
|
20
|
+
type HireInput,
|
|
21
|
+
type Job,
|
|
22
|
+
type OpenAuthorization,
|
|
23
|
+
type OpenParams,
|
|
24
|
+
type Passport,
|
|
25
|
+
type PolicyInput,
|
|
26
|
+
type Scorecard,
|
|
27
|
+
} from "./types.js";
|
|
28
|
+
import { ZERO_ADDRESS, evaluatePolicy, formatUsdc, hashContent, toAgentId, toPolicy } from "./utils.js";
|
|
29
|
+
|
|
30
|
+
type AnyPublicClient = PublicClient<Transport, Chain | undefined>;
|
|
31
|
+
type AgentIdLike = bigint | number | string;
|
|
32
|
+
|
|
33
|
+
/** Decoded JobEscrow event (JobOpened / JobDelivered / JobReleased / JobRefunded / JobDisputed / PasskeyRegistered). */
|
|
34
|
+
export type JobEvent = ParseEventLogsReturnType<typeof jobEscrowAbi, ContractEventName<typeof jobEscrowAbi>, true>[number];
|
|
35
|
+
|
|
36
|
+
export interface AgentPassportClientOptions {
|
|
37
|
+
publicClient: AnyPublicClient;
|
|
38
|
+
/** Needed for writes only. Must carry an account (local key or JSON-RPC account). */
|
|
39
|
+
walletClient?: WalletClient;
|
|
40
|
+
/** Defaults to the live Monad testnet deployment. */
|
|
41
|
+
deployment?: Deployment;
|
|
42
|
+
/**
|
|
43
|
+
* Largest block span per `eth_getLogs`. Public Monad testnet RPCs cap it at 100 blocks
|
|
44
|
+
* (QuickNode: "eth_getLogs is limited to a 100 range"), so the SDK pages through ranges.
|
|
45
|
+
*/
|
|
46
|
+
maxLogRange?: bigint;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TxResult {
|
|
50
|
+
hash: Hex;
|
|
51
|
+
receipt: TransactionReceipt;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Delivery {
|
|
55
|
+
jobId: bigint;
|
|
56
|
+
deliverableHash: Hex;
|
|
57
|
+
deliverableURI: string;
|
|
58
|
+
blockNumber: bigint;
|
|
59
|
+
transactionHash: Hex;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class AgentPassportError extends Error {}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One object for everything AgentPassport: passport reads and policy checks, the escrow lifecycle
|
|
66
|
+
* (plain and gasless EIP-3009 opens), ERC-8004 identity/reputation lookups and event tailing.
|
|
67
|
+
*
|
|
68
|
+
* Writes are simulated first (so a revert surfaces as a decoded custom error and costs nothing:
|
|
69
|
+
* Monad charges the gas *limit*, not gas used), then sent and awaited; every write returns the
|
|
70
|
+
* receipt, which on Monad is final ~800 ms after submission.
|
|
71
|
+
*/
|
|
72
|
+
export class AgentPassportClient {
|
|
73
|
+
readonly publicClient: AnyPublicClient;
|
|
74
|
+
readonly walletClient?: WalletClient;
|
|
75
|
+
readonly deployment: Deployment;
|
|
76
|
+
readonly maxLogRange: bigint;
|
|
77
|
+
|
|
78
|
+
constructor(opts: AgentPassportClientOptions) {
|
|
79
|
+
this.publicClient = opts.publicClient;
|
|
80
|
+
this.walletClient = opts.walletClient;
|
|
81
|
+
this.deployment = opts.deployment ?? MONAD_TESTNET;
|
|
82
|
+
this.maxLogRange = opts.maxLogRange ?? 100n;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ───────────────────────────── passport ─────────────────────────────
|
|
86
|
+
|
|
87
|
+
async getPassport(agentId: AgentIdLike, blockNumber?: bigint): Promise<Passport> {
|
|
88
|
+
const p = await this.publicClient.readContract({
|
|
89
|
+
address: this.deployment.agentPassport,
|
|
90
|
+
abi: agentPassportAbi,
|
|
91
|
+
functionName: "passportOf",
|
|
92
|
+
args: [toAgentId(agentId)],
|
|
93
|
+
blockNumber,
|
|
94
|
+
});
|
|
95
|
+
return { ...p, jobsSettled: BigInt(p.jobsSettled), jobsRefunded: BigInt(p.jobsRefunded), jobsDisputed: BigInt(p.jobsDisputed), firstSeen: BigInt(p.firstSeen), lastSettled: BigInt(p.lastSettled) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The on-chain policy check integrators call before routing work or money to an agent. */
|
|
99
|
+
async meets(agentId: AgentIdLike, policy: PolicyInput = {}, blockNumber?: bigint): Promise<boolean> {
|
|
100
|
+
return this.publicClient.readContract({
|
|
101
|
+
address: this.deployment.agentPassport,
|
|
102
|
+
abi: agentPassportAbi,
|
|
103
|
+
functionName: "meets",
|
|
104
|
+
args: [toAgentId(agentId), toPolicy(policy)],
|
|
105
|
+
blockNumber,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Settled jobs between one hirer and one agent (repeat-business signal). */
|
|
110
|
+
async settledBetween(hirer: Address, agentId: AgentIdLike): Promise<bigint> {
|
|
111
|
+
return BigInt(
|
|
112
|
+
await this.publicClient.readContract({
|
|
113
|
+
address: this.deployment.agentPassport,
|
|
114
|
+
abi: agentPassportAbi,
|
|
115
|
+
functionName: "settledBetween",
|
|
116
|
+
args: [hirer, toAgentId(agentId)],
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Everything a router needs to decide on one agent, read at a single block: the chain's `meets`
|
|
123
|
+
* verdict, a rule-by-rule explanation, the passport, the ERC-8004 identity, and the escrow-backed
|
|
124
|
+
* slice of ERC-8004 reputation (feedback whose client is the AgentPassport contract).
|
|
125
|
+
*/
|
|
126
|
+
async scorecard(agentId: AgentIdLike, policyInput: PolicyInput = {}, opts: { blockNumber?: bigint } = {}): Promise<Scorecard> {
|
|
127
|
+
const id = toAgentId(agentId);
|
|
128
|
+
const policy = toPolicy(policyInput);
|
|
129
|
+
const block = await this.publicClient.getBlock(opts.blockNumber ? { blockNumber: opts.blockNumber } : { blockTag: "latest" });
|
|
130
|
+
const blockNumber = block.number;
|
|
131
|
+
const [passport, meets, identity, reputation] = await Promise.all([
|
|
132
|
+
this.getPassport(id, blockNumber),
|
|
133
|
+
this.meets(id, policy, blockNumber),
|
|
134
|
+
this.getAgent(id, blockNumber).catch(() => null),
|
|
135
|
+
this.getEscrowReputation(id, blockNumber),
|
|
136
|
+
]);
|
|
137
|
+
const explained = evaluatePolicy(passport, policy, block.timestamp);
|
|
138
|
+
const iso = (t: bigint) => (t === 0n ? null : new Date(Number(t) * 1000).toISOString());
|
|
139
|
+
return {
|
|
140
|
+
agentId: id.toString(),
|
|
141
|
+
chainId: this.deployment.chainId,
|
|
142
|
+
blockNumber: blockNumber.toString(),
|
|
143
|
+
meets,
|
|
144
|
+
checks: explained.checks,
|
|
145
|
+
passport: {
|
|
146
|
+
jobsSettled: passport.jobsSettled.toString(),
|
|
147
|
+
jobsRefunded: passport.jobsRefunded.toString(),
|
|
148
|
+
jobsDisputed: passport.jobsDisputed.toString(),
|
|
149
|
+
volumeSettled: passport.volumeSettled.toString(),
|
|
150
|
+
volumeSettledUsdc: formatUsdc(passport.volumeSettled),
|
|
151
|
+
firstSeen: iso(passport.firstSeen),
|
|
152
|
+
lastSettled: iso(passport.lastSettled),
|
|
153
|
+
token: passport.token === ZERO_ADDRESS ? null : passport.token,
|
|
154
|
+
},
|
|
155
|
+
identity,
|
|
156
|
+
reputation: {
|
|
157
|
+
count: reputation.count.toString(),
|
|
158
|
+
summaryValue: reputation.count === 0n ? null : reputation.summary,
|
|
159
|
+
client: this.deployment.agentPassport,
|
|
160
|
+
},
|
|
161
|
+
policy: {
|
|
162
|
+
minJobsSettled: policy.minJobsSettled.toString(),
|
|
163
|
+
minVolumeSettled: policy.minVolumeSettled.toString(),
|
|
164
|
+
maxJobsDisputed: policy.maxJobsDisputed.toString(),
|
|
165
|
+
maxAgeOfLastSettlement: policy.maxAgeOfLastSettlement.toString(),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ───────────────────────────── ERC-8004 ─────────────────────────────
|
|
171
|
+
|
|
172
|
+
/** ERC-8004 identity: owner, payment wallet (`agentWallet` metadata) and agent-card URI. Throws if the agent does not exist. */
|
|
173
|
+
async getAgent(agentId: AgentIdLike, blockNumber?: bigint): Promise<{ owner: Address; agentWallet: Address | null; agentURI: string }> {
|
|
174
|
+
const id = toAgentId(agentId);
|
|
175
|
+
const c = { address: this.deployment.identityRegistry, abi: identityRegistryAbi, blockNumber } as const;
|
|
176
|
+
const [owner, agentWallet, agentURI] = await Promise.all([
|
|
177
|
+
this.publicClient.readContract({ ...c, functionName: "ownerOf", args: [id] }),
|
|
178
|
+
this.publicClient.readContract({ ...c, functionName: "getAgentWallet", args: [id] }),
|
|
179
|
+
// Registries without ERC-721 metadata simply have no card URI.
|
|
180
|
+
this.publicClient.readContract({ ...c, functionName: "tokenURI", args: [id] }).catch(() => ""),
|
|
181
|
+
]);
|
|
182
|
+
return { owner, agentWallet: agentWallet === ZERO_ADDRESS ? null : agentWallet, agentURI };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Where the escrow pays the agent: `agentWallet`, falling back to the ERC-721 owner (same rule as JobEscrow). */
|
|
186
|
+
async getPayoutAddress(agentId: AgentIdLike): Promise<Address> {
|
|
187
|
+
const a = await this.getAgent(agentId);
|
|
188
|
+
return a.agentWallet ?? a.owner;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Fetches and parses the agent's registration file (ERC-8004 agent card). Supports https, ipfs:// and data: URIs. */
|
|
192
|
+
async fetchAgentCard(agentId: AgentIdLike, fetchImpl: typeof fetch = fetch): Promise<Record<string, unknown>> {
|
|
193
|
+
const { agentURI } = await this.getAgent(agentId);
|
|
194
|
+
if (agentURI.startsWith("data:")) {
|
|
195
|
+
const [meta, body = ""] = agentURI.slice(5).split(",", 2);
|
|
196
|
+
const text = meta?.endsWith(";base64")
|
|
197
|
+
? new TextDecoder().decode(Uint8Array.from(atob(body), (ch) => ch.charCodeAt(0)))
|
|
198
|
+
: decodeURIComponent(body);
|
|
199
|
+
return JSON.parse(text);
|
|
200
|
+
}
|
|
201
|
+
const url = agentURI.startsWith("ipfs://") ? `https://ipfs.io/ipfs/${agentURI.slice(7)}` : agentURI;
|
|
202
|
+
const res = await fetchImpl(url, { headers: { accept: "application/json" } });
|
|
203
|
+
if (!res.ok) throw new AgentPassportError(`agent card ${url}: HTTP ${res.status}`);
|
|
204
|
+
return (await res.json()) as Record<string, unknown>;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* ERC-8004 `getSummary` restricted to feedback written by the AgentPassport contract, i.e. only
|
|
209
|
+
* feedback that a settled (or disputed) escrow job paid for. Sybil feedback from other clients is excluded.
|
|
210
|
+
*/
|
|
211
|
+
async getEscrowReputation(agentId: AgentIdLike, blockNumber?: bigint): Promise<{ count: bigint; summary: string }> {
|
|
212
|
+
try {
|
|
213
|
+
const [count, value, decimals] = await this.publicClient.readContract({
|
|
214
|
+
address: this.deployment.reputationRegistry,
|
|
215
|
+
abi: reputationRegistryAbi,
|
|
216
|
+
functionName: "getSummary",
|
|
217
|
+
args: [toAgentId(agentId), [this.deployment.agentPassport], "", ""],
|
|
218
|
+
blockNumber,
|
|
219
|
+
});
|
|
220
|
+
return { count: BigInt(count), summary: formatFixed(value, decimals) };
|
|
221
|
+
} catch {
|
|
222
|
+
return { count: 0n, summary: "0" };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Individual escrow-backed feedback entries (value 1.00 = settled, 0.00 = disputed). */
|
|
227
|
+
async getEscrowFeedback(agentId: AgentIdLike) {
|
|
228
|
+
const id = toAgentId(agentId);
|
|
229
|
+
const [clients, indexes, values, decimals, tag1s, tag2s, revoked] = await this.publicClient.readContract({
|
|
230
|
+
address: this.deployment.reputationRegistry,
|
|
231
|
+
abi: reputationRegistryAbi,
|
|
232
|
+
functionName: "readAllFeedback",
|
|
233
|
+
args: [id, [this.deployment.agentPassport], "", "", true],
|
|
234
|
+
});
|
|
235
|
+
return clients.map((client, i) => ({
|
|
236
|
+
client,
|
|
237
|
+
index: BigInt(indexes[i]!),
|
|
238
|
+
value: formatFixed(values[i]!, decimals[i]!),
|
|
239
|
+
tag1: tag1s[i]!,
|
|
240
|
+
tag2: tag2s[i]!,
|
|
241
|
+
revoked: revoked[i]!,
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ───────────────────────────── escrow reads ─────────────────────────────
|
|
246
|
+
|
|
247
|
+
async getJob(jobId: bigint | number): Promise<Job> {
|
|
248
|
+
const j = await this.publicClient.readContract({
|
|
249
|
+
address: this.deployment.jobEscrow,
|
|
250
|
+
abi: jobEscrowAbi,
|
|
251
|
+
functionName: "getJob",
|
|
252
|
+
args: [BigInt(jobId)],
|
|
253
|
+
});
|
|
254
|
+
return { ...j, amount: BigInt(j.amount), deadline: BigInt(j.deadline), reviewWindow: BigInt(j.reviewWindow), deliveredAt: BigInt(j.deliveredAt), status: j.status as JobStatus };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async jobCount(): Promise<bigint> {
|
|
258
|
+
return this.publicClient.readContract({ address: this.deployment.jobEscrow, abi: jobEscrowAbi, functionName: "jobCount" });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Jobs from `fromId` up to the current `jobCount`, optionally for one agent / status.
|
|
263
|
+
* State-based (one `getJob` per id), so it works on RPCs that cap `eth_getLogs` ranges.
|
|
264
|
+
*/
|
|
265
|
+
async listJobs(filter: { agentId?: AgentIdLike; status?: JobStatus; fromId?: bigint } = {}): Promise<Array<Job & { jobId: bigint }>> {
|
|
266
|
+
const count = await this.jobCount();
|
|
267
|
+
const agentId = filter.agentId === undefined ? undefined : toAgentId(filter.agentId);
|
|
268
|
+
const ids: bigint[] = [];
|
|
269
|
+
for (let i = filter.fromId ?? 1n; i <= count; i++) ids.push(i);
|
|
270
|
+
const jobs = await Promise.all(ids.map(async (jobId) => ({ jobId, ...(await this.getJob(jobId)) })));
|
|
271
|
+
return jobs.filter((j) => (agentId === undefined || j.agentId === agentId) && (filter.status === undefined || j.status === filter.status));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Locates the `JobDelivered` event of a delivered job (URI + hash + tx) without scanning history. */
|
|
275
|
+
async getDelivery(jobId: bigint | number): Promise<Delivery | null> {
|
|
276
|
+
const id = BigInt(jobId);
|
|
277
|
+
const job = await this.getJob(id);
|
|
278
|
+
if (job.deliveredAt === 0n) return null;
|
|
279
|
+
// The delivery block is among the blocks stamped with `deliveredAt`; walk them window by window.
|
|
280
|
+
const head = await this.publicClient.getBlockNumber({ cacheTime: 0 });
|
|
281
|
+
for (let from = await this.firstBlockAtOrAfter(job.deliveredAt); from <= head; from += this.maxLogRange) {
|
|
282
|
+
const to = from + this.maxLogRange - 1n < head ? from + this.maxLogRange - 1n : head;
|
|
283
|
+
const ev = (await this.getJobEvents({ fromBlock: from, toBlock: to })).find((e) => e.eventName === "JobDelivered" && e.args.jobId === id);
|
|
284
|
+
if (ev?.eventName === "JobDelivered") {
|
|
285
|
+
return { jobId: id, deliverableHash: ev.args.deliverableHash, deliverableURI: ev.args.deliverableURI, blockNumber: ev.blockNumber, transactionHash: ev.transactionHash };
|
|
286
|
+
}
|
|
287
|
+
if ((await this.publicClient.getBlock({ blockNumber: to })).timestamp > job.deliveredAt) break;
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Downloads a delivered job's deliverable and checks keccak256(bytes) against the hash the agent
|
|
294
|
+
* committed on chain — what a hirer (or verifier) does before `release`.
|
|
295
|
+
*/
|
|
296
|
+
async verifyDelivery(jobId: bigint | number, fetchImpl: typeof fetch = fetch): Promise<{ ok: boolean; delivery: Delivery; actualHash: Hex; bytes: Uint8Array }> {
|
|
297
|
+
const delivery = await this.getDelivery(jobId);
|
|
298
|
+
if (!delivery) throw new AgentPassportError(`job ${jobId} has not been delivered`);
|
|
299
|
+
const res = await fetchImpl(delivery.deliverableURI);
|
|
300
|
+
if (!res.ok) throw new AgentPassportError(`deliverable ${delivery.deliverableURI}: HTTP ${res.status}`);
|
|
301
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
302
|
+
const actualHash = hashContent(bytes);
|
|
303
|
+
return { ok: actualHash === delivery.deliverableHash, delivery, actualHash, bytes };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ───────────────────────────── events ─────────────────────────────
|
|
307
|
+
|
|
308
|
+
/** Decoded JobEscrow events in [fromBlock, toBlock], paged in `maxLogRange` chunks. */
|
|
309
|
+
async getJobEvents(range: { fromBlock: bigint; toBlock?: bigint }): Promise<JobEvent[]> {
|
|
310
|
+
const toBlock = range.toBlock ?? (await this.publicClient.getBlockNumber({ cacheTime: 0 }));
|
|
311
|
+
const out: JobEvent[] = [];
|
|
312
|
+
for (let from = range.fromBlock; from <= toBlock; from += this.maxLogRange) {
|
|
313
|
+
const to = from + this.maxLogRange - 1n < toBlock ? from + this.maxLogRange - 1n : toBlock;
|
|
314
|
+
const logs = await this.publicClient.getLogs({ address: this.deployment.jobEscrow, fromBlock: from, toBlock: to });
|
|
315
|
+
out.push(...(parseEventLogs({ abi: jobEscrowAbi, logs, strict: true }) as JobEvent[]));
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Tails JobEscrow events from `fromBlock` (default: head) and calls `onEvent` in order. Pages
|
|
322
|
+
* through the 100-block `eth_getLogs` cap, so it catches up after a pause instead of skipping.
|
|
323
|
+
* Returns a stop function. `onError` gets RPC errors; the loop retries on the next tick.
|
|
324
|
+
*/
|
|
325
|
+
watchJobEvents(opts: {
|
|
326
|
+
onEvent: (e: JobEvent) => void | Promise<void>;
|
|
327
|
+
onError?: (err: unknown) => void;
|
|
328
|
+
onBlock?: (block: bigint) => void;
|
|
329
|
+
fromBlock?: bigint;
|
|
330
|
+
pollMs?: number;
|
|
331
|
+
}): () => void {
|
|
332
|
+
let stopped = false;
|
|
333
|
+
let next = opts.fromBlock;
|
|
334
|
+
const tick = async () => {
|
|
335
|
+
try {
|
|
336
|
+
const head = await this.publicClient.getBlockNumber({ cacheTime: 0 });
|
|
337
|
+
let from: bigint = next ?? head;
|
|
338
|
+
while (!stopped && from <= head) {
|
|
339
|
+
const to: bigint = from + this.maxLogRange - 1n < head ? from + this.maxLogRange - 1n : head;
|
|
340
|
+
for (const e of await this.getJobEvents({ fromBlock: from, toBlock: to })) await opts.onEvent(e);
|
|
341
|
+
from = to + 1n;
|
|
342
|
+
next = from;
|
|
343
|
+
opts.onBlock?.(to);
|
|
344
|
+
}
|
|
345
|
+
} catch (err) {
|
|
346
|
+
opts.onError?.(err);
|
|
347
|
+
}
|
|
348
|
+
if (!stopped) timer = setTimeout(tick, opts.pollMs ?? 1000);
|
|
349
|
+
};
|
|
350
|
+
let timer: ReturnType<typeof setTimeout> = setTimeout(tick, 0);
|
|
351
|
+
return () => {
|
|
352
|
+
stopped = true;
|
|
353
|
+
clearTimeout(timer);
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ───────────────────────────── escrow writes ─────────────────────────────
|
|
358
|
+
|
|
359
|
+
/** Turns a friendly `HireInput` into the exact `OpenParams` struct (token pinned to the deployment's USDC). */
|
|
360
|
+
toOpenParams(input: HireInput): OpenParams {
|
|
361
|
+
const now = BigInt(Math.floor(Date.now() / 1000));
|
|
362
|
+
return {
|
|
363
|
+
agentId: toAgentId(input.agentId),
|
|
364
|
+
token: this.deployment.usdc,
|
|
365
|
+
amount: input.amount,
|
|
366
|
+
deadline: input.deadline === undefined ? now + 86400n : BigInt(input.deadline),
|
|
367
|
+
reviewWindow: input.reviewWindow === undefined ? 3600n : BigInt(input.reviewWindow),
|
|
368
|
+
verifier: input.verifier ?? ZERO_ADDRESS,
|
|
369
|
+
specHash: input.specHash,
|
|
370
|
+
endpoint: input.endpoint ?? "",
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Hire an agent: approves USDC if the allowance is short, then `open`. Returns the new job id. */
|
|
375
|
+
async hire(input: HireInput): Promise<TxResult & { jobId: bigint; params: OpenParams; approveHash?: Hex }> {
|
|
376
|
+
const params = this.toOpenParams(input);
|
|
377
|
+
const { account } = this.wallet();
|
|
378
|
+
const allowance = await this.publicClient.readContract({
|
|
379
|
+
address: this.deployment.usdc,
|
|
380
|
+
abi: usdcAbi,
|
|
381
|
+
functionName: "allowance",
|
|
382
|
+
args: [account.address, this.deployment.jobEscrow],
|
|
383
|
+
});
|
|
384
|
+
let approveHash: Hex | undefined;
|
|
385
|
+
if (allowance < params.amount) {
|
|
386
|
+
approveHash = (await this.write(this.deployment.usdc, usdcAbi, "approve", [this.deployment.jobEscrow, params.amount])).hash;
|
|
387
|
+
}
|
|
388
|
+
const tx = await this.write(this.deployment.jobEscrow, jobEscrowAbi, "open", [params]);
|
|
389
|
+
return { ...tx, jobId: this.jobIdFrom(tx.receipt), params, approveHash };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The EIP-3009 nonce `openWithAuthorization` expects (computed locally; equals `JobEscrow.openNonce`). */
|
|
393
|
+
openNonce(params: OpenParams, validAfter: bigint, validBefore: bigint): Hex {
|
|
394
|
+
return openNonce(this.deployment.chainId, this.deployment.jobEscrow, params, validAfter, validBefore);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Hirer side of a gasless hire: sign only, no transaction, no MON needed. */
|
|
398
|
+
async signHire(input: HireInput, window: { validAfter?: bigint; validBefore?: bigint } = {}): Promise<{ params: OpenParams; authorization: OpenAuthorization }> {
|
|
399
|
+
const params = this.toOpenParams(input);
|
|
400
|
+
const { wallet, account } = this.wallet();
|
|
401
|
+
const authorization = await signOpenAuthorization({
|
|
402
|
+
wallet,
|
|
403
|
+
account,
|
|
404
|
+
chainId: this.deployment.chainId,
|
|
405
|
+
escrow: this.deployment.jobEscrow,
|
|
406
|
+
token: this.deployment.usdc,
|
|
407
|
+
tokenDomain: this.deployment.usdcDomain,
|
|
408
|
+
params,
|
|
409
|
+
...window,
|
|
410
|
+
});
|
|
411
|
+
return { params, authorization };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Relayer side of a gasless hire: submits the hirer's signed authorization and opens the job. */
|
|
415
|
+
async openWithAuthorization(params: OpenParams, authorization: OpenAuthorization): Promise<TxResult & { jobId: bigint }> {
|
|
416
|
+
const tx = await this.write(this.deployment.jobEscrow, jobEscrowAbi, "openWithAuthorization", [params, authorization]);
|
|
417
|
+
return { ...tx, jobId: this.jobIdFrom(tx.receipt) };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Agent side: commit to a deliverable. Pass the exact bytes (hashed here) or a precomputed hash,
|
|
422
|
+
* plus the URI where the hirer can fetch those bytes.
|
|
423
|
+
*/
|
|
424
|
+
async deliver(jobId: bigint | number, deliverable: { uri: string; content?: string | Uint8Array; hash?: Hex }): Promise<TxResult & { deliverableHash: Hex }> {
|
|
425
|
+
const deliverableHash = deliverable.hash ?? (deliverable.content !== undefined ? hashContent(deliverable.content) : undefined);
|
|
426
|
+
if (!deliverableHash) throw new AgentPassportError("deliver: pass content or hash");
|
|
427
|
+
const tx = await this.write(this.deployment.jobEscrow, jobEscrowAbi, "deliver", [BigInt(jobId), deliverableHash, deliverable.uri]);
|
|
428
|
+
return { ...tx, deliverableHash };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Hirer / verifier (or anyone after the review window): pay the agent and stamp its passport. */
|
|
432
|
+
release(jobId: bigint | number): Promise<TxResult> {
|
|
433
|
+
return this.write(this.deployment.jobEscrow, jobEscrowAbi, "release", [BigInt(jobId)]);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Hirer: reclaim an undelivered job after its deadline. */
|
|
437
|
+
refund(jobId: bigint | number): Promise<TxResult> {
|
|
438
|
+
return this.write(this.deployment.jobEscrow, jobEscrowAbi, "refund", [BigInt(jobId)]);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Hirer: reject a delivery inside the review window (refund + negative stamp). */
|
|
442
|
+
dispute(jobId: bigint | number): Promise<TxResult> {
|
|
443
|
+
return this.write(this.deployment.jobEscrow, jobEscrowAbi, "dispute", [BigInt(jobId)]);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ───────────────────────────── internals ─────────────────────────────
|
|
447
|
+
|
|
448
|
+
private wallet(): { wallet: WalletClient; account: Account } {
|
|
449
|
+
const wallet = this.walletClient;
|
|
450
|
+
if (!wallet?.account) throw new AgentPassportError("this call needs a walletClient with an account");
|
|
451
|
+
return { wallet, account: wallet.account };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private async write(address: Address, abi: Abi, functionName: string, args: readonly unknown[]): Promise<TxResult> {
|
|
455
|
+
const { wallet, account } = this.wallet();
|
|
456
|
+
const { request } = await this.publicClient.simulateContract({ address, abi, functionName, args, account });
|
|
457
|
+
const hash = await wallet.writeContract({ ...request, chain: wallet.chain ?? null, account } as Parameters<WalletClient["writeContract"]>[0]);
|
|
458
|
+
// Monad produces a block every ~400 ms; poll at that pace rather than viem's 4 s default.
|
|
459
|
+
const receipt = await this.publicClient.waitForTransactionReceipt({ hash, pollingInterval: Math.min(this.publicClient.pollingInterval, 400) });
|
|
460
|
+
if (receipt.status !== "success") throw new AgentPassportError(`${functionName} reverted in tx ${hash}`);
|
|
461
|
+
return { hash, receipt };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private jobIdFrom(receipt: TransactionReceipt): bigint {
|
|
465
|
+
const [ev] = parseEventLogs({ abi: jobEscrowAbi, logs: receipt.logs, eventName: "JobOpened" });
|
|
466
|
+
if (!ev) throw new AgentPassportError(`no JobOpened event in ${receipt.transactionHash}`);
|
|
467
|
+
return ev.args.jobId;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Binary search for the first block with timestamp >= `ts` (Monad: 2-3 blocks share a second). */
|
|
471
|
+
private async firstBlockAtOrAfter(ts: bigint): Promise<bigint> {
|
|
472
|
+
let lo = this.deployment.fromBlock;
|
|
473
|
+
let hi = await this.publicClient.getBlockNumber({ cacheTime: 0 });
|
|
474
|
+
while (lo < hi) {
|
|
475
|
+
const mid = (lo + hi) / 2n;
|
|
476
|
+
const b = await this.publicClient.getBlock({ blockNumber: mid });
|
|
477
|
+
if (b.timestamp < ts) lo = mid + 1n;
|
|
478
|
+
else hi = mid;
|
|
479
|
+
}
|
|
480
|
+
return lo;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Signed fixed-point to a decimal string: (150, 2) -> "1.5", (-5, 1) -> "-0.5". */
|
|
485
|
+
export function formatFixed(value: bigint, decimals: number): string {
|
|
486
|
+
const neg = value < 0n;
|
|
487
|
+
const abs = neg ? -value : value;
|
|
488
|
+
const base = 10n ** BigInt(decimals);
|
|
489
|
+
const frac = (abs % base).toString().padStart(decimals, "0").replace(/0+$/, "");
|
|
490
|
+
return `${neg ? "-" : ""}${abs / base}${frac ? `.${frac}` : ""}`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Shorthand for `new AgentPassportClient(opts)`. */
|
|
494
|
+
export function createAgentPassport(opts: AgentPassportClientOptions): AgentPassportClient {
|
|
495
|
+
return new AgentPassportClient(opts);
|
|
496
|
+
}
|
package/src/gasless.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { encodeAbiParameters, keccak256, toBytes, type Account, type Address, type Hex, type WalletClient } from "viem";
|
|
2
|
+
import type { OpenAuthorization, OpenParams } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/** keccak256("AgentPassport.JobEscrow.openWithAuthorization") — the domain tag of `openNonce`. */
|
|
5
|
+
export const OPEN_AUTH_TYPEHASH = keccak256(toBytes("AgentPassport.JobEscrow.openWithAuthorization"));
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The EIP-3009 nonce that binds a hirer's USDC authorization to one exact job.
|
|
9
|
+
* Byte-for-byte the same as `JobEscrow.openNonce` (checked against the live contract in the tests):
|
|
10
|
+
* a relayer that changes the agent, amount, deadline, verifier, spec or endpoint invalidates it.
|
|
11
|
+
*/
|
|
12
|
+
export function openNonce(
|
|
13
|
+
chainId: number | bigint,
|
|
14
|
+
escrow: Address,
|
|
15
|
+
p: OpenParams,
|
|
16
|
+
validAfter: bigint,
|
|
17
|
+
validBefore: bigint,
|
|
18
|
+
): Hex {
|
|
19
|
+
return keccak256(
|
|
20
|
+
encodeAbiParameters(
|
|
21
|
+
[
|
|
22
|
+
{ type: "bytes32" },
|
|
23
|
+
{ type: "uint256" },
|
|
24
|
+
{ type: "address" },
|
|
25
|
+
{ type: "uint256" },
|
|
26
|
+
{ type: "address" },
|
|
27
|
+
{ type: "uint128" },
|
|
28
|
+
{ type: "uint64" },
|
|
29
|
+
{ type: "uint64" },
|
|
30
|
+
{ type: "address" },
|
|
31
|
+
{ type: "bytes32" },
|
|
32
|
+
{ type: "bytes32" },
|
|
33
|
+
{ type: "uint256" },
|
|
34
|
+
{ type: "uint256" },
|
|
35
|
+
],
|
|
36
|
+
[
|
|
37
|
+
OPEN_AUTH_TYPEHASH,
|
|
38
|
+
BigInt(chainId),
|
|
39
|
+
escrow,
|
|
40
|
+
p.agentId,
|
|
41
|
+
p.token,
|
|
42
|
+
p.amount,
|
|
43
|
+
p.deadline,
|
|
44
|
+
p.reviewWindow,
|
|
45
|
+
p.verifier,
|
|
46
|
+
p.specHash,
|
|
47
|
+
keccak256(toBytes(p.endpoint)),
|
|
48
|
+
validAfter,
|
|
49
|
+
validBefore,
|
|
50
|
+
],
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** EIP-712 types of Circle USDC's `receiveWithAuthorization` (EIP-3009) — also what x402 "exact" signs. */
|
|
56
|
+
export const RECEIVE_WITH_AUTHORIZATION_TYPES = {
|
|
57
|
+
ReceiveWithAuthorization: [
|
|
58
|
+
{ name: "from", type: "address" },
|
|
59
|
+
{ name: "to", type: "address" },
|
|
60
|
+
{ name: "value", type: "uint256" },
|
|
61
|
+
{ name: "validAfter", type: "uint256" },
|
|
62
|
+
{ name: "validBefore", type: "uint256" },
|
|
63
|
+
{ name: "nonce", type: "bytes32" },
|
|
64
|
+
],
|
|
65
|
+
} as const;
|
|
66
|
+
|
|
67
|
+
export interface SignOpenArgs {
|
|
68
|
+
/** Wallet of the hirer; it signs only, it needs no MON. */
|
|
69
|
+
wallet: WalletClient;
|
|
70
|
+
account?: Account | Address;
|
|
71
|
+
chainId: number;
|
|
72
|
+
escrow: Address;
|
|
73
|
+
token: Address;
|
|
74
|
+
tokenDomain: { name: string; version: string };
|
|
75
|
+
params: OpenParams;
|
|
76
|
+
/** Unix seconds; default 0 (valid immediately). */
|
|
77
|
+
validAfter?: bigint;
|
|
78
|
+
/** Unix seconds; default now + 1 h. */
|
|
79
|
+
validBefore?: bigint;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Hirer side of a gasless hire: signs an EIP-3009 `ReceiveWithAuthorization` for exactly
|
|
84
|
+
* `params.amount` USDC to the escrow, with the nonce bound to `params`. Anyone (the agent, a relayer,
|
|
85
|
+
* an x402 facilitator) can then submit `JobEscrow.openWithAuthorization(params, authorization)`.
|
|
86
|
+
*/
|
|
87
|
+
export async function signOpenAuthorization(args: SignOpenArgs): Promise<OpenAuthorization> {
|
|
88
|
+
const account = args.account ?? args.wallet.account;
|
|
89
|
+
if (!account) throw new Error("signOpenAuthorization: wallet has no account");
|
|
90
|
+
const from = typeof account === "string" ? account : account.address;
|
|
91
|
+
const validAfter = args.validAfter ?? 0n;
|
|
92
|
+
const validBefore = args.validBefore ?? BigInt(Math.floor(Date.now() / 1000) + 3600);
|
|
93
|
+
const nonce = openNonce(args.chainId, args.escrow, args.params, validAfter, validBefore);
|
|
94
|
+
const signature = await args.wallet.signTypedData({
|
|
95
|
+
account,
|
|
96
|
+
domain: { ...args.tokenDomain, chainId: args.chainId, verifyingContract: args.token },
|
|
97
|
+
types: RECEIVE_WITH_AUTHORIZATION_TYPES,
|
|
98
|
+
primaryType: "ReceiveWithAuthorization",
|
|
99
|
+
message: { from, to: args.escrow, value: args.params.amount, validAfter, validBefore, nonce },
|
|
100
|
+
});
|
|
101
|
+
return { from, validAfter, validBefore, nonce, signature };
|
|
102
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { AgentPassportClient, AgentPassportError, createAgentPassport, formatFixed } from "./client.js";
|
|
2
|
+
export type { AgentPassportClientOptions, Delivery, JobEvent, TxResult } from "./client.js";
|
|
3
|
+
export { MONAD_TESTNET, AGENTFROMZERO_AGENT_ID, monadTestnet } from "./addresses.js";
|
|
4
|
+
export type { Deployment } from "./addresses.js";
|
|
5
|
+
export { OPEN_AUTH_TYPEHASH, RECEIVE_WITH_AUTHORIZATION_TYPES, openNonce, signOpenAuthorization } from "./gasless.js";
|
|
6
|
+
export type { SignOpenArgs } from "./gasless.js";
|
|
7
|
+
export { POLICIES, USDC_DECIMALS, ZERO_ADDRESS, evaluatePolicy, formatUsdc, hashContent, jobRef, parseUsdc, toAgentId, toPolicy } from "./utils.js";
|
|
8
|
+
export { JobStatus, jobStatusName } from "./types.js";
|
|
9
|
+
export type { HireInput, Job, OpenAuthorization, OpenParams, Passport, Policy, PolicyCheck, PolicyInput, Scorecard } from "./types.js";
|
|
10
|
+
export { agentPassportAbi, identityRegistryAbi, jobEscrowAbi, reputationRegistryAbi, usdcAbi } from "./abis.js";
|