nara-sdk 1.0.74 → 1.0.76

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
@@ -11,7 +11,7 @@
11
11
 
12
12
  ---
13
13
 
14
- TypeScript/JavaScript SDK for interacting with the Nara blockchain. Build agents, submit transactions, query accounts, and integrate with on-chain programs.
14
+ TypeScript/JavaScript SDK for interacting with the Nara blockchain. Build agents, submit transactions, query accounts, cross-chain bridge, and integrate with on-chain programs.
15
15
 
16
16
  ## Install
17
17
 
@@ -19,29 +19,162 @@ TypeScript/JavaScript SDK for interacting with the Nara blockchain. Build agents
19
19
  npm install nara-sdk
20
20
  ```
21
21
 
22
+ ## Features
23
+
24
+ - **Agent Registry** — Register agents, bind Twitter, submit tweets, verify, referral system
25
+ - **Quest (PoMI)** — Proof of Machine Intelligence ZK quest system, stake, answer-to-earn
26
+ - **Skills Hub** — On-chain skill registry for AI agents, upload/query skill content
27
+ - **ZK ID** — Zero-knowledge anonymous identity, deposit, withdraw, ownership proofs
28
+ - **Cross-chain Bridge** — Nara ↔ Solana bridge via Hyperlane warp routes (USDC, SOL), with in-tx fee extraction and validator signature tracking
29
+
22
30
  ## Quick Start
23
31
 
24
- ```js
25
- import { Connection, Keypair, Transaction } from 'nara-sdk';
32
+ ```ts
33
+ import { Connection, Keypair } from '@solana/web3.js';
34
+ import { getQuestInfo, submitAnswer, generateProof } from 'nara-sdk';
26
35
 
27
36
  const connection = new Connection('https://mainnet-api.nara.build');
28
- const balance = await connection.getBalance(publicKey);
29
37
  ```
30
38
 
31
- ## Features
39
+ ## Cross-chain Bridge
40
+
41
+ Bridge tokens between Solana and Nara with built-in 0.5% fee extraction.
42
+
43
+ ### One-step bridge
44
+
45
+ ```ts
46
+ import { Connection, Keypair } from '@solana/web3.js';
47
+ import { bridgeTransfer, setAltAddress } from 'nara-sdk';
48
+
49
+ const solanaConn = new Connection('https://api.mainnet-beta.solana.com');
50
+
51
+ // Disable Nara ALT when sending from Solana
52
+ setAltAddress(null);
53
+
54
+ const result = await bridgeTransfer(solanaConn, wallet, {
55
+ token: 'USDC', // 'USDC' | 'SOL'
56
+ fromChain: 'solana', // 'solana' | 'nara'
57
+ recipient: targetPubkey, // destination chain address
58
+ amount: 1_000_000n, // raw units (1 USDC = 1_000_000)
59
+ });
32
60
 
61
+ console.log(result.signature); // source chain tx
62
+ console.log(result.messageId); // cross-chain message ID (0x...)
63
+ console.log(result.feeAmount); // fee deducted
64
+ console.log(result.bridgeAmount); // net amount bridged
33
65
  ```
34
- Transactions Build, sign, and send transactions
35
- Accounts Query balances, token accounts, and program state
36
- Programs Interact with Nara on-chain programs (Agent Registry, PoMI, ZK ID)
37
- Keypairs Generate and manage wallet keypairs
38
- RPC Client Full RPC method coverage
66
+
67
+ ### Build instructions for relay
68
+
69
+ ```ts
70
+ import { makeBridgeIxs } from 'nara-sdk';
71
+
72
+ const { instructions, uniqueMessageKeypair, feeAmount, bridgeAmount } =
73
+ makeBridgeIxs({
74
+ token: 'USDC',
75
+ fromChain: 'solana',
76
+ sender: userPubkey,
77
+ recipient: targetPubkey,
78
+ amount: 1_000_000n,
79
+ });
80
+
81
+ // uniqueMessageKeypair must sign the tx
39
82
  ```
40
83
 
41
- ## CLI
84
+ ### Track cross-chain message
42
85
 
