Skip to content

Commit 2b1d327

Browse files
authored
Merge pull request #209 from MeshJS/feature/bot-claim-flow
Feature/bot-claim-flow
2 parents dfb87d2 + 0fdc0ed commit 2b1d327

25 files changed

Lines changed: 1740 additions & 287 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ PINATA_JWT="your-pinata-jwt-token"
2929
NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET="your-blockfrost-mainnet-api-key"
3030
NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD="your-blockfrost-preprod-api-key"
3131

32+
# Snapshot Auth Token
33+
# Used to authenticate the balance snapshot batch endpoint
34+
# SNAPSHOT_AUTH_TOKEN="your-snapshot-auth-token"
35+
3236
# Optional: Skip environment validation during builds
3337
# Useful for Docker builds where env vars are set at runtime
3438
# SKIP_ENV_VALIDATION=true
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
-- CreateEnum
2+
CREATE TYPE "PendingBotStatus" AS ENUM ('UNCLAIMED', 'CLAIMED');
3+
4+
-- CreateTable
5+
CREATE TABLE "PendingBot" (
6+
"id" TEXT NOT NULL,
7+
"name" TEXT NOT NULL,
8+
"paymentAddress" TEXT NOT NULL,
9+
"stakeAddress" TEXT,
10+
"requestedScopes" TEXT NOT NULL,
11+
"status" "PendingBotStatus" NOT NULL DEFAULT 'UNCLAIMED',
12+
"claimedBy" TEXT,
13+
"secretCipher" TEXT,
14+
"pickedUp" BOOLEAN NOT NULL DEFAULT false,
15+
"expiresAt" TIMESTAMP(3) NOT NULL,
16+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
17+
18+
CONSTRAINT "PendingBot_pkey" PRIMARY KEY ("id")
19+
);
20+
21+
-- CreateTable
22+
CREATE TABLE "BotClaimToken" (
23+
"id" TEXT NOT NULL,
24+
"pendingBotId" TEXT NOT NULL,
25+
"tokenHash" TEXT NOT NULL,
26+
"attempts" INTEGER NOT NULL DEFAULT 0,
27+
"expiresAt" TIMESTAMP(3) NOT NULL,
28+
"consumedAt" TIMESTAMP(3),
29+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
30+
31+
CONSTRAINT "BotClaimToken_pkey" PRIMARY KEY ("id")
32+
);
33+
34+
-- CreateIndex
35+
CREATE INDEX "PendingBot_paymentAddress_idx" ON "PendingBot"("paymentAddress");
36+
37+
-- CreateIndex
38+
CREATE INDEX "PendingBot_expiresAt_idx" ON "PendingBot"("expiresAt");
39+
40+
-- CreateIndex
41+
CREATE UNIQUE INDEX "BotClaimToken_pendingBotId_key" ON "BotClaimToken"("pendingBotId");
42+
43+
-- CreateIndex
44+
CREATE INDEX "BotClaimToken_tokenHash_idx" ON "BotClaimToken"("tokenHash");
45+
46+
-- AddForeignKey
47+
ALTER TABLE "BotClaimToken" ADD CONSTRAINT "BotClaimToken_pendingBotId_fkey" FOREIGN KEY ("pendingBotId") REFERENCES "PendingBot"("id") ON DELETE CASCADE ON UPDATE CASCADE;

prisma/schema.prisma

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,39 @@ model WalletBotAccess {
221221
@@index([walletId])
222222
@@index([botId])
223223
}
224+
225+
enum PendingBotStatus {
226+
UNCLAIMED
227+
CLAIMED
228+
}
229+
230+
model PendingBot {
231+
id String @id @default(cuid())
232+
name String
233+
paymentAddress String
234+
stakeAddress String?
235+
requestedScopes String // JSON array of requested scopes
236+
status PendingBotStatus @default(UNCLAIMED)
237+
claimedBy String? // ownerAddress of the claiming human
238+
secretCipher String? // Encrypted secret (set on claim, cleared on pickup)
239+
pickedUp Boolean @default(false)
240+
expiresAt DateTime
241+
createdAt DateTime @default(now())
242+
claimToken BotClaimToken?
243+
244+
@@index([paymentAddress])
245+
@@index([expiresAt])
246+
}
247+
248+
model BotClaimToken {
249+
id String @id @default(cuid())
250+
pendingBotId String @unique
251+
pendingBot PendingBot @relation(fields: [pendingBotId], references: [id], onDelete: Cascade)
252+
tokenHash String // SHA-256 hash of the claim code
253+
attempts Int @default(0)
254+
expiresAt DateTime
255+
consumedAt DateTime?
256+
createdAt DateTime @default(now())
257+
258+
@@index([tokenHash])
259+
}

