@odatano/nightgate-mcp 0.17.1 → 0.18.1

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/dist/tools.js CHANGED
@@ -1,10 +1,13 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { z } from 'zod';
2
3
  import { int64, raw, NightgateApiError } from './client.js';
3
- import { BUILDABLE_CALLS, BUILDABLE_ARTIFACTS, DEFAULT_ARTIFACT, buildSponsorable, attesterIdentity, seedIdentity } from './builder.js';
4
- import { buildOffer, postedOfferIds, readOffer, revertHalf, sameOffer, settleBoardRows, settleHalfJob, swapWalletInfo, takenHalves, takeOffer, trackHalfJob, trackPostedHalf, } from './swap.js';
5
- import { txExport } from './tx-module.js';
4
+ import { isGatewayKey, vaultOf } from './config.js';
5
+ import { BUILDABLE_CALLS, BUILDABLE_ARTIFACTS, DEFAULT_ARTIFACT, buildSponsorable, attesterIdentity } from './builder.js';
6
+ import { buildOffer, halfJobIds, postedOfferIds, readOffer, revertHalf, sameOffer, settleBoardRows, settleHalfJob, swapWalletInfo, takenHalves, takeOffer, trackHalfJob, trackPostedHalf, } from './swap.js';
6
7
  import { buildMint } from './factory.js';
7
8
  import { resolveTxSource } from './built.js';
9
+ import { awaitJob, forgetStart, jobSession, startOnce } from './jobs.js';
10
+ import { seedCreated, seedNotice } from './seed.js';
8
11
  const HEX64 = /^[0-9a-fA-F]{64}$/;
9
12
  const hex64 = (what) => z.string().regex(HEX64, `${what} must be 64 hex characters`);
10
13
  /** Scaled non-negative integer, as string (preferred, exact) or JS number; a number stays below 2^53 so String() renders it exactly. */
@@ -12,19 +15,6 @@ const scaledInt = (what) => z.union([
12
15
  z.string().regex(/^\d+$/, `${what} must be a non-negative integer (decimal string)`),
13
16
  z.number().int().nonnegative().safe(),
14
17
  ]);
15
- const merkleSiblings = z.array(hex64('sibling')).length(4)
16
- .describe('Depth-4 Merkle inclusion path: exactly 4 sibling digests (64 hex each)');
17
- const merkleDirs = z.array(z.boolean()).length(4)
18
- .describe('Exactly 4 booleans; true means the current node is the LEFT child');
19
- const setSiblings = z.array(hex64('setSibling')).length(6)
20
- .describe('Depth-6 membership-set path: exactly 6 sibling digests (64 hex each)');
21
- const setDirs = z.array(z.boolean()).length(6)
22
- .describe('Exactly 6 booleans; true means the current node is the LEFT child');
23
- const POLL_HINT = 'Async: returns { jobId, status } immediately; call get_job_status, which waits on the server until the job settles.';
24
- /** How long one get_job_status call lets the server wait for the job to settle, in ms. */
25
- const JOB_WAIT_MS = 20_000;
26
- // Off for this process once a server below NIGHTGATE 0.37 refuses getJobStatus' waitMs.
27
- let jobWait = true;
28
18
  /** One side of a swap: a raw token type and an amount in atoms. */
