@mantle-agent/client 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/src/agent.ts ADDED
@@ -0,0 +1,337 @@
1
+ import type { Address, Hex } from "viem";
2
+ import { ClientError, request, type Fetch } from "./http.js";
3
+
4
+ export type CredentialKind = "trading" | "social" | "runtime";
5
+ export type Credentials = Record<CredentialKind, { token: string; expiresAt: string }>;
6
+
7
+ /** What the agent signs with when proving it holds the key that started a claim. */
8
+ export type AgentKey = {
9
+ /** Ed25519 public key, 32 raw bytes as hex. */
10
+ publicKey: string;
11
+ /** Ed25519 signature over `message` (UTF-8), 64 bytes as hex. */
12
+ sign(message: string): string | Promise<string>;
13
+ };
14
+
15
+ export type Claim = {
16
+ claimId: string;
17
+ claimUrl: string;
18
+ userCode: string;
19
+ fingerprint: string;
20
+ expiresAt: string;
21
+ /** Proposed limits as the owner will see them, or null. */
22
+ proposal: string[] | null;
23
+ };
24
+
25
+ export type Side = "buy" | "sell";
26
+
27
+ /** Amounts are decimal strings in wei (18 decimals), as the API returns them. */
28
+ export type Quote = {
29
+ quoteId: string;
30
+ side: Side;
31
+ adapter: Address;
32
+ token: Address;
33
+ amountIn: string;
34
+ expectedOut: string;
35
+ minAmountOut: string;
36
+ expiresAt: string;
37
+ };
38
+
39
+ export type IntentStatus = "accepted" | "submitted" | "pending" | "confirmed" | "failed" | "unknown";
40
+
41
+ export type Intent = {
42
+ intentId: string;
43
+ requestId: string;
44
+ quoteId: string;
45
+ side: Side;
46
+ token: Address;
47
+ amountIn: string;
48
+ minAmountOut: string;
49
+ status: IntentStatus;
50
+ txHash: Hex | null;
51
+ amountOut: string | null;
52
+ errorCode: string | null;
53
+ error: string | null;
54
+ createdAt: string;
55
+ updatedAt: string;
56
+ };
57
+
58
+ export type DimensionState = "active" | "expiring" | "expired" | "revoked" | "none";
59
+
60
+ export type RuntimeStatus = {
61
+ enabled: boolean;
62
+ paused: boolean;
63
+ nextRunAt: string | null;
64
+ config: {
65
+ intervalSeconds: number;
66
+ maxRunSeconds: number;
67
+ maxTradesPerRun: number;
68
+ maxTradesPerDay: number;
69
+ maxSpendPerDayWei: string;
70
+ maxLlmCostPerDayUsd: number;
71
+ };
72
+ today: { trades: number; spendWei: string; llmCostUsd: number };
73
+ running: { leaseId: string; expiresAt: string; trades: number } | null;
74
+ pendingCommands: { commandId: string; kind: string }[];
75
+ } | null;
76
+
77
+ export type AgentStatus = {
78
+ agent: { agentId: string; name: string; status: "pending" | "active" | "stopped"; account: Address | null };
79
+ balanceWei: string | null;
80
+ session: {
81
+ keyHash: Hex;
82
+ state: DimensionState;
83
+ expiresAt: string;
84
+ maxSlippageBps: number;
85
+ limits: { perTradeCap: string; periodCap: string; totalCap: string };
86
+ remaining: { perTrade: string; period: string; total: string } | null;
87
+ } | null;
88
+ credentials: { kind: CredentialKind; state: DimensionState; expiresAt: string; lastUsedAt: string | null }[];
89
+ runtime: RuntimeStatus;
90
+ recentTrades: Intent[];
91
+ };
92
+
93
+ export type Lease =
94
+ | { granted: false; reason: "disabled" | "paused" | "not_due" | "budget_exhausted"; nextRunAt: string | null }
95
+ | {
96
+ granted: true;
97
+ leaseId: string;
98
+ expiresAt: string;
99
+ budget: { trades: number; spendWei: string; llmCostUsd: number };
100
+ commands: { commandId: string; kind: "pause" | "resume" | "run_now"; createdAt: string }[];
101
+ };
102
+
103
+ /** Session limits an agent can ask for (PRD WAL-02). Amounts in MNT wei; the owner confirms them with a passkey. */
104
+ export type SessionLimits = {
105
+ perTradeCap: bigint;
106
+ periodCap: bigint;
107
+ totalCap: bigint;
108
+ periodSeconds: number;
109
+ durationSeconds: number;
110
+ maxSlippageBps: number;
111
+ };
112
+
113
+ const MNT = 10n ** 18n;
114
+ /** PRD V1 defaults: 2 MNT per trade, 10 MNT per 24 h, 20 MNT in total, 7 days, 2 % slippage. */
115
+ export const DEFAULT_SESSION_LIMITS: SessionLimits = {
116
+ perTradeCap: 2n * MNT,
117
+ periodCap: 10n * MNT,
118
+ totalCap: 20n * MNT,
119
+ periodSeconds: 86_400,
120
+ durationSeconds: 7 * 86_400,
121
+ maxSlippageBps: 200,
122
+ };
123
+
124
+ /** Scheduled-run budgets (PRD RUN-02/03), on top of the on-chain limits. */
125
+ export type AutoRunSettings = {
126
+ enabled: boolean;
127
+ intervalSeconds: number;
128
+ maxRunSeconds: number;
129
+ maxTradesPerRun: number;
130
+ maxTradesPerDay: number;
131
+ maxSpendPerDay: bigint;
132
+ maxLlmCostPerDayUsd: number;
133
+ };
134
+
135
+ export type OwnerRequestKind = "authorize" | "renew" | "revoke" | "withdraw" | "runtime" | "resume";
136
+
137
+ /**
138
+ * Something only the owner can do, asked for by the agent. Relay `confirmUrl` and `summary` to the owner; they
139
+ * confirm on the page with a passkey. `completed` once it took effect (on chain for signing kinds).
140
+ */
141
+ export type OwnerRequest = {
142
+ requestId: string;
143
+ agentId: string;
144
+ agentName: string;
145
+ kind: OwnerRequestKind;
146
+ status: "pending" | "approved" | "completed" | "rejected" | "expired" | "superseded";
147
+ createdBy: "agent" | "claim";
148
+ summary: string[];
149
+ warnings: string[];
150
+ confirmUrl: string;
151
+ expiresAt: string;
152
+ createdAt: string;
153
+ operation: { operationId: string; status: "prepared" | "submitted" | "confirmed" | "failed"; error: string | null } | null;
154
+ note: string | null;
155
+ };
156
+
157
+ const limitsJson = (l: SessionLimits) => ({
158
+ perTradeCap: l.perTradeCap.toString(),
159
+ periodCap: l.periodCap.toString(),
160
+ totalCap: l.totalCap.toString(),
161
+ periodSeconds: l.periodSeconds,
162
+ durationSeconds: l.durationSeconds,
163
+ maxSlippageBps: l.maxSlippageBps,
164
+ });
165
+
166
+ const FINAL: ReadonlySet<IntentStatus> = new Set(["confirmed", "failed", "unknown"]);
167
+
168
+ /** Must match the API's claimProofMessage. */
169
+ export const claimProofMessage = (claimId: string) => `mantle-agent-claim:${claimId}`;
170
+
171
+ /**
172
+ * PRD IDN-02: register the agent key, hand the owner a link + code + fingerprint via `onClaim`, then poll with a
173
+ * proof of the key until the owner approves. Returns the credentials exactly once; store them securely.
174
+ */
175
+ export async function connectAgent(params: {
176
+ baseUrl: string;
177
+ agentName: string;
178
+ host: string;
179
+ key: AgentKey;
180
+ /** Show this to the owner. Compare `fingerprint` with one computed locally from the key. */
181
+ onClaim: (claim: Claim) => void | Promise<void>;
182
+ /** Limits to propose: the owner confirms them right after claiming, in the same visit. */
183
+ authorize?: SessionLimits;
184
+ pollMs?: number;
185
+ fetch?: Fetch;
186
+ }): Promise<{ agentId: string; credentials: Credentials }> {
187
+ const claim = await startClaim(params);
188
+ await params.onClaim(claim);
189
+ for (;;) {
190
+ const result = await exchangeClaim({ ...params, claimId: claim.claimId });
191
+ if (result.status === "exchanged") return result;
192
+ await new Promise((r) => setTimeout(r, params.pollMs ?? 2_000));
193
+ }
194
+ }
195
+
196
+ /** First half of `connectAgent`: register the key and get the link + code + fingerprint for the owner. */
197
+ export async function startClaim(params: {
198
+ baseUrl: string;
199
+ agentName: string;
200
+ host: string;
201
+ key: AgentKey;
202
+ authorize?: SessionLimits;
203
+ fetch?: Fetch;
204
+ }): Promise<Claim> {
205
+ const { body } = await request<Claim>(params.fetch ?? fetch, params.baseUrl, "POST", "/v1/claims", {
206
+ body: {
207
+ agentName: params.agentName,
208
+ host: params.host,
209
+ publicKey: params.key.publicKey,
210
+ ...(params.authorize ? { authorize: limitsJson(params.authorize) } : {}),
211
+ },
212
+ });
213
+ return body;
214
+ }
215
+
216
+ export type ClaimResult = { status: "pending"; expiresAt: string } | { status: "exchanged"; agentId: string; credentials: Credentials };
217
+
218
+ /**
219
+ * Second half of `connectAgent`, one attempt: `pending` until the owner approves, then the credentials exactly once.
220
+ * Stateless, so an agent can start a claim, end its turn, and check again later (CLAIM_EXPIRED once it lapsed).
221
+ */
222
+ export async function exchangeClaim(params: { baseUrl: string; claimId: string; key: AgentKey; fetch?: Fetch }): Promise<ClaimResult> {
223
+ const signature = await params.key.sign(claimProofMessage(params.claimId));
224
+ const { body } = await request<ClaimResult>(params.fetch ?? fetch, params.baseUrl, "POST", `/v1/claims/${params.claimId}/exchange`, {
225
+ body: { signature },
226
+ });
227
+ return body;
228
+ }
229
+
230
+ /**
231
+ * The agent's side of a Mantle Agent Account. It can only do what the owner authorized: trade within on-chain
232
+ * limits, read its status, manage its own scheduled runs, and stop itself. It never holds a private key.
233
+ */
234
+ export class AgentClient {
235
+ private readonly fetchImpl: Fetch;
236
+ /** Set when running inside a scheduled run; trades are charged to that run's budget. */
237
+ leaseId: string | undefined;
238
+
239
+ constructor(
240
+ readonly baseUrl: string,
241
+ private readonly credentials: Partial<Credentials> | null,
242
+ opts: { fetch?: Fetch; leaseId?: string } = {},
243
+ ) {
244
+ this.fetchImpl = opts.fetch ?? ((...a) => fetch(...a));
245
+ this.leaseId = opts.leaseId;
246
+ }
247
+
248
+ private async call<T>(method: "GET" | "POST", path: string, kind: CredentialKind, opts: { body?: unknown; headers?: Record<string, string> } = {}) {
249
+ const token = this.credentials?.[kind]?.token;
250
+ if (!token) throw new ClientError("NOT_CONNECTED", "no credentials; connect the agent first");
251
+ return request<T>(this.fetchImpl, this.baseUrl, method, path, { ...opts, token });
252
+ }
253
+
254
+ async status(): Promise<AgentStatus> {
255
+ return (await this.call<AgentStatus>("GET", "/v1/status", "trading")).body;
256
+ }
257
+
258
+ /** Price a trade. Valid ~30 s and for one trade. `amountIn` is wei (MNT for buys, tokens for sells). */
259
+ async quote(params: { side: Side; token: Address; amountIn: bigint }): Promise<Quote> {
260
+ return (await this.call<Quote>("POST", "/v1/quotes", "trading", { body: params })).body;
261
+ }
262
+
263
+ /**
264
+ * Quote and execute. Reuse `requestId` when retrying: the same trade is never executed twice. With `wait`, polls
265
+ * until confirmed / failed / unknown (or `timeoutMs`).
266
+ */
267
+ async trade(params: {
268
+ side: Side;
269
+ token: Address;
270
+ amountIn: bigint;
271
+ requestId?: string;
272
+ wait?: boolean;
273
+ timeoutMs?: number;
274
+ }): Promise<Intent> {
275
+ const quote = await this.quote(params);
276
+ const { body: intent } = await this.call<Intent>("POST", "/v1/trades", "trading", {
277
+ body: { quoteId: quote.quoteId },
278
+ headers: { "idempotency-key": params.requestId ?? globalThis.crypto.randomUUID(), ...(this.leaseId ? { "x-run-lease": this.leaseId } : {}) },
279
+ });
280
+ return params.wait === false ? intent : this.waitForTrade(intent, params.timeoutMs);
281
+ }
282
+
283
+ async tradeStatus(intentId: string): Promise<Intent> {
284
+ return (await this.call<Intent>("GET", `/v1/trades/${intentId}`, "trading")).body;
285
+ }
286
+
287
+ async waitForTrade(intent: Intent, timeoutMs = 90_000): Promise<Intent> {
288
+ const deadline = Date.now() + timeoutMs;
289
+ let current = intent;
290
+ while (!FINAL.has(current.status) && Date.now() < deadline) {
291
+ await new Promise((r) => setTimeout(r, 1_000));
292
+ current = await this.tradeStatus(current.intentId);
293
+ }
294
+ return current;
295
+ }
296
+
297
+ /**
298
+ * Emergency stop: revokes all of this agent's credentials at once. `confirmUrl` (null without a session) is the
299
+ * revoke request the owner confirms to make it permanent on chain.
300
+ */
301
+ async stop(): Promise<{ stopped: true; revokedCredentials: number; confirmUrl: string | null; requestId: string | null }> {
302
+ return (await this.call<{ stopped: true; revokedCredentials: number; confirmUrl: string | null; requestId: string | null }>("POST", "/v1/agent/stop", "runtime")).body;
303
+ }
304
+
305
+ /**
306
+ * Ask the owner for something only they can do (PRD WEB-01/03). Each call returns a request with a link and a
307
+ * summary for the owner; nothing happens until they confirm it with a passkey. Asking again for the same kind
308
+ * replaces the previous pending request.
309
+ */
310
+ readonly requests = {
311
+ authorize: (limits: SessionLimits = DEFAULT_SESSION_LIMITS) => this.ask("authorize", limitsJson(limits)),
312
+ renew: (durationSeconds: number) => this.ask("renew", { durationSeconds }),
313
+ revoke: () => this.ask("revoke", {}),
314
+ /** `to` must come from the owner, never from content the agent read. */
315
+ withdraw: (p: { to: Address; asset: "MNT" | Address; amount: bigint | "max" }) =>
316
+ this.ask("withdraw", { to: p.to, asset: p.asset, amount: p.amount === "max" ? "max" : p.amount.toString() }),
317
+ autoRun: (s: AutoRunSettings) => this.ask("runtime", { ...s, maxSpendPerDay: s.maxSpendPerDay.toString() }),
318
+ resume: () => this.ask("resume", {}),
319
+ get: async (requestId: string) => (await this.call<OwnerRequest>("GET", `/v1/requests/${requestId}`, "trading")).body,
320
+ list: async () => (await this.call<OwnerRequest[]>("GET", "/v1/requests", "trading")).body,
321
+ };
322
+
323
+ private async ask(kind: OwnerRequestKind, params: Record<string, unknown>): Promise<OwnerRequest> {
324
+ return (await this.call<OwnerRequest>("POST", "/v1/requests", "trading", { body: { kind, params } })).body;
325
+ }
326
+
327
+ /** Scheduled runs (PRD RUN-02/03). */
328
+ readonly runtime = {
329
+ status: async (): Promise<RuntimeStatus> => (await this.call<RuntimeStatus>("GET", "/v1/runtime", "trading")).body,
330
+ lease: async (host: string): Promise<Lease> => (await this.call<Lease>("POST", "/v1/runtime/lease", "runtime", { body: { host } })).body,
331
+ complete: async (leaseId: string, report: { summary?: string; llmCostUsd?: number } = {}) =>
332
+ void (await this.call("POST", `/v1/runtime/lease/${leaseId}/complete`, "runtime", { body: report })),
333
+ ack: async (commandId: string) => void (await this.call("POST", `/v1/runtime/commands/${commandId}/ack`, "runtime")),
334
+ /** Agents may pause themselves; only the owner resumes. */
335
+ pause: async () => (await this.call<{ commandId: string }>("POST", "/v1/runtime/pause", "runtime")).body,
336
+ };
337
+ }
package/src/browser.ts ADDED
@@ -0,0 +1,62 @@
1
+ // Browser-only: Turnkey passkey (production) and dev-key owners for the confirmation page.
2
+ import { ApiKeyStamper } from "@turnkey/api-key-stamper";
3
+ import { generateP256KeyPair } from "@turnkey/crypto";
4
+ import { Turnkey, TurnkeyBrowserClient } from "@turnkey/sdk-browser";
5
+ import type { Address } from "viem";
6
+ import { rootSigner, TURNKEY_API_BASE_URL, type OwnerApi, type SignedTurnkeyRequest } from "@mantle-agent/custody/owner";
7
+ import type { OwnerTurnkey } from "./owner.js";
8
+
9
+ type Client = OwnerApi & {
10
+ stampGetWhoami(input: { organizationId: string }): Promise<SignedTurnkeyRequest | undefined>;
11
+ };
12
+
13
+ function fromClient(kind: OwnerTurnkey["kind"], client: Client, parentOrgId: string, register: OwnerTurnkey["register"]): OwnerTurnkey {
14
+ return {
15
+ kind,
16
+ register,
17
+ async stampWhoami() {
18
+ const signed = await client.stampGetWhoami({ organizationId: parentOrgId });
19
+ if (!signed) throw new Error("could not produce a signed login request");
20
+ return signed;
21
+ },
22
+ owner: () => client,
23
+ rootSigner: (subOrgId, address) =>
24
+ rootSigner({ client: client as unknown as Parameters<typeof rootSigner>[0]["client"], subOrgId, rootAddress: address }),
25
+ };
26
+ }
27
+
28
+ /** Production: every owner action is approved with a passkey bound to this site (rpId = hostname). */
29
+ export function passkeyTurnkey(params: { parentOrgId: string; rpId?: string; apiBaseUrl?: string }): OwnerTurnkey {
30
+ const rpId = params.rpId ?? window.location.hostname;
31
+ const sdk = new Turnkey({ apiBaseUrl: params.apiBaseUrl ?? TURNKEY_API_BASE_URL, defaultOrganizationId: params.parentOrgId, rpId });
32
+ const client = sdk.passkeyClient();
33
+ return fromClient("passkey", client as unknown as Client, params.parentOrgId, async (ownerName) => {
34
+ const { encodedChallenge, attestation } = await client.createUserPasskey({
35
+ publicKey: { rp: { id: rpId, name: "Mantle Agent Account" }, user: { name: ownerName, displayName: ownerName } },
36
+ });
37
+ return { passkey: { authenticatorName: `passkey-${Date.now()}`, challenge: encodedChallenge, attestation } };
38
+ });
39
+ }
40
+
41
+ const DEV_KEY_STORAGE = "mantle-agent:dev-owner-key";
42
+
43
+ /**
44
+ * Development only (no passkeys, e.g. local or automated testing): a P-256 API key kept in this browser stands in
45
+ * for the passkey. The API accepts such owners only with ALLOW_API_KEY_OWNERS=true.
46
+ */
47
+ export function devKeyTurnkey(params: { parentOrgId: string; apiBaseUrl?: string; storage?: Storage }): OwnerTurnkey {
48
+ const storage = params.storage ?? window.localStorage;
49
+ let saved = storage.getItem(DEV_KEY_STORAGE);
50
+ if (!saved) {
51
+ const kp = generateP256KeyPair();
52
+ saved = JSON.stringify({ publicKey: kp.publicKey, privateKey: kp.privateKey });
53
+ storage.setItem(DEV_KEY_STORAGE, saved);
54
+ }
55
+ const key = JSON.parse(saved) as { publicKey: string; privateKey: string };
56
+ const client = new TurnkeyBrowserClient({
57
+ stamper: new ApiKeyStamper({ apiPublicKey: key.publicKey, apiPrivateKey: key.privateKey }),
58
+ apiBaseUrl: params.apiBaseUrl ?? TURNKEY_API_BASE_URL,
59
+ organizationId: params.parentOrgId,
60
+ });
61
+ return fromClient("dev-key", client as unknown as Client, params.parentOrgId, async () => ({ apiPublicKey: key.publicKey }));
62
+ }
package/src/http.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * A failure with the Mantle Agent API's stable error code (or a local one such as NOT_CONNECTED or
3
+ * API_UNREACHABLE). React to `code`, not to the message.
4
+ */
5
+ export class ClientError extends Error {
6
+ constructor(
7
+ readonly code: string,
8
+ message: string,
9
+ readonly details?: unknown,
10
+ readonly status?: number,
11
+ ) {
12
+ super(message);
13
+ }
14
+ }
15
+
16
+ export type Fetch = typeof fetch;
17
+
18
+ export type RequestOptions = { token?: string | null; body?: unknown; headers?: Record<string, string> };
19
+
20
+ /** JSON request against the API; non-2xx responses become ClientError with the API's code. */
21
+ export async function request<T>(
22
+ fetchImpl: Fetch,
23
+ baseUrl: string,
24
+ method: "GET" | "POST",
25
+ path: string,
26
+ opts: RequestOptions = {},
27
+ ): Promise<{ status: number; body: T }> {
28
+ const headers: Record<string, string> = { ...opts.headers };
29
+ if (opts.body !== undefined) headers["content-type"] = "application/json";
30
+ if (opts.token) headers["authorization"] = `Bearer ${opts.token}`;
31
+ let res: Response;
32
+ try {
33
+ res = await fetchImpl(`${baseUrl}${path}`, {
34
+ method,
35
+ headers,
36
+ ...(opts.body === undefined ? {} : { body: JSON.stringify(opts.body, (_k, v) => (typeof v === "bigint" ? v.toString() : v)) }),
37
+ });
38
+ } catch (err) {
39
+ throw new ClientError("API_UNREACHABLE", `cannot reach ${baseUrl}`, { reason: String(err) });
40
+ }
41
+ const body = (await res.json().catch(() => ({}))) as T & { code?: string; message?: string; details?: unknown };
42
+ if (res.status >= 400) {
43
+ throw new ClientError(body.code ?? `HTTP_${res.status}`, body.message ?? res.statusText, body.details, res.status);
44
+ }
45
+ return { status: res.status, body };
46
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ // Agent side (no Turnkey, no custody). Owner flows: "@mantle-agent/client/owner"; browser passkey: "@mantle-agent/client/browser".
2
+ export * from "./http.js";
3
+ export * from "./agent.js";