scripts/bot-ref/README.md

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,30 @@ Minimal client to test the multisig v1 bot API. Use it from the Cursor agent or
44

55
## Config
66

7-
One JSON blob (from the "Create bot" UI or manually):
7+
Use config in two phases:
8+
9+
1. Registration/claim phase (before credentials exist):
810

911
```json
1012
{
1113
"baseUrl": "http://localhost:3000",
12-
"botKeyId": "<from Create bot>",
13-
"secret": "<from Create bot, shown once>",
14+
"paymentAddress": "<Cardano payment address for this bot>"
15+
}
16+
```
17+
18+
2. Authenticated phase (after pickup):
19+
20+
```json
21+
{
22+
"baseUrl": "http://localhost:3000",
23+
"botKeyId": "<from GET /api/v1/botPickupSecret>",
24+
"secret": "<from GET /api/v1/botPickupSecret>",
1425
"paymentAddress": "<Cardano payment address for this bot>"
1526
}
1627
```
1728

1829
- **baseUrl**: API base (e.g. `http://localhost:3000` for dev).
19-
- **botKeyId** / **secret**: From the Create bot dialog (copy the JSON blob, fill `paymentAddress`).
30+
- **botKeyId** / **secret**: Returned by `GET /api/v1/botPickupSecret` after a human claims the bot.
2031
- **paymentAddress**: The bot’s **own** Cardano payment address (a wallet the bot controls, not the owner’s address). One bot, one address. Required for `auth` and for all authenticated calls.
2132

2233
Provide config in one of these ways:
@@ -36,7 +47,29 @@ cd scripts/bot-ref
3647
npm install
3748
```
3849

39-
### 1. Register / get token
50+
### 1. Register -> claim -> pickup -> auth
51+
52+
1. Bot self-registers and receives a claim code:
53+
54+
```bash
55+
curl -sS -X POST http://localhost:3000/api/v1/botRegister \
56+
-H "Content-Type: application/json" \
57+
-d '{"name":"Reference Bot","paymentAddress":"addr1_xxx","scopes":["multisig:read"]}'
58+
```
59+
60+
Response includes `pendingBotId` and `claimCode`.
61+
62+
2. Human claims the bot in the app by entering `pendingBotId` and `claimCode`.
63+
64+
3. Bot picks up credentials:
65+
66+
```bash
67+
curl -sS "http://localhost:3000/api/v1/botPickupSecret?pendingBotId=<pendingBotId>"
68+
```
69+
70+
Response includes `botKeyId` and `secret`.
71+
72+
4. Set config with `botKeyId`, `secret`, and `paymentAddress`, then authenticate to get a JWT:
4073

4174
```bash
4275
BOT_CONFIG='{"baseUrl":"http://localhost:3000","botKeyId":"YOUR_KEY","secret":"YOUR_SECRET","paymentAddress":"addr1_xxx"}' npx tsx bot-client.ts auth
@@ -77,7 +110,7 @@ npx tsx bot-client.ts freeUtxos <walletId>
77110
npx tsx bot-client.ts botMe
78111
```
79112

80-
Returns the bot’s own info: `botId`, `paymentAddress`, `displayName`, `botName`, **`ownerAddress`** (the address of the human who created the bot). No `paymentAddress` in config needed for this command.
113+
Returns the bot’s own info: `botId`, `paymentAddress`, `displayName`, `botName`, **`ownerAddress`** (the address of the human who claimed the bot). No `paymentAddress` in config needed for this command.
81114

82115
### 6. Owner info
83116

@@ -111,13 +144,14 @@ From **repo root**: `npx tsx scripts/bot-ref/generate-bot-wallet.ts` — creates
111144
cd scripts/bot-ref && npx tsx create-wallet-us.ts
112145
```
113146

114-
Uses the owner’s address from `botMe` and the bot’s address from config. **The bot must have its own wallet and address** (not the same as the owner). Set `paymentAddress` in `bot-config.json` to the bot’s Cardano address, register it with POST /api/v1/botAuth, then run the script.
147+
Uses the owner’s address from `botMe` and the bot’s address from config. **The bot must have its own wallet and address** (not the same as the owner). Set `paymentAddress` in `bot-config.json` to the bot’s Cardano address, complete register -> claim -> pickup, then run `auth` and this script.
115148

116149
## Cursor agent testing
117150

118-
1. Create a bot in the app (User page → Create bot). Copy the JSON blob and add the bot’s `paymentAddress`.
119-
2. Save as `scripts/bot-ref/bot-config.json` (or pass via `BOT_CONFIG`).
120-
3. Run auth and use the token:
151+
1. Self-register the bot (`POST /api/v1/botRegister`) and capture `pendingBotId` + `claimCode`.
152+
2. Claim it in the app using that ID/code (User page -> Claim a bot).
153+
3. Call `GET /api/v1/botPickupSecret?pendingBotId=...` and place `botKeyId` + `secret` in `scripts/bot-ref/bot-config.json` with the bot `paymentAddress`.
154+
4. Run auth and use the token:
121155

