Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7a04531
fix(account): isolate activity history, token balances, and sender ad…
Ryjen1 Nov 4, 2026
7ad0240
fix(wallet): coordinate network switching and separate account change…
Ryjen1 Nov 4, 2026
080d6a6
fix(ethers): track replaced and cancelled transactions from external …
Ryjen1 Nov 4, 2026
3a47d89
fix(walletconnect): reject unsupported proposals and prune incompatib…
Ryjen1 Nov 4, 2026
4d319b6
fix(walletconnect): use case-insensitive address comparison in Wallet…
Ryjen1 Nov 4, 2026
411a07d
fix(reown): serialize signMessage through wallet-operation queue
Ryjen1 Nov 4, 2026
45a1fb5
fix(ethers): capture startBlock before wallet submission
Ryjen1 Nov 4, 2026
6a0e61f
fix(config): restore canonical @/config path alias imports
Ryjen1 Nov 4, 2026
7facb25
test(config): mock @/config alias in reown and walletConnectStore tests
Ryjen1 Nov 4, 2026
090b471
test(balances): verify amountsStore drops late async responses across…
Ryjen1 Nov 4, 2026
6c74108
fix(history): reset activeAccount inside clearActivityHistory
Ryjen1 Nov 4, 2026
477e4b1
build(vitest): prioritize config aliases over src wildcard and remove…
Ryjen1 Nov 4, 2026
b33c44f
test(walletconnect): add integration test for session pruning in init…
Ryjen1 Nov 4, 2026
d2e4f70
fix(ethers): rethrow cancelled and replaced transactions instead of r…
Ryjen1 Nov 4, 2026
ff1c920
fix: harden transaction tracking, lock concurrency, and session valid…
Ryjen1 Nov 6, 2026
25313ad
fix: guard late balance and transaction writes across sessions
Ryjen1 Nov 6, 2026
1ed4a0d
fix: harden walletconnect lifecycle, cross-session tokens, and activi…
Ryjen1 Nov 6, 2026
c91ac40
fix: prevent superseded session pruning, preserve uncertain activity,…
Ryjen1 Nov 6, 2026
8e2ec84
fix: refine superseded init abort, activity tracking, and speed-up de…
Ryjen1 Nov 6, 2026
0de4c78
fix: disconnect restored sessions when logged out during WalletKit in…
Ryjen1 Nov 6, 2026
c1831d0
test(swap): add regression test for RouteSlider token-capture guard (R2)
Ryjen1 Nov 6, 2026
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 src/ethers-utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type BroadcastOptions =
export type BroadcastResult = {
txResponse: TransactionResponse
txReceipt: TransactionReceipt | null
originalTxResponse?: TransactionResponse
}

export type RpcUrls = Record<string, string>
Expand Down
268 changes: 263 additions & 5 deletions src/ethers-utils/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const {
mockBroadcastTransaction,
mockGetTransaction,
mockGetTransactionCount,
mockGetBlockNumber,
mockSubmitReferral,
mockSetDivviMarked,
} = vi.hoisted(() => ({
Expand All @@ -18,6 +19,7 @@ const {
mockBroadcastTransaction: vi.fn(),
mockGetTransaction: vi.fn(),
mockGetTransactionCount: vi.fn(),
mockGetBlockNumber: vi.fn(),
mockSubmitReferral: vi.fn(),
mockSetDivviMarked: vi.fn(),
}))
Expand All @@ -36,6 +38,7 @@ vi.mock("./ethersProviders", () => ({
broadcastTransaction: mockBroadcastTransaction,
getTransaction: mockGetTransaction,
getTransactionCount: mockGetTransactionCount,
getBlockNumber: mockGetBlockNumber,
})),
}))

