Skip to content
Open
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
280 changes: 270 additions & 10 deletions bun.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { keepkeyBexWallet } from "@swapkit/wallets/keepkey-bex";
import { keplrWallet } from "@swapkit/wallets/keplr";
import { keystoreWallet } from "@swapkit/wallets/keystore";
import { ledgerWallet } from "@swapkit/wallets/ledger";
import { metamaskWallet } from "@swapkit/wallets/metamask";
import { walletSelectorWallet } from "@swapkit/wallets/near-wallet-selector";
import { okxWallet } from "@swapkit/wallets/okx";
import { onekeyWallet } from "@swapkit/wallets/onekey";
Expand Down Expand Up @@ -66,6 +67,7 @@ export {
keplrWallet,
keystoreWallet,
ledgerWallet,
metamaskWallet,
okxWallet,
onekeyWallet,
passkeysWallet,
Expand Down Expand Up @@ -103,6 +105,7 @@ export const defaultWallets = {
...keplrWallet,
...keystoreWallet,
...ledgerWallet,
...metamaskWallet,
...okxWallet,
...onekeyWallet,
...phantomWallet,
Expand Down
10 changes: 10 additions & 0 deletions packages/wallets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"@coinbase/wallet-sdk": "~4.3.7",
"@cosmjs/amino": "~0.37.0",
"@cosmjs/proto-signing": "~0.37.0",
"@metamask/connect-multichain": "~1.1.0",
"@near-js/transactions": "~2.5.0",
"@near-wallet-selector/bitget-wallet": "~10.1.4",
"@near-wallet-selector/core": "~10.1.4",
Expand All @@ -14,6 +15,7 @@
"@radixdlt/radix-dapp-toolkit": "~2.3.0",
"@scure/base": "~2.2.0",
"@scure/bip39": "~2.2.0",
"@solana/web3.js": "~1.98.4",
"@swapkit/helpers": "^4.15.1",
"@swapkit/toolboxes": "^4.19.0",
"@swapkit/utxo-signer": "^2.2.2",
Expand All @@ -33,6 +35,7 @@
"@coinbase/wallet-sdk": "4.3.7",
"@cosmjs/amino": "0.37.0",
"@cosmjs/proto-signing": "0.37.0",
"@metamask/connect-multichain": "1.1.0",
"@near-js/transactions": "2.5.0",
"@near-wallet-selector/bitget-wallet": "10.1.4",
"@near-wallet-selector/core": "10.1.4",
Expand All @@ -42,6 +45,7 @@
"@radixdlt/babylon-gateway-api-sdk": "1.10.1",
"@radixdlt/radix-dapp-toolkit": "2.3.0",
"@scure/base": "2.2.0",
"@solana/web3.js": "1.98.4",
"@walletconnect/logger": "3.0.2",
"@walletconnect/modal": "2.7.0",
"@walletconnect/sign-client": "2.23.9",
Expand Down Expand Up @@ -126,6 +130,12 @@
"require": "./dist/src/ledger.cjs",
"types": "./dist/types/ledger.d.ts"
},
"./metamask": {
"bun": "./src/metamask/index.ts",
"default": "./dist/src/metamask/index.js",
"require": "./dist/src/metamask/index.cjs",
"types": "./dist/types/metamask/index.d.ts"
},
"./near-wallet-selector": {
"bun": "./src/near-wallet-selector/index.ts",
"default": "./dist/src/near-wallet-selector/index.js",
Expand Down
217 changes: 217 additions & 0 deletions packages/wallets/src/metamask/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import {
Chain,
type EVMChain,
EVMChains,
filterSupportedChains,
getChainConfig,
getRPCUrl,
SwapKitError,
WalletOption,
} from "@swapkit/helpers";
import { createWallet, getWalletSupportedChains } from "@swapkit/wallet-core";
import { getWeb3WalletMethods } from "@swapkit/wallet-extensions/evm-extensions";
import type { Eip1193Provider } from "ethers";

// MetaMask connector built on @metamask/connect-multichain.
//
// One CAIP-25 session covers EVM + Solana (+ future ecosystems) behind a single
// approval prompt. The multichain client has NO per-chain EIP-1193 provider —
// everything goes through `invokeMethod({ scope, request })`. We adapt that into
// the shape each existing SwapKit toolbox already consumes:
// • EVM -> EIP-1193 shim -> ethers BrowserProvider -> getWeb3WalletMethods (unchanged)
// • Solana -> SolanaProvider-style signer -> getSolanaToolbox({ signer })
// • Future -> add one adapter per ecosystem as SwapKit gains a toolbox for it.

// Solana mainnet CAIP-2 id (MetaMask multichain "supported chains").
const SOLANA_MAINNET_CAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";

const EVM_CHAIN_SET = new Set<Chain>(EVMChains);
const isEVMChain = (chain: Chain): chain is EVMChain => EVM_CHAIN_SET.has(chain);

// SwapKit Chain -> CAIP-2 scope.
const chainToScope = (chain: Chain): string => {
if (isEVMChain(chain)) {
return `eip155:${Number.parseInt(getChainConfig(chain).chainIdHex, 16)}`;
}
switch (chain) {
case Chain.Solana:
return SOLANA_MAINNET_CAIP2;
default:
throw new SwapKitError("wallet_chain_not_supported", { chain });
}
};

export type ConnectMetamaskOptions = {
dapp?: { name: string; url?: string; iconUrl?: string };
// Optional override; otherwise derived from SwapKit RPCs per requested chain.
supportedNetworks?: Record<string, string>;
};

// Minimal multichain client surface we depend on. Kept loose to avoid coupling
// to a specific SDK minor; tighten once the dependency is installed.
type InvokeRequest = { method: string; params?: unknown[] | Record<string, unknown> };
type MultichainClient = {
connect: (scopes: string[], caipAccountIds: string[]) => Promise<void>;
disconnect: (scopes?: string[]) => Promise<void>;
invokeMethod: (options: { scope: string; request: InvokeRequest }) => Promise<unknown>;
provider: { getSession: () => Promise<{ sessionScopes: Record<string, { accounts?: string[] }> }> };
};

const isUserRejection = (error: unknown) =>
typeof error === "object" && error !== null && (error as { code?: number }).code === 4001;

// Resolve the address for a CAIP-2 scope from a session.
//
// getSession may key sessionScopes by full CAIP-2 ("eip155:1") OR collapse a
// namespace into one bucket ("eip155" with references: ["1","137"]). Rather than
// rely on the key shape, scan every bucket's CAIP-10 accounts and match on
// namespace:reference. CAIP-10 is "namespace:reference:address" (address = [2]).
const findAddressForScope = (
session: { sessionScopes?: Record<string, { accounts?: string[] }> },
scope: string,
): string | undefined => {
const [namespace, reference] = scope.split(":");
for (const bucket of Object.values(session.sessionScopes ?? {})) {
for (const caip10 of bucket.accounts ?? []) {
const [accNamespace, accReference, address] = caip10.split(":");
if (accNamespace === namespace && accReference === reference && address) return address;
}
}
return undefined;
};

// ---- EVM adapter: invokeMethod -> EIP-1193 ----------------------------------
// BrowserProvider only needs `request`. Account/chain queries are answered from
// the session; everything else is forwarded to the client, which routes reads to
// the RPC node and wallet methods (eth_sendTransaction, personal_sign, …) to MetaMask.
const makeEip1193ForScope = (client: MultichainClient, scope: string, address: string): Eip1193Provider => {
const chainIdHex = `0x${Number(scope.split(":")[1]).toString(16)}`;
return {
request: ({ method, params }: { method: string; params?: unknown[] | object }) => {
switch (method) {
case "eth_accounts":
case "eth_requestAccounts":
return Promise.resolve([address]);
case "eth_chainId":
return Promise.resolve(chainIdHex);
// In a CAIP-25 session the scope already pins the chain; there is no
// single "active chain" to switch. prepareNetworkSwitch (always applied
// by getWeb3WalletMethods) must never forward a switch/add into the
// multichain session, so answer these locally as no-ops.
case "wallet_switchEthereumChain":
case "wallet_addEthereumChain":
return Promise.resolve(null);
default:
return client.invokeMethod({ scope, request: { method, params: params as unknown[] } });
}
},
} as unknown as Eip1193Provider;
};

// ---- Solana adapter: invokeMethod -> SolanaProvider-style signer ------------
// Matches the SolanaProvider interface getSolanaToolbox({ signer }) consumes:
// the toolbox calls signer.signTransaction(tx) and broadcasts the result itself,
// so we sign-and-return (solana_signTransaction), we do NOT send.
const makeSolanaSigner = async (client: MultichainClient, scope: string, address: string) => {
const { PublicKey, Transaction, VersionedTransaction } = await import("@solana/web3.js");
const publicKey = new PublicKey(address);

const signTransaction = async <T extends import("@solana/web3.js").Transaction | import("@solana/web3.js").VersionedTransaction>(
transaction: T,
): Promise<T> => {
const serialized = transaction.serialize({ requireAllSignatures: false, verifySignatures: false });
const base64Transaction = Buffer.from(serialized).toString("base64");

const result = (await client.invokeMethod({
scope,
request: { method: "solana_signTransaction", params: { transaction: base64Transaction } },
})) as { transaction: string };

const signedBuffer = Buffer.from(result.transaction, "base64");
return (
transaction instanceof VersionedTransaction
? VersionedTransaction.deserialize(signedBuffer)
: Transaction.from(signedBuffer)
) as T;
};

return {
publicKey,
connect: () => Promise.resolve({ publicKey }),
disconnect: () => client.disconnect([scope]),
signTransaction,
};
};

export const metamaskWallet = createWallet({
connect: ({ addChain, supportedChains, walletType }) =>
async function connectMetamask(chains: Chain[], options?: ConnectMetamaskOptions) {
const filteredChains = filterSupportedChains({ chains, supportedChains, walletType });
const { createMultichainClient } = await import("@metamask/connect-multichain");

const scopeByChain = new Map(filteredChains.map((chain) => [chain, chainToScope(chain)] as const));
const scopes = [...new Set(scopeByChain.values())];

const supportedNetworks =
options?.supportedNetworks ??
Object.fromEntries(
await Promise.all(
filteredChains.map(async (chain) => [scopeByChain.get(chain) as string, await getRPCUrl(chain)] as const),
),
);

const client = (await createMultichainClient({
dapp: options?.dapp ?? { name: "SwapKit", url: globalThis.location?.href },
api: { supportedNetworks },
})) as unknown as MultichainClient;

try {
// Single approval prompt for every requested scope.
await client.connect(scopes, []);
} catch (error) {
if (isUserRejection(error)) throw new SwapKitError("wallet_connection_rejected_by_user", error);
throw error;
}

const session = await client.provider.getSession();
const disconnect = () => client.disconnect();

await Promise.all(
filteredChains.map(async (chain) => {
const scope = scopeByChain.get(chain) as string;
const address = findAddressForScope(session, scope);
if (!address) throw new SwapKitError("wallet_connection_rejected_by_user", { chain, scope });

if (isEVMChain(chain)) {
const { BrowserProvider } = await import("ethers");
const eip1193 = makeEip1193ForScope(client, scope, address);
const browserProvider = new BrowserProvider(eip1193, "any");

const walletMethods = await getWeb3WalletMethods({
address,
chain,
provider: browserProvider,
walletProvider: eip1193,
});
addChain({ ...walletMethods, address, chain, disconnect, walletType });
return;
}

// Solana (and future ecosystems via their own adapter + toolbox).
const { getSolanaToolbox } = await import("@swapkit/toolboxes/solana");
const signer = await makeSolanaSigner(client, scope, address);
const toolbox = getSolanaToolbox({ signer });
addChain({ ...toolbox, address, chain, disconnect, walletType });
}),
);

return true;
},
directSigningSupport: Object.fromEntries([...EVMChains, Chain.Solana].map((chain) => [chain, true])),
name: "connectMetamask",
// EVM + Solana under one session. Widen further as non-EVM adapters land.
supportedChains: [...EVMChains, Chain.Solana],
walletType: WalletOption.METAMASK,
});

export const METAMASK_SUPPORTED_CHAINS = getWalletSupportedChains(metamaskWallet);
5 changes: 3 additions & 2 deletions packages/wallets/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type { ledgerWallet } from "@swapkit/wallet-hardware/ledger";
import type { trezorWallet } from "@swapkit/wallet-hardware/trezor";
import type { coinbaseWallet } from "./coinbase";
import type { keystoreWallet } from "./keystore";
import type { metamaskWallet } from "./metamask";
import type { walletSelectorWallet } from "./near-wallet-selector";
import type { passkeysWallet } from "./passkeys";
import type { radixWallet } from "./radix";
Expand All @@ -40,7 +41,7 @@ export type SKWallets = {
[WalletOption.KEYSTORE]: typeof keystoreWallet;
[WalletOption.LEAP]: typeof keplrWallet;
[WalletOption.LEDGER]: typeof ledgerWallet;
[WalletOption.METAMASK]: typeof evmWallet;
[WalletOption.METAMASK]: typeof metamaskWallet;
[WalletOption.OKX]: typeof okxWallet;
[WalletOption.OKX_MOBILE]: typeof evmWallet;
[WalletOption.ONEKEY]: typeof onekeyWallet;
Expand Down Expand Up @@ -116,7 +117,7 @@ export type SKWalletsSupportedChains = {
[WalletOption.KEYSTORE]: typeof keystoreWallet.connectKeystore.supportedChains;
[WalletOption.LEAP]: typeof keplrWallet.connectKeplr.supportedChains;
[WalletOption.LEDGER]: typeof ledgerWallet.connectLedger.supportedChains;
[WalletOption.METAMASK]: typeof evmWallet.connectEVMWallet.supportedChains;
[WalletOption.METAMASK]: typeof metamaskWallet.connectMetamask.supportedChains;
[WalletOption.OKX]: typeof okxWallet.connectOkx.supportedChains;
[WalletOption.OKX_MOBILE]: typeof evmWallet.connectEVMWallet.supportedChains;
[WalletOption.ONEKEY]: typeof onekeyWallet.connectOnekeyWallet.supportedChains;
Expand Down
2 changes: 1 addition & 1 deletion packages/wallets/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ export async function loadWallet<W extends keyof SKWallets>(walletOption: W): Pr
WalletOption.BRAVE,
WalletOption.COINBASE_WEB,
WalletOption.EIP6963,
WalletOption.METAMASK,
WalletOption.OKX_MOBILE,
async () => (await import("@swapkit/wallet-extensions/evm-extensions")).evmWallet,
)
.with(WalletOption.METAMASK, async () => (await import("./metamask")).metamaskWallet)
.with(
WalletOption.TRUSTWALLET_WEB,
async () => (await import("@swapkit/wallet-extensions/trustwallet")).trustwalletWallet,
Expand Down
9 changes: 9 additions & 0 deletions playgrounds/vite-lite/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export default defineConfig({
],
esbuildOptions: { define: { global: "globalThis" } },
exclude: [
// Stencil lazy web components resolve chunks via import.meta.url; pre-bundling
// breaks that, so serve the MetaMask connect UI package as native ESM.
"@metamask/multichain-ui",
"@swapkit/helpers",
"@swapkit/helpers/api",
"@swapkit/wallets",
Expand All @@ -46,6 +49,12 @@ export default defineConfig({
"@ledgerhq/hw-app-btc",
"@ledgerhq/hw-transport-webhid",
"@ledgerhq/hw-transport-webusb",
// CJS deps dynamically imported with NAMED imports by the MetaMask connect
// SDK; without listing them esbuild emits default-only interop and the
// mobile/QR flow throws on undefined named members.
"@metamask/mobile-wallet-protocol-core",
"@metamask/mobile-wallet-protocol-dapp-client",
"eciesjs",
"@near-js/accounts",
"@near-js/crypto",
"@near-js/providers",
Expand Down
Loading