122156
```bash
123157
cd /path/to/multisig/scripts/bot-ref
@@ -130,7 +164,7 @@ The reference client only uses **bot-key auth** (POST /api/v1/botAuth). Wallet-b
130164

131165
## Governance bot flow
132166

133-
For governance automation, grant these bot scopes when creating the bot key:
167+
For governance automation, request and approve these bot scopes during register/claim:
134168

135169
- `governance:read` to call `GET /api/v1/governanceActiveProposals`
136170
- `ballot:write` to call `POST /api/v1/botBallotsUpsert`

scripts/bot-ref/bot-client.ts

Lines changed: 110 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,29 @@
44
* Used by Cursor agent and local scripts to test bot flows.
55
*
66
* Usage:
7+
* BOT_CONFIG='{"baseUrl":"http://localhost:3000","paymentAddress":"addr1_..."}' npx tsx bot-client.ts register "Reference Bot" multisig:read
8+
* BOT_CONFIG='{"baseUrl":"http://localhost:3000"}' npx tsx bot-client.ts pickup <pendingBotId>
79
* BOT_CONFIG='{"baseUrl":"http://localhost:3000","botKeyId":"...","secret":"...","paymentAddress":"addr1_..."}' npx tsx bot-client.ts auth
810
* npx tsx bot-client.ts walletIds
911
* npx tsx bot-client.ts pendingTransactions <walletId>
1012
*/
1113

1214
export type BotConfig = {
1315
baseUrl: string;
14-
botKeyId: string;
15-
secret: string;
16-
paymentAddress: string;
16+
botKeyId?: string;
17+
secret?: string;
18+
paymentAddress?: string;
1719
};
1820

1921
export async function loadConfig(): Promise<BotConfig> {
2022
const fromEnv = process.env.BOT_CONFIG;
2123
if (fromEnv) {
2224
try {
23-
return JSON.parse(fromEnv) as BotConfig;
25+
const parsed = JSON.parse(fromEnv) as BotConfig;
26+
if (!parsed.baseUrl || typeof parsed.baseUrl !== "string") {
27+
throw new Error("baseUrl is required in config");
28+
}
29+
return parsed;
2430
} catch (e) {
2531
throw new Error("BOT_CONFIG is invalid JSON: " + (e as Error).message);
2632
}
@@ -31,7 +37,11 @@ export async function loadConfig(): Promise<BotConfig> {
3137
const fullPath = path.startsWith("/") ? path : join(process.cwd(), path);
3238
try {
3339
const raw = readFileSync(fullPath, "utf8");
34-
return JSON.parse(raw) as BotConfig;
40+
const parsed = JSON.parse(raw) as BotConfig;
41+
if (!parsed.baseUrl || typeof parsed.baseUrl !== "string") {
42+
throw new Error("baseUrl is required in config");
43+
}
44+
return parsed;
3545
} catch (e) {
3646
throw new Error(`Failed to load config from ${path}: ${(e as Error).message}`);
3747
}
@@ -43,6 +53,9 @@ function ensureSlash(url: string): string {
4353

4454
/** Authenticate with bot key + payment address; returns JWT. */
4555
export async function botAuth(config: BotConfig): Promise<{ token: string; botId: string }> {
56+
if (!config.botKeyId || !config.secret || !config.paymentAddress) {
57+
throw new Error("auth requires botKeyId, secret, and paymentAddress in config");
58+
}
4659
const base = ensureSlash(config.baseUrl);
4760
const res = await fetch(`${base}/api/v1/botAuth`, {
4861
method: "POST",
@@ -61,6 +74,45 @@ export async function botAuth(config: BotConfig): Promise<{ token: string; botId
6174
return { token: data.token, botId: data.botId };
6275
}
6376

77+
/** Register a pending bot and receive a claim code for human claim in UI. */
78+
export async function registerBot(
79+
baseUrl: string,
80+
body: {
81+
name: string;
82+
paymentAddress: string;
83+
requestedScopes: string[];
84+
stakeAddress?: string;
85+
},
86+
): Promise<{ pendingBotId: string; claimCode: string; claimExpiresAt: string }> {
87+
const base = ensureSlash(baseUrl);
88+
const res = await fetch(`${base}/api/v1/botRegister`, {
89+
method: "POST",
90+
headers: { "Content-Type": "application/json" },
91+
body: JSON.stringify(body),
92+
});
93+
if (!res.ok) {
94+
const text = await res.text();
95+
throw new Error(`botRegister failed ${res.status}: ${text}`);
96+
}
97+
return (await res.json()) as { pendingBotId: string; claimCode: string; claimExpiresAt: string };
98+
}
99+
100+
/** Pickup claimed bot credentials once human claim is complete. */
101+
export async function pickupBotSecret(
102+
baseUrl: string,
103+
pendingBotId: string,
104+
): Promise<{ botKeyId: string; secret: string; paymentAddress: string }> {
105+
const base = ensureSlash(baseUrl);
106+
const res = await fetch(
107+
`${base}/api/v1/botPickupSecret?pendingBotId=${encodeURIComponent(pendingBotId)}`,
108+
);
109+
if (!res.ok) {
110+
const text = await res.text();
111+
throw new Error(`botPickupSecret failed ${res.status}: ${text}`);
112+
}
113+
return (await res.json()) as { botKeyId: string; secret: string; paymentAddress: string };
114+
}
115+
64116
/** Get wallet IDs for the bot (requires prior auth; pass JWT). */
65117
export async function getWalletIds(baseUrl: string, token: string, address: string): Promise<{ walletId: string; walletName: string }[]> {
66118
const base = ensureSlash(baseUrl);
@@ -190,8 +242,10 @@ async function main() {
190242
const config = await loadConfig();
191243
const cmd = process.argv[2];
192244
if (!cmd) {
193-
console.error("Usage: bot-client.ts <auth|walletIds|pendingTransactions|freeUtxos|ownerInfo|createWallet> [args]");
194-
console.error(" auth - register/login and print token");
245+
console.error("Usage: bot-client.ts <register|pickup|auth|walletIds|pendingTransactions|freeUtxos|botMe|ownerInfo|createWallet> [args]");
246+
console.error(" register <name> [scope1,scope2,...] [paymentAddress] - create pending bot + claim code");
247+
console.error(" pickup <pendingBotId> - pickup botKeyId + secret after human claim");
248+
console.error(" auth - authenticate and print token");
195249
console.error(" walletIds - list wallet IDs (requires auth first; set BOT_TOKEN)");
196250
console.error(" pendingTransactions <walletId>");
197251
console.error(" freeUtxos <walletId>");
@@ -202,9 +256,56 @@ async function main() {
202256
process.exit(1);
203257
}
204258

259+
if (cmd === "register") {
260+
const name = process.argv[3];
261+
const scopesArg = process.argv[4] ?? "multisig:read";
262+
const paymentAddress = process.argv[5] ?? config.paymentAddress;
263+
264+
if (!name) {
265+
console.error("Usage: bot-client.ts register <name> [scope1,scope2,...] [paymentAddress]");
266+
process.exit(1);
267+
}
268+
269+
if (!paymentAddress) {
270+
console.error("paymentAddress is required for register (arg or config).");
271+
process.exit(1);
272+
}
273+
274+
const requestedScopes = scopesArg
275+
.split(",")
276+
.map((s) => s.trim())
277+
.filter(Boolean);
278+
279+
if (requestedScopes.length === 0) {
280+
console.error("At least one scope is required for register.");
281+
process.exit(1);
282+
}
283+
284+
const result = await registerBot(config.baseUrl, {
285+
name,
286+
paymentAddress,
287+
requestedScopes,
288+
});
289+
console.log(JSON.stringify(result, null, 2));
290+
console.error("Human must now claim this bot in UI using pendingBotId + claimCode.");
291+
return;
292+
}
293+
294+
if (cmd === "pickup") {
295+
const pendingBotId = process.argv[3];
296+
if (!pendingBotId) {
297+
console.error("Usage: bot-client.ts pickup <pendingBotId>");
298+
process.exit(1);
299+
}
300+
const creds = await pickupBotSecret(config.baseUrl, pendingBotId);
301+
console.log(JSON.stringify(creds, null, 2));
302+
console.error("Store botKeyId + secret in config, then run 'auth'.");
303+
return;
304+
}
305+
205306
if (cmd === "auth") {
206-
if (!config.paymentAddress) {
207-
console.error("paymentAddress is required in config for auth.");
307+
if (!config.paymentAddress || !config.botKeyId || !config.secret) {
308+
console.error("auth requires paymentAddress, botKeyId, and secret in config.");
208309
process.exit(1);
209310
}
210311
const { token, botId } = await botAuth(config);
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"baseUrl": "http://localhost:3000",
3-
"botKeyId": "",
4-
"secret": "",
5-
"paymentAddress": ""
3+
"paymentAddress": "addr1_your_bot_payment_address_here",
4+
"pendingBotId": "optional_pending_bot_id_from_register",
5+
"botKeyId": "set_after_pickup",
6+
"secret": "set_after_pickup"
67
}

src/components/common/overall-layout/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -731,4 +731,4 @@ export default function RootLayout({
731731
)}
732732
</div>
733733
);
734-
}
734+
}

0 commit comments

Comments
 (0)