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