Skip to content

Commit e441361

Browse files
committed
fix: derive usedNonces and eventAuthority PDAs dynamically, add memo to relay tx
1 parent 9db2890 commit e441361

1 file changed

Lines changed: 260 additions & 11 deletions

File tree

public/app.js

Lines changed: 260 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ const { Connection, PublicKey, Transaction, TransactionInstruction, SystemProgra
66
// ============ State ============
77
let phantomWallet = null;
88
let walletPublicKey = null;
9+
let activeTab = 'receive'; // 'send' | 'receive'
10+
let nobleWalletAddress = null;
11+
let nobleUsdcBalance = 0n;
912

1013
// ============ DOM Elements ============
1114
const 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)
153188
const 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
215415
function 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);
769989
elements.relayBtn.addEventListener('click', relayToSolana);
770990
elements.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
7731022
elements.messageHex.addEventListener('input', () => {
7741023
elements.messageHex.classList.remove('field-computed');

0 commit comments

Comments
 (0)