@@ -6,6 +6,9 @@ const { Connection, PublicKey, Transaction, TransactionInstruction, SystemProgra
66// ============ State ============
77let phantomWallet = null ;
88let walletPublicKey = null ;
9+ let activeTab = 'receive' ; // 'send' | 'receive'
10+ let nobleWalletAddress = null ;
11+ let nobleUsdcBalance = 0n ;
912
1013// ============ DOM Elements ============
1114const elements = {
@@ -49,6 +52,30 @@ const elements = {
4952
5053 // Logs
5154 logs : document . getElementById ( 'logs' ) ,
55+
56+ // Send (Noble) tab elements
57+ nobleWalletStatus : document . getElementById ( 'nobleWalletStatus' ) ,
58+ nobleWalletAddress : document . getElementById ( 'nobleWalletAddress' ) ,
59+ nobleUsdcBalance : document . getElementById ( 'nobleUsdcBalance' ) ,
60+ connectNobleWalletBtn : document . getElementById ( 'connectNobleWalletBtn' ) ,
61+ sendDestChain : document . getElementById ( 'sendDestChain' ) ,
62+ sendDestAddress : document . getElementById ( 'sendDestAddress' ) ,
63+ usePhantomAddressBtn : document . getElementById ( 'usePhantomAddressBtn' ) ,
64+ sendAmount : document . getElementById ( 'sendAmount' ) ,
65+ sendAmountMaxBtn : document . getElementById ( 'sendAmountMaxBtn' ) ,
66+ sendFromNobleBtn : document . getElementById ( 'sendFromNobleBtn' ) ,
67+ goToReceiveBtn : document . getElementById ( 'goToReceiveBtn' ) ,
68+ } ;
69+
70+ // Tab buttons and contents
71+ const tabs = {
72+ send : document . getElementById ( 'tab-send' ) ,
73+ receive : document . getElementById ( 'tab-receive' ) ,
74+ } ;
75+
76+ const tabContents = {
77+ send : document . getElementById ( 'send-tab' ) ,
78+ receive : document . getElementById ( 'receive-tab' ) ,
5279} ;
5380
5481// Progress steps
@@ -149,6 +176,14 @@ Object.entries(sections).forEach(([key, el]) => {
149176 } ) ;
150177} ) ;
151178
179+ // Make top-level tabs clickable
180+ if ( tabs . send ) {
181+ tabs . send . addEventListener ( 'click' , ( ) => setActiveTab ( 'send' ) ) ;
182+ }
183+ if ( tabs . receive ) {
184+ tabs . receive . addEventListener ( 'click' , ( ) => setActiveTab ( 'receive' ) ) ;
185+ }
186+
152187// Byte utilities for browsers (avoid Node Buffer)
153188const textEncoder = new TextEncoder ( ) ;
154189
@@ -211,6 +246,171 @@ function getApiBase() {
211246 return raw . replace ( / \/ + $ / , '' ) ;
212247}
213248
249+ // ============ Noble (Keplr) Wallet ============
250+ async function connectNobleWallet ( ) {
251+ try {
252+ if ( ! window . keplr || ! window . getOfflineSigner ) {
253+ log ( 'Keplr extension not found. Please install Keplr and refresh.' , 'error' ) ;
254+ return ;
255+ }
256+
257+ const chainId = 'noble-1' ;
258+ log ( 'Connecting to Keplr (Noble)...' , 'info' ) ;
259+ await window . keplr . enable ( chainId ) ;
260+
261+ const offlineSigner = window . getOfflineSigner ( chainId ) ;
262+ const accounts = await offlineSigner . getAccounts ( ) ;
263+ if ( ! accounts || accounts . length === 0 ) {
264+ throw new Error ( 'No Noble accounts available in Keplr' ) ;
265+ }
266+
267+ nobleWalletAddress = accounts [ 0 ] . address ;
268+ elements . nobleWalletAddress . textContent =
269+ nobleWalletAddress . substring ( 0 , 10 ) +
270+ '...' +
271+ nobleWalletAddress . substring ( nobleWalletAddress . length - 8 ) ;
272+ elements . nobleWalletStatus . textContent = 'Connected' ;
273+ elements . nobleWalletStatus . className = 'status-badge status-connected' ;
274+
275+ log ( `Connected Noble wallet: ${ nobleWalletAddress } ` , 'success' ) ;
276+
277+ // Load Noble USDC balance
278+ await loadNobleUsdcBalance ( ) ;
279+ } catch ( error ) {
280+ log ( `Failed to connect Keplr (Noble): ${ error . message } ` , 'error' ) ;
281+ }
282+ }
283+
284+ async function loadNobleUsdcBalance ( ) {
285+ if ( ! nobleWalletAddress ) return ;
286+ const lcdBase = elements . nobleRpc ?. value ?. trim ( ) ;
287+ if ( ! lcdBase ) {
288+ log ( 'Noble LCD API URL is empty; cannot load Noble balance.' , 'error' ) ;
289+ return ;
290+ }
291+
292+ const url = `${ lcdBase } /cosmos/bank/v1beta1/balances/${ nobleWalletAddress } ` ;
293+ log ( 'Fetching Noble USDC balance...' , 'info' ) ;
294+
295+ try {
296+ const res = await fetch ( url ) ;
297+ if ( ! res . ok ) {
298+ throw new Error ( `HTTP ${ res . status } ` ) ;
299+ }
300+ const data = await res . json ( ) ;
301+ const balances = data . balances || [ ] ;
302+
303+ // Noble native USDC denom (uusdc)
304+ const usdc = balances . find ( ( b ) => b . denom === 'uusdc' ) ;
305+ if ( ! usdc ) {
306+ nobleUsdcBalance = 0n ;
307+ } else {
308+ nobleUsdcBalance = BigInt ( usdc . amount || '0' ) ;
309+ }
310+
311+ const human = Number ( nobleUsdcBalance ) / 1_000_000 ;
312+ elements . nobleUsdcBalance . value = human . toLocaleString ( undefined , {
313+ minimumFractionDigits : 0 ,
314+ maximumFractionDigits : 6 ,
315+ } ) ;
316+
317+ log ( `Noble USDC balance: ${ human } ` , 'info' ) ;
318+ updateSendFromNobleButton ( ) ;
319+ } catch ( error ) {
320+ log ( `Failed to fetch Noble USDC balance: ${ error . message } ` , 'error' ) ;
321+ elements . nobleUsdcBalance . value = 'Error' ;
322+ }
323+ }
324+
325+ function useMaxSendAmount ( ) {
326+ if ( ! elements . sendAmount ) return ;
327+ if ( nobleUsdcBalance <= 0n ) {
328+ log ( 'No Noble USDC balance available for Max.' , 'warning' ) ;
329+ return ;
330+ }
331+ const human = Number ( nobleUsdcBalance ) / 1_000_000 ;
332+ elements . sendAmount . value = human . toString ( ) ;
333+ }
334+
335+ function usePhantomAddressForDestination ( ) {
336+ if ( ! elements . sendDestAddress ) return ;
337+ if ( ! walletPublicKey ) {
338+ log ( 'Connect Phantom first on the Receive tab to use its address.' , 'warning' ) ;
339+ return ;
340+ }
341+ elements . sendDestAddress . value = walletPublicKey . toString ( ) ;
342+ log ( 'Filled destination address from connected Phantom wallet.' , 'info' ) ;
343+ }
344+
345+ function updateSendFromNobleButton ( ) {
346+ if ( ! elements . sendFromNobleBtn ) return ;
347+ const hasWallet = ! ! nobleWalletAddress ;
348+ const dest = elements . sendDestAddress ? elements . sendDestAddress . value . trim ( ) : '' ;
349+ const amtStr = elements . sendAmount ? elements . sendAmount . value . trim ( ) : '' ;
350+ const amt = Number ( amtStr ) ;
351+ const validAmt = ! Number . isNaN ( amt ) && amt > 0 ;
352+ elements . sendFromNobleBtn . disabled = ! ( hasWallet && dest && validAmt ) ;
353+ }
354+
355+ async function sendFromNoble ( ) {
356+ if ( ! nobleWalletAddress ) {
357+ log ( 'Connect Keplr (Noble) first.' , 'error' ) ;
358+ return ;
359+ }
360+ if ( ! elements . sendDestAddress || ! elements . sendAmount ) {
361+ log ( 'Destination address and amount are required.' , 'error' ) ;
362+ return ;
363+ }
364+
365+ const destChain = elements . sendDestChain ?. value || 'solana' ;
366+ const destAddress = elements . sendDestAddress . value . trim ( ) ;
367+ const amountStr = elements . sendAmount . value . trim ( ) ;
368+ const amount = Number ( amountStr ) ;
369+
370+ if ( ! destAddress ) {
371+ log ( 'Please enter a destination address for Solana.' , 'error' ) ;
372+ return ;
373+ }
374+ if ( Number . isNaN ( amount ) || amount <= 0 ) {
375+ log ( 'Please enter a valid positive USDC amount.' , 'error' ) ;
376+ return ;
377+ }
378+
379+ log ( '--- Noble send (burn) flow is experimental and not yet broadcasting on-chain. ---' , 'warning' ) ;
380+ log ( `Noble from: ${ nobleWalletAddress } ` , 'info' ) ;
381+ log ( `Destination: ${ destChain } -> ${ destAddress } ` , 'info' ) ;
382+ log ( `Amount: ${ amount } USDC` , 'info' ) ;
383+ log (
384+ 'Tx construction & broadcast for the Noble CCTP burn message is not yet implemented. ' +
385+ 'Use Noble CLI or official tooling to originate the burn, then come back to the Receive tab to complete the mint on Solana.' ,
386+ 'warning'
387+ ) ;
388+ }
389+
390+ // ============ Tab Switching ============
391+ function setActiveTab ( nextTab ) {
392+ if ( nextTab !== 'send' && nextTab !== 'receive' ) return ;
393+ activeTab = nextTab ;
394+
395+ Object . entries ( tabs ) . forEach ( ( [ key , btn ] ) => {
396+ if ( ! btn ) return ;
397+ if ( key === nextTab ) {
398+ btn . classList . add ( 'tab-button-active' ) ;
399+ } else {
400+ btn . classList . remove ( 'tab-button-active' ) ;
401+ }
402+ } ) ;
403+
404+ Object . entries ( tabContents ) . forEach ( ( [ key , el ] ) => {
405+ if ( ! el ) return ;
406+ if ( key === nextTab ) {
407+ el . classList . add ( 'tab-content-active' ) ;
408+ } else {
409+ el . classList . remove ( 'tab-content-active' ) ;
410+ }
411+ } ) ;
412+ }
413+
214414// Update global CCTP explorer link from Noble tx hash
215415function updateExplorerLinkFromTxHash ( ) {
216416 if ( ! elements . viewOnExplorer || ! elements . nobleTxHash ) return ;
@@ -526,14 +726,21 @@ async function relayToSolana() {
526726 messageTransmitterProgramId
527727 ) ;
528728
529- // For Noble → Solana USDC on mainnet, the used_nonces PDA for this bucket
530- // is stable and known from on-chain introspection. To keep the UI simple
531- // and robust, we hard-code it here.
532- const usedNonces = new PublicKey ( 'CPG84dn5W4kmtwGKdCBwnbe34zaMGDJQrHPCu5bNHyJ1' ) ;
533- log ( `Using hard-coded UsedNonces account: ${ usedNonces . toString ( ) } ` , 'info' ) ;
534-
535- // Buffer for source domain (still used for other PDAs below)
729+ // Buffer for source domain (used for multiple PDA derivations)
536730 const sourceDomainBuffer = u32ToBytesLE ( sourceDomain ) ;
731+
732+ // Derive UsedNonces PDA dynamically based on source domain and nonce
733+ // The nonce is grouped into buckets of 6400 (0x1900) nonces each
734+ // PDA seeds: "used_nonces" + source_domain (LE u32) + first_nonce_in_bucket (LE u64)
735+ const NONCES_PER_ACCOUNT = 6400n ;
736+ const nonceBucket = ( nonceValue / NONCES_PER_ACCOUNT ) * NONCES_PER_ACCOUNT ;
737+ const firstNonceBuffer = u64ToBytesLE ( nonceBucket ) ;
738+
739+ const [ usedNonces ] = PublicKey . findProgramAddressSync (
740+ [ bytesFromString ( 'used_nonces' ) , sourceDomainBuffer , firstNonceBuffer ] ,
741+ messageTransmitterProgramId
742+ ) ;
743+ log ( `Derived UsedNonces PDA for nonce bucket ${ nonceBucket } : ${ usedNonces . toString ( ) } ` , 'info' ) ;
537744
538745 // TokenMessenger state PDA
539746 const [ tokenMessenger ] = PublicKey . findProgramAddressSync (
@@ -595,15 +802,18 @@ async function relayToSolana() {
595802 // Token program
596803 const TOKEN_PROGRAM_ID = new PublicKey ( 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' ) ;
597804
598- // Event authority PDA for Anchor events (hard-coded for TokenMessengerMinter)
599- const eventAuthority = new PublicKey ( '6mH8scevHQJsyyp1qxu8kyAapHuzEE67mtjFDJZjSbQW' ) ;
805+ // Event authority PDA for Anchor events (derived from MessageTransmitter program)
806+ const [ eventAuthority ] = PublicKey . findProgramAddressSync (
807+ [ bytesFromString ( '__event_authority' ) ] ,
808+ messageTransmitterProgramId
809+ ) ;
600810
601811 // Log all remaining accounts that go into the instruction to help match Left/Right in errors
602812 log ( `localToken: ${ localToken . toString ( ) } ` , 'info' ) ;
603813 log ( `tokenPair: ${ tokenPair . toString ( ) } ` , 'info' ) ;
604814 log ( `custodyToken: ${ custodyToken . toString ( ) } ` , 'info' ) ;
605815 log ( `TOKEN_PROGRAM_ID: ${ TOKEN_PROGRAM_ID . toString ( ) } ` , 'info' ) ;
606- log ( `eventAuthority (hard-coded ): ${ eventAuthority . toString ( ) } ` , 'info' ) ;
816+ log ( `eventAuthority (derived ): ${ eventAuthority . toString ( ) } ` , 'info' ) ;
607817
608818 // Build the receiveMessage instruction
609819 // Discriminator for receive_message in MessageTransmitter
@@ -657,7 +867,17 @@ async function relayToSolana() {
657867 data : instructionData ,
658868 } ) ;
659869
660- const transaction = new Transaction ( ) . add ( instruction ) ;
870+ // SPL Memo Program for adding a distinct app identifier
871+ const MEMO_PROGRAM_ID = new PublicKey ( 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr' ) ;
872+ const memoText = `CCTP Relay via github.com/jasbanza/cctp-relayer | Noble→Solana | nonce:${ nonceValue } ` ;
873+ const memoInstruction = new TransactionInstruction ( {
874+ keys : [ { pubkey : walletPublicKey , isSigner : true , isWritable : false } ] ,
875+ programId : MEMO_PROGRAM_ID ,
876+ data : bytesFromString ( memoText ) ,
877+ } ) ;
878+
879+ const transaction = new Transaction ( ) . add ( instruction ) . add ( memoInstruction ) ;
880+ log ( `Adding memo: "${ memoText } "` , 'info' ) ;
661881
662882 // Get recent blockhash - try proxy first, fall back to direct RPC
663883 let blockhash , lastValidBlockHeight ;
@@ -769,6 +989,35 @@ elements.connectWalletBtn.addEventListener('click', toggleWallet);
769989elements . relayBtn . addEventListener ( 'click' , relayToSolana ) ;
770990elements . clearLogsBtn . addEventListener ( 'click' , clearLogs ) ;
771991
992+ // Noble send tab
993+ if ( elements . connectNobleWalletBtn ) {
994+ elements . connectNobleWalletBtn . addEventListener ( 'click' , connectNobleWallet ) ;
995+ }
996+ if ( elements . sendAmountMaxBtn ) {
997+ elements . sendAmountMaxBtn . addEventListener ( 'click' , useMaxSendAmount ) ;
998+ }
999+ if ( elements . usePhantomAddressBtn ) {
1000+ elements . usePhantomAddressBtn . addEventListener ( 'click' , usePhantomAddressForDestination ) ;
1001+ }
1002+ if ( elements . sendFromNobleBtn ) {
1003+ elements . sendFromNobleBtn . addEventListener ( 'click' , sendFromNoble ) ;
1004+ }
1005+
1006+ // Keep Noble send button state in sync as user types
1007+ if ( elements . sendDestAddress ) {
1008+ elements . sendDestAddress . addEventListener ( 'input' , updateSendFromNobleButton ) ;
1009+ }
1010+ if ( elements . sendAmount ) {
1011+ elements . sendAmount . addEventListener ( 'input' , updateSendFromNobleButton ) ;
1012+ }
1013+
1014+ if ( elements . goToReceiveBtn ) {
1015+ elements . goToReceiveBtn . addEventListener ( 'click' , ( ) => {
1016+ setActiveTab ( 'receive' ) ;
1017+ scrollToSection ( 'section-source' ) ;
1018+ } ) ;
1019+ }
1020+
7721021// Update relay button when fields change
7731022elements . messageHex . addEventListener ( 'input' , ( ) => {
7741023 elements . messageHex . classList . remove ( 'field-computed' ) ;
0 commit comments