Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions packages/good-design/src/core/web3/modals/ErrorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}) => {
Expand All @@ -33,7 +37,7 @@ export const ErrorModal = ({
<BasicStyledModal
{...props}
type="cta"
title="Oops!"
title={title}
show={show}
onClose={handleClose}
withCloseButton={true}
Expand All @@ -42,9 +46,7 @@ export const ErrorModal = ({
body={<ModalErrorBody error={error} />}
footer={
<View w={"100%"}>
<Center>
<Text>{reason}</Text>
</Center>
<Center>{footer ?? <Text>{reason}</Text>}</Center>
</View>
}
/>
Expand Down
66 changes: 66 additions & 0 deletions packages/good-design/src/core/web3/modals/ReserveLimitModal.tsx
Original file line number Diff line number Diff line change
@@ -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<number, { limit: string; alternative: string }> = {
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 (
<ErrorModal
title={i18n._(/*i18n*/ "Reserve limit reached")}
error={i18n._(
messages[chainId]?.limit ?? /*i18n*/ "The Reserve’s trading limit on this network has been reached."
)}
overlay="blur"
onClose={onClose}
footer={
<VStack space={3} alignItems="center">
{otherChainMessage && <Text textAlign="center">{i18n._(otherChainMessage)}</Text>}
<HStack space={4} justifyContent="center" flexWrap="wrap">
{dexRoutes(chainId).map(({ name, href }) => (
<Link key={name} href={href} isExternal _text={{ underline: true }}>
{name}
</Link>
))}
</HStack>
</VStack>
}
/>
);
};
1 change: 1 addition & 0 deletions packages/good-design/src/core/web3/modals/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export * from "./ErrorModal";
export * from "./YouSureModal";
export * from "./TxDetailsModal";
export * from "./SwitchChainModal";
export * from "./ReserveLimitModal";
6 changes: 5 additions & 1 deletion packages/sdk-v2/lint-staged.config.js
Original file line number Diff line number Diff line change
@@ -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"
]
};
197 changes: 197 additions & 0 deletions packages/sdk-v2/src/sdk/base/react.readProviders.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("react")>("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(
<DAppProvider
config={{
readOnlyChainId: 122,
readOnlyUrls: { 122: factory ? () => fuse : fuse, 42220: factory ? () => celo : celo },
networks: [
createMockChain(122, "Mock Fuse", "0x0000000000000000000000000000000000000010"),
createMockChain(42220, "Mock Celo", "0x0000000000000000000000000000000000000020")
],
multicallVersion: 1,
pollingInterval: 15000,
autoConnect: false
}}
>
<ConnectorContext.Provider value={{ account, chainId: 122 } as any}>
<View />
</ConnectorContext.Provider>
</DAppProvider>
)
);
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
);
});
42 changes: 42 additions & 0 deletions packages/sdk-v2/src/sdk/base/sdk.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 8 additions & 6 deletions packages/sdk-v2/src/sdk/base/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Comment on lines +104 to +105
const signer = provider.getSigner();
signer
.getAddress()
.then(async () => void (this.signer = signer))
.catch(noop);
}
}

getContract(contractName: "UBIScheme"): UBIScheme;
Expand Down
Loading
Loading