43
- ```bash
44
- npx nara-sdk --help
86
+ ```ts
87
+ import {
88
+ extractMessageId,
89
+ queryMessageSignatures,
90
+ queryMessageStatus,
91
+ } from 'nara-sdk';
92
+
93
+ // 1. Extract message ID from source tx
94
+ const messageId = await extractMessageId(connection, signature);
95
+
96
+ // 2. Query validator signatures (3-way parallel scan on S3)
97
+ const sigs = await queryMessageSignatures(messageId, 'solana');
98
+ console.log(sigs.signedCount, '/', sigs.totalValidators); // e.g. 3/3
99
+ console.log(sigs.fullySigned); // true
100
+
101
+ // 3. Check delivery on destination chain
102
+ const status = await queryMessageStatus(naraConn, messageId, 'nara');
103
+ console.log(status.delivered); // true
104
+ console.log(status.deliverySignature); // destination tx
105
+ ```
106
+
107
+ ### Supported tokens
108
+
109
+ | Token | Solana side | Nara side | Decimals |
110
+ |---|---|---|---|
111
+ | USDC | collateral (lock) | synthetic (mint, Token-2022) | 6 |
112
+ | SOL | native (lamports) | synthetic (mint, Token-2022) | 9 |
113
+
114
+ Add new tokens at runtime:
115
+
116
+ ```ts
117
+ import { registerBridgeToken } from 'nara-sdk';
118
+
119
+ registerBridgeToken('USDT', {
120
+ symbol: 'USDT',
121
+ decimals: 6,
122
+ solana: { warpProgram, mode: 'collateral', mint, tokenProgram },
123
+ nara: { warpProgram, mode: 'synthetic', mint, tokenProgram },
124
+ });
125
+ ```
126
+
127
+ ### Fee configuration
128
+
129
+ Default fee: **0.5%** (50 bps), deducted from the bridged amount on the source chain.
130
+
131
+ ```ts
132
+ import { setBridgeFeeRecipient } from 'nara-sdk';
133
+
134
+ // Override fee recipient at runtime
135
+ setBridgeFeeRecipient('YourFeeRecipientPubkey...');
136
+
137
+ // Or per-call
138
+ await bridgeTransfer(conn, wallet, {
139
+ ...params,
140
+ feeBps: 100, // 1%
141
+ feeRecipient: customPubkey, // override
142
+ skipFee: true, // or skip entirely
143
+ });
144
+ ```
145
+
146
+ ## Agent Registry
147
+
148
+ ```ts
149
+ import {
150
+ registerAgent,
151
+ getAgentRecord,
152
+ setTwitter,
153
+ verifyTwitter,
154
+ submitTweet,
155
+ approveTweet,
156
+ } from 'nara-sdk';
157
+
158
+ // Register an agent
159
+ await registerAgent(connection, wallet, agentId, name, metadataUri);
160
+
161
+ // Twitter verification flow
162
+ await setTwitter(connection, wallet, agentId, handle);
163
+ await verifyTwitter(connection, verifierWallet, agentId);
164
+
165
+ // Tweet submission & approval
166
+ await submitTweet(connection, wallet, agentId, tweetId, tweetUrl);
167
+ await approveTweet(connection, verifierWallet, agentId, tweetId, freeCredits);
168
+ ```
169
+
170
+ ## Quest (PoMI)
171
+
172
+ ```ts
173
+ import { getQuestInfo, generateProof, submitAnswer } from 'nara-sdk';
174
+
175
+ const quest = await getQuestInfo(connection);
176
+ const proof = await generateProof(quest.question, answer);
177
+ const sig = await submitAnswer(connection, wallet, proof);
45
178
  ```
46
179
 
47
180
  ## Documentation
package/index.ts CHANGED
@@ -15,6 +15,9 @@ export {
15
15
  DEFAULT_ZKID_PROGRAM_ID,
16
16
  DEFAULT_AGENT_REGISTRY_PROGRAM_ID,
17
17
  DEFAULT_ALT_ADDRESS,
18
+ DEFAULT_BRIDGE_FEE_BPS,
19
+ DEFAULT_BRIDGE_FEE_RECIPIENT,
20
+ BRIDGE_FEE_BPS_DENOMINATOR,
18
21
  } from "./src/constants";
19
22
 
20
23
  // Export signing utilities
@@ -165,6 +168,57 @@ export {
165
168
  type AgentRegistryOptions,
166
169
  } from "./src/agent_registry";
167
170
 
171
+ // Export bridge functions and types
172
+ export {
173
+ // Constants
174
+ SOLANA_DOMAIN,
175
+ NARA_DOMAIN,
176
+ SOLANA_MAILBOX,
177
+ NARA_MAILBOX,
178
+ SPL_NOOP,
179
+ SOLANA_USDC_MINT,
180
+ NARA_USDC_MINT,
181
+ SOLANA_USDT_MINT,
182
+ NARA_USDT_MINT,
183
+ NARA_SOL_MINT,
184
+ BRIDGE_TOKENS,
185
+ // Token registry
186
+ registerBridgeToken,
187
+ // Fee recipient runtime override
188
+ setBridgeFeeRecipient,
189
+ getBridgeFeeRecipient,
190
+ // PDA helpers
191
+ deriveOutboxPda,
192
+ deriveDispatchAuthorityPda,
193
+ deriveDispatchedMessagePda,
194
+ deriveTokenPda,
195
+ deriveEscrowPda,
196
+ deriveNativeCollateralPda,
197
+ // Encoders
198
+ encodeTransferRemote,
199
+ // Fee + ix builders
200
+ calculateBridgeFee,
201
+ makeBridgeFeeIxs,
202
+ makeTransferRemoteIx,
203
+ makeBridgeIxs,
204
+ // High level
205
+ bridgeTransfer,
206
+ extractMessageId,
207
+ queryMessageStatus,
208
+ queryMessageSignatures,
209
+ type BridgeChain,
210
+ type BridgeMode,
211
+ type BridgeTokenSide,
212
+ type BridgeTokenConfig,
213
+ type BridgeTransferParams,
214
+ type BridgeIxsResult,
215
+ type BridgeTransferResult,
216
+ type FeeSplit,
217
+ type MessageStatus,
218
+ type ValidatorSignature,
219
+ type MessageSignatureStatus,
220
+ } from "./src/bridge";
221
+
168
222
  // Export IDLs and types
169
223
  export { default as NaraQuestIDL } from "./src/idls/nara_quest.json";
170
224
  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.74",
3
+ "version": "1.0.76",
4
4
  "description": "SDK for the Nara chain (Solana-compatible)",
5
5
  "module": "index.ts",
6
6
  "main": "index.ts",
package/src/bridge.ts ADDED
@@ -0,0 +1,804 @@
1
+ /**
2
+ * Cross-chain bridge SDK between Nara and Solana via Hyperlane warp routes.
3
+ *
4
+ * Supports two assets out of the box (USDC, SOL) in both directions, with
5
+ * an in-tx fee deduction (default 0.5%) sent to BRIDGE_FEE_RECIPIENT.
6
+ *
7
+ * Adding a new asset:
8
+ * 1. Append a new entry to BRIDGE_TOKENS with {symbol, decimals, solana, nara}.
9
+ * 2. The PDA derivation, account-list construction, and instruction encoding
10
+ * are mode-driven (collateral / synthetic / native), no per-token code paths.
11
+ */
12
+
13
+ import {
14
+ Connection,
15
+ Keypair,
16
+ PublicKey,
17
+ SystemProgram,
18
+ TransactionInstruction,
19
+ } from "@solana/web3.js";
20
+ import {
21
+ createAssociatedTokenAccountIdempotentInstruction,
22
+ createTransferCheckedInstruction,
23
+ getAssociatedTokenAddressSync,
24
+ TOKEN_2022_PROGRAM_ID,
25
+ TOKEN_PROGRAM_ID,
26
+ } from "@solana/spl-token";
27
+ import {
28
+ BRIDGE_FEE_BPS_DENOMINATOR,
29
+ DEFAULT_BRIDGE_FEE_BPS,
30
+ DEFAULT_BRIDGE_FEE_RECIPIENT,
31
+ } from "./constants";
32
+ import { sendTx } from "./tx";
33
+
34
+ // ─── Types ────────────────────────────────────────────────────────
35
+
36
+ export type BridgeChain = "solana" | "nara";
37
+ export type BridgeMode = "collateral" | "synthetic" | "native";
38
+
39
+ export interface BridgeTokenSide {
40
+ warpProgram: PublicKey;
41
+ mode: BridgeMode;
42
+ /** SPL mint pubkey. null for Solana native SOL. */
43
+ mint: PublicKey | null;
44
+ /** SPL token program. null for Solana native SOL. */
45
+ tokenProgram: PublicKey | null;
46
+ }
47
+
48
+ export interface BridgeTokenConfig {
49
+ symbol: string;
50
+ decimals: number;
51
+ solana: BridgeTokenSide;
52
+ nara: BridgeTokenSide;
53
+ }
54
+
55
+ export interface BridgeTransferParams {
56
+ /** Token symbol from BRIDGE_TOKENS (e.g. "USDC", "SOL") */
57
+ token: string;
58
+ /** Source chain — fee is deducted on this chain in the source token */
59
+ fromChain: BridgeChain;
60
+ /** Sender pubkey on the source chain */
61
+ sender: PublicKey;
62
+ /** Recipient pubkey on the destination chain */
63
+ recipient: PublicKey;
64
+ /** Gross amount (raw units, source-chain decimals). Fee is deducted from this. */
65
+ amount: bigint;
66
+ /** Optional fee bps override (defaults to BRIDGE_FEE_BPS) */
67
+ feeBps?: number;
68
+ /** Optional fee recipient override (defaults to BRIDGE_FEE_RECIPIENT) */
69
+ feeRecipient?: PublicKey;
70
+ /** Skip fee deduction entirely */
71
+ skipFee?: boolean;
72
+ }
73
+
74
+ export interface BridgeIxsResult {
75
+ /** All instructions in order: [feeIx?, atapayerIx?, transferRemoteIx] */
76
+ instructions: TransactionInstruction[];
77
+ /** Extra signer required by the transferRemote ix (the unique message keypair) */
78
+ uniqueMessageKeypair: Keypair;
79
+ /** Fee deducted in source token raw units */
80
+ feeAmount: bigint;
81
+ /** Net amount actually bridged */
82
+ bridgeAmount: bigint;
83
+ }
84
+
85
+ export interface BridgeTransferResult {
86
+ signature: string;
87
+ messageId: string | null;
88
+ feeAmount: bigint;
89
+ bridgeAmount: bigint;
90
+ }
91
+
92
+ // ─── Chain constants ───────────────────────────────────────────────
93
+
94
+ export const SOLANA_DOMAIN = 1399811149;
95
+ export const NARA_DOMAIN = 40778959;
96
+
97
+ export const SOLANA_MAILBOX = new PublicKey(
98
+ "E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi"
99
+ );
100
+ export const NARA_MAILBOX = new PublicKey(
101
+ "EjtLD3MCBJregFKAce2pQqPtSnnmBWK5oAZ3wBifHnaH"
102
+ );
103
+
104
+ export const SPL_NOOP = new PublicKey(
105
+ "noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV"
106
+ );
107
+
108
+ // ─── Token mint constants ─────────────────────────────────────────
109
+
110
+ export const SOLANA_USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
111
+ export const NARA_USDC_MINT = new PublicKey("8P7UGWjq86N3WUmwEgKeGHJZLcoMJqr5jnRUmeBN7YwR");
112
+ export const SOLANA_USDT_MINT = new PublicKey("Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB");
113
+ export const NARA_USDT_MINT = new PublicKey("8yQSyqC85A9Vcqz8gTU2Bk5Y63bnC5378sgx1biTKsjd");
114
+ export const NARA_SOL_MINT = new PublicKey("7fKh7DqPZmsYPHdGvt9Qw2rZkSEGp9F5dBa3XuuuhavU");
115
+
116
+ function mailboxFor(chain: BridgeChain): PublicKey {
117
+ return chain === "solana" ? SOLANA_MAILBOX : NARA_MAILBOX;
118
+ }
119
+
120
+ function destinationDomainFor(toChain: BridgeChain): number {
121
+ return toChain === "solana" ? SOLANA_DOMAIN : NARA_DOMAIN;
122
+ }
123
+
124
+ // ─── Token registry ───────────────────────────────────────────────
125
+
126
+ export const BRIDGE_TOKENS: Record<string, BridgeTokenConfig> = {
127
+ USDC: {
128
+ symbol: "USDC",
129
+ decimals: 6,
130
+ solana: {
131
+ warpProgram: new PublicKey("4GcZJTa8s9vxtTz97Vj1RrwKMqPkT3DiiJkvUQDwsuZP"),
132
+ mode: "collateral",
133
+ mint: SOLANA_USDC_MINT,
134
+ tokenProgram: TOKEN_PROGRAM_ID,
135
+ },
136
+ nara: {
137
+ warpProgram: new PublicKey("BC2j6WrdPs9xhU9CfBwJsYSnJrGq5Tcm4SEen9ENv7go"),
138
+ mode: "synthetic",
139
+ mint: NARA_USDC_MINT,
140
+ tokenProgram: TOKEN_2022_PROGRAM_ID,
141
+ },
142
+ },
143
+ USDT: {
144
+ symbol: "USDT",
145
+ decimals: 6,
146
+ solana: {
147
+ warpProgram: new PublicKey("DCTt9H3pwwU89qC3Z4voYNThZypV68AwhYNzMNBxWXoy"),
148
+ mode: "collateral",
149
+ mint: SOLANA_USDT_MINT,
150
+ tokenProgram: TOKEN_PROGRAM_ID,
151
+ },
152
+ nara: {
153
+ warpProgram: new PublicKey("2q5HJaaagMxBM7GD5yR55xHN4tDZMh1gYraG1Y4wbry6"),
154
+ mode: "synthetic",
155
+ mint: NARA_USDT_MINT,
156
+ tokenProgram: TOKEN_2022_PROGRAM_ID,
157
+ },
158
+ },
159
+ SOL: {
160
+ symbol: "SOL",
161
+ decimals: 9,
162
+ solana: {
163
+ warpProgram: new PublicKey("46MmAWwKRAt9uvn7m44NXbVq2DCWBQE2r1TDw25nyXrt"),
164
+ mode: "native",
165
+ mint: null,
166
+ tokenProgram: null,
167
+ },
168
+ nara: {
169
+ warpProgram: new PublicKey("6bKmjEMbjcJUnqAiNw7AXuMvUALzw5XRKiV9dBsterxg"),
170
+ mode: "synthetic",
171
+ mint: NARA_SOL_MINT,
172
+ tokenProgram: TOKEN_2022_PROGRAM_ID,
173
+ },
174
+ },
175
+ };
176
+
177
+ /** Register a new bridge token at runtime */
178
+ export function registerBridgeToken(symbol: string, config: BridgeTokenConfig): void {
179
+ BRIDGE_TOKENS[symbol] = config;
180
+ }
181
+
182
+ function getToken(symbol: string): BridgeTokenConfig {
183
+ const t = BRIDGE_TOKENS[symbol];
184
+ if (!t) throw new Error(`Unknown bridge token: ${symbol}`);
185
+ return t;
186
+ }
187
+
188
+ // ─── Fee recipient (runtime override) ─────────────────────────────
189
+
190
+ let _feeRecipientOverride: PublicKey | null = null;
191
+
192
+ /** Override the bridge fee recipient at runtime */
193
+ export function setBridgeFeeRecipient(recipient: PublicKey | string | null): void {
194
+ if (recipient === null) {
195
+ _feeRecipientOverride = null;
196
+ return;
197
+ }
198
+ _feeRecipientOverride =
199
+ typeof recipient === "string" ? new PublicKey(recipient) : recipient;
200
+ }
201
+
202
+ export function getBridgeFeeRecipient(): PublicKey {
203
+ if (_feeRecipientOverride) return _feeRecipientOverride;
204
+ return new PublicKey(DEFAULT_BRIDGE_FEE_RECIPIENT);
205
+ }
206
+
207
+ // ─── PDA derivation ───────────────────────────────────────────────
208
+
209
+ export function deriveOutboxPda(mailbox: PublicKey): PublicKey {
210
+ const [pda] = PublicKey.findProgramAddressSync(
211
+ [Buffer.from("hyperlane"), Buffer.from("-"), Buffer.from("outbox")],
212
+ mailbox
213
+ );
214
+ return pda;
215
+ }
216
+
217
+ export function deriveDispatchAuthorityPda(warpProgram: PublicKey): PublicKey {
218
+ const [pda] = PublicKey.findProgramAddressSync(
219
+ [
220
+ Buffer.from("hyperlane_dispatcher"),
221
+ Buffer.from("-"),
222
+ Buffer.from("dispatch_authority"),
223
+ ],
224
+ warpProgram
225
+ );
226
+ return pda;
227
+ }
228
+
229
+ export function deriveDispatchedMessagePda(
230
+ mailbox: PublicKey,
231
+ uniqueMessagePubkey: PublicKey
232
+ ): PublicKey {
233
+ const [pda] = PublicKey.findProgramAddressSync(
234
+ [
235
+ Buffer.from("hyperlane"),
236
+ Buffer.from("-"),
237
+ Buffer.from("dispatched_message"),
238
+ Buffer.from("-"),
239
+ uniqueMessagePubkey.toBuffer(),
240
+ ],
241
+ mailbox
242
+ );
243
+ return pda;
244
+ }
245
+
246
+ export function deriveTokenPda(warpProgram: PublicKey): PublicKey {
247
+ const [pda] = PublicKey.findProgramAddressSync(
248
+ [
249
+ Buffer.from("hyperlane_message_recipient"),
250
+ Buffer.from("-"),
251
+ Buffer.from("handle"),
252
+ Buffer.from("-"),
253
+ Buffer.from("account_metas"),
254
+ ],
255
+ warpProgram
256
+ );
257
+ return pda;
258
+ }
259
+
260
+ export function deriveEscrowPda(warpProgram: PublicKey): PublicKey {
261
+ const [pda] = PublicKey.findProgramAddressSync(
262
+ [
263
+ Buffer.from("hyperlane_token"),
264
+ Buffer.from("-"),
265
+ Buffer.from("escrow"),
266
+ ],
267
+ warpProgram
268
+ );
269
+ return pda;
270
+ }
271
+
272
+ export function deriveNativeCollateralPda(warpProgram: PublicKey): PublicKey {
273
+ const [pda] = PublicKey.findProgramAddressSync(
274
+ [
275
+ Buffer.from("hyperlane_token"),
276
+ Buffer.from("-"),
277
+ Buffer.from("native_collateral"),
278
+ ],
279
+ warpProgram
280
+ );
281
+ return pda;
282
+ }
283
+
284
+ // ─── Instruction data encoder ─────────────────────────────────────
285
+
286
+ /**
287
+ * Encodes a TransferRemote instruction body.
288
+ * Layout:
289
+ * [9 bytes prefix 0x01*9] [4 bytes destinationDomain u32 LE]
290
+ * [32 bytes recipient] [32 bytes amount as U256 LE]
291
+ */
292
+ export function encodeTransferRemote(
293
+ destinationDomain: number,
294
+ recipient: PublicKey,
295
+ amount: bigint
296
+ ): Buffer {
297
+ const buf = Buffer.alloc(9 + 4 + 32 + 32);
298
+ for (let i = 0; i < 9; i++) buf[i] = 0x01;
299
+ buf.writeUInt32LE(destinationDomain, 9);
300
+ recipient.toBuffer().copy(buf, 13);
301
+ buf.writeBigUInt64LE(amount, 45);
302
+ return buf;
303
+ }
304
+
305
+ // ─── Fee calculation ──────────────────────────────────────────────
306
+
307
+ export interface FeeSplit {
308
+ feeAmount: bigint;
309
+ bridgeAmount: bigint;
310
+ feeBps: number;
311
+ }
312
+
313
+ export function calculateBridgeFee(amount: bigint, feeBps?: number): FeeSplit {
314
+ const bps = feeBps ?? DEFAULT_BRIDGE_FEE_BPS;
315
+ if (bps < 0 || bps > BRIDGE_FEE_BPS_DENOMINATOR) {
316
+ throw new Error(`Invalid feeBps: ${bps}`);
317
+ }
318
+ const feeAmount = (amount * BigInt(bps)) / BigInt(BRIDGE_FEE_BPS_DENOMINATOR);
319
+ const bridgeAmount = amount - feeAmount;
320
+ return { feeAmount, bridgeAmount, feeBps: bps };
321
+ }
322
+
323
+ // ─── Fee instruction builder ──────────────────────────────────────
324
+
325
+ /**
326
+ * Build the fee-collection instructions on the source chain in the source token.
327
+ *
328
+ * For SPL tokens: returns [createIdempotentATA(feeRecipient), transferChecked].
329
+ * For native SOL on Solana: returns [SystemProgram.transfer].
330
+ *
331
+ * Returns empty array if feeAmount == 0.
332
+ */
333
+ export function makeBridgeFeeIxs(params: {
334
+ token: string;
335
+ fromChain: BridgeChain;
336
+ sender: PublicKey;
337
+ feeRecipient: PublicKey;
338
+ feeAmount: bigint;
339
+ }): TransactionInstruction[] {
340
+ const { token, fromChain, sender, feeRecipient, feeAmount } = params;
341
+ if (feeAmount === 0n) return [];
342
+
343
+ const tokenCfg = getToken(token);
344
+ const side = tokenCfg[fromChain];
345
+
346
+ // Native SOL → SystemProgram.transfer
347
+ if (side.mode === "native") {
348
+ return [
349
+ SystemProgram.transfer({
350
+ fromPubkey: sender,
351
+ toPubkey: feeRecipient,
352
+ lamports: feeAmount,
353
+ }),
354
+ ];
355
+ }
356
+
357
+ // SPL token (collateral or synthetic) → ensure recipient ATA + transferChecked
358
+ if (!side.mint || !side.tokenProgram) {
359
+ throw new Error(`Token ${token} on ${fromChain} missing mint/tokenProgram`);
360
+ }
361
+
362
+ const senderAta = getAssociatedTokenAddressSync(
363
+ side.mint,
364
+ sender,
365
+ false,
366
+ side.tokenProgram
367
+ );
368
+ const recipientAta = getAssociatedTokenAddressSync(
369
+ side.mint,
370
+ feeRecipient,
371
+ true, // allowOwnerOffCurve — fee recipient may be a PDA
372
+ side.tokenProgram
373
+ );
374
+
375
+ return [
376
+ createAssociatedTokenAccountIdempotentInstruction(
377
+ sender,
378
+ recipientAta,
379
+ feeRecipient,
380
+ side.mint,
381
+ side.tokenProgram
382
+ ),
383
+ createTransferCheckedInstruction(
384
+ senderAta,
385
+ side.mint,
386
+ recipientAta,
387
+ sender,
388
+ feeAmount,
389
+ tokenCfg.decimals,
390
+ [],
391
+ side.tokenProgram
392
+ ),
393
+ ];
394
+ }
395
+
396
+ // ─── TransferRemote instruction builder ───────────────────────────
397
+
398
+ /**
399
+ * Build the warp route TransferRemote instruction for a given token+direction.
400
+ * The unique message keypair must be passed in (it's a required tx signer).
401
+ */
402
+ export function makeTransferRemoteIx(params: {
403
+ token: string;
404
+ fromChain: BridgeChain;
405
+ sender: PublicKey;
406
+ recipient: PublicKey;
407
+ amount: bigint;
408
+ uniqueMessageKeypair: Keypair;
409
+ }): TransactionInstruction {
410
+ const { token, fromChain, sender, recipient, amount, uniqueMessageKeypair } =
411
+ params;
412
+
413
+ const tokenCfg = getToken(token);
414
+ const side = tokenCfg[fromChain];
415
+ const toChain: BridgeChain = fromChain === "solana" ? "nara" : "solana";
416
+
417
+ const mailbox = mailboxFor(fromChain);
418
+ const tokenPda = deriveTokenPda(side.warpProgram);
419
+ const dispatchAuthPda = deriveDispatchAuthorityPda(side.warpProgram);
420
+ const outboxPda = deriveOutboxPda(mailbox);
421
+ const dispatchedMsgPda = deriveDispatchedMessagePda(
422
+ mailbox,
423
+ uniqueMessageKeypair.publicKey
424
+ );
425
+
426
+ // First 9 accounts are common to all modes
427
+ const keys = [
428
+ { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
429
+ { pubkey: SPL_NOOP, isSigner: false, isWritable: false },
430
+ { pubkey: tokenPda, isSigner: false, isWritable: false },
431
+ { pubkey: mailbox, isSigner: false, isWritable: false },
432
+ { pubkey: outboxPda, isSigner: false, isWritable: true },
433
+ { pubkey: dispatchAuthPda, isSigner: false, isWritable: false },
434
+ { pubkey: sender, isSigner: true, isWritable: true },
435
+ { pubkey: uniqueMessageKeypair.publicKey, isSigner: true, isWritable: false },
436
+ { pubkey: dispatchedMsgPda, isSigner: false, isWritable: true },
437
+ ];
438
+
439
+ // Plugin-specific accounts
440
+ if (side.mode === "collateral") {
441
+ if (!side.mint || !side.tokenProgram) {
442
+ throw new Error(`Collateral mode requires mint+tokenProgram for ${token}`);
443
+ }
444
+ const senderAta = getAssociatedTokenAddressSync(
445
+ side.mint,
446
+ sender,
447
+ false,
448
+ side.tokenProgram
449
+ );
450
+ const escrowPda = deriveEscrowPda(side.warpProgram);
451
+ keys.push(
452
+ { pubkey: side.tokenProgram, isSigner: false, isWritable: false },
453
+ { pubkey: side.mint, isSigner: false, isWritable: true },
454
+ { pubkey: senderAta, isSigner: false, isWritable: true },
455
+ { pubkey: escrowPda, isSigner: false, isWritable: true }
456
+ );
457
+ } else if (side.mode === "synthetic") {
458
+ if (!side.mint || !side.tokenProgram) {
459
+ throw new Error(`Synthetic mode requires mint+tokenProgram for ${token}`);
460
+ }
461
+ const senderAta = getAssociatedTokenAddressSync(
462
+ side.mint,
463
+ sender,
464
+ false,
465
+ side.tokenProgram
466
+ );
467
+ keys.push(
468
+ { pubkey: side.tokenProgram, isSigner: false, isWritable: false },
469
+ { pubkey: side.mint, isSigner: false, isWritable: true },
470
+ { pubkey: senderAta, isSigner: false, isWritable: true }
471
+ );
472
+ } else if (side.mode === "native") {
473
+ const nativeCollateralPda = deriveNativeCollateralPda(side.warpProgram);
474
+ keys.push(
475
+ { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
476
+ { pubkey: nativeCollateralPda, isSigner: false, isWritable: true }
477
+ );
478
+ }
479
+
480
+ const data = encodeTransferRemote(destinationDomainFor(toChain), recipient, amount);
481
+
482
+ return new TransactionInstruction({
483
+ programId: side.warpProgram,
484
+ keys,
485
+ data,
486
+ });
487
+ }
488
+
489
+ // ─── High-level: build full bridge instruction set ────────────────
490
+
491
+ /**
492
+ * Build all instructions needed to bridge tokens cross-chain with in-tx fee.
493
+ * Returns the instructions, the unique-message keypair (must sign the tx),
494
+ * and the fee/bridge amounts.
495
+ */
496
+ export function makeBridgeIxs(params: BridgeTransferParams): BridgeIxsResult {
497
+ const {
498
+ token,
499
+ fromChain,
500
+ sender,
501
+ recipient,
502
+ amount,
503
+ feeBps,
504
+ feeRecipient,
505
+ skipFee,
506
+ } = params;
507
+
508
+ if (amount <= 0n) throw new Error("amount must be > 0");
509
+
510
+ const split = skipFee
511
+ ? { feeAmount: 0n, bridgeAmount: amount, feeBps: 0 }
512
+ : calculateBridgeFee(amount, feeBps);
513
+
514
+ if (split.bridgeAmount <= 0n) {
515
+ throw new Error("bridge amount after fee is zero — increase amount or lower feeBps");
516
+ }
517
+
518
+ const recipientForFee = feeRecipient ?? getBridgeFeeRecipient();
519
+ const feeIxs = makeBridgeFeeIxs({
520
+ token,
521
+ fromChain,
522
+ sender,
523
+ feeRecipient: recipientForFee,
524
+ feeAmount: split.feeAmount,
525
+ });
526
+
527
+ const uniqueMessageKeypair = Keypair.generate();
528
+ const transferIx = makeTransferRemoteIx({
529
+ token,
530
+ fromChain,
531
+ sender,
532
+ recipient,
533
+ amount: split.bridgeAmount,
534
+ uniqueMessageKeypair,
535
+ });
536
+
537
+ return {
538
+ instructions: [...feeIxs, transferIx],
539
+ uniqueMessageKeypair,
540
+ feeAmount: split.feeAmount,
541
+ bridgeAmount: split.bridgeAmount,
542
+ };
543
+ }
544
+
545
+ // ─── Send + confirm ───────────────────────────────────────────────
546
+
547
+ /**
548
+ * Build, sign, and send a bridge transfer in one call.
549
+ * `wallet` is the sender keypair on the source chain and pays fees + signs.
550
+ *
551
+ * `connection` must point to the SOURCE chain RPC:
552
+ * - fromChain: "solana" → Solana mainnet RPC
553
+ * - fromChain: "nara" → Nara mainnet RPC
554
+ */
555
+ export async function bridgeTransfer(
556
+ connection: Connection,
557
+ wallet: Keypair,
558
+ params: Omit<BridgeTransferParams, "sender">,
559
+ opts?: { skipPreflight?: boolean; computeUnitLimit?: number; computeUnitPrice?: number | "auto" }
560
+ ): Promise<BridgeTransferResult> {
561
+ const built = makeBridgeIxs({
562
+ ...params,
563
+ sender: wallet.publicKey,
564
+ });
565
+
566
+ const signature = await sendTx(
567
+ connection,
568
+ wallet,
569
+ built.instructions,
570
+ [built.uniqueMessageKeypair],
571
+ {
572
+ computeUnitLimit: opts?.computeUnitLimit ?? 1_400_000,
573
+ computeUnitPrice: opts?.computeUnitPrice,
574
+ skipPreflight: opts?.skipPreflight,
575
+ }
576
+ );
577
+
578
+ // Try to extract message_id from logs
579
+ const messageId = await extractMessageId(connection, signature);
580
+
581
+ return {
582
+ signature,
583
+ messageId,
584
+ feeAmount: built.feeAmount,
585
+ bridgeAmount: built.bridgeAmount,
586
+ };
587
+ }
588
+
589
+ // ─── Message ID extraction ────────────────────────────────────────
590
+
591
+ /**
592
+ * Extract the cross-chain message_id from a dispatch tx's logs.
593
+ * Returns null if the log is missing (tx not yet visible to RPC, etc.).
594
+ */
595
+ export async function extractMessageId(
596
+ connection: Connection,
597
+ signature: string
598
+ ): Promise<string | null> {
599
+ const tx = await connection.getTransaction(signature, {
600
+ maxSupportedTransactionVersion: 0,
601
+ commitment: "confirmed",
602
+ });
603
+ if (!tx?.meta?.logMessages) return null;
604
+ for (const log of tx.meta.logMessages) {
605
+ const m = log.match(/Dispatched message to \d+, ID (0x[0-9a-fA-F]+)/);
606
+ if (m && m[1]) return m[1];
607
+ }
608
+ return null;
609
+ }
610
+
611
+ // ─── Message delivery status ──────────────────────────────────────
612
+
613
+ export interface MessageStatus {
614
+ /** Whether the message has been processed on the destination chain */
615
+ delivered: boolean;
616
+ /** Destination chain tx signature (null if not yet delivered) */
617
+ deliverySignature: string | null;
618
+ }
619
+
620
+ /**
621
+ * Query whether a cross-chain message has been delivered on the destination chain.
622
+ *
623
+ * Scans recent transactions on the destination Mailbox for the message_id.
624
+ * The Hyperlane inbox emits: "Hyperlane inbox processed message 0x..."
625
+ *
626
+ * @param destConnection - connection to the DESTINATION chain RPC
627
+ * @param messageId - the 0x-prefixed message ID from extractMessageId()
628
+ * @param toChain - "solana" | "nara" (which chain to check)
629
+ * @param opts.limit - how many recent Mailbox txs to scan (default 50)
630
+ */
631
+ export async function queryMessageStatus(
632
+ destConnection: Connection,
633
+ messageId: string,
634
+ toChain: BridgeChain,
635
+ opts?: { limit?: number }
636
+ ): Promise<MessageStatus> {
637
+ const mailbox = mailboxFor(toChain);
638
+ const limit = opts?.limit ?? 50;
639
+
640
+ const sigs = await destConnection.getSignaturesForAddress(mailbox, {
641
+ limit,
642
+ });
643
+
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
+ });
650
+ if (!tx?.meta?.logMessages) continue;
651
+ for (const log of tx.meta.logMessages) {
652
+ if (log.includes(messageId)) {
653
+ return { delivered: true, deliverySignature: entry.signature };
654
+ }
655
+ }
656
+ }
657
+
658
+ return { delivered: false, deliverySignature: null };
659
+ }
660
+
661
+ // ─── Validator signature status (S3) ──────────────────────────────
662
+
663
+ const S3_BASE = "https://nara-hyperlane.s3.us-west-1.amazonaws.com";
664
+
665
+ /** Validator S3 prefixes per source chain */
666
+ const VALIDATOR_PREFIXES: Record<BridgeChain, string[]> = {
667
+ solana: ["validator-solana-1", "validator-solana-2", "validator-solana-3"],
668
+ nara: ["validator-nara-1", "validator-nara-2", "validator-nara-3"],
669
+ };
670
+
671
+ export interface ValidatorSignature {
672
+ folder: string;
673
+ signed: boolean;
674
+ latestIndex: number;
675
+ serializedSignature: string | null;
676
+ }
677
+
678
+ export interface MessageSignatureStatus {
679
+ messageId: string;
680
+ /** Merkle tree checkpoint index for this message (null if not found) */
681
+ checkpointIndex: number | null;
682
+ sourceChain: BridgeChain;
683
+ validators: ValidatorSignature[];
684
+ signedCount: number;
685
+ totalValidators: number;
686
+ /** All validators have signed */
687
+ fullySigned: boolean;
688
+ }
689
+
690
+ async function s3Json<T = unknown>(key: string): Promise<T | null> {
691
+ try {
692
+ const resp = await fetch(`${S3_BASE}/${key}`);
693
+ if (!resp.ok) return null;
694
+ return (await resp.json()) as T;
695
+ } catch {
696
+ return null;
697
+ }
698
+ }
699
+
700
+ interface S3Checkpoint {
701
+ value: {
702
+ checkpoint: {
703
+ merkle_tree_hook_address: string;
704
+ mailbox_domain: number;
705
+ root: string;
706
+ index: number;
707
+ };
708
+ message_id: string;
709
+ };
710
+ signature: { r: string; s: string; v: number };
711
+ serialized_signature: string;
712
+ }
713
+
714
+ /**
715
+ * Query Hyperlane validator signatures for a cross-chain message from AWS S3.
716
+ *
717
+ * Each validator stores signed merkle checkpoints in S3. Each checkpoint
718
+ * at index N contains the message_id of the Nth dispatched message and the
719
+ * validator's ECDSA signature over the merkle root.
720
+ *
721
+ * All validators are scanned concurrently (one goroutine per validator).
722
+ *
723
+ * @param messageId - 0x-prefixed message ID from extractMessageId()
724
+ * @param sourceChain - source chain where the message was dispatched
725
+ * @param opts.maxScan - how many checkpoints to scan backwards (default 200)
726
+ */
727
+ export async function queryMessageSignatures(
728
+ messageId: string,
729
+ sourceChain: BridgeChain,
730
+ opts?: { maxScan?: number }
731
+ ): Promise<MessageSignatureStatus> {
732
+ const prefixes = VALIDATOR_PREFIXES[sourceChain];
733
+ const maxScan = opts?.maxScan ?? 200;
734
+
735
+ // Each validator independently: fetch latest index → reverse-scan for messageId
736
+ const results = await Promise.all(
737
+ prefixes.map((folder) => scanValidator(folder, messageId, maxScan))
738
+ );
739
+
740
+ // checkpointIndex comes from whichever validator found it
741
+ const found = results.find((r) => r.checkpointIndex !== null);
742
+ const checkpointIndex = found?.checkpointIndex ?? null;
743
+
744
+ const validators: ValidatorSignature[] = results.map((r) => ({
745
+ folder: r.folder,
746
+ signed: r.signed,
747
+ latestIndex: r.latestIndex,
748
+ serializedSignature: r.serializedSignature,
749
+ }));
750
+
751
+ const signedCount = validators.filter((v) => v.signed).length;
752
+ return {
753
+ messageId,
754
+ checkpointIndex,
755
+ sourceChain,
756
+ validators,
757
+ signedCount,
758
+ totalValidators: prefixes.length,
759
+ fullySigned: signedCount === prefixes.length,
760
+ };
761
+ }
762
+
763
+ async function scanValidator(
764
+ folder: string,
765
+ messageId: string,
766
+ maxScan: number
767
+ ): Promise<ValidatorSignature & { checkpointIndex: number | null }> {
768
+ const latestIndex = await s3Json<number>(
769
+ `${folder}/checkpoint_latest_index.json`
770
+ );
771
+ if (latestIndex === null) {
772
+ return {
773
+ folder,
774
+ signed: false,
775
+ latestIndex: -1,
776
+ serializedSignature: null,
777
+ checkpointIndex: null,
778
+ };
779
+ }
780
+
781
+ const minIndex = Math.max(0, latestIndex - maxScan);
782
+ for (let i = latestIndex; i >= minIndex; i--) {
783
+ const cp = await s3Json<S3Checkpoint>(
784
+ `${folder}/checkpoint_${i}_with_id.json`
785
+ );
786
+ if (cp?.value?.message_id === messageId) {
787
+ return {
788
+ folder,
789
+ signed: true,
790
+ latestIndex,
791
+ serializedSignature: cp.serialized_signature,
792
+ checkpointIndex: i,
793
+ };
794
+ }
795
+ }
796
+
797
+ return {
798
+ folder,
799
+ signed: false,
800
+ latestIndex,
801
+ serializedSignature: null,
802
+ checkpointIndex: null,
803
+ };
804
+ }
package/src/constants.ts CHANGED
@@ -45,3 +45,23 @@ export const DEFAULT_AGENT_REGISTRY_PROGRAM_ID =
45
45
  * When empty, uses legacy transactions.
46
46
  */
47
47
  export const DEFAULT_ALT_ADDRESS = process.env.ALT_ADDRESS || "3uw7RatGTB4hdHnuVLXjsqcMZ87zXsMSc3XbyoPA8mB7";
48
+
49
+ /**
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.
53
+ */
54
+ export const DEFAULT_BRIDGE_FEE_BPS = 50;
55
+
56
+ /** BPS denominator (10000 = 100%) */
57
+ export const BRIDGE_FEE_BPS_DENOMINATOR = 10000;
58
+
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.
65
+ */
66
+ export const DEFAULT_BRIDGE_FEE_RECIPIENT =
67
+ "FERLFwBpCyoEuvFP68eP6Fv4FCVocnNyyFUCYwpfmjqn";