29
19
  const swapLeg = (what) => z.object({
30
20
  tokenType: hex64(`${what}.tokenType`).describe('Raw token type (64 hex), what derive_token_type returns'),
@@ -47,136 +37,74 @@ const OFFER_STATUS = z.enum(['open', 'filled', 'retired', 'expired', 'all']);
47
37
  const BOARD_ROW_HINT = 'Each row: offerId, the offer half (feed it to read_swap_offer or take_swap_offer), gives/wants ' +
48
38
  'type and amount, tags, expiresAt, postedAt, status, filledTxHash, closedAt, changedAt. Never the poster or the ' +
49
39
  "half's nullifiers.";
50
- /**
51
- * Per-slot salt of a proof field. Content-tree leaves are SALTED, so every
52
- * single-field proof needs the slot's salt from prepare_document_proof
53
- * (field `salt`; batch claims carry it as `salt` too). Witness material.
54
- */
55
- const fieldSalt = hex64('fieldSalt')
56
- .describe('Per-slot salt of this field, from prepare_document_proof (fields[].salt). Witness material, never publish');
57
- /** The shared 16-slot schema descriptor list from prepare_document_proof. */
58
- /**
59
- * Content-tree slot widths NIGHTGATE ships: 16 (`attestation-vault`) and 32
60
- * (`attestation-vault-32`). A document uses exactly one of them, picked by
61
- * the `compiledArtifactRef` it is anchored under. These bounds only keep
62
- * obvious nonsense out; the SERVER knows the registered width of the actual
63
- * artifact and rejects a mismatch with a message naming it.
64
- */
65
- const SLOT_WIDTHS = [16, 32];
66
40
  const MAX_SLOT_WIDTH = 32;
41
+ const RETRY_HINT = 'If the call was cut off or came back pending, run it again with the same arguments: ' +
42
+ 'it picks up the job it started and submits nothing new.';
43
+ const WAIT_HINT = 'The tool waits until the job settled (about 1 minute) and returns { status, txHash, transactionHash, result }. ' +
44
+ 'failed: `next` says what to do. ' + RETRY_HINT;
67
45
  const MAX_MASK = 0xffffffff;
68
- const slotCount = (what) => (list) => SLOT_WIDTHS.includes(list.length)
69
- || `${what} must have ${SLOT_WIDTHS.join(' or ')} entries, one per slot of the vault width`;
70
- /**
71
- * Mirrors the server's vacuity guard: a mask that frees every REAL
72
- * (non-padding) slot of the schema proves nothing at all. Checking it
73
- * against the SCHEMA rather than against a fixed all-ones constant is what
74
- * makes it width-independent, and it also catches the case a constant never
75
- * could, a mask that frees every real slot of a SHORT schema.
76
- */
77
- function maskFreesEveryRealSlot(allowedMask, schema) {
78
- return schema.every((s, i) => s.kind === 2 || (allowedMask & (1 << i)) !== 0);
46
+ /** Names one request, so a repeat of it is recognised; witness and bytes enter only as a hash. */
47
+ function callKey(...parts) {
48
+ return createHash('sha256').update(JSON.stringify(parts, (_k, v) => (v && typeof v === 'object' && !Array.isArray(v)
49
+ ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) : v))).digest('hex');
79
50
  }
80
- const VACUOUS_MASK_MESSAGE = 'allowedMask frees every real (non-padding) schema slot; the claim would be vacuous and the server rejects it';
81
- const schemaSlots = z.array(z.object({
82
- fieldKey: hex64('schema.fieldKey'),
83
- kind: z.union([z.literal(0), z.literal(1), z.literal(2)])
84
- .describe('0 = uint, 1 = bytes, 2 = padding'),
85
- scale: z.union([z.string(), z.number()]).describe("Off-chain scaling of uint slots, '0' otherwise"),
86
- })).refine((l) => slotCount('schema')(l) === true, { message: 'schema must have 16 or 32 entries, one per slot of the vault width' })
87
- .describe('The schema descriptor list returned by prepare_document_proof as `schema`, one entry per slot (16 on the default vault, 32 on attestation-vault-32). Both documents of a comparison MUST use the same one');
88
- /** One document's full opening (salt seed + one value per slot). */
89
- const documentOpening = z.object({
90
- saltSeed: hex64('saltSeed'),
91
- slots: z.array(z.object({
92
- present: z.boolean(),
93
- value: z.string().optional().describe('uint slots: the scaled integer'),
94
- valueDigest: hex64('slot.valueDigest').optional().describe('bytes slots: the value digest'),
95
- })).refine((l) => slotCount('slots')(l) === true, { message: 'slots must have 16 or 32 entries, one per slot of the vault width' }),
96
- }).describe('A document\'s complete opening from prepare_document_proof (`opening`): salt seed plus every slot value (16 or 32, matching the vault width). WITNESS material for the whole document, never publish');
97
- /** Batch claim shapes; claims may mix all kinds in one transaction. */
98
- const numericClaim = z.object({
99
- predicate: z.enum(['lessOrEqual', 'greaterOrEqual']),
100
- fieldKey: hex64('fieldKey'),
101
- value: z.string().regex(/^\d+$/, 'value must be a non-negative integer (decimal string)'),
102
- salt: fieldSalt,
103
- siblings: merkleSiblings,
104
- dirs: merkleDirs,
105
- threshold: scaledInt('threshold'),
106
- unit: z.string().optional(),
107
- });
108
- const equalityClaim = z.object({
109
- predicate: z.literal('bytesEquality'),
110
- fieldKey: hex64('fieldKey'),
111
- expectedValue: z.string().min(1).optional()
112
- .describe('Raw expected string; the server digests the EXACT string'),
113
- expectedDigest: hex64('expectedDigest').optional()
114
- .describe('blake2b-256 of the exact expected string (64 hex)'),
115
- salt: fieldSalt,
116
- siblings: merkleSiblings,
117
- dirs: merkleDirs,
118
- }).superRefine((c, ctx) => {
119
- if (!!c.expectedValue === !!c.expectedDigest) {
120
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'pass exactly one of expectedValue / expectedDigest' });
121
- }
122
- });
123
- const membershipClaim = z.object({
124
- predicate: z.literal('setMembership'),
125
- fieldKey: hex64('fieldKey'),
126
- value: z.string().min(1).optional().describe('Raw member string (witness only)'),
127
- valueDigest: hex64('valueDigest').optional().describe('blake2b-256 of the exact member string (witness only)'),
128
- allowedValues: z.array(z.string().min(1)).min(1).max(64).optional()
129
- .describe('The public allow-list; the server builds the canonical set root + path'),
130
- setRoot: hex64('setRoot').optional().describe('Precomputed canonical set root (64 hex)'),
131
- setSiblings: setSiblings.optional(),
132
- setDirs: setDirs.optional(),
133
- salt: fieldSalt,
134
- siblings: merkleSiblings,
135
- dirs: merkleDirs,
136
- }).superRefine((c, ctx) => {
137
- if (!!c.value === !!c.valueDigest) {
138
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'pass exactly one of value / valueDigest' });
139
- }
140
- const hasList = c.allowedValues !== undefined;
141
- const hasPath = !!(c.setRoot || c.setSiblings || c.setDirs);
142
- if (hasList && hasPath) {
143
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'pass either allowedValues or setRoot + setSiblings + setDirs, not both' });
144
- }
145
- if (!hasList && !(c.setRoot && c.setSiblings && c.setDirs)) {
146
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'allowedValues or setRoot + setSiblings + setDirs is required' });
147
- }
148
- });
149
- /** Cross-root claim shapes: they relate the batch payload hash (document A) to a second document. */
150
- const documentIntegrityClaim = z.object({
151
- predicate: z.literal('documentIntegrity'),
152
- payloadHashB: hex64('payloadHashB').describe('The second document (A is the batch payloadHash)'),
153
- attesterIdB: hex64('attesterIdB').optional().describe('Document B\'s attester; default the batch attester'),
154
- allowedMask: z.number().int().min(0).max(MAX_MASK - 1)
155
- .describe('Packed slot mask, one bit per slot of the vault width (16 bits by default, 32 on attestation-vault-32), bit i = slot i MAY differ. Must leave at least one real schema slot constrained, otherwise the claim is vacuous and rejected'),
156
- schema: schemaSlots,
157
- openingA: documentOpening,
158
- openingB: documentOpening,
159
- }).superRefine((c, ctx) => {
160
- if (maskFreesEveryRealSlot(c.allowedMask, c.schema)) {
161
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: VACUOUS_MASK_MESSAGE });
51
+ /** The free verify call that shows a vault call's effect on-chain; none when the call names its record by key only. */
52
+ function checkFor(input, ownAttesterId) {
53
+ const p = input.params;
54
+ const hex = (k) => (typeof p[k] === 'string' ? String(p[k]).toLowerCase() : undefined);
55
+ const base = { contractAddress: input.contractAddress, compiledArtifactRef: input.compiledArtifactRef ?? DEFAULT_ARTIFACT };
56
+ const record = (payloadHash, attesterId) => payloadHash ? { ...base, attesterId: attesterId ?? ownAttesterId, payloadHash } : undefined;
57
+ const claim = (args) => (args ? { tool: 'verify_predicate', args } : undefined);
58
+ switch (input.call) {
59
+ case 'attest':
60
+ case 'anchorContentRoot':
61
+ case 'grantDisclosure':
62
+ case 'revokeDisclosure':
63
+ case 'bindPassport': {
64
+ const r = record(hex('payloadHash'));
65
+ if (!r)
66
+ return undefined;
67
+ const root = input.call === 'anchorContentRoot' ? { contentRoot: hex('contentRoot'), schemaId: hex('schemaId') } : {};
68
+ return { tool: 'verify_attestation', args: { ...r, ...root } };
69
+ }
70
+ case 'proveFieldPredicate': {
71
+ const r = record(hex('payloadHash'), hex('attesterId'));
72
+ return claim(r && { ...r, predicate: Number(p.op) === 1 ? 'greaterOrEqual' : 'lessOrEqual', fieldKey: hex('fieldKey'), threshold: String(p.threshold) });
73
+ }
74
+ case 'proveFieldEquality': {
75
+ const r = record(hex('payloadHash'), hex('attesterId'));
76
+ return claim(r && { ...r, predicate: 'bytesEquality', fieldKey: hex('fieldKey'), expectedDigest: hex('expectedDigest') });
77
+ }
78
+ case 'proveFieldMembership': {
79
+ const r = record(hex('payloadHash'), hex('attesterId'));
80
+ return claim(r && { ...r, predicate: 'setMembership', fieldKey: hex('fieldKey'), setRoot: hex('setRoot') });
81
+ }
82
+ case 'proveFieldsUnchangedExcept':
83
+ case 'proveFieldsDiffer': {
84
+ const r = record(hex('payloadHashA'), hex('attesterIdA'));
85
+ if (!r || !hex('payloadHashB'))
86
+ return undefined;
87
+ const extraArgs = input.call === 'proveFieldsUnchangedExcept'
88
+ ? { predicate: 'documentIntegrity', allowedMask: Number(p.allowedMask) }
89
+ : { predicate: 'documentDiff', k: Number(p.k) };
90
+ return claim({ ...r, payloadHashB: hex('payloadHashB'), attesterIdB: hex('attesterIdB'), ...extraArgs });
91
+ }
92
+ default:
93
+ return undefined;
162
94
  }
163
- });
164
- const documentDiffClaim = z.object({
165
- predicate: z.literal('documentDiff'),
166
- payloadHashB: hex64('payloadHashB').describe('The second document (A is the batch payloadHash)'),
167
- attesterIdB: hex64('attesterIdB').optional().describe('Document B\'s attester; default the batch attester'),
168
- k: z.number().int().min(1).max(MAX_SLOT_WIDTH).describe('Minimum number of differing slots to prove, up to the vault width'),
169
- schema: schemaSlots,
170
- openingA: documentOpening,
171
- openingB: documentOpening,
172
- });
173
- /**
174
- * Phase A tool set: crawler-free verification, job polling, and the
175
- * curated write path (anchoring, field predicates, disclosure).
176
- * Wallet lifecycle actions are deliberately never exposed over MCP.
177
- */
95
+ }
96
+ const CONTRACT = z.string().regex(HEX64, 'contractAddress must be 64 hex characters').optional()
97
+ .describe('AttestationVault address (64 hex); default the shared sandbox vault of the network (preprod, preview)');
98
+ /** Every NIGHTGATE tool. Writes are built here with the caller seed and paid by a sponsor; no tool needs a server wallet. */
178
99
  export function registerTools(server, client, config) {
179
100
  const run = wrapHandler(client);
101
+ const vault = (contractAddress, compiledArtifactRef) => {
102
+ if (config)
103
+ return vaultOf(config, contractAddress, compiledArtifactRef);
104
+ if (!contractAddress)
105
+ throw new Error('contractAddress is required');
106
+ return contractAddress.toLowerCase();
107
+ };
180
108
  server.registerTool('verify_attestation', {
181
109
  description: 'Verify against LIVE Midnight contract state that an attester\'s record of a payload ' +
182
110
  'hash stands in an AttestationVault (crawler-free, no txHash needed). A record is ONE ' +
@@ -184,9 +112,12 @@ export function registerTools(server, client, config) {
184
112
  'name it by attesterId + payloadHash, or by a bound documentId, which resolves to exactly ' +
185
113
  'one record and reveals its attester. Optionally also checks that the anchored content ' +
186
114
  'root / schema id match. Returns verified:false (not an error) when absent; the result ' +
187
- 'carries attesterId, payloadHash, recordKey and documentId.',
115
+ 'carries attesterId, payloadHash, recordKey and documentId. bindingRegistered is only ' +
116
+ 'about documentId lookups: true when the vault registrar assigned that id to this ' +
117
+ 'attester. A record anchored without a document id reports false, and that is fine. ' +
118
+ 'Free: it reads the public verify service.',
188
119
  inputSchema: {
189
- contractAddress: z.string().min(1).describe('AttestationVault contract address'),
120
+ contractAddress: CONTRACT,
190
121
  attesterId: hex64('attesterId').optional()
191
122
  .describe('The attester whose record to check (64 hex; get_attester_identity for this server\'s own). Required unless documentId is given'),
192
123
  payloadHash: hex64('payloadHash').optional()
@@ -204,8 +135,8 @@ export function registerTools(server, client, config) {
204
135
  if (!args.documentId && !(args.attesterId && args.payloadHash)) {
205
136
  throw new Error('name the record: attesterId + payloadHash, or documentId');
206
137
  }
207
- return client.callFunction('verifyAttestationState', {
208
- contractAddress: args.contractAddress,
138
+ return client.callVerifyFunction('verifyAttestationState', {
139
+ contractAddress: vault(args.contractAddress, args.compiledArtifactRef),
209
140
  attesterId: args.attesterId,
210
141
  payloadHash: args.payloadHash,
211
142
  documentId: args.documentId,
@@ -222,9 +153,10 @@ export function registerTools(server, client, config) {
222
153
  'mismatch yields verified:false) and optionally fieldKey for field-bound proofs. ' +
223
154
  'bytesEquality needs fieldKey + expectedDigest; setMembership needs fieldKey + setRoot ' +
224
155
  '(recompute it from the published list via prepare_membership_set). Every claim is bound ' +
225
- 'to ONE attester\'s record of the document, so attesterId is part of the coordinates.',
156
+ 'to ONE attester\'s record of the document, so attesterId is part of the coordinates. ' +
157
+ 'Free: it reads the public verify service.',
226
158
  inputSchema: {
227
- contractAddress: z.string().min(1).describe('AttestationVault contract address'),
159
+ contractAddress: CONTRACT,
228
160
  attesterId: hex64('attesterId').describe('The attester whose record of payloadHash carries the claim (64 hex)'),
229
161
  payloadHash: hex64('payloadHash').describe('The attestation payload hash (64 hex)'),
230
162
  predicate: z.enum(['lessOrEqual', 'greaterOrEqual', 'bytesEquality', 'setMembership',
@@ -265,8 +197,8 @@ export function registerTools(server, client, config) {
265
197
  if (args.predicate === 'documentDiff' && (!args.payloadHashB || args.k === undefined)) {
266
198
  throw new Error("predicate 'documentDiff' requires payloadHashB and k");
267
199
  }
268
- return client.callFunction('verifyPredicateState', {
269
- contractAddress: args.contractAddress,
200
+ return client.callVerifyFunction('verifyPredicateState', {
201
+ contractAddress: vault(args.contractAddress, args.compiledArtifactRef),
270
202
  attesterId: args.attesterId,
271
203
  payloadHash: args.payloadHash,
272
204
  attesterIdB: args.attesterIdB,
@@ -282,37 +214,11 @@ export function registerTools(server, client, config) {
282
214
  network: args.network,
283
215
  });
284
216
  }));
285
- server.registerTool('verify_predicate_attestation', {
286
- description: 'Verify a server-issued predicate attestation by its NIGHTGATE row id (UUID). ' +
287
- 'Confirms the proving transaction succeeded, with a crawler-free live-state fallback. ' +
288
- 'Use verify_predicate instead when you only have on-chain coordinates.',
289
- inputSchema: {
290
- predicateAttestationId: z.string().uuid().describe('PredicateAttestations row id'),
291
- },
292
- }, run(async (args) => client.callFunction('verifyPredicateAttestation', {
293
- predicateAttestationId: args.predicateAttestationId,
294
- })));
295
- server.registerTool('verify_document', {
296
- description: 'Verify an anchored document by its NIGHTGATE document id: compares the provided sha256 ' +
297
- 'against the anchored original and confirms the anchoring transaction, with a crawler-free ' +
298
- 'live-state fallback when contractAddress is supplied.',
299
- inputSchema: {
300
- documentId: z.string().uuid().describe('Documents row id'),
301
- providedSha256: hex64('providedSha256').describe('sha256 of the document to check (64 hex)'),
302
- contractAddress: z.string().optional().describe('Optional vault address, enables the crawler-free fallback'),
303
- compiledArtifactRef: z.string().optional().describe("Contract artifact ref, defaults to 'attestation-vault-32'; 'attestation-vault' for a 16-slot vault"),
304
- },
305
- }, run(async (args) => client.callFunction('verifyDocument', {
306
- documentId: args.documentId,
307
- providedSha256: args.providedSha256,
308
- contractAddress: args.contractAddress,
309
- compiledArtifactRef: args.compiledArtifactRef ?? DEFAULT_ARTIFACT,
310
- })));
311
217
  server.registerTool('prepare_document_proof', {
312
218
  description: 'Turn a structured document into everything the proof tools need: canonical JSON and its ' +
313
219
  'payloadHash (what anchor_document anchors), a Merkle contentRoot over an ORDERED list of ' +
314
- 'up to 16 proof fields, and per-field inclusion paths ready for prove_field_predicate / ' +
315
- 'prove_field_equality / prove_field_membership. kind "uint" (default) scales a numeric ' +
220
+ 'up to 16 proof fields, and per-field inclusion paths ready for the proveField* calls of ' +
221
+ 'submit_vault_call. kind "uint" (default) scales a numeric ' +
316
222
  'value; kind "bytes" (NIGHTGATE >= 0.16.0) enters a STRING field as the digest of the ' +
317
223
  'exact string, feeding the equality/membership proofs. Keep the field order stable across ' +
318
224
  'anchor and proof: it is part of the tree identity. Compute-only and synchronous, nothing ' +
@@ -345,117 +251,6 @@ export function registerTools(server, client, config) {
345
251
  saltSeed: args.saltSeed,
346
252
  compiledArtifactRef: args.compiledArtifactRef ?? DEFAULT_ARTIFACT,
347
253
  })));
348
- server.registerTool('attest_agent_output', {
349
- description: 'Anchor agent-output provenance on the Midnight chain: "agent X produced output O from ' +
350
- 'input I at time T". Builds the canonical v1 envelope server-side, hashes it and anchors ' +
351
- 'it; the response returns the envelopeJson any third party can re-hash and check via ' +
352
- 'verify_attestation, without trusting this server. ' + POLL_HINT,
353
- inputSchema: {
354
- agentId: z.string().min(1).max(200).describe('Agent identity, ideally a registered grantee id'),
355
- inputHash: hex64('inputHash').describe('Commitment to the agent input (64 hex)'),
356
- outputHash: hex64('outputHash').describe('Commitment to the produced output (64 hex)'),
357
- sessionId: z.string().uuid().describe('Wallet session id that signs and submits'),
358
- contractAddress: z.string().min(1).describe('AttestationVault deployment'),
359
- modelId: z.string().max(200).optional().describe('Optional model identifier'),
360
- policyHash: hex64('policyHash').optional().describe('Optional commitment to the governing policy (64 hex)'),
361
- producedAt: z.string().datetime().optional().describe('Optional ISO-8601 production time, defaults to now'),
362
- storageRef: z.string().optional().describe('Where output/envelope live, defaults to agent-output://<agentId>'),
363
- compiledArtifactRef: z.string().optional().describe("Contract artifact ref, defaults to 'attestation-vault-32'; 'attestation-vault' for a 16-slot vault"),
364
- idempotencyKey: z.string().optional().describe('Dedupes retries'),
365
- sponsorSessionId: z.string().uuid().optional().describe('Optional second session that pays the dust fee'),
366
- },
367
- }, run(async (args) => client.callAction('attestAgentOutput', {
368
- agentId: args.agentId,
369
- inputHash: args.inputHash,
370
- outputHash: args.outputHash,
371
- modelId: args.modelId,
372
- policyHash: args.policyHash,
373
- producedAt: args.producedAt,
374
- storageRef: args.storageRef,
375
- sessionId: args.sessionId,
376
- contractAddress: args.contractAddress,
377
- compiledArtifactRef: args.compiledArtifactRef ?? DEFAULT_ARTIFACT,
378
- idempotencyKey: args.idempotencyKey,
379
- sponsorSessionId: args.sponsorSessionId,
380
- })));
381
- server.registerTool('anchor_document', {
382
- description: 'Anchor a document content hash on the Midnight chain via the AttestationVault attest ' +
383
- 'circuit (one transaction). Commits only the sha256 + public metadata; you are responsible ' +
384
- 'for storing the actual bytes at storageRef. The on-chain record is keyed by the session\'s ' +
385
- 'attester id AND the hash, so nobody can pre-empt or take over it; the same hash anchored ' +
386
- 'by another identity is a separate record. ' + POLL_HINT +
387
- ' Also returns documentId for verify_document and attesterId, which verifiers need next to the hash.',
388
- inputSchema: {
389
- sha256: hex64('sha256').describe('sha256 of the document content (64 hex), becomes the on-chain payload hash'),
390
- storageRef: z.string().min(1).describe('Where the bytes live, e.g. file://, s3://, ipfs://'),
391
- sessionId: z.string().uuid().describe('Wallet session id that signs and submits'),
392
- contractAddress: z.string().min(1).describe('AttestationVault deployment to anchor into'),
393
- contentType: z.string().optional().describe('MIME type, informational'),
394
- size: z.number().int().nonnegative().optional().describe('Content size in bytes, informational'),
395
- metadata: z.record(z.unknown()).optional().describe('Public metadata object; its hash is anchored alongside'),
396
- compiledArtifactRef: z.string().optional().describe("Contract artifact ref, defaults to 'attestation-vault-32'; 'attestation-vault' for a 16-slot vault"),
397
- idempotencyKey: z.string().optional().describe('Dedupes retries of the same anchor request'),
398
- sponsorSessionId: z.string().uuid().optional().describe('Optional second session that pays the dust fee'),
399
- },
400
- }, run(async (args) => client.callAction('anchorDocument', {
401
- sha256: args.sha256,
402
- contentType: args.contentType,
403
- size: args.size,
404
- storageRef: args.storageRef,
405
- metadata: args.metadata === undefined ? undefined : JSON.stringify(args.metadata),
406
- sessionId: args.sessionId,
407
- contractAddress: args.contractAddress,
408
- compiledArtifactRef: args.compiledArtifactRef ?? DEFAULT_ARTIFACT,
409
- idempotencyKey: args.idempotencyKey,
410
- sponsorSessionId: args.sponsorSessionId,
411
- })));
412
- server.registerTool('prove_field_predicate', {
413
- description: 'Issue a zero-knowledge field-bound predicate proof: prove that a hidden field of an ' +
414
- 'anchored document satisfies "value <= threshold" or "value >= threshold" WITHOUT revealing ' +
415
- 'the value. Needs the depth-4 Merkle inclusion path of the field in the anchored content ' +
416
- 'root. value/threshold are scaled integers (decimal strings); the value is a witness and ' +
417
- 'never persisted. If contentRoot is supplied it is anchored first. A false predicate fails ' +
418
- 'at local proving time, nothing is submitted. ' + POLL_HINT,
419
- inputSchema: {
420
- payloadHash: hex64('payloadHash').describe('Attestation payload hash (64 hex)'),
421
- attesterId: hex64('attesterId').optional()
422
- .describe('The attester whose record of payloadHash the claim is bound to (64 hex); default the session\'s own. A contentRoot can only be anchored under the session\'s own record'),
423
- fieldKey: hex64('fieldKey').describe('Canonical field id (64 hex, public)'),
424
- value: z.string().regex(/^\d+$/, 'value must be a non-negative integer (decimal string)')
425
- .describe('Scaled integer field value (witness only, never persisted)'),
426
- fieldSalt,
427
- siblings: merkleSiblings,
428
- dirs: merkleDirs,
429
- predicate: z.enum(['lessOrEqual', 'greaterOrEqual']).describe('Predicate operator'),
430
- threshold: scaledInt('threshold').describe('Scaled integer threshold (same scaling as value)'),
431
- sessionId: z.string().uuid().describe('Wallet session id that signs and submits'),
432
- contractAddress: z.string().min(1).describe('AttestationVault deployment'),
433
- contentRoot: hex64('contentRoot').optional().describe('Optional Merkle root (64 hex) to anchor first'),
434
- schemaId: hex64('schemaId').optional().describe('Schema id of the field list, required whenever contentRoot is supplied'),
435
- unit: z.string().optional().describe('Informational unit, e.g. kWh'),
436
- compiledArtifactRef: z.string().optional().describe("Contract artifact ref, defaults to 'attestation-vault-32'; 'attestation-vault' for a 16-slot vault"),
437
- idempotencyKey: z.string().optional().describe('Dedupes retries'),
438
- sponsorSessionId: z.string().uuid().optional().describe('Optional second session that pays the dust fee'),
439
- },
440
- }, run(async (args) => client.callAction('issueFieldPredicateAttestation', {
441
- payloadHash: args.payloadHash,
442
- attesterId: args.attesterId,
443
- fieldKey: args.fieldKey,
444
- value: args.value,
445
- fieldSalt: args.fieldSalt,
446
- contentRoot: args.contentRoot,
447
- schemaId: args.schemaId,
448
- siblings: args.siblings,
449
- dirs: args.dirs,
450
- predicate: args.predicate,
451
- threshold: String(args.threshold),
452
- unit: args.unit,
453
- sessionId: args.sessionId,
454
- contractAddress: args.contractAddress,
455
- compiledArtifactRef: args.compiledArtifactRef ?? DEFAULT_ARTIFACT,
456
- idempotencyKey: args.idempotencyKey,
457
- sponsorSessionId: args.sponsorSessionId,
458
- })));
459
254
  server.registerTool('prepare_membership_set', {
460
255
  description: 'Build the canonical depth-6 membership-set tree over a public allow-list (NIGHTGATE ' +
461
256
  '>= 0.16.0). Deterministic rule (digest each exact string, dedupe, sort ascending, pad by ' +
@@ -483,311 +278,111 @@ export function registerTools(server, client, config) {
483
278
  compiledArtifactRef: args.compiledArtifactRef ?? DEFAULT_ARTIFACT,
484
279
  });
485
280
  }));
486
- server.registerTool('prove_field_equality', {
487
- description: 'Issue a ZK bytes-equality proof (NIGHTGATE >= 0.16.0): the anchored document field ' +
488
- 'carries EXACTLY the value behind the public expectedDigest. The digest IS the statement, ' +
489
- 'so this is an authenticity/binding proof, not confidentiality (a low-entropy value\'s ' +
490
- 'digest is dictionary-guessable). The field must be a kind:"bytes" leaf from ' +
491
- 'prepare_document_proof; siblings/dirs are its depth-4 inclusion path. If contentRoot is ' +
492
- 'supplied it is anchored first. ' + POLL_HINT,
493
- inputSchema: {
494
- payloadHash: hex64('payloadHash').describe('Attestation payload hash (64 hex)'),
495
- attesterId: hex64('attesterId').optional()
496
- .describe('The attester whose record of payloadHash the claim is bound to (64 hex); default the session\'s own. A contentRoot can only be anchored under the session\'s own record'),
497
- fieldKey: hex64('fieldKey').describe('Canonical field id (64 hex, public)'),
498
- expectedValue: z.string().min(1).optional()
499
- .describe('Raw expected string (the server digests the EXACT string; pass this OR expectedDigest)'),
500
- expectedDigest: hex64('expectedDigest').optional()
501
- .describe('blake2b-256 of the exact expected string (64 hex)'),
502
- fieldSalt,
503
- siblings: merkleSiblings,
504
- dirs: merkleDirs,
505
- sessionId: z.string().uuid().describe('Wallet session id that signs and submits'),
506
- contractAddress: z.string().min(1).describe('AttestationVault deployment'),
507
- contentRoot: hex64('contentRoot').optional().describe('Optional Merkle root (64 hex) to anchor first'),
508
- schemaId: hex64('schemaId').optional().describe('Schema id of the field list, required whenever contentRoot is supplied'),
509
- compiledArtifactRef: z.string().optional().describe("Contract artifact ref, defaults to 'attestation-vault-32'; 'attestation-vault' for a 16-slot vault"),
510
- idempotencyKey: z.string().optional().describe('Dedupes retries'),
511
- sponsorSessionId: z.string().uuid().optional().describe('Optional second session that pays the dust fee'),
512
- },
513
- }, run(async (args) => {
514
- if (!!args.expectedValue === !!args.expectedDigest) {
515
- throw new Error('pass exactly one of expectedValue / expectedDigest');
516
- }
517
- return client.callAction('issueFieldEqualityAttestation', {
518
- payloadHash: args.payloadHash,
519
- attesterId: args.attesterId,
520
- fieldKey: args.fieldKey,
521
- expectedValue: args.expectedValue,
522
- expectedDigest: args.expectedDigest,
523
- fieldSalt: args.fieldSalt,
524
- contentRoot: args.contentRoot,
525
- schemaId: args.schemaId,
526
- siblings: args.siblings,
527
- dirs: args.dirs,
528
- sessionId: args.sessionId,
529
- contractAddress: args.contractAddress,
530
- compiledArtifactRef: args.compiledArtifactRef ?? DEFAULT_ARTIFACT,
531
- idempotencyKey: args.idempotencyKey,
532
- sponsorSessionId: args.sponsorSessionId,
533
- });
534
- }));
535
- server.registerTool('prove_field_membership', {
536
- description: 'Issue a ZK set-membership proof (NIGHTGATE >= 0.16.0): the anchored document field\'s ' +
537
- 'HIDDEN value is one of a public allow-list, without revealing which one. Supply the ' +
538
- 'allow-list directly (allowedValues: the server builds the canonical set root + path and ' +
539
- 'rejects a non-member with 400 BEFORE any proving) or a precomputed setRoot + setSiblings ' +
540
- '+ setDirs from prepare_membership_set. The field must be a kind:"bytes" leaf from ' +
541
- 'prepare_document_proof; value/valueDigest stay witness material, never persisted. If ' +
542
- 'contentRoot is supplied it is anchored first. ' + POLL_HINT,
543
- inputSchema: {
544
- payloadHash: hex64('payloadHash').describe('Attestation payload hash (64 hex)'),
545
- attesterId: hex64('attesterId').optional()
546
- .describe('The attester whose record of payloadHash the claim is bound to (64 hex); default the session\'s own. A contentRoot can only be anchored under the session\'s own record'),
547
- fieldKey: hex64('fieldKey').describe('Canonical field id (64 hex, public)'),
548
- value: z.string().min(1).optional()
549
- .describe('Raw member string (witness only; pass this OR valueDigest)'),
550
- valueDigest: hex64('valueDigest').optional()
551
- .describe('blake2b-256 of the exact member string (witness only)'),
552
- allowedValues: z.array(z.string().min(1)).min(1).max(64).optional()
553
- .describe('The public allow-list; pass this OR setRoot + setSiblings + setDirs'),
554
- setRoot: hex64('setRoot').optional().describe('Precomputed canonical set root (64 hex)'),
555
- setSiblings: setSiblings.optional(),
556
- setDirs: setDirs.optional(),
557
- fieldSalt,
558
- siblings: merkleSiblings,
559
- dirs: merkleDirs,
560
- sessionId: z.string().uuid().describe('Wallet session id that signs and submits'),
561
- contractAddress: z.string().min(1).describe('AttestationVault deployment'),
562
- contentRoot: hex64('contentRoot').optional().describe('Optional Merkle root (64 hex) to anchor first'),
563
- schemaId: hex64('schemaId').optional().describe('Schema id of the field list, required whenever contentRoot is supplied'),
564
- compiledArtifactRef: z.string().optional().describe("Contract artifact ref, defaults to 'attestation-vault-32'; 'attestation-vault' for a 16-slot vault"),
565
- idempotencyKey: z.string().optional().describe('Dedupes retries'),
566
- sponsorSessionId: z.string().uuid().optional().describe('Optional second session that pays the dust fee'),
567
- },
568
- }, run(async (args) => {
569
- if (!!args.value === !!args.valueDigest) {
570
- throw new Error('pass exactly one of value / valueDigest');
571
- }
572
- const hasList = args.allowedValues !== undefined;
573
- const hasPath = !!(args.setRoot || args.setSiblings || args.setDirs);
574
- if (hasList && hasPath)
575
- throw new Error('pass either allowedValues or setRoot + setSiblings + setDirs, not both');
576
- if (!hasList && !(args.setRoot && args.setSiblings && args.setDirs)) {
577
- throw new Error('allowedValues or setRoot + setSiblings + setDirs is required');
578
- }
579
- return client.callAction('issueFieldMembershipAttestation', {
580
- payloadHash: args.payloadHash,
581
- attesterId: args.attesterId,
582
- fieldKey: args.fieldKey,
583
- value: args.value,
584
- valueDigest: args.valueDigest,
585
- allowedValues: args.allowedValues,
586
- setRoot: args.setRoot,
587
- setSiblings: args.setSiblings,
588
- setDirs: args.setDirs,
589
- fieldSalt: args.fieldSalt,
590
- contentRoot: args.contentRoot,
591
- schemaId: args.schemaId,
592
- siblings: args.siblings,
593
- dirs: args.dirs,
594
- sessionId: args.sessionId,
595
- contractAddress: args.contractAddress,
596
- compiledArtifactRef: args.compiledArtifactRef ?? DEFAULT_ARTIFACT,
597
- idempotencyKey: args.idempotencyKey,
598
- sponsorSessionId: args.sponsorSessionId,
281
+ /** Wait for a swap job; a landed swap frees its half here. */
282
+ const awaitSwap = async (started, offerId, extra) => {
283
+ const check = offerId ? { tool: 'get_swap_offer', args: { offerId } } : { tool: 'get_swap_wallet', args: {} };
284
+ const job = await awaitJob(client, config, started, extra, check);
285
+ if (typeof started.jobId === 'string')
286
+ settleHalfJob(started.jobId, job.status);
287
+ return job;
288
+ };
289
+ /** Hand bytes to a sponsor once and wait; the same bytes again pick up that job. */
290
+ const sponsorOnce = async (action, body, extra, check) => {
291
+ const key = callKey(action, body);
292
+ const started = await startOnce(key, async () => await client.callAction(action, body));
293
+ const job = await awaitJob(client, config, started, extra, check);
294
+ if (job.status === 'failed')
295
+ forgetStart(key);
296
+ return job;
297
+ };
298
+ /** Build one vault call here, hand it to the sponsor and wait until it settled. */
299
+ const submitVaultCall = async (input, idempotencyKey, extra) => {
300
+ if (!config)
301
+ throw new Error('local building is not configured for this server instance');
302
+ const key = callKey('vault', input.contractAddress, input.compiledArtifactRef ?? DEFAULT_ARTIFACT, input.call, input.params, input.merkleProof, input.docPair);
303
+ const started = await startOnce(key, async () => {
304
+ const built = await buildSponsorable(config, { ...input, bind: false });
305
+ const { b64 } = await resolveTxSource({ txId: built.txId }, 'unboundTxB64');
306
+ const job = await client.callAction('sponsorUnboundTransaction', { unboundTxB64: b64, idempotencyKey });
307
+ return { ...job, attesterId: built.attesterId, contractAddress: input.contractAddress, buildMs: built.buildMs };
599
308
  });
600
- }));
601
- server.registerTool('prove_field_predicates_batch', {
602
- description: 'Batch variant of the field proof tools: prove up to 8 field-bound claims on ONE anchored ' +
603
- 'document in ONE transaction (7 if contentRoot is supplied, since the anchor occupies one ' +
604
- 'call slot). Claims may MIX the three kinds (NIGHTGATE >= 0.16.0), discriminated by ' +
605
- 'predicate: numeric (lessOrEqual/greaterOrEqual), bytesEquality, setMembership. Duplicate ' +
606
- 'claim tuples are dropped server-side. One false claim aborts the whole batch at local ' +
607
- 'proving time with zero on-chain effect. After submission the chain can finalize a ' +
608
- 'PARTIAL_SUCCESS subset; verify per claim via verify_predicate_attestation instead of ' +
609
- 'assuming all-or-nothing. ' + POLL_HINT,
610
- inputSchema: {
611
- payloadHash: hex64('payloadHash').describe('Shared attestation payload hash; also document A of any cross-root claim (64 hex)'),
612
- attesterId: hex64('attesterId').optional()
613
- .describe('The attester whose record of payloadHash the claim is bound to (64 hex); default the session\'s own. A contentRoot can only be anchored under the session\'s own record'),
614
- claims: z.array(z.union([numericClaim, equalityClaim, membershipClaim, documentIntegrityClaim, documentDiffClaim]))
615
- .min(1).max(8)
616
- .describe('1-8 claims on the same payload hash; any mix of numeric, bytesEquality, setMembership, documentIntegrity and documentDiff'),
617
- sessionId: z.string().uuid().describe('Wallet session id that signs and submits'),
618
- contractAddress: z.string().min(1).describe('AttestationVault deployment'),
619
- contentRoot: hex64('contentRoot').optional().describe('Optional Merkle root anchored as first call of the same batch'),
620
- schemaId: hex64('schemaId').optional().describe('Schema id of the field list, required whenever contentRoot is supplied'),
621
- compiledArtifactRef: z.string().optional().describe("Contract artifact ref, defaults to 'attestation-vault-32'; 'attestation-vault' for a 16-slot vault"),
622
- idempotencyKey: z.string().optional().describe('Dedupes retries'),
623
- sponsorSessionId: z.string().uuid().optional().describe('Optional second session that pays the dust fee'),
624
- },
625
- }, run(async (args) => {
626
- if (args.contentRoot && args.claims.length > 7) {
627
- throw new Error('with contentRoot the anchor occupies one slot: max 7 claims');
309
+ const job = await awaitJob(client, config, started, extra, checkFor(input, String(started.attesterId)));
310
+ if (job.status === 'failed')
311
+ forgetStart(key);
312
+ return job;
313
+ };
314
+ server.registerTool('anchor_document', {
315
+ description: 'Anchor a document hash on Midnight in one call: builds the attest transaction here with ' +
316
+ 'the caller seed, a sponsor pays the fee, and the tool waits until it landed (about 1 to 2 ' +
317
+ 'minutes; the first call also downloads the prover keys). The record belongs to this ' +
318
+ 'seed (attesterId). Only the hash and the hash of the metadata go on-chain; keep the ' +
319
+ 'document yourself. With contentRoot and schemaId from prepare_document_proof it also ' +
320
+ 'anchors the content root, which the field claims of submit_vault_call need. Returns ' +
321
+ '{ status, attesterId, payloadHash, contractAddress, metadataJson, txHash, transactionHash, ' +
322
+ 'check }; check is the verify_attestation call that shows the record on-chain. ' + RETRY_HINT,
323
+ inputSchema: {
324
+ sha256: hex64('sha256').describe('sha256 of the document (64 hex); it becomes the payloadHash'),
325
+ metadata: z.record(z.unknown()).optional()
326
+ .describe('Public metadata; its sha256 is anchored with the hash. Default none'),
327
+ contentRoot: hex64('contentRoot').optional().describe('Content root from prepare_document_proof (64 hex); needs schemaId'),
328
+ schemaId: hex64('schemaId').optional().describe('Schema id from prepare_document_proof (64 hex); needs contentRoot'),
329
+ contractAddress: CONTRACT,
330
+ compiledArtifactRef: z.enum(BUILDABLE_ARTIFACTS).optional()
331
+ .describe("Vault lineage, default 'attestation-vault-32' (the sandbox vaults); 'attestation-vault' for a 16-slot vault"),
332
+ idempotencyKey: z.string().optional().describe('Dedupes retries of the same anchor'),
333
+ },
334
+ }, run(async (args, extra) => {
335
+ if (!!args.contentRoot !== !!args.schemaId)
336
+ throw new Error('pass contentRoot and schemaId together');
337
+ const contractAddress = vault(args.contractAddress, args.compiledArtifactRef);
338
+ const payloadHash = args.sha256.toLowerCase();
339
+ const metadataJson = args.metadata === undefined ? '' : JSON.stringify(args.metadata);
340
+ const metadataHash = createHash('sha256').update(metadataJson, 'utf8').digest('hex');
341
+ const ref = args.compiledArtifactRef ?? DEFAULT_ARTIFACT;
342
+ const anchor = await submitVaultCall({ contractAddress, call: 'attest', params: { payloadHash, metadataHash }, compiledArtifactRef: ref }, args.idempotencyKey, extra);
343
+ if (!args.contentRoot || !args.schemaId)
344
+ return { ...anchor, payloadHash, metadataJson };
345
+ if (anchor.status !== 'succeeded') {
346
+ const rootNext = anchor.status === 'pending'
347
+ ? 'Run anchor_document again with the same arguments: it waits for this anchor and then anchors the content root.'
348
+ : 'The content root needs the anchor first.';
349
+ return { ...anchor, payloadHash, metadataJson, contentRoot: { status: 'not anchored', next: rootNext } };
628
350
  }
629
- return client.callAction('issueFieldPredicateAttestationBatch', {
630
- payloadHash: args.payloadHash,
631
- attesterId: args.attesterId,
632
- contentRoot: args.contentRoot,
633
- schemaId: args.schemaId,
634
- claims: args.claims.map((c) => 'threshold' in c ? { ...c, threshold: String(c.threshold) } : c),
635
- sessionId: args.sessionId,
636
- contractAddress: args.contractAddress,
637
- compiledArtifactRef: args.compiledArtifactRef ?? DEFAULT_ARTIFACT,
638
- idempotencyKey: args.idempotencyKey,
639
- sponsorSessionId: args.sponsorSessionId,
640
- });
351
+ const root = await submitVaultCall({ contractAddress, call: 'anchorContentRoot', params: { payloadHash, contentRoot: args.contentRoot, schemaId: args.schemaId }, compiledArtifactRef: ref }, args.idempotencyKey && `${args.idempotencyKey}:root`, extra);
352
+ return { ...root, payloadHash, metadataJson, anchorTxHash: anchor.txHash };
641
353
  }));
642
- server.registerTool('prove_document_integrity', {
643
- description: 'Prove that document B differs from anchored document A ONLY in the slots flagged by a ' +
644
- 'public slot mask, values hidden: the version-integrity claim ("v2 changed nothing ' +
645
- 'outside the allowed fields"). Both documents must be anchored, prepared with the SAME ' +
646
- 'ordered field list (identical schemaId) and you need BOTH full openings, which is why ' +
647
- 'this only works for a party that holds both documents. A mask that frees every real slot ' +
648
- 'is vacuous and rejected; a slot that changed, appeared or disappeared outside the mask ' +
649
- 'fails at local proving time with zero on-chain effect. ' + POLL_HINT,
650
- inputSchema: {
651
- payloadHashA: hex64('payloadHashA').describe('Anchored document A (64 hex)'),
652
- payloadHashB: hex64('payloadHashB').describe('Anchored document B; must differ from A'),
653
- attesterIdA: hex64('attesterIdA').optional()
654
- .describe('Document A\'s attester (64 hex); default the session\'s own'),
655
- attesterIdB: hex64('attesterIdB').optional()
656
- .describe('Document B\'s attester (64 hex); default attesterIdA'),
657
- allowedMask: z.number().int().min(0).max(MAX_MASK - 1)
658
- .describe('Packed slot mask, one bit per slot of the vault width (16 bits by default, 32 on attestation-vault-32), bit i = slot i MAY differ. At least one real schema slot must stay constrained'),
659
- schema: schemaSlots,
660
- openingA: documentOpening,
661
- openingB: documentOpening,
662
- sessionId: z.string().uuid().describe('Wallet session id that signs and submits'),
663
- contractAddress: z.string().min(1).describe('AttestationVault deployment'),
664
- contentRootA: hex64('contentRootA').optional().describe('Optional: anchor A\'s root in the same flow'),
665
- contentRootB: hex64('contentRootB').optional().describe('Optional: anchor B\'s root in the same flow'),
666
- schemaId: hex64('schemaId').optional().describe('Shared schema id, required whenever a contentRoot is supplied'),
667
- compiledArtifactRef: z.string().optional().describe("Contract artifact ref, defaults to 'attestation-vault-32'; 'attestation-vault' for a 16-slot vault"),
668
- idempotencyKey: z.string().optional().describe('Dedupes retries'),
669
- sponsorSessionId: z.string().uuid().optional().describe('Optional second session that pays the dust fee'),
670
- },
671
- }, run(async (args) => {
672
- if (maskFreesEveryRealSlot(args.allowedMask, args.schema))
673
- throw new Error(VACUOUS_MASK_MESSAGE);
674
- return client.callAction('issueDocumentIntegrityAttestation', {
675
- payloadHashA: args.payloadHashA,
676
- payloadHashB: args.payloadHashB,
677
- attesterIdA: args.attesterIdA,
678
- attesterIdB: args.attesterIdB,
679
- allowedMask: args.allowedMask,
680
- schema: args.schema,
681
- openingA: args.openingA,
682
- openingB: args.openingB,
683
- contentRootA: args.contentRootA,
684
- contentRootB: args.contentRootB,
685
- schemaId: args.schemaId,
686
- sessionId: args.sessionId,
687
- contractAddress: args.contractAddress,
688
- compiledArtifactRef: args.compiledArtifactRef ?? DEFAULT_ARTIFACT,
689
- idempotencyKey: args.idempotencyKey,
690
- sponsorSessionId: args.sponsorSessionId,
691
- });
692
- }));
693
- server.registerTool('prove_document_diff', {
694
- description: 'Prove that at least k of the aligned slots differ between two anchored documents ' +
695
- '(16 slots on the default vault, 32 on attestation-vault-32), ' +
696
- 'without revealing which slots or what values: the distinctness claim (k=1 is "provably ' +
697
- 'not the same document"). Same requirements as prove_document_integrity: identical field ' +
698
- 'list, both openings in hand. A difference is a value change or a field that appeared or ' +
699
- 'disappeared; both-empty slots compare equal and padding never counts. k above the real ' +
700
- 'count fails at local proving time with zero on-chain effect. ' + POLL_HINT,
701
- inputSchema: {
702
- payloadHashA: hex64('payloadHashA').describe('Anchored document A (64 hex)'),
703
- payloadHashB: hex64('payloadHashB').describe('Anchored document B; must differ from A'),
704
- attesterIdA: hex64('attesterIdA').optional()
705
- .describe('Document A\'s attester (64 hex); default the session\'s own'),
706
- attesterIdB: hex64('attesterIdB').optional()
707
- .describe('Document B\'s attester (64 hex); default attesterIdA'),
708
- k: z.number().int().min(1).max(MAX_SLOT_WIDTH).describe('Minimum number of differing slots to prove, up to the vault width'),
709
- schema: schemaSlots,
710
- openingA: documentOpening,
711
- openingB: documentOpening,
712
- sessionId: z.string().uuid().describe('Wallet session id that signs and submits'),
713
- contractAddress: z.string().min(1).describe('AttestationVault deployment'),
714
- contentRootA: hex64('contentRootA').optional().describe('Optional: anchor A\'s root in the same flow'),
715
- contentRootB: hex64('contentRootB').optional().describe('Optional: anchor B\'s root in the same flow'),
716
- schemaId: hex64('schemaId').optional().describe('Shared schema id, required whenever a contentRoot is supplied'),
717
- compiledArtifactRef: z.string().optional().describe("Contract artifact ref, defaults to 'attestation-vault-32'; 'attestation-vault' for a 16-slot vault"),
718
- idempotencyKey: z.string().optional().describe('Dedupes retries'),
719
- sponsorSessionId: z.string().uuid().optional().describe('Optional second session that pays the dust fee'),
720
- },
721
- }, run(async (args) => client.callAction('issueDocumentDiffAttestation', {
722
- payloadHashA: args.payloadHashA,
723
- payloadHashB: args.payloadHashB,
724
- attesterIdA: args.attesterIdA,
725
- attesterIdB: args.attesterIdB,
726
- k: args.k,
727
- schema: args.schema,
728
- openingA: args.openingA,
729
- openingB: args.openingB,
730
- contentRootA: args.contentRootA,
731
- contentRootB: args.contentRootB,
732
- schemaId: args.schemaId,
733
- sessionId: args.sessionId,
734
- contractAddress: args.contractAddress,
735
- compiledArtifactRef: args.compiledArtifactRef ?? DEFAULT_ARTIFACT,
736
- idempotencyKey: args.idempotencyKey,
737
- sponsorSessionId: args.sponsorSessionId,
738
- })));
739
- server.registerTool('grant_disclosure', {
740
- description: 'Grant a disclosure level for an attestation to a grantee identity, on-chain via the ' +
741
- 'AttestationVault. Attester-only: the transaction is rejected in-circuit unless the ' +
742
- 'session wallet is the original attester. level: 0=public, 1=legitimate-interest, ' +
743
- '2=authority. ' + POLL_HINT,
744
- inputSchema: {
745
- payloadHash: hex64('payloadHash').describe('The attestation payload hash (64 hex)'),
746
- grantee: hex64('grantee').describe('Grantee identity (64 hex Bytes<32>)'),
747
- level: z.union([z.literal(0), z.literal(1), z.literal(2)])
748
- .describe('0=public, 1=legitimate-interest, 2=authority'),
749
- sessionId: z.string().uuid().describe('Wallet session id (must be the attester)'),
750
- contractAddress: z.string().min(1).describe('AttestationVault deployment'),
751
- compiledArtifactRef: z.string().optional().describe("Contract artifact ref, defaults to 'attestation-vault-32'; 'attestation-vault' for a 16-slot vault"),
752
- idempotencyKey: z.string().optional().describe('Dedupes retries'),
753
- sponsorSessionId: z.string().uuid().optional().describe('Optional second session that pays the dust fee'),
754
- },
755
- }, run(async (args) => client.callAction('grantDisclosure', {
756
- payloadHash: args.payloadHash,
757
- grantee: args.grantee,
758
- level: args.level,
759
- sessionId: args.sessionId,
760
- contractAddress: args.contractAddress,
761
- compiledArtifactRef: args.compiledArtifactRef ?? DEFAULT_ARTIFACT,
762
- idempotencyKey: args.idempotencyKey,
763
- sponsorSessionId: args.sponsorSessionId,
764
- })));
765
- server.registerTool('revoke_disclosure', {
766
- description: 'Revoke a previously granted disclosure on-chain (removes the grantee entry). ' +
767
- 'Attester-only, enforced in-circuit. ' + POLL_HINT,
768
- inputSchema: {
769
- payloadHash: hex64('payloadHash').describe('The attestation payload hash (64 hex)'),
770
- grantee: hex64('grantee').describe('Grantee identity (64 hex Bytes<32>)'),
771
- sessionId: z.string().uuid().describe('Wallet session id (must be the attester)'),
772
- contractAddress: z.string().min(1).describe('AttestationVault deployment'),
773
- compiledArtifactRef: z.string().optional().describe("Contract artifact ref, defaults to 'attestation-vault-32'; 'attestation-vault' for a 16-slot vault"),
774
- idempotencyKey: z.string().optional().describe('Dedupes retries'),
775
- sponsorSessionId: z.string().uuid().optional().describe('Optional second session that pays the dust fee'),
354
+ server.registerTool('submit_vault_call', {
355
+ description: 'Run any AttestationVault call in one go: build, prove and sign it here with the caller ' +
356
+ 'seed, a sponsor pays, and the tool waits until it landed. Same calls and params as ' +
357
+ 'build_sponsorable_transaction: the ZK claims over a hidden field (proveFieldPredicate, ' +
358
+ 'proveFieldEquality, proveFieldMembership), the claims across two documents ' +
359
+ '(proveFieldsUnchangedExcept, proveFieldsDiffer), disclosure (grantDisclosure, ' +
360
+ 'revokeDisclosure), passports and anchoring. The witness from prepare_document_proof is ' +
361
+ 'used here and never sent. Returns { status, attesterId, contractAddress, txHash, ' +
362
+ 'transactionHash, check }; check is the free verify call that shows the effect on-chain. ' +
363
+ 'A failed job says in `next` what to do. ' + RETRY_HINT,
364
+ inputSchema: {
365
+ contractAddress: CONTRACT,
366
+ call: z.enum(BUILDABLE_CALLS).describe('Which circuit to call'),
367
+ params: z.record(z.union([z.string(), z.number()]))
368
+ .describe('The call parameters, listed per call in build_sponsorable_transaction'),
369
+ merkleProof: z.record(z.any()).optional()
370
+ .describe('WITNESS for the proveField* calls, as prepare_document_proof returns it for that field'),
371
+ docPair: z.record(z.any()).optional()
372
+ .describe('WITNESS { schema, openingA, openingB } for proveFieldsUnchangedExcept and proveFieldsDiffer'),
373
+ compiledArtifactRef: z.enum(BUILDABLE_ARTIFACTS).optional()
374
+ .describe("Vault lineage, default 'attestation-vault-32' (the sandbox vaults); 'attestation-vault' for a 16-slot vault"),
375
+ idempotencyKey: z.string().optional().describe('Dedupes retries of the same call'),
776
376
  },
777
- }, run(async (args) => client.callAction('revokeDisclosure', {
778
- payloadHash: args.payloadHash,
779
- grantee: args.grantee,
780
- sessionId: args.sessionId,
781
- contractAddress: args.contractAddress,
782
- compiledArtifactRef: args.compiledArtifactRef ?? DEFAULT_ARTIFACT,
783
- idempotencyKey: args.idempotencyKey,
784
- sponsorSessionId: args.sponsorSessionId,
785
- })));
377
+ }, run(async (args, extra) => submitVaultCall({
378
+ contractAddress: vault(args.contractAddress, args.compiledArtifactRef), call: args.call, params: args.params,
379
+ merkleProof: args.merkleProof, docPair: args.docPair, compiledArtifactRef: args.compiledArtifactRef ?? DEFAULT_ARTIFACT,
380
+ }, args.idempotencyKey, extra)));
786
381
  server.registerTool('build_sponsorable_transaction', {
787
- description: 'Build, prove and sign an AttestationVault call LOCALLY (caller half of cross-server fee ' +
788
- 'sponsoring, @odatano/nightgate-tx txbuilder): the seed comes from the MCP server ' +
789
- 'environment (NIGHTGATE_SEED_HEX), the attestation secret is derived from it, nothing ' +
790
- 'secret leaves this machine. The fee-unpaid transaction stays in this process under the ' +
382
+ description: 'Build, prove and sign an AttestationVault call LOCALLY and hand back the bytes, for a ' +
383
+ 'sponsor on another server or a later hand-over; submit_vault_call does build and submit ' +
384
+ 'in one go. The caller seed (NIGHTGATE_SEED_HEX, else the seed file) stays here, the ' +
385
+ 'attestation secret is derived from it, nothing secret leaves this machine. The fee-unpaid transaction stays in this process under the ' +
791
386
  'returned txId (its sha256; characters and bytes alongside), or is written to outFile: ' +
792
387
  'UNBOUND by default (hand the txId to sponsor_unbound_transaction, parallel channel) or ' +
793
388
  'bound with bind:true (sponsor_finalized_transaction). The on-chain effect carries the attester id ' +
@@ -814,7 +409,7 @@ export function registerTools(server, client, config) {
814
409
  'the transaction TTL (~30 min) and against the contract state at build time: if the ' +
815
410
  'sponsor job ends CHAIN_EXECUTION_FAILED, build again.',
816
411
  inputSchema: {
817
- contractAddress: z.string().min(1).describe('AttestationVault contract address (64 hex)'),
412
+ contractAddress: CONTRACT,
818
413
  call: z.enum(BUILDABLE_CALLS).describe('Which circuit to call'),
819
414
  params: z.record(z.union([z.string(), z.number()]))
820
415
  .describe('The call parameters (see the per-call list in the description)'),
@@ -833,13 +428,13 @@ export function registerTools(server, client, config) {
833
428
  if (!config)
834
429
  throw new Error('local building is not configured for this server instance');
835
430
  return buildSponsorable(config, {
836
- contractAddress: args.contractAddress, call: args.call, params: args.params, bind: args.bind === true,
431
+ contractAddress: vault(args.contractAddress, args.compiledArtifactRef), call: args.call, params: args.params, bind: args.bind === true,
837
432
  compiledArtifactRef: args.compiledArtifactRef ?? DEFAULT_ARTIFACT,
838
433
  merkleProof: args.merkleProof, docPair: args.docPair, outFile: args.outFile,
839
434
  });
840
435
  }));
841
436
  server.registerTool('get_attester_identity', {
842
- description: 'The attester id this MCP server builds under (derived from NIGHTGATE_SEED_HEX via the ' +
437
+ description: 'The attester id this MCP server builds under (derived from the caller seed via the ' +
843
438
  'txbuilder), plus the network, the shielded address and the two public shielded keys ' +
844
439
  '(what a sender needs to create a coin for this wallet). Use it to check ' +
845
440
  'verify_attestation results against the identity that will appear on-chain; builds ' +
@@ -859,8 +454,7 @@ export function registerTools(server, client, config) {
859
454
  'outside it. The transaction expires with its TTL (default 30 min), so hand it over ' +
860
455
  'promptly. This is the SERIAL channel (one tx per sponsor wallet at a time); for ' +
861
456
  'parallel submission build with bind:false and use sponsor_unbound_transaction. ' +
862
- 'The server picks the wallet that pays. Call get_job_status with the sessionId ' +
863
- 'RETURNED by this call. ' + POLL_HINT,
457
+ 'The server picks the wallet that pays. ' + WAIT_HINT,
864
458
  inputSchema: {
865
459
  txId: TX_ID.describe('txId returned by build_sponsorable_transaction (bind:true) on this server; the bytes never pass through the conversation'),
866
460
  file: TX_FILE,
@@ -869,13 +463,9 @@ export function registerTools(server, client, config) {
869
463
  idempotencyKey: z.string().optional()
870
464
  .describe('Dedupes retries; the request is also fingerprinted by the transaction bytes'),
871
465
  },
872
- }, run(async (args) => {
466
+ }, run(async (args, extra) => {
873
467
  const { b64, sent } = await resolveTxSource({ txId: args.txId, file: args.file, b64: args.finalizedTxB64 }, 'finalizedTxB64');
874
- const job = await client.callAction('sponsorFinalizedTransaction', {
875
- finalizedTxB64: b64,
876
- idempotencyKey: args.idempotencyKey,
877
- });
878
- return { ...job, sent };
468
+ return { ...await sponsorOnce('sponsorFinalizedTransaction', { finalizedTxB64: b64, idempotencyKey: args.idempotencyKey }, extra), sent };
879
469
  }));
880
470
  server.registerTool('sponsor_unbound_transaction', {
881
471
  description: 'Cross-server fee sponsoring, PARALLEL channel (NIGHTGATE 0.18.0): submit an UNBOUND ' +
@@ -889,8 +479,7 @@ export function registerTools(server, client, config) {
889
479
  'calls against the SAME contract in one block conflict, the loser lands on-chain but its ' +
890
480
  'call does not apply and the job ends failed with errorCode CHAIN_EXECUTION_FAILED (the ' +
891
481
  'transaction hash is in the error); then REBUILD the transaction against the current ' +
892
- 'contract state and sponsor again (never resubmit the same bytes). Call get_job_status ' +
893
- 'with the sessionId RETURNED by this call. ' + POLL_HINT,
482
+ 'contract state and sponsor again (never resubmit the same bytes). ' + WAIT_HINT,
894
483
  inputSchema: {
895
484
  txId: TX_ID.describe('txId returned by build_sponsorable_transaction on this server; the bytes never pass through the conversation'),
896
485
  file: TX_FILE,
@@ -899,16 +488,12 @@ export function registerTools(server, client, config) {
899
488
  idempotencyKey: z.string().optional()
900
489
  .describe('Dedupes retries of the SAME bytes; a rebuilt transaction is a new key'),
901
490
  },
902
- }, run(async (args) => {
491
+ }, run(async (args, extra) => {
903
492
  const { b64, sent } = await resolveTxSource({ txId: args.txId, file: args.file, b64: args.unboundTxB64 }, 'unboundTxB64');
904
- const job = await client.callAction('sponsorUnboundTransaction', {
905
- unboundTxB64: b64,
906
- idempotencyKey: args.idempotencyKey,
907
- });
908
- return { ...job, sent };
493
+ return { ...await sponsorOnce('sponsorUnboundTransaction', { unboundTxB64: b64, idempotencyKey: args.idempotencyKey }, extra), sent };
909
494
  }));
910
495
  server.registerTool('get_swap_wallet', {
911
- description: 'The shielded wallet this MCP server swaps from (seed from NIGHTGATE_SEED_HEX): address, ' +
496
+ description: 'The shielded wallet this MCP server swaps from (the caller seed): address, ' +
912
497
  'public keys, and per token type the balance, the number of free coins and `spendable`, ' +
913
498
  'the most ONE swap half can give (what its largest coins hold, up to maxInputs coins). ' +
914
499
  'Also lists the halves built here that are not handed over yet; one posted with ' +
@@ -922,6 +507,15 @@ export function registerTools(server, client, config) {
922
507
  for (const offerId of postedOfferIds()) {
923
508
  settleBoardRows(await client.callFunction('getSwapOffer', { offerId: raw(offerId) }).catch(() => undefined));
924
509
  }
510
+ // a swap job that was still open when its tool returned may have landed since
511
+ for (const jobId of halfJobIds()) {
512
+ const sessionId = jobSession(jobId);
513
+ if (!sessionId)
514
+ continue;
515
+ const job = await client.callAction('getJobStatus', { jobId, sessionId }).catch(() => undefined);
516
+ if (job)
517
+ settleHalfJob(jobId, job.status);
518
+ }
925
519
  return swapWalletInfo(config);
926
520
  }));
927
521
  server.registerTool('read_swap_offer', {
@@ -959,9 +553,9 @@ export function registerTools(server, client, config) {
959
553
  description: SWAP_HINT + ' This takes an offer: it reads the offer\'s terms from the transaction, ' +
960
554
  'refuses when they differ from `expect`, builds and proves the mirror half from this ' +
961
555
  'server\'s wallet (it spends what the offer wants and creates what the offer gives). ' +
962
- 'With submit: true the two halves go to the sponsor at once and the answer is the ' +
963
- 'job ({ jobId, status, sessionId, id, terms }): call get_job_status with the returned ' +
964
- 'sessionId. Without it the answer carries both halves for sponsor_swap. ALWAYS pass ' +
556
+ 'With submit: true the two halves go to the sponsor at once and the tool waits until ' +
557
+ 'the swap landed ({ status, txHash, transactionHash, result.swap, id, terms }). ' +
558
+ 'Without it the answer carries both halves for sponsor_swap. ALWAYS pass ' +
965
559
  '`expect` with the terms you agreed to: an offer is text from someone else. With offerId ' +
966
560
  'the board entry is read first and the call refuses, before proving, when it is not open ' +
967
561
  'or carries another offer than `offer`. A sponsor that refuses the swap leaves the proven ' +
@@ -978,45 +572,55 @@ export function registerTools(server, client, config) {
978
572
  offerId: z.string().uuid().optional()
979
573
  .describe('The board entry the offer came from (list_swap_offers), so the fill is recorded against it'),
980
574
  },
981
- }, run(async (args) => {
575
+ }, run(async (args, extra) => {
982
576
  if (!config)
983
577
  throw new Error('swaps are not configured for this server instance');
984
- if (args.offerId) {
985
- const row = await client.callFunction('getSwapOffer', { offerId: raw(args.offerId) });
986
- if (row.status !== 'open')
987
- throw new Error(`board offer ${args.offerId} is ${String(row.status)}, not open`);
988
- if (typeof row.offer !== 'string' || !(await sameOffer(args.offer, row.offer))) {
989
- throw new Error(`board offer ${args.offerId} carries another offer than the one passed: take the board's text, or drop offerId`);
578
+ const take = async () => {
579
+ if (args.offerId) {
580
+ const row = await client.callFunction('getSwapOffer', { offerId: raw(args.offerId) });
581
+ if (row.status !== 'open')
582
+ throw new Error(`board offer ${args.offerId} is ${String(row.status)}, not open`);
583
+ if (typeof row.offer !== 'string' || !(await sameOffer(args.offer, row.offer))) {
584
+ throw new Error(`board offer ${args.offerId} carries another offer than the one passed: take the board's text, or drop offerId`);
585
+ }
990
586
  }
991
- }
992
- const taken = await takeOffer(config, {
993
- offer: args.offer,
994
- ...(args.expect ? { expect: { gives: legOut(args.expect.gives), wants: legOut(args.expect.wants) } } : {}),
995
- });
996
- if (args.submit !== true)
997
- return taken;
998
- let job;
999
- try {
1000
- job = await client.callAction('sponsorSwap', {
1001
- ...(args.offerId ? { offerId: args.offerId } : { makerHalfB64: taken.makerHalfB64 }),
1002
- takerHalfB64: taken.takerHalfB64,
1003
- idempotencyKey: args.idempotencyKey,
587
+ return takeOffer(config, {
588
+ offer: args.offer,
589
+ ...(args.expect ? { expect: { gives: legOut(args.expect.gives), wants: legOut(args.expect.wants) } } : {}),
1004
590
  });
1005
- }
1006
- catch (err) {
1007
- if (!(err instanceof NightgateApiError))
1008
- throw err;
1009
- throw new NightgateApiError(err.status, err.code, err.message, { ...(err.detail ?? {}), halfId: taken.id });
1010
- }
1011
- trackHalfJob(taken.id, job.jobId);
1012
- return { ...job, id: taken.id, terms: taken.terms, bound: taken.bound, buildMs: taken.buildMs };
591
+ };
592
+ if (args.submit !== true)
593
+ return take();
594
+ const key = callKey('takeSwapOffer', args.offer, args.expect, args.offerId);
595
+ const started = await startOnce(key, async () => {
596
+ const taken = await take();
597
+ let job;
598
+ try {
599
+ job = await client.callAction('sponsorSwap', {
600
+ ...(args.offerId ? { offerId: args.offerId } : { makerHalfB64: taken.makerHalfB64 }),
601
+ takerHalfB64: taken.takerHalfB64,
602
+ idempotencyKey: args.idempotencyKey,
603
+ });
604
+ }
605
+ catch (err) {
606
+ if (!(err instanceof NightgateApiError))
607
+ throw err;
608
+ throw new NightgateApiError(err.status, err.code, err.message, { ...(err.detail ?? {}), halfId: taken.id });
609
+ }
610
+ trackHalfJob(taken.id, job.jobId);
611
+ return { ...job, id: taken.id, terms: taken.terms, bound: taken.bound, buildMs: taken.buildMs };
612
+ });
613
+ const settled = await awaitSwap(started, args.offerId, extra);
614
+ if (settled.status === 'failed')
615
+ forgetStart(key);
616
+ return settled;
1013
617
  }));
1014
618
  server.registerTool('revert_swap_offer', {
1015
619
  description: 'Release the coins of a swap half this server built (build_swap_offer or take_swap_offer) ' +
1016
620
  'that is not going to be used. Until then, or until the swap lands, the coins count as ' +
1017
621
  'spent in this wallet. It does not recall an offer somebody already holds: to make a ' +
1018
622
  'published offer worthless, spend its coins in another swap. Ids are listed by ' +
1019
- 'get_swap_wallet; a half whose swap landed leaves the list when get_job_status reports it.',
623
+ 'get_swap_wallet; a half whose swap landed leaves the list.',
1020
624
  inputSchema: {
1021
625
  id: z.string().regex(/^[0-9a-f]{32}$/, 'id must be the 32 hex characters a swap tool returned'),
1022
626
  },
@@ -1036,7 +640,7 @@ export function registerTools(server, client, config) {
1036
640
  'An offer fills once: if it was taken first by somebody else the job fails. Pass both ' +
1037
641
  'halves, or `halfId` alone for an offer this server took (take_swap_offer keeps both ' +
1038
642
  'halves); with `offerId` the maker half comes from the board, next to takerHalf or the ' +
1039
- 'halfId of the taken offer. Call get_job_status with the sessionId RETURNED by this call. ' + POLL_HINT,
643
+ 'halfId of the taken offer. ' + WAIT_HINT,
1040
644
  inputSchema: {
1041
645
  makerHalf: swapHalf("The maker's half (the offer)").optional(),
1042
646
  takerHalf: swapHalf("The taker's half").optional(),
@@ -1047,7 +651,7 @@ export function registerTools(server, client, config) {
1047
651
  offerId: z.string().uuid().optional()
1048
652
  .describe('An offer on the server\'s board (list_swap_offers): the server takes the maker half from there; pass takerHalf or the halfId of the taken offer with it'),
1049
653
  },
1050
- }, run(async (args) => {
654
+ }, run(async (args, extra) => {
1051
655
  let halves;
1052
656
  if (args.offerId) {
1053
657
  if (args.makerHalf)
@@ -1066,13 +670,17 @@ export function registerTools(server, client, config) {
1066
670
  ? { makerHalfB64: args.makerHalf, takerHalfB64: args.takerHalf }
1067
671
  : takenHalves(args.halfId);
1068
672
  }
1069
- const job = await client.callAction('sponsorSwap', {
1070
- ...halves,
1071
- idempotencyKey: args.idempotencyKey,
673
+ const key = callKey('sponsorSwap', halves);
674
+ const started = await startOnce(key, async () => {
675
+ const job = await client.callAction('sponsorSwap', { ...halves, idempotencyKey: args.idempotencyKey });
676
+ if (args.halfId)
677
+ trackHalfJob(args.halfId, job?.jobId);
678
+ return job;
1072
679
  });
1073
- if (args.halfId)
1074
- trackHalfJob(args.halfId, job?.jobId);
1075
- return job;
680
+ const settled = await awaitSwap(started, args.offerId, extra);
681
+ if (settled.status === 'failed')
682
+ forgetStart(key);
683
+ return settled;
1076
684
  }));
1077
685
  server.registerTool('post_swap_offer', {
1078
686
  description: 'Post a maker half on the server\'s offer board, so takers find it with list_swap_offers ' +
@@ -1143,66 +751,6 @@ export function registerTools(server, client, config) {
1143
751
  offerId: z.string().uuid().describe('The board entry'),
1144
752
  },
1145
753
  }, run(async (args) => client.callAction('retireSwapOffer', { offerId: args.offerId })));
1146
- server.registerTool('grant_disclosure_to_holders', {
1147
- description: 'Disclose a document to everyone who holds a token: whoever registered a claim key on ' +
1148
- 'the holder-registry contract for tokenType by passing a coin through it can read the ' +
1149
- 'text with claim_disclosure, and the server never learns who. content must hash to ' +
1150
- 'payloadHash (blake2b-256 or sha256 of the UTF-8 text); it is stored encrypted. A repeat ' +
1151
- 'call for the same payload, token type and registry updates the grant (content kept ' +
1152
- 'when omitted). Returns { holderGrantId, hasContent, expiresAt, status }.',
1153
- inputSchema: {
1154
- payloadHash: hex64('payloadHash').describe('Hash of the document (64 hex)'),
1155
- tokenType: hex64('tokenType').describe('The raw shielded token type whose holders may read (64 hex)'),
1156
- registryAddress: hex64('registryAddress').describe('The holder-registry deployment holders registered on'),
1157
- content: z.string().max(1_048_576).optional().describe('The document text; must hash to payloadHash'),
1158
- contentType: z.string().max(100).optional().describe("Default 'text/plain'"),
1159
- expiresAt: z.string().datetime().optional().describe('ISO timestamp, at most one year ahead'),
1160
- },
1161
- }, run(async (args) => client.callAction('grantDisclosureToHolders', {
1162
- payloadHash: args.payloadHash.toLowerCase(),
1163
- tokenType: args.tokenType.toLowerCase(),
1164
- registryAddress: args.registryAddress.toLowerCase(),
1165
- content: args.content,
1166
- contentType: args.contentType,
1167
- expiresAt: args.expiresAt,
1168
- })));
1169
- server.registerTool('revoke_holder_disclosure', {
1170
- description: 'Revoke a disclosure to token holders. Only its grantor may. Returns { holderGrantId, status }.',
1171
- inputSchema: {
1172
- holderGrantId: z.string().uuid().describe('From grant_disclosure_to_holders'),
1173
- },
1174
- }, run(async (args) => client.callAction('revokeHolderDisclosure', { holderGrantId: args.holderGrantId })));
1175
- server.registerTool('claim_disclosure', {
1176
- description: 'Read a document disclosed to the holders of a token. claimSecret is the 32-byte secret ' +
1177
- 'behind the claim key registered on the holder-registry contract (holder_claim_key); the ' +
1178
- 'server reads the registry live and answers { entitled, content, contentType, ' +
1179
- 'contentHashKind, expiresAt } or { entitled: false, reason }. Needs a live indexer on ' +
1180
- 'the server.',
1181
- inputSchema: {
1182
- payloadHash: hex64('payloadHash').describe('Hash of the document (64 hex)'),
1183
- tokenType: hex64('tokenType').describe('The raw shielded token type (64 hex)'),
1184
- claimSecret: hex64('claimSecret').describe('The secret behind the registered claim key (64 hex)'),
1185
- },
1186
- }, run(async (args) => client.callAction('claimDisclosure', {
1187
- payloadHash: args.payloadHash.toLowerCase(),
1188
- tokenType: args.tokenType.toLowerCase(),
1189
- claimSecret: args.claimSecret.toLowerCase(),
1190
- })));
1191
- server.registerTool('holder_claim_key', {
1192
- description: 'The claim key a token holder registers on the holder-registry contract, derived from a ' +
1193
- '32-byte secret (compute-only). Without a secret a fresh random one is generated: keep ' +
1194
- 'it, claim_disclosure needs it. Register the key with registerHolder(coin, claimKey) on ' +
1195
- 'the registry (one coin of the token type passes through the contract and comes back), ' +
1196
- 'e.g. through build_sponsorable_transaction on a builder that knows the registry.',
1197
- inputSchema: {
1198
- claimSecret: hex64('claimSecret').optional().describe('Existing secret (64 hex); omit to generate one'),
1199
- },
1200
- }, run(async (args) => {
1201
- const holderClaimKey = await txExport('holderClaimKey', 'holder_claim_key', '0.10.0');
1202
- const { randomBytes } = await import('node:crypto');
1203
- const claimSecret = (args.claimSecret ?? randomBytes(32).toString('hex')).toLowerCase();
1204
- return { claimSecret, claimKey: holderClaimKey(claimSecret) };
1205
- }));
1206
754
  server.registerTool('derive_token_type', {
1207
755
  description: 'Derive the raw token type a minting contract produces (compute-only, no wallet, no ' +
1208
756
  'chain access). A custom token is addressed by rawTokenType(domainSeparator, ' +
@@ -1219,18 +767,14 @@ export function registerTools(server, client, config) {
1219
767
  domainSeparator: args.domainSeparator,
1220
768
  })));
1221
769
  server.registerTool('mint_token', {
1222
- description: 'Mint a shielded token with a name of your own on a token-factory deployment. Two ways. ' +
1223
- 'LOCAL (default, no sessionId): this process is the issuer, its issuer secret derives from ' +
1224
- 'NIGHTGATE_SEED_HEX, the mint is built, proven and signed here and the fee-unpaid ' +
1225
- 'transaction goes to the sponsor like sponsor_unbound_transaction; the seed never ' +
1226
- 'leaves this process. The factory has to be ' +
1227
- 'on the sponsor\'s contract list and mint on its circuit list. SERVER (sessionId given): the ' +
1228
- 'server session is the issuer and mints through mintFactoryToken; needs that action in ' +
1229
- 'the grant. Either way the same name from another issuer is another token, and the ' +
1230
- 'response names the token before the job runs ({ tokenType, domain, issuerKey }); a ' +
1231
- 'landed mint makes the type known to the sponsor (swaps, offer board). The recipient ' +
1232
- 'defaults to this wallet (get_attester_identity); another wallet takes both of its keys. ' +
1233
- 'Job result { txHash, transactionHash, ... }. Call get_job_status with the sessionId RETURNED by this call. ' + POLL_HINT,
770
+ description: 'Mint a shielded token with a name of your own on a token-factory deployment. This ' +
771
+ 'process is the issuer: its issuer secret derives from the caller seed, the mint is ' +
772
+ 'built, proven and signed here, and a sponsor pays; the seed never leaves this process. ' +
773
+ 'The factory has to be on the sponsor\'s contract list and mint on its circuit list. ' +
774
+ 'The same name from another issuer is another token. The answer names the token ' +
775
+ '({ tokenType, domain, issuerKey }); a landed mint makes the type known to the sponsor ' +
776
+ '(swaps, offer board). The recipient defaults to this wallet (get_attester_identity); ' +
777
+ 'another wallet takes both of its keys. ' + WAIT_HINT,
1234
778
  inputSchema: {
1235
779
  contractAddress: hex64('contractAddress').describe('A token-factory deployment'),
1236
780
  name: z.string().min(1).refine((s) => Buffer.byteLength(s, 'utf8') <= 32, 'name must be at most 32 UTF-8 bytes')
@@ -1242,92 +786,46 @@ export function registerTools(server, client, config) {
1242
786
  recipientCoinPublicKey: hex64('recipientCoinPublicKey').optional()
1243
787
  .describe('Zswap coin public key of the receiving wallet (64 hex); default this wallet\'s own'),
1244
788
  recipientEncryptionPublicKey: hex64('recipientEncryptionPublicKey').optional()
1245
- .describe('The receiving wallet\'s encryption public key (64 hex); required with a recipient other than this wallet, local way'),
1246
- sessionId: z.string().uuid().optional()
1247
- .describe('Server way only: the wallet session that mints; it is the issuer'),
789
+ .describe('The receiving wallet\'s encryption public key (64 hex); required with a recipient other than this wallet'),
1248
790
  idempotencyKey: z.string().optional().describe('Dedupes retries'),
1249
791
  },
1250
- }, run(async (args) => {
1251
- if (args.sessionId) {
1252
- let recipient = args.recipientCoinPublicKey;
1253
- if (!recipient) {
1254
- if (!config?.seedHex)
1255
- throw new Error('mint_token needs recipientCoinPublicKey: no NIGHTGATE_SEED_HEX for a default');
1256
- recipient = (await seedIdentity(config)).coinPublicKey;
1257
- if (!recipient)
1258
- throw new Error('mint_token needs recipientCoinPublicKey: the seed exposes no shielded keys');
1259
- }
1260
- return client.callAction('mintFactoryToken', {
1261
- contractAddress: args.contractAddress.toLowerCase(),
1262
- name: args.name,
1263
- amount: String(args.amount),
1264
- recipientCoinPublicKey: recipient.toLowerCase(),
1265
- sessionId: args.sessionId,
1266
- idempotencyKey: args.idempotencyKey,
1267
- });
1268
- }
792
+ }, run(async (args, extra) => {
1269
793
  if (!config)
1270
794
  throw new Error('local minting is not configured for this server instance');
1271
- const built = await buildMint(config, {
795
+ const input = {
1272
796
  contractAddress: args.contractAddress,
1273
797
  name: args.name,
1274
798
  amount: String(args.amount),
1275
799
  recipientCoinPublicKey: args.recipientCoinPublicKey,
1276
800
  recipientEncryptionPublicKey: args.recipientEncryptionPublicKey,
801
+ };
802
+ const key = callKey('mintToken', input);
803
+ const started = await startOnce(key, async () => {
804
+ const { unboundTxB64, ...built } = await buildMint(config, input);
805
+ const job = await client.callAction('sponsorUnboundTransaction', { unboundTxB64, idempotencyKey: args.idempotencyKey });
806
+ return { ...built, ...job };
1277
807
  });
1278
- const job = await client.callAction('sponsorUnboundTransaction', {
1279
- unboundTxB64: built.unboundTxB64,
1280
- idempotencyKey: args.idempotencyKey,
1281
- });
1282
- const { unboundTxB64: _tx, ...rest } = built;
1283
- return { ...job, ...rest, channel: 'local' };
808
+ const job = await awaitJob(client, config, started, extra, { tool: 'get_swap_wallet', args: {} });
809
+ if (job.status === 'failed')
810
+ forgetStart(key);
811
+ return job;
1284
812
  }));
1285
- server.registerTool('get_grant_usage', {
1286
- description: 'What this key did and what it may still do: jobs by kind and status, landed, failed, dustPaid, ' +
1287
- 'and limits { jobsPerHour, jobsPerDay: { max, used, resetsAt }, classes: [{ name, windowSeconds, max, remaining }] }. ' +
1288
- 'Read it before a burst and after a 429 (the error carries retryAfterSeconds). Default window: the last 30 days.',
813
+ server.registerTool('get_usage', {
814
+ description: 'What this key has left and what it spent. With a gateway key (oda_...): unitsLeft, ' +
815
+ 'validUntil, status, usage today and over 30 days, and the last calls with the units each ' +
816
+ 'one cost (ledger). Free. With an agent grant of a direct instance: jobs by kind and ' +
817
+ 'status and the limits { jobsPerHour, jobsPerDay, classes }. Read it before a burst and ' +
818
+ 'after a 429 (the error carries retryAfterSeconds).',
1289
819
  inputSchema: {
1290
- since: z.string().datetime().optional().describe('ISO timestamp; start of the usage window'),
1291
- until: z.string().datetime().optional().describe('ISO timestamp; end of the usage window'),
1292
- },
1293
- }, run(async (args) => client.callFunction('getGrantUsage', { grantId: null, since: args.since ?? null, until: args.until ?? null })));
1294
- server.registerTool('get_job_status', {
1295
- description: 'Status of an async NIGHTGATE job (all submit actions return a jobId). ' +
1296
- 'status: pending | running | external_execution | submitted | reconciliation_required | ' +
1297
- 'succeeded | failed. The server holds the call up to waitMs (default 20 s) until the job ' +
1298
- 'settles, so call it again only if the status is still open; result carries the job outcome ' +
1299
- 'JSON, chainStatus tracks on-chain finalization independently. txHash and transactionId are ' +
1300
- 'the ledger identifier (stable, tracks the job); transactionHash is the hash explorers show, ' +
1301
- 'null until indexed. ' +
1302
- 'Sponsor jobs: failed + errorCode CHAIN_EXECUTION_FAILED = the transaction IS on-chain but ' +
1303
- 'the contract call did not apply (same-contract conflict) -> rebuild and sponsor again; ' +
1304
- 'reconciliation_required = broadcast outcome unknown to the server yet, the transaction ' +
1305
- 'identifier is in the error message, the server keeps resolving it via the indexer. ' +
1306
- 'Batches: once confirmed, chainSegments ([{ segment, calls, applied }]) says which calls ' +
1307
- 'applied; resend only the ones with applied false. Swaps: the result carries swap ' +
1308
- '{ gives, wants }, what was exchanged from the maker\'s side. Mints: tokenType.',
1309
- inputSchema: {
1310
- jobId: z.string().uuid().describe('Job id returned by a submit action'),
1311
- sessionId: z.string().uuid()
1312
- .describe('Wallet session id the job belongs to; for sponsor jobs the sessionId RETURNED by the sponsor call'),
1313
- waitMs: z.number().int().min(0).max(25_000).optional()
1314
- .describe('ms the server waits for the job to settle before it answers; default 20000, 0 answers at once'),
820
+ since: z.string().datetime().optional().describe('Agent grants only: ISO timestamp, start of the window; default 30 days back'),
821
+ until: z.string().datetime().optional().describe('Agent grants only: ISO timestamp, end of the window'),
1315
822
  },
1316
823
  }, run(async (args) => {
1317
- const ask = { jobId: args.jobId, sessionId: args.sessionId };
1318
- const waitMs = jobWait ? Math.min(args.waitMs ?? JOB_WAIT_MS, Math.max(0, (config?.timeoutMs ?? 30_000) - 5_000)) : 0;
1319
- let job;
1320
- try {
1321
- job = await client.callAction('getJobStatus', waitMs > 0 ? { ...ask, waitMs } : ask);
1322
- }
1323
- catch (err) {
1324
- if (!(waitMs > 0 && err instanceof NightgateApiError && err.status === 400 && /waitMs/.test(err.message)))
1325
- throw err;
1326
- jobWait = false;
1327
- job = await client.callAction('getJobStatus', ask);
1328
- }
1329
- settleHalfJob(args.jobId, job?.status);
1330
- return job;
824
+ if (!config?.token && !config?.username)
825
+ throw new Error('get_usage needs a key: set ODATANO_ACCESS_KEY (an oda_... key from https://api.odatano.dev)');
826
+ if (config && isGatewayKey(config))
827
+ return client.gatewayMe();
828
+ return client.callFunction('getGrantUsage', { grantId: null, since: args.since ?? null, until: args.until ?? null });
1331
829
  }));
1332
830
  }
1333
831
  /** listSwapOffers wants all seven parameters, null for the unused ones. */
@@ -1354,20 +852,44 @@ function listOffers(client, args) {
1354
852
  */
1355
853
  export function wrapHandler(_client) {
1356
854
  return function run(fn) {
1357
- return async (args) => {
855
+ return async (args, extra) => {
856
+ const notice = {};
857
+ const stopProgress = startProgress(extra);
1358
858
  try {
1359
- const result = await fn(args);
1360
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
859
+ const result = await seedNotice.run(notice, () => fn(args, extra));
860
+ const seed = notice.createdFile ? seedCreated(notice.createdFile) : undefined;
861
+ const out = seed && result && typeof result === 'object' && !Array.isArray(result) ? { ...result, seed } : result;
862
+ return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
1361
863
  }
1362
864
  catch (err) {
865
+ const seed = notice.createdFile ? seedCreated(notice.createdFile) : undefined;
1363
866
  if (err instanceof NightgateApiError) {
1364
- const detail = { httpStatus: err.status, code: err.code ?? null, message: err.message, ...(err.detail ?? {}) };
867
+ const detail = { httpStatus: err.status, code: err.code ?? null, message: err.message, ...(err.detail ?? {}), ...(seed ? { seed } : {}) };
1365
868
  return { content: [{ type: 'text', text: JSON.stringify(detail, null, 2) }], isError: true };
1366
869
  }
1367
870
  const message = err instanceof Error ? err.message : String(err);
1368
- return { content: [{ type: 'text', text: message }], isError: true };
871
+ return { content: [{ type: 'text', text: seed ? `${message}\n${JSON.stringify({ seed }, null, 2)}` : message }], isError: true };
872
+ }
873
+ finally {
874
+ stopProgress();
1369
875
  }
1370
876
  };
1371
877
  };
1372
878
  }
879
+ /** Progress every 5 s while a call runs, when the client asked for it with a progress token. */
880
+ function startProgress(extra) {
881
+ const token = extra?._meta?.progressToken;
882
+ if (token === undefined || !extra)
883
+ return () => { };
884
+ const t0 = Date.now();
885
+ let n = 0;
886
+ const timer = setInterval(() => {
887
+ extra.sendNotification({
888
+ method: 'notifications/progress',
889
+ params: { progressToken: token, progress: ++n, message: `working, ${Math.round((Date.now() - t0) / 1000)} s` },
890
+ }).catch(() => undefined);
891
+ }, 5_000);
892
+ timer.unref?.();
893
+ return () => clearInterval(timer);
894
+ }
1373
895
  //# sourceMappingURL=tools.js.map