Skip to content
Draft
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ NEXT_PUBLIC_GOODDOLLAR_FEED_URL=https://feed-dev.gooddollar.org/api/ceramic-feed
NEXT_PUBLIC_LOGIN_PWLESS_ENABLED=true
NEXT_PUBLIC_LOGIN_GOOGLE_ENABLED=true
NEXT_PUBLIC_LOGIN_FACEBOOK_ENABLED=false
NEXT_PUBLIC_LOGIN_EXISTING_WALLET_ENABLED=true

# Test login (dev only — never enable in production).
NEXT_PUBLIC_TEST_LOGIN_ENABLED=false
Expand Down
3 changes: 3 additions & 0 deletions config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type EnabledLoginOptions = {
google: boolean
facebook: boolean
pwless: boolean
existingWallet: boolean
testLogin: {
enabled: boolean
masterSeed: string
Expand All @@ -66,6 +67,8 @@ const enabledLoginOptions: EnabledLoginOptions = {
google: process.env.NEXT_PUBLIC_LOGIN_GOOGLE_ENABLED === "true",
facebook: process.env.NEXT_PUBLIC_LOGIN_FACEBOOK_ENABLED === "true",
pwless: process.env.NEXT_PUBLIC_LOGIN_PWLESS_ENABLED === "true",
existingWallet:
process.env.NEXT_PUBLIC_LOGIN_EXISTING_WALLET_ENABLED === "true",
testLogin: {
masterSeed: process.env.NEXT_PUBLIC_TEST_LOGIN_MASTER_SEED as string,
userName: process.env.NEXT_PUBLIC_TEST_LOGIN_USER_NAME as string,
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
"@lottiefiles/react-lottie-player": "^3.6.0",
"@neondatabase/serverless": "^1.1.0",
"@react-hookz/web": "^25.2.0",
"@reown/appkit": "1.8.23",
"@reown/appkit-adapter-ethers": "1.8.23",
"@reown/walletkit": "^1.5.6",
"@sentry/nextjs": "^10.56.0",
"@solana-name-service/sns-sdk-kit": "^0.10.0",
Expand Down
15 changes: 15 additions & 0 deletions src/chain/chains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "viem/chains"

import { config } from "@/config"
import type { Addresses } from "@/login/types"

import {
BASE_CHAIN_ID,
Expand Down Expand Up @@ -448,6 +449,20 @@ export const AVAILABLE_CHAINS_IDS = Array.from(AVAILABLE_CHAINS.values())
.sort((a, b) => b.tvl - a.tvl)
.map((c) => c.id)

// Chains the session can use, i.e. it holds an address for the chain family.
export const isChainAvailableForAddresses = (
chainId: number,
addresses: Addresses | undefined,
) => {
const family = AVAILABLE_CHAINS.get(chainId)?.family
return !!family && !!addresses?.has(family)
}

export const getAvailableChainIds = (addresses: Addresses | undefined) =>
AVAILABLE_CHAINS_IDS.filter((chainId) =>
isChainAvailableForAddresses(chainId, addresses),
)

export const getChainName = (chainId: number): string => {
const chain = AVAILABLE_CHAINS.get(chainId)

Expand Down
87 changes: 87 additions & 0 deletions src/ethers-utils/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ const {
mockAcquire,
mockRelease,
mockBroadcastTransaction,
mockGetTransaction,
mockGetTransactionCount,
mockSubmitReferral,
mockSetDivviMarked,
} = vi.hoisted(() => ({
mockAcquire: vi.fn(),
mockRelease: vi.fn(),
mockBroadcastTransaction: vi.fn(),
mockGetTransaction: vi.fn(),
mockGetTransactionCount: vi.fn(),
mockSubmitReferral: vi.fn(),
mockSetDivviMarked: vi.fn(),
Expand All @@ -32,6 +34,7 @@ vi.mock("async-await-mutex-lock", () => ({
vi.mock("./ethersProviders", () => ({
getEthersProvider: vi.fn(() => ({
broadcastTransaction: mockBroadcastTransaction,
getTransaction: mockGetTransaction,
getTransactionCount: mockGetTransactionCount,
})),
}))
Expand Down Expand Up @@ -366,4 +369,88 @@ describe("ethers-utils", () => {
expect(mockRelease).toHaveBeenCalledWith(request.chainId)
})
})

// ── external wallet (submitTransaction) ───────────────────────────────

describe("external wallet", () => {
const createWalletSigner = () => ({
...createSigner(),
submitTransaction: vi.fn().mockResolvedValue(TX_HASH),
})

let walletSigner: ReturnType<typeof createWalletSigner>

beforeEach(() => {
walletSigner = createWalletSigner()
mockGetTransaction.mockResolvedValue(txResponse)
})

it("sendTx submits through the wallet without signing, locking, or broadcasting", async () => {
request = createRequest({ data: "0xcalldata" })

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

expect(result.hash).toBe(TX_HASH)
expect(walletSigner.submitTransaction).toHaveBeenCalledWith({
...request,
from: walletSigner.address,
gasLimit: DEFAULT_GAS_LIMIT,
})
expect(walletSigner.signTransaction).not.toHaveBeenCalled()
expect(mockAcquire).not.toHaveBeenCalled()
expect(mockBroadcastTransaction).not.toHaveBeenCalled()
expect(mockGetTransactionCount).not.toHaveBeenCalled()
await expect(result.trxMinedPromise).resolves.toEqual({ hash: TX_HASH })
expect(mockGetTransaction).toHaveBeenCalledWith(TX_HASH)
})

it("sendTx does not pass wallet-managed fees or nonce", async () => {
await sendTx(walletSigner as never, DEFAULT_GAS_LIMIT, request, options)

const submitted = walletSigner.submitTransaction.mock.calls[0][0]
expect(submitted).not.toHaveProperty("nonce")
expect(submitted).not.toHaveProperty("gasPrice")
expect(submitted).not.toHaveProperty("maxFeePerGas")
})

it("sendTxWaitForMining returns the response and receipt and keeps divvi tracking", async () => {
request = createRequest({ data: "0xcalldata_DIVVI_SUFFIX" })

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

expect(result.txResponse.hash).toBe(TX_HASH)
expect(result.txReceipt).toEqual({ hash: TX_HASH })
expect(mockBroadcastTransaction).not.toHaveBeenCalled()
expect(mockSubmitReferral).toHaveBeenCalledWith({
txHash: TX_HASH,
chainId: request.chainId,
})
})

it("propagates wallet rejection", async () => {
walletSigner.submitTransaction.mockRejectedValue(
new Error("User rejected"),
)

await expect(
sendTxWaitForMining(
walletSigner as never,
DEFAULT_GAS_LIMIT,
request,
options,
),
).rejects.toThrow("User rejected")
expect(mockBroadcastTransaction).not.toHaveBeenCalled()
})
})
})
60 changes: 60 additions & 0 deletions src/ethers-utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,37 @@ export const encodeMethodCall = async (
return { contractAddress, data }
}
const replacementFeeMultipler = BigInt(2)
const SUBMITTED_TX_POLL_MS = 1500
const SUBMITTED_TX_MAX_ATTEMPTS = 40

const getSubmittedTransaction = async (
provider: AbstractProvider,
hash: string,
): Promise<TransactionResponse> => {
for (let attempt = 0; attempt < SUBMITTED_TX_MAX_ATTEMPTS; attempt++) {
const txResponse = await provider.getTransaction(hash)
if (txResponse) return txResponse
await new Promise((resolve) => setTimeout(resolve, SUBMITTED_TX_POLL_MS))
}
throw new Error(`Transaction ${hash} was not found on chain`)
}

// The wallet picks nonce and fees and broadcasts once; we only track the hash.
const submitWithWallet = async (
submit: NonNullable<EVMSigner["submitTransaction"]>,
signer: EVMSigner,
gasLimit: bigint,
broadcastRequest: BroadcastRequest,
) => {
const hash = await submit({
...broadcastRequest,
from: signer.address,
gasLimit,
})
const provider = getEthersProvider(broadcastRequest.chainId)
return { hash, txResponse: getSubmittedTransaction(provider, hash) }
}

export const sendTx = async (
signer: EVMSigner,
gasLimit: bigint,
Expand All @@ -58,6 +89,19 @@ export const sendTx = async (
hash: string
trxMinedPromise: Promise<TransactionReceipt | null>
}> => {
if (signer.submitTransaction) {
const { hash, txResponse } = await submitWithWallet(
signer.submitTransaction,
signer,
gasLimit,
broadcastRequest,
)
const trxMinedPromise = txResponse.then((trx) =>
waitForMinedStatus(trx, broadcastRequest, signer),
)
return { hash, trxMinedPromise }
}

const from = signer.address
const provider = getEthersProvider(broadcastRequest.chainId)
await lock.acquire(broadcastRequest.chainId)
Expand Down Expand Up @@ -85,6 +129,22 @@ export const sendTxWaitForMining = async (
broadcastOptions: BroadcastOptions,
usePendingNonce: boolean = false,
): Promise<BroadcastResult> => {
if (signer.submitTransaction) {
const submitted = await submitWithWallet(
signer.submitTransaction,
signer,
gasLimit,
broadcastRequest,
)
const txResponse = await submitted.txResponse
const txReceipt = await waitForMinedStatus(
txResponse,
broadcastRequest,
signer,
)
return { txResponse, txReceipt }
}

const from = signer.address
const provider = getEthersProvider(broadcastRequest.chainId)
await lock.acquire(broadcastRequest.chainId)
Expand Down
20 changes: 18 additions & 2 deletions src/hooks/useTokenBalances/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"use client"

import { useCallback } from "react"
import { useCallback, useMemo } from "react"
import { useSnapshot } from "valtio"

import { isChainAvailableForAddresses } from "@/chain/chains"
import { sessionState } from "@/login/context/SessionContext/storage"
import type { Prices, Tokens } from "@/tokens/types"

Expand Down Expand Up @@ -57,8 +58,23 @@ export function useTokenBalances(): {
[tokens, addresses],
)

// Hide chains the session has no address for (e.g. non-EVM on connected wallets).
const availableTokens = useMemo(() => {
const source = tokensStore.tokens
if (!tokens || !source || !addresses) return tokens
const entries = Array.from(source).filter(([chainId]) =>
isChainAvailableForAddresses(chainId, addresses),
)
if (entries.length === source.size) return tokens
const filtered: Tokens = Object.assign(new Map(entries), {
getBy: source.getBy,
getNativeBy: source.getNativeBy,
})
return filtered
}, [tokens, addresses])

return {
tokens,
tokens: availableTokens,
prices,
balances,
isLoading,
Expand Down
18 changes: 11 additions & 7 deletions src/hooks/useTokenBalances/stores/amountsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { imap } from "itertools"
import { proxy } from "valtio"
import { proxyMap, subscribeKey } from "valtio/utils"

import { isChainAvailableForAddresses } from "@/chain/chains"
import { getChainProvider } from "@/chain/provider/provider"
import { sessionState } from "@/login/context/SessionContext/storage"
import type { Addresses } from "@/login/types"
Expand Down Expand Up @@ -59,12 +60,15 @@ export const refreshAmounts = async (
if (chainId === "all") {
const promises = imap(
tokens.entries(),
async ([chainId, tokensOfChain]) =>
getAmountsForChain(tokensOfChain, addresses, chainId).then(
(amounts) => {
amountsStore.amounts.set(chainId, amounts)
},
),
async ([chainId, tokensOfChain]) => {
if (!isChainAvailableForAddresses(chainId, addresses)) return
const amounts = await getAmountsForChain(
tokensOfChain,
addresses,
chainId,
)
amountsStore.amounts.set(chainId, amounts)
},
)
const r = await Promise.allSettled(promises)
//Log errors, if any
Expand All @@ -73,7 +77,7 @@ export const refreshAmounts = async (
)
} else {
const tokensOfChain = tokens.get(chainId)
if (tokensOfChain) {
if (tokensOfChain && isChainAvailableForAddresses(chainId, addresses)) {
const amountsForChain = await getAmountsForChain(
tokensOfChain,
addresses,
Expand Down
Loading
Loading