UNPKG

proximity-wallet-connect

Version:

Wallet Connect package for NEAR Wallet Selector (Proximity fork with transaction fixes).

900 lines (759 loc) 24.3 kB
import type { KeyPair, providers } from "near-api-js"; import * as nearAPI from "near-api-js"; // @ts-ignore - BN.js doesn't have proper ESM types import BN from "bn.js"; // AccessKeyViewRaw not available in 0.44.2, using any type AccessKeyViewRaw = any; import type { SignClientTypes, SessionTypes } from "@walletconnect/types"; import type { WalletModuleFactory, WalletBehaviourFactory, BridgeWallet, Subscription, Transaction, WalletEvents, EventEmitterService, VerifiedOwner, Account, SignMessageParams, SignedMessage, Action, AddKeyPermission, } from "proximity-dex-core"; import { getActiveAccount } from "proximity-dex-core"; import WalletConnectClient from "./wallet-connect-client"; import icon from "./icon"; // PROXIMITY FIX: Inline createAction using BN (not BigInt) for near-api-js@0.44.2 compatibility const { transactions, utils } = nearAPI; const getAccessKey = (permission: AddKeyPermission) => { if (permission === "FullAccess") { return transactions.fullAccessKey(); } const { receiverId, methodNames = [] } = permission; const allowance = permission.allowance ? (new BN(permission.allowance) as any) : undefined; return transactions.functionCallAccessKey(receiverId, methodNames, allowance as any); }; const createAction = (action: Action) => { switch (action.type) { case "CreateAccount": return transactions.createAccount(); case "DeployContract": { const { code } = action.params; return transactions.deployContract(code); } case "FunctionCall": { const { methodName, args, gas, deposit } = action.params; return transactions.functionCall( methodName, args, new BN(gas) as any, new BN(deposit) as any ); } case "Transfer": { const { deposit } = action.params; return transactions.transfer(new BN(deposit) as any); } case "Stake": { const { stake, publicKey } = action.params; return transactions.stake(new BN(stake) as any, utils.PublicKey.from(publicKey)); } case "AddKey": { const { publicKey, accessKey } = action.params; return transactions.addKey( utils.PublicKey.from(publicKey), getAccessKey(accessKey.permission) ); } case "DeleteKey": { const { publicKey } = action.params; return transactions.deleteKey(utils.PublicKey.from(publicKey)); } case "DeleteAccount": { const { beneficiaryId } = action.params; return transactions.deleteAccount(beneficiaryId); } default: throw new Error("Invalid action type"); } }; export interface WalletConnectParams { projectId: string; metadata: SignClientTypes.Metadata; relayUrl?: string; iconUrl?: string; chainId?: string; deprecated?: boolean; methods?: Array<string>; events?: Array<string>; } interface WalletConnectExtraOptions { chainId?: string; projectId: string; metadata: SignClientTypes.Metadata; relayUrl: string; methods?: Array<string>; events?: Array<string>; } interface LimitedAccessKeyPair { accountId: string; keyPair: KeyPair; } interface LimitedAccessAccount { accountId: string; publicKey: string; } interface WalletConnectAccount { accountId: string; publicKey: string; } interface WalletConnectState { client: WalletConnectClient; session: SessionTypes.Struct | null; keystore: nearAPI.keyStores.KeyStore; subscriptions: Array<Subscription>; } interface ConnectParams { state: WalletConnectState; chainId: string; qrCodeModal: boolean; projectId: string; methods?: Array<string>; events?: Array<string>; } // VERSION: v32-PRODUCTION-CLEAN - 2024-10-10 // Always uses singular near_signTransaction method (works with Fireblocks) console.log('PROXIMITY-WALLET-CONNECT v32 - Production'); const WC_METHODS = [ "near_signIn", "near_signOut", "near_getAccounts", "near_signTransaction", "near_signTransactions", "near_signAndSendTransaction", "near_signAndSendTransactions", // disabling these two due to WalletConnect not supporting it // see https://docs.reown.com/advanced/multichain/rpc-reference/near-rpc // "near_verifyOwner", // "near_signMessage", ]; const WC_EVENTS = ["chainChanged", "accountsChanged"]; const setupWalletConnectState = async ( id: string, params: WalletConnectExtraOptions, emitter: EventEmitterService<WalletEvents> ): Promise<WalletConnectState> => { const client = new WalletConnectClient(emitter); let session: SessionTypes.Struct | null = null; const keystore = new nearAPI.keyStores.BrowserLocalStorageKeyStore( window.localStorage, `near-wallet-selector:${id}:keystore:` ); await client.init({ projectId: params.projectId, metadata: params.metadata, relayUrl: params.relayUrl, }); if (client.session.length) { const lastKeyIndex = client.session.keys.length - 1; session = client.session.get(client.session.keys[lastKeyIndex]); } return { client, session, keystore, subscriptions: [], }; }; const connect = async ({ state, chainId, qrCodeModal, projectId, methods, events, }: ConnectParams) => { return await state.client.connect( { requiredNamespaces: { near: { chains: [chainId], methods: methods || WC_METHODS, events: events || WC_EVENTS, }, }, }, qrCodeModal, projectId, chainId ); }; const disconnect = async ({ state }: { state: WalletConnectState }) => { await state.client.disconnect({ topic: state.session!.topic, reason: { code: 5900, message: "User disconnected", }, }); }; const getSignatureData = (result: any) => { if (result instanceof Uint8Array) { return result; } else if (Array.isArray(result)) { return new Uint8Array(result); } else if (typeof result === "object" && result !== null) { // Check if it's a Buffer-like object {type: 'Buffer', data: {...}} if ('type' in result && 'data' in result) { const data = result.data; // data might be an Array or an Object with numeric keys {0: 64, 1: 0, ...} if (Array.isArray(data)) { return new Uint8Array(data); } else if (typeof data === 'object' && data !== null) { // Convert {0: 64, 1: 0, 2: 0, ...} to [64, 0, 0, ...] return new Uint8Array(Object.values(data)); } } // Fallback: try Object.values on the result itself return new Uint8Array(Object.values(result)); } else { throw new Error("Unexpected result type from near_signTransaction"); } }; const WalletConnect: WalletBehaviourFactory< BridgeWallet, { params: WalletConnectExtraOptions } > = async ({ id, options, store, params, provider, emitter, logger, metadata, }) => { const _state = await setupWalletConnectState(id, params, emitter); const getChainId = () => { if (params.chainId) { return params.chainId; } const { networkId } = options.network; if (["mainnet", "testnet"].includes(networkId)) { return `near:${networkId}`; } throw new Error("Invalid chain id"); }; const getAccounts = async (): Promise<Array<Account>> => { const accounts = _state.session?.namespaces["near"].accounts || []; const newAccounts = []; for (let i = 0; i < accounts.length; i++) { const signer = new nearAPI.InMemorySigner(_state.keystore); const publicKey = await signer.getPublicKey( accounts[i].split(":")[2], options.network.networkId ); newAccounts.push({ accountId: accounts[i].split(":")[2], publicKey: publicKey ? publicKey.toString() : "", }); } return newAccounts; }; const cleanup = async () => { _state.subscriptions.forEach((subscription) => subscription.remove()); _state.subscriptions = []; _state.session = null; }; const validateAccessKey = ( transaction: Transaction, accessKey: AccessKeyViewRaw ) => { if (accessKey.permission === "FullAccess") { return accessKey; } // eslint-disable-next-line @typescript-eslint/naming-convention const { receiver_id, method_names } = accessKey.permission.FunctionCall; if (transaction.receiverId !== receiver_id) { return null; } return transaction.actions.every((action) => { if (action.type !== "FunctionCall") { return false; } const { methodName, deposit } = action.params; if (method_names.length && method_names.includes(methodName)) { return false; } return parseFloat(deposit) <= 0; }); }; const signTransactions = async (transactions: Array<Transaction>) => { const signer = new nearAPI.InMemorySigner(_state.keystore); const signedTransactions: Array<nearAPI.transactions.SignedTransaction> = []; const block = await provider.block({ finality: "final" }); for (let i = 0; i < transactions.length; i += 1) { const transaction = transactions[i]; const publicKey = await signer.getPublicKey( transaction.signerId, options.network.networkId ); if (!publicKey) { throw new Error("No public key found"); } const accessKey = await provider.query<AccessKeyViewRaw>({ request_type: "view_access_key", finality: "final", account_id: transaction.signerId, public_key: publicKey.toString(), }); if (!validateAccessKey(transaction, accessKey)) { throw new Error("Invalid access key"); } const tx = nearAPI.transactions.createTransaction( transactions[i].signerId, nearAPI.utils.PublicKey.from(publicKey.toString()), transactions[i].receiverId, accessKey.nonce + i + 1, transaction.actions.map((action) => createAction(action)), nearAPI.utils.serialize.base_decode(block.header.hash) ); const [, signedTx] = await nearAPI.transactions.signTransaction( tx, signer, transactions[i].signerId, options.network.networkId ); signedTransactions.push(signedTx); } return signedTransactions; }; const requestAccounts = async () => { return _state.client.request<Array<WalletConnectAccount>>({ topic: _state.session!.topic, chainId: getChainId(), request: { method: "near_getAccounts", params: {}, }, }); }; const requestVerifyOwner = async (accountId: string, message: string) => { return _state.client.request<VerifiedOwner>({ topic: _state.session!.topic, chainId: getChainId(), request: { method: "near_verifyOwner", params: { accountId, message }, }, }); }; const requestSignMessage = async ( messageParams: SignMessageParams & { accountId?: string } ) => { const { message, nonce, recipient, callbackUrl, accountId } = messageParams; return _state.client.request<SignedMessage>({ topic: _state.session!.topic, chainId: getChainId(), request: { method: "near_signMessage", params: { message, nonce, recipient, ...(callbackUrl && { callbackUrl }), ...(accountId && { accountId }), }, }, }); }; const requestSignTransaction = async (transaction: Transaction) => { const accounts = await requestAccounts(); const account = accounts.find((x) => x.accountId === transaction.signerId); if (!account) { throw new Error("Invalid signer id"); } const [block, accessKey] = await Promise.all([ provider.block({ finality: "final" }), provider.query<AccessKeyViewRaw>({ request_type: "view_access_key", finality: "final", account_id: transaction.signerId, public_key: account.publicKey, }), ]); const tx = nearAPI.transactions.createTransaction( transaction.signerId, nearAPI.utils.PublicKey.from(account.publicKey), transaction.receiverId, accessKey.nonce + 1, transaction.actions.map((action) => createAction(action)), nearAPI.utils.serialize.base_decode(block.header.hash) ); const result = await _state.client.request<Uint8Array>({ topic: _state.session!.topic, chainId: getChainId(), request: { method: "near_signTransaction", params: { transaction: Array.from(tx.encode()) }, // Convert to plain Array }, }); const signatureData = getSignatureData(result); // PROXIMITY FIX: Create a SignedTransaction from raw bytes // We can't decode because Fireblocks may use a different near-api-js version // But provider.sendTransaction() needs the actual bytes, which we have const bytes = Buffer.from(signatureData); // Create a minimal SignedTransaction-like object that works with sendTransaction const signedTx: any = { transaction: null as any, signature: null as any, encode: () => bytes }; return signedTx; }; // PROXIMITY: Helper function to sign and send transactions via WalletConnect // Using near_signAndSendTransactions - Fireblocks signs AND broadcasts const requestSignTransactions = async (transactions: Array<Transaction>) => { if (!transactions.length) { return []; } // Use singular near_signTransaction method for each transaction logger.log(`Signing ${transactions.length} transaction${transactions.length > 1 ? 's' : ''} via near_signTransaction`); const signedTxs: Array<any> = []; for (let i = 0; i < transactions.length; i++) { logger.log(`Signing transaction ${i + 1}/${transactions.length}...`); const signedTx = await requestSignTransaction(transactions[i]); signedTxs.push(signedTx); } logger.log(`Successfully signed ${signedTxs.length} transaction${signedTxs.length > 1 ? 's' : ''}`); return signedTxs; }; const createLimitedAccessKeyPairs = async (): Promise< Array<LimitedAccessKeyPair> > => { const accounts = await getAccounts(); return accounts.map(({ accountId }) => ({ accountId, keyPair: nearAPI.utils.KeyPair.fromRandom("ed25519"), })); }; const requestSignIn = async ( permission: nearAPI.transactions.FunctionCallPermission ) => { const keyPairs = await createLimitedAccessKeyPairs(); const limitedAccessAccounts: Array<LimitedAccessAccount> = keyPairs.map( ({ accountId, keyPair }) => ({ accountId, publicKey: keyPair.getPublicKey().toString(), }) ); await _state.client.request({ topic: _state.session!.topic, chainId: getChainId(), request: { method: "near_signIn", params: { permission: permission, accounts: limitedAccessAccounts, }, }, }); for (let i = 0; i < keyPairs.length; i += 1) { const { accountId, keyPair } = keyPairs[i]; await _state.keystore.setKey( options.network.networkId, accountId, keyPair ); } }; const requestSignOut = async () => { const accounts = await getAccounts(); const limitedAccessAccounts: Array<LimitedAccessAccount> = []; for (let i = 0; i < accounts.length; i += 1) { const account = accounts[i]; const keyPair = await _state.keystore.getKey( options.network.networkId, account.accountId ); if (!keyPair) { continue; } limitedAccessAccounts.push({ accountId: account.accountId, publicKey: keyPair.getPublicKey().toString(), }); } if (!limitedAccessAccounts.length) { return; } await _state.client.request({ topic: _state.session!.topic, chainId: getChainId(), request: { method: "near_signOut", params: { accounts: limitedAccessAccounts, }, }, }); }; const signOut = async () => { if (_state.session) { await requestSignOut(); await disconnect({ state: _state }); } await cleanup(); }; const setupEvents = async () => { _state.subscriptions.push( _state.client.on("session_update", async (event) => { logger.log("Session Update", event); if (event.topic === _state.session?.topic) { _state.session = { ..._state.client.session.get(event.topic), namespaces: event.params.namespaces, }; emitter.emit("accountsChanged", { accounts: await getAccounts() }); } }) ); _state.subscriptions.push( _state.client.on("session_delete", async (event) => { logger.log("Session Deleted", event); if (event.topic === _state.session?.topic) { await cleanup(); emitter.emit("signedOut", null); } }) ); // Add comprehensive debugging for WalletConnect events const clientEvents = [ "session_event", "session_ping", "session_expire", "session_extend", "session_proposal", "session_request", "session_request_sent", ]; clientEvents.forEach((eventName) => { try { _state.subscriptions.push( _state.client.on(eventName as any, (event: any) => { console.log(`🔔 [WC-EVENT] ${eventName}:`, { topic: event.topic, timestamp: new Date().toISOString(), event: event, }); }) ); } catch (e) { // Event might not be supported console.log(`⚠️ [WC-EVENT] Could not subscribe to ${eventName}`); } }); // Monitor the underlying SignClient core events for transport issues try { const coreClient = (_state.client as any).core; if (coreClient) { console.log('🔌 [WC-SETUP] Monitoring core client events'); // Listen to transport/relay events const coreEvents = ['relayer_connect', 'relayer_disconnect', 'relayer_error', 'relayer_message']; coreEvents.forEach(eventName => { try { coreClient.relayer?.on(eventName, (data: any) => { console.log(`🔌 [WC-RELAYER] ${eventName}:`, { timestamp: new Date().toISOString(), data: data, }); }); } catch (e) { console.log(`⚠️ [WC-RELAYER] Could not subscribe to ${eventName}`); } }); } } catch (e) { console.log('⚠️ [WC-SETUP] Could not access core client for event monitoring'); } }; if (_state.session) { await setupEvents(); } return { async signIn({ contractId, methodNames = [], qrCodeModal = true }) { try { const { contract } = store.getState(); if (_state.session && !contract) { await disconnect({ state: _state }); await cleanup(); } const chainId = getChainId(); _state.session = await connect({ state: _state, chainId, qrCodeModal, projectId: params.projectId, methods: params.methods, events: params.events, }); await requestSignIn({ receiverId: contractId || "", methodNames }); await setupEvents(); return await getAccounts(); } catch (err) { await signOut(); throw err; } }, signOut, async getAccounts() { return getAccounts(); }, async verifyOwner({ message }) { logger.log("WalletConnect:verifyOwner", { message }); const { contract } = store.getState(); if (!_state.session || !contract) { throw new Error("Wallet not signed in"); } const account = getActiveAccount(store.getState()); if (!account) { throw new Error("No active account"); } return requestVerifyOwner(account.accountId, message); }, async signMessage({ message, nonce, recipient, callbackUrl }) { logger.log("WalletConnect:signMessage", { message, nonce, recipient }); try { const chainId = getChainId(); if (!_state.session) { _state.session = _state.session = await connect({ state: _state, chainId, qrCodeModal: true, projectId: params.projectId, }); } const account = getActiveAccount(store.getState()); return await requestSignMessage({ message, nonce, recipient, callbackUrl, accountId: account?.accountId, }); } catch (err) { await disconnect({ state: _state }); await cleanup(); throw err; } }, async signAndSendTransaction({ signerId, receiverId, actions }) { logger.log("signAndSendTransaction", { signerId, receiverId, actions }); const { contract } = store.getState(); if (!_state.session || !contract) { throw new Error("Wallet not signed in"); } const account = getActiveAccount(store.getState()); if (!account) { throw new Error("No active account"); } const resolvedTransaction: Transaction = { signerId: signerId || account.accountId, receiverId: receiverId || contract.contractId, actions, }; const signedTransactions = await requestSignTransactions([resolvedTransaction]); const [result] = await Promise.all( signedTransactions.map((signedTx) => provider.sendTransaction(signedTx)) ); return result; }, async signAndSendTransactions({ transactions }) { logger.log("🚀 PROXIMITY-WALLET-CONNECT v14 signAndSendTransactions called!"); logger.log("signAndSendTransactions", { transactions }); const { contract } = store.getState(); if (!_state.session || !contract) { throw new Error("Wallet not signed in"); } const account = getActiveAccount(store.getState()); if (!account) { throw new Error("No active account"); } const resolvedTransactions = transactions.map((x) => ({ signerId: x.signerId || account.accountId, receiverId: x.receiverId, actions: x.actions, })); const signedTransactions = await requestSignTransactions(resolvedTransactions); return await Promise.all( signedTransactions.map((signedTx) => provider.sendTransaction(signedTx)) ); }, async createSignedTransaction(receiverId, actions) { logger.log("createSignedTransaction", { receiverId, actions }); throw new Error(`Method not supported by ${metadata.name}`); }, async signTransaction(transaction) { logger.log("signTransaction", { transaction }); throw new Error(`Method not supported by ${metadata.name}`); }, async getPublicKey() { logger.log("getPublicKey", {}); throw new Error(`Method not supported by ${metadata.name}`); }, async signNep413Message(message, accountId, recipient, nonce, callbackUrl) { logger.log("signNep413Message", { message, accountId, recipient, nonce, callbackUrl, }); throw new Error(`Method not supported by ${metadata.name}`); }, async signDelegateAction(delegateAction) { logger.log("signDelegateAction", { delegateAction }); throw new Error(`Method not supported by ${metadata.name}`); }, }; }; export function setupWalletConnect({ projectId, metadata, chainId, relayUrl = "wss://relay.walletconnect.com", iconUrl = icon, deprecated = false, methods, events, }: WalletConnectParams): WalletModuleFactory<BridgeWallet> { return async () => { return { id: "wallet-connect", type: "bridge", metadata: { name: "WalletConnect", description: "Bridge wallet for NEAR.", iconUrl, deprecated, available: true, }, init: (options) => { return WalletConnect({ ...options, params: { projectId, metadata, relayUrl, chainId, methods, events, }, }); }, }; }; }