proximity-wallet-connect
Version:
Wallet Connect package for NEAR Wallet Selector (Proximity fork with transaction fixes).
900 lines (759 loc) • 24.3 kB
text/typescript
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,
},
});
},
};
};
}