diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 646ca4d8c..7f49fd162 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -41,6 +41,16 @@ module.exports = { JSX: true, NodeJS: true }, + overrides: [ + { + files: ["packages/sdk-v2/src/**/*.test.ts", "packages/sdk-v2/src/**/*.test.tsx"], + env: { jest: true, es2020: true }, + parserOptions: { project: ["./packages/sdk-v2/tsconfig.tests.json"] }, + rules: { + "@typescript-eslint/unbound-method": "off" + } + } + ], ignorePatterns: [ "/packages/*/dist/**/*.js", "/packages/*/types/**/*.d.ts", diff --git a/packages/good-design/src/core/web3/modals/ErrorModal.tsx b/packages/good-design/src/core/web3/modals/ErrorModal.tsx index c5df82ede..0b4dfff06 100644 --- a/packages/good-design/src/core/web3/modals/ErrorModal.tsx +++ b/packages/good-design/src/core/web3/modals/ErrorModal.tsx @@ -7,12 +7,16 @@ import BasicStyledModal, { ModalErrorBody } from "./BasicStyledModal"; export const ErrorModal = ({ error, reason, + title = "Oops!", + footer, overlay, onClose, ...props }: { error: string; reason?: string; + title?: string; + footer?: React.ReactNode; overlay: "dark" | "blur"; onClose: () => void; }) => { @@ -33,7 +37,7 @@ export const ErrorModal = ({ } footer={ -
- {reason} -
+
{footer ?? {reason}}
} /> diff --git a/packages/good-design/src/core/web3/modals/ReserveLimitModal.tsx b/packages/good-design/src/core/web3/modals/ReserveLimitModal.tsx new file mode 100644 index 000000000..2114a1931 --- /dev/null +++ b/packages/good-design/src/core/web3/modals/ReserveLimitModal.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { HStack, Link, Text, VStack } from "native-base"; +import { useLingui } from "@lingui/react"; + +import { ErrorModal } from "./ErrorModal"; + +export interface ReserveLimitModalProps { + chainId?: number; + // The consumer supplies this only after a fresh, matching alternative Reserve check succeeds. + otherChainId?: number; + onClose: () => void; +} + +// Full message IDs work in production even before translated catalogs are refreshed. +const messages: Record = { + 42220: { + limit: /*i18n*/ "The Reserve’s trading limit on Celo has been reached.", + alternative: /*i18n*/ "You can still swap on Celo." + }, + 50: { + limit: /*i18n*/ "The Reserve’s trading limit on XDC has been reached.", + alternative: /*i18n*/ "You can still swap on XDC." + } +}; + +// Homepages follow the failed swap's origin chain; they do not assert route liquidity. +const dexRoutes = (chainId: number): { name: string; href: string }[] => { + if (chainId === 50) return [{ name: "XSwap", href: "https://app.xspswap.finance/" }]; + if (chainId === 42220) { + return [ + { name: "Uniswap", href: "https://app.uniswap.org/" }, + { name: "Ubeswap", href: "https://app.ubeswap.org/" } + ]; + } + return []; +}; + +export const ReserveLimitModal = ({ chainId, otherChainId, onClose }: ReserveLimitModalProps) => { + const { i18n } = useLingui(); + if (chainId === undefined) return null; + + const otherChainMessage = otherChainId !== chainId ? messages[otherChainId ?? 0]?.alternative : undefined; + + return ( + + {otherChainMessage && {i18n._(otherChainMessage)}} + + {dexRoutes(chainId).map(({ name, href }) => ( + + {name} + + ))} + + + } + /> + ); +}; diff --git a/packages/good-design/src/core/web3/modals/index.ts b/packages/good-design/src/core/web3/modals/index.ts index c440b38ef..90473fc27 100644 --- a/packages/good-design/src/core/web3/modals/index.ts +++ b/packages/good-design/src/core/web3/modals/index.ts @@ -9,3 +9,4 @@ export * from "./ErrorModal"; export * from "./YouSureModal"; export * from "./TxDetailsModal"; export * from "./SwitchChainModal"; +export * from "./ReserveLimitModal"; diff --git a/packages/sdk-v2/lint-staged.config.js b/packages/sdk-v2/lint-staged.config.js index ccdb214d3..31d128c78 100644 --- a/packages/sdk-v2/lint-staged.config.js +++ b/packages/sdk-v2/lint-staged.config.js @@ -1,3 +1,7 @@ module.exports = { - "src/**/*.{ts,js,tsx,jsx}": ["eslint --no-ignore --fix --max-warnings=0", () => "tsc --noEmit"] + "src/**/*.{ts,js,tsx,jsx}": [ + "eslint --no-ignore --fix --max-warnings=0", + () => "yarn workspace @gooddollar/web3sdk-v2 exec tsc --noEmit", + () => "yarn workspace @gooddollar/web3sdk-v2 exec tsc -p tsconfig.tests.json" + ] }; diff --git a/packages/sdk-v2/src/sdk/base/react.readProviders.test.tsx b/packages/sdk-v2/src/sdk/base/react.readProviders.test.tsx new file mode 100644 index 000000000..f1382053b --- /dev/null +++ b/packages/sdk-v2/src/sdk/base/react.readProviders.test.tsx @@ -0,0 +1,197 @@ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { Chain, DAppProvider } from "@usedapp/core"; +import { providers, utils } from "ethers"; +import TokenBridgeABI from "@gooddollar/bridge-contracts/artifacts/contracts/bridge/TokenBridge.sol/TokenBridge.json"; +import { ConnectorContext } from "@usedapp/core/dist/cjs/src/providers/network/connectors/context"; +import { useReadonlyNetworks } from "@usedapp/core/dist/cjs/src/providers/network/readonlyNetworks/context"; +import { useGetContract, useG$Balance } from "./react"; +import { useReadOnlyProvider } from "../../hooks/useMulticallAtChain"; +import { useGetBridgeData, useWithinBridgeLimits } from "../microbridge/react"; +import { CONTRACT_TO_ABI } from "./sdk"; +import { getUrl } from "@ethersproject/web/lib/geturl"; + +jest.mock("@ethersproject/web/lib/geturl", () => ({ getUrl: jest.fn() })); + +jest.mock("../../contexts", () => { + const React = jest.requireActual("react"); + return { + Web3Context: React.createContext({ env: "production" }), + TokenContext: React.createContext({ G$: { 122: 2, 42220: 18 }, GOOD: { 122: 18, 42220: 18 } }) + }; +}); +jest.mock("../claim/sdk", () => ({ ClaimSDK: class {} })); +jest.mock("../savings/sdk", () => ({ SavingsSDK: class {} })); +jest.mock("../../hooks", () => ({ useRefreshOrNever: (refresh: number) => refresh })); +jest.mock("../../hooks/useRefreshOrNever", () => ({ __esModule: true, default: (refresh: number) => refresh })); +jest.mock("@gooddollar/bridge-app/dist/sdk.js", () => ({ BridgeSDK: class {} })); +jest.mock("../storage", () => ({ AsyncStorage: {} })); +jest.mock("@usedapp/core/dist/esm/src/providers/network/readonlyNetworks", () => + jest.requireActual("@usedapp/core/dist/cjs/src/providers/network/readonlyNetworks") +); + +const account = "0x0000000000000000000000000000000000000001"; +const createMockChain = (chainId: number, chainName: string, multicallAddress: string): Chain => ({ + chainId, + chainName, + multicallAddress, + isTestChain: true, + isLocalChain: false, + getExplorerAddressLink: address => `https://offline.invalid/address/${address}`, + getExplorerTransactionLink: hash => `https://offline.invalid/tx/${hash}` +}); +const aggregate = new utils.Interface([ + "function aggregate(tuple(address target,bytes callData)[] calls) view returns(uint256 blockNumber,bytes[] returnData)" +]); +const interfaces = [ + new utils.Interface(TokenBridgeABI.abi), + ...Object.values(CONTRACT_TO_ABI).map(x => new utils.Interface(x.abi)) +]; +const valueFor = (param: utils.ParamType): any => { + if (param.baseType === "tuple") return param.components.map(valueFor); + if (param.type === "bool") return true; + if (param.type === "string") return ""; + if (param.type === "address") return account; + if (param.type === "uint8") return 18; + return 100000; +}; +const encodeCall = (data: string) => { + for (const iface of interfaces) { + let fn; + try { + fn = iface.getFunction(data.slice(0, 10)); + } catch { + continue; + } + return iface.encodeFunctionResult(fn, fn.outputs.map(valueFor)); + } + throw new Error("Unexpected call selector"); +}; + +function createPool(chainId: number) { + const children = ["a", "b"].map(name => { + const child = new providers.StaticJsonRpcProvider( + { url: `https://${name}.offline.invalid`, throttleLimit: 1, timeout: 5000 }, + chainId + ); + jest.spyOn(child, "send"); + return child; + }); + return new providers.FallbackProvider( + children.map((provider, index) => ({ provider, priority: index + 1, weight: 1, stallTimeout: 1000 })), + 1 + ); +} +let latest: any; +const View = () => { + latest = { + provider: useReadOnlyProvider(122), + contextProviders: useReadonlyNetworks(), + contract: useGetContract("GoodDollar", true, "base", 122), + fuseFees: useGetBridgeData(122, ""), + celoFees: useGetBridgeData(42220, ""), + eligibility: useWithinBridgeLimits(122, account, "100"), + fuseBalance: useG$Balance(5, 122), + celoBalance: useG$Balance(5, 42220) + }; + return null; +}; + +describe("SDK and Microbridge fallback read-provider wiring", () => { + beforeEach(() => { + (getUrl as jest.Mock).mockImplementation(async (url, options) => { + if (url === "https://a.offline.invalid") return { statusCode: 429, headers: {}, body: Buffer.from("{}") }; + if (url !== "https://b.offline.invalid") throw new Error("Unexpected URL: network access forbidden"); + const request = JSON.parse(Buffer.from(options.body).toString()); + let result; + if (request.method === "eth_blockNumber") result = "0x64"; + else if (request.method === "eth_getLogs") result = []; + else if (request.method === "eth_call") { + const { calls } = aggregate.decodeFunctionData("aggregate", request.params[0].data); + result = aggregate.encodeFunctionResult("aggregate", [ + 100, + calls.map((call: { callData: string }) => encodeCall(call.callData)) + ]); + } else throw new Error("Unexpected RPC method"); + return { + statusCode: 200, + headers: {}, + body: Buffer.from(JSON.stringify({ jsonrpc: "2.0", id: request.id, result })) + }; + }); + }); + beforeAll(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + }); + afterAll(() => { + delete (globalThis as any).IS_REACT_ACT_ENVIRONMENT; + }); + it.each([false, true])( + "loads form data through fallback provider (factory=%s)", + async factory => { + const fuse = createPool(122), + celo = createPool(42220); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => + root.render( + fuse : fuse, 42220: factory ? () => celo : celo }, + networks: [ + createMockChain(122, "Mock Fuse", "0x0000000000000000000000000000000000000010"), + createMockChain(42220, "Mock Celo", "0x0000000000000000000000000000000000000020") + ], + multicallVersion: 1, + pollingInterval: 15000, + autoConnect: false + }} + > + + + + + ) + ); + try { + // Finish each act scope so React 18 can commit usedapp's debounced multicall effects. + for (let i = 0; i < 240 && (!latest.fuseFees.bridgeFees || !latest.celoFees.bridgeFees); i++) { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + } + expect(latest.fuseFees.bridgeFees).toBeDefined(); + expect(latest.celoFees.bridgeFees).toBeDefined(); + expect(latest.eligibility.isValid).toBe(true); + expect(latest.fuseBalance.G$.value.toString()).toBe("100000"); + expect(latest.celoBalance.G$.value.toString()).toBe("100000"); + expect(latest.provider).toBe(fuse); + expect(latest.contextProviders[122]).toBe(fuse); + expect(latest.contract.provider).toBe(fuse); + for (const pool of [fuse, celo]) { + const a = pool.providerConfigs[0].provider as providers.StaticJsonRpcProvider; + const b = pool.providerConfigs[1].provider as providers.StaticJsonRpcProvider; + expect(a.send).toHaveBeenCalled(); + expect(b.send).toHaveBeenCalledWith("eth_call", expect.any(Array)); + expect( + (b.send as jest.Mock).mock.calls.every(([method]) => !method.includes("send") && !method.includes("sign")) + ).toBe(true); + } + } finally { + await act(async () => root.unmount()); + container.remove(); + for (const pool of [fuse, celo]) { + pool.removeAllListeners(); + pool.polling = false; + for (const { provider } of pool.providerConfigs) { + (provider as providers.BaseProvider).removeAllListeners(); + (provider as providers.BaseProvider).polling = false; + } + } + } + }, + 30000 + ); +}); diff --git a/packages/sdk-v2/src/sdk/base/sdk.test.ts b/packages/sdk-v2/src/sdk/base/sdk.test.ts new file mode 100644 index 000000000..94cae68af --- /dev/null +++ b/packages/sdk-v2/src/sdk/base/sdk.test.ts @@ -0,0 +1,42 @@ +import { providers } from "ethers"; +import { BaseSDK } from "./sdk"; + +const account = "0x0000000000000000000000000000000000000001"; +const flush = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve(); +}; + +describe("BaseSDK provider compatibility", () => { + it("initializes fallback read contracts without getSigner or HTTP calls", async () => { + const child = new providers.StaticJsonRpcProvider("https://offline.invalid", 122); + const send = jest.spyOn(child, "send").mockRejectedValue(new Error("No network access allowed")); + const provider = new providers.FallbackProvider([child], 1); + const sdk = new BaseSDK(provider as unknown as providers.JsonRpcProvider, "production"); + await flush(); + expect(sdk.signer).toBeUndefined(); + expect(sdk.getContract("GoodDollar").provider).toBe(provider); + expect(send).not.toHaveBeenCalled(); + }); + + it("keeps the wallet signer path for providers exposing getSigner", async () => { + const provider = new providers.StaticJsonRpcProvider("https://offline.invalid", 122); + const send = jest.spyOn(provider, "send").mockImplementation(async method => { + if (method === "eth_accounts") return [account]; + throw new Error("Unexpected RPC method"); + }); + const sdk = new BaseSDK(provider, "production"); + await flush(); + expect(await sdk.getContract("GoodDollar").signer.getAddress()).toBe(account); + expect(sdk.getContract("GoodDollar").signer.provider).toBe(provider); + expect(send.mock.calls.every(([method]) => method === "eth_accounts")).toBe(true); + }); + + it("keeps an unsigned read contract when a JSON-RPC provider has no accounts", async () => { + const provider = new providers.StaticJsonRpcProvider("https://offline.invalid", 122); + jest.spyOn(provider, "send").mockResolvedValue([]); + const sdk = new BaseSDK(provider, "production"); + await flush(); + expect(sdk.signer).toBeUndefined(); + expect(sdk.getContract("GoodDollar").provider).toBe(provider); + }); +}); diff --git a/packages/sdk-v2/src/sdk/base/sdk.ts b/packages/sdk-v2/src/sdk/base/sdk.ts index 5150009e5..3beee134e 100644 --- a/packages/sdk-v2/src/sdk/base/sdk.ts +++ b/packages/sdk-v2/src/sdk/base/sdk.ts @@ -101,12 +101,14 @@ export class BaseSDK { }) .catch(noop); - const signer = provider.getSigner(); - - signer - .getAddress() - .then(async () => void (this.signer = signer)) - .catch(noop); + // Read-only BaseProviders (including FallbackProvider) do not expose a signer. + if (typeof provider.getSigner === "function") { + const signer = provider.getSigner(); + signer + .getAddress() + .then(async () => void (this.signer = signer)) + .catch(noop); + } } getContract(contractName: "UBIScheme"): UBIScheme; diff --git a/packages/sdk-v2/src/sdk/microbridge/history.helpers.test.ts b/packages/sdk-v2/src/sdk/microbridge/history.helpers.test.ts new file mode 100644 index 000000000..0e1ee9cd0 --- /dev/null +++ b/packages/sdk-v2/src/sdk/microbridge/history.helpers.test.ts @@ -0,0 +1,155 @@ +import { Contract, providers, utils } from "ethers"; +import TokenBridgeABI from "@gooddollar/bridge-contracts/artifacts/contracts/bridge/TokenBridge.sol/TokenBridge.json"; +import { decodeMicroBridgeHistoryLogs, getMicroBridgeHistoryCacheKey, syncMicroBridgeHistory } from "./history.helpers"; + +const account = "0x0000000000000000000000000000000000000001"; +const other = "0x0000000000000000000000000000000000000002"; +const address = "0x0000000000000000000000000000000000000003"; +const iface = new utils.Interface(TokenBridgeABI.abi); +const contractWith = (getLogs: jest.Mock) => + ({ address, interface: iface, provider: { getLogs } } as unknown as Contract); +const logFor = (eventName: string, args: unknown[], blockNumber = 100, logIndex = 0): providers.Log => { + const encoded = iface.encodeEventLog(iface.getEvent(eventName), args); + return { + ...encoded, + address, + blockNumber, + blockHash: `0x${"1".repeat(64)}`, + transactionHash: `0x${"2".repeat(64)}`, + transactionIndex: 0, + logIndex, + removed: false + }; +}; + +const runTimers = async (pending: Promise): Promise => { + let finished = false; + void pending.finally(() => { + finished = true; + }); + while (!finished) { + await Promise.resolve(); + jest.runOnlyPendingTimers(); + } + return pending; +}; + +describe("Microbridge history", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it("preserves the 20,000-block inclusive window with sequential ranges no larger than 500 blocks", async () => { + let pending = 0; + let maximumPending = 0; + const getLogs = jest.fn, [providers.Filter]>(async () => { + pending += 1; + maximumPending = Math.max(maximumPending, pending); + await Promise.resolve(); + pending -= 1; + return []; + }); + const result = await runTimers(syncMicroBridgeHistory(contractWith(getLogs), 30000, account, {})); + const filters = getLogs.mock.calls.map(call => call[0]); + expect(filters).toHaveLength(164); // 41 chunks x 2 indexed address filters x 2 event types + expect(maximumPending).toBe(1); + expect(Math.min(...filters.map(filter => Number(filter.fromBlock)))).toBe(10000); + expect(Math.max(...filters.map(filter => Number(filter.toBlock)))).toBe(30000); + expect(filters.every(filter => Number(filter.toBlock) - Number(filter.fromBlock) + 1 <= 500)).toBe(true); + const ranges = filters.slice(0, 41).reverse(); + ranges.slice(1).forEach((range, i) => expect(Number(range.fromBlock)).toBe(Number(ranges[i].toBlock) + 1)); + expect(filters[0].topics).toEqual([iface.getEventTopic("BridgeRequest"), utils.hexZeroPad(account, 32)]); + expect(filters[41].topics).toEqual([iface.getEventTopic("BridgeRequest"), null, utils.hexZeroPad(account, 32)]); + expect(result.cache.lastSyncedBlock).toBe(30000); + }); + + it("fetches only new blocks after a successful cursor and retains cached events", async () => { + const cachedLog = logFor("BridgeRequest", [account, other, 42220, 10, false, 123, 7]); + const getLogs = jest.fn, [providers.Filter]>().mockResolvedValue([]); + const result = await runTimers( + syncMicroBridgeHistory(contractWith(getLogs), 110, account, { + BridgeRequest: [cachedLog], + lastSyncedBlock: 100 + }) + ); + expect(getLogs.mock.calls.map(call => [call[0].fromBlock, call[0].toBlock])).toEqual(Array(4).fill([101, 110])); + expect(result.cache.BridgeRequest).toEqual([cachedLog]); + expect(result.cache.lastSyncedBlock).toBe(110); + }); + + it("stops after an RPC failure, retains partial rows, and retries without advancing its cursor", async () => { + const row = logFor("BridgeRequest", [account, other, 42220, 10, false, 123, 7], 900); + const getLogs = jest.fn().mockResolvedValueOnce([row]).mockRejectedValueOnce(new Error("HTTP 429")); + const failed = await runTimers( + syncMicroBridgeHistory(contractWith(getLogs), 1000, account, { lastSyncedBlock: 0 }) + ); + expect(getLogs).toHaveBeenCalledTimes(2); + expect(failed.error).toBeInstanceOf(Error); + expect(failed.cache.BridgeRequest).toEqual([row]); + expect(failed.cache.lastSyncedBlock).toBe(0); + getLogs.mockReset().mockResolvedValue([row]); + const retried = await runTimers(syncMicroBridgeHistory(contractWith(getLogs), 1000, account, failed.cache)); + expect(retried.cache.BridgeRequest).toEqual([row]); + expect(retried.cache.lastSyncedBlock).toBe(1000); + }); + + it("dedupes sender/recipient matches and preserves Microbridge decoding and relay IDs", async () => { + const request = logFor("BridgeRequest", [account, account, 42220, 10, false, 123, 7]); + const executed = logFor("ExecutedTransfer", [account, account, other, 10, 1, 122, 99, 7], 101, 1); + const getLogs = jest.fn(async (filter: providers.Filter) => + filter.topics?.[0] === iface.getEventTopic("BridgeRequest") ? [request] : [executed] + ); + const contract = contractWith(getLogs); + const result = await runTimers(syncMicroBridgeHistory(contract, 101, account, {})); + expect(result.cache.BridgeRequest).toHaveLength(1); + expect(result.cache.ExecutedTransfer).toHaveLength(1); + const [decodedRequest] = decodeMicroBridgeHistoryLogs(contract, "BridgeRequest", result.cache.BridgeRequest); + const [decodedExecution] = decodeMicroBridgeHistoryLogs( + contract, + "ExecutedTransfer", + result.cache.ExecutedTransfer + ); + expect(decodedRequest.data.timestamp.toString()).toBe("123"); + expect(decodedRequest.data.withRelay).toBe(false); + expect(decodedRequest.data.targetChainId.toString()).toBe("42220"); + expect(decodedExecution.data.sourceChainId.toString()).toBe("122"); + expect(decodedExecution.data.id.toString()).toBe(decodedRequest.data.id.toString()); + expect(Object.keys(decodedRequest)).toEqual([ + "data", + "blockNumber", + "blockHash", + "transactionIndex", + "transactionHash", + "removed" + ]); + }); + + it("prunes the rolling lookback without adopting MPB's shorter 5,000-block coverage", async () => { + const old = logFor("BridgeRequest", [account, other, 42220, 10, false, 123, 7], 9999); + const retained = { ...old, blockNumber: 10000, logIndex: 1 }; + const getLogs = jest.fn(async () => []); + const result = await runTimers( + syncMicroBridgeHistory(contractWith(getLogs), 30000, account, { + BridgeRequest: [old, retained], + lastSyncedBlock: 29999 + }) + ); + expect(result.cache.BridgeRequest).toEqual([retained]); + }); + + it("scopes cache entries to account, chain and deployment", () => { + const key = getMicroBridgeHistoryCacheKey(122, address, account); + expect(getMicroBridgeHistoryCacheKey(122, address.toUpperCase(), account.toUpperCase())).toBe(key); + expect(getMicroBridgeHistoryCacheKey(122, address, other)).not.toBe(key); + expect(getMicroBridgeHistoryCacheKey(42220, address, account)).not.toBe(key); + expect(getMicroBridgeHistoryCacheKey(122, other, account)).not.toBe(key); + }); + + it("cancels between paced requests without publishing a successful cursor", async () => { + const getLogs = jest.fn(async () => []); + const result = await runTimers( + syncMicroBridgeHistory(contractWith(getLogs), 1000, account, {}, () => getLogs.mock.calls.length > 0) + ); + expect(getLogs).toHaveBeenCalledTimes(1); + expect(result.cache.lastSyncedBlock).toBeUndefined(); + }); +}); diff --git a/packages/sdk-v2/src/sdk/microbridge/history.helpers.ts b/packages/sdk-v2/src/sdk/microbridge/history.helpers.ts new file mode 100644 index 000000000..d91cc9ab1 --- /dev/null +++ b/packages/sdk-v2/src/sdk/microbridge/history.helpers.ts @@ -0,0 +1,72 @@ +import { Contract, providers } from "ethers"; +import { createAccountEventTopics, createBlockChunks, dedupeLogs } from "../mpbridge/hooks/useMPBBridgeHistory.helpers"; + +const HISTORY_LOOKBACK_BLOCKS = 20000; +const HISTORY_REQUEST_DELAY_MS = 500; +const EVENT_NAMES = ["BridgeRequest", "ExecutedTransfer"] as const; + +export type MicroBridgeHistoryCache = { + BridgeRequest?: providers.Log[]; + ExecutedTransfer?: providers.Log[]; + lastSyncedBlock?: number; +}; + +export const getMicroBridgeHistoryCacheKey = (chainId: number, address: string, account: string) => + `GD_MicroBridgeHistory_v1_${chainId}_${address.toLowerCase()}_${account.toLowerCase()}`; + +/** Keep the original inclusive latest-20,000-block window, including on stale-cache refreshes. */ +export const syncMicroBridgeHistory = async ( + contract: Contract, + latestBlock: number, + account: string, + cache: MicroBridgeHistoryCache, + cancelled: () => boolean = () => false +) => { + const windowStart = Math.max(0, latestBlock - HISTORY_LOOKBACK_BLOCKS); + const fromBlock = Math.max(windowStart, (cache.lastSyncedBlock ?? -1) + 1); + const next: MicroBridgeHistoryCache = { ...cache }; + const chunks = createBlockChunks(fromBlock, latestBlock).reverse(); + let requested = false; + + try { + for (const eventName of EVENT_NAMES) { + const topicFilters = createAccountEventTopics(contract.interface.getEventTopic(eventName), account); + for (const topics of topicFilters) { + for (const chunk of chunks) { + if (requested) { + await new Promise(resolve => setTimeout(resolve, HISTORY_REQUEST_DELAY_MS)); + } + if (cancelled()) return { cache: next }; + requested = true; + const logs = await contract.provider.getLogs({ address: contract.address, topics, ...chunk }); + next[eventName] = dedupeLogs([...(next[eventName] || []), ...logs]); + } + } + } + + for (const eventName of EVENT_NAMES) { + next[eventName] = (next[eventName] || []) + .filter(log => log.blockNumber >= windowStart) + .sort((a, b) => a.blockNumber - b.blockNumber || a.logIndex - b.logIndex); + } + next.lastSyncedBlock = latestBlock; + return { cache: next }; + } catch (error) { + // Preserve successful chunks and the old cursor so the missing range is retried, not skipped. + return { cache: next, error }; + } +}; + +export const decodeMicroBridgeHistoryLogs = ( + contract: Contract, + eventName: (typeof EVENT_NAMES)[number], + logs: providers.Log[] = [] +) => + logs.map(log => ({ + data: contract.interface.decodeEventLog(eventName, log.data, log.topics), + blockNumber: log.blockNumber, + blockHash: log.blockHash, + transactionIndex: log.transactionIndex, + transactionHash: log.transactionHash, + removed: log.removed + })); diff --git a/packages/sdk-v2/src/sdk/microbridge/index.ts b/packages/sdk-v2/src/sdk/microbridge/index.ts index 2b5ed7162..c1f2c2f1f 100644 --- a/packages/sdk-v2/src/sdk/microbridge/index.ts +++ b/packages/sdk-v2/src/sdk/microbridge/index.ts @@ -1 +1,3 @@ +export * from "./history.helpers"; export * from "./react"; +export * from "./useMicroBridgeHistory"; diff --git a/packages/sdk-v2/src/sdk/microbridge/react.ts b/packages/sdk-v2/src/sdk/microbridge/react.ts index 87d29551d..10915c0c9 100644 --- a/packages/sdk-v2/src/sdk/microbridge/react.ts +++ b/packages/sdk-v2/src/sdk/microbridge/react.ts @@ -4,7 +4,7 @@ import TokenBridgeABI from "@gooddollar/bridge-contracts/artifacts/contracts/bri import bridgeContracts from "@gooddollar/bridge-contracts/release/deployment.json"; import { TokenBridge } from "@gooddollar/bridge-contracts/typechain-types/contracts/bridge/TokenBridge.sol/TokenBridge"; import { IGoodDollar } from "@gooddollar/goodprotocol/types"; -import { ChainId, TransactionStatus, useCalls, useEthers, useLogs } from "@usedapp/core"; +import { TransactionStatus, useCalls, useEthers, useLogs } from "@usedapp/core"; import { BigNumber, Contract, ethers } from "ethers"; import { first, groupBy, mapValues } from "lodash"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -14,6 +14,7 @@ import useRefreshOrNever from "../../hooks/useRefreshOrNever"; import { useGetContract, useGetEnvChainId } from "../base/react"; import { SupportedChains, formatAmount } from "../constants"; import { useContractFunctionWithDefaultGasFees } from "../base/hooks/useGasFees"; +import { useMicroBridgeHistory } from "./useMicroBridgeHistory"; export const useGetBridgeContracts = () => { const { baseEnv } = useGetEnvChainId(); @@ -289,59 +290,8 @@ export const useRelayTx = () => { export const useBridgeHistory = () => { const { account } = useEthers(); const bridgeContracts = useGetBridgeContracts(); - const refresh = useRefreshOrNever(5); - const fuseChainId = 122 as ChainId; - const fuseOut = useLogs( - { - contract: bridgeContracts[122] as TokenBridge, - event: "BridgeRequest", - args: [] - }, - { - chainId: fuseChainId, - fromBlock: -2e4, - refresh - } - ); - - const fuseIn = useLogs( - { - contract: bridgeContracts[122] as TokenBridge, - event: "ExecutedTransfer", - args: [] - }, - { - chainId: fuseChainId, - fromBlock: -2e4, - refresh - } - ); - - const celoOut = useLogs( - { - contract: bridgeContracts[42220] as TokenBridge, - event: "BridgeRequest", - args: [] - }, - { - chainId: 42220, - fromBlock: -2e4, - refresh - } - ); - - const celoIn = useLogs( - { - contract: bridgeContracts[42220] as TokenBridge, - event: "ExecutedTransfer", - args: [] - }, - { - chainId: 42220, - fromBlock: -2e4, - refresh - } - ); + const { requests: fuseOut, transfers: fuseIn } = useMicroBridgeHistory(122, bridgeContracts[122]); + const { requests: celoOut, transfers: celoIn } = useMicroBridgeHistory(42220, bridgeContracts[42220]); if (!fuseOut || !fuseIn || !celoOut || !celoIn) { return {}; diff --git a/packages/sdk-v2/src/sdk/microbridge/useMicroBridgeHistory.test.tsx b/packages/sdk-v2/src/sdk/microbridge/useMicroBridgeHistory.test.tsx new file mode 100644 index 000000000..5560d07f5 --- /dev/null +++ b/packages/sdk-v2/src/sdk/microbridge/useMicroBridgeHistory.test.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { act, render } from "@testing-library/react"; +import { Contract, providers } from "ethers"; +import TokenBridgeABI from "@gooddollar/bridge-contracts/artifacts/contracts/bridge/TokenBridge.sol/TokenBridge.json"; +import { AsyncStorage } from "../storage"; +import { useMicroBridgeHistory } from "./useMicroBridgeHistory"; + +let mockAccount = "0x0000000000000000000000000000000000000001"; +let mockBlock = 0; +const mockProvider = new providers.StaticJsonRpcProvider("https://offline.invalid", 122); +const address = "0x0000000000000000000000000000000000000003"; + +jest.mock("@usedapp/core", () => ({ + useEthers: () => ({ account: mockAccount }), + useBlockNumbers: () => ({ 122: mockBlock }) +})); +jest.mock("@usedapp/core/dist/esm/src/providers/network/readonlyNetworks", () => ({ + useReadonlyNetworks: () => ({ 122: mockProvider }) +})); +jest.mock("../../hooks/useRefreshOrNever", () => ({ __esModule: true, default: () => 5 })); +jest.mock("../storage", () => ({ AsyncStorage: { getItem: jest.fn(), setItem: jest.fn() } })); + +let latest: ReturnType; +const View = () => { + // The existing bridge factory creates a fresh contract on every render. + latest = useMicroBridgeHistory(122, new Contract(address, TokenBridgeABI.abi)); + return null; +}; +const flush = async () => { + await act(async () => { + for (let i = 0; i < 30; i++) { + await Promise.resolve(); + jest.runOnlyPendingTimers(); + } + }); +}; + +describe("Microbridge history hook", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockAccount = "0x0000000000000000000000000000000000000001"; + mockBlock = 0; + jest.spyOn(mockProvider, "getBlockNumber").mockResolvedValue(0); + jest.spyOn(mockProvider, "getLogs").mockResolvedValue([]); + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(undefined); + (AsyncStorage.setItem as jest.Mock).mockResolvedValue(undefined); + }); + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it("uses one queue per chain, keeps the contract stable, and refreshes only after five blocks", async () => { + const view = render(); + await flush(); + expect(mockProvider.getLogs).toHaveBeenCalledTimes(4); + expect(latest).toEqual({ requests: { value: [] }, transfers: { value: [] } }); + view.rerender(); + await flush(); + expect(mockProvider.getBlockNumber).toHaveBeenCalledTimes(1); + expect(mockProvider.getLogs).toHaveBeenCalledTimes(4); + mockBlock = 4; + view.rerender(); + await flush(); + expect(mockProvider.getLogs).toHaveBeenCalledTimes(4); + mockBlock = 5; + view.rerender(); + await flush(); + expect(mockProvider.getLogs).toHaveBeenCalledTimes(8); + expect( + (mockProvider.getLogs as jest.Mock).mock.calls.slice(4).map(call => [call[0].fromBlock, call[0].toBlock]) + ).toEqual(Array(4).fill([1, 5])); + view.unmount(); + }); + + it("hides the previous account's history immediately and ignores its in-flight response", async () => { + let resolveOld: (logs: providers.Log[]) => void = () => undefined; + (mockProvider.getLogs as jest.Mock).mockImplementationOnce( + () => + new Promise(resolve => { + resolveOld = resolve; + }) + ); + const view = render(); + await flush(); + expect(mockProvider.getLogs).toHaveBeenCalledTimes(1); + mockAccount = "0x0000000000000000000000000000000000000002"; + view.rerender(); + expect(latest).toEqual({}); + await flush(); + const writesBefore = (AsyncStorage.setItem as jest.Mock).mock.calls.length; + await act(async () => { + resolveOld([]); + }); + await flush(); + expect((AsyncStorage.setItem as jest.Mock).mock.calls.length).toBe(writesBefore); + expect((AsyncStorage.setItem as jest.Mock).mock.calls.every(call => call[0].endsWith(mockAccount))).toBe(true); + view.unmount(); + }); +}); diff --git a/packages/sdk-v2/src/sdk/microbridge/useMicroBridgeHistory.ts b/packages/sdk-v2/src/sdk/microbridge/useMicroBridgeHistory.ts new file mode 100644 index 000000000..6bc0f9d10 --- /dev/null +++ b/packages/sdk-v2/src/sdk/microbridge/useMicroBridgeHistory.ts @@ -0,0 +1,86 @@ +import { useBlockNumbers, useEthers } from "@usedapp/core"; +import { useReadonlyNetworks } from "@usedapp/core/dist/esm/src/providers/network/readonlyNetworks"; +import { Contract } from "ethers"; +import { useEffect, useMemo, useRef, useState } from "react"; +import useRefreshOrNever from "../../hooks/useRefreshOrNever"; +import { AsyncStorage } from "../storage"; +import { + decodeMicroBridgeHistoryLogs, + getMicroBridgeHistoryCacheKey, + MicroBridgeHistoryCache, + syncMicroBridgeHistory +} from "./history.helpers"; + +/** Both event types share one per-chain queue, cache and successful cursor. */ +export const useMicroBridgeHistory = (chainId: number, bridge?: Contract) => { + const { account } = useEthers(); + const providers = useReadonlyNetworks(); + const provider = providers[chainId]; + const blockNumber = useBlockNumbers()[chainId]; + const refresh = useRefreshOrNever(5); + const address = bridge?.address; + const abi = bridge?.interface.format("json") as string | undefined; + const contract = useMemo( + () => (address && provider && abi ? new Contract(address, abi, provider) : undefined), + [address, provider, abi] + ); + const cacheKey = account && address ? getMicroBridgeHistoryCacheKey(chainId, address, account) : undefined; + const [history, setHistory] = useState<{ key: string; cache: MicroBridgeHistoryCache }>(); + const syncRef = useRef<(block?: number) => Promise>(); + + useEffect(() => { + if (!cacheKey || !account || !contract) return; + let cancelled = false; + let busy = false; + let cache: MicroBridgeHistoryCache = {}; + let lastAttemptBlock: number | undefined; + const hydrated = AsyncStorage.getItem(cacheKey) + .then(stored => { + cache = stored || {}; + if (!cancelled) setHistory({ key: cacheKey, cache }); + }) + .catch(() => undefined); + + const sync = async (block?: number) => { + if (busy || cancelled) return; + busy = true; + try { + await hydrated; + if (cancelled) return; + const latestBlock = block ?? (await contract.provider.getBlockNumber()); + if (lastAttemptBlock !== undefined && latestBlock - lastAttemptBlock < 5) return; + lastAttemptBlock = latestBlock; + const result = await syncMicroBridgeHistory(contract, latestBlock, account, cache, () => cancelled); + if (cancelled) return; + cache = result.cache; + setHistory({ key: cacheKey, cache }); + await AsyncStorage.setItem(cacheKey, cache).catch(() => undefined); + if (result.error) console.warn("Microbridge history refresh failed", { chainId }); + } catch { + // History failures remain independent of the form and transaction state. + console.warn("Microbridge history refresh failed", { chainId }); + } finally { + busy = false; + } + }; + + syncRef.current = sync; + void sync(); + return () => { + cancelled = true; + syncRef.current = undefined; + }; + }, [account, cacheKey, chainId, contract]); + + useEffect(() => { + if (refresh !== "never" && blockNumber !== undefined) void syncRef.current?.(blockNumber); + }, [blockNumber, refresh]); + + return useMemo(() => { + if (!contract || !cacheKey || history?.key !== cacheKey) return {}; + return { + requests: { value: decodeMicroBridgeHistoryLogs(contract, "BridgeRequest", history.cache.BridgeRequest) }, + transfers: { value: decodeMicroBridgeHistoryLogs(contract, "ExecutedTransfer", history.cache.ExecutedTransfer) } + }; + }, [cacheKey, contract, history]); +}; diff --git a/packages/sdk-v2/src/sdk/reserve/alternative.ts b/packages/sdk-v2/src/sdk/reserve/alternative.ts new file mode 100644 index 000000000..36050123f --- /dev/null +++ b/packages/sdk-v2/src/sdk/reserve/alternative.ts @@ -0,0 +1,170 @@ +import { BigNumber, BigNumberish, Contract, providers, utils } from "ethers"; + +const brokerAbi = [ + "function getAmountOut(address,bytes32,address,address,uint256) view returns (uint256)", + "function getAmountIn(address,bytes32,address,address,uint256) view returns (uint256)", + "function tradingLimitsConfig(bytes32) view returns (tuple(uint32 timestep0,uint32 timestep1,int48 limit0In,int48 limit0Out,int48 limit1In,int48 limit1Out,int48 limitGlobal,uint8 flags))", + "function tradingLimitsState(bytes32) view returns (tuple(uint32 lastUpdated0,uint32 lastUpdated1,int48 netflow0,int48 netflow1,int48 netflowGlobal))" +]; +const exchangeAbi = ["function getExchanges() view returns (tuple(bytes32 exchangeId,address[] assets)[])"]; +const tokenAbi = ["function decimals() view returns (uint8)"]; +const maxInt48 = BigNumber.from(2).pow(47).sub(1); +const minInt48 = BigNumber.from(2).pow(47).mul(-1); +const maxInt256 = BigNumber.from(2).pow(255).sub(1); + +interface LimitConfig { + flags: number; + timestep0: number; + timestep1: number; + limit0In: BigNumberish; + limit0Out: BigNumberish; + limit1In: BigNumberish; + limit1Out: BigNumberish; + limitGlobal: BigNumberish; +} + +interface LimitState { + lastUpdated0: number; + lastUpdated1: number; + netflow0: BigNumberish; + netflow1: BigNumberish; + netflowGlobal: BigNumberish; +} + +export interface ReserveAlternative { + chainId: number; + /** Local verification time, used internally to discard stale recommendations. */ + checkedAt: number; +} + +export interface ReserveAlternativeRequest { + buying: boolean; + // Human token units, normalized separately using each chain's discovered decimals. + exactInput?: { input: string; minAmountOut: string }; + exactOutput?: { output: string; maxAmountIn: string }; +} + +/** Match the deployed TradingLimits.update/verify rules without exposing headroom to the UI. */ +export const areReserveLimitsSatisfied = ( + config: LimitConfig, + state: LimitState, + amount: BigNumber, + decimals: number, + inflow: boolean, + chainTimestamp: number +): boolean => { + if (config.flags & ~7 || amount.lte(0) || amount.gt(maxInt256) || decimals < 0 || decimals > 77) return false; + if (!config.flags) return true; + // Solidity truncates fractional whole-token units, but a nonzero sub-token flow counts as one. + let delta = amount.div(BigNumber.from(10).pow(decimals)); + if (delta.isZero()) delta = BigNumber.from(1); + if (!inflow) delta = delta.mul(-1); + if (delta.gt(maxInt48) || delta.lt(minInt48)) return false; + + const within = (netflow: BigNumberish, limitIn: BigNumberish, limitOut: BigNumberish) => { + const next = BigNumber.from(netflow).add(delta); + // Equivalently, outflow headroom is limitOut + effectiveNetflow. + return next.gte(minInt48) && next.lte(maxInt48) && next.gte(BigNumber.from(limitOut).mul(-1)) && next.lte(limitIn); + }; + if (config.flags & 1) { + const flow = chainTimestamp > state.lastUpdated0 + config.timestep0 ? 0 : state.netflow0; + if (!within(flow, config.limit0In, config.limit0Out)) return false; + } + if (config.flags & 2) { + const flow = chainTimestamp > state.lastUpdated1 + config.timestep1 ? 0 : state.netflow1; + if (!within(flow, config.limit1In, config.limit1Out)) return false; + } + if (config.flags & 4 && !within(state.netflowGlobal, config.limitGlobal, config.limitGlobal)) return false; + return true; +}; + +export const getReserveLimitKey = (exchangeId: string, tokenAddress: string): string => + utils.hexZeroPad(BigNumber.from(exchangeId).xor(BigNumber.from(tokenAddress)).toHexString(), 32); + +/** Read-only check of the target environment's pair, quote and every enabled limit on both legs. */ +export const checkReserveAlternative = async ( + provider: providers.JsonRpcProvider, + chainId: number, + deployment: { MentoBroker?: string; MentoExchangeProvider?: string; GoodDollar?: string } | undefined, + request: ReserveAlternativeRequest +): Promise => { + if (!deployment?.MentoBroker || !deployment.MentoExchangeProvider || !deployment.GoodDollar) return undefined; + if (!!request.exactInput === !!request.exactOutput) return undefined; + const actualChainId = BigNumber.from(await provider.send("eth_chainId", [])).toNumber(); + if (actualChainId !== chainId) return undefined; + const block = await provider.getBlock("latest"); + if (!block || Math.abs(Date.now() / 1000 - block.timestamp) > 60) return undefined; + const overrides = { blockTag: block.number }; + const exchangeProvider = new Contract(deployment.MentoExchangeProvider, exchangeAbi, provider); + const broker = new Contract(deployment.MentoBroker, brokerAbi, provider); + const exchanges = (await exchangeProvider.getExchanges(overrides)) as { exchangeId: string; assets: string[] }[]; + const gd = deployment.GoodDollar.toLowerCase(); + // Follow the Reserve's default-pair discovery; never substitute an address from the active chain. + const exchange = exchanges.find( + pool => pool.assets.length === 2 && pool.assets.some(token => token.toLowerCase() === gd) + ); + if (!exchange) return undefined; + const reserveToken = exchange.assets.find(token => token.toLowerCase() !== gd); + if (!reserveToken) return undefined; + const inputToken = request.buying ? reserveToken : deployment.GoodDollar; + const outputToken = request.buying ? deployment.GoodDollar : reserveToken; + const [inputDecimals, outputDecimals] = (await Promise.all([ + new Contract(inputToken, tokenAbi, provider).decimals(overrides), + new Contract(outputToken, tokenAbi, provider).decimals(overrides) + ])) as [number, number]; + let amountIn: BigNumber; + let amountOut: BigNumber; + if (request.exactInput) { + amountIn = utils.parseUnits(request.exactInput.input, inputDecimals); + amountOut = await broker.getAmountOut( + deployment.MentoExchangeProvider, + exchange.exchangeId, + inputToken, + outputToken, + amountIn, + overrides + ); + if (amountOut.lt(utils.parseUnits(request.exactInput.minAmountOut, outputDecimals))) return undefined; + } else if (request.exactOutput) { + amountOut = utils.parseUnits(request.exactOutput.output, outputDecimals); + amountIn = await broker.getAmountIn( + deployment.MentoExchangeProvider, + exchange.exchangeId, + inputToken, + outputToken, + amountOut, + overrides + ); + if (amountIn.gt(utils.parseUnits(request.exactOutput.maxAmountIn, inputDecimals))) return undefined; + } else return undefined; + const allowed = await Promise.all( + [ + [inputToken, amountIn, inputDecimals, true], + [outputToken, amountOut, outputDecimals, false] + ].map(async leg => { + const [token, amount, decimals, inflow] = leg as [string, BigNumber, number, boolean]; + const key = getReserveLimitKey(exchange.exchangeId, token); + const [config, state] = (await Promise.all([ + broker.tradingLimitsConfig(key, overrides), + broker.tradingLimitsState(key, overrides) + ])) as [LimitConfig, LimitState]; + return areReserveLimitsSatisfied(config, state, amount, decimals, inflow, block.timestamp); + }) + ); + return allowed.every(Boolean) ? { chainId, checkedAt: Date.now() } : undefined; +}; + +/** Bound failed/slow RPC checks; the original transaction flow must not depend on these reads. */ +export const withReserveCheckTimeout = async (check: Promise): Promise => { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + check, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("Reserve alternative check timed out")), 10000); + }) + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +}; diff --git a/packages/sdk-v2/src/sdk/reserve/errors.ts b/packages/sdk-v2/src/sdk/reserve/errors.ts new file mode 100644 index 000000000..8315d8945 --- /dev/null +++ b/packages/sdk-v2/src/sdk/reserve/errors.ts @@ -0,0 +1,95 @@ +import { utils } from "ethers"; +import type { TransactionStatus } from "@usedapp/core"; + +export const RESERVE_LIMIT_ERROR = "RESERVE_LIMIT" as const; + +export type ReserveLimitReason = + | "L0Out Exceeded" + | "L1Out Exceeded" + | "L0In Exceeded" + | "L1In Exceeded" + | "LG Exceeded"; + +export interface ReserveTransactionStatus extends TransactionStatus { + errorType?: typeof RESERVE_LIMIT_ERROR; + /** The unmodified provider/ethers error, for debugging rather than display. */ + originalError?: unknown; +} + +const limitReasons = new Set([ + "L0Out Exceeded", + "L1Out Exceeded", + "L0In Exceeded", + "L1In Exceeded", + "LG Exceeded" +]); + +const exactLimitReason = (reason: string): ReserveLimitReason | undefined => + limitReasons.has(reason) ? (reason as ReserveLimitReason) : undefined; + +const decodeRevertReason = (data: string): ReserveLimitReason | undefined => { + // Solidity Error(string), not Panic(uint256) or arbitrary custom errors. + if (!/^0x08c379a0[0-9a-f]+$/i.test(data)) return undefined; + try { + const [reason] = utils.defaultAbiCoder.decode(["string"], `0x${data.slice(10)}`) as [string]; + return exactLimitReason(reason); + } catch { + return undefined; + } +}; + +const reasonFromMessage = (message: string): ReserveLimitReason | undefined => { + const exact = exactLimitReason(message.trim()); + if (exact) return exact; + + // Recognize complete revert reasons in standard ethers/RPC wrappers, not loose substrings. + const reverted = message.match( + /(?:execution reverted(?::| with reason string)|VM Exception while processing transaction: revert)\s*["']?(L[01](?:Out|In) Exceeded|LG Exceeded)["']?(?=$|\s*\()/i + ); + const attributed = message.match(/\b(?:reason|message)["']?\s*[:=]\s*["'](L[01](?:Out|In) Exceeded|LG Exceeded)["']/); + const reason = exactLimitReason(reverted?.[1] ?? attributed?.[1] ?? ""); + if (reason) return reason; + + // Some providers put the ABI-encoded revert data only in the serialized message. + for (const match of message.matchAll(/\b(?:data|return|result|revertData)["']?\s*[:=]\s*["']?(0x[0-9a-f]+)/gi)) { + const decoded = decodeRevertReason(match[1]); + if (decoded) return decoded; + } + return undefined; +}; + +/** Detect only the five known Reserve trading-limit reasons in nested ethers/RPC errors. */ +export const getReserveLimitReason = (error: unknown): ReserveLimitReason | undefined => { + const pending: unknown[] = [error]; + const seen = new Set(); + let found: ReserveLimitReason | undefined; + + while (pending.length) { + const value = pending.pop(); + if (typeof value === "string") { + found ??= reasonFromMessage(value) ?? decodeRevertReason(value); + try { + // JSON-RPC response bodies can contain a second, serialized error object. + const trimmed = value.trimStart(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) pending.push(JSON.parse(value)); + } catch { + // Plain messages and malformed JSON are handled as text above. + } + } else if (value && typeof value === "object" && !seen.has(value)) { + seen.add(value); + const record = value as Record; + // Preserve wallet-rejection handling even if a provider attaches an earlier simulation error. + if (record.code === 4001 || record.code === "4001" || record.code === "ACTION_REJECTED") return undefined; + for (const key of ["reason", "message", "shortMessage", "error", "originalError", "cause", "body", "response"]) { + pending.push(record[key]); + } + for (const key of ["data", "return", "result", "revertData"]) { + const data = record[key]; + pending.push(data); + // MetaMask sometimes keys the revert details by transaction hash under `data`. + if (data && typeof data === "object") pending.push(...Object.values(data)); + } + } + } + return found; +}; diff --git a/packages/sdk-v2/src/sdk/reserve/index.ts b/packages/sdk-v2/src/sdk/reserve/index.ts index 2b5ed7162..c47541099 100644 --- a/packages/sdk-v2/src/sdk/reserve/index.ts +++ b/packages/sdk-v2/src/sdk/reserve/index.ts @@ -1 +1,3 @@ +export * from "./alternative"; +export * from "./errors"; export * from "./react"; diff --git a/packages/sdk-v2/src/sdk/reserve/react.tsx b/packages/sdk-v2/src/sdk/reserve/react.tsx index 3819f7a8c..ca02fe251 100644 --- a/packages/sdk-v2/src/sdk/reserve/react.tsx +++ b/packages/sdk-v2/src/sdk/reserve/react.tsx @@ -1,10 +1,14 @@ -import { useMemo } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { BigNumber, Contract, ethers } from "ethers"; -import { ERC20Interface, QueryParams, useCall, useCalls, useTokenAllowance } from "@usedapp/core"; +import { ERC20Interface, QueryParams, useCall, useCalls, useEthers, useTokenAllowance } from "@usedapp/core"; import contractAddresses from "@gooddollar/goodprotocol/releases/deployment.json"; -import { useContractFunctionWithDefaultGasFees, useG$Tokens, useGetEnvChainId } from "../../sdk"; +import { useG$Tokens, useGetEnvChainId } from "../base/react"; +import { useGasFees } from "../base/hooks/useGasFees"; import { useRefreshOrNever } from "../../hooks"; +import { getReserveLimitReason, RESERVE_LIMIT_ERROR, ReserveTransactionStatus } from "./errors"; +import { checkReserveAlternative, ReserveAlternative, withReserveCheckTimeout } from "./alternative"; +import { useReadOnlyProvider } from "../../hooks/useMulticallAtChain"; const exchangeAbi = [ "function currentPrice(bytes32 exchangeId) external view returns (uint256 price)", @@ -186,6 +190,101 @@ export const useSwapMeta = ( }; }; +// Submit directly, as in MPBBridge: usedapp assumes every account with bytecode is a Safe, +// including EOAs carrying an EIP-7702 delegation (for example MetaMask gas delegation). +const useReserveContractFunction = (contract: Contract, functionName: string) => { + const { account, chainId, library } = useEthers(); + const gasFees = useGasFees(); + const [state, setState] = useState({ status: "None" }); + const [events, setEvents] = useState(); + const resetState = useCallback(() => setState({ status: "None" }), []); + + const send = useCallback( + async (...args: any[]) => { + if (!library || !account || !chainId) { + setState({ status: "Exception", chainId, errorMessage: "Wallet is not connected" }); + return undefined; + } + + let transaction: ethers.providers.TransactionResponse | undefined; + setEvents(undefined); + setState({ status: "PendingSignature", chainId }); + + try { + const signer = (library as ethers.providers.Web3Provider).getSigner(account); + const contractWithSigner = contract.connect(signer); + const overrides: ethers.Overrides = {}; + // Match useContractFunctionWithDefaultGasFees without entering usedapp's Safe detection. + if (gasFees.gasPrice != null) overrides.maxFeePerGas = gasFees.gasPrice; + if (gasFees.maxPriorityFeePerGas != null) overrides.maxPriorityFeePerGas = gasFees.maxPriorityFeePerGas; + transaction = (await contractWithSigner[functionName]( + ...args, + overrides + )) as ethers.providers.TransactionResponse; + setState({ status: "Mining", transaction, chainId }); + + const receipt = await transaction.wait(); + const failed = receipt.status === 0; + setState({ + status: failed ? "Fail" : "Success", + transaction, + receipt, + chainId, + errorMessage: failed ? "Reserve transaction failed" : undefined + }); + setEvents( + receipt.logs.reduce((parsed, log) => { + try { + if (log.address.toLowerCase() === contract.address.toLowerCase()) { + parsed.push(contract.interface.parseLog(log)); + } + } catch { + // Ignore logs not described by the contract ABI. + } + return parsed; + }, []) + ); + return receipt; + } catch (error: any) { + // ethers rejects wait() for mined reverts and replacement transactions. + const receipt = error?.receipt as ethers.providers.TransactionReceipt | undefined; + if (transaction && error?.code === "TRANSACTION_REPLACED" && !error.cancelled && receipt) { + setState({ + status: receipt.status === 0 ? "Fail" : "Success", + transaction: error.replacement, + originalTransaction: transaction, + receipt, + chainId, + originalError: receipt.status === 0 ? error : undefined, + errorType: receipt.status === 0 && getReserveLimitReason(error) ? RESERVE_LIMIT_ERROR : undefined, + errorMessage: receipt.status === 0 ? "Reserve transaction failed" : undefined + }); + return receipt; + } + setState({ + status: transaction ? "Fail" : "Exception", + transaction, + receipt, + chainId, + originalError: error, + errorType: getReserveLimitReason(error) ? RESERVE_LIMIT_ERROR : undefined, + errorMessage: + error?.error?.data?.message || + error?.error?.message || + error?.reason || + error?.data?.message || + error?.message || + "Reserve transaction failed" + }); + return undefined; + } + }, + [account, chainId, library, contract, functionName, gasFees] + ); + + return { state, send, resetState, events }; +}; + export const useSwap = ( inputToken: string, outputToken: string, @@ -195,6 +294,20 @@ export const useSwap = ( if (exactInput && exactOutput) throw new Error("Only one of exactInput or exactOutput can be specified"); const { connectedEnv } = useGetEnvChainId(); + const { chainId } = useEthers(); + const otherChainId = chainId === 42220 ? 50 : 42220; + const { defaultEnv: otherEnv } = useGetEnvChainId(otherChainId); + const sourceProvider = useReadOnlyProvider(chainId ?? 42220); + const otherProvider = useReadOnlyProvider(otherChainId); + const lastAlternativeCheck = useRef<() => Promise>(); + const checkAlternativeReserve = useCallback(async () => { + try { + return await withReserveCheckTimeout(lastAlternativeCheck.current?.() ?? Promise.resolve(undefined)); + } catch { + // Missing deployments, unsupported pairs, failed or stale RPC reads use the generic UI suggestion. + return undefined; + } + }, []); const { exchangeId } = useExchange(); const exchangeProviderAddress = @@ -209,14 +322,37 @@ export const useSwap = ( ); const tokenContract = useMemo(() => new Contract(inputToken, ERC20Interface), [inputToken]); - const approve = useContractFunctionWithDefaultGasFees(tokenContract, "approve"); + const approve = useReserveContractFunction(tokenContract, "approve"); const { send } = approve; approve.send = () => send(mentoBroker.address, exactInput ? exactInput.input : exactOutput?.maxAmountIn); - const swap = useContractFunctionWithDefaultGasFees(mentoBroker, exactInput ? "swapIn" : "swapOut"); + const swap = useReserveContractFunction(mentoBroker, exactInput ? "swapIn" : "swapOut"); const { send: swapSend } = swap; // address exchangeProvider,bytes32 exchangeId,address tokenIn,address tokenOut,uint256 amountIn,uint256 amountOutMin swap.send = async () => { + // Snapshot the submitted swap: the legacy UI clears its amount fields once mining starts. + // These reads run only when requested after a limit error and never touch the wallet signer. + lastAlternativeCheck.current = async () => { + if (!sourceProvider || !otherProvider || (chainId !== 42220 && chainId !== 50)) return undefined; + const sourceGd = contractAddresses[connectedEnv]?.GoodDollar?.toLowerCase(); + const buying = outputToken.toLowerCase() === sourceGd; + if (!buying && inputToken.toLowerCase() !== sourceGd) return undefined; + const [inputDecimals, outputDecimals] = (await Promise.all([ + tokenContract.connect(sourceProvider).decimals(), + new Contract(outputToken, ERC20Interface, sourceProvider).decimals() + ])) as [number, number]; + return checkReserveAlternative(otherProvider, otherChainId, contractAddresses[otherEnv], { + buying, + exactInput: exactInput && { + input: ethers.utils.formatUnits(exactInput.input, inputDecimals), + minAmountOut: ethers.utils.formatUnits(exactInput.minAmountOut, outputDecimals) + }, + exactOutput: exactOutput && { + output: ethers.utils.formatUnits(exactOutput.output, outputDecimals), + maxAmountIn: ethers.utils.formatUnits(exactOutput.maxAmountIn, inputDecimals) + } + }); + }; return swapSend( exchangeProviderAddress, exchangeId, @@ -226,5 +362,5 @@ export const useSwap = ( exactInput?.minAmountOut || exactOutput?.maxAmountIn ); }; - return { approve, swap }; + return { approve, swap, checkAlternativeReserve }; }; diff --git a/packages/sdk-v2/tsconfig.tests.json b/packages/sdk-v2/tsconfig.tests.json new file mode 100644 index 000000000..c38d2673d --- /dev/null +++ b/packages/sdk-v2/tsconfig.tests.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true, + "types": ["jest", "node"] + }, + "include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/custom.d.ts"], + "exclude": ["node_modules", "../../node_modules"] +}