@multiplatform.one/web3 7.36.0 → 7.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.cjs +79 -8
- package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.native.js +90 -5
- package/dist/cjs/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
- package/dist/cjs/connectkit/connectors/restoredSession.cjs +136 -0
- package/dist/cjs/connectkit/connectors/restoredSession.native.js +297 -0
- package/dist/cjs/connectkit/connectors/restoredSession.native.js.map +1 -0
- package/dist/cjs/connectkit/defaultConfig.cjs +3 -1
- package/dist/cjs/connectkit/defaultConfig.native.js +3 -1
- package/dist/cjs/connectkit/defaultConfig.native.js.map +1 -1
- package/dist/cjs/connectkit/defaultConnectors.cjs +5 -1
- package/dist/cjs/connectkit/defaultConnectors.native.js +5 -1
- package/dist/cjs/connectkit/defaultConnectors.native.js.map +1 -1
- package/dist/cjs/connectkit/index.cjs +3 -0
- package/dist/cjs/connectkit/index.native.js +3 -0
- package/dist/cjs/connectkit/index.native.js.map +1 -1
- package/dist/cjs/keycloak/KeycloakWalletLogin.cjs +16 -2
- package/dist/cjs/keycloak/useWalletLogin.cjs +9 -2
- package/dist/esm/connectkit/connectors/metaMaskConnectConnector.mjs +79 -8
- package/dist/esm/connectkit/connectors/metaMaskConnectConnector.mjs.map +1 -1
- package/dist/esm/connectkit/connectors/metaMaskConnectConnector.native.js +90 -5
- package/dist/esm/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
- package/dist/esm/connectkit/connectors/restoredSession.mjs +107 -0
- package/dist/esm/connectkit/connectors/restoredSession.mjs.map +1 -0
- package/dist/esm/connectkit/connectors/restoredSession.native.js +265 -0
- package/dist/esm/connectkit/connectors/restoredSession.native.js.map +1 -0
- package/dist/esm/connectkit/defaultConfig.mjs +3 -1
- package/dist/esm/connectkit/defaultConfig.mjs.map +1 -1
- package/dist/esm/connectkit/defaultConfig.native.js +3 -1
- package/dist/esm/connectkit/defaultConfig.native.js.map +1 -1
- package/dist/esm/connectkit/defaultConnectors.mjs +5 -1
- package/dist/esm/connectkit/defaultConnectors.mjs.map +1 -1
- package/dist/esm/connectkit/defaultConnectors.native.js +5 -1
- package/dist/esm/connectkit/defaultConnectors.native.js.map +1 -1
- package/dist/esm/connectkit/index.mjs +2 -1
- package/dist/esm/connectkit/index.mjs.map +1 -1
- package/dist/esm/connectkit/index.native.js +2 -1
- package/dist/esm/connectkit/index.native.js.map +1 -1
- package/dist/esm/keycloak/KeycloakWalletLogin.mjs +16 -2
- package/dist/esm/keycloak/KeycloakWalletLogin.mjs.map +1 -1
- package/dist/esm/keycloak/useWalletLogin.mjs +10 -3
- package/dist/esm/keycloak/useWalletLogin.mjs.map +1 -1
- package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.mjs +79 -8
- package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.mjs.map +1 -1
- package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.native.js +90 -5
- package/dist/jsx/connectkit/connectors/metaMaskConnectConnector.native.js.map +1 -1
- package/dist/jsx/connectkit/connectors/restoredSession.mjs +107 -0
- package/dist/jsx/connectkit/connectors/restoredSession.mjs.map +1 -0
- package/dist/jsx/connectkit/connectors/restoredSession.native.js +297 -0
- package/dist/jsx/connectkit/connectors/restoredSession.native.js.map +1 -0
- package/dist/jsx/connectkit/defaultConfig.mjs +3 -1
- package/dist/jsx/connectkit/defaultConfig.mjs.map +1 -1
- package/dist/jsx/connectkit/defaultConfig.native.js +3 -1
- package/dist/jsx/connectkit/defaultConfig.native.js.map +1 -1
- package/dist/jsx/connectkit/defaultConnectors.mjs +5 -1
- package/dist/jsx/connectkit/defaultConnectors.mjs.map +1 -1
- package/dist/jsx/connectkit/defaultConnectors.native.js +5 -1
- package/dist/jsx/connectkit/defaultConnectors.native.js.map +1 -1
- package/dist/jsx/connectkit/index.mjs +2 -1
- package/dist/jsx/connectkit/index.mjs.map +1 -1
- package/dist/jsx/connectkit/index.native.js +3 -0
- package/dist/jsx/connectkit/index.native.js.map +1 -1
- package/dist/jsx/keycloak/KeycloakWalletLogin.mjs +16 -2
- package/dist/jsx/keycloak/KeycloakWalletLogin.mjs.map +1 -1
- package/dist/jsx/keycloak/useWalletLogin.mjs +10 -3
- package/dist/jsx/keycloak/useWalletLogin.mjs.map +1 -1
- package/dist/keycloak/login.iife.js +356 -356
- package/package.json +6 -6
- package/src/connectkit/connectors/metaMaskConnectConnector.spec.ts +316 -0
- package/src/connectkit/connectors/metaMaskConnectConnector.ts +143 -3
- package/src/connectkit/connectors/restoredSession.spec.ts +206 -0
- package/src/connectkit/connectors/restoredSession.ts +165 -0
- package/src/connectkit/defaultConfig.ts +8 -0
- package/src/connectkit/defaultConnectors.ts +6 -0
- package/src/connectkit/index.ts +5 -0
- package/src/keycloak/KeycloakWalletLogin.spec.tsx +51 -1
- package/src/keycloak/KeycloakWalletLogin.tsx +12 -1
- package/src/keycloak/useWalletLogin.ts +18 -2
- package/types/connectkit/connectors/metaMaskConnectConnector.d.ts +9 -0
- package/types/connectkit/connectors/metaMaskConnectConnector.d.ts.map +1 -1
- package/types/connectkit/connectors/restoredSession.d.ts +45 -0
- package/types/connectkit/connectors/restoredSession.d.ts.map +1 -0
- package/types/connectkit/defaultConfig.d.ts +7 -1
- package/types/connectkit/defaultConfig.d.ts.map +1 -1
- package/types/connectkit/defaultConnectors.d.ts +3 -1
- package/types/connectkit/defaultConnectors.d.ts.map +1 -1
- package/types/connectkit/index.d.ts +1 -0
- package/types/connectkit/index.d.ts.map +1 -1
- package/types/keycloak/KeycloakWalletLogin.d.ts.map +1 -1
- package/types/keycloak/useWalletLogin.d.ts +6 -0
- package/types/keycloak/useWalletLogin.d.ts.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/web3",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.38.0",
|
|
4
4
|
"description": "Web3 wallet integration for multiplatform.one ecosystem with ConnectKit and wagmi",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"connectkit",
|
|
@@ -120,10 +120,10 @@
|
|
|
120
120
|
"@aave/account": "^0.2.1",
|
|
121
121
|
"@coinbase/wallet-sdk": "^4.4.0",
|
|
122
122
|
"@metamask/connect-evm": "^2.1.1",
|
|
123
|
-
"@multiplatform.one/connectkit": "^7.
|
|
124
|
-
"@multiplatform.one/gpg": "^7.
|
|
125
|
-
"@multiplatform.one/platform": "^7.
|
|
126
|
-
"@multiplatform.one/theme": "^7.
|
|
123
|
+
"@multiplatform.one/connectkit": "^7.38.0",
|
|
124
|
+
"@multiplatform.one/gpg": "^7.38.0",
|
|
125
|
+
"@multiplatform.one/platform": "^7.38.0",
|
|
126
|
+
"@multiplatform.one/theme": "^7.38.0",
|
|
127
127
|
"@noble/ciphers": "^1.3.0",
|
|
128
128
|
"@noble/curves": "^1.9.7",
|
|
129
129
|
"@solana/kit": "^8.5.0",
|
|
@@ -146,7 +146,7 @@
|
|
|
146
146
|
"wagmi": "^3.7.1"
|
|
147
147
|
},
|
|
148
148
|
"devDependencies": {
|
|
149
|
-
"@multiplatform.one/config": "7.
|
|
149
|
+
"@multiplatform.one/config": "7.38.0",
|
|
150
150
|
"@tamagui/build": "2.7.6",
|
|
151
151
|
"@tanstack/react-query": "^5.104.3",
|
|
152
152
|
"@testing-library/jest-dom": "^7.0.1",
|
|
@@ -3,9 +3,16 @@ import type { Chain } from "viem";
|
|
|
3
3
|
import { getAddress, SwitchChainError, UserRejectedRequestError } from "viem";
|
|
4
4
|
import { base, baseSepolia, bscTestnet, mainnet, polygon, polygonAmoy, sepolia } from "viem/chains";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { IDBFactory } from "fake-indexeddb";
|
|
6
7
|
import { createConfig, http } from "wagmi";
|
|
7
8
|
import { connect, reconnect, sendTransaction, signMessage, signTypedData } from "wagmi/actions";
|
|
8
9
|
|
|
10
|
+
import {
|
|
11
|
+
metaMaskConnectKV,
|
|
12
|
+
SessionStore,
|
|
13
|
+
storedSession,
|
|
14
|
+
} from "../../../tests/fixtures/metaMaskConnectStore";
|
|
15
|
+
|
|
9
16
|
import {
|
|
10
17
|
metaMaskConnect,
|
|
11
18
|
metaMaskConnectChainIds,
|
|
@@ -666,6 +673,315 @@ describe("metaMaskConnect switchChain on a phone", () => {
|
|
|
666
673
|
});
|
|
667
674
|
});
|
|
668
675
|
|
|
676
|
+
describe("metaMaskConnect on a session MetaMask no longer holds", () => {
|
|
677
|
+
const account = "0x00000000000000000000000000000000000000aa";
|
|
678
|
+
const timeout = { afterReturnMs: 20_000, withoutTripMs: 45_000 };
|
|
679
|
+
const listeners = new Set<() => void>();
|
|
680
|
+
const tab = {
|
|
681
|
+
visibilityState: "visible",
|
|
682
|
+
addEventListener: (_: string, listener: () => void) => void listeners.add(listener),
|
|
683
|
+
removeEventListener: (_: string, listener: () => void) => void listeners.delete(listener),
|
|
684
|
+
};
|
|
685
|
+
const show = (state: "hidden" | "visible") => {
|
|
686
|
+
tab.visibilityState = state;
|
|
687
|
+
for (const listener of [...listeners]) listener();
|
|
688
|
+
};
|
|
689
|
+
// A relay channel no wallet reads: the request is sent and nothing ever comes back.
|
|
690
|
+
const unanswered = vi.fn(
|
|
691
|
+
({ method }: { method: string }) =>
|
|
692
|
+
new Promise((resolve) => {
|
|
693
|
+
if (method !== "personal_sign") resolve(null);
|
|
694
|
+
}),
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
beforeEach(() => {
|
|
698
|
+
resetMetaMaskConnectClient();
|
|
699
|
+
vi.clearAllMocks();
|
|
700
|
+
vi.useFakeTimers();
|
|
701
|
+
vi.stubGlobal("document", tab);
|
|
702
|
+
tab.visibilityState = "visible";
|
|
703
|
+
listeners.clear();
|
|
704
|
+
client.getChainId.mockReturnValue("0x1");
|
|
705
|
+
client.getProvider.mockReturnValue({ request: unanswered } as never);
|
|
706
|
+
// createEVMClient resumed the stored session: its cached accounts are there.
|
|
707
|
+
client.accounts = [account];
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
afterEach(() => {
|
|
711
|
+
vi.useRealTimers();
|
|
712
|
+
vi.unstubAllGlobals();
|
|
713
|
+
resetMetaMaskConnectClient();
|
|
714
|
+
client.disconnect.mockReset();
|
|
715
|
+
client.disconnect.mockImplementation(async () => undefined);
|
|
716
|
+
client.accounts = [];
|
|
717
|
+
client.getChainId.mockImplementation(() => "0x2105");
|
|
718
|
+
client.getProvider.mockImplementation(() => ({ request: vi.fn() }));
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
function restoredConfig(options: { mobile?: boolean; restoredSessionTimeout?: typeof timeout }) {
|
|
722
|
+
return createConfig({
|
|
723
|
+
chains: [mainnet],
|
|
724
|
+
connectors: [
|
|
725
|
+
metaMaskConnect({ dapp: { name: "test", url: "https://example.test" }, ...options }),
|
|
726
|
+
],
|
|
727
|
+
transports: { [mainnet.id]: http() },
|
|
728
|
+
multiInjectedProviderDiscovery: false,
|
|
729
|
+
storage: null,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function signOnRestoredSession(config: ReturnType<typeof restoredConfig>) {
|
|
734
|
+
await reconnect(config);
|
|
735
|
+
expect(config.state.status).toBe("connected");
|
|
736
|
+
const signing = signMessage(config, { message: "Sign in" });
|
|
737
|
+
const outcome: { error?: unknown; settled: boolean } = { settled: false };
|
|
738
|
+
signing.then(
|
|
739
|
+
() => (outcome.settled = true),
|
|
740
|
+
(error: unknown) => Object.assign(outcome, { error, settled: true }),
|
|
741
|
+
);
|
|
742
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
743
|
+
return outcome;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
it("gives up 20 s after the page is back from MetaMask, drops the session and connects afresh", async () => {
|
|
747
|
+
const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
|
|
748
|
+
const outcome = await signOnRestoredSession(config);
|
|
749
|
+
|
|
750
|
+
// The deeplink takes the user to MetaMask ("Connection Not Found") and back.
|
|
751
|
+
show("hidden");
|
|
752
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
753
|
+
show("visible");
|
|
754
|
+
await vi.advanceTimersByTimeAsync(timeout.afterReturnMs - 1);
|
|
755
|
+
expect(outcome.settled).toBe(false);
|
|
756
|
+
expect(client.disconnect).not.toHaveBeenCalled();
|
|
757
|
+
|
|
758
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
759
|
+
// viem reports EIP-1193's 4900 as ProviderDisconnectedError, ours as its cause.
|
|
760
|
+
expect(outcome.error).toMatchObject({
|
|
761
|
+
code: 4900,
|
|
762
|
+
cause: { name: "MetaMaskSessionLostError" },
|
|
763
|
+
});
|
|
764
|
+
expect(client.disconnect).toHaveBeenCalledOnce();
|
|
765
|
+
expect(config.state.status).toBe("disconnected");
|
|
766
|
+
expect(config.state.connections.size).toBe(0);
|
|
767
|
+
|
|
768
|
+
// MetaMask Connect has forgotten the session, so the next connect is a new one.
|
|
769
|
+
client.accounts = [];
|
|
770
|
+
await connect(config, { connector: config.connectors[0]! });
|
|
771
|
+
expect(client.connect).toHaveBeenCalledOnce();
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("gives up after 45 s when the page never got to MetaMask", async () => {
|
|
775
|
+
const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
|
|
776
|
+
const outcome = await signOnRestoredSession(config);
|
|
777
|
+
|
|
778
|
+
await vi.advanceTimersByTimeAsync(timeout.withoutTripMs - 1);
|
|
779
|
+
expect(outcome.settled).toBe(false);
|
|
780
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
781
|
+
expect(outcome.error).toMatchObject({ code: 4900 });
|
|
782
|
+
expect(config.state.status).toBe("disconnected");
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it("lets go of the page even when MetaMask Connect's disconnect never finishes", async () => {
|
|
786
|
+
client.disconnect.mockImplementationOnce(() => new Promise(() => undefined));
|
|
787
|
+
const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
|
|
788
|
+
const outcome = await signOnRestoredSession(config);
|
|
789
|
+
|
|
790
|
+
await vi.advanceTimersByTimeAsync(timeout.withoutTripMs);
|
|
791
|
+
expect(config.state.status).toBe("disconnected");
|
|
792
|
+
expect(outcome.settled).toBe(false);
|
|
793
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
794
|
+
expect(outcome.error).toMatchObject({ code: 4900 });
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it("connects afresh while a dropped session's disconnect still runs, never on that session", async () => {
|
|
798
|
+
// fake-indexeddb runs its requests on setImmediate.
|
|
799
|
+
vi.useRealTimers();
|
|
800
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
|
|
801
|
+
// MetaMask Connect's stored session, which its connect would resume.
|
|
802
|
+
const idb = new IDBFactory();
|
|
803
|
+
vi.stubGlobal("indexedDB", idb);
|
|
804
|
+
const kv = metaMaskConnectKV(idb);
|
|
805
|
+
await (await SessionStore.create(kv)).set(storedSession("stale"));
|
|
806
|
+
client.disconnect.mockImplementationOnce(() => new Promise<undefined>(() => undefined));
|
|
807
|
+
let resumable: string[] | undefined;
|
|
808
|
+
client.connect.mockImplementationOnce(async () => {
|
|
809
|
+
resumable = (await (await SessionStore.create(kv)).list()).map((s) => s.id);
|
|
810
|
+
return { accounts: [account], chainId: "0x1" };
|
|
811
|
+
});
|
|
812
|
+
const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
|
|
813
|
+
const outcome = await signOnRestoredSession(config);
|
|
814
|
+
|
|
815
|
+
// The tap lands as the session is dropped, before the request has even failed.
|
|
816
|
+
await vi.advanceTimersByTimeAsync(timeout.withoutTripMs);
|
|
817
|
+
expect(config.state.status).toBe("disconnected");
|
|
818
|
+
expect(client.disconnect).toHaveBeenCalledOnce();
|
|
819
|
+
client.accounts = [];
|
|
820
|
+
let connected = false;
|
|
821
|
+
connect(config, { connector: config.connectors[0]! }).then(() => (connected = true));
|
|
822
|
+
for (let i = 0; i < 100 && !connected; i++) {
|
|
823
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
824
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// MetaMask Connect's disconnect never finished.
|
|
828
|
+
expect(client.connect).toHaveBeenCalledOnce();
|
|
829
|
+
expect(resumable).toEqual([]);
|
|
830
|
+
expect(config.state.status).toBe("connected");
|
|
831
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
832
|
+
expect(outcome.error).toMatchObject({ code: 4900 });
|
|
833
|
+
await kv.close();
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it("makes a connect wait for MetaMask Connect's disconnect still running, then go ahead", async () => {
|
|
837
|
+
let finishDisconnect!: () => void;
|
|
838
|
+
client.disconnect.mockImplementationOnce(
|
|
839
|
+
() => new Promise<undefined>((resolve) => (finishDisconnect = () => resolve(undefined))),
|
|
840
|
+
);
|
|
841
|
+
const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
|
|
842
|
+
const outcome = await signOnRestoredSession(config);
|
|
843
|
+
await vi.advanceTimersByTimeAsync(timeout.withoutTripMs + 5_000);
|
|
844
|
+
expect(outcome.error).toMatchObject({ code: 4900 });
|
|
845
|
+
|
|
846
|
+
client.accounts = [];
|
|
847
|
+
const connecting = connect(config, { connector: config.connectors[0]! });
|
|
848
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
849
|
+
expect(client.connect).not.toHaveBeenCalled();
|
|
850
|
+
finishDisconnect();
|
|
851
|
+
await connecting;
|
|
852
|
+
expect(client.connect).toHaveBeenCalledOnce();
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it("waits at most a second for a disconnect that never finishes", async () => {
|
|
856
|
+
client.disconnect.mockImplementationOnce(() => new Promise<undefined>(() => undefined));
|
|
857
|
+
const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
|
|
858
|
+
const outcome = await signOnRestoredSession(config);
|
|
859
|
+
await vi.advanceTimersByTimeAsync(timeout.withoutTripMs + 5_000);
|
|
860
|
+
expect(outcome.error).toMatchObject({ code: 4900 });
|
|
861
|
+
|
|
862
|
+
client.accounts = [];
|
|
863
|
+
const connecting = connect(config, { connector: config.connectors[0]! });
|
|
864
|
+
await vi.advanceTimersByTimeAsync(999);
|
|
865
|
+
expect(client.connect).not.toHaveBeenCalled();
|
|
866
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
867
|
+
await connecting;
|
|
868
|
+
expect(client.connect).toHaveBeenCalledOnce();
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it("fails the request and lets a connect through when IndexedDB never answers", async () => {
|
|
872
|
+
// An open request that never fires, as IndexedDB sometimes does on iOS.
|
|
873
|
+
vi.stubGlobal("indexedDB", { open: () => ({}) });
|
|
874
|
+
const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
|
|
875
|
+
const outcome = await signOnRestoredSession(config);
|
|
876
|
+
await vi.advanceTimersByTimeAsync(timeout.withoutTripMs);
|
|
877
|
+
expect(config.state.status).toBe("disconnected");
|
|
878
|
+
|
|
879
|
+
client.accounts = [];
|
|
880
|
+
const connecting = connect(config, { connector: config.connectors[0]! });
|
|
881
|
+
await vi.advanceTimersByTimeAsync(1_499);
|
|
882
|
+
expect(outcome.settled).toBe(false);
|
|
883
|
+
expect(client.connect).not.toHaveBeenCalled();
|
|
884
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
885
|
+
await connecting;
|
|
886
|
+
expect(client.connect).toHaveBeenCalledOnce();
|
|
887
|
+
expect(outcome.error).toMatchObject({ code: 4900 });
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it("drops the session once when several requests give up on it together", async () => {
|
|
891
|
+
let finishDisconnect!: () => void;
|
|
892
|
+
client.disconnect.mockImplementationOnce(
|
|
893
|
+
() => new Promise<undefined>((resolve) => (finishDisconnect = () => resolve(undefined))),
|
|
894
|
+
);
|
|
895
|
+
const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
|
|
896
|
+
const first = await signOnRestoredSession(config);
|
|
897
|
+
const second: { error?: unknown } = {};
|
|
898
|
+
signMessage(config, { message: "again" }).catch((error: unknown) => (second.error = error));
|
|
899
|
+
|
|
900
|
+
await vi.advanceTimersByTimeAsync(timeout.withoutTripMs);
|
|
901
|
+
finishDisconnect();
|
|
902
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
903
|
+
expect(first.error).toMatchObject({ code: 4900 });
|
|
904
|
+
expect(second.error).toMatchObject({ code: 4900 });
|
|
905
|
+
expect(client.disconnect).toHaveBeenCalledOnce();
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it("still watches a restored session whose only answers came from MetaMask Connect's cache", async () => {
|
|
909
|
+
// eth_requestAccounts on a stored session resolves from cache, never from MetaMask.
|
|
910
|
+
const request = vi.fn(
|
|
911
|
+
({ method }: { method: string }): Promise<unknown> =>
|
|
912
|
+
method === "personal_sign" ? new Promise(() => undefined) : Promise.resolve([account]),
|
|
913
|
+
);
|
|
914
|
+
client.getProvider.mockReturnValue({ request } as never);
|
|
915
|
+
const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
|
|
916
|
+
await reconnect(config);
|
|
917
|
+
const provider = (await config.connectors[0]!.getProvider()) as {
|
|
918
|
+
request(args: { method: string }): Promise<unknown>;
|
|
919
|
+
};
|
|
920
|
+
await provider.request({ method: "eth_requestAccounts" });
|
|
921
|
+
await provider.request({ method: "wallet_switchEthereumChain" });
|
|
922
|
+
|
|
923
|
+
const outcome = await signOnRestoredSession(config);
|
|
924
|
+
await vi.advanceTimersByTimeAsync(timeout.withoutTripMs);
|
|
925
|
+
expect(outcome.error).toMatchObject({ code: 4900 });
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it("keeps a restored session MetaMask answers, and stops watching it", async () => {
|
|
929
|
+
const request = vi.fn(
|
|
930
|
+
async ({ method }: { method: string }): Promise<unknown> =>
|
|
931
|
+
method === "personal_sign" ? "0x5167" : null,
|
|
932
|
+
);
|
|
933
|
+
client.getProvider.mockReturnValue({ request } as never);
|
|
934
|
+
const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
|
|
935
|
+
await reconnect(config);
|
|
936
|
+
|
|
937
|
+
expect(await signMessage(config, { message: "Sign in" })).toBe("0x5167");
|
|
938
|
+
|
|
939
|
+
// Answered once: a later request that MetaMask holds a long time is left alone.
|
|
940
|
+
request.mockImplementation(() => new Promise(() => undefined));
|
|
941
|
+
signMessage(config, { message: "again" }).catch(() => undefined);
|
|
942
|
+
await vi.advanceTimersByTimeAsync(timeout.withoutTripMs * 4);
|
|
943
|
+
expect(client.disconnect).not.toHaveBeenCalled();
|
|
944
|
+
expect(config.state.status).toBe("connected");
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
it("leaves a session alone that this page's own connect handshook", async () => {
|
|
948
|
+
client.accounts = [];
|
|
949
|
+
const config = restoredConfig({ mobile: true, restoredSessionTimeout: timeout });
|
|
950
|
+
const events = await (async () => {
|
|
951
|
+
await vi.waitFor(() => expect(createEVMClient).toHaveBeenCalledTimes(1));
|
|
952
|
+
return (createEVMClient.mock.calls[0]![0] as { eventHandlers: EventHandlers }).eventHandlers;
|
|
953
|
+
})();
|
|
954
|
+
client.connect.mockImplementationOnce(async () => {
|
|
955
|
+
events.displayUri("metamask://connect/mwp?p=new");
|
|
956
|
+
return { accounts: [account], chainId: "0x1" };
|
|
957
|
+
});
|
|
958
|
+
await connect(config, { connector: config.connectors[0]! });
|
|
959
|
+
|
|
960
|
+
signMessage(config, { message: "Sign in" }).catch(() => undefined);
|
|
961
|
+
await vi.advanceTimersByTimeAsync(timeout.withoutTripMs * 4);
|
|
962
|
+
expect(client.disconnect).not.toHaveBeenCalled();
|
|
963
|
+
expect(config.state.status).toBe("connected");
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
it("waits on the relay as before without restoredSessionTimeout", async () => {
|
|
967
|
+
const config = restoredConfig({ mobile: true });
|
|
968
|
+
const outcome = await signOnRestoredSession(config);
|
|
969
|
+
show("hidden");
|
|
970
|
+
show("visible");
|
|
971
|
+
await vi.advanceTimersByTimeAsync(timeout.withoutTripMs * 4);
|
|
972
|
+
expect(outcome.settled).toBe(false);
|
|
973
|
+
expect(client.disconnect).not.toHaveBeenCalled();
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it("leaves the desktop alone, where MetaMask is the extension", async () => {
|
|
977
|
+
const config = restoredConfig({ mobile: false, restoredSessionTimeout: timeout });
|
|
978
|
+
const outcome = await signOnRestoredSession(config);
|
|
979
|
+
await vi.advanceTimersByTimeAsync(timeout.withoutTripMs * 4);
|
|
980
|
+
expect(outcome.settled).toBe(false);
|
|
981
|
+
expect(client.disconnect).not.toHaveBeenCalled();
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
|
|
669
985
|
describe("metaMaskMayLackChain", () => {
|
|
670
986
|
it("is true for a testnet MetaMask does not ship and false for mainnets and its own testnets", () => {
|
|
671
987
|
expect(metaMaskMayLackChain(baseSepolia)).toBe(true);
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from "viem";
|
|
27
27
|
import { mainnet } from "wagmi/chains";
|
|
28
28
|
|
|
29
|
+
import { forgetStoredSessions, type RestoredSessionTimeout, untilSilent } from "./restoredSession";
|
|
29
30
|
import { isSyntheticChainId } from "./syntheticChains";
|
|
30
31
|
|
|
31
32
|
export type MetaMaskConnectParameters = {
|
|
@@ -49,6 +50,14 @@ export type MetaMaskConnectParameters = {
|
|
|
49
50
|
* rather than to switch to it. Defaults to `metaMaskMayLackChain`.
|
|
50
51
|
*/
|
|
51
52
|
addChainFirst?: (chain: Chain) => boolean;
|
|
53
|
+
/**
|
|
54
|
+
* On a phone, give up on a session restored from storage once a wallet
|
|
55
|
+
* request goes unanswered this long (see restoredSession.ts): the request
|
|
56
|
+
* fails with MetaMaskSessionLostError, wagmi disconnects, the stored session is
|
|
57
|
+
* deleted and MetaMask Connect disconnects, so the next connect starts a new
|
|
58
|
+
* one. Off by default.
|
|
59
|
+
*/
|
|
60
|
+
restoredSessionTimeout?: RestoredSessionTimeout;
|
|
52
61
|
};
|
|
53
62
|
|
|
54
63
|
type ConnectorConfig = Parameters<CreateConnectorFn>[0];
|
|
@@ -227,11 +236,89 @@ let owner: Emitter | undefined;
|
|
|
227
236
|
const permittedChains = new Set<number>();
|
|
228
237
|
const unrecognizedChains = new Set<number>();
|
|
229
238
|
let lastDeeplink: { url: string; open: (url: string, target?: string) => void } | undefined;
|
|
239
|
+
// Whether MetaMask has answered on this page's session: a connect that ran a
|
|
240
|
+
// handshake (MetaMask Connect raises display_uri only for a new session
|
|
241
|
+
// request) or a wallet request it settled. A session resumed from storage
|
|
242
|
+
// starts unanswered.
|
|
243
|
+
let handshakes = 0;
|
|
244
|
+
let answered = false;
|
|
230
245
|
|
|
231
246
|
function forgetChains(): void {
|
|
232
247
|
permittedChains.clear();
|
|
233
248
|
unrecognizedChains.clear();
|
|
234
249
|
lastDeeplink = undefined;
|
|
250
|
+
answered = false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// What only the wallet answers, so a reply proves MetaMask still holds the
|
|
254
|
+
// session. Not eth_requestAccounts or a switch to a permitted chain: MetaMask
|
|
255
|
+
// Connect answers those from the stored session without asking the wallet.
|
|
256
|
+
const walletMethods = new Set([
|
|
257
|
+
"personal_sign",
|
|
258
|
+
"eth_sign",
|
|
259
|
+
"eth_signTypedData",
|
|
260
|
+
"eth_signTypedData_v3",
|
|
261
|
+
"eth_signTypedData_v4",
|
|
262
|
+
"eth_sendTransaction",
|
|
263
|
+
"wallet_requestPermissions",
|
|
264
|
+
"wallet_addEthereumChain",
|
|
265
|
+
"wallet_watchAsset",
|
|
266
|
+
"wallet_sendCalls",
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
function watchRestoredSession(
|
|
270
|
+
provider: EIP1193Provider,
|
|
271
|
+
timeout: RestoredSessionTimeout,
|
|
272
|
+
drop: () => Promise<void>,
|
|
273
|
+
): EIP1193Provider {
|
|
274
|
+
const request = ((args: { method: string; params?: unknown }) => {
|
|
275
|
+
const sent = provider.request(args as never) as Promise<unknown>;
|
|
276
|
+
if (!walletMethods.has(args.method)) return sent;
|
|
277
|
+
const heard = sent.then(
|
|
278
|
+
(value) => {
|
|
279
|
+
answered = true;
|
|
280
|
+
return value;
|
|
281
|
+
},
|
|
282
|
+
(err: unknown) => {
|
|
283
|
+
if ((err as { code?: number })?.code === UserRejectedRequestError.code) answered = true;
|
|
284
|
+
throw err;
|
|
285
|
+
},
|
|
286
|
+
);
|
|
287
|
+
return answered ? heard : untilSilent(heard, timeout, drop);
|
|
288
|
+
}) as EIP1193Provider["request"];
|
|
289
|
+
return new Proxy(provider, {
|
|
290
|
+
get(target, property) {
|
|
291
|
+
if (property === "request") return request;
|
|
292
|
+
const value = Reflect.get(target, property);
|
|
293
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// IndexedDB can stop answering (a known iOS failure), so the deletion is capped
|
|
299
|
+
// too; past it, the request still fails and a connect still goes ahead.
|
|
300
|
+
const forgetDeadlineMs = 1_500;
|
|
301
|
+
const dropDeadlineMs = 5_000;
|
|
302
|
+
const disconnectWaitMs = 1_000;
|
|
303
|
+
// The drop under way, shared by every request that gives up meanwhile. It ends
|
|
304
|
+
// once the stored session is deleted and MetaMask Connect's disconnect is done,
|
|
305
|
+
// or at the deadlines.
|
|
306
|
+
let dropping: Promise<void> | undefined;
|
|
307
|
+
// The dropped session's deletion from storage, while it runs.
|
|
308
|
+
let forgetting: Promise<void> | undefined;
|
|
309
|
+
// MetaMask Connect's disconnect, while it runs. One that finishes under a new
|
|
310
|
+
// connect clears the transport that connect handshakes on, so a connect waits
|
|
311
|
+
// for it, briefly: a tap gets this far only once the drop has given up on it.
|
|
312
|
+
let disconnecting: Promise<void> | undefined;
|
|
313
|
+
|
|
314
|
+
function withDeadline<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
315
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
316
|
+
return Promise.race([
|
|
317
|
+
promise,
|
|
318
|
+
new Promise<never>((_, reject) => {
|
|
319
|
+
timer = setTimeout(() => reject(new Error(`timed out after ${ms} ms`)), ms);
|
|
320
|
+
}),
|
|
321
|
+
]).finally(() => clearTimeout(timer));
|
|
235
322
|
}
|
|
236
323
|
|
|
237
324
|
/**
|
|
@@ -315,7 +402,10 @@ function getClient(
|
|
|
315
402
|
forgetChains();
|
|
316
403
|
owner?.emit("disconnect");
|
|
317
404
|
},
|
|
318
|
-
displayUri: (uri) =>
|
|
405
|
+
displayUri: (uri) => {
|
|
406
|
+
handshakes += 1;
|
|
407
|
+
owner?.emit("message", { type: "display_uri", data: uri });
|
|
408
|
+
},
|
|
319
409
|
},
|
|
320
410
|
}),
|
|
321
411
|
);
|
|
@@ -329,6 +419,10 @@ function getClient(
|
|
|
329
419
|
export function resetMetaMaskConnectClient(): void {
|
|
330
420
|
client = undefined;
|
|
331
421
|
owner = undefined;
|
|
422
|
+
handshakes = 0;
|
|
423
|
+
dropping = undefined;
|
|
424
|
+
forgetting = undefined;
|
|
425
|
+
disconnecting = undefined;
|
|
332
426
|
forgetChains();
|
|
333
427
|
}
|
|
334
428
|
|
|
@@ -349,6 +443,7 @@ export function metaMaskConnect(parameters: MetaMaskConnectParameters) {
|
|
|
349
443
|
const fallbackChainId = Number(Object.keys(networks)[0] ?? 1);
|
|
350
444
|
const mobile = parameters.mobile ?? isMobile();
|
|
351
445
|
const addChainFirst = parameters.addChainFirst ?? metaMaskMayLackChain;
|
|
446
|
+
const restoredSessionTimeout = mobile ? parameters.restoredSessionTimeout : undefined;
|
|
352
447
|
const instance = () => getClient(parameters, supportedNetworks);
|
|
353
448
|
let guarded: { provider: EIP1193Provider; wrapped: EIP1193Provider } | undefined;
|
|
354
449
|
// Every call wagmi makes on the connector it uses claims the client's events;
|
|
@@ -362,6 +457,37 @@ export function metaMaskConnect(parameters: MetaMaskConnectParameters) {
|
|
|
362
457
|
const hex = mm.getChainId();
|
|
363
458
|
return hex ? Number(hex) : fallbackChainId;
|
|
364
459
|
};
|
|
460
|
+
// wagmi lets go first, so the page stops waiting even if the relay is gone
|
|
461
|
+
// too. The stored session goes before the request fails and the page offers
|
|
462
|
+
// a new connect, so that connect cannot resume it whatever the disconnect
|
|
463
|
+
// is doing; the deadlines keep a dead relay or IndexedDB from holding the
|
|
464
|
+
// request open.
|
|
465
|
+
const dropSession = (): Promise<void> => {
|
|
466
|
+
if (dropping) return dropping;
|
|
467
|
+
forgetChains();
|
|
468
|
+
config.emitter.emit("disconnect");
|
|
469
|
+
const forgotten: Promise<void> = withDeadline(forgetStoredSessions(), forgetDeadlineMs)
|
|
470
|
+
.catch(() => undefined)
|
|
471
|
+
.finally(() => {
|
|
472
|
+
if (forgetting === forgotten) forgetting = undefined;
|
|
473
|
+
});
|
|
474
|
+
forgetting = forgotten;
|
|
475
|
+
const disconnected: Promise<void> = instance()
|
|
476
|
+
.then((mm) => mm.disconnect())
|
|
477
|
+
.catch(() => undefined)
|
|
478
|
+
.finally(() => {
|
|
479
|
+
if (disconnecting === disconnected) disconnecting = undefined;
|
|
480
|
+
});
|
|
481
|
+
disconnecting = disconnected;
|
|
482
|
+
const drop: Promise<void> = forgotten
|
|
483
|
+
.then(() => withDeadline(disconnected, dropDeadlineMs))
|
|
484
|
+
.catch(() => undefined)
|
|
485
|
+
.finally(() => {
|
|
486
|
+
if (dropping === drop) dropping = undefined;
|
|
487
|
+
});
|
|
488
|
+
dropping = drop;
|
|
489
|
+
return drop;
|
|
490
|
+
};
|
|
365
491
|
|
|
366
492
|
return {
|
|
367
493
|
id: "metaMaskSDK",
|
|
@@ -376,13 +502,23 @@ export function metaMaskConnect(parameters: MetaMaskConnectParameters) {
|
|
|
376
502
|
async connect({ chainId, isReconnecting } = {}) {
|
|
377
503
|
// No separate chain switch: the requested chain leads the permission request
|
|
378
504
|
// and becomes the active chain, so connecting stays one wallet round trip.
|
|
505
|
+
// Nothing slow comes before mm.connect(): iOS Safari opens the MetaMask
|
|
506
|
+
// link only within the tap's user activation.
|
|
507
|
+
if (forgetting) await forgetting;
|
|
508
|
+
// A tap reaches this wait only when the disconnect outlasted the drop's
|
|
509
|
+
// deadline. Up to 1 s of the tap's activation can go here; iOS may then
|
|
510
|
+
// block the link and cost the user one more tap, which beats a hang.
|
|
511
|
+
if (disconnecting)
|
|
512
|
+
await withDeadline(disconnecting, disconnectWaitMs).catch(() => undefined);
|
|
379
513
|
const mm = await claim();
|
|
380
514
|
try {
|
|
381
515
|
let accounts = isReconnecting ? accountsOf(mm) : [];
|
|
382
516
|
if (!accounts.length) {
|
|
517
|
+
const before = handshakes;
|
|
383
518
|
const result = await mm.connect({
|
|
384
519
|
chainIds: metaMaskConnectChainIds(networks, chainId),
|
|
385
520
|
});
|
|
521
|
+
if (handshakes !== before) answered = true;
|
|
386
522
|
accounts = result.accounts.map((a) => getAddress(a));
|
|
387
523
|
}
|
|
388
524
|
const current = chainIdOf(mm);
|
|
@@ -407,11 +543,15 @@ export function metaMaskConnect(parameters: MetaMaskConnectParameters) {
|
|
|
407
543
|
// connector wraps it for itself.
|
|
408
544
|
const mm = await claim();
|
|
409
545
|
const provider = mm.getProvider();
|
|
410
|
-
if (guarded?.provider !== provider)
|
|
546
|
+
if (guarded?.provider !== provider) {
|
|
547
|
+
const listed = listedChainsOnly(provider, mm, networks, supportedNetworks);
|
|
411
548
|
guarded = {
|
|
412
549
|
provider,
|
|
413
|
-
wrapped:
|
|
550
|
+
wrapped: restoredSessionTimeout
|
|
551
|
+
? watchRestoredSession(listed, restoredSessionTimeout, dropSession)
|
|
552
|
+
: listed,
|
|
414
553
|
};
|
|
554
|
+
}
|
|
415
555
|
return guarded.wrapped;
|
|
416
556
|
},
|
|
417
557
|
async isAuthorized() {
|