Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules/
dist/
release/
.DS_Store
*.log
.vite-cache
Expand Down
156 changes: 117 additions & 39 deletions extension/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
RPC_API_VERSION,
} from '@nockbox/iris-sdk';
import type { RpcRequest, RpcResponse, ConnectResponse } from '@nockbox/iris-sdk';
import { ensureWasmInitialized } from '../shared/wasm-utils';
import wasm from '../shared/sdk-wasm.js';
import type { Note } from '@nockbox/iris-sdk/wasm';
import type { Nicks } from '@nockbox/iris-sdk/wasm';
Expand Down Expand Up @@ -92,6 +93,7 @@ const utxoSyncInFlight = new Map<

// Track pending sub-wallet discoveries (fire-and-forget) so we don't double-schedule.
const subwalletDiscoveryInFlight = new Set<string>();
const subwalletDiscoveryAfterInitialSync = new Set<string>();

async function clearUnlockSessionCache(): Promise<void> {
try {
Expand Down Expand Up @@ -666,6 +668,66 @@ function scheduleSubwalletDiscovery(seedId: string): void {
})();
}

function queueSubwalletDiscoveryAfterInitialSync(seedId: string): void {
if (subwalletDiscoveryInFlight.has(seedId)) return;
subwalletDiscoveryAfterInitialSync.add(seedId);
}

function scheduleQueuedSubwalletDiscoveryAfterInitialSync(accountAddress: string): void {
const currentAccount = vault.getCurrentAccount();
if (currentAccount?.address !== accountAddress || subwalletDiscoveryAfterInitialSync.size === 0) {
return;
}

const seedIds = Array.from(subwalletDiscoveryAfterInitialSync);
subwalletDiscoveryAfterInitialSync.clear();
for (const seedId of seedIds) {
scheduleSubwalletDiscovery(seedId);
}
}

async function syncAccountUTXOsWithDedupe(
accountAddress: string,
accountName = accountAddress
): Promise<{
ok: boolean;
results: Record<string, { success: boolean; error?: string }>;
}> {
let inFlight = utxoSyncInFlight.get(accountAddress);
if (!inFlight) {
inFlight = (async () => {
const results: Record<string, { success: boolean; error?: string }> = {};

if (vault.isLocked()) {
results[accountAddress] = {
success: false,
error: ERROR_CODES.LOCKED,
};
return { ok: true, results };
}

try {
await vault.syncAccountUTXOs(accountAddress);
results[accountAddress] = { success: true };
scheduleQueuedSubwalletDiscoveryAfterInitialSync(accountAddress);
} catch (syncErr) {
console.warn(`[Background] UTXO sync failed for ${accountName}:`, syncErr);
results[accountAddress] = {
success: false,
error: syncErr instanceof Error ? syncErr.message : String(syncErr),
};
}

return { ok: true, results };
})().finally(() => {
utxoSyncInFlight.delete(accountAddress);
});
utxoSyncInFlight.set(accountAddress, inFlight);
}

return inFlight;
}