Expand All @@ -47,7 +50,7 @@ vi.mock("@/gooddollar/methods/divvi", () => ({

// ── Import after mocks ─────────────────────────────────────────────────────

import { sendTx, sendTxWaitForMining } from "./utils"
import { isConfirmedTxFailure, sendTx, sendTxWaitForMining } from "./utils"

// ── Helpers ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -84,10 +87,17 @@ const createType2Options = (
maxPriorityFeePerGas,
})

const createTxResponse = (hash = TX_HASH) => ({
hash,
wait: vi.fn().mockResolvedValue({ hash }),
})
const createTxResponse = (hash = TX_HASH) => {
const response = {
hash,
wait: vi.fn().mockResolvedValue({ hash }),
replaceableTransaction: vi.fn(),
}
// By default replaceableTransaction returns a copy of itself so the tracked
// path exercises the same wait mock as the untracked path.
response.replaceableTransaction.mockReturnValue(response)
return response
}

// ── Tests ───────────────────────────────────────────────────────────────────

Expand All @@ -107,6 +117,7 @@ describe("ethers-utils", () => {

mockAcquire.mockResolvedValue(undefined)
mockGetTransactionCount.mockResolvedValue(0)
mockGetBlockNumber.mockResolvedValue(100)
mockBroadcastTransaction.mockResolvedValue(txResponse)
mockSubmitReferral.mockResolvedValue({
json: vi.fn().mockResolvedValue({}),
Expand Down Expand Up @@ -452,5 +463,252 @@ describe("ethers-utils", () => {
).rejects.toThrow("User rejected")
expect(mockBroadcastTransaction).not.toHaveBeenCalled()
})

it("resolves with the replacement receipt when the tx is sped up by the external wallet", async () => {
// Simulate the external wallet submitting a speed-up: the original hash
// is never mined; replaceableTransaction().wait() throws TRANSACTION_REPLACED.
const replacementReceipt = { hash: "0xreplacementhash" }
const replacedError = Object.assign(
new Error("transaction was replaced"),
{
code: "TRANSACTION_REPLACED",
reason: "speedup",
receipt: replacementReceipt,
replacement: { hash: "0xreplacementhash" },
},
)

txResponse.replaceableTransaction.mockReturnValue({
...txResponse,
wait: vi.fn().mockRejectedValue(replacedError),
})
mockGetTransaction.mockResolvedValue(txResponse)
request = createRequest({ data: "0xcalldata_DIVVI_SUFFIX" })

const result = await sendTxWaitForMining(
walletSigner as never,
DEFAULT_GAS_LIMIT,
request,
options,
)

// The replacement receipt is surfaced, not an error.
expect(result.txReceipt).toEqual(replacementReceipt)
// The replacement response (with replacement hash) is returned to callers.
expect(result.txResponse.hash).toBe("0xreplacementhash")
expect(result.originalTxResponse?.hash).toBe(txResponse.hash)
// Divvi tracking uses the replacement hash.
expect(mockSubmitReferral).toHaveBeenCalledWith({
txHash: "0xreplacementhash",
chainId: request.chainId,
})
})

it("throws and does not submit a Divvi referral when the tx is cancelled by the external wallet", async () => {
// "cancelled" = zero-value self-transfer: the original intent was NOT
// executed; waitForMinedStatus must rethrow so callers surface a failure.
const cancelReceipt = { hash: "0xcancelhash" }
const cancelError = Object.assign(new Error("transaction was replaced"), {
code: "TRANSACTION_REPLACED",
reason: "cancelled",
receipt: cancelReceipt,
replacement: { hash: "0xcancelhash" },
})

txResponse.replaceableTransaction.mockReturnValue({
...txResponse,
wait: vi.fn().mockRejectedValue(cancelError),
})
mockGetTransaction.mockResolvedValue(txResponse)
request = createRequest({ data: "0xcalldata_DIVVI_SUFFIX" })

await expect(
sendTxWaitForMining(
walletSigner as never,
DEFAULT_GAS_LIMIT,
request,
options,
),
).rejects.toThrow("transaction was replaced")

expect(mockSubmitReferral).not.toHaveBeenCalled()
})

it("throws and does not submit a Divvi referral when the tx is replaced with different intent by the external wallet", async () => {
// "replaced" = different calldata/to/value: a different-intent transaction
// mined in place of the original; waitForMinedStatus must rethrow.
const replaceReceipt = { hash: "0xreplacehash" }
const replaceError = Object.assign(
new Error("transaction was replaced"),
{
code: "TRANSACTION_REPLACED",
reason: "replaced",
receipt: replaceReceipt,
replacement: { hash: "0xreplacehash" },
},
)

txResponse.replaceableTransaction.mockReturnValue({
...txResponse,
wait: vi.fn().mockRejectedValue(replaceError),
})
mockGetTransaction.mockResolvedValue(txResponse)
request = createRequest({ data: "0xcalldata_DIVVI_SUFFIX" })

await expect(
sendTxWaitForMining(
walletSigner as never,
DEFAULT_GAS_LIMIT,
request,
options,
),
).rejects.toThrow("transaction was replaced")

expect(mockSubmitReferral).not.toHaveBeenCalled()
})

it("throws and does not submit a Divvi referral when a repriced replacement tx reverts on-chain (status 0)", async () => {
const revertedReceipt = { hash: "0xreplacementhash", status: 0 }
const revertedError = Object.assign(
new Error("transaction was replaced and reverted"),
{
code: "TRANSACTION_REPLACED",
reason: "repriced",
receipt: revertedReceipt,
replacement: { hash: "0xreplacementhash" },
},
)

txResponse.replaceableTransaction.mockReturnValue({
...txResponse,
wait: vi.fn().mockRejectedValue(revertedError),
})
mockGetTransaction.mockResolvedValue(txResponse)
request = createRequest({ data: "0xcalldata_DIVVI_SUFFIX" })

await expect(
sendTxWaitForMining(
walletSigner as never,
DEFAULT_GAS_LIMIT,
request,
options,
),
).rejects.toThrow("transaction was replaced and reverted")

expect(mockSubmitReferral).not.toHaveBeenCalled()
})

it("does not trigger a resend when getTransaction temporarily fails to find the tx", async () => {
// Simulate RPC returning null for the first few polls then succeeding.
mockGetTransaction
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValue(txResponse)

const result = await sendTxWaitForMining(
walletSigner as never,
DEFAULT_GAS_LIMIT,
request,
options,
)

// No second submitTransaction call — no resend.
expect(walletSigner.submitTransaction).toHaveBeenCalledTimes(1)
expect(result.txResponse.hash).toBe(TX_HASH)
})

it("retries when getTransaction encounters transient RPC errors and resolves when it succeeds", async () => {
// Simulate RPC throwing transient errors before returning the transaction.
mockGetTransaction
.mockRejectedValueOnce(new Error("RPC rate limited: 429"))
.mockRejectedValueOnce(new Error("Network timeout"))
.mockResolvedValue(txResponse)

const result = await sendTxWaitForMining(
walletSigner as never,
DEFAULT_GAS_LIMIT,
request,
options,
)

expect(result.txResponse.hash).toBe(TX_HASH)
expect(mockGetTransaction).toHaveBeenCalledTimes(3)
expect(walletSigner.submitTransaction).toHaveBeenCalledTimes(1)
})

it("captures the start block before wallet submission so replacement tracking covers the popup window", async () => {
const START_BLOCK = 42
const callOrder: string[] = []
mockGetBlockNumber.mockImplementation(async () => {
callOrder.push("getBlockNumber")
return START_BLOCK
})
walletSigner.submitTransaction.mockImplementation(async () => {
callOrder.push("submitTransaction")
return TX_HASH
})
mockGetTransaction.mockResolvedValue(txResponse)

await sendTxWaitForMining(
walletSigner as never,
DEFAULT_GAS_LIMIT,
request,
options,
)

expect(callOrder).toEqual(["getBlockNumber", "submitTransaction"])
expect(txResponse.replaceableTransaction).toHaveBeenCalledWith(
START_BLOCK,
)
})
})

describe("isConfirmedTxFailure", () => {
it("returns true for confirmed cancellation", () => {
const err = Object.assign(new Error("transaction cancelled"), {
code: "TRANSACTION_REPLACED",
reason: "cancelled",
})
expect(isConfirmedTxFailure(err)).toBe(true)
})

it("returns true for different-intent replacement", () => {
const err = Object.assign(new Error("transaction replaced"), {
code: "TRANSACTION_REPLACED",
reason: "replaced",
})
expect(isConfirmedTxFailure(err)).toBe(true)
})

it("returns true for reverted replacement transaction (status 0)", () => {
const err = Object.assign(new Error("replacement reverted"), {
code: "TRANSACTION_REPLACED",
reason: "repriced",
receipt: { status: 0 },
})
expect(isConfirmedTxFailure(err)).toBe(true)
})

it("returns true for on-chain revert CALL_EXCEPTION", () => {
const err = Object.assign(new Error("execution reverted"), {
code: "CALL_EXCEPTION",
receipt: { status: 0 },
})
expect(isConfirmedTxFailure(err)).toBe(true)
})

it("returns false for uncertain errors like timeouts, network errors, or polling retry exhaustion", () => {
expect(isConfirmedTxFailure(new Error("timeout"))).toBe(false)
expect(
isConfirmedTxFailure(
new Error("Transaction was not found after 5 retries"),
),
).toBe(false)
expect(
isConfirmedTxFailure(
Object.assign(new Error("network error"), { code: "NETWORK_ERROR" }),
),
).toBe(false)
})
})
})
Loading