@dopamint-fun/open-sdk 0.2.0-dev.0 → 0.2.0-dev.10

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 CHANGED
@@ -1,4 +1,4 @@
1
- # `libs/dopa-open-client-ts`
1
+ # `libs/dopa-open/client-ts`
2
2
 
3
3
  Status: implemented
4
4
  Ownership: Optional official TypeScript SDK for DOPA-OPEN self-custody agents:
@@ -36,8 +36,8 @@ signatures, not whether this package is used. Allowed dependencies:
36
36
  ## Parity
37
37
 
38
38
  Preimages and signatures are pinned byte-identical to the Rust signer by
39
- `libs/dopa-open-client-rs/vectors/ts-signer-parity.json`, generated by
40
- `libs/dopa-open-client-rs/tests/ts_parity_vectors.rs` and replayed by
39
+ `libs/dopa-open/client-rs/vectors/ts-signer-parity.json`, generated by
40
+ `libs/dopa-open/client-rs/tests/ts_parity_vectors.rs` and replayed by
41
41
  `src/parity.test.ts` here. Regenerate with
42
42
  `WRITE_TS_PARITY_VECTORS=1 cargo test -p dopa-open-client-rs --test
43
43
  ts_parity_vectors`; a wire change updates both sides together.
package/dist/agentHttp.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * tour or table request without ever holding a bearer credential.
3
3
  *
4
4
  * Byte-identical to `dopa_open_api::agent_http`, pinned by
5
- * `libs/dopa-open-api/vectors/agent_http_capability_v1.json`. A bearer token
5
+ * `libs/dopa-open/api/vectors/agent_http_capability_v1.json`. A bearer token
6
6
  * is a secret presented per request; a signature is not, so each capability is
7
7
  * bound to the method, the request target, a digest of the exact body bytes,
8
8
  * and a single-use nonce inside a bounded window. One minted to enter the
@@ -13,7 +13,7 @@
13
13
  * server reconstructs them from the request it actually received, so there is
14
14
  * no restated copy to compare and therefore no comparison to forget.
15
15
  */
16
- import { ByteWriter, frameSigningBytes, fromHex, textBytes, toHex } from "./bytes.js";
16
+ import { ByteReader, ByteWriter, frameSigningBytes, fromHex, textBytes, toHex, } from "./bytes.js";
17
17
  import { blake2b256 } from "./crypto.js";
18
18
  import { signRaw } from "./keypair.js";
19
19
  const AGENT_HTTP_CAPABILITY_DOMAIN = textBytes("dopa_open::agent_http_capability::v1");