// Initialize auto-lock setting, load approved origins, vault state, connection monitoring, and schedule alarms
const initPromise = (async () => {
const stored = await chrome.storage.local.get([
Expand Down Expand Up @@ -870,7 +932,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
await sendBridgedResponse({ error: { code: -32602, message: 'Invalid params' } });
return;
}

await ensureWasmInitialized();
const nativeRawTx = wasm.nockchainTxToRawTx(signTxParams.tx);
const nativeNotes = (signTxParams.notes ?? []) as Note[];
const nativeSpendConditions = wasm.rawTxInputSpendConditions(nativeRawTx);
Expand Down Expand Up @@ -1032,6 +1094,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
// Reset the wallet completely - clears all data
await vault.reset();
await clearUnlockSessionCache();
subwalletDiscoveryAfterInitialSync.clear();
subwalletDiscoveryInFlight.clear();
manuallyLocked = false;
sendResponse({ ok: true });

Expand Down Expand Up @@ -1059,7 +1123,13 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (importedExistingPhrase) {
const firstSeedId = vault.getSeedSources()[0]?.id;
if (firstSeedId) {
scheduleSubwalletDiscovery(firstSeedId);
queueSubwalletDiscoveryAfterInitialSync(firstSeedId);
}
const currentAccount = vault.getCurrentAccount();
if (currentAccount) {
void syncAccountUTXOsWithDedupe(currentAccount.address, currentAccount.name).catch(
err => console.warn('[Background] Initial imported wallet sync failed:', err)
);
}
}
}
Expand Down Expand Up @@ -1162,7 +1232,13 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
cur?.address ?? createSeedResult.account.address,
]);
if (payload.params?.[2] === true) {
scheduleSubwalletDiscovery(createSeedResult.seedSource.id);
queueSubwalletDiscoveryAfterInitialSync(createSeedResult.seedSource.id);
const curAfterSeed = vault.getCurrentAccount();
if (curAfterSeed) {
void syncAccountUTXOsWithDedupe(curAfterSeed.address, curAfterSeed.name).catch(err =>
console.warn('[Background] Initial seed source sync failed:', err)
);
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
return;
Expand Down Expand Up @@ -1239,42 +1315,9 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
}

// Serialize across concurrent callers for the same account (popup
// refreshes, multiple screens): a single in-flight sync is reused
// rather than kicking off parallel passes that race on
// saveAccountData. Keyed by address so different accounts can sync
// in parallel if ever requested concurrently.
let inFlight = utxoSyncInFlight.get(requestedAddress);
if (!inFlight) {
inFlight = (async () => {
const results: Record<string, { success: boolean; error?: string }> = {};

if (vault.isLocked()) {
results[account.address] = {
success: false,
error: ERROR_CODES.LOCKED,
};
return { ok: true, results };
}

try {
await vault.syncAccountUTXOs(account.address);
results[account.address] = { success: true };
} catch (syncErr) {
console.warn(`[Background] UTXO sync failed for ${account.name}:`, syncErr);
results[account.address] = {
success: false,
error: syncErr instanceof Error ? syncErr.message : String(syncErr),
};
}

return { ok: true, results };
})().finally(() => {
utxoSyncInFlight.delete(requestedAddress);
});
utxoSyncInFlight.set(requestedAddress, inFlight);
}

const syncOutcome = await inFlight;
// refreshes, multiple screens, initial import sync): a single in-flight
// sync is reused rather than kicking off parallel passes.
const syncOutcome = await syncAccountUTXOsWithDedupe(account.address, account.name);
sendResponse(syncOutcome);
} catch (err) {
console.error('[Background] SYNC_UTXOS error:', err);
Expand Down Expand Up @@ -1927,6 +1970,41 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
}
return;

case INTERNAL_METHODS.ESTIMATE_MAX_BRIDGE:
// params: [destinationAddress] - estimates max bridge amount after reserving network fee
if (vault.isLocked()) {
sendResponse({ error: ERROR_CODES.LOCKED });
return;
}

const [maxBridgeDest] = payload.params || [];
if (!maxBridgeDest || !isEvmAddress(maxBridgeDest)) {
sendResponse({ error: 'Invalid destination address. Expected EVM address (0x...).' });
return;
}

try {
const maxBridgeResult = await vault.estimateMaxBridgeAmount(maxBridgeDest);

if ('error' in maxBridgeResult) {
sendResponse({ error: maxBridgeResult.error });
return;
}

sendResponse({
maxAmount: maxBridgeResult.maxAmount,
fee: maxBridgeResult.fee,
totalAvailable: maxBridgeResult.totalAvailable,
utxoCount: maxBridgeResult.utxoCount,
});
} catch (error) {
console.error('[Background] Max bridge estimation failed:', error);
sendResponse({
error: error instanceof Error ? error.message : 'Max bridge estimation failed',
});
}
return;

