@stage-labs/metro 0.1.0-beta.153 → 0.1.0-beta.155

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.153",
3
+ "version": "0.1.0-beta.155",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.153",
3
+ "version": "0.1.0-beta.155",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -101,6 +101,7 @@ export interface Station {
101
101
  groupOps?: ReadonlySet<GroupOp>;
102
102
  resolvesSenders?: boolean;
103
103
  profileFields?: ReadonlySet<ProfileField>;
104
+ claimsName?: boolean;
104
105
  attachmentMode: AttachmentMode;
105
106
  sendAttachments?(
106
107
  line: string,
@@ -10,7 +10,8 @@ export type AccountRoute =
10
10
  | { kind: 'allowlist'; station: StationName; accountId: string }
11
11
  | { kind: 'senders'; station: StationName; accountId: string }
12
12
  | { kind: 'resolve'; station: StationName; accountId: string }
13
- | { kind: 'enabled'; station: StationName; accountId: string };
13
+ | { kind: 'enabled'; station: StationName; accountId: string }
14
+ | { kind: 'name'; station: StationName; accountId: string };
14
15
 
15
16
  const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
16
17
  start: ['POST'],
@@ -21,9 +22,10 @@ const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
21
22
  senders: ['GET'],
22
23
  resolve: ['GET'],
23
24
  enabled: ['PUT'],
25
+ name: ['GET', 'POST'],
24
26
  };
25
27
 
26
- const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled'] as const;
28
+ const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name'] as const;
27
29
 
28
30
  type SubRoute = (typeof SUB_ROUTES)[number];
29
31
 
@@ -77,6 +77,11 @@ export interface AccountApiDeps {
77
77
  accountId: string,
78
78
  query: string,
79
79
  ) => Promise<unknown>;
80
+ accountCall: (
81
+ station: StationName,
82
+ action: string,
83
+ args: Record<string, unknown>,
84
+ ) => Promise<unknown>;
80
85
  setAccountEnabled: (
81
86
  subject: string,
82
87
  agentId: string,
@@ -110,6 +115,23 @@ async function handleResolve(
110
115
  sendJson(req, res, 200, found);
111
116
  }
112
117
 
118
+ async function handleName(
119
+ req: IncomingMessage,
120
+ res: ServerResponse,
121
+ deps: AccountApiDeps,
122
+ target: { station: StationName; accountId: string },
123
+ ): Promise<void> {
124
+ if (stationByName(target.station)?.claimsName !== true)
125
+ throw new ApiError(`${target.station} accounts have no name to claim`, 400);
126
+ if (req.method === 'GET') {
127
+ sendJson(req, res, 200, await deps.accountCall(target.station, 'name', { account: target.accountId }));
128
+ return;
129
+ }
130
+ const label = bodyField(await readJsonBody(req), 'label');
131
+ if (typeof label !== 'string' || label.trim() === '') throw new ApiError('a label is required', 400);
132
+ sendJson(req, res, 200, await deps.accountCall(target.station, 'claim_name', { account: target.accountId, label }));
133
+ }
134
+
113
135
  async function activate(
114
136
  deps: AccountApiDeps,
115
137
  station: StationName,
@@ -342,6 +364,7 @@ async function dispatchRoute(
342
364
  if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, session, agentId, route);
343
365
  if (route.kind === 'enabled') return handleEnabled(req, res, deps, session, agentId, route);
344
366
  if (route.kind === 'resolve') return handleResolve(req, res, deps, route);
367
+ if (route.kind === 'name') return handleName(req, res, deps, route);
345
368
  if (route.kind === 'senders') {
346
369
  sendJson(req, res, 200, { senders: deps.recentSenders(route.station, route.accountId) });
347
370
  return;
@@ -19,12 +19,14 @@ export const SET_PROFILE_TOOL = {
19
19
  '`profiles`); a field the station cannot set is refused by name, never dropped. Discord changes ' +
20
20
  'the bot username and app description (Discord allows two username changes an hour); Telegram ' +
21
21
  'bots set their name, description and photo; a Telegram user account sets its first name, bio ' +
22
- '(70 chars) and photo; WhatsApp sets the profile name, about and picture. Visible to everyone ' +
22
+ '(70 chars) and photo; WhatsApp sets the profile name, about and picture; XMTP writes the ' +
23
+ 'name, description and avatar records of the account\'s <label>.stage.base.eth name on Base, ' +
24
+ 'so it needs a name first (claimed on the channel page, never by you). Visible to everyone ' +
23
25
  'who talks to the account, so do it when asked, not on your own. Returns {account, applied}.',
24
26
  inputSchema: {
25
27
  type: 'object',
26
28
  properties: {
27
- station: { type: 'string', description: 'discord-bot | telegram-bot | telegram | whatsapp' },
29
+ station: { type: 'string', description: 'discord-bot | telegram-bot | telegram | whatsapp | xmtp' },
28
30
  account: { type: 'string', description: 'The account id on that station, from list_accounts.' },
29
31
  name: { type: 'string', description: 'The display name.' },
30
32
  bio: { type: 'string', description: 'The bio or about line.' },
@@ -46,7 +48,7 @@ export const profileCapabilities = (): Record<string, ProfileField[]> => {
46
48
 
47
49
  const asked = (a: Record<string, unknown>): ProfileField[] => PROFILE_FIELDS.filter((f) => a[f] !== undefined);
48
50
 
49
- type Target = { station: Station; fields: ProfileField[] } | { refused: string };
51
+ type Target = { station: Station } | { refused: string };
50
52
 
51
53
  function targetOf(a: Record<string, unknown>): Target {
52
54
  const name = str(a.station);
@@ -59,7 +61,7 @@ function targetOf(a: Record<string, unknown>): Target {
59
61
  if (wanted.length === 0) return { refused: 'set_profile needs at least one of name, bio, avatar' };
60
62
  const refused = wanted.filter((f) => !fields.has(f));
61
63
  if (refused.length > 0) return { refused: `${name} cannot set ${refused.join(', ')}; it takes ${[...fields].join(', ')}` };
62
- return { station, fields: wanted };
64
+ return { station };
63
65
  }
64
66
 
65
67
  function trainArgs(a: Record<string, unknown>, picture: ResolvedAttachment | undefined): Record<string, unknown> {
@@ -98,7 +98,8 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
98
98
  setAllowlist: localSetAllowlist,
99
99
  setAccountEnabled: localSetAccountEnabled,
100
100
  recentSenders,
101
- resolveSender,
101
+ resolveSender: (station, accountId, query) => accountCall(station, 'resolve_sender', { account: accountId, query }),
102
+ accountCall,
102
103
  reloadAgents: deps.reloadAgents,
103
104
  };
104
105
  }
@@ -169,17 +170,14 @@ function localModeInfo(): ModeInfo {
169
170
  return { mode: 'local', owner: localOwner(), project: LOCAL_PROJECT_ID, version: METRO_VERSION };
170
171
  }
171
172
 
172
- async function resolveSender(
173
+ async function accountCall(
173
174
  station: StationName,
174
- accountId: string,
175
- query: string,
175
+ action: string,
176
+ args: Record<string, unknown>,
176
177
  ): Promise<unknown> {
177
178
  let response;
178
179
  try {
179
- response = await forwardTrainCall(station, 'resolve_sender', {
180
- account: accountId,
181
- query,
182
- });
180
+ response = await forwardTrainCall(station, action, args);
183
181
  } catch (err) {
184
182
  throw new ApiError(
185
183
  `metro could not reach the ${station} train: ${errMsg(err)}`,
@@ -89,7 +89,7 @@ export async function verifyXmtpKeyOutOfProcess(
89
89
  dbPath: string,
90
90
  ): Promise<XmtpIdentity> {
91
91
  const raw = await runVerifier(
92
- JSON.stringify({ privateKey, dbPath }),
92
+ JSON.stringify({ privateKey, dbPath, smart: true }),
93
93
  ).catch((err: unknown) => {
94
94
  log.warn({ err: errMsg(err) }, 'attach-xmtp: could not run the inbox check');
95
95
  throw new XmtpAttachError(UNREACHABLE);
@@ -158,7 +158,7 @@ async function prepareXmtp(verify: VerifyXmtpKey): Promise<PreparedAccount> {
158
158
  ),
159
159
  );
160
160
  return {
161
- config: { privateKey, dbPath },
161
+ config: { privateKey, dbPath, smart: true },
162
162
  identity: { inboxId: identity.inboxId, address: identity.address },
163
163
  secret: {
164
164
  label: 'xmtp private key',
@@ -21,6 +21,8 @@
21
21
  "@xmtp/content-type-wallet-send-calls": "^2.0.0",
22
22
  "@xmtp/node-bindings": "1.9.1",
23
23
  "@xmtp/node-sdk": "^6.0.0",
24
+ "@zerodev/ecdsa-validator": "5.4.9",
25
+ "@zerodev/sdk": "5.5.10",
24
26
  "viem": "^2.52.2"
25
27
  },
26
28
  "devDependencies": {
@@ -7,7 +7,8 @@ import { homedir } from 'node:os';
7
7
  import { existsSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import { CODECS } from './codecs.js';
10
- import { expandHome, signerFor, XMTP_ENV } from './identity.js';
10
+ import { expandHome, identityFor, XMTP_ENV } from './identity.js';
11
+ import type { SmartAccount } from './smart.js';
11
12
  import {
12
13
  makeAccountStore,
13
14
  resolveAccountId,
@@ -23,6 +24,7 @@ export interface AccountConfig {
23
24
  privateKey?: string;
24
25
  owner?: string;
25
26
  dbPath?: string;
27
+ smart?: boolean;
26
28
  }
27
29
 
28
30
  export const { loadAccounts } = makeAccountStore<AccountConfig>({
@@ -50,18 +52,19 @@ export interface Account {
50
52
  client: Client<unknown>;
51
53
  inboxId: string;
52
54
  address: string;
55
+ smart: SmartAccount | null;
53
56
  }
54
57
  export const accounts = new Map<string, Account>();
55
58
 
56
59
  export async function bootAccount(cfg: AccountConfig): Promise<void> {
57
- const { signer, address } = signerFor(resolvePrivateKey(cfg));
60
+ const { signer, address, smart } = await identityFor(resolvePrivateKey(cfg), cfg.smart === true);
58
61
  const dbPath = expandHome(
59
62
  cfg.dbPath ?? join(homedir(), '.metro', `xmtp-${XMTP_ENV}-${cfg.id}.db3`),
60
63
  );
61
64
  const fresh = !existsSync(dbPath);
62
65
  const options: ClientOptions = { env: XMTP_ENV, codecs: CODECS(), dbPath };
63
66
  const client: Client<unknown> = await Client.create(signer, options);
64
- accounts.set(cfg.id, { cfg, client, inboxId: client.inboxId, address });
67
+ accounts.set(cfg.id, { cfg, client, inboxId: client.inboxId, address, smart });
65
68
  if (fresh)
66
69
  process.stderr.write(
67
70
  `xmtp[${cfg.id}] registered a NEW installation for this machine — ` +
@@ -21,6 +21,7 @@ import {
21
21
  import { convHandlers } from './actions-conv.js';
22
22
  import { normalizeXmtp } from '@metro-labs/core/stations/messaging-normalize';
23
23
  import { TrainError } from '@metro-labs/core/train-error';
24
+ import { claimNameAction, nameAction, setProfile } from './profile.js';
24
25
  import { makeStation, type CallMsg } from '@metro-labs/core/stations/station-runtime';
25
26
 
26
27
  type Args = Record<string, unknown>;
@@ -338,6 +339,7 @@ async function accountsAction(id: string): Promise<void> {
338
339
  url: `https://stage.box/#/${a.address}`,
339
340
  address: a.address,
340
341
  inboxId: a.inboxId,
342
+ smart: a.smart !== null,
341
343
  env: 'production',
342
344
  owner: a.cfg.owner ?? null,
343
345
  keySource: 'privateKey',
@@ -353,6 +355,9 @@ function unsupportedVerb(id: string, verb: string): Promise<void> {
353
355
 
354
356
  const handlers: Record<string, (id: string, args: Args) => Promise<void>> = {
355
357
  accounts: (id) => accountsAction(id),
358
+ set_profile: setProfile,
359
+ claim_name: claimNameAction,
360
+ name: nameAction,
356
361
  send,
357
362
  ask,
358
363
  sendPoll: ask,
@@ -2,32 +2,46 @@ import { IdentifierKind, type Signer } from '@xmtp/node-sdk';
2
2
  import { privateKeyToAccount } from 'viem/accounts';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { SMART_CHAIN_ID, smartAccountFor, type SmartAccount } from './smart.js';
5
6
 
6
7
  export const XMTP_ENV = 'production' as const;
7
8
 
8
9
  export const expandHome = (p: string): string =>
9
10
  p.startsWith('~') ? join(homedir(), p.slice(1)) : p;
10
11
 
11
- export function signerFor(privateKey: string): {
12
+ export interface Identity {
12
13
  signer: Signer;
13
14
  address: string;
14
- } {
15
+ smart: SmartAccount | null;
16
+ }
17
+
18
+ const bytesOf = (sig: string): Uint8Array => {
19
+ const hex = sig.slice(2);
20
+ const out = new Uint8Array(hex.length / 2);
21
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
22
+ return out;
23
+ };
24
+
25
+ export function signerFor(privateKey: string): Identity {
15
26
  const acct = privateKeyToAccount(privateKey as `0x${string}`);
16
27
  const signer: Signer = {
17
28
  type: 'EOA',
18
- getIdentifier: () =>
19
- Promise.resolve({
20
- identifier: acct.address,
21
- identifierKind: IdentifierKind.Ethereum,
22
- }),
23
- signMessage: async (msg: string) => {
24
- const sig = await acct.signMessage({ message: msg });
25
- const hex = sig.slice(2);
26
- const out = new Uint8Array(hex.length / 2);
27
- for (let i = 0; i < out.length; i++)
28
- out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
29
- return out;
30
- },
29
+ getIdentifier: () => Promise.resolve({ identifier: acct.address, identifierKind: IdentifierKind.Ethereum }),
30
+ signMessage: async (msg: string) => bytesOf(await acct.signMessage({ message: msg })),
31
+ };
32
+ return { signer, address: acct.address, smart: null };
33
+ }
34
+
35
+ export async function smartSignerFor(privateKey: string, rpc?: string): Promise<Identity> {
36
+ const smart = await smartAccountFor(privateKey, rpc);
37
+ const signer: Signer = {
38
+ type: 'SCW',
39
+ getIdentifier: () => Promise.resolve({ identifier: smart.address, identifierKind: IdentifierKind.Ethereum }),
40
+ signMessage: async (msg: string) => bytesOf(await smart.signMessage(msg)),
41
+ getChainId: () => BigInt(SMART_CHAIN_ID),
31
42
  };
32
- return { signer, address: acct.address };
43
+ return { signer, address: smart.address, smart };
33
44
  }
45
+
46
+ export const identityFor = (privateKey: string, smart: boolean): Promise<Identity> =>
47
+ smart ? smartSignerFor(privateKey) : Promise.resolve(signerFor(privateKey));
@@ -0,0 +1,61 @@
1
+ import { TrainError } from '@metro-labs/core/train-error';
2
+
3
+ export const STAGE_NAMES_PARENT = 'stage.base.eth';
4
+ export const STAGE_PROXY = 'https://proxy.stage.box';
5
+ const LABEL_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
6
+ const MIN = 6;
7
+ const MAX = 32;
8
+ const HEADERS = { 'content-type': 'application/json', 'x-stage-client': '1' };
9
+
10
+ export const proxyBase = (): string => process.env.METRO_STAGE_PROXY ?? STAGE_PROXY;
11
+
12
+ export const stageNameOf = (label: string): string => `${label}.${STAGE_NAMES_PARENT}`;
13
+
14
+ export function parseLabel(raw: unknown): string {
15
+ const label = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
16
+ if (label.length < MIN) throw new TrainError('bad_request', `a name needs at least ${String(MIN)} characters`);
17
+ if (label.length > MAX) throw new TrainError('bad_request', `a name can have at most ${String(MAX)} characters`);
18
+ if (!LABEL_RE.test(label)) throw new TrainError('bad_request', 'a name is lowercase letters, digits and single inner hyphens');
19
+ return label;
20
+ }
21
+
22
+ export const claimMessage = (label: string, address: string, issuedAt: number): string =>
23
+ `Claim ${stageNameOf(label)} for ${address.toLowerCase()} at ${String(issuedAt)}`;
24
+
25
+ async function ask(path: string, init: RequestInit, fetchImpl: typeof fetch): Promise<Record<string, unknown>> {
26
+ let res: Response;
27
+ try {
28
+ res = await fetchImpl(`${proxyBase()}/names/${path}`, { ...init, headers: HEADERS, signal: AbortSignal.timeout(30_000) });
29
+ } catch (err) {
30
+ throw new TrainError('names_unreachable', `could not reach the stage name service: ${err instanceof Error ? err.message : String(err)}`);
31
+ }
32
+ const body: unknown = await res.json().catch(() => null);
33
+ const record = typeof body === 'object' && body !== null ? (body as Record<string, unknown>) : {};
34
+ if (!res.ok) throw new TrainError('names_refused', typeof record.error === 'string' ? record.error : `the stage name service answered ${String(res.status)}`);
35
+ return record;
36
+ }
37
+
38
+ export async function nameOf(address: string, fetchImpl: typeof fetch = fetch): Promise<string | null> {
39
+ const body = await ask(`status?address=${encodeURIComponent(address)}`, {}, fetchImpl);
40
+ return typeof body.name === 'string' && body.name !== '' ? body.name : null;
41
+ }
42
+
43
+ export async function checkLabel(label: string, fetchImpl: typeof fetch = fetch): Promise<void> {
44
+ const body = await ask(`check?label=${encodeURIComponent(label)}`, {}, fetchImpl);
45
+ if (body.valid !== true) throw new TrainError('bad_request', typeof body.reason === 'string' ? body.reason : `${stageNameOf(label)} is not a valid name`);
46
+ if (body.available !== true) throw new TrainError('name_taken', `${stageNameOf(label)} is already taken`);
47
+ }
48
+
49
+ export async function claimName(
50
+ label: string,
51
+ address: string,
52
+ sign: (message: string) => Promise<string>,
53
+ fetchImpl: typeof fetch = fetch,
54
+ now = Date.now(),
55
+ ): Promise<string> {
56
+ await checkLabel(label, fetchImpl);
57
+ const signature = await sign(claimMessage(label, address, now));
58
+ const body = await ask('claim', { method: 'POST', body: JSON.stringify({ label, address, issuedAt: now, signature }) }, fetchImpl);
59
+ if (typeof body.name !== 'string' || body.name === '') throw new TrainError('names_refused', 'the stage name service issued no name');
60
+ return body.name;
61
+ }
@@ -0,0 +1,112 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { encodeFunctionData, namehash, type Hex } from 'viem';
3
+ import { normalize } from 'viem/ens';
4
+ import { TrainError } from '@metro-labs/core/train-error';
5
+ import {
6
+ assertImage,
7
+ fieldsOf,
8
+ parseProfileChange,
9
+ type ProfileApplied,
10
+ type ProfileAvatar,
11
+ type ProfileChange,
12
+ } from '@metro-labs/core/stations/profile';
13
+ import { accountForCall } from './accounts.js';
14
+ import { claimName, nameOf, parseLabel } from './names.js';
15
+ import { sendSponsored, type SmartAccount } from './smart.js';
16
+ import { respond } from './wire.js';
17
+
18
+ export const BASENAME_REGISTRY = '0xB94704422c2a1E396835A571837Aa5AE53285a95' as const;
19
+ export const BASENAME_L2_RESOLVER = '0xC6d566A56A1aFf6508b41f6c90ff131615583BCD' as const;
20
+ export const PINEAPPLE_UPLOAD_URL = 'https://pineapple.fyi/upload';
21
+ const STAMP_CLEAR_URL = 'https://stamp.fyi/clear/';
22
+ const ZERO = '0x0000000000000000000000000000000000000000';
23
+ const TEXT_KEYS = { name: 'name', bio: 'description', avatar: 'avatar' } as const;
24
+
25
+ const REGISTRY_ABI = [
26
+ { name: 'resolver', type: 'function', stateMutability: 'view', inputs: [{ name: 'node', type: 'bytes32' }], outputs: [{ name: '', type: 'address' }] },
27
+ ] as const;
28
+
29
+ const RESOLVER_ABI = [
30
+ {
31
+ name: 'setText',
32
+ type: 'function',
33
+ stateMutability: 'nonpayable',
34
+ inputs: [{ name: 'node', type: 'bytes32' }, { name: 'key', type: 'string' }, { name: 'value', type: 'string' }],
35
+ outputs: [],
36
+ },
37
+ { name: 'multicall', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'data', type: 'bytes[]' }], outputs: [{ name: 'results', type: 'bytes[]' }] },
38
+ ] as const;
39
+
40
+ type Args = Record<string, unknown>;
41
+
42
+ const NO_NAME = 'this XMTP account has no name yet, so it has no profile: claim a <label>.stage.base.eth name on its channel page first';
43
+ const NOT_SMART = 'this XMTP account was attached before names existed and cannot hold one; attach XMTP again to get an account that can';
44
+
45
+ export async function pinAvatar(avatar: ProfileAvatar, fetchImpl: typeof fetch = fetch): Promise<string> {
46
+ assertImage(avatar);
47
+ const form = new FormData();
48
+ form.append('file', new Blob([await readFile(avatar.path)], { type: avatar.mime }), avatar.name);
49
+ const res = await fetchImpl(PINEAPPLE_UPLOAD_URL, { method: 'POST', body: form, signal: AbortSignal.timeout(60_000) });
50
+ const body = (await res.json().catch(() => ({}))) as { result?: { cid?: string }; error?: { message?: string } };
51
+ if (!res.ok) throw new TrainError('avatar_upload', `the image upload answered ${String(res.status)}`);
52
+ if (typeof body.error?.message === 'string') throw new TrainError('avatar_upload', body.error.message);
53
+ if (typeof body.result?.cid !== 'string' || body.result.cid === '') throw new TrainError('avatar_upload', 'the image upload returned no CID');
54
+ return `ipfs://${body.result.cid}`;
55
+ }
56
+
57
+ export function encodeTextRecords(name: string, records: Record<string, string>): Hex {
58
+ const node = namehash(normalize(name));
59
+ const calls = Object.entries(records).map(([key, value]) => encodeFunctionData({ abi: RESOLVER_ABI, functionName: 'setText', args: [node, key, value] }));
60
+ return encodeFunctionData({ abi: RESOLVER_ABI, functionName: 'multicall', args: [calls] });
61
+ }
62
+
63
+ async function resolverFor(smart: SmartAccount, name: string): Promise<Hex> {
64
+ const found: Hex = await smart.publicClient.readContract({ address: BASENAME_REGISTRY, abi: REGISTRY_ABI, functionName: 'resolver', args: [namehash(normalize(name))] });
65
+ return found === ZERO ? BASENAME_L2_RESOLVER : found;
66
+ }
67
+
68
+ async function recordsFor(change: ProfileChange, fetchImpl: typeof fetch): Promise<Record<string, string>> {
69
+ const records: Record<string, string> = {};
70
+ if (change.name !== undefined) records[TEXT_KEYS.name] = change.name;
71
+ if (change.bio !== undefined) records[TEXT_KEYS.bio] = change.bio;
72
+ if (change.avatar !== undefined) records[TEXT_KEYS.avatar] = await pinAvatar(change.avatar, fetchImpl);
73
+ return records;
74
+ }
75
+
76
+ export async function writeProfile(smart: SmartAccount, name: string, change: ProfileChange, fetchImpl: typeof fetch = fetch): Promise<Hex> {
77
+ const records = await recordsFor(change, fetchImpl);
78
+ const hash = await sendSponsored(smart, await resolverFor(smart, name), encodeTextRecords(name, records));
79
+ const id = smart.address.toLowerCase();
80
+ await Promise.all([fetchImpl(`${STAMP_CLEAR_URL}address/${id}`), fetchImpl(`${STAMP_CLEAR_URL}avatar/eth:${id}`)]).catch(() => undefined);
81
+ return hash;
82
+ }
83
+
84
+ const smartOf = (args: Args): { accountId: string; address: string; smart: SmartAccount } => {
85
+ const acct = accountForCall(args);
86
+ if (acct.smart === null) throw new TrainError('no_name', NOT_SMART);
87
+ return { accountId: acct.cfg.id, address: acct.address, smart: acct.smart };
88
+ };
89
+
90
+ export async function setProfile(id: string, args: Args): Promise<void> {
91
+ const { accountId, address, smart } = smartOf(args);
92
+ const change = parseProfileChange(args);
93
+ const name = await nameOf(address);
94
+ if (name === null) throw new TrainError('no_name', NO_NAME);
95
+ const tx = await writeProfile(smart, name, change);
96
+ const result: ProfileApplied & { name: string; tx: string } = { account: accountId, applied: fieldsOf(change), name, tx };
97
+ respond(id, { result });
98
+ }
99
+
100
+ export async function claimNameAction(id: string, args: Args): Promise<void> {
101
+ const { accountId, address, smart } = smartOf(args);
102
+ const held = await nameOf(address);
103
+ if (held !== null) throw new TrainError('name_held', `this account already holds ${held}`);
104
+ const name = await claimName(parseLabel(args.label), address, (message) => smart.signMessage(message));
105
+ respond(id, { result: { account: accountId, name } });
106
+ }
107
+
108
+ export async function nameAction(id: string, args: Args): Promise<void> {
109
+ const acct = accountForCall(args);
110
+ const name = acct.smart === null ? null : await nameOf(acct.address);
111
+ respond(id, { result: { account: acct.cfg.id, name, canClaim: acct.smart !== null && name === null } });
112
+ }
@@ -0,0 +1,72 @@
1
+ import { createPublicClient, http, type Hex } from 'viem';
2
+ import { privateKeyToAccount } from 'viem/accounts';
3
+ import { base } from 'viem/chains';
4
+ import {
5
+ createKernelAccount,
6
+ createKernelAccountClient,
7
+ createZeroDevPaymasterClient,
8
+ getUserOperationGasPrice,
9
+ type KernelAccountClient,
10
+ } from '@zerodev/sdk';
11
+ import { getEntryPoint, KERNEL_V3_1 } from '@zerodev/sdk/constants';
12
+ import { signerToEcdsaValidator } from '@zerodev/ecdsa-validator';
13
+
14
+ export const SMART_CHAIN_ID = 8453;
15
+ const STAGE_ZERODEV_PROJECT = 'c107f734-f7bb-48c1-a5d2-ceb3f70a42f6';
16
+ const ENTRY_POINT = getEntryPoint('0.7');
17
+
18
+ export const zerodevRpc = (): string =>
19
+ `https://rpc.zerodev.app/api/v3/${process.env.METRO_ZERODEV_PROJECT ?? STAGE_ZERODEV_PROJECT}/chain/${String(SMART_CHAIN_ID)}`;
20
+
21
+ const makePublicClient = (rpc: string) => createPublicClient({ chain: base, transport: http(rpc) });
22
+
23
+ export type BaseClient = ReturnType<typeof makePublicClient>;
24
+
25
+ export interface SmartAccount {
26
+ address: Hex;
27
+ publicClient: BaseClient;
28
+ client: () => KernelAccountClient;
29
+ signMessage: (message: string) => Promise<Hex>;
30
+ deployed: () => Promise<boolean>;
31
+ }
32
+
33
+ export async function smartAccountFor(privateKey: string, rpc = zerodevRpc()): Promise<SmartAccount> {
34
+ const owner = privateKeyToAccount(privateKey as Hex);
35
+ const publicClient = makePublicClient(rpc);
36
+ const validator = await signerToEcdsaValidator(publicClient, { signer: owner, entryPoint: ENTRY_POINT, kernelVersion: KERNEL_V3_1 });
37
+ const account = await createKernelAccount(publicClient, { plugins: { sudo: validator }, entryPoint: ENTRY_POINT, kernelVersion: KERNEL_V3_1, index: 0n });
38
+ const paymaster = createZeroDevPaymasterClient({ chain: base, transport: http(rpc) });
39
+ const client = (): KernelAccountClient =>
40
+ createKernelAccountClient({
41
+ account,
42
+ chain: base,
43
+ bundlerTransport: http(rpc),
44
+ client: publicClient,
45
+ paymaster: { getPaymasterData: (userOperation) => paymaster.sponsorUserOperation({ userOperation }) },
46
+ userOperation: { estimateFeesPerGas: ({ bundlerClient }) => getUserOperationGasPrice(bundlerClient) },
47
+ });
48
+ return {
49
+ address: account.address,
50
+ publicClient,
51
+ client,
52
+ signMessage: (message) => account.signMessage({ message }),
53
+ deployed: async () => {
54
+ const code = await publicClient.getCode({ address: account.address });
55
+ return code !== undefined && code !== '0x';
56
+ },
57
+ };
58
+ }
59
+
60
+ export async function sendSponsored(smart: SmartAccount, to: Hex, data: Hex): Promise<Hex> {
61
+ const client = smart.client();
62
+ const hash = await client.sendTransaction({ to, data, value: 0n } as Parameters<typeof client.sendTransaction>[0]);
63
+ const receipt = await smart.publicClient.waitForTransactionReceipt({ hash });
64
+ if (receipt.status !== 'success') throw new Error(`the Base transaction ${hash} reverted`);
65
+ return hash;
66
+ }
67
+
68
+ export async function ensureDeployed(smart: SmartAccount): Promise<boolean> {
69
+ if (await smart.deployed()) return false;
70
+ await sendSponsored(smart, smart.address, '0x');
71
+ return true;
72
+ }
@@ -1,4 +1,5 @@
1
1
  import type { GroupOp, Station, Verb } from '@metro-labs/core/stations/types';
2
+ import { PROFILE_FIELDS, type ProfileField } from '@metro-labs/core/stations/profile';
2
3
  import { XMTP_TOOLS, xmtpSendAttachments } from './tools.js';
3
4
 
4
5
  export const xmtpStation: Station = {
@@ -8,6 +9,8 @@ export const xmtpStation: Station = {
8
9
  messageVerbs: new Set<Verb>(['send', 'reply', 'react', 'unreact', 'read']),
9
10
  groupOps: new Set<GroupOp>(['create_group', 'add_members', 'remove_members']),
10
11
  attachmentMode: 'native',
12
+ profileFields: new Set<ProfileField>(PROFILE_FIELDS),
13
+ claimsName: true,
11
14
  sendAttachments: xmtpSendAttachments,
12
15
  tools: XMTP_TOOLS,
13
16
  };
@@ -1,7 +1,8 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import { Client, type ClientOptions, type Signer } from '@xmtp/node-sdk';
4
- import { expandHome, signerFor, XMTP_ENV } from './identity.js';
4
+ import { expandHome, identityFor, signerFor, XMTP_ENV } from './identity.js';
5
+ import { ensureDeployed } from './smart.js';
5
6
 
6
7
  export class XmtpVerifyError extends Error {}
7
8
 
@@ -10,6 +11,7 @@ export interface XmtpVerified {
10
11
  address: string;
11
12
  installationId: string;
12
13
  dbPath: string;
14
+ smart: boolean;
13
15
  }
14
16
 
15
17
  interface RegisteredClient {
@@ -34,15 +36,25 @@ const reason = (err: unknown): string =>
34
36
  export async function verifyXmtpKey(
35
37
  privateKey: string,
36
38
  dbPath: string,
39
+ smart = false,
37
40
  create: CreateXmtpClient = openInbox,
38
41
  ): Promise<XmtpVerified> {
39
- let signer: Signer;
40
- let address: string;
42
+ let identity: Awaited<ReturnType<typeof identityFor>>;
41
43
  try {
42
- ({ signer, address } = signerFor(privateKey));
44
+ signerFor(privateKey);
43
45
  } catch {
44
46
  throw new XmtpVerifyError('that is not a usable XMTP private key');
45
47
  }
48
+ try {
49
+ identity = await identityFor(privateKey, smart);
50
+ } catch (err) {
51
+ throw new XmtpVerifyError(`could not build the smart account on Base: ${reason(err)}`);
52
+ }
53
+ const { signer, address } = identity;
54
+ if (identity.smart !== null)
55
+ await ensureDeployed(identity.smart).catch((err: unknown) => {
56
+ throw new XmtpVerifyError(`could not deploy the smart account on Base (is the ZeroDev sponsorship still on?): ${reason(err)}`);
57
+ });
46
58
  const resolved = expandHome(dbPath);
47
59
  mkdirSync(dirname(resolved), { recursive: true });
48
60
  const client = await create(signer, resolved).catch((err: unknown) => {
@@ -59,6 +71,7 @@ export async function verifyXmtpKey(
59
71
  address,
60
72
  installationId: client.installationId,
61
73
  dbPath: resolved,
74
+ smart,
62
75
  };
63
76
  }
64
77
 
@@ -68,10 +81,11 @@ async function runCli(): Promise<void> {
68
81
  const input = JSON.parse(readFileSync(0, 'utf8')) as {
69
82
  privateKey?: unknown;
70
83
  dbPath?: unknown;
84
+ smart?: unknown;
71
85
  };
72
86
  if (typeof input.privateKey !== 'string' || typeof input.dbPath !== 'string')
73
87
  throw new XmtpVerifyError('xmtp verify needs a privateKey and a dbPath');
74
- out = { ok: true, ...(await verifyXmtpKey(input.privateKey, input.dbPath)) };
88
+ out = { ok: true, ...(await verifyXmtpKey(input.privateKey, input.dbPath, input.smart === true)) };
75
89
  } catch (err) {
76
90
  out = { ok: false, error: reason(err) };
77
91
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.153"
2
+ "version": "0.1.0-beta.155"
3
3
  }
@@ -15,6 +15,8 @@
15
15
  "@xmtp/content-type-wallet-send-calls": "^2.0.0",
16
16
  "@xmtp/node-bindings": "1.9.1",
17
17
  "@xmtp/node-sdk": "^6.0.0",
18
+ "@zerodev/ecdsa-validator": "5.4.9",
19
+ "@zerodev/sdk": "5.5.10",
18
20
  "viem": "^2.52.2"
19
21
  },
20
22
  "telegram-bot": {},