@@ -94,20 +94,17 @@ export function decodeAgentHttpHeader(value) {
94
94
  const bytes = fromHex(value);
95
95
  if (bytes.length !== HEADER_BYTES)
96
96
  throw new Error(`capability header must be ${HEADER_BYTES} bytes, got ${bytes.length}`);
97
- const readU64 = (offset) => {
98
- let out = 0n;
99
- for (let index = 0; index < 8; index++)
100
- out = (out << 8n) | BigInt(bytes[offset + index]);
101
- return out;
102
- };
103
- return {
104
- agentId: bytes.slice(0, 32),
105
- agentPublicKey: bytes.slice(32, 64),
106
- issuedAtMs: readU64(64),
107
- expiresAtMs: readU64(72),
108
- nonce: bytes.slice(80, 80 + AGENT_HTTP_CAPABILITY_NONCE_BYTES),
109
- signature: bytes.slice(80 + AGENT_HTTP_CAPABILITY_NONCE_BYTES),
97
+ const reader = new ByteReader(bytes);
98
+ const capability = {
99
+ agentId: reader.readFixed(32, "agent id"),
100
+ agentPublicKey: reader.readFixed(32, "agent public key"),
101
+ issuedAtMs: reader.readU64("issued_at_ms"),
102
+ expiresAtMs: reader.readU64("expires_at_ms"),
103
+ nonce: reader.readFixed(AGENT_HTTP_CAPABILITY_NONCE_BYTES, "nonce"),
104
+ signature: reader.readFixed(SIGNATURE_BYTES, "signature"),
110
105
  };
106
+ reader.finish();
107
+ return capability;
111
108
  }
112
109
  /** Mint and sign one capability, and return the header to send with it.
113
110
  *
package/dist/bytes.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * These helpers exist so every framing in this package spells a number the
6
6
  * same way; a preimage that drifts from the Rust client by one byte produces
7
7
  * signatures the authority silently rejects, which is why the parity vectors
8
- * in `libs/dopa-open-client-rs/vectors/` pin all of it.
8
+ * in `libs/dopa-open/client-rs/vectors/` pin all of it.
9
9
  */
10
10
  export function toHex(bytes) {
11
11
  let out = "";
package/dist/claim.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { signRaw } from "./keypair.js";
3
- import { concatBytes, equalBytes, frameSigningBytes, fromHex, textBytes, toHex0x, } from "./bytes.js";
3
+ import { ByteReader, ByteWriter, equalBytes, frameSigningBytes, fromHex, textBytes, toHex0x, } from "./bytes.js";
4
4
  /* The agent's half of a claim: an invitation its own key signs.
5
5
  *
6
6
  * A wallet claims an agent by signing for it in the arena's UI. That proves
@@ -22,16 +22,6 @@ export const AGENT_CLAIM_INVITE_MAX_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
22
22
  * nonce || signature`, every field fixed-width. */
23
23
  export const AGENT_CLAIM_INVITE_TOKEN_BYTES = 32 + 32 + 32 + 8 + 8 + 16 + 64;
24
24
  const ZERO_OWNER = new Uint8Array(32);
25
- function u64be(value) {
26
- const out = new Uint8Array(8);
27
- new DataView(out.buffer).setBigUint64(0, value, false);
28
- return out;
29
- }
30
- function fixed(bytes, length, what) {
31
- if (bytes.length !== length)
32
- throw new Error(`${what} must be ${length} bytes, got ${bytes.length}`);
33
- return bytes;
34
- }
35
25
  /** `AgentClaimInvite::canonical_payload` -- the framed fields. */
36
26
  export function claimInviteCanonicalPayload(invite) {
37
27
  if (invite.expiresAtMs <= invite.issuedAtMs)
@@ -41,7 +31,18 @@ export function claimInviteCanonicalPayload(invite) {
41
31
  throw new Error("an invitation may stay open for at most a week");
42
32
  if (invite.nonce.every((byte) => byte === 0))
43
33
  throw new Error("the invitation's nonce must not be all zero");
44
- return concatBytes(AGENT_CLAIM_INVITE_DOMAIN, Uint8Array.of(0, CANONICAL_WIRE_VERSION, INVITE_OPERATION), fixed(invite.agentId, 32, "agent id"), invite.owner ? fixed(invite.owner, 32, "owner") : ZERO_OWNER, fixed(invite.agentPublicKey, 32, "agent public key"), u64be(invite.issuedAtMs), u64be(invite.expiresAtMs), fixed(invite.nonce, AGENT_CLAIM_INVITE_NONCE_BYTES, "nonce"));
34
+ return new ByteWriter()
35
+ .pushBytes(AGENT_CLAIM_INVITE_DOMAIN)
36
+ .pushByte(0)
37
+ .pushByte(CANONICAL_WIRE_VERSION)
38
+ .pushByte(INVITE_OPERATION)
39
+ .pushFixed(invite.agentId, 32, "agent id")
40
+ .pushFixed(invite.owner ?? ZERO_OWNER, 32, "owner")
41
+ .pushFixed(invite.agentPublicKey, 32, "agent public key")
42
+ .pushU64(invite.issuedAtMs)
43
+ .pushU64(invite.expiresAtMs)
44
+ .pushFixed(invite.nonce, AGENT_CLAIM_INVITE_NONCE_BYTES, "nonce")
45
+ .bytes();
45
46
  }
46
47
  /** The bytes the agent's key signs, raw ed25519. */
47
48
  export function claimInviteSigningBytes(invite) {
@@ -66,7 +67,15 @@ export async function mintClaimInvite(agent, agentId, options = {}) {
66
67
  function claimInviteBytes(invite) {
67
68
  if (invite.signature.length !== 64)
68
69
  throw new Error("an invitation is encoded only once it is signed");
69
- return concatBytes(fixed(invite.agentId, 32, "agent id"), invite.owner ?? ZERO_OWNER, fixed(invite.agentPublicKey, 32, "agent public key"), u64be(invite.issuedAtMs), u64be(invite.expiresAtMs), fixed(invite.nonce, 16, "nonce"), invite.signature);
70
+ return new ByteWriter()
71
+ .pushFixed(invite.agentId, 32, "agent id")
72
+ .pushFixed(invite.owner ?? ZERO_OWNER, 32, "owner")
73
+ .pushFixed(invite.agentPublicKey, 32, "agent public key")
74
+ .pushU64(invite.issuedAtMs)
75
+ .pushU64(invite.expiresAtMs)
76
+ .pushFixed(invite.nonce, AGENT_CLAIM_INVITE_NONCE_BYTES, "nonce")
77
+ .pushBytes(invite.signature)
78
+ .bytes();
70
79
  }
71
80
  /** The token the link carries and the claim body posts back: 0x-hex.
72
81
  *
@@ -115,20 +124,27 @@ export function decodeClaimInvite(token) {
115
124
  const bytes = claimInviteTokenBytes(token.trim());
116
125
  if (bytes.length !== AGENT_CLAIM_INVITE_TOKEN_BYTES)
117
126
  throw new Error(`an invitation token is ${AGENT_CLAIM_INVITE_TOKEN_BYTES} bytes, got ${bytes.length}`);
118
- const view = new DataView(bytes.buffer, bytes.byteOffset);
119
- const owner = bytes.slice(32, 64);
127
+ const reader = new ByteReader(bytes);
128
+ const agentId = reader.readFixed(32, "agent id");
129
+ const owner = reader.readFixed(32, "owner");
130
+ const agentPublicKey = reader.readFixed(32, "agent public key");
131
+ const issuedAtMs = reader.readU64("issued");
132
+ const expiresAtMs = reader.readU64("expires");
133
+ const nonce = reader.readFixed(AGENT_CLAIM_INVITE_NONCE_BYTES, "nonce");
134
+ const signature = reader.readFixed(64, "signature");
135
+ reader.finish();
120
136
  return {
121
- agentId: bytes.slice(0, 32),
137
+ agentId,
122
138
  owner: equalBytes(owner, ZERO_OWNER) ? null : owner,
123
- agentPublicKey: bytes.slice(64, 96),
124
- issuedAtMs: view.getBigUint64(96, false),
125
- expiresAtMs: view.getBigUint64(104, false),
126
- nonce: bytes.slice(112, 128),
127
- signature: bytes.slice(128),
139
+ agentPublicKey,
140
+ issuedAtMs,
141
+ expiresAtMs,
142
+ nonce,
143
+ signature,
128
144
  };
129
145
  }
130
146
  /** Where the owner goes to accept: the agent's claim page with the token. */
131
147
  export function claimInviteLink(arenaOrigin, agentIdHex, token) {
132
148
  const id = agentIdHex.startsWith("0x") ? agentIdHex : `0x${agentIdHex}`;
133
- return `${arenaOrigin.replace(/\/$/, "")}/arena/agents/${id}/claim?invite=${token}`;
149
+ return `${arenaOrigin.replace(/\/$/, "")}/open/agents/${id}/claim?invite=${token}`;
134
150
  }
package/dist/cli.js CHANGED
@@ -23,14 +23,20 @@ import { claimInviteLink, encodeClaimInvite, encodeClaimInviteCompact, mintClaim
23
23
  import { acceptAndAwaitAdmission } from "./offer.js";
24
24
  import { playTour, queueUntilSeated } from "./tour.js";
25
25
  import { joinRoomWhenComposed, MIN_ROOM_SEATS, openRoom, roomInvitePrompt, } from "./room.js";
26
- import { disputeHolding, JoinRefused, joinTransaction, leaveTransaction, listTournaments, matchmakingOverLine, planJoin, presentToTournament, readAgentEntry, playsHeldBy, giveBackTransaction, readTournament, sponsorAndExecute, tournamentIdArg, } from "./openTournament.js";
27
- import { authorityOriginFromSessionBase, buildConsentRequest, digestForPrompt, settlementConsentPath, verifyConsentDisclosure, } from "./settlement.js";
26
+ import { disputeHolding, JoinRefused, joinTransaction, leaveTransaction, listTournaments, matchmakingOverLine, planJoin, presentToTournament, readAgentEntry, readAgentEntrySettled, playsHeldBy, giveBackTransaction, readTournament, sponsorAndExecute, tournamentIdArg, } from "./openTournament.js";
27
+ import { buildConsentRequest, DEFAULT_OPEN_API_BASE_URL, digestForPrompt, settlementConsentPath, verifyConsentDisclosure, } from "./settlement.js";
28
28
  import { actionSigningBytes, joinSigningBytes, requireSessionVersion, resumeSigningBytes, SESSION_VERSION, } from "./sessionWire.js";
29
29
  import { describeNext, refusalMessageFromText } from "./refusal.js";
30
30
  function fail(message) {
31
31
  console.error(`dopa-open: ${message}`);
32
32
  process.exit(1);
33
33
  }
34
+ /** Resolves `--product-url`, falling back to `DOPA_OPEN_PRODUCT_URL` and then
35
+ * to the deployed default — the same precedence and env name the Rust CLI
36
+ * uses, so a script that sets one variable configures both binaries. */
37
+ function resolveProductUrl(explicit) {
38
+ return explicit ?? process.env.DOPA_OPEN_PRODUCT_URL ?? DEFAULT_OPEN_API_BASE_URL;
39
+ }
34
40
  const hex = (value, field) => {
35
41
  if (typeof value !== "string")
36
42
  fail(`${field} must be a hex string`);
@@ -116,9 +122,7 @@ async function commandRegister(args) {
116
122
  "dry-run": { type: "boolean", default: false },
117
123
  },
118
124
  });
119
- const productUrl = values["product-url"];
120
- if (!productUrl && !values["dry-run"])
121
- fail("--product-url is required (or pass --dry-run to print the request)");
125
+ const productUrl = resolveProductUrl(values["product-url"]);
122
126
  const agent = loadKeypair(values.key);
123
127
  const versions = (raw, flag) => {
124
128
  const list = raw.split(",").map((piece) => Number.parseInt(piece, 10));
@@ -162,7 +166,7 @@ async function commandRegister(args) {
162
166
  console.log(JSON.stringify(request, null, 2));
163
167
  return;
164
168
  }
165
- const response = await fetch(`${productUrl.replace(/\/$/, "")}/open/v1/agents`, {
169
+ const response = await fetch(`${productUrl.replace(/\/$/, "")}/v1/agents`, {
166
170
  method: "POST",
167
171
  headers: { "content-type": "application/json" },
168
172
  body: JSON.stringify(request),
@@ -225,9 +229,7 @@ async function commandName(args) {
225
229
  bio: { type: "string" },
226
230
  },
227
231
  });
228
- const productUrl = values["product-url"];
229
- if (!productUrl)
230
- fail("--product-url is required");
232
+ const productUrl = resolveProductUrl(values["product-url"]);
231
233
  if (!values["agent-id"])
232
234
  fail("--agent-id is required");
233
235
  if (!values.name && !values.handle && !values.bio)
@@ -258,7 +260,7 @@ async function commandName(args) {
258
260
  * against what they believed, and the refusal for getting it wrong is a bare
259
261
  * `stale_generation` that names neither. */
260
262
  async function readKeyState(productUrl, agentId) {
261
- const response = await fetch(`${productUrl}/open/v1/agents/${encodeURIComponent(agentId)}`);
263
+ const response = await fetch(`${productUrl}/v1/agents/${encodeURIComponent(agentId)}`);
262
264
  if (!response.ok)
263
265
  fail(`could not read agent ${agentId} (${response.status})`);
264
266
  const wire = (await response.json());
@@ -291,9 +293,7 @@ async function commandKey(args) {
291
293
  "dry-run": { type: "boolean", default: false },
292
294
  },
293
295
  });
294
- const productUrl = values["product-url"]?.replace(/\/$/, "");
295
- if (!productUrl)
296
- fail("--product-url is required");
296
+ const productUrl = resolveProductUrl(values["product-url"]).replace(/\/$/, "");
297
297
  const agentId = values["agent-id"];
298
298
  if (!agentId)
299
299
  fail("--agent-id is required");
@@ -356,7 +356,7 @@ async function commandKey(args) {
356
356
  return;
357
357
  }
358
358
  const path = retire ? "revocations" : "key-rotations";
359
- const response = await fetch(`${productUrl}/open/v1/agents/${encodeURIComponent(agentId)}/${path}`, {
359
+ const response = await fetch(`${productUrl}/v1/agents/${encodeURIComponent(agentId)}/${path}`, {
360
360
  method: "POST",
361
361
  headers: { "content-type": "application/json" },
362
362
  body: JSON.stringify(request),
@@ -486,9 +486,7 @@ async function commandPlay(args) {
486
486
  "disconnect-after-actions": { type: "string" },
487
487
  },
488
488
  });
489
- const productUrl = values["product-url"];
490
- if (!productUrl)
491
- fail("--product-url is required");
489
+ const productUrl = resolveProductUrl(values["product-url"]);
492
490
  if (!values["agent-id"])
493
491
  fail("--agent-id is required");
494
492
  /* A tour seat is played over the Product API, so it needs none of the
@@ -581,7 +579,7 @@ function runRecordPath(keyFile) {
581
579
  }
582
580
  function writeRunRecord(keyFile, record) {
583
581
  const path = runRecordPath(keyFile);
584
- const watchUrl = `${record.productUrl.replace(/\/$/, "")}/arena/matches/0x${record.executionId.replace(/^0x/i, "")}`;
582
+ const watchUrl = `${record.productUrl.replace(/\/$/, "")}/open/matches/0x${record.executionId.replace(/^0x/i, "")}`;
585
583
  writeFileSync(path, `${JSON.stringify({ ...record, watchUrl, at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
586
584
  return path;
587
585
  }
@@ -642,7 +640,7 @@ async function signedGet(productUrl, target, agent, agentIdHex) {
642
640
  headers: { [AGENT_HTTP_CAPABILITY_HEADER]: header },
643
641
  });
644
642
  }
645
- /** `dopa-open me`: the arena's record of this agent, signed for.
643
+ /** `dopa-open me`: Open's record of this agent, signed for.
646
644
  *
647
645
  * Both agents that played on 2026-09-06 wrote this request by hand, against
648
646
  * the skill's own warning that a hand-written signature drifts by a byte. */
@@ -657,13 +655,13 @@ async function signedGet(productUrl, target, agent, agentIdHex) {
657
655
  async function resolveAgentId(productUrl, ownerAddressHex, given) {
658
656
  if (given)
659
657
  return given;
660
- const response = await fetch(`${productUrl.replace(/\/$/, "")}/open/v1/agents?owner=${ownerAddressHex}&limit=60`);
658
+ const response = await fetch(`${productUrl.replace(/\/$/, "")}/v1/agents?owner=${ownerAddressHex}&limit=60`);
661
659
  if (!response.ok)
662
660
  fail(`--agent-id was not given and the roster could not be read (${response.status})`);
663
661
  const body = (await response.json());
664
662
  const agents = body.agents ?? [];
665
663
  if (agents.length === 0)
666
- fail(`no agent on this arena is claimed by ${ownerAddressHex}. The roster lists an agent under the wallet that ` +
664
+ fail(`no agent on this Open deployment is claimed by ${ownerAddressHex}. The roster lists an agent under the wallet that ` +
667
665
  "claimed it, so one that registered itself is not here until it is claimed; pass --agent-id (register printed it)");
668
666
  if (agents.length > 1)
669
667
  fail(`this key owns ${agents.length} agents; pass --agent-id to say which:\n ${agents
@@ -680,16 +678,14 @@ async function commandMe(args) {
680
678
  "agent-id": { type: "string" },
681
679
  },
682
680
  });
683
- const productUrl = values["product-url"];
684
- if (!productUrl)
685
- fail("--product-url is required");
681
+ const productUrl = resolveProductUrl(values["product-url"]);
686
682
  const agent = loadKeypair(values.key);
687
683
  /* Without `--agent-id`, ask the roster which agent this key owns. */
688
684
  const agentId = await resolveAgentId(productUrl, agent.ownerAddressHex, values["agent-id"]);
689
- const response = await signedGet(productUrl, "/open/v1/agent/me", agent, agentId);
685
+ const response = await signedGet(productUrl, "/v1/agent/me", agent, agentId);
690
686
  const body = await response.text();
691
687
  if (!response.ok)
692
- fail(refusalMessageFromText(`agent/me at ${productUrl}/open/v1/agent/me`, response.status, body));
688
+ fail(refusalMessageFromText(`agent/me at ${productUrl}/v1/agent/me`, response.status, body));
693
689
  try {
694
690
  console.log(JSON.stringify(JSON.parse(body), null, 2));
695
691
  }
@@ -760,6 +756,11 @@ async function commandTurn(args) {
760
756
  agent,
761
757
  agentId: fromHex(agentId),
762
758
  waitMs: values.wait === undefined ? 0 : Number(values.wait) * 1000,
759
+ /* Written before each acknowledgement, not once at the end. `turn`
760
+ acknowledges the viewless join and the named prepared/open notices as
761
+ it takes them, and an acknowledgement the file does not record is a
762
+ phase the next process cannot know it is in. */
763
+ checkpoint: (next) => saveSeatState(statePath, next),
763
764
  });
764
765
  saveSeatState(statePath, outcome.state);
765
766
  if (outcome.kind === "your-turn") {
@@ -852,14 +853,19 @@ async function commandAct(args) {
852
853
  * this, so it is worth a caller of its own rather than a flag on the play
853
854
  * loop. Prints what the authority recorded. */
854
855
  async function consentToTerminal(options) {
855
- const offerResponse = await fetch(`${options.productUrl.replace(/\/$/, "")}/open/v1/playground/matches/${options.offerId}`);
856
+ const offerResponse = await fetch(`${options.productUrl.replace(/\/$/, "")}/v1/playground/matches/${options.offerId}`);
856
857
  const offerBody = await offerResponse.text();
857
858
  if (!offerResponse.ok)
858
859
  fail(`offer read failed (${offerResponse.status}): ${offerBody}`);
859
860
  const record = JSON.parse(offerBody);
860
861
  if (!record.admission)
861
862
  fail("offer is not admitted; there is no execution to settle");
862
- const origin = authorityOriginFromSessionBase(record.admission.session_base_url);
863
+ /* The settlement verbs live on the product origin `/v1/authority/…` is
864
+ the product's public route, which forwards to the shard that admitted
865
+ the execution. The session's own origin serves `/v1/exec/` only, so
866
+ deriving the consent root from `session_base_url` asks a listener that
867
+ does not serve this spelling. */
868
+ const origin = options.productUrl.replace(/\/$/, "");
863
869
  const path = settlementConsentPath(record.admission.execution_id);
864
870
  const promptResponse = await fetch(`${origin}${path}`);
865
871
  const promptText = await promptResponse.text();
@@ -1002,9 +1008,7 @@ async function commandConsent(args) {
1002
1008
  "agent-id": { type: "string" },
1003
1009
  },
1004
1010
  });
1005
- const productUrl = values["product-url"];
1006
- if (!productUrl)
1007
- fail("--product-url is required");
1011
+ const productUrl = resolveProductUrl(values["product-url"]);
1008
1012
  if (!values.offer)
1009
1013
  fail("--offer is required");
1010
1014
  if (!values.seat)
@@ -1040,9 +1044,7 @@ async function commandQueue(args) {
1040
1044
  play: { type: "boolean", default: false },
1041
1045
  },
1042
1046
  });
1043
- const productUrl = values["product-url"];
1044
- if (!productUrl)
1045
- fail("--product-url is required");
1047
+ const productUrl = resolveProductUrl(values["product-url"]);
1046
1048
  if (!values["agent-id"])
1047
1049
  fail("--agent-id is required");
1048
1050
  const tour = values.tour;
@@ -1086,7 +1088,7 @@ async function commandQueue(args) {
1086
1088
  Labelled `agent_page`, never `watch`. It was `watch` for one release, and
1087
1089
  an agent reading that line handed its operator a profile under the word
1088
1090
  WATCH; `watch` is the table, printed below once there is one. */
1089
- console.log(`agent_page ${client.productUrl.replace(/\/$/, "")}/arena/agents/0x${values["agent-id"].replace(/^0x/i, "")}`);
1091
+ console.log(`agent_page ${client.productUrl.replace(/\/$/, "")}/open/agents/0x${values["agent-id"].replace(/^0x/i, "")}`);
1090
1092
  let lastWaitingLine = "";
1091
1093
  const seated = await queueUntilSeated(client, tour, {
1092
1094
  minAgents,
@@ -1141,7 +1143,7 @@ async function commandQueue(args) {
1141
1143
  executionId: admitted.executionId,
1142
1144
  };
1143
1145
  const recordPath = writeRunRecord(values.key, runRecord);
1144
- console.log(`watch ${client.productUrl.replace(/\/$/, "")}/arena/matches/0x${admitted.executionId.replace(/^0x/i, "")}`);
1146
+ console.log(`watch ${client.productUrl.replace(/\/$/, "")}/open/matches/0x${admitted.executionId.replace(/^0x/i, "")}`);
1145
1147
  console.log(`run_record ${recordPath}`);
1146
1148
  console.log(`reconnect ${reconnectCommand(values.key, runRecord)}`);
1147
1149
  takeSeatLock(values.key, values["agent-id"]);
@@ -1233,9 +1235,7 @@ async function commandRoom(args) {
1233
1235
  "timeout-ms": { type: "string", default: String(30 * 60_000) },
1234
1236
  },
1235
1237
  });
1236
- const productUrl = values["product-url"];
1237
- if (!productUrl)
1238
- fail("--product-url is required");
1238
+ const productUrl = resolveProductUrl(values["product-url"]);
1239
1239
  if (!values["agent-id"])
1240
1240
  fail("--agent-id is required");
1241
1241
  const client = {
@@ -1255,6 +1255,12 @@ async function commandRoom(args) {
1255
1255
  const room = await openRoom(client, seats);
1256
1256
  console.log(`table ${room.tableId}`);
1257
1257
  console.log(`seats ${room.seatCount}`);
1258
+ /* How full it is and what fills it. A room seats nobody but the agents
1259
+ invited to it: the chairs nobody takes stay empty, so an opener has to
1260
+ know how many are still open to know who to send the invite to. */
1261
+ console.log(`seated 1 of ${room.seatCount}`);
1262
+ console.log("fill the room deals when its last seat is taken, or two minutes after " +
1263
+ "its second agent sits, whichever comes first; untaken seats stay empty");
1258
1264
  console.log(`mode ${room.mode}`);
1259
1265
  console.log(`settlement ${room.settlement}`);
1260
1266
  if (room.executionId)
@@ -1263,12 +1269,12 @@ async function commandRoom(args) {
1263
1269
  operator forwards the whole thing rather than the table id alone: an id
1264
1270
  without the document is how a guest ends up asking the table routes for
1265
1271
  a decision. */
1266
- console.log(`invite ${roomInvitePrompt(client.productUrl, room.tableId)}`);
1272
+ console.log(`invite ${roomInvitePrompt(client.productUrl, room.tableId, room.seatCount - 1)}`);
1267
1273
  /* The room's own page, which exists before any hand does. Not `watch`:
1268
1274
  that word is the match link on every door, and `room join` prints it
1269
1275
  once the room composes, so two different links under one label was a
1270
1276
  thing an agent had to be warned about. */
1271
- console.log(`room_page ${client.productUrl.replace(/\/$/, "")}/arena/tours/private-room/tables/${room.tableId}`);
1277
+ console.log(`room_page ${client.productUrl.replace(/\/$/, "")}/open/tours/private-room/tables/${room.tableId}`);
1272
1278
  return;
1273
1279
  }
1274
1280
  const tableId = values["table-id"];
@@ -1276,10 +1282,23 @@ async function commandRoom(args) {
1276
1282
  fail("--table-id is required: the id the room's opener sent you");
1277
1283
  const joined = await joinRoomWhenComposed(client, tableId, {
1278
1284
  timeoutMs: Number(values["timeout-ms"]),
1279
- onWaiting: () => console.log(`waiting_for_guest table ${tableId} holds this agent's seat; the room composes when its guest joins`),
1285
+ onWaiting: (found) => {
1286
+ /* What the room is waiting for, said in its own numbers where the join
1287
+ answered with them. An opener's own join is refused rather than
1288
+ answered, so it has none to print. */
1289
+ const seats = found?.seated === undefined || found.seatCount === undefined
1290
+ ? ""
1291
+ : ` ${found.seated} of ${found.seatCount} seated;`;
1292
+ const deals = found?.fillAtMs === undefined
1293
+ ? " it deals when a second agent sits"
1294
+ : ` it deals by ${new Date(found.fillAtMs).toISOString()} or sooner if its last seat is taken`;
1295
+ console.log(`waiting_for_room table ${tableId} holds this agent's seat;${seats}${deals}`);
1296
+ },
1280
1297
  });
1281
1298
  console.log(`table ${joined.tableId}`);
1282
1299
  console.log(`joined ${joined.joined}`);
1300
+ if (joined.seated !== undefined && joined.seatCount !== undefined)
1301
+ console.log(`seated ${joined.seated} of ${joined.seatCount}`);
1283
1302
  if (joined.offerId === undefined) {
1284
1303
  /* A mock stack composes no offer, so there is no seat to accept and
1285
1304
  nothing for `--play` to drive. Said plainly rather than by an empty
@@ -1304,7 +1323,7 @@ async function commandRoom(args) {
1304
1323
  executionId: admitted.executionId,
1305
1324
  };
1306
1325
  const recordPath = writeRunRecord(values.key, runRecord);
1307
- console.log(`watch ${client.productUrl.replace(/\/$/, "")}/arena/matches/0x${admitted.executionId.replace(/^0x/i, "")}`);
1326
+ console.log(`watch ${client.productUrl.replace(/\/$/, "")}/open/matches/0x${admitted.executionId.replace(/^0x/i, "")}`);
1308
1327
  console.log(`run_record ${recordPath}`);
1309
1328
  console.log(`reconnect ${reconnectCommand(values.key, runRecord)}`);
1310
1329
  takeSeatLock(values.key, values["agent-id"]);
@@ -1371,9 +1390,7 @@ async function commandTournament(args) {
1371
1390
  "until-out": { type: "boolean", default: false },
1372
1391
  },
1373
1392
  });
1374
- const productUrl = values["product-url"];
1375
- if (!productUrl)
1376
- fail("--product-url is required");
1393
+ const productUrl = resolveProductUrl(values["product-url"]);
1377
1394
  const agent = loadKeypair(values.key);
1378
1395
  const tournamentId = await tournamentArg(productUrl, values.tournament);
1379
1396
  const overview = await readTournament(productUrl, tournamentId);
@@ -1384,7 +1401,7 @@ async function commandTournament(args) {
1384
1401
  this agent holds now is on the owner's side of the book. */
1385
1402
  const playTakenBackNext = () => describeNext({
1386
1403
  action: "read",
1387
- route: `/open/v1/tournaments/${tournamentId}/owners/${owner ?? "{owner}"}`,
1404
+ route: `/v1/tournaments/${tournamentId}/owners/${owner ?? "{owner}"}`,
1388
1405
  });
1389
1406
  const held = async () => owner ? await playsHeldBy(productUrl, tournamentId, owner, chip) : [];
1390
1407
  let plays = await held();
@@ -1447,8 +1464,16 @@ async function commandTournament(args) {
1447
1464
  return;
1448
1465
  }
1449
1466
  if (verb === "leave") {
1450
- if (entry?.state !== "queued")
1451
- fail(`this agent is ${entry?.state ?? "not in the book"}; only a queued agent can leave, and a seated one plays its table out`);
1467
+ /* An agent that queued a moment ago is on chain and not yet in the book,
1468
+ which is read a checkpoint behind it. Refusing on the first read told an
1469
+ agent that had just been printed `queued waiting` that it was not in the
1470
+ book at all, and the refusal's own `next` sent it to join again — which
1471
+ would queue an agent that is already queued. So an absent entry is
1472
+ waited out, briefly, before it is believed. */
1473
+ const settled = entry ??
1474
+ (await readAgentEntrySettled(productUrl, tournamentId, chip));
1475
+ if (settled?.state !== "queued")
1476
+ fail(`this agent is ${settled?.state ?? "not in the book"}; only a queued agent can leave, and a seated one plays its table out`);
1452
1477
  const executed = await sponsorAndExecute(client, leaveTransaction(overview, chip));
1453
1478
  console.log(`left ${executed.digest} ${executed.status}`);
1454
1479
  if (executed.status !== "success")
@@ -1610,7 +1635,7 @@ async function commandTournament(args) {
1610
1635
  console.log(`offer ${offerId}`);
1611
1636
  console.log(`seat ${seated.seat}`);
1612
1637
  if (seated.executionId)
1613
- console.log(`watch ${client.productUrl.replace(/\/$/, "")}/arena/matches/${seated.executionId}`);
1638
+ console.log(`watch ${client.productUrl.replace(/\/$/, "")}/open/matches/${seated.executionId}`);
1614
1639
  if (!values.play)
1615
1640
  return;
1616
1641
  const seat = seated.seat;
@@ -1652,7 +1677,7 @@ async function commandTournament(args) {
1652
1677
  }
1653
1678
  /** `dopa-open claim-invite`: the link a wallet needs to claim this agent.
1654
1679
  *
1655
- * A claim is two consents. The wallet signs on the arena's page; you, holding
1680
+ * A claim is two consents. The wallet signs on Open's claim page; you, holding
1656
1681
  * the agent's key, sign the invitation that lets it. Name the wallet with
1657
1682
  * `--owner` to make the link good for that wallet alone; leave it out and the
1658
1683
  * link is good for whoever opens it, for as long as `--hours` says. */
@@ -1668,9 +1693,7 @@ async function commandClaimInvite(args) {
1668
1693
  hours: { type: "string", default: "24" },
1669
1694
  },
1670
1695
  });
1671
- const productUrl = values["product-url"]?.replace(/\/$/, "");
1672
- if (!productUrl)
1673
- fail("--product-url is required");
1696
+ const productUrl = resolveProductUrl(values["product-url"]).replace(/\/$/, "");
1674
1697
  if (!values["agent-id"])
1675
1698
  fail("--agent-id is required");
1676
1699
  const hours = Number(values.hours);
@@ -1688,7 +1711,7 @@ async function commandClaimInvite(args) {
1688
1711
  expects the old form. Both decode to the same 192 bytes. */
1689
1712
  const token = encodeClaimInvite(invite);
1690
1713
  const linkToken = encodeClaimInviteCompact(invite);
1691
- /* The arena's pages and its API share an origin on a deployment; a local
1714
+ /* Open's pages and its API share an origin on a deployment; a local
1692
1715
  stack serves them apart, which is what --arena-url is for. */
1693
1716
  const arena = (values["arena-url"] ?? productUrl).replace(/\/$/, "");
1694
1717
  console.log(`invite ${token}`);
@@ -1700,7 +1723,7 @@ const USAGE = `usage: dopa-open <command>
1700
1723
 
1701
1724
  keygen generate a keypair into .dopa-keypair (Sui suiprivkey format)
1702
1725
  address print the owner address and public key of an existing key file
1703
- me print the arena's record of this agent, signed for (agent/me)
1726
+ me print Open's record of this agent, signed for (agent/me)
1704
1727
  claim-invite
1705
1728
  mint the claim link a wallet needs to claim this agent
1706
1729
  register self-allocate and register the agent with a product deployment
package/dist/identity.js CHANGED
@@ -7,11 +7,13 @@
7
7
  * and this key can no longer sign for it -- which is the point of a claim.
8
8
  *
9
9
  * The payload mirrors `AgentIdentityEdit::canonical_payload` in
10
- * `backend/dopa-open/product/src/domain/custodial/key_rotation.rs`: every
11
- * field is length-framed, so "ab"/"c" and "a"/"bc" cannot sign the same
12
- * bytes. The product parses before it verifies -- it trims each field and
13
- * lower-cases the handle -- so this signs the parsed form, or the signature
14
- * is over bytes the store never holds. */
10
+ * `backend/dopa-open/product/src/domain/custodial/key_rotation.rs`: the two
11
+ * ids are fixed 32 bytes and every named field is length-framed, so "ab"/"c"
12
+ * and "a"/"bc" cannot sign the same bytes, and neither can a shorter agent id
13
+ * borrow a byte from the owner behind it. The product parses before it
14
+ * verifies -- it trims each field and lower-cases the handle -- so this signs
15
+ * the parsed form, or the signature is over bytes the store never holds. */
16
+ import { ByteWriter, fromHex, textBytes } from "./bytes.js";
15
17
  import { signOwnerAuthenticator } from "./keypair.js";
16
18
  import { refusalMessageFromText } from "./refusal.js";
17
19
  const IDENTITY_DOMAIN = "dopa_open::agent_identity::v1";
@@ -25,40 +27,20 @@ export function parseIdentityFields(fields) {
25
27
  bio: fields.bio?.trim() || null,
26
28
  };
27
29
  }
28
- function u64be(value) {
29
- const out = new Uint8Array(8);
30
- new DataView(out.buffer).setBigUint64(0, BigInt(value));
31
- return out;
32
- }
33
- function fromHex(value) {
34
- const hex = value.replace(/^0x/i, "");
35
- const out = new Uint8Array(hex.length / 2);
36
- for (let index = 0; index < out.length; index++)
37
- out[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
38
- return out;
39
- }
40
30
  /** The exact bytes the owning address signs to name an agent. */
41
31
  export function canonicalIdentityPayload(edit) {
42
- const encoder = new TextEncoder();
43
- const domain = encoder.encode(IDENTITY_DOMAIN);
32
+ const domain = textBytes(IDENTITY_DOMAIN);
44
33
  const parsed = parseIdentityFields(edit.fields);
45
- const parts = [parsed.name ?? "", parsed.handle ?? "", parsed.bio ?? ""].map((field) => encoder.encode(field));
46
- const chunks = [
47
- u64be(domain.length),
48
- domain,
49
- fromHex(edit.agentId),
50
- fromHex(edit.owner),
51
- ...parts.flatMap((field) => [u64be(field.length), field]),
52
- u64be(edit.issuedAtMs),
53
- u64be(edit.expiresAtMs),
54
- ];
55
- const out = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
56
- let offset = 0;
57
- for (const chunk of chunks) {
58
- out.set(chunk, offset);
59
- offset += chunk.length;
34
+ const writer = new ByteWriter()
35
+ .pushU64(domain.length)
36
+ .pushBytes(domain)
37
+ .pushFixed(fromHex(edit.agentId), 32, "agent id")
38
+ .pushFixed(fromHex(edit.owner), 32, "owner");
39
+ for (const field of [parsed.name ?? "", parsed.handle ?? "", parsed.bio ?? ""]) {
40
+ const bytes = textBytes(field);
41
+ writer.pushU64(bytes.length).pushBytes(bytes);
60
42
  }
61
- return out;
43
+ return writer.pushU64(edit.issuedAtMs).pushU64(edit.expiresAtMs).bytes();
62
44
  }
63
45
  /** Name an agent, as the address that owns it. Answers what the arena stored. */
64
46
  export async function nameAgent(args) {
@@ -73,8 +55,8 @@ export async function nameAgent(args) {
73
55
  issuedAtMs,
74
56
  expiresAtMs,
75
57
  }));
76
- const agentId = `0x${args.agentId.replace(/^0x/i, "")}`;
77
- const response = await fetchImpl(`${args.productUrl.replace(/\/$/, "")}/open/v1/agents/${agentId}/identity`, {
58
+ const agentId = `0x${args.agentId.replace(/^0x/, "")}`;
59
+ const response = await fetchImpl(`${args.productUrl.replace(/\/$/, "")}/v1/agents/${agentId}/identity`, {
78
60
  method: "PUT",
79
61
  headers: { "content-type": "application/json" },
80
62
  body: JSON.stringify({