case INTERNAL_METHODS.SEND_BRIDGE_TRANSACTION:
// params: [destinationAddress, amountNicks, priceUsdAtTime?]
// EVM address (Base), amount in nicks
Expand Down
2 changes: 1 addition & 1 deletion extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Iris Wallet",
"homepage_url": "https://iriswallet.io",
"version": "1.2.3",
"version": "1.2.4",
"description": "Iris Wallet - Browser Wallet for Nockchain",
"icons": {
"16": "icons/icon16.png",
Expand Down
54 changes: 52 additions & 2 deletions extension/popup/screens/SwapScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ import {
bridgeReceiveAmountAfterProtocolFeeNock,
} from '../../shared/bridge-protocol-fee';
import { formatNock } from '../../shared/currency';
import { INTERNAL_METHODS, NOCK_TO_NICKS } from '../../shared/constants';
import { formatWithCommas, parseAmount } from '../utils/format';
import { send } from '../utils/messaging';

export function SwapScreen() {
const { navigate, wallet, setPendingBridgeSwap, priceUsd, isBalanceFetching } = useStore();
const [amount, setAmount] = useState('');
const [destinationAddress, setDestinationAddress] = useState('');
const [isPreparing, setIsPreparing] = useState(false);
const [isEstimatingMax, setIsEstimatingMax] = useState(false);
const [error, setError] = useState('');
const [amountFontSizePx, setAmountFontSizePx] = useState(36);
const amountContainerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -84,6 +87,38 @@ export function SwapScreen() {
}
}

async function handleMaxAmount() {
setError('');
if (!isEvmAddress(destinationAddress)) {
setError('Enter a valid Base (EVM) address.');
return;
}

setIsEstimatingMax(true);
try {
const result = await send<{
maxAmount?: number;
fee?: number;
totalAvailable?: number;
utxoCount?: number;
error?: string;
}>(INTERNAL_METHODS.ESTIMATE_MAX_BRIDGE, [destinationAddress]);

if (result?.error) {
setError(result.error);
return;
}
if (result?.maxAmount !== undefined) {
const maxAmountNock = Math.floor((result.maxAmount / NOCK_TO_NICKS) * 100000) / 100000;
setAmount(formatNock(maxAmountNock, 5));
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Max bridge estimation failed');
} finally {
setIsEstimatingMax(false);
}
}

const hasDecimalPart = /\.\d/.test(amount.replace(/,/g, ''));
const receiveDisplayAmount =
amountNum > 0 && !Number.isNaN(amountNum)
Expand Down Expand Up @@ -185,7 +220,7 @@ export function SwapScreen() {
<div className="relative flex flex-col gap-2">
{/* You pay (Nockchain) - white/bg card with border */}
<div
className="rounded-lg p-3 flex items-center justify-between gap-3"
className="rounded-lg px-3 pt-3 pb-5 flex items-center justify-between gap-3"
style={{
backgroundColor: 'var(--color-bg)',
border: '1px solid var(--color-divider)',
Expand Down Expand Up @@ -228,6 +263,21 @@ export function SwapScreen() {
{usdValue !== null ? `$${usdValue} USD` : '0 USD'}
<img src={UpDownVec} alt="" className="h-3.5 w-3.5 shrink-0" />
</div>
<div
className="text-[12px] leading-4 font-medium flex items-center gap-2 whitespace-nowrap"
style={{ color: 'var(--color-text-muted)', letterSpacing: '0.24px' }}
>
<span>Spendable: {formatNock(spendableNock, 2)} NOCK</span>
<button
type="button"
onClick={handleMaxAmount}
disabled={isEstimatingMax}
className="shrink-0 rounded-full px-[7px] py-[3px] transition disabled:opacity-50"
style={{ backgroundColor: 'var(--color-surface-800)' }}
>
{isEstimatingMax ? '...' : 'Max'}
</button>
</div>
</div>
<div className="flex items-start gap-2 shrink-0 self-start">
<div className="flex flex-col items-end text-right">
Expand Down Expand Up @@ -443,7 +493,7 @@ export function SwapScreen() {
letterSpacing: '0.14px',
}}
onClick={handleReview}
disabled={false}
disabled={isPreparing || isEstimatingMax}
>
{isPreparing ? 'Preparing...' : 'Review'}
</button>
Expand Down
2 changes: 2 additions & 0 deletions extension/popup/screens/onboarding/ImportScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function ImportScreen() {
navigate,
syncWallet,
refreshWalletAccounts,
fetchBalance,
createMnemonicSeedSource,
onboardingMnemonic,
setOnboardingMnemonic,
Expand Down Expand Up @@ -195,6 +196,7 @@ export function ImportScreen() {
accountSpendableBalances: {},
accountBalanceDetails: {},
});
void fetchBalance();
await refreshWalletAccounts();
setOnboardingMnemonic(null);
navigate('onboarding-import-success');
Expand Down
3 changes: 3 additions & 0 deletions extension/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ export const INTERNAL_METHODS = {
/** Estimate bridge transaction fee for a given destination and amount */
ESTIMATE_BRIDGE_FEE: 'wallet:estimateBridgeFee',

/** Estimate max bridgeable amount after reserving network fee */
ESTIMATE_MAX_BRIDGE: 'wallet:estimateMaxBridge',

/** Build, sign, and broadcast a bridge transaction (Nockchain → Base) */
SEND_BRIDGE_TRANSACTION: 'wallet:sendBridgeTransaction',

Expand Down
3 changes: 3 additions & 0 deletions extension/shared/v0-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@ export async function signAndBroadcastV0Migration(

const signedTx = builder.build();
const signedRawTx = wasm.nockchainTxToRawTx(signedTx) as wasm.RawTxV1;
if (!wasm.guard.isRawTxV1(signedRawTx)) {
throw new Error('Migration transaction not in expected shape');
}
const protobuf = wasm.rawTxToProtobuf(signedRawTx);
const txId = signedTx.id;

Expand Down
Loading
Loading