nara-sdk 1.0.76 → 1.0.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -127,12 +127,17 @@ registerBridgeToken('USDT', {
127
127
  ### Fee configuration
128
128
 
129
129
  Default fee: **0.5%** (50 bps), deducted from the bridged amount on the source chain.
130
+ Fee recipients are chain-specific (one per source chain).
130
131
 
131
132
  ```ts
132
- import { setBridgeFeeRecipient } from 'nara-sdk';
133
+ import { setBridgeFeeRecipient, getBridgeFeeRecipient } from 'nara-sdk';
133
134
 
134
- // Override fee recipient at runtime
135
- setBridgeFeeRecipient('YourFeeRecipientPubkey...');
135
+ // Override fee recipient at runtime (per chain)
136
+ setBridgeFeeRecipient('solana', 'SolanaFeeRecipientPubkey...');
137
+ setBridgeFeeRecipient('nara', 'NaraFeeRecipientPubkey...');
138
+
139
+ // Read current recipient
140
+ const recipient = getBridgeFeeRecipient('solana'); // PublicKey
136
141
 
137
142
  // Or per-call
138
143
  await bridgeTransfer(conn, wallet, {
package/index.ts CHANGED
@@ -16,7 +16,8 @@ export {
16
16
  DEFAULT_AGENT_REGISTRY_PROGRAM_ID,
17
17
  DEFAULT_ALT_ADDRESS,
18
18
  DEFAULT_BRIDGE_FEE_BPS,
19
- DEFAULT_BRIDGE_FEE_RECIPIENT,
19
+ DEFAULT_BRIDGE_FEE_RECIPIENT_SOLANA,
20
+ DEFAULT_BRIDGE_FEE_RECIPIENT_NARA,
20
21
  BRIDGE_FEE_BPS_DENOMINATOR,
21
22
  } from "./src/constants";
22
23
 
@@ -219,6 +220,15 @@ export {
219
220
  type MessageSignatureStatus,
220
221
  } from "./src/bridge";
221
222
 
223
+ // Export transaction parser
224
+ export {
225
+ parseTxFromHash,
226
+ parseTxResponse,
227
+ formatParsedTx,
228
+ type ParsedInstruction,
229
+ type ParsedTransaction,
230
+ } from "./src/tx_parser";
231
+
222
232
  // Export IDLs and types
223
233
  export { default as NaraQuestIDL } from "./src/idls/nara_quest.json";
224
234
  export { default as NaraSkillsHubIDL } from "./src/idls/nara_skills_hub.json";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nara-sdk",
3
- "version": "1.0.76",
3
+ "version": "1.0.78",
4
4
  "description": "SDK for the Nara chain (Solana-compatible)",
5
5
  "module": "index.ts",
6
6
  "main": "index.ts",
@@ -17,6 +17,9 @@
17
17
  "blockchain",
18
18
  "sdk"
19
19
  ],
20
+ "scripts": {
21
+ "test": "tsx test/read_api.test.ts"
22
+ },
20
23
  "author": "",
21
24
  "license": "MIT",
22
25
  "devDependencies": {
@@ -352,31 +352,21 @@ export async function getAgentInfo(
352
352
  ): Promise<AgentInfo> {
353
353
  const pid = new PublicKey(options?.programId ?? DEFAULT_AGENT_REGISTRY_PROGRAM_ID);
354
354
  const agentPda = getAgentPda(pid, agentId);
355
- const accountInfo = await connection.getAccountInfo(agentPda);
356
- if (!accountInfo) {
357
- throw new Error(`Agent "${agentId}" not found`);
358
- }
359
- const record = parseAgentRecordData(accountInfo.data);
360
-
361
355
  const bioPda = getBioPda(pid, agentPda);
362
356
  const metaPda = getMetaPda(pid, agentPda);
363
357
 
364
- let bio: string | null = null;
365
- let metadata: string | null = null;
366
-
367
- try {
368
- const bioInfo = await connection.getAccountInfo(bioPda);
369
- if (bioInfo) bio = deserializeRawString(Buffer.from(bioInfo.data));
370
- } catch {
371
- // account not created yet
358
+ // Single RPC: fetch agent record, bio, and metadata in one call
359
+ const [agentInfo, bioInfo, metaInfo] = await connection.getMultipleAccountsInfo(
360
+ [agentPda, bioPda, metaPda],
361
+ "confirmed"
362
+ );
363
+ if (!agentInfo) {
364
+ throw new Error(`Agent "${agentId}" not found`);
372
365
  }
373
366
 
374
- try {
375
- const metaInfo = await connection.getAccountInfo(metaPda);
376
- if (metaInfo) metadata = deserializeRawString(Buffer.from(metaInfo.data));
377
- } catch {
378
- // account not created yet
379
- }
367
+ const record = parseAgentRecordData(agentInfo.data);
368
+ const bio = bioInfo ? deserializeRawString(Buffer.from(bioInfo.data)) : null;
369
+ const metadata = metaInfo ? deserializeRawString(Buffer.from(metaInfo.data)) : null;
380
370
 
381
371
  return { record, bio, metadata };
382
372
  }
package/src/bridge.ts CHANGED
@@ -27,7 +27,8 @@ import {
27
27
  import {
28
28
  BRIDGE_FEE_BPS_DENOMINATOR,
29
29
  DEFAULT_BRIDGE_FEE_BPS,
30
- DEFAULT_BRIDGE_FEE_RECIPIENT,
30
+ DEFAULT_BRIDGE_FEE_RECIPIENT_SOLANA,
31
+ DEFAULT_BRIDGE_FEE_RECIPIENT_NARA,
31
32
  } from "./constants";
32
33
  import { sendTx } from "./tx";
33
34
 
@@ -185,23 +186,38 @@ function getToken(symbol: string): BridgeTokenConfig {
185
186
  return t;
186
187
  }
187
188
 
188
- // ─── Fee recipient (runtime override) ─────────────────────────────
189
+ // ─── Fee recipient (runtime override, per chain) ──────────────────
189
190
 
190
- let _feeRecipientOverride: PublicKey | null = null;
191
+ const _feeRecipientOverrides: Record<BridgeChain, PublicKey | null> = {
192
+ solana: null,
193
+ nara: null,
194
+ };
191
195
 
192
- /** Override the bridge fee recipient at runtime */
193
- export function setBridgeFeeRecipient(recipient: PublicKey | string | null): void {
196
+ /**
197
+ * Override the bridge fee recipient for a specific source chain at runtime.
198
+ * Pass `null` as recipient to clear the override and fall back to the default.
199
+ */
200
+ export function setBridgeFeeRecipient(
201
+ chain: BridgeChain,
202
+ recipient: PublicKey | string | null
203
+ ): void {
194
204
  if (recipient === null) {
195
- _feeRecipientOverride = null;
205
+ _feeRecipientOverrides[chain] = null;
196
206
  return;
197
207
  }
198
- _feeRecipientOverride =
208
+ _feeRecipientOverrides[chain] =
199
209
  typeof recipient === "string" ? new PublicKey(recipient) : recipient;
200
210
  }
201
211
 
202
- export function getBridgeFeeRecipient(): PublicKey {
203
- if (_feeRecipientOverride) return _feeRecipientOverride;
204
- return new PublicKey(DEFAULT_BRIDGE_FEE_RECIPIENT);
212
+ /** Get the current fee recipient for a source chain (override or default). */
213
+ export function getBridgeFeeRecipient(chain: BridgeChain): PublicKey {
214
+ const override = _feeRecipientOverrides[chain];
215
+ if (override) return override;
216
+ const defaultAddr =
217
+ chain === "solana"
218
+ ? DEFAULT_BRIDGE_FEE_RECIPIENT_SOLANA
219
+ : DEFAULT_BRIDGE_FEE_RECIPIENT_NARA;
220
+ return new PublicKey(defaultAddr);
205
221
  }
206
222
 
207
223
  // ─── PDA derivation ───────────────────────────────────────────────
@@ -515,7 +531,7 @@ export function makeBridgeIxs(params: BridgeTransferParams): BridgeIxsResult {
515
531
  throw new Error("bridge amount after fee is zero — increase amount or lower feeBps");
516
532
  }
517
533
 
518
- const recipientForFee = feeRecipient ?? getBridgeFeeRecipient();
534
+ const recipientForFee = feeRecipient ?? getBridgeFeeRecipient(fromChain);
519
535
  const feeIxs = makeBridgeFeeIxs({
520
536
  token,
521
537
  fromChain,
@@ -640,17 +656,23 @@ export async function queryMessageStatus(
640
656
  const sigs = await destConnection.getSignaturesForAddress(mailbox, {
641
657
  limit,
642
658
  });
659
+ const validSigs = sigs.filter((s) => !s.err).map((s) => s.signature);
660
+ if (!validSigs.length) {
661
+ return { delivered: false, deliverySignature: null };
662
+ }
663
+
664
+ // Batch-fetch transactions in a single JSON-RPC call
665
+ const txs = await destConnection.getTransactions(validSigs, {
666
+ maxSupportedTransactionVersion: 0,
667
+ commitment: "confirmed",
668
+ });
643
669
 
644
- for (const entry of sigs) {
645
- if (entry.err) continue;
646
- const tx = await destConnection.getTransaction(entry.signature, {
647
- maxSupportedTransactionVersion: 0,
648
- commitment: "confirmed",
649
- });
670
+ for (let i = 0; i < txs.length; i++) {
671
+ const tx = txs[i];
650
672
  if (!tx?.meta?.logMessages) continue;
651
673
  for (const log of tx.meta.logMessages) {
652
674
  if (log.includes(messageId)) {
653
- return { delivered: true, deliverySignature: entry.signature };
675
+ return { delivered: true, deliverySignature: validSigs[i] ?? null };
654
676
  }
655
677
  }
656
678
  }
package/src/constants.ts CHANGED
@@ -48,8 +48,8 @@ export const DEFAULT_ALT_ADDRESS = process.env.ALT_ADDRESS || "3uw7RatGTB4hdHnuV
48
48
 
49
49
  /**
50
50
  * Bridge fee in basis points (1 bps = 0.01%). 50 = 0.5%.
51
- * Deducted from the bridged amount and transferred to DEFAULT_BRIDGE_FEE_RECIPIENT
52
- * in the same transaction.
51
+ * Deducted from the bridged amount and transferred to the chain-specific
52
+ * fee recipient in the same transaction.
53
53
  */
54
54
  export const DEFAULT_BRIDGE_FEE_BPS = 50;
55
55
 
@@ -57,11 +57,15 @@ export const DEFAULT_BRIDGE_FEE_BPS = 50;
57
57
  export const BRIDGE_FEE_BPS_DENOMINATOR = 10000;
58
58
 
59
59
  /**
60
- * Default fee recipient pubkey for cross-chain bridge transactions.
61
- * Same Ed25519 keypair works on both Solana and Nara chains.
62
- * Override at runtime via setBridgeFeeRecipient() in src/bridge.ts.
63
- *
64
- * NOTE: replace with actual fee recipient before mainnet usage.
60
+ * Default fee recipient pubkey on Solana (when bridging FROM Solana).
61
+ * Override at runtime via setBridgeFeeRecipient("solana", ...).
65
62
  */
66
- export const DEFAULT_BRIDGE_FEE_RECIPIENT =
63
+ export const DEFAULT_BRIDGE_FEE_RECIPIENT_SOLANA =
64
+ "HaPQTvGJBunoWA3AyyWRL9etVEbQWsXVoj3fHpBprLy5";
65
+
66
+ /**
67
+ * Default fee recipient pubkey on Nara (when bridging FROM Nara).
68
+ * Override at runtime via setBridgeFeeRecipient("nara", ...).
69
+ */
70
+ export const DEFAULT_BRIDGE_FEE_RECIPIENT_NARA =
67
71
  "FERLFwBpCyoEuvFP68eP6Fv4FCVocnNyyFUCYwpfmjqn";
package/src/quest.ts CHANGED
@@ -8,7 +8,11 @@ import {
8
8
  LAMPORTS_PER_SOL,
9
9
  PublicKey,
10
10
  } from "@solana/web3.js";
11
- import { getAssociatedTokenAddressSync } from "@solana/spl-token";
11
+ import {
12
+ getAssociatedTokenAddressSync,
13
+ unpackAccount,
14
+ TOKEN_PROGRAM_ID,
15
+ } from "@solana/spl-token";
12
16
  import * as anchor from "@coral-xyz/anchor";
13
17
  import { Program, AnchorProvider, Wallet } from "@coral-xyz/anchor";
14
18
  import BN from "bn.js";
@@ -283,7 +287,22 @@ export async function getQuestInfo(
283
287
  const kp = wallet ?? Keypair.generate();
284
288
  const program = createProgram(connection, kp, options?.programId);
285
289
  const poolPda = getPoolPda(program.programId);
286
- const pool = await program.account.pool.fetch(poolPda);
290
+ const programId = new PublicKey(options?.programId ?? DEFAULT_QUEST_PROGRAM_ID);
291
+ const [configPda] = PublicKey.findProgramAddressSync(
292
+ [new TextEncoder().encode("quest_config")],
293
+ programId
294
+ );
295
+
296
+ // Single RPC: fetch pool + gameConfig together
297
+ const [poolAcct, configAcct] = await connection.getMultipleAccountsInfo(
298
+ [poolPda, configPda],
299
+ "confirmed"
300
+ );
301
+ if (!poolAcct) throw new Error(`Pool account not found: ${poolPda.toBase58()}`);
302
+ if (!configAcct) throw new Error(`GameConfig account not found: ${configPda.toBase58()}`);
303
+
304
+ const pool = program.coder.accounts.decode("pool", poolAcct.data);
305
+ const config = program.coder.accounts.decode("gameConfig", configAcct.data);
287
306
 
288
307
  const now = Math.floor(Date.now() / 1000);
289
308
  const deadline = pool.deadline.toNumber();
@@ -295,13 +314,6 @@ export async function getQuestInfo(
295
314
  const stakeLow = Number(pool.stakeLow.toString()) / LAMPORTS_PER_SOL;
296
315
  const createdAt = pool.createdAt.toNumber();
297
316
 
298
- // Fetch decayMs from GameConfig for effective calculation
299
- const programId = new PublicKey(options?.programId ?? DEFAULT_QUEST_PROGRAM_ID);
300
- const [configPda] = PublicKey.findProgramAddressSync(
301
- [new TextEncoder().encode("quest_config")],
302
- programId
303
- );
304
- const config = await program.account.gameConfig.fetch(configPda);
305
317
  const decayMs = Number(config.decayMs.toString());
306
318
 
307
319
  // createdAt is unix timestamp (seconds), convert to ms for decay calculation
@@ -627,25 +639,39 @@ export async function getStakeInfo(
627
639
  const kp = Keypair.generate();
628
640
  const program = createProgram(connection, kp, options?.programId);
629
641
  const stakeRecordPda = getStakeRecordPda(program.programId, user);
642
+ const stakeTokenAccount = getStakeTokenAccount(stakeRecordPda);
643
+
644
+ // Single batch: fetch stake record + wSOL token account at once.
645
+ // Both are plain account reads, so getMultipleAccountsInfo works.
646
+ const [recordAcct, tokenAcct] = await connection.getMultipleAccountsInfo(
647
+ [stakeRecordPda, stakeTokenAccount],
648
+ "confirmed"
649
+ );
650
+ if (!recordAcct) return null;
651
+
652
+ let record;
630
653
  try {
631
- const record = await program.account.stakeRecord.fetch(stakeRecordPda);
632
- // Read wSOL token account balance (stake amount is stored as wSOL tokens)
633
- const stakeTokenAccount = getStakeTokenAccount(stakeRecordPda);
634
- let amount = 0;
654
+ record = program.coder.accounts.decode("stakeRecord", recordAcct.data);
655
+ } catch {
656
+ return null;
657
+ }
658
+
659
+ // Decode wSOL token account (legacy SPL Token program)
660
+ let amount = 0;
661
+ if (tokenAcct) {
635
662
  try {
636
- const balance = await connection.getTokenAccountBalance(stakeTokenAccount);
637
- amount = Number(balance.value.amount) / LAMPORTS_PER_SOL;
663
+ const unpacked = unpackAccount(stakeTokenAccount, tokenAcct, TOKEN_PROGRAM_ID);
664
+ amount = Number(unpacked.amount) / LAMPORTS_PER_SOL;
638
665
  } catch {
639
- // Token account may not exist yet (no stake deposited)
666
+ // Token account not initialized yet
640
667
  }
641
- return {
642
- amount,
643
- stakeRound: record.stakeRound.toNumber(),
644
- freeCredits: record.freeCredits,
645
- };
646
- } catch {
647
- return null;
648
668
  }
669
+
670
+ return {
671
+ amount,
672
+ stakeRound: record.stakeRound.toNumber(),
673
+ freeCredits: record.freeCredits,
674
+ };
649
675
  }
650
676
 
651
677
  /**
package/src/skills.ts CHANGED
@@ -216,38 +216,32 @@ export async function getSkillInfo(
216
216
  ): Promise<SkillInfo> {
217
217
  const pid = new PublicKey(options?.programId ?? DEFAULT_SKILLS_PROGRAM_ID);
218
218
  const skillPda = getSkillPda(pid, name);
219
- const accountInfo = await connection.getAccountInfo(skillPda);
220
- if (!accountInfo) {
219
+ const descPda = getDescPda(pid, skillPda);
220
+ const metaPda = getMetaPda(pid, skillPda);
221
+
222
+ // Single RPC: fetch skill record, description, and metadata in one call
223
+ const [skillInfo, descInfo, metaInfo] = await connection.getMultipleAccountsInfo(
224
+ [skillPda, descPda, metaPda],
225
+ "confirmed"
226
+ );
227
+ if (!skillInfo) {
221
228
  throw new Error(`Skill "${name}" not found`);
222
229
  }
223
- const record = parseSkillRecordData(accountInfo.data);
224
230
 
225
- const descPda = getDescPda(pid, skillPda);
226
- const metaPda = getMetaPda(pid, skillPda);
231
+ const record = parseSkillRecordData(skillInfo.data);
227
232
 
228
233
  let description: string | null = null;
229
- let metadata: string | null = null;
230
-
231
- try {
232
- const descInfo = await connection.getAccountInfo(descPda);
233
- if (descInfo) {
234
- const buf = Buffer.from(descInfo.data);
235
- const descLen = buf.readUInt16LE(8);
236
- description = buf.subarray(10, 10 + descLen).toString("utf-8");
237
- }
238
- } catch {
239
- // account not created yet
234
+ if (descInfo) {
235
+ const buf = Buffer.from(descInfo.data);
236
+ const descLen = buf.readUInt16LE(8);
237
+ description = buf.subarray(10, 10 + descLen).toString("utf-8");
240
238
  }
241
239
 
242
- try {
243
- const metaInfo = await connection.getAccountInfo(metaPda);
244
- if (metaInfo) {
245
- const buf = Buffer.from(metaInfo.data);
246
- const dataLen = buf.readUInt16LE(8);
247
- metadata = buf.subarray(10, 10 + dataLen).toString("utf-8");
248
- }
249
- } catch {
250
- // account not created yet
240
+ let metadata: string | null = null;
241
+ if (metaInfo) {
242
+ const buf = Buffer.from(metaInfo.data);
243
+ const dataLen = buf.readUInt16LE(8);
244
+ metadata = buf.subarray(10, 10 + dataLen).toString("utf-8");
251
245
  }
252
246
 
253
247
  return { record, description, metadata };
@@ -0,0 +1,750 @@
1
+ /**
2
+ * Transaction parser — decodes on-chain transaction into human-readable instructions.
3
+ *
4
+ * Supports:
5
+ * - ComputeBudget (CU limit / price)
6
+ * - SystemProgram (transfer, createAccount, ...)
7
+ * - SPL Token / Token-2022 (transfer, transferChecked, burn, mintTo, ...)
8
+ * - Associated Token Account (create)
9
+ * - Quest (PoMI) — answer, stake, airdrop, etc.
10
+ * - Agent Registry — register, twitter, tweet, etc.
11
+ * - Skills Hub — register, upload, etc.
12
+ * - ZK ID — deposit, withdraw, transfer, etc.
13
+ * - Bridge (Hyperlane warp routes) — TransferRemote
14
+ */
15
+
16
+ import {
17
+ Connection,
18
+ PublicKey,
19
+ SystemInstruction,
20
+ SystemProgram,
21
+ TransactionInstruction,
22
+ ComputeBudgetProgram,
23
+ } from "@solana/web3.js";
24
+ import type {
25
+ MessageCompiledInstruction,
26
+ VersionedTransactionResponse,
27
+ } from "@solana/web3.js";
28
+ import {
29
+ decodeInstruction as decodeSplTokenInstruction,
30
+ TokenInstruction,
31
+ TOKEN_PROGRAM_ID,
32
+ TOKEN_2022_PROGRAM_ID,
33
+ ASSOCIATED_TOKEN_PROGRAM_ID,
34
+ } from "@solana/spl-token";
35
+ import {
36
+ DEFAULT_QUEST_PROGRAM_ID,
37
+ DEFAULT_AGENT_REGISTRY_PROGRAM_ID,
38
+ DEFAULT_SKILLS_PROGRAM_ID,
39
+ DEFAULT_ZKID_PROGRAM_ID,
40
+ } from "./constants";
41
+ import { BRIDGE_TOKENS } from "./bridge";
42
+
43
+ // ─── Types ────────────────────────────────────────────────────────
44
+
45
+ export interface ParsedInstruction {
46
+ /** Instruction index in the transaction */
47
+ index: number;
48
+ /** Human-readable program name */
49
+ programName: string;
50
+ /** Program ID */
51
+ programId: string;
52
+ /** Human-readable instruction type / name */
53
+ type: string;
54
+ /** Decoded fields (key-value) */
55
+ info: Record<string, unknown>;
56
+ /** All account pubkeys involved */
57
+ accounts: string[];
58
+ /** Raw instruction data (base64) */
59
+ rawData: string;
60
+ }
61
+
62
+ export interface ParsedTransaction {
63
+ /** Transaction signature */
64
+ signature: string;
65
+ /** Slot number */
66
+ slot: number;
67
+ /** Block time (unix) */
68
+ blockTime: number | null;
69
+ /** Whether the transaction succeeded */
70
+ success: boolean;
71
+ /** Error message if failed */
72
+ error: string | null;
73
+ /** Fee paid in lamports */
74
+ fee: number;
75
+ /** Parsed instructions */
76
+ instructions: ParsedInstruction[];
77
+ /** Log messages */
78
+ logs: string[];
79
+ }
80
+
81
+ // ─── Program ID registry ──────────────────────────────────────────
82
+
83
+ const SYSTEM_PROGRAM = SystemProgram.programId.toBase58();
84
+ const COMPUTE_BUDGET = ComputeBudgetProgram.programId.toBase58();
85
+ const TOKEN_PROGRAM = TOKEN_PROGRAM_ID.toBase58();
86
+ const TOKEN_2022 = TOKEN_2022_PROGRAM_ID.toBase58();
87
+ const ATA_PROGRAM = ASSOCIATED_TOKEN_PROGRAM_ID.toBase58();
88
+ const MEMO_PROGRAM = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
89
+ const MEMO_PROGRAM_V1 = "Memo1UhkJBfCR6MNBin8nfyBmg8aW7S8K2LoYKrntv";
90
+
91
+ function buildProgramNameMap(): Map<string, string> {
92
+ const m = new Map<string, string>();
93
+
94
+ // System programs
95
+ m.set(SYSTEM_PROGRAM, "System Program");
96
+ m.set(COMPUTE_BUDGET, "Compute Budget");
97
+ m.set(TOKEN_PROGRAM, "SPL Token");
98
+ m.set(TOKEN_2022, "SPL Token-2022");
99
+ m.set(ATA_PROGRAM, "Associated Token");
100
+ m.set(MEMO_PROGRAM, "Memo");
101
+ m.set(MEMO_PROGRAM_V1, "Memo (v1)");
102
+
103
+ // Nara programs
104
+ m.set(DEFAULT_QUEST_PROGRAM_ID, "Quest (PoMI)");
105
+ m.set(DEFAULT_AGENT_REGISTRY_PROGRAM_ID, "Agent Registry");
106
+ m.set(DEFAULT_SKILLS_PROGRAM_ID, "Skills Hub");
107
+ m.set(DEFAULT_ZKID_PROGRAM_ID, "ZK ID");
108
+
109
+ // Hyperlane mailboxes
110
+ m.set("E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi", "Hyperlane Mailbox (Solana)");
111
+ m.set("EjtLD3MCBJregFKAce2pQqPtSnnmBWK5oAZ3wBifHnaH", "Hyperlane Mailbox (Nara)");
112
+ m.set("noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV", "SPL Noop");
113
+
114
+ // Bridge warp routes
115
+ for (const [symbol, cfg] of Object.entries(BRIDGE_TOKENS)) {
116
+ m.set(cfg.solana.warpProgram.toBase58(), `Bridge: ${symbol} (Solana)`);
117
+ m.set(cfg.nara.warpProgram.toBase58(), `Bridge: ${symbol} (Nara)`);
118
+ }
119
+
120
+ return m;
121
+ }
122
+
123
+ const programNames = buildProgramNameMap();
124
+
125
+ function getProgramName(pid: string): string {
126
+ return programNames.get(pid) ?? "Unknown";
127
+ }
128
+
129
+ // ─── Anchor discriminator map ─────────────────────────────────────
130
+
131
+ type DiscriminatorMap = Map<string, string>;
132
+
133
+ function discKey(bytes: number[]): string {
134
+ return bytes.map((b) => b.toString(16).padStart(2, "0")).join("");
135
+ }
136
+
137
+ function buildAnchorDiscriminators(): Map<string, DiscriminatorMap> {
138
+ const programs = new Map<string, DiscriminatorMap>();
139
+
140
+ // Quest
141
+ const quest: DiscriminatorMap = new Map();
142
+ quest.set(discKey([221, 73, 184, 157, 1, 150, 231, 48]), "submitAnswer");
143
+ quest.set(discKey([206, 176, 202, 18, 200, 209, 179, 108]), "stake");
144
+ quest.set(discKey([90, 95, 107, 42, 205, 124, 50, 225]), "unstake");
145
+ quest.set(discKey([222, 74, 49, 30, 160, 220, 179, 27]), "createQuestion");
146
+ quest.set(discKey([137, 50, 122, 111, 89, 254, 8, 20]), "claimAirdrop");
147
+ quest.set(discKey([21, 111, 164, 64, 220, 115, 26, 60]), "adjustFreeStake");
148
+ quest.set(discKey([255, 181, 252, 34, 155, 230, 65, 227]), "setAirdropConfig");
149
+ quest.set(discKey([175, 175, 109, 31, 13, 152, 155, 237]), "initialize");
150
+ quest.set(discKey([120, 201, 195, 128, 35, 202, 73, 161]), "expandConfig");
151
+ quest.set(discKey([19, 140, 189, 111, 121, 111, 118, 50]), "setQuestAuthority");
152
+ quest.set(discKey([69, 227, 209, 29, 164, 111, 166, 41]), "setQuestInterval");
153
+ quest.set(discKey([163, 34, 211, 14, 25, 118, 181, 233]), "setRewardConfig");
154
+ quest.set(discKey([163, 41, 94, 29, 221, 56, 112, 60]), "setRewardPerShare");
155
+ quest.set(discKey([202, 75, 225, 146, 240, 65, 15, 60]), "setStakeAuthority");
156
+ quest.set(discKey([84, 37, 76, 39, 236, 111, 214, 191]), "setStakeConfig");
157
+ quest.set(discKey([48, 169, 76, 72, 229, 180, 55, 161]), "transferAuthority");
158
+ programs.set(DEFAULT_QUEST_PROGRAM_ID, quest);
159
+
160
+ // Agent Registry
161
+ const agent: DiscriminatorMap = new Map();
162
+ agent.set(discKey([135, 157, 66, 195, 2, 113, 175, 30]), "registerAgent");
163
+ agent.set(discKey([12, 236, 115, 32, 129, 99, 250, 6]), "registerAgentWithReferral");
164
+ agent.set(discKey([136, 238, 98, 223, 118, 178, 238, 183]), "setTwitter");
165
+ agent.set(discKey([170, 213, 202, 134, 247, 139, 15, 24]), "verifyTwitter");
166
+ agent.set(discKey([97, 238, 35, 162, 61, 92, 88, 183]), "rejectTwitter");
167
+ agent.set(discKey([119, 20, 181, 34, 178, 73, 81, 50]), "approveRejectedTwitter");
168
+ agent.set(discKey([93, 66, 28, 60, 27, 34, 252, 166]), "unbindTwitter");
169
+ agent.set(discKey([140, 200, 213, 38, 145, 255, 191, 254]), "submitTweet");
170
+ agent.set(discKey([57, 71, 49, 146, 108, 84, 107, 45]), "approveTweet");
171
+ agent.set(discKey([231, 64, 127, 185, 55, 253, 175, 30]), "rejectTweet");
172
+ agent.set(discKey([92, 170, 90, 13, 148, 155, 212, 55]), "deleteAgent");
173
+ agent.set(discKey([196, 133, 219, 43, 75, 223, 195, 213]), "setBio");
174
+ agent.set(discKey([78, 157, 75, 242, 151, 20, 121, 144]), "setMetadata");
175
+ agent.set(discKey([213, 23, 157, 74, 199, 152, 182, 8]), "setReferral");
176
+ agent.set(discKey([158, 66, 173, 69, 248, 86, 13, 237]), "logActivity");
177
+ agent.set(discKey([165, 163, 235, 109, 240, 153, 233, 188]), "logActivityWithReferral");
178
+ agent.set(discKey([123, 211, 233, 210, 166, 139, 218, 60]), "initBuffer");
179
+ agent.set(discKey([114, 53, 121, 144, 201, 97, 248, 69]), "writeToBuffer");
180
+ agent.set(discKey([46, 114, 179, 58, 57, 45, 194, 172]), "closeBuffer");
181
+ agent.set(discKey([215, 42, 43, 208, 191, 38, 11, 146]), "finalizeMemoryNew");
182
+ agent.set(discKey([163, 20, 118, 65, 132, 16, 239, 4]), "finalizeMemoryUpdate");
183
+ agent.set(discKey([50, 204, 47, 193, 90, 227, 5, 220]), "finalizeMemoryAppend");
184
+ agent.set(discKey([23, 235, 115, 232, 168, 96, 1, 231]), "initConfig");
185
+ agent.set(discKey([120, 201, 195, 128, 35, 202, 73, 161]), "expandConfig");
186
+ agent.set(discKey([161, 176, 40, 213, 60, 184, 179, 228]), "updateAdmin");
187
+ agent.set(discKey([16, 11, 242, 97, 55, 197, 142, 249]), "updateRegisterFee");
188
+ agent.set(discKey([129, 209, 121, 34, 163, 184, 187, 56]), "updateReferralConfig");
189
+ agent.set(discKey([81, 250, 176, 204, 250, 169, 146, 144]), "updateTwitterVerifier");
190
+ agent.set(discKey([74, 177, 105, 71, 46, 192, 112, 135]), "updateTwitterVerificationConfig");
191
+ agent.set(discKey([16, 173, 44, 208, 249, 61, 172, 152]), "updateTweetVerifyConfig");
192
+ agent.set(discKey([167, 203, 189, 80, 145, 175, 74, 127]), "updateActivityConfig");
193
+ agent.set(discKey([15, 89, 27, 201, 127, 239, 187, 80]), "updatePointsConfig");
194
+ agent.set(discKey([198, 212, 171, 109, 144, 215, 174, 89]), "withdrawFees");
195
+ agent.set(discKey([64, 212, 105, 60, 34, 93, 221, 176]), "withdrawTwitterVerifyFees");
196
+ agent.set(discKey([48, 169, 76, 72, 229, 180, 55, 161]), "transferAuthority");
197
+ programs.set(DEFAULT_AGENT_REGISTRY_PROGRAM_ID, agent);
198
+
199
+ // Skills Hub
200
+ const skills: DiscriminatorMap = new Map();
201
+ skills.set(discKey([166, 249, 255, 189, 192, 197, 102, 2]), "registerSkill");
202
+ skills.set(discKey([123, 211, 233, 210, 166, 139, 218, 60]), "initBuffer");
203
+ skills.set(discKey([114, 53, 121, 144, 201, 97, 248, 69]), "writeToBuffer");
204
+ skills.set(discKey([46, 114, 179, 58, 57, 45, 194, 172]), "closeBuffer");
205
+ skills.set(discKey([253, 108, 88, 38, 27, 56, 113, 217]), "finalizeSkillNew");
206
+ skills.set(discKey([43, 248, 97, 58, 212, 79, 238, 179]), "finalizeSkillUpdate");
207
+ skills.set(discKey([17, 38, 1, 212, 5, 56, 231, 151]), "deleteSkill");
208
+ skills.set(discKey([234, 4, 121, 243, 47, 60, 8, 236]), "setDescription");
209
+ skills.set(discKey([170, 182, 43, 239, 97, 78, 225, 186]), "updateMetadata");
210
+ skills.set(discKey([23, 235, 115, 232, 168, 96, 1, 231]), "initConfig");
211
+ skills.set(discKey([161, 176, 40, 213, 60, 184, 179, 228]), "updateAdmin");
212
+ skills.set(discKey([16, 11, 242, 97, 55, 197, 142, 249]), "updateRegisterFee");
213
+ skills.set(discKey([198, 212, 171, 109, 144, 215, 174, 89]), "withdrawFees");
214
+ skills.set(discKey([48, 169, 76, 72, 229, 180, 55, 161]), "transferAuthority");
215
+ programs.set(DEFAULT_SKILLS_PROGRAM_ID, skills);
216
+
217
+ // ZK ID
218
+ const zk: DiscriminatorMap = new Map();
219
+ zk.set(discKey([211, 124, 67, 15, 211, 194, 178, 240]), "register");
220
+ zk.set(discKey([242, 35, 198, 137, 82, 225, 242, 182]), "deposit");
221
+ zk.set(discKey([183, 18, 70, 156, 148, 109, 161, 34]), "withdraw");
222
+ zk.set(discKey([58, 45, 142, 162, 7, 21, 17, 83]), "transferZkId");
223
+ zk.set(discKey([175, 175, 109, 31, 13, 152, 155, 237]), "initialize");
224
+ zk.set(discKey([208, 127, 21, 1, 194, 190, 196, 70]), "initializeConfig");
225
+ zk.set(discKey([29, 158, 252, 191, 10, 83, 219, 99]), "updateConfig");
226
+ zk.set(discKey([198, 212, 171, 109, 144, 215, 174, 89]), "withdrawFees");
227
+ programs.set(DEFAULT_ZKID_PROGRAM_ID, zk);
228
+
229
+ return programs;
230
+ }
231
+
232
+ const anchorDiscs = buildAnchorDiscriminators();
233
+
234
+ // ─── Instruction decoders ─────────────────────────────────────────
235
+
236
+ function resolveAccounts(
237
+ ix: MessageCompiledInstruction,
238
+ accountKeys: string[]
239
+ ): string[] {
240
+ return ix.accountKeyIndexes.map((i) => accountKeys[i] ?? `<index:${i}>`);
241
+ }
242
+
243
+ /**
244
+ * Build a full TransactionInstruction from a compiled ix + account key list,
245
+ * so built-in decoders (SystemInstruction, spl-token decodeInstruction) can consume it.
246
+ */
247
+ function toTransactionInstruction(
248
+ ix: MessageCompiledInstruction,
249
+ accountKeys: string[]
250
+ ): TransactionInstruction {
251
+ const programIdStr = accountKeys[ix.programIdIndex] ?? "11111111111111111111111111111111";
252
+ return new TransactionInstruction({
253
+ programId: new PublicKey(programIdStr),
254
+ keys: ix.accountKeyIndexes.map((i) => ({
255
+ pubkey: new PublicKey(accountKeys[i] ?? "11111111111111111111111111111111"),
256
+ isSigner: false,
257
+ isWritable: false,
258
+ })),
259
+ data: Buffer.from(ix.data),
260
+ });
261
+ }
262
+
263
+ function parseComputeBudget(
264
+ data: Buffer
265
+ ): { type: string; info: Record<string, unknown> } {
266
+ // ComputeBudget layout is simple and stable; no built-in decoder in web3.js.
267
+ const ixType = data[0];
268
+ if (ixType === 2) {
269
+ return { type: "setComputeUnitLimit", info: { units: data.readUInt32LE(1) } };
270
+ }
271
+ if (ixType === 3) {
272
+ return { type: "setComputeUnitPrice", info: { microLamports: Number(data.readBigUInt64LE(1)) } };
273
+ }
274
+ if (ixType === 1) {
275
+ return { type: "requestHeapFrame", info: { bytes: data.readUInt32LE(1) } };
276
+ }
277
+ if (ixType === 0) {
278
+ return { type: "requestUnits", info: {} };
279
+ }
280
+ return { type: `unknown(${ixType})`, info: {} };
281
+ }
282
+
283
+ function parseSystemProgram(
284
+ txIx: TransactionInstruction
285
+ ): { type: string; info: Record<string, unknown> } {
286
+ let type: string;
287
+ try {
288
+ type = SystemInstruction.decodeInstructionType(txIx);
289
+ } catch {
290
+ return { type: "unknown", info: {} };
291
+ }
292
+
293
+ try {
294
+ switch (type) {
295
+ case "Create": {
296
+ const d = SystemInstruction.decodeCreateAccount(txIx);
297
+ return {
298
+ type: "createAccount",
299
+ info: {
300
+ from: d.fromPubkey.toBase58(),
301
+ newAccount: d.newAccountPubkey.toBase58(),
302
+ lamports: d.lamports,
303
+ space: d.space,
304
+ owner: d.programId.toBase58(),
305
+ },
306
+ };
307
+ }
308
+ case "Transfer": {
309
+ const d = SystemInstruction.decodeTransfer(txIx);
310
+ const lamports = Number(d.lamports);
311
+ return {
312
+ type: "transfer",
313
+ info: {
314
+ from: d.fromPubkey.toBase58(),
315
+ to: d.toPubkey.toBase58(),
316
+ lamports,
317
+ sol: lamports / 1e9,
318
+ },
319
+ };
320
+ }
321
+ case "TransferWithSeed": {
322
+ const d = SystemInstruction.decodeTransferWithSeed(txIx);
323
+ return {
324
+ type: "transferWithSeed",
325
+ info: {
326
+ from: d.fromPubkey.toBase58(),
327
+ to: d.toPubkey.toBase58(),
328
+ lamports: Number(d.lamports),
329
+ seed: d.seed,
330
+ },
331
+ };
332
+ }
333
+ case "Allocate": {
334
+ const d = SystemInstruction.decodeAllocate(txIx);
335
+ return { type: "allocate", info: { account: d.accountPubkey.toBase58(), space: d.space } };
336
+ }
337
+ case "Assign": {
338
+ const d = SystemInstruction.decodeAssign(txIx);
339
+ return {
340
+ type: "assign",
341
+ info: { account: d.accountPubkey.toBase58(), owner: d.programId.toBase58() },
342
+ };
343
+ }
344
+ case "AdvanceNonceAccount": {
345
+ const d = SystemInstruction.decodeNonceAdvance(txIx);
346
+ return {
347
+ type: "advanceNonce",
348
+ info: { nonce: d.noncePubkey.toBase58(), authorized: d.authorizedPubkey.toBase58() },
349
+ };
350
+ }
351
+ case "WithdrawNonceAccount": {
352
+ const d = SystemInstruction.decodeNonceWithdraw(txIx);
353
+ return {
354
+ type: "withdrawNonce",
355
+ info: {
356
+ nonce: d.noncePubkey.toBase58(),
357
+ to: d.toPubkey.toBase58(),
358
+ lamports: Number(d.lamports),
359
+ },
360
+ };
361
+ }
362
+ default:
363
+ return { type: type.charAt(0).toLowerCase() + type.slice(1), info: {} };
364
+ }
365
+ } catch {
366
+ return { type: type.charAt(0).toLowerCase() + type.slice(1), info: {} };
367
+ }
368
+ }
369
+
370
+ function parseSplToken(
371
+ txIx: TransactionInstruction,
372
+ is2022: boolean
373
+ ): { type: string; info: Record<string, unknown> } {
374
+ const programId = is2022 ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID;
375
+ const program = is2022 ? "Token-2022" : "Token";
376
+
377
+ let decoded: any;
378
+ try {
379
+ decoded = decodeSplTokenInstruction(txIx, programId);
380
+ } catch {
381
+ const ixByte = txIx.data[0];
382
+ return { type: `splToken(${ixByte})`, info: { program } };
383
+ }
384
+
385
+ const ixKind: number = decoded.data.instruction;
386
+ const name = TokenInstruction[ixKind] ?? "unknown";
387
+ const typeStr = name.charAt(0).toLowerCase() + name.slice(1);
388
+ const info: Record<string, unknown> = { program };
389
+
390
+ // `decoded.keys` is an object with named fields — expose them all as base58 strings.
391
+ for (const [key, value] of Object.entries(decoded.keys)) {
392
+ if (value && typeof value === "object" && "pubkey" in (value as object)) {
393
+ info[key] = (value as { pubkey: PublicKey }).pubkey.toBase58();
394
+ }
395
+ }
396
+
397
+ // Expose decoded data fields (amount, decimals, etc.)
398
+ for (const [key, value] of Object.entries(decoded.data)) {
399
+ if (key === "instruction") continue;
400
+ if (typeof value === "bigint") info[key] = Number(value);
401
+ else info[key] = value;
402
+ }
403
+
404
+ return { type: typeStr, info };
405
+ }
406
+
407
+ function parseAta(
408
+ data: Buffer,
409
+ accounts: string[]
410
+ ): { type: string; info: Record<string, unknown> } {
411
+ // ATA program: no built-in decoder; layout is trivially [0]=create, [1]=createIdempotent
412
+ const ixType = data.length > 0 ? data[0] : 0;
413
+ return {
414
+ type: ixType === 1 ? "createIdempotent" : "create",
415
+ info: {
416
+ payer: accounts[0],
417
+ ata: accounts[1],
418
+ owner: accounts[2],
419
+ mint: accounts[3],
420
+ },
421
+ };
422
+ }
423
+
424
+ function parseAnchor(
425
+ pid: string,
426
+ data: Buffer,
427
+ accounts: string[]
428
+ ): { type: string; info: Record<string, unknown> } | null {
429
+ if (data.length < 8) return null;
430
+ const disc = data.subarray(0, 8);
431
+ const key = Buffer.from(disc).toString("hex");
432
+ const discMap = anchorDiscs.get(pid);
433
+ if (!discMap) return null;
434
+ const name = discMap.get(key);
435
+ if (!name) return null;
436
+
437
+ const info: Record<string, unknown> = {};
438
+
439
+ // Extract common fields for known instructions
440
+ if (pid === DEFAULT_AGENT_REGISTRY_PROGRAM_ID) {
441
+ if (name === "registerAgent" || name === "registerAgentWithReferral") {
442
+ if (accounts[0]) info.authority = accounts[0];
443
+ // agentId is first arg after discriminator: 4-byte string length + string
444
+ try {
445
+ const len = data.readUInt32LE(8);
446
+ if (len > 0 && len < 100) info.agentId = data.subarray(12, 12 + len).toString("utf-8");
447
+ } catch {}
448
+ }
449
+ if (name === "setTwitter") {
450
+ if (accounts[0]) info.authority = accounts[0];
451
+ try {
452
+ const len1 = data.readUInt32LE(8);
453
+ const agentId = data.subarray(12, 12 + len1).toString("utf-8");
454
+ const off2 = 12 + len1;
455
+ const len2 = data.readUInt32LE(off2);
456
+ const handle = data.subarray(off2 + 4, off2 + 4 + len2).toString("utf-8");
457
+ info.agentId = agentId;
458
+ info.handle = handle;
459
+ } catch {}
460
+ }
461
+ if (name === "submitTweet") {
462
+ if (accounts[0]) info.authority = accounts[0];
463
+ try {
464
+ const len1 = data.readUInt32LE(8);
465
+ info.agentId = data.subarray(12, 12 + len1).toString("utf-8");
466
+ } catch {}
467
+ }
468
+ }
469
+
470
+ if (pid === DEFAULT_QUEST_PROGRAM_ID) {
471
+ if (name === "submitAnswer") {
472
+ if (accounts[0]) info.user = accounts[0];
473
+ }
474
+ if (name === "stake" || name === "unstake") {
475
+ if (accounts[0]) info.user = accounts[0];
476
+ }
477
+ if (name === "adjustFreeStake") {
478
+ if (accounts[0]) info.authority = accounts[0];
479
+ }
480
+ if (name === "claimAirdrop") {
481
+ if (accounts[0]) info.user = accounts[0];
482
+ }
483
+ if (name === "createQuestion") {
484
+ if (accounts[0]) info.authority = accounts[0];
485
+ }
486
+ }
487
+
488
+ if (pid === DEFAULT_ZKID_PROGRAM_ID) {
489
+ if (name === "register") {
490
+ if (accounts[0]) info.authority = accounts[0];
491
+ }
492
+ if (name === "deposit" || name === "withdraw" || name === "transferZkId") {
493
+ if (accounts[0]) info.user = accounts[0];
494
+ }
495
+ }
496
+
497
+ return { type: name, info };
498
+ }
499
+
500
+ function parseBridgeWarpRoute(
501
+ pid: string,
502
+ data: Buffer,
503
+ accounts: string[]
504
+ ): { type: string; info: Record<string, unknown> } | null {
505
+ // TransferRemote: 9 bytes prefix (all 0x01) + 4 bytes domain + 32 bytes recipient + 32 bytes amount
506
+ if (data.length < 77) return null;
507
+ // Check prefix: first 9 bytes all 0x01
508
+ for (let i = 0; i < 9; i++) {
509
+ if (data[i] !== 0x01) return null;
510
+ }
511
+
512
+ const destinationDomain = data.readUInt32LE(9);
513
+ const recipient = new PublicKey(data.subarray(13, 45)).toBase58();
514
+ const amount = Number(data.readBigUInt64LE(45));
515
+
516
+ // Find token symbol from program ID
517
+ let tokenSymbol = "unknown";
518
+ let mode = "";
519
+ for (const [sym, cfg] of Object.entries(BRIDGE_TOKENS)) {
520
+ if (cfg.solana.warpProgram.toBase58() === pid) {
521
+ tokenSymbol = sym;
522
+ mode = cfg.solana.mode;
523
+ break;
524
+ }
525
+ if (cfg.nara.warpProgram.toBase58() === pid) {
526
+ tokenSymbol = sym;
527
+ mode = cfg.nara.mode;
528
+ break;
529
+ }
530
+ }
531
+
532
+ return {
533
+ type: "transferRemote",
534
+ info: {
535
+ token: tokenSymbol,
536
+ mode,
537
+ destinationDomain,
538
+ recipient,
539
+ amount,
540
+ sender: accounts[6],
541
+ },
542
+ };
543
+ }
544
+
545
+ function parseMemo(
546
+ data: Buffer
547
+ ): { type: string; info: Record<string, unknown> } {
548
+ return { type: "memo", info: { text: data.toString("utf-8") } };
549
+ }
550
+
551
+ // ─── Main decoder ─────────────────────────────────────────────────
552
+
553
+ function decodeInstruction(
554
+ ix: MessageCompiledInstruction,
555
+ index: number,
556
+ accountKeys: string[]
557
+ ): ParsedInstruction {
558
+ const pid = accountKeys[ix.programIdIndex] ?? "<unknown>";
559
+ const accounts = resolveAccounts(ix, accountKeys);
560
+ const data = Buffer.from(ix.data);
561
+ const rawData = Buffer.from(ix.data).toString("base64");
562
+
563
+ let type = "unknown";
564
+ let info: Record<string, unknown> = {};
565
+
566
+ if (pid === COMPUTE_BUDGET) {
567
+ const r = parseComputeBudget(data);
568
+ type = r.type;
569
+ info = r.info;
570
+ } else if (pid === SYSTEM_PROGRAM) {
571
+ const r = parseSystemProgram(toTransactionInstruction(ix, accountKeys));
572
+ type = r.type;
573
+ info = r.info;
574
+ } else if (pid === TOKEN_PROGRAM || pid === TOKEN_2022) {
575
+ const r = parseSplToken(
576
+ toTransactionInstruction(ix, accountKeys),
577
+ pid === TOKEN_2022
578
+ );
579
+ type = r.type;
580
+ info = r.info;
581
+ } else if (pid === ATA_PROGRAM) {
582
+ const r = parseAta(data, accounts);
583
+ type = r.type;
584
+ info = r.info;
585
+ } else if (pid === MEMO_PROGRAM || pid === MEMO_PROGRAM_V1) {
586
+ const r = parseMemo(data);
587
+ type = r.type;
588
+ info = r.info;
589
+ } else if (anchorDiscs.has(pid)) {
590
+ const r = parseAnchor(pid, data, accounts);
591
+ if (r) {
592
+ type = r.type;
593
+ info = r.info;
594
+ }
595
+ } else {
596
+ // Try bridge warp route
597
+ const r = parseBridgeWarpRoute(pid, data, accounts);
598
+ if (r) {
599
+ type = r.type;
600
+ info = r.info;
601
+ }
602
+ }
603
+
604
+ return {
605
+ index,
606
+ programName: getProgramName(pid),
607
+ programId: pid,
608
+ type,
609
+ info,
610
+ accounts,
611
+ rawData,
612
+ };
613
+ }
614
+
615
+ // ─── Public API ───────────────────────────────────────────────────
616
+
617
+ /**
618
+ * Fetch a transaction by signature and parse it.
619
+ *
620
+ * @example
621
+ * ```ts
622
+ * const parsed = await parseTxFromHash(connection, "5abc...");
623
+ * console.log(formatParsedTx(parsed));
624
+ * ```
625
+ */
626
+ export async function parseTxFromHash(
627
+ connection: Connection,
628
+ signature: string
629
+ ): Promise<ParsedTransaction> {
630
+ const tx = await connection.getTransaction(signature, {
631
+ maxSupportedTransactionVersion: 0,
632
+ commitment: "confirmed",
633
+ });
634
+ if (!tx) throw new Error(`Transaction not found: ${signature}`);
635
+ return parseTxResponse(tx);
636
+ }
637
+
638
+ /**
639
+ * Parse a VersionedTransactionResponse (from getTransaction) synchronously.
640
+ *
641
+ * @example
642
+ * ```ts
643
+ * const txResp = await connection.getTransaction(sig, { maxSupportedTransactionVersion: 0 });
644
+ * const parsed = parseTxResponse(txResp);
645
+ * ```
646
+ */
647
+ export function parseTxResponse(tx: VersionedTransactionResponse): ParsedTransaction {
648
+ const sig = tx.transaction.signatures[0] ?? "";
649
+
650
+ // Collect all account keys (static + loaded via ALT)
651
+ const msg = tx.transaction.message;
652
+ const staticKeys = msg.getAccountKeys({
653
+ accountKeysFromLookups: tx.meta?.loadedAddresses,
654
+ });
655
+ const accountKeys: string[] = [];
656
+ for (let i = 0; i < staticKeys.length; i++) {
657
+ accountKeys.push(staticKeys.get(i)!.toBase58());
658
+ }
659
+
660
+ // Parse each top-level instruction
661
+ const compiled = msg.compiledInstructions;
662
+ const instructions: ParsedInstruction[] = compiled.map((ix, i) =>
663
+ decodeInstruction(ix, i, accountKeys)
664
+ );
665
+
666
+ // Also parse inner instructions (CPI)
667
+ if (tx.meta?.innerInstructions) {
668
+ for (const inner of tx.meta.innerInstructions) {
669
+ for (const innerIx of inner.instructions) {
670
+ let dataBuf: Buffer;
671
+ if (typeof innerIx.data === "string") {
672
+ dataBuf = Buffer.from(decodeBase58(innerIx.data));
673
+ } else {
674
+ dataBuf = Buffer.from(innerIx.data);
675
+ }
676
+ const innerCompiled: MessageCompiledInstruction = {
677
+ programIdIndex: innerIx.programIdIndex,
678
+ accountKeyIndexes: innerIx.accounts,
679
+ data: dataBuf,
680
+ };
681
+ instructions.push(
682
+ decodeInstruction(innerCompiled, instructions.length, accountKeys)
683
+ );
684
+ }
685
+ }
686
+ }
687
+
688
+ return {
689
+ signature: sig,
690
+ slot: tx.slot,
691
+ blockTime: tx.blockTime ?? null,
692
+ success: tx.meta?.err === null,
693
+ error: tx.meta?.err ? JSON.stringify(tx.meta.err) : null,
694
+ fee: tx.meta?.fee ?? 0,
695
+ instructions,
696
+ logs: tx.meta?.logMessages ?? [],
697
+ };
698
+ }
699
+
700
+ function decodeBase58(str: string): Uint8Array {
701
+ const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
702
+ const BASE = 58;
703
+ const bytes: number[] = [0];
704
+ for (const char of str) {
705
+ let carry = ALPHABET.indexOf(char);
706
+ if (carry < 0) throw new Error(`Invalid base58 character: ${char}`);
707
+ for (let j = 0; j < bytes.length; j++) {
708
+ carry += bytes[j]! * BASE;
709
+ bytes[j] = carry & 0xff;
710
+ carry >>= 8;
711
+ }
712
+ while (carry > 0) {
713
+ bytes.push(carry & 0xff);
714
+ carry >>= 8;
715
+ }
716
+ }
717
+ // Leading zeros
718
+ for (const char of str) {
719
+ if (char !== "1") break;
720
+ bytes.push(0);
721
+ }
722
+ return new Uint8Array(bytes.reverse());
723
+ }
724
+
725
+ /**
726
+ * Pretty-print a parsed transaction to a human-readable string.
727
+ */
728
+ export function formatParsedTx(parsed: ParsedTransaction): string {
729
+ const lines: string[] = [];
730
+ const status = parsed.success ? "SUCCESS" : `FAILED: ${parsed.error}`;
731
+ const time = parsed.blockTime
732
+ ? new Date(parsed.blockTime * 1000).toISOString()
733
+ : "unknown";
734
+
735
+ lines.push(`Tx: ${parsed.signature}`);
736
+ lines.push(`Status: ${status}`);
737
+ lines.push(`Slot: ${parsed.slot} | Time: ${time} | Fee: ${parsed.fee} lamports`);
738
+ lines.push(`Instructions (${parsed.instructions.length}):`);
739
+ lines.push("");
740
+
741
+ for (const ix of parsed.instructions) {
742
+ const infoStr = Object.entries(ix.info)
743
+ .map(([k, v]) => `${k}=${v}`)
744
+ .join(", ");
745
+ lines.push(` #${ix.index} [${ix.programName}] ${ix.type}`);
746
+ if (infoStr) lines.push(` ${infoStr}`);
747
+ }
748
+
749
+ return lines.join("\n");
750
+ }
package/src/zkid.ts CHANGED
@@ -400,8 +400,16 @@ export async function scanClaimableDeposits(
400
400
  const [zkIdPda] = findZkIdPda(nameHashBuf, programId);
401
401
  const [inboxPda] = findInboxPda(nameHashBuf, programId);
402
402
 
403
- const zkId = await program.account.zkIdAccount.fetch(zkIdPda);
404
- const inbox = await program.account.inboxAccount.fetch(inboxPda);
403
+ // Step 1: fetch zkId + inbox in a single RPC call
404
+ const [zkIdAcct, inboxAcct] = await connection.getMultipleAccountsInfo(
405
+ [zkIdPda, inboxPda],
406
+ "confirmed"
407
+ );
408
+ if (!zkIdAcct) throw new Error(`ZK ID account not found: ${zkIdPda.toBase58()}`);
409
+ if (!inboxAcct) throw new Error(`Inbox account not found: ${inboxPda.toBase58()}`);
410
+
411
+ const zkId = program.coder.accounts.decode("zkIdAccount", zkIdAcct.data);
412
+ const inbox = program.coder.accounts.decode("inboxAccount", inboxAcct.data);
405
413
 
406
414
  const depositCount: number = zkId.depositCount;
407
415
  const commitmentStart: number = zkId.commitmentStartIndex;
@@ -422,23 +430,34 @@ export async function scanClaimableDeposits(
422
430
  // Oldest entry in inbox corresponds to depositIndex = (depositCount - count)
423
431
  const startDepositIndex = depositCount - count;
424
432
 
425
- const claimable: ClaimableDeposit[] = [];
433
+ // Step 2: derive all nullifier PDAs for candidate entries (owned deposits only)
434
+ type Candidate = { leafIndex: bigint; depositIndex: number; denomination: bigint; nullifierPda: PublicKey };
435
+ const candidates: Candidate[] = [];
426
436
  for (let i = 0; i < entries.length; i++) {
427
437
  const depositIndex = startDepositIndex + i;
428
-
429
- // Deposits before commitmentStartIndex belong to a previous owner
430
438
  if (depositIndex < commitmentStart) continue;
431
439
 
432
440
  const { leafIndex, denomination } = entries[i]!;
433
-
434
- // Check if nullifier has been spent
435
441
  const nullifierHash_bi = await poseidonHash([idSecret, BigInt(depositIndex)]);
436
442
  const nullifierHashBuf = bigIntToBytes32BE(nullifierHash_bi);
437
443
  const denominationBN = new BN(denomination.toString());
438
444
  const [nullifierPda] = findNullifierPda(denominationBN, nullifierHashBuf, programId);
439
- const nullifierInfo = await connection.getAccountInfo(nullifierPda);
445
+ candidates.push({ leafIndex, depositIndex, denomination, nullifierPda });
446
+ }
447
+
448
+ if (!candidates.length) return [];
440
449
 
441
- if (nullifierInfo === null) {
450
+ // Step 3: batch-check all nullifiers in a single RPC call
451
+ // (inbox is capped at 64 entries, well under the 100-key limit)
452
+ const nullifierInfos = await connection.getMultipleAccountsInfo(
453
+ candidates.map((c) => c.nullifierPda),
454
+ "confirmed"
455
+ );
456
+
457
+ const claimable: ClaimableDeposit[] = [];
458
+ for (let i = 0; i < candidates.length; i++) {
459
+ if (nullifierInfos[i] === null) {
460
+ const { leafIndex, depositIndex, denomination } = candidates[i]!;
442
461
  claimable.push({ leafIndex, depositIndex, denomination });
443
462
  }
444
463
  }