@absol-labs/agent 0.3.2 → 0.5.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 +378 -168
- package/dist/capability/invocation-capability.d.ts +184 -0
- package/dist/capability/invocation-capability.d.ts.map +1 -0
- package/dist/capability/invocation-capability.js +183 -0
- package/dist/capability/invocation-capability.js.map +1 -0
- package/dist/frameworks/agentkit.js +2 -2
- package/dist/frameworks/agentkit.js.map +1 -1
- package/dist/frameworks/eliza.js +2 -2
- package/dist/frameworks/eliza.js.map +1 -1
- package/dist/frameworks/langchain.js +2 -2
- package/dist/frameworks/langchain.js.map +1 -1
- package/dist/gateway/caller-auth-gateway.d.ts +106 -0
- package/dist/gateway/caller-auth-gateway.d.ts.map +1 -0
- package/dist/gateway/caller-auth-gateway.js +189 -0
- package/dist/gateway/caller-auth-gateway.js.map +1 -0
- package/dist/gateway/http-server.d.ts +49 -0
- package/dist/gateway/http-server.d.ts.map +1 -0
- package/dist/gateway/http-server.js +227 -0
- package/dist/gateway/http-server.js.map +1 -0
- package/dist/gateway/server-entry.d.ts +2 -0
- package/dist/gateway/server-entry.d.ts.map +1 -0
- package/dist/gateway/server-entry.js +30 -0
- package/dist/gateway/server-entry.js.map +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +2 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/sdk/invoke.d.ts +122 -0
- package/dist/sdk/invoke.d.ts.map +1 -0
- package/dist/sdk/invoke.js +158 -0
- package/dist/sdk/invoke.js.map +1 -0
- package/dist/wallet/lifecycle.d.ts +101 -0
- package/dist/wallet/lifecycle.d.ts.map +1 -0
- package/dist/wallet/lifecycle.js +57 -0
- package/dist/wallet/lifecycle.js.map +1 -0
- package/dist/zktls/reclaim.d.ts +84 -5
- package/dist/zktls/reclaim.d.ts.map +1 -1
- package/dist/zktls/reclaim.js +47 -3
- package/dist/zktls/reclaim.js.map +1 -1
- package/dist/zktls/t2-delivery-proof.d.ts +296 -0
- package/dist/zktls/t2-delivery-proof.d.ts.map +1 -0
- package/dist/zktls/t2-delivery-proof.js +336 -0
- package/dist/zktls/t2-delivery-proof.js.map +1 -0
- package/package.json +6 -3
- package/src/capability/invocation-capability.ts +255 -0
- package/src/frameworks/agentkit.ts +2 -2
- package/src/frameworks/eliza.ts +2 -2
- package/src/frameworks/langchain.ts +2 -2
- package/src/gateway/caller-auth-gateway.ts +328 -0
- package/src/gateway/http-server.ts +325 -0
- package/src/gateway/server-entry.ts +38 -0
- package/src/index.ts +103 -0
- package/src/mcp/server.ts +2 -2
- package/src/sdk/invoke.ts +313 -0
- package/src/wallet/lifecycle.ts +158 -0
- package/src/zktls/reclaim.ts +88 -8
- package/src/zktls/t2-delivery-proof.ts +559 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { startCallerAuthGatewayServerFromEnv } from "./http-server.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Runnable entrypoint for the reference caller-auth gateway (metrik-agent#63).
|
|
5
|
+
* Reads all configuration from the environment (see
|
|
6
|
+
* {@link parseCallerAuthGatewayEnvConfig}) and refuses to start if required
|
|
7
|
+
* config is missing (fail-closed).
|
|
8
|
+
*
|
|
9
|
+
* Start with: `pnpm gateway` (or `node dist/gateway/server-entry.js`).
|
|
10
|
+
*/
|
|
11
|
+
async function main(): Promise<void> {
|
|
12
|
+
const server = await startCallerAuthGatewayServerFromEnv();
|
|
13
|
+
const host = process.env.METRIK_GATEWAY_HOST ?? "0.0.0.0";
|
|
14
|
+
console.error(
|
|
15
|
+
`[metrik-caller-auth-gateway] listening on http://${host}:${server.boundPort}`,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const shutdown = (signal: string) => {
|
|
19
|
+
console.error(
|
|
20
|
+
`[metrik-caller-auth-gateway] ${signal} received — shutting down`,
|
|
21
|
+
);
|
|
22
|
+
server
|
|
23
|
+
.close()
|
|
24
|
+
.then(() => process.exit(0))
|
|
25
|
+
.catch(() => process.exit(1));
|
|
26
|
+
};
|
|
27
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
28
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
main().catch((error) => {
|
|
32
|
+
console.error(
|
|
33
|
+
error instanceof Error
|
|
34
|
+
? error.message
|
|
35
|
+
: "failed to start caller-auth gateway server",
|
|
36
|
+
);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -113,13 +113,32 @@ export {
|
|
|
113
113
|
type WalletBackedAgentClient,
|
|
114
114
|
} from "./wallet/provider.js";
|
|
115
115
|
|
|
116
|
+
export {
|
|
117
|
+
UnsupportedFaucetChainError,
|
|
118
|
+
faucetHint,
|
|
119
|
+
getWalletBalances,
|
|
120
|
+
requestCdpFaucet,
|
|
121
|
+
type BalanceReader,
|
|
122
|
+
type CdpFaucetClientLike,
|
|
123
|
+
type FaucetHint,
|
|
124
|
+
type FaucetRequestResult,
|
|
125
|
+
type FaucetToken,
|
|
126
|
+
type GetWalletBalancesOptions,
|
|
127
|
+
type RequestCdpFaucetOptions,
|
|
128
|
+
type WalletBalances,
|
|
129
|
+
} from "./wallet/lifecycle.js";
|
|
130
|
+
|
|
116
131
|
export {
|
|
117
132
|
ReclaimConsumerProofService,
|
|
118
133
|
ResponseProofUnavailableError,
|
|
119
134
|
ResponseProofVerificationError,
|
|
135
|
+
buildDeliveryContextEnvelopeV2,
|
|
120
136
|
createReclaimConsumerProofServiceFromEnv,
|
|
137
|
+
deliveryContextEnvelopeV2Schema,
|
|
121
138
|
parseReclaimProofEnv,
|
|
122
139
|
type ConsumerDeliveryProofService,
|
|
140
|
+
type DeliveryContextEnvelopeV2,
|
|
141
|
+
type DeliveryContextEnvelopeV2Input,
|
|
123
142
|
type DeliveryProofBinding,
|
|
124
143
|
type HttpsResponseMatch,
|
|
125
144
|
type HttpsResponseRedaction,
|
|
@@ -132,6 +151,90 @@ export {
|
|
|
132
151
|
type VerifyProofFn,
|
|
133
152
|
} from "./zktls/reclaim.js";
|
|
134
153
|
|
|
154
|
+
export {
|
|
155
|
+
NoDeliveryAttestorConfiguredError,
|
|
156
|
+
PaidRouteNonceInjectionError,
|
|
157
|
+
ReclaimDeliveryProofAttestor,
|
|
158
|
+
buildT2DeliveryProofSubmission,
|
|
159
|
+
createReclaimT2AttestorFromEnv,
|
|
160
|
+
deliveryProofRequestBodySchema,
|
|
161
|
+
toWireDeliveryReceipt,
|
|
162
|
+
type BuildT2DeliveryProofInput,
|
|
163
|
+
type BuildT2DeliveryProofResult,
|
|
164
|
+
type DeliveryProofAttestor,
|
|
165
|
+
type DeliveryProofRequestBody,
|
|
166
|
+
type PaidRouteRequestSpec,
|
|
167
|
+
type ProveDeliveryResponseInput,
|
|
168
|
+
type T2NonceInjection,
|
|
169
|
+
type WireDeliveryReceipt,
|
|
170
|
+
} from "./zktls/t2-delivery-proof.js";
|
|
171
|
+
|
|
172
|
+
export {
|
|
173
|
+
CAPABILITY_HEADER_NAME,
|
|
174
|
+
DEFAULT_CAPABILITY_TTL_SECONDS,
|
|
175
|
+
InvalidCapabilityHeaderError,
|
|
176
|
+
buildInvocationCapabilityTypedData,
|
|
177
|
+
createInvocationCapabilityEip712Domain,
|
|
178
|
+
decodeCapabilityHeader,
|
|
179
|
+
encodeCapabilityHeader,
|
|
180
|
+
generateCapabilityNonce,
|
|
181
|
+
hashInvocationPath,
|
|
182
|
+
httpMethodSchema,
|
|
183
|
+
invocationCapabilityDomainName,
|
|
184
|
+
invocationCapabilityDomainVersion,
|
|
185
|
+
invocationCapabilitySchema,
|
|
186
|
+
invocationCapabilityTypedData,
|
|
187
|
+
normalizeInvocationPath,
|
|
188
|
+
recoverInvocationCapabilitySigner,
|
|
189
|
+
signInvocationCapability,
|
|
190
|
+
signedInvocationCapabilitySchema,
|
|
191
|
+
type HttpMethod,
|
|
192
|
+
type InvocationCapability,
|
|
193
|
+
type InvocationCapabilityDomainInput,
|
|
194
|
+
type SignedInvocationCapability,
|
|
195
|
+
} from "./capability/invocation-capability.js";
|
|
196
|
+
|
|
197
|
+
export {
|
|
198
|
+
CallerAuthGateway,
|
|
199
|
+
InMemoryNonceReplayCache,
|
|
200
|
+
UnknownStreamGatewayError,
|
|
201
|
+
createSdkStreamReader,
|
|
202
|
+
type CallerAuthDecision,
|
|
203
|
+
type CallerAuthDenialReason,
|
|
204
|
+
type CallerAuthGatewayConfig,
|
|
205
|
+
type CallerAuthRequestInput,
|
|
206
|
+
type GatewayStreamView,
|
|
207
|
+
type NonceReplayCache,
|
|
208
|
+
type StreamReader,
|
|
209
|
+
} from "./gateway/caller-auth-gateway.js";
|
|
210
|
+
|
|
211
|
+
export {
|
|
212
|
+
createCallerAuthGatewayServer,
|
|
213
|
+
parseCallerAuthGatewayEnvConfig,
|
|
214
|
+
startCallerAuthGatewayServerFromEnv,
|
|
215
|
+
type CallerAuthGatewayEnvConfig,
|
|
216
|
+
type CallerAuthGatewayServer,
|
|
217
|
+
type CallerAuthGatewayServerConfig,
|
|
218
|
+
} from "./gateway/http-server.js";
|
|
219
|
+
|
|
220
|
+
export {
|
|
221
|
+
InvokeBuyerMismatchError,
|
|
222
|
+
InvokeStreamExpiredError,
|
|
223
|
+
InvokeStreamNotActiveError,
|
|
224
|
+
capabilityFor,
|
|
225
|
+
createSdkInvokeStreamReader,
|
|
226
|
+
invoke,
|
|
227
|
+
invokeWithT2DeliveryProof,
|
|
228
|
+
type CapabilityForOptions,
|
|
229
|
+
type InvokeOptions,
|
|
230
|
+
type InvokeResult,
|
|
231
|
+
type InvokeServiceInput,
|
|
232
|
+
type InvokeStreamReader,
|
|
233
|
+
type InvokeStreamView,
|
|
234
|
+
type InvokeWithT2DeliveryProofOptions,
|
|
235
|
+
type InvokeWithT2DeliveryProofResult,
|
|
236
|
+
} from "./sdk/invoke.js";
|
|
237
|
+
|
|
135
238
|
// Framework adapters are NOT re-exported from this top-level barrel on purpose.
|
|
136
239
|
// Some of them (notably the Coinbase AgentKit adapter) pull heavy optional
|
|
137
240
|
// dependency graphs, so importing "@absol-labs/agent" must stay light and load
|
package/src/mcp/server.ts
CHANGED
|
@@ -171,13 +171,13 @@ export const METRIK_MCP_TOOLS: readonly McpToolSpec[] = [
|
|
|
171
171
|
{
|
|
172
172
|
name: "check_stream_status",
|
|
173
173
|
description:
|
|
174
|
-
"Read a stream's live status, accrued amount, and claimable/reclaimable balances.",
|
|
174
|
+
"Read a stream's live status, accrued amount, and claimable/reclaimable balances. In V2, failed or unproven intervals do not advance cumulative entitlement; the stream remains active until buyer close or expiry.",
|
|
175
175
|
movesFunds: false,
|
|
176
176
|
},
|
|
177
177
|
{
|
|
178
178
|
name: "reclaim_unspent",
|
|
179
179
|
description:
|
|
180
|
-
"Close a stream and reclaim unspent funds to the buyer
|
|
180
|
+
"Close a stream and reclaim unspent funds to the buyer. V2 reclaim follows checkpoint finalization or escape-window rules.",
|
|
181
181
|
movesFunds: true,
|
|
182
182
|
},
|
|
183
183
|
{
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { type DeliveryReceiptDomainInput } from "@absol-labs/shared";
|
|
2
|
+
import { MetrikClient } from "@absol-labs/sdk";
|
|
3
|
+
import type { Address, Hex, LocalAccount } from "viem";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
buildT2DeliveryProofSubmission,
|
|
7
|
+
type BuildT2DeliveryProofResult,
|
|
8
|
+
type DeliveryProofAttestor,
|
|
9
|
+
type PaidRouteRequestSpec,
|
|
10
|
+
} from "../zktls/t2-delivery-proof.js";
|
|
11
|
+
import {
|
|
12
|
+
CAPABILITY_HEADER_NAME,
|
|
13
|
+
encodeCapabilityHeader,
|
|
14
|
+
generateCapabilityNonce,
|
|
15
|
+
hashInvocationPath,
|
|
16
|
+
signInvocationCapability,
|
|
17
|
+
type HttpMethod,
|
|
18
|
+
type InvocationCapability,
|
|
19
|
+
type InvocationCapabilityDomainInput,
|
|
20
|
+
type SignedInvocationCapability,
|
|
21
|
+
DEFAULT_CAPABILITY_TTL_SECONDS,
|
|
22
|
+
} from "../capability/invocation-capability.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Buyer half of the pay -> use loop (metrik-agent#64, impl of #62 part 2/2).
|
|
26
|
+
*
|
|
27
|
+
* After `hire()`/`openStream()`, an agent calls the purchased service directly
|
|
28
|
+
* with no out-of-band credentials: `invoke()` loads the stream, builds+signs
|
|
29
|
+
* a minimal-scope `InvocationCapability`, attaches it, and calls the
|
|
30
|
+
* gateway-fronted service (`../gateway/caller-auth-gateway.ts`).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The subset of `MetrikClient.getStreamV2` this module needs. Duck-typed so any real SDK client satisfies it. */
|
|
34
|
+
export interface InvokeStreamView {
|
|
35
|
+
readonly buyer: Address;
|
|
36
|
+
readonly operator: Address;
|
|
37
|
+
readonly serviceRef: Hex;
|
|
38
|
+
readonly status: "active" | "closed";
|
|
39
|
+
readonly expiresAt: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface InvokeStreamReader {
|
|
43
|
+
getStreamV2(streamId: Hex): Promise<InvokeStreamView>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Default `InvokeStreamReader`: a real, read-only `MetrikClient` (no wallet, no writes). */
|
|
47
|
+
export function createSdkInvokeStreamReader(options: {
|
|
48
|
+
readonly escrowAddress: Address;
|
|
49
|
+
readonly rpcUrl: string;
|
|
50
|
+
}): InvokeStreamReader {
|
|
51
|
+
const metrik = MetrikClient.baseSepolia({
|
|
52
|
+
escrow: options.escrowAddress,
|
|
53
|
+
rpcUrl: options.rpcUrl,
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
async getStreamV2(streamId) {
|
|
57
|
+
const stream = await metrik.getStreamV2(streamId);
|
|
58
|
+
return {
|
|
59
|
+
buyer: stream.buyer,
|
|
60
|
+
operator: stream.operator,
|
|
61
|
+
serviceRef: stream.serviceRef,
|
|
62
|
+
status: stream.status,
|
|
63
|
+
expiresAt: stream.expiresAt,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class InvokeStreamNotActiveError extends Error {
|
|
70
|
+
constructor(streamId: Hex, status: string) {
|
|
71
|
+
super(`stream ${streamId} is not active (status=${status})`);
|
|
72
|
+
this.name = "InvokeStreamNotActiveError";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class InvokeStreamExpiredError extends Error {
|
|
77
|
+
constructor(streamId: Hex, expiresAt: number) {
|
|
78
|
+
super(`stream ${streamId} expired at ${expiresAt}`);
|
|
79
|
+
this.name = "InvokeStreamExpiredError";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class InvokeBuyerMismatchError extends Error {
|
|
84
|
+
constructor(streamId: Hex, streamBuyer: Address, signer: Address) {
|
|
85
|
+
super(
|
|
86
|
+
`stream ${streamId} buyer ${streamBuyer} does not match the signing account ${signer}`,
|
|
87
|
+
);
|
|
88
|
+
this.name = "InvokeBuyerMismatchError";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface CapabilityForOptions {
|
|
93
|
+
readonly buyer: LocalAccount;
|
|
94
|
+
readonly domain: InvocationCapabilityDomainInput;
|
|
95
|
+
readonly serviceRef: Hex;
|
|
96
|
+
/** Default {@link DEFAULT_CAPABILITY_TTL_SECONDS}. */
|
|
97
|
+
readonly ttlSeconds?: number;
|
|
98
|
+
/** Default a fresh random nonce. */
|
|
99
|
+
readonly nonce?: Hex;
|
|
100
|
+
/** Default `Math.floor(Date.now() / 1000)`. Injectable for tests. */
|
|
101
|
+
readonly nowSeconds?: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Builds and signs a minimal-scope `InvocationCapability` for one
|
|
106
|
+
* `method`+`path` call against `streamId`, WITHOUT touching the network.
|
|
107
|
+
*/
|
|
108
|
+
export async function capabilityFor(
|
|
109
|
+
streamId: Hex,
|
|
110
|
+
request: { readonly method: HttpMethod; readonly path: string },
|
|
111
|
+
options: CapabilityForOptions,
|
|
112
|
+
): Promise<SignedInvocationCapability> {
|
|
113
|
+
const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
114
|
+
const capability: InvocationCapability = {
|
|
115
|
+
streamId,
|
|
116
|
+
buyer: options.buyer.address,
|
|
117
|
+
serviceRef: options.serviceRef,
|
|
118
|
+
method: request.method,
|
|
119
|
+
pathHash: hashInvocationPath(request.path),
|
|
120
|
+
nonce: options.nonce ?? generateCapabilityNonce(),
|
|
121
|
+
expiry: nowSeconds + (options.ttlSeconds ?? DEFAULT_CAPABILITY_TTL_SECONDS),
|
|
122
|
+
};
|
|
123
|
+
const signature = await signInvocationCapability(
|
|
124
|
+
capability,
|
|
125
|
+
options.domain,
|
|
126
|
+
options.buyer,
|
|
127
|
+
);
|
|
128
|
+
return { ...capability, signature };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface InvokeServiceInput {
|
|
132
|
+
readonly method: HttpMethod;
|
|
133
|
+
readonly path: string;
|
|
134
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
135
|
+
readonly body?: NonNullable<RequestInit["body"]>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface InvokeOptions {
|
|
139
|
+
/** Reads the stream's current serviceRef/operator/status/expiry. Real default: a `MetrikClient` instance. */
|
|
140
|
+
readonly streamReader: InvokeStreamReader;
|
|
141
|
+
/** The buyer's own wallet - signs the capability, never broadcasts a transaction. */
|
|
142
|
+
readonly buyer: LocalAccount;
|
|
143
|
+
/** `{chainId, verifyingContract}` - the `StreamEscrowV2` the stream settles on. */
|
|
144
|
+
readonly domain: InvocationCapabilityDomainInput;
|
|
145
|
+
/** Base URL of the gateway-fronted service (from the service listing/descriptor). */
|
|
146
|
+
readonly serviceBaseUrl: string;
|
|
147
|
+
readonly ttlSeconds?: number;
|
|
148
|
+
readonly nonce?: Hex;
|
|
149
|
+
readonly nowSeconds?: number;
|
|
150
|
+
/** Injectable `fetch` implementation, for tests. Default: global `fetch`. */
|
|
151
|
+
readonly fetchImpl?: typeof fetch;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface InvokeResult {
|
|
155
|
+
readonly response: Response;
|
|
156
|
+
readonly capability: SignedInvocationCapability;
|
|
157
|
+
readonly stream: InvokeStreamView;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* `open()` -> `invoke()`: loads the stream, builds+signs a capability scoped
|
|
162
|
+
* to exactly this `method`+`path`, and calls the gateway-fronted service.
|
|
163
|
+
* Fails closed BEFORE any network call to the service if the stream is not
|
|
164
|
+
* active, is expired, or does not belong to the signing account.
|
|
165
|
+
*/
|
|
166
|
+
export async function invoke(
|
|
167
|
+
streamId: Hex,
|
|
168
|
+
request: InvokeServiceInput,
|
|
169
|
+
options: InvokeOptions,
|
|
170
|
+
): Promise<InvokeResult> {
|
|
171
|
+
const stream = await options.streamReader.getStreamV2(streamId);
|
|
172
|
+
const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
173
|
+
|
|
174
|
+
if (stream.buyer.toLowerCase() !== options.buyer.address.toLowerCase()) {
|
|
175
|
+
throw new InvokeBuyerMismatchError(
|
|
176
|
+
streamId,
|
|
177
|
+
stream.buyer,
|
|
178
|
+
options.buyer.address,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (stream.status !== "active") {
|
|
182
|
+
throw new InvokeStreamNotActiveError(streamId, stream.status);
|
|
183
|
+
}
|
|
184
|
+
if (nowSeconds >= stream.expiresAt) {
|
|
185
|
+
throw new InvokeStreamExpiredError(streamId, stream.expiresAt);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const capability = await capabilityFor(streamId, request, {
|
|
189
|
+
buyer: options.buyer,
|
|
190
|
+
domain: options.domain,
|
|
191
|
+
serviceRef: stream.serviceRef,
|
|
192
|
+
...(options.ttlSeconds === undefined
|
|
193
|
+
? {}
|
|
194
|
+
: { ttlSeconds: options.ttlSeconds }),
|
|
195
|
+
...(options.nonce === undefined ? {} : { nonce: options.nonce }),
|
|
196
|
+
nowSeconds,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const header = encodeCapabilityHeader(capability);
|
|
200
|
+
const url = new URL(request.path, options.serviceBaseUrl).toString();
|
|
201
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
202
|
+
const response = await fetchImpl(url, {
|
|
203
|
+
method: request.method,
|
|
204
|
+
headers: {
|
|
205
|
+
...(request.headers ?? {}),
|
|
206
|
+
[CAPABILITY_HEADER_NAME]: header,
|
|
207
|
+
},
|
|
208
|
+
...(request.body === undefined ? {} : { body: request.body }),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return { response, capability, stream };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── T2 integration: usage + delivery proof as one action ───────────────────
|
|
215
|
+
|
|
216
|
+
export interface InvokeWithT2DeliveryProofOptions {
|
|
217
|
+
readonly streamReader: InvokeStreamReader;
|
|
218
|
+
readonly buyer: LocalAccount;
|
|
219
|
+
readonly domain: DeliveryReceiptDomainInput;
|
|
220
|
+
/** The T2 paid-route spec (absolute `url`, `responseMatches`, nonce injection point, ...). */
|
|
221
|
+
readonly request: PaidRouteRequestSpec;
|
|
222
|
+
/** Which billing interval this delivery proof covers. */
|
|
223
|
+
readonly intervalIndex: bigint;
|
|
224
|
+
/** Oracle-issued anti-replay nonce, from `GET /delivery/nonce`. */
|
|
225
|
+
readonly nonce: Hex;
|
|
226
|
+
readonly issuedAt?: number;
|
|
227
|
+
readonly mandateId?: string;
|
|
228
|
+
readonly attestor?: DeliveryProofAttestor;
|
|
229
|
+
readonly capabilityTtlSeconds?: number;
|
|
230
|
+
readonly capabilityNonce?: Hex;
|
|
231
|
+
readonly nowSeconds?: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface InvokeWithT2DeliveryProofResult {
|
|
235
|
+
readonly capability: SignedInvocationCapability;
|
|
236
|
+
readonly stream: InvokeStreamView;
|
|
237
|
+
readonly t2: BuildT2DeliveryProofResult;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The T2 variant of `invoke()`: the SAME capability-authorized call the
|
|
242
|
+
* gateway serves is what the buyer's Reclaim attestor proves, so usage and
|
|
243
|
+
* delivery proof are one action (issue #63's "the invocation IS the
|
|
244
|
+
* delivery"). Attaches the `InvocationCapability` header to the exact request
|
|
245
|
+
* `buildT2DeliveryProofSubmission` proves via zkTLS, then signs the resulting
|
|
246
|
+
* `DeliveryReceipt` - identical evidence chain as the plain T2 flow, just
|
|
247
|
+
* capability-gated at the gateway.
|
|
248
|
+
*/
|
|
249
|
+
export async function invokeWithT2DeliveryProof(
|
|
250
|
+
streamId: Hex,
|
|
251
|
+
options: InvokeWithT2DeliveryProofOptions,
|
|
252
|
+
): Promise<InvokeWithT2DeliveryProofResult> {
|
|
253
|
+
const stream = await options.streamReader.getStreamV2(streamId);
|
|
254
|
+
const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
255
|
+
|
|
256
|
+
if (stream.buyer.toLowerCase() !== options.buyer.address.toLowerCase()) {
|
|
257
|
+
throw new InvokeBuyerMismatchError(
|
|
258
|
+
streamId,
|
|
259
|
+
stream.buyer,
|
|
260
|
+
options.buyer.address,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (stream.status !== "active") {
|
|
264
|
+
throw new InvokeStreamNotActiveError(streamId, stream.status);
|
|
265
|
+
}
|
|
266
|
+
if (nowSeconds >= stream.expiresAt) {
|
|
267
|
+
throw new InvokeStreamExpiredError(streamId, stream.expiresAt);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const path = new URL(options.request.url).pathname;
|
|
271
|
+
const capability = await capabilityFor(
|
|
272
|
+
streamId,
|
|
273
|
+
{ method: options.request.method ?? "GET", path },
|
|
274
|
+
{
|
|
275
|
+
buyer: options.buyer,
|
|
276
|
+
domain: options.domain,
|
|
277
|
+
serviceRef: stream.serviceRef,
|
|
278
|
+
...(options.capabilityTtlSeconds === undefined
|
|
279
|
+
? {}
|
|
280
|
+
: { ttlSeconds: options.capabilityTtlSeconds }),
|
|
281
|
+
...(options.capabilityNonce === undefined
|
|
282
|
+
? {}
|
|
283
|
+
: { nonce: options.capabilityNonce }),
|
|
284
|
+
nowSeconds,
|
|
285
|
+
},
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const requestWithCapability: PaidRouteRequestSpec = {
|
|
289
|
+
...options.request,
|
|
290
|
+
headers: {
|
|
291
|
+
...(options.request.headers ?? {}),
|
|
292
|
+
[CAPABILITY_HEADER_NAME]: encodeCapabilityHeader(capability),
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const t2 = await buildT2DeliveryProofSubmission({
|
|
297
|
+
streamId,
|
|
298
|
+
operator: stream.operator,
|
|
299
|
+
serviceRef: stream.serviceRef,
|
|
300
|
+
intervalIndex: options.intervalIndex,
|
|
301
|
+
nonce: options.nonce,
|
|
302
|
+
buyer: options.buyer,
|
|
303
|
+
domain: options.domain,
|
|
304
|
+
request: requestWithCapability,
|
|
305
|
+
...(options.issuedAt === undefined ? {} : { issuedAt: options.issuedAt }),
|
|
306
|
+
...(options.mandateId === undefined
|
|
307
|
+
? {}
|
|
308
|
+
: { mandateId: options.mandateId }),
|
|
309
|
+
...(options.attestor === undefined ? {} : { attestor: options.attestor }),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
return { capability, stream, t2 };
|
|
313
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { erc20Abi, type Address } from "viem";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Agent wallet lifecycle helpers (metrik-agent#65).
|
|
5
|
+
*
|
|
6
|
+
* `../wallet/provider.ts` already resolves a wallet (injected key or a
|
|
7
|
+
* Coinbase CDP Server Wallet v2 account) and hands back a viem `Account` that
|
|
8
|
+
* can sign directly — `resolveAgentWallet()`'s CDP path is create-OR-restore
|
|
9
|
+
* by construction (`cdp.evm.getOrCreateAccount({ name })` is idempotent: the
|
|
10
|
+
* same `ownerName` always resolves to the same on-chain address, so a second
|
|
11
|
+
* run with the same env "restores" rather than creates a new wallet).
|
|
12
|
+
*
|
|
13
|
+
* This module adds the remaining thin, non-custodial lifecycle pieces the
|
|
14
|
+
* issue asks for: reading balances, a testnet faucet hint, and requesting
|
|
15
|
+
* funds from the CDP faucet. It deliberately does NOT wrap signing — a
|
|
16
|
+
* `ResolvedAgentWallet.account` is already a plain viem `Account`
|
|
17
|
+
* (`signMessage` / `signTypedData` / `signTransaction` all work as-is), and
|
|
18
|
+
* adding a second signing surface here would be exactly the "second wallet
|
|
19
|
+
* system" AGENTS.md warns against.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Native gas balance + USDC balance for one address. */
|
|
23
|
+
export interface WalletBalances {
|
|
24
|
+
/** Native token balance (ETH on Base/Base Sepolia), in wei. */
|
|
25
|
+
readonly nativeWei: bigint;
|
|
26
|
+
/** USDC balance, in atomic units (6 decimals). */
|
|
27
|
+
readonly usdcAtomic: bigint;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The subset of a viem `PublicClient` balance reads need. Duck-typed so any real client satisfies it. */
|
|
31
|
+
export interface BalanceReader {
|
|
32
|
+
getBalance(args: { readonly address: Address }): Promise<bigint>;
|
|
33
|
+
readContract(args: {
|
|
34
|
+
readonly address: Address;
|
|
35
|
+
readonly abi: typeof erc20Abi;
|
|
36
|
+
readonly functionName: "balanceOf";
|
|
37
|
+
readonly args: readonly [Address];
|
|
38
|
+
}): Promise<unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GetWalletBalancesOptions {
|
|
42
|
+
readonly publicClient: BalanceReader;
|
|
43
|
+
readonly address: Address;
|
|
44
|
+
/** The USDC token contract to read the ERC-20 balance from. */
|
|
45
|
+
readonly usdc: Address;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Reads a wallet's native gas balance and USDC balance in one call. Read-only — no signing, no writes. */
|
|
49
|
+
export async function getWalletBalances(
|
|
50
|
+
options: GetWalletBalancesOptions,
|
|
51
|
+
): Promise<WalletBalances> {
|
|
52
|
+
const [nativeWei, usdcAtomic] = await Promise.all([
|
|
53
|
+
options.publicClient.getBalance({ address: options.address }),
|
|
54
|
+
options.publicClient.readContract({
|
|
55
|
+
address: options.usdc,
|
|
56
|
+
abi: erc20Abi,
|
|
57
|
+
functionName: "balanceOf",
|
|
58
|
+
args: [options.address],
|
|
59
|
+
}),
|
|
60
|
+
]);
|
|
61
|
+
return { nativeWei, usdcAtomic: usdcAtomic as bigint };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Human-facing pointers to fund a freshly created/restored testnet wallet. */
|
|
65
|
+
export interface FaucetHint {
|
|
66
|
+
readonly network: string;
|
|
67
|
+
/** Coinbase CDP faucet — funds native ETH (and, on some networks, USDC) for a CDP-managed address. */
|
|
68
|
+
readonly cdpFaucetUrl: string;
|
|
69
|
+
/** Circle's own Base Sepolia USDC faucet — an alternative USDC source that doesn't require a CDP account. */
|
|
70
|
+
readonly usdcFaucetUrl: string;
|
|
71
|
+
/** How to request the same funds programmatically from a CDP wallet — see {@link requestCdpFaucet}. */
|
|
72
|
+
readonly programmaticHint: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class UnsupportedFaucetChainError extends Error {
|
|
76
|
+
constructor(chainId: number) {
|
|
77
|
+
super(
|
|
78
|
+
`no known testnet faucet for chainId ${chainId} (Metrik is Base Sepolia (84532) only)`,
|
|
79
|
+
);
|
|
80
|
+
this.name = "UnsupportedFaucetChainError";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const BASE_SEPOLIA_CHAIN_ID = 84532;
|
|
85
|
+
|
|
86
|
+
const BASE_SEPOLIA_FAUCET_HINT: FaucetHint = {
|
|
87
|
+
network: "base-sepolia",
|
|
88
|
+
cdpFaucetUrl: "https://portal.cdp.coinbase.com/products/faucet",
|
|
89
|
+
usdcFaucetUrl: "https://faucet.circle.com",
|
|
90
|
+
programmaticHint:
|
|
91
|
+
"requestCdpFaucet({ cdp, address, token: 'eth' | 'usdc' }) — subject to a 24h per-address rate limit",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** A faucet hint for the given chain. Only Base Sepolia (84532) is supported — Metrik is testnet-only, single-chain. */
|
|
95
|
+
export function faucetHint(chainId: number): FaucetHint {
|
|
96
|
+
if (chainId !== BASE_SEPOLIA_CHAIN_ID) {
|
|
97
|
+
throw new UnsupportedFaucetChainError(chainId);
|
|
98
|
+
}
|
|
99
|
+
return BASE_SEPOLIA_FAUCET_HINT;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type FaucetToken = "eth" | "usdc";
|
|
103
|
+
|
|
104
|
+
/** The subset of the Coinbase CDP client the faucet request needs. Duck-typed for testability. */
|
|
105
|
+
export interface CdpFaucetClientLike {
|
|
106
|
+
readonly evm: {
|
|
107
|
+
requestFaucet(options: {
|
|
108
|
+
readonly address: Address;
|
|
109
|
+
readonly network: string;
|
|
110
|
+
readonly token: FaucetToken;
|
|
111
|
+
}): Promise<{ readonly transactionHash: string }>;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface RequestCdpFaucetOptions {
|
|
116
|
+
readonly cdp: CdpFaucetClientLike;
|
|
117
|
+
readonly address: Address;
|
|
118
|
+
readonly token: FaucetToken;
|
|
119
|
+
/** Default `"base-sepolia"`. */
|
|
120
|
+
readonly network?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface FaucetRequestResult {
|
|
124
|
+
/** `true` iff the faucet accepted the request (a tx hash was returned). */
|
|
125
|
+
readonly requested: boolean;
|
|
126
|
+
readonly txHash?: string;
|
|
127
|
+
/**
|
|
128
|
+
* Set when `requested` is `false`. The CDP faucet enforces a 24h per-address
|
|
129
|
+
* rate limit, so a failure here is an EXPECTED, non-fatal outcome — never
|
|
130
|
+
* thrown. Callers should log this and fall back to checking whether the
|
|
131
|
+
* balance is already sufficient.
|
|
132
|
+
*/
|
|
133
|
+
readonly error?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Requests testnet funds from the Coinbase CDP faucet for a CDP-managed
|
|
138
|
+
* address. Tolerates the faucet's 24h rate limit and any other request
|
|
139
|
+
* failure by returning `{ requested: false, error }` — it never throws, so a
|
|
140
|
+
* caller can always fall through to reading the current balance instead.
|
|
141
|
+
*/
|
|
142
|
+
export async function requestCdpFaucet(
|
|
143
|
+
options: RequestCdpFaucetOptions,
|
|
144
|
+
): Promise<FaucetRequestResult> {
|
|
145
|
+
try {
|
|
146
|
+
const result = await options.cdp.evm.requestFaucet({
|
|
147
|
+
address: options.address,
|
|
148
|
+
network: options.network ?? "base-sepolia",
|
|
149
|
+
token: options.token,
|
|
150
|
+
});
|
|
151
|
+
return { requested: true, txHash: result.transactionHash };
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return {
|
|
154
|
+
requested: false,
|
|
155
|
+
error: error instanceof Error ? error.message : String(error),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|