Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
24 changes: 18 additions & 6 deletions apps/extension/e2e/transaction/cadence-transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ export const sendTokenFlow = async ({
await page.getByTestId('next').click();
await page.getByTestId('confirm').click();
// Wait for the transaction to be completed
const txId = await waitForTransaction({ page, successtext: /Executed|Sealed/, ingoreFlowCharge });
const txId = await waitForTransaction({
page,
successtext: /success|Finalized|Executed|Sealed/,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a typo in the parameter name ingoreFlowCharge - it should be ignoreFlowCharge. This typo appears to be pre-existing in the codebase, but it's being propagated in the new code.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a typo in successtext - it should be successText (camelCase). This inconsistency in naming convention could cause confusion.

ingoreFlowCharge,
});
return { txId, tokenname, amount, ingoreFlowCharge };
};

Expand All @@ -47,7 +51,11 @@ export const moveTokenFlow = async ({
await page.getByRole('button', { name: 'Move' }).click();

// Wait for the transaction to be completed
const txId = await waitForTransaction({ page, successtext: /Executed|Sealed/, ingoreFlowCharge });
const txId = await waitForTransaction({
page,
successtext: /success|Finalized|Executed|Sealed/,
ingoreFlowCharge,
});
return { txId, tokenname, amount, ingoreFlowCharge };
};

Expand All @@ -66,7 +74,11 @@ export const moveTokenFlowHomepage = async ({
await page.getByPlaceholder('0.00').fill(amount);
await page.getByRole('button', { name: 'Move' }).click();
// Wait for the transaction to be completed
const txId = await waitForTransaction({ page, successtext: /Executed|Sealed/, ingoreFlowCharge });
const txId = await waitForTransaction({
page,
successtext: /success|Finalized|Executed|Sealed/,
ingoreFlowCharge,
});
return { txId, tokenname, amount, ingoreFlowCharge };
};

Expand All @@ -79,7 +91,7 @@ let txList: { txId: string; tokenname: string; amount: string; ingoreFlowCharge:

//Send FLOW token from Flow to Flow
test('send Cadence transactions', async ({ page, extensionId }) => {
test.setTimeout(120_000);
test.setTimeout(600_000);
await loginToSenderAccount({ page, extensionId });

await switchToMainAccount({
Expand Down Expand Up @@ -126,7 +138,7 @@ test('send Cadence transactions', async ({ page, extensionId }) => {

//Send FLOW token to evm
test('send Cadence transactions to evm', async ({ page, extensionId }) => {
test.setTimeout(120_000);
test.setTimeout(600_000);
await loginToSenderAccount({ page, extensionId });

await switchToMainAccount({
Expand Down Expand Up @@ -172,7 +184,7 @@ test('send Cadence transactions to evm', async ({ page, extensionId }) => {
});

test('send from evm to Flow', async ({ page, extensionId }) => {
test.setTimeout(120_000);
test.setTimeout(600_000);
await loginToSenderAccount({ page, extensionId });

await switchToEOAAccount({
Expand Down
16 changes: 10 additions & 6 deletions apps/extension/e2e/transaction/ft-transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@
await page.getByTestId('next').click();
await page.getByTestId('confirm').click();
// Wait for the transaction to be completed
const txId = await waitForTransaction({ page, successtext: /Executed|Sealed/, ingoreFlowCharge });
const txId = await waitForTransaction({
page,
successtext: /success|Finalized|Executed|Sealed/,
ingoreFlowCharge,
});
return { txId, tokenName, amount, ingoreFlowCharge };
};

Expand All @@ -47,7 +51,7 @@

//Send FLOW token from Flow to Flow
test('send FTs ', async ({ page, extensionId }) => {
test.setTimeout(120_000);
test.setTimeout(600_000);
await loginToSenderAccount({
page,
extensionId,
Expand Down Expand Up @@ -86,8 +90,8 @@
);
});

test('send FTs with Coa ', async ({ page, extensionId }) => {

Check warning on line 93 in apps/extension/e2e/transaction/ft-transaction.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

apps/extension/e2e/transaction/ft-transaction.test.ts#L93

'extensionId' is defined but never used. Allowed unused args must match /^_/u (unused-imports/no-unused-vars)
test.setTimeout(120_000);
test.setTimeout(600_000);
const txList: { txId: string; tokenName: string; amount: string; ingoreFlowCharge: boolean }[] =
[];
await switchToEvmAddress({
Expand Down Expand Up @@ -119,7 +123,7 @@
});

test('send FTs with EOA ', async ({ page, extensionId }) => {
test.setTimeout(120_000);
test.setTimeout(600_000);
await loginToEOAAccount({
page,
extensionId,
Expand Down Expand Up @@ -156,7 +160,7 @@

// child account ft receive test
// test('send FTs tp child ', async ({ page, extensionId }) => {
// test.setTimeout(120_000);
// test.setTimeout(600_000);
// const txList: { txId: string; tokenName: string; amount: string; ingoreFlowCharge: boolean }[] =
// [];
// await switchToMainAccount({
Expand Down Expand Up @@ -196,7 +200,7 @@

// // child account ft send test
// test('send FTs with child ', async ({ page, extensionId }) => {
// test.setTimeout(120_000);
// test.setTimeout(600_000);
// const txList: { txId: string; tokenName: string; amount: string; ingoreFlowCharge: boolean }[] =
// [];
// await switchToChildAccount({
Expand Down
8 changes: 4 additions & 4 deletions apps/extension/e2e/utils/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@

export const checkNFTTrx = async ({ page, sealedText, collectionName, txId, isEvm = false }) => {
const activityItemRegexp = getActivityItemRegexp(txId);
const sealedItem = page.getByTestId(activityItemRegexp).filter({ hasText: sealedText });
const sealedItem = page.getByTestId(activityItemRegexp).filter({ hasText: sealedText }).first();
await expect(sealedItem).toBeVisible({
timeout: 60_000,
});
Expand Down Expand Up @@ -591,7 +591,7 @@
ingoreFlowCharge = false,
}) => {
const activityItemRegexp = getActivityItemRegexp(txId, ingoreFlowCharge);
const sealedItem = page.getByTestId(activityItemRegexp).filter({ hasText: sealedText });
const sealedItem = page.getByTestId(activityItemRegexp).filter({ hasText: sealedText }).first();
await expect(sealedItem).toBeVisible({
timeout: 60_000,
});
Expand Down Expand Up @@ -637,8 +637,8 @@
// Get the executed item with the cadence txId that was put in the url and status is success
const executedItem = page.getByTestId(activityItemRegexp).filter({ hasText: successtext });

await expect(executedItem).toBeVisible({
timeout: 100_000,
await expect(executedItem.first()).toBeVisible({

Check failure on line 640 in apps/extension/e2e/utils/helper.ts

View workflow job for this annotation

GitHub Actions / e2e (22.x)

[transaction-test] › e2e/transaction/cadence-transaction.test.ts:186:1 › send from evm to Flow

1) [transaction-test] › e2e/transaction/cadence-transaction.test.ts:186:1 › send from evm to Flow Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByTestId(/^.*7f29e1e9f2cf874eb3e0f04253494e5913bd0a7044a4831a2b0bf222253c1fcd.*$/).filter({ hasText: /success|Finalized|Executed|Sealed/ }).first() Expected: visible Timeout: 200000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 200000ms - waiting for getByTestId(/^.*7f29e1e9f2cf874eb3e0f04253494e5913bd0a7044a4831a2b0bf222253c1fcd.*$/).filter({ hasText: /success|Finalized|Executed|Sealed/ }).first() at utils/helper.ts:640 638 | const executedItem = page.getByTestId(activityItemRegexp).filter({ hasText: successtext }); 639 | > 640 | await expect(executedItem.first()).toBeVisible({ | ^ 641 | timeout: 200_000, 642 | }); 643 | at waitForTransaction (/home/runner/work/FRW-monorepo/FRW-monorepo/apps/extension/e2e/utils/helper.ts:640:38) at sendTokenFlow (/home/runner/work/FRW-monorepo/FRW-monorepo/apps/extension/e2e/transaction/cadence-transaction.test.ts:31:16) at /home/runner/work/FRW-monorepo/FRW-monorepo/apps/extension/e2e/transaction/cadence-transaction.test.ts:195:15

Check failure on line 640 in apps/extension/e2e/utils/helper.ts

View workflow job for this annotation

GitHub Actions / e2e (22.x)

[transaction-test] › e2e/transaction/cadence-transaction.test.ts:186:1 › send from evm to Flow

1) [transaction-test] › e2e/transaction/cadence-transaction.test.ts:186:1 › send from evm to Flow Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByTestId(/^.*0bdb91a9b0c999c86717503e3077887dcf2e3f9c398d7581be95a21f5c560292.*$/).filter({ hasText: /success|Finalized|Executed|Sealed/ }).first() Expected: visible Timeout: 200000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 200000ms - waiting for getByTestId(/^.*0bdb91a9b0c999c86717503e3077887dcf2e3f9c398d7581be95a21f5c560292.*$/).filter({ hasText: /success|Finalized|Executed|Sealed/ }).first() at utils/helper.ts:640 638 | const executedItem = page.getByTestId(activityItemRegexp).filter({ hasText: successtext }); 639 | > 640 | await expect(executedItem.first()).toBeVisible({ | ^ 641 | timeout: 200_000, 642 | }); 643 | at waitForTransaction (/home/runner/work/FRW-monorepo/FRW-monorepo/apps/extension/e2e/utils/helper.ts:640:38) at sendTokenFlow (/home/runner/work/FRW-monorepo/FRW-monorepo/apps/extension/e2e/transaction/cadence-transaction.test.ts:31:16) at /home/runner/work/FRW-monorepo/FRW-monorepo/apps/extension/e2e/transaction/cadence-transaction.test.ts:195:15

Check failure on line 640 in apps/extension/e2e/utils/helper.ts

View workflow job for this annotation

GitHub Actions / e2e (22.x)

[transaction-test] › e2e/transaction/cadence-transaction.test.ts:186:1 › send from evm to Flow

1) [transaction-test] › e2e/transaction/cadence-transaction.test.ts:186:1 › send from evm to Flow Error: expect(locator).toBeVisible() failed Locator: getByTestId(/^.*131bc2def84d02ec91c4163dee65773b6efcff6be24e31baed4472dd9ab3b557.*$/).filter({ hasText: /success|Finalized|Executed|Sealed/ }).first() Expected: visible Timeout: 200000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 200000ms - waiting for getByTestId(/^.*131bc2def84d02ec91c4163dee65773b6efcff6be24e31baed4472dd9ab3b557.*$/).filter({ hasText: /success|Finalized|Executed|Sealed/ }).first() at utils/helper.ts:640 638 | const executedItem = page.getByTestId(activityItemRegexp).filter({ hasText: successtext }); 639 | > 640 | await expect(executedItem.first()).toBeVisible({ | ^ 641 | timeout: 200_000, 642 | }); 643 | at waitForTransaction (/home/runner/work/FRW-monorepo/FRW-monorepo/apps/extension/e2e/utils/helper.ts:640:38) at sendTokenFlow (/home/runner/work/FRW-monorepo/FRW-monorepo/apps/extension/e2e/transaction/cadence-transaction.test.ts:31:16) at /home/runner/work/FRW-monorepo/FRW-monorepo/apps/extension/e2e/transaction/cadence-transaction.test.ts:195:15
timeout: 200_000,
});

if (amount) {
Expand Down
4 changes: 4 additions & 0 deletions apps/extension/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ async function restoreAppState() {

await userWalletService.init();
await transactionActivityService.init();
// Re-register FCL monitoring for any transactions that were pending before
// the service worker was restarted. This prevents items from being stuck
// in a non-terminal status (e.g. PENDING, Executed) indefinitely.
userWalletService.resumePendingTransactions().catch(() => {});
await nftService.init();
await googleDriveService.init({
baseURL: 'https://www.googleapis.com/',
Expand Down
55 changes: 49 additions & 6 deletions apps/extension/src/core/service/transaction-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,25 @@ class TransactionActivity {
);
const existingTxList = existingTxStore?.list || [];
const existingPendingList = await this.getPendingList(network, address);

// If the in-memory pending list is empty (e.g., service worker was restarted),
// recover any in-progress items from the cached list so they are not lost while
// the API has not yet indexed those transactions.
// This includes PENDING, Executed, and Finalized states — all of which represent
// transactions that are not yet confirmed by the indexer. Recovering only PENDING
// items misses the case where updatePending already advanced the status before
// the SW restarted (common for EVM/Cadence hybrid transactions like withdrawCoa).
const TERMINAL_STATUSES = new Set(['SEALED', 'EXPIRED', 'ERROR']);
if (existingPendingList.length === 0 && existingTxList.length > 0) {
const cachedPendingItems = existingTxList.filter(
(item) => !TERMINAL_STATUSES.has((item.status ?? '').toUpperCase())
);
if (cachedPendingItems.length > 0) {
existingPendingList.push(...cachedPendingItems);
this.setPendingList(network, address, existingPendingList);
}
}

const txList: TransferItem[] = [];
data?.transactions?.forEach(async (tx) => {
const transactionHolder = {
Expand Down Expand Up @@ -428,11 +447,13 @@ class TransactionActivity {
transactionHolder.transferType = tx.transfer_type;
transactionHolder.additionalMessage = tx.additional_message;
// see if there's a pending item for this transaction
// Use case-insensitive comparison to handle API/FCL hash casing differences
const normalizedTxid = tx.txid.toLowerCase();
const pendingItemIndex = existingPendingList.findIndex(
(item) =>
item.hash.includes(tx.txid) ||
item.cadenceTxId?.includes(tx.txid) ||
item.evmTxIds?.includes(tx.txid)
item.hash.toLowerCase().includes(normalizedTxid) ||
item.cadenceTxId?.toLowerCase().includes(normalizedTxid) ||
item.evmTxIds?.some((id) => id.toLowerCase().includes(normalizedTxid))
Comment on lines +454 to +456

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using includes() for hash comparison is potentially problematic. If normalizedTxid is a substring of another hash, this could lead to false positive matches. Consider using strict equality (===) instead of includes() for hash comparisons to ensure exact matches.

);
if (pendingItemIndex !== -1) {
// Store the cadence transaction id
Expand All @@ -443,9 +464,9 @@ class TransactionActivity {
// see if there's an existing transaction with cadenceId in the store
const existingTx = existingTxList.find(
(item) =>
item.hash.includes(tx.txid) ||
item.cadenceTxId?.includes(tx.txid) ||
item.evmTxIds?.includes(tx.txid)
item.hash.toLowerCase().includes(normalizedTxid) ||
item.cadenceTxId?.toLowerCase().includes(normalizedTxid) ||
item.evmTxIds?.some((id) => id.toLowerCase().includes(normalizedTxid))
Comment on lines +467 to +469

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as above - using includes() for hash comparison could match unintended transactions if one hash is a substring of another. Consider using strict equality (===) for reliable hash matching.

);
if (existingTx && existingTx.cadenceTxId) {
// Found existing cadence transaction id
Expand Down Expand Up @@ -595,6 +616,28 @@ class TransactionActivity {
return this.getPendingList(network, address);
};

/**
* Returns Cadence txIds from cached non-terminal transactions that need FCL monitoring
* re-registered after a service worker restart. Only items with a cadenceTxId and a
* non-terminal status (i.e., not SEALED/EXPIRED/ERROR) are returned.
*/
getRecoverableCadenceTxIds = async (network: string, address: string): Promise<string[]> => {
const TERMINAL_STATUSES = new Set(['SEALED', 'EXPIRED', 'ERROR']);
const existingTxStore = await getInvalidData<TransferListStore>(
transferListKey(network, address, '0', '15')
);
if (!existingTxStore?.list?.length) {
return [];
}
const recoverableIds = new Set<string>();
for (const item of existingTxStore.list) {
if (item.cadenceTxId && !TERMINAL_STATUSES.has((item.status ?? '').toUpperCase())) {
recoverableIds.add(item.cadenceTxId);
}
}
return Array.from(recoverableIds);
};

getCount = async (
network: string,
address: string,
Expand Down
45 changes: 45 additions & 0 deletions apps/extension/src/core/service/userWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1157,6 +1157,51 @@ class UserWallet {
}
};

/**
* Re-registers FCL monitoring for any pending Cadence transactions that were in
* progress before a service worker restart. This is necessary because FCL
* onceExecuted/onceSealed promise chains are lost when the SW restarts, leaving
* items permanently stuck in a non-terminal status (PENDING, Executed, Finalized).
*
* Unlike listenTransaction, this does NOT call setPending — the cached entry already
* exists. It only re-attaches FCL event subscriptions so updatePending can advance
* the status once on-chain confirmation arrives.
*/
resumePendingTransactions = async (): Promise<void> => {
try {
const network = await this.getNetwork();
const address = await this.getCurrentAddress();
if (!network || !address) {
return;
}
const cadenceTxIds = await transactionActivityService.getRecoverableCadenceTxIds(
network,
address
);
for (const txId of cadenceTxIds) {
this.reattachFclMonitoring(network, address, txId).catch(() => {});
}
} catch {
// Do not throw — SW startup should not fail due to recovery errors
}
};

private reattachFclMonitoring = async (
network: string,
address: string,
txId: string
): Promise<void> => {
if (!txId || !txId.match(/^0?x?[0-9a-fA-F]{64}/)) {
return;
}
const fclTx = fcl.tx(txId);
const txStatusExecuted = await fclTx.onceExecuted();
await transactionActivityService.updatePending(network, address, txId, txStatusExecuted);
const txStatusSealed = await fclTx.onceSealed();
await transactionActivityService.updatePending(network, address, txId, txStatusSealed);
await transactionActivityService.pollTransferList(address, txId, network);
Comment on lines +1209 to +1213

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If fclTx.onceExecuted() or fclTx.onceSealed() never resolves (e.g., transaction is already in a terminal state or network issues), this function will hang indefinitely. Consider adding a timeout mechanism to prevent blocking.

};

authorizationFunction = async (account) => {
// authorization function need to return an account
const address = fcl.withPrefix(await this.getParentAddress());
Expand Down
19 changes: 18 additions & 1 deletion apps/extension/src/ui/hooks/useTransferListHook.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react';

import { transferListKey, type TransferListStore } from '@/data-model';
import { transferListKey, triggerRefresh, type TransferListStore } from '@/data-model';
import { useWallet } from '@/ui/hooks/use-wallet';
import { useProfiles } from '@/ui/hooks/useProfileHook';

import { useCachedData } from './use-data';
import { useNetwork } from './useNetworkHook';

const PENDING_POLL_INTERVAL_MS = 5_000;

export const useTransferList = () => {
const wallet = useWallet();
const { network } = useNetwork();
Expand All @@ -18,6 +20,21 @@ export const useTransferList = () => {
network && currentAddress ? transferListKey(network, currentAddress, '0', '15') : null
);

// Poll for updates while there are pending transactions.
// This guards against the case where chrome.storage.onChanged events are
// not reliably delivered to the popup from the background service worker.
useEffect(() => {
if (!network || !currentAddress) return;
if (!transferListStore?.pendingCount) return;

const key = transferListKey(network, currentAddress, '0', '15');
const id = setInterval(() => {
triggerRefresh(key);
}, PENDING_POLL_INTERVAL_MS);

return () => clearInterval(id);
}, [network, currentAddress, transferListStore?.pendingCount]);

const [monitor, setMonitor] = useState<string | null>(null);
const [flowscanURL, setFlowscanURL] = useState<string | null>(null);
const [viewSourceURL, setViewSourceURL] = useState<string | null>(null);
Expand Down
1 change: 1 addition & 0 deletions apps/extension/src/ui/views/TransferList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ const TransferList = () => {
{' '}
{(transactions || []).map((tx) => {
const txCombinedKey = `${tx.cadenceTxId || tx.hash}${tx.evmTxIds ? `_${tx.evmTxIds.join('_')}` : ''}_${tx.transferType}_${tx.additionalMessage}_${tx.interaction}`;
const txStableId = tx.cadenceTxId || tx.hash;
return (
<ListItem
key={txCombinedKey}
Expand Down
Loading
Loading