@odatano/nightgate-mcp 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { z } from 'zod';
2
- import { int64, NightgateApiError } from './client.js';
3
- import { BUILDABLE_CALLS, BUILDABLE_ARTIFACTS, buildSponsorable, attesterIdentity } from './builder.js';
2
+ import { int64, raw, NightgateApiError } from './client.js';
3
+ import { BUILDABLE_CALLS, BUILDABLE_ARTIFACTS, buildSponsorable, attesterIdentity, seedIdentity } from './builder.js';
4
+ import { buildOffer, readOffer, revertHalf, sameOffer, settleHalfJob, swapWalletInfo, takenHalves, takeOffer, trackHalfJob } from './swap.js';
5
+ import { txExport } from './tx-module.js';
6
+ import { buildMint } from './factory.js';
4
7
  const HEX64 = /^[0-9a-fA-F]{64}$/;
5
8
  const hex64 = (what) => z.string().regex(HEX64, `${what} must be 64 hex characters`);
6
- /** Scaled non-negative integer, as string (preferred, exact) or JS number. */
9
+ /** Scaled non-negative integer, as string (preferred, exact) or JS number; a number stays below 2^53 so String() renders it exactly. */
7
10
  const scaledInt = (what) => z.union([
8
11
  z.string().regex(/^\d+$/, `${what} must be a non-negative integer (decimal string)`),
9
- z.number().int().nonnegative(),
12
+ z.number().int().nonnegative().safe(),
10
13
  ]);
11
14
  const merkleSiblings = z.array(hex64('sibling')).length(4)
12
15
  .describe('Depth-4 Merkle inclusion path: exactly 4 sibling digests (64 hex each)');
@@ -19,6 +22,25 @@ const setDirs = z.array(z.boolean()).length(6)
19
22
  const POLL_HINT = 'Async: returns { jobId, status } immediately; poll get_job_status until succeeded or failed.';
20
23
  /** NIGHTGATE's platform sponsor pool id (0.17.2+): the server picks a free pool sponsor. */
21
24
  const PLATFORM_POOL_SENTINEL = '00000000-0000-0000-0000-706f6f6c0000';
25
+ /** One side of a swap: a raw token type and an amount in atoms. */
26
+ const swapLeg = (what) => z.object({
27
+ tokenType: hex64(`${what}.tokenType`).describe('Raw token type (64 hex), what derive_token_type returns'),
28
+ amount: z.union([
29
+ z.string().regex(/^[1-9]\d*$/, `${what}.amount must be a positive integer (decimal string)`),
30
+ z.number().int().positive().safe(),
31
+ ]).describe('Amount in atoms; a decimal string is exact'),
32
+ });
33
+ const legOut = (leg) => ({ tokenType: leg.tokenType.toLowerCase(), amount: String(leg.amount) });
34
+ /** A swap half as it is handed over: offer file text or base64 of the serialized transaction. */
35
+ const swapHalf = (what) => z.string().min(16)
36
+ .describe(`${what}: offer file text (swapoffer1...) or base64 of the serialized, proven transaction`);
37
+ const SWAP_HINT = 'A swap settles two shielded tokens between two wallets without a contract: each side builds one half ' +
38
+ '(it spends the coin it gives and creates the coin it wants), the two mirrored halves merge into one transaction, ' +
39
+ 'and a sponsor pays the fee.';
40
+ const OFFER_STATUS = z.enum(['open', 'filled', 'retired', 'expired', 'all']);
41
+ const BOARD_ROW_HINT = 'Each row: offerId, the offer half (feed it to read_swap_offer or take_swap_offer), gives/wants ' +
42
+ 'type and amount, tags, expiresAt, postedAt, status, filledTxHash, closedAt, changedAt. Never the poster or the ' +
43
+ "half's nullifiers.";
22
44
  /**
23
45
  * Per-slot salt of a proof field. Content-tree leaves are SALTED, so every
24
46
  * single-field proof needs the slot's salt from prepare_document_proof
@@ -809,8 +831,10 @@ export function registerTools(server, client, config) {
809
831
  }));
810
832
  server.registerTool('get_attester_identity', {
811
833
  description: 'The attester id this MCP server builds under (derived from NIGHTGATE_SEED_HEX via the ' +
812
- 'txbuilder), plus the network. Use it to check verify_attestation results against the ' +
813
- 'identity that will appear on-chain; builds nothing and submits nothing.',
834
+ 'txbuilder), plus the network, the shielded address and the two public shielded keys ' +
835
+ '(what a sender needs to create a coin for this wallet). Use it to check ' +
836
+ 'verify_attestation results against the identity that will appear on-chain; builds ' +
837
+ 'nothing and submits nothing.',
814
838
  inputSchema: {},
815
839
  }, run(async () => {
816
840
  if (!config)
@@ -870,6 +894,299 @@ export function registerTools(server, client, config) {
870
894
  sponsorSessionId: args.sponsorSessionId,
871
895
  idempotencyKey: args.idempotencyKey,
872
896
  })));
897
+ server.registerTool('get_swap_wallet', {
898
+ description: 'The shielded wallet this MCP server swaps from (seed from NIGHTGATE_SEED_HEX): address, ' +
899
+ 'public keys, and per token type the balance, the number of free coins and `spendable`, ' +
900
+ 'the most ONE swap half can give (what its largest coins hold, up to maxInputs coins). ' +
901
+ 'Also lists the halves built here that are not handed over yet. The wallet syncs its ' +
902
+ 'shielded coins only; a first sync from genesis takes about 5 minutes, during which the ' +
903
+ 'answer is { synced: false, syncingSeconds }: call again. Spends nothing.',
904
+ inputSchema: {},
905
+ }, run(async () => {
906
+ if (!config)
907
+ throw new Error('swaps are not configured for this server instance');
908
+ return swapWalletInfo(config);
909
+ }));
910
+ server.registerTool('read_swap_offer', {
911
+ description: SWAP_HINT + ' This reads what an offer gives and wants FROM THE TRANSACTION it carries, ' +
912
+ 'never from what its maker says: gives / wants (token type and amount, from the ' +
913
+ "maker's side), the coins it carries, and whether it is bound. Refuses anything that is " +
914
+ 'not a plain swap half (a contract call, unshielded value, more than one token type ' +
915
+ 'given or wanted). Needs no wallet and no network. Check the terms here before take_swap_offer.',
916
+ inputSchema: {
917
+ offer: swapHalf('The offer'),
918
+ },
919
+ }, run(async (args) => readOffer(args.offer)));
920
+ server.registerTool('build_swap_offer', {
921
+ description: SWAP_HINT + ' This builds and proves ONE half from this server\'s wallet: it spends `give` ' +
922
+ 'and creates `want` (and the change) for this wallet. Returns the offer file ' +
923
+ '(`offer`, text starting with swapoffer1, about 25 000 characters) to publish, its `id`, ' +
924
+ 'and the terms read back from the built transaction. Anyone holding the wanted token can ' +
925
+ 'take the offer; the first swap that lands consumes it. The coins stay reserved until ' +
926
+ 'the swap lands: withdraw an offer that is not going to be used with revert_swap_offer. ' +
927
+ 'An offer refers to a recent chain state and expires with it, so publish it promptly. ' +
928
+ '`give.amount` may not exceed `spendable` of get_swap_wallet. Proving takes about 200 s ' +
929
+ 'in-process, 11 to 17 s on a proof server (NIGHTGATE_PROOF_SERVER_URL). Spends no fee.',
930
+ inputSchema: {
931
+ give: swapLeg('give'),
932
+ want: swapLeg('want'),
933
+ bind: z.boolean().optional()
934
+ .describe('true (default): bound, with its offer file. false: unbound, base64 only (halfB64), for a taker that builds unbound too'),
935
+ },
936
+ }, run(async (args) => {
937
+ if (!config)
938
+ throw new Error('swaps are not configured for this server instance');
939
+ return buildOffer(config, { give: legOut(args.give), want: legOut(args.want), bind: args.bind });
940
+ }));
941
+ server.registerTool('take_swap_offer', {
942
+ description: SWAP_HINT + ' This takes an offer: it reads the offer\'s terms from the transaction, ' +
943
+ 'refuses when they differ from `expect`, builds and proves the mirror half from this ' +
944
+ 'server\'s wallet (it spends what the offer wants and creates what the offer gives). ' +
945
+ 'With sponsorSessionId the two halves go to the sponsor at once and the answer is the ' +
946
+ 'job ({ jobId, status, sessionId, id, terms }): poll get_job_status with the returned ' +
947
+ 'sessionId. Without it the answer carries both halves for sponsor_swap. ALWAYS pass ' +
948
+ '`expect` with the terms you agreed to: an offer is text from someone else. With offerId ' +
949
+ 'the board entry is read first and the call refuses, before proving, when it is not open ' +
950
+ 'or carries another offer than `offer`. A sponsor that refuses the swap leaves the proven ' +
951
+ 'half held under `halfId`: send it again with sponsor_swap({ halfId, offerId?, ' +
952
+ 'sponsorSessionId }) or release its coins with revert_swap_offer. If the offer was taken ' +
953
+ 'by somebody else first, the job fails: revert_swap_offer.',
954
+ inputSchema: {
955
+ offer: swapHalf('The offer to take'),
956
+ expect: z.object({ gives: swapLeg('expect.gives'), wants: swapLeg('expect.wants') }).optional()
957
+ .describe("The terms you agreed to, from the MAKER's side: what the offer gives and what it wants"),
958
+ sponsorSessionId: z.string().uuid().optional()
959
+ .describe('Sponsor session or the platform sponsor POOL id ' + `${PLATFORM_POOL_SENTINEL}` + '; when given the swap is submitted at once'),
960
+ idempotencyKey: z.string().optional().describe('Dedupes retries of the same submission'),
961
+ offerId: z.string().uuid().optional()
962
+ .describe('The board entry the offer came from (list_swap_offers), so the fill is recorded against it'),
963
+ },
964
+ }, run(async (args) => {
965
+ if (!config)
966
+ throw new Error('swaps are not configured for this server instance');
967
+ if (args.offerId) {
968
+ const row = await client.callFunction('getSwapOffer', { offerId: raw(args.offerId) });
969
+ if (row.status !== 'open')
970
+ throw new Error(`board offer ${args.offerId} is ${String(row.status)}, not open`);
971
+ if (typeof row.offer !== 'string' || !(await sameOffer(args.offer, row.offer))) {
972
+ throw new Error(`board offer ${args.offerId} carries another offer than the one passed: take the board's text, or drop offerId`);
973
+ }
974
+ }
975
+ const taken = await takeOffer(config, {
976
+ offer: args.offer,
977
+ ...(args.expect ? { expect: { gives: legOut(args.expect.gives), wants: legOut(args.expect.wants) } } : {}),
978
+ });
979
+ if (!args.sponsorSessionId)
980
+ return taken;
981
+ let job;
982
+ try {
983
+ job = await client.callAction('sponsorSwap', {
984
+ ...(args.offerId ? { offerId: args.offerId } : { makerHalfB64: taken.makerHalfB64 }),
985
+ takerHalfB64: taken.takerHalfB64,
986
+ sponsorSessionId: args.sponsorSessionId,
987
+ idempotencyKey: args.idempotencyKey,
988
+ });
989
+ }
990
+ catch (err) {
991
+ if (!(err instanceof NightgateApiError))
992
+ throw err;
993
+ throw new NightgateApiError(err.status, err.code, err.message, { ...(err.detail ?? {}), halfId: taken.id });
994
+ }
995
+ trackHalfJob(taken.id, job.jobId);
996
+ return { ...job, id: taken.id, terms: taken.terms, bound: taken.bound, buildMs: taken.buildMs };
997
+ }));
998
+ server.registerTool('revert_swap_offer', {
999
+ description: 'Release the coins of a swap half this server built (build_swap_offer or take_swap_offer) ' +
1000
+ 'that is not going to be used. Until then, or until the swap lands, the coins count as ' +
1001
+ 'spent in this wallet. It does not recall an offer somebody already holds: to make a ' +
1002
+ 'published offer worthless, spend its coins in another swap. Ids are listed by ' +
1003
+ 'get_swap_wallet; a half whose swap landed leaves the list when get_job_status reports it.',
1004
+ inputSchema: {
1005
+ id: z.string().regex(/^[0-9a-f]{32}$/, 'id must be the 32 hex characters a swap tool returned'),
1006
+ },
1007
+ }, run(async (args) => {
1008
+ if (!config)
1009
+ throw new Error('swaps are not configured for this server instance');
1010
+ return revertHalf(config, args.id);
1011
+ }));
1012
+ server.registerTool('sponsor_swap', {
1013
+ description: SWAP_HINT + ' This hands the two halves to the sponsor, which checks them, merges them, ' +
1014
+ 'pays the dust and submits: each half carries an offer and nothing else, gives exactly ' +
1015
+ 'one token type and wants exactly one other, both on the sponsor\'s token allow-list and ' +
1016
+ 'never NIGHT, at most 4 inputs and 2 outputs per half, and the halves mirror each other ' +
1017
+ 'in types and amounts. A half may be bound or unbound and is passed as offer file text ' +
1018
+ 'or base64. Off on a server by default (NIGHTGATE_SPONSOR_ALLOW_SWAPS); an agent grant ' +
1019
+ 'needs sponsorSwap in its allowedActions. Job result { txHash, swap: { gives, wants } }. ' +
1020
+ 'An offer fills once: if it was taken first by somebody else the job fails. Pass both ' +
1021
+ 'halves, or `halfId` alone for an offer this server took (take_swap_offer keeps both ' +
1022
+ 'halves); with `offerId` the maker half comes from the board, next to takerHalf or the ' +
1023
+ 'halfId of the taken offer. Poll get_job_status with the sessionId RETURNED by this call. ' + POLL_HINT,
1024
+ inputSchema: {
1025
+ makerHalf: swapHalf("The maker's half (the offer)").optional(),
1026
+ takerHalf: swapHalf("The taker's half").optional(),
1027
+ sponsorSessionId: z.string().uuid()
1028
+ .describe('Wallet session that pays the dust and submits, or the platform sponsor POOL id ' +
1029
+ `${PLATFORM_POOL_SENTINEL}`),
1030
+ idempotencyKey: z.string().optional()
1031
+ .describe('Dedupes retries of the SAME two halves'),
1032
+ halfId: z.string().regex(/^[0-9a-f]{32}$/, 'halfId must be the 32 hex characters a swap tool returned').optional()
1033
+ .describe('Id of the half this server built for the swap: alone it names a taken offer, next to both halves it ties the job to the half'),
1034
+ offerId: z.string().uuid().optional()
1035
+ .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'),
1036
+ },
1037
+ }, run(async (args) => {
1038
+ let halves;
1039
+ if (args.offerId) {
1040
+ if (args.makerHalf)
1041
+ throw new Error('sponsor_swap with offerId takes the maker half from the board: no makerHalf');
1042
+ const takerHalf = args.takerHalf ?? (args.halfId ? takenHalves(args.halfId).takerHalfB64 : undefined);
1043
+ if (!takerHalf)
1044
+ throw new Error('sponsor_swap with offerId needs takerHalf, or the halfId of an offer this server took');
1045
+ halves = { offerId: args.offerId, takerHalfB64: takerHalf };
1046
+ }
1047
+ else {
1048
+ if (!!args.makerHalf !== !!args.takerHalf)
1049
+ throw new Error('sponsor_swap needs makerHalf AND takerHalf, or halfId alone');
1050
+ if (!args.makerHalf && !args.halfId)
1051
+ throw new Error('sponsor_swap needs makerHalf and takerHalf, or the halfId of an offer this server took');
1052
+ halves = args.makerHalf && args.takerHalf
1053
+ ? { makerHalfB64: args.makerHalf, takerHalfB64: args.takerHalf }
1054
+ : takenHalves(args.halfId);
1055
+ }
1056
+ const job = await client.callAction('sponsorSwap', {
1057
+ ...halves,
1058
+ sponsorSessionId: args.sponsorSessionId,
1059
+ idempotencyKey: args.idempotencyKey,
1060
+ });
1061
+ if (args.halfId)
1062
+ trackHalfJob(args.halfId, job?.jobId);
1063
+ return job;
1064
+ }));
1065
+ server.registerTool('post_swap_offer', {
1066
+ description: 'Post a maker half on the server\'s offer board, so takers find it with list_swap_offers ' +
1067
+ 'instead of receiving the offer file out of band. The server checks it like a half it ' +
1068
+ 'would sponsor (one token type given, one wanted, both on its list, never NIGHT) and ' +
1069
+ 'records its terms; the half itself stays signed by the maker. The offer closes when a ' +
1070
+ 'swap that spends one of its inputs lands, on expiry, or by retire_swap_offer. Needs ' +
1071
+ 'postSwapOffer in an agent grant\'s allowedActions. Returns { offerId, status, terms }.',
1072
+ inputSchema: {
1073
+ offer: swapHalf('The maker half, as offer file text or base64'),
1074
+ expiresAt: z.string().datetime().optional().describe('ISO timestamp; at most 90 days ahead'),
1075
+ tags: z.array(z.string().min(1).max(40)).max(8).optional().describe('Up to 8 tags for list_swap_offers'),
1076
+ },
1077
+ }, run(async (args) => client.callAction('postSwapOffer', {
1078
+ offer: args.offer,
1079
+ expiresAt: args.expiresAt,
1080
+ tags: args.tags ? JSON.stringify(args.tags) : undefined,
1081
+ })));
1082
+ server.registerTool('list_swap_offers', {
1083
+ description: 'Offers on the server\'s board. Without status: the open ones, newest first. status filled | ' +
1084
+ 'retired | expired | all reads the closed ones instead, ordered by last change; since keeps ' +
1085
+ 'offers changed after that instant, so status all + since polled repeatedly is the board\'s ' +
1086
+ 'change feed. Filters are exact 64-hex token types and one tag. ' + BOARD_ROW_HINT +
1087
+ ' Every token may read the board.',
1088
+ inputSchema: {
1089
+ givesType: hex64('givesType').optional().describe('Only offers that give this token type'),
1090
+ wantsType: hex64('wantsType').optional().describe('Only offers that want this token type'),
1091
+ tag: z.string().min(1).max(40).optional().describe('Only offers carrying this tag'),
1092
+ limit: z.number().int().min(1).max(200).optional().describe('Default 50'),
1093
+ status: OFFER_STATUS.optional().describe('Default open'),
1094
+ since: z.string().datetime().optional().describe('ISO timestamp; only offers changed after it'),
1095
+ },
1096
+ }, run(async (args) => listOffers(client, { ...args, mine: null })));
1097
+ server.registerTool('my_swap_offers', {
1098
+ description: 'The offers this caller posted on the board (a token: its grant\'s), open and closed, ' +
1099
+ 'ordered by last change: what is still open, what filled (filledTxHash), what expired or ' +
1100
+ 'was retired. status narrows to one state, since to offers changed after an instant. ' +
1101
+ BOARD_ROW_HINT,
1102
+ inputSchema: {
1103
+ status: OFFER_STATUS.optional().describe('Default all'),
1104
+ since: z.string().datetime().optional().describe('ISO timestamp; only offers changed after it'),
1105
+ limit: z.number().int().min(1).max(200).optional().describe('Default 50'),
1106
+ },
1107
+ }, run(async (args) => listOffers(client, { status: args.status ?? 'all', since: args.since, limit: args.limit, mine: true })));
1108
+ server.registerTool('get_swap_offer', {
1109
+ description: 'One board offer by id, open or closed, in the board\'s shape: status, filledTxHash once a ' +
1110
+ 'swap spent it, closedAt, changedAt. The way to follow an offer after post_swap_offer or ' +
1111
+ 'after taking one. Unknown id: 404. ' + BOARD_ROW_HINT,
1112
+ inputSchema: {
1113
+ offerId: z.string().uuid().describe('The board entry'),
1114
+ },
1115
+ }, run(async (args) => client.callFunction('getSwapOffer', { offerId: raw(args.offerId) })));
1116
+ server.registerTool('get_board_status', {
1117
+ description: 'Counts of the server\'s offer board and its sponsors, no credentials needed: openOffers, ' +
1118
+ 'offersFilledToday and swapsToday (UTC day), sponsorsConfigured and sponsorsReady (at tip ' +
1119
+ 'with a spendable dust note; 0 means a sponsor_swap would wait or fail now), asOf. ' +
1120
+ 'Computed at most every 10 s.',
1121
+ inputSchema: {},
1122
+ }, run(async () => client.callIndexerFunction('getBoardStatus')));
1123
+ server.registerTool('retire_swap_offer', {
1124
+ description: 'Take an offer off the board. Only its poster may; the coins behind the half stay ' +
1125
+ 'reserved until revert_swap_offer releases the half built here. Returns { offerId, status }.',
1126
+ inputSchema: {
1127
+ offerId: z.string().uuid().describe('The board entry'),
1128
+ },
1129
+ }, run(async (args) => client.callAction('retireSwapOffer', { offerId: args.offerId })));
1130
+ server.registerTool('grant_disclosure_to_holders', {
1131
+ description: 'Disclose a document to everyone who holds a token: whoever registered a claim key on ' +
1132
+ 'the holder-registry contract for tokenType by passing a coin through it can read the ' +
1133
+ 'text with claim_disclosure, and the server never learns who. content must hash to ' +
1134
+ 'payloadHash (blake2b-256 or sha256 of the UTF-8 text); it is stored encrypted. A repeat ' +
1135
+ 'call for the same payload, token type and registry updates the grant (content kept ' +
1136
+ 'when omitted). Returns { holderGrantId, hasContent, expiresAt, status }.',
1137
+ inputSchema: {
1138
+ payloadHash: hex64('payloadHash').describe('Hash of the document (64 hex)'),
1139
+ tokenType: hex64('tokenType').describe('The raw shielded token type whose holders may read (64 hex)'),
1140
+ registryAddress: hex64('registryAddress').describe('The holder-registry deployment holders registered on'),
1141
+ content: z.string().max(1_048_576).optional().describe('The document text; must hash to payloadHash'),
1142
+ contentType: z.string().max(100).optional().describe("Default 'text/plain'"),
1143
+ expiresAt: z.string().datetime().optional().describe('ISO timestamp, at most one year ahead'),
1144
+ },
1145
+ }, run(async (args) => client.callAction('grantDisclosureToHolders', {
1146
+ payloadHash: args.payloadHash.toLowerCase(),
1147
+ tokenType: args.tokenType.toLowerCase(),
1148
+ registryAddress: args.registryAddress.toLowerCase(),
1149
+ content: args.content,
1150
+ contentType: args.contentType,
1151
+ expiresAt: args.expiresAt,
1152
+ })));
1153
+ server.registerTool('revoke_holder_disclosure', {
1154
+ description: 'Revoke a disclosure to token holders. Only its grantor may. Returns { holderGrantId, status }.',
1155
+ inputSchema: {
1156
+ holderGrantId: z.string().uuid().describe('From grant_disclosure_to_holders'),
1157
+ },
1158
+ }, run(async (args) => client.callAction('revokeHolderDisclosure', { holderGrantId: args.holderGrantId })));
1159
+ server.registerTool('claim_disclosure', {
1160
+ description: 'Read a document disclosed to the holders of a token. claimSecret is the 32-byte secret ' +
1161
+ 'behind the claim key registered on the holder-registry contract (holder_claim_key); the ' +
1162
+ 'server reads the registry live and answers { entitled, content, contentType, ' +
1163
+ 'contentHashKind, expiresAt } or { entitled: false, reason }. Needs a live indexer on ' +
1164
+ 'the server.',
1165
+ inputSchema: {
1166
+ payloadHash: hex64('payloadHash').describe('Hash of the document (64 hex)'),
1167
+ tokenType: hex64('tokenType').describe('The raw shielded token type (64 hex)'),
1168
+ claimSecret: hex64('claimSecret').describe('The secret behind the registered claim key (64 hex)'),
1169
+ },
1170
+ }, run(async (args) => client.callAction('claimDisclosure', {
1171
+ payloadHash: args.payloadHash.toLowerCase(),
1172
+ tokenType: args.tokenType.toLowerCase(),
1173
+ claimSecret: args.claimSecret.toLowerCase(),
1174
+ })));
1175
+ server.registerTool('holder_claim_key', {
1176
+ description: 'The claim key a token holder registers on the holder-registry contract, derived from a ' +
1177
+ '32-byte secret (compute-only). Without a secret a fresh random one is generated: keep ' +
1178
+ 'it, claim_disclosure needs it. Register the key with registerHolder(coin, claimKey) on ' +
1179
+ 'the registry (one coin of the token type passes through the contract and comes back), ' +
1180
+ 'e.g. through build_sponsorable_transaction on a builder that knows the registry.',
1181
+ inputSchema: {
1182
+ claimSecret: hex64('claimSecret').optional().describe('Existing secret (64 hex); omit to generate one'),
1183
+ },
1184
+ }, run(async (args) => {
1185
+ const holderClaimKey = await txExport('holderClaimKey', 'holder_claim_key', '0.10.0');
1186
+ const { randomBytes } = await import('node:crypto');
1187
+ const claimSecret = (args.claimSecret ?? randomBytes(32).toString('hex')).toLowerCase();
1188
+ return { claimSecret, claimKey: holderClaimKey(claimSecret) };
1189
+ }));
873
1190
  server.registerTool('derive_token_type', {
874
1191
  description: 'Derive the raw token type a minting contract produces (compute-only, no wallet, no ' +
875
1192
  'chain access). A custom token is addressed by rawTokenType(domainSeparator, ' +
@@ -885,6 +1202,74 @@ export function registerTools(server, client, config) {
885
1202
  contractAddress: args.contractAddress,
886
1203
  domainSeparator: args.domainSeparator,
887
1204
  })));
1205
+ server.registerTool('mint_token', {
1206
+ description: 'Mint a shielded token with a name of your own on a token-factory deployment. Two ways. ' +
1207
+ 'LOCAL (default, no sessionId): this process is the issuer, its issuer secret derives from ' +
1208
+ 'NIGHTGATE_SEED_HEX, the mint is built, proven and signed here and the fee-unpaid ' +
1209
+ 'transaction goes to the sponsor (sponsorSessionId, default the platform pool) like ' +
1210
+ 'sponsor_unbound_transaction; the seed never leaves this process. The factory has to be ' +
1211
+ 'on the sponsor\'s contract list and mint on its circuit list. SERVER (sessionId given): the ' +
1212
+ 'server session is the issuer and mints through mintFactoryToken; needs that action in ' +
1213
+ 'the grant. Either way the same name from another issuer is another token, and the ' +
1214
+ 'response names the token before the job runs ({ tokenType, domain, issuerKey }); a ' +
1215
+ 'landed mint makes the type known to the sponsor (swaps, offer board). The recipient ' +
1216
+ 'defaults to this wallet (get_attester_identity); another wallet takes both of its keys. ' +
1217
+ 'Job result { txHash, ... }. Poll get_job_status with the sessionId RETURNED by this call. ' + POLL_HINT,
1218
+ inputSchema: {
1219
+ contractAddress: hex64('contractAddress').describe('A token-factory deployment'),
1220
+ name: z.string().min(1).refine((s) => Buffer.byteLength(s, 'utf8') <= 32, 'name must be at most 32 UTF-8 bytes')
1221
+ .describe('Token name, at most 32 UTF-8 bytes; with the issuer it names the token type'),
1222
+ amount: z.union([
1223
+ z.string().regex(/^[1-9]\d*$/, 'amount must be a positive integer (decimal string)'),
1224
+ z.number().int().positive().safe(),
1225
+ ]).describe('Atoms to mint; a decimal string is exact'),
1226
+ recipientCoinPublicKey: hex64('recipientCoinPublicKey').optional()
1227
+ .describe('Zswap coin public key of the receiving wallet (64 hex); default this wallet\'s own'),
1228
+ recipientEncryptionPublicKey: hex64('recipientEncryptionPublicKey').optional()
1229
+ .describe('The receiving wallet\'s encryption public key (64 hex); required with a recipient other than this wallet, local way'),
1230
+ sponsorSessionId: z.string().uuid().optional()
1231
+ .describe(`Local way: the session that pays the dust and submits; default the platform sponsor POOL id ${PLATFORM_POOL_SENTINEL}. Server way: an optional second session that pays`),
1232
+ sessionId: z.string().uuid().optional()
1233
+ .describe('Server way only: the wallet session that mints; it is the issuer'),
1234
+ idempotencyKey: z.string().optional().describe('Dedupes retries'),
1235
+ },
1236
+ }, run(async (args) => {
1237
+ if (args.sessionId) {
1238
+ let recipient = args.recipientCoinPublicKey;
1239
+ if (!recipient) {
1240
+ if (!config?.seedHex)
1241
+ throw new Error('mint_token needs recipientCoinPublicKey: no NIGHTGATE_SEED_HEX for a default');
1242
+ recipient = (await seedIdentity(config)).coinPublicKey;
1243
+ if (!recipient)
1244
+ throw new Error('mint_token needs recipientCoinPublicKey: the seed exposes no shielded keys');
1245
+ }
1246
+ return client.callAction('mintFactoryToken', {
1247
+ contractAddress: args.contractAddress.toLowerCase(),
1248
+ name: args.name,
1249
+ amount: String(args.amount),
1250
+ recipientCoinPublicKey: recipient.toLowerCase(),
1251
+ sessionId: args.sessionId,
1252
+ idempotencyKey: args.idempotencyKey,
1253
+ sponsorSessionId: args.sponsorSessionId,
1254
+ });
1255
+ }
1256
+ if (!config)
1257
+ throw new Error('local minting is not configured for this server instance');
1258
+ const built = await buildMint(config, {
1259
+ contractAddress: args.contractAddress,
1260
+ name: args.name,
1261
+ amount: String(args.amount),
1262
+ recipientCoinPublicKey: args.recipientCoinPublicKey,
1263
+ recipientEncryptionPublicKey: args.recipientEncryptionPublicKey,
1264
+ });
1265
+ const job = await client.callAction('sponsorUnboundTransaction', {
1266
+ unboundTxB64: built.unboundTxB64,
1267
+ sponsorSessionId: args.sponsorSessionId ?? PLATFORM_POOL_SENTINEL,
1268
+ idempotencyKey: args.idempotencyKey,
1269
+ });
1270
+ const { unboundTxB64: _tx, ...rest } = built;
1271
+ return { ...job, ...rest, channel: 'local' };
1272
+ }));
888
1273
  server.registerTool('get_job_status', {
889
1274
  description: 'Poll the status of an async NIGHTGATE job (all submit actions return a jobId). ' +
890
1275
  'status: pending | running | external_execution | submitted | reconciliation_required | ' +
@@ -895,16 +1280,33 @@ export function registerTools(server, client, config) {
895
1280
  'reconciliation_required = broadcast outcome unknown to the server yet, the transaction ' +
896
1281
  'identifier is in the error message, the server keeps resolving it via the indexer. ' +
897
1282
  'Batches: once confirmed, chainSegments ([{ segment, calls, applied }]) says which calls ' +
898
- 'applied; resend only the ones with applied false.',
1283
+ 'applied; resend only the ones with applied false. Swaps: the result carries swap ' +
1284
+ '{ gives, wants }, what was exchanged from the maker\'s side. Mints: tokenType.',
899
1285
  inputSchema: {
900
1286
  jobId: z.string().uuid().describe('Job id returned by a submit action'),
901
1287
  sessionId: z.string().uuid()
902
1288
  .describe('Wallet session id the job belongs to; for sponsor jobs the sessionId RETURNED by the sponsor call'),
903
1289
  },
904
- }, run(async (args) => client.callAction('getJobStatus', {
905
- jobId: args.jobId,
906
- sessionId: args.sessionId,
907
- })));
1290
+ }, run(async (args) => {
1291
+ const job = await client.callAction('getJobStatus', {
1292
+ jobId: args.jobId,
1293
+ sessionId: args.sessionId,
1294
+ });
1295
+ settleHalfJob(args.jobId, job?.status);
1296
+ return job;
1297
+ }));
1298
+ }
1299
+ /** listSwapOffers wants all seven parameters, null for the unused ones. */
1300
+ function listOffers(client, args) {
1301
+ return client.callFunction('listSwapOffers', {
1302
+ givesType: args.givesType?.toLowerCase() ?? null,
1303
+ wantsType: args.wantsType?.toLowerCase() ?? null,
1304
+ tag: args.tag ?? null,
1305
+ limit: args.limit ?? null,
1306
+ status: args.status ?? null,
1307
+ since: args.since ?? null,
1308
+ mine: args.mine,
1309
+ });
908
1310
  }
909
1311
  /**
910
1312
  * Uniform handler wrapper: JSON success payloads, API errors reported as