UNPKG

@kaiachain/ethers-ext

Version:
639 lines (574 loc) 20.3 kB
import { SigningKey, TransactionLike, TypedDataDomain, TypedDataField, TypedDataEncoder, BrowserProvider as EthersWeb3Provider, JsonRpcApiProvider as EthersJsonRpcApiProvider, JsonRpcSigner as EthersJsonRpcSigner, Provider, TransactionRequest, TransactionResponse, Wallet as EthersWallet, hexlify, toUtf8Bytes, ProgressCallback, keccak256, assert, } from "ethers"; import _ from "lodash"; import { HexStr, KlaytnTxFactory, parseTransaction, getRpcTxObject, isKlaytnTxType, isFeePayerSigTxType, parseTxType, getKaikasTxType, } from "@kaiachain/js-ext-core"; import { decryptKeystoreList, decryptKeystoreListSync } from "./keystore.js"; import { eip155sign, getTransactionRequest, populateFrom, populateTo, populateNonce, populateGasLimit, populateGasPrice, populateChainId, populateFeePayerAndSignatures, pollTransactionInPool, } from "./txutil.js"; import { ExternalProvider } from "./types.js"; export class Wallet extends EthersWallet { // Override Wallet factories accepting keystores to support both v3 and v4 (KIP-3) formats static override async fromEncryptedJson( json: string, password: string | Uint8Array, progress?: ProgressCallback ): Promise<Wallet> { const { address, privateKey } = await decryptKeystoreList( json, password, progress ); return new Wallet(address, privateKey); } static override fromEncryptedJsonSync( json: string, password: string | Uint8Array ): Wallet { const { address, privateKey } = decryptKeystoreListSync(json, password); return new Wallet(address, privateKey); } // New Wallet[] factories accepting keystores supporting v4 (KIP-3) format static async fromEncryptedJsonList( json: string, password: string | Uint8Array, progress?: ProgressCallback ): Promise<Wallet[]> { const { address, privateKeyList } = await decryptKeystoreList( json, password, progress ); return _.map( privateKeyList, (privateKey) => new Wallet(address, privateKey) ); } static fromEncryptedJsonListSync( json: string, password: string | Uint8Array ): Wallet[] { const { address, privateKeyList } = decryptKeystoreListSync(json, password); return _.map( privateKeyList, (privateKey) => new Wallet(address, privateKey) ); } // Decoupled account address. Defined only if specified in constructor. private klaytnAddr: string | undefined; // new KlaytnWallet(privateKey, provider?) or // new KlaytnWallet(address, privateKey, provider?) constructor( addressOrPrivateKey: string | SigningKey, privateKeyOrProvider?: SigningKey | Provider | string, provider?: Provider ) { if (HexStr.isHex(addressOrPrivateKey, 20)) { // First argument is an address. new KlaytnWallet(address, privateKey, provider?) const _address = HexStr.from(addressOrPrivateKey); const _privateKey = privateKeyOrProvider as SigningKey; const _provider = provider; super(_privateKey, _provider); this.klaytnAddr = _address; } else { // First argument is a private key. new KlaytnWallet(privateKey, provider?) const _privateKey = addressOrPrivateKey as SigningKey; const _provider = privateKeyOrProvider as Provider; super(_privateKey, _provider); } } // If the Wallet is created as a decoupled account, and `legacy` is false, returns the decoupled address. // Otherwise, returns the address derived from the private key. override getAddress(legacy?: boolean): Promise<string> { if (legacy || !this.klaytnAddr) { return super.getAddress(); } else { return Promise.resolve(this.klaytnAddr); } } // @deprecated in favor of getAddress(true) getEtherAddress(): Promise<string> { return super.getAddress(); } // @deprecated in favor of parseTransaction decodeTxFromRLP(rlp: string): any { return parseTransaction(rlp); } async isDecoupled(): Promise<boolean> { if (!this.klaytnAddr) { return false; } else { return (await this.getAddress(false)) == (await this.getAddress(true)); } } async populateTransaction( transaction: TransactionRequest ): Promise<TransactionLike<string>> { return this._populateTransaction(transaction, false); } // If asFeePayer is true, skip the 'from' address check. private async _populateTransaction( transaction: TransactionRequest, asFeePayer: boolean ): Promise<TransactionLike<string>> { const tx = await getTransactionRequest(transaction); // Not a Klaytn TxType; fallback to ethers.Signer.populateTransaction() if (!isKlaytnTxType(parseTxType(tx.type))) { return super.populateTransaction(tx); } // If the current Wallet acts as feePayer, then tx.from is unrelated to this.getAddress(). // Skip the check, and does not fill up here. If tx.from was empty, then an error is generated // at signTransaction(), not here. if (!asFeePayer) { await populateFrom(tx, await this.getAddress()); } await populateTo(tx, this.provider); await populateNonce(tx, this.provider, await this.getAddress()); await populateGasLimit(tx, this.provider); await populateGasPrice(tx, this.provider); await populateChainId(tx, this.provider); return tx; } // Sign as a sender // tx.sigs += Sign(tx.sigRLP(), wallet.privateKey) // return tx.txHashRLP() or tx.senderTxHashRLP(); override async signTransaction( transaction: TransactionRequest ): Promise<string> { const tx = await getTransactionRequest(transaction); // Not a Klaytn TxType; fallback to ethers.Wallet.signTransaction() if (!isKlaytnTxType(parseTxType(tx.type))) { return super.signTransaction(tx); } // Because RLP-encoded tx may not contain chainId, fill up here. await populateChainId(tx, this.provider); const chainId = tx.chainId!; // chainId should have been determined in populateChainId. const klaytnTx = KlaytnTxFactory.fromObject(tx); const sigHash = keccak256(klaytnTx.sigRLP()); const sig = eip155sign( this.signingKey, sigHash, Number(chainId.toString()) ); klaytnTx.addSenderSig(sig); if (isFeePayerSigTxType(klaytnTx.type)) { return klaytnTx.senderTxHashRLP(); } else { return klaytnTx.txHashRLP(); } } // Sign as a fee payer // tx.feepayerSigs += Sign(tx.sigFeePayerRLP(), wallet.privateKey) // return tx.txHashRLP(); async signTransactionAsFeePayer( transactionOrRLP: TransactionRequest | string ): Promise<string> { const tx = await getTransactionRequest(transactionOrRLP); // Not a Klaytn FeePayerSig TxType; not supported assert( isFeePayerSigTxType(parseTxType(tx.type)), `signTransactionAsFeePayer not supported for tx type ${tx.type}`, "UNSUPPORTED_OPERATION", { operation: "signTransactionAsFeePayer", } ); // Because RLP-encoded tx may contain dummy fee payer fields, fix here. await populateFeePayerAndSignatures(tx, await this.getAddress()); // Because RLP-encoded tx may not contain chainId, fill up here. await populateChainId(tx, this.provider); const chainId = tx.chainId!; // chainId should have been determined in populateChainId. const klaytnTx = KlaytnTxFactory.fromObject(tx); const sigFeePayerHash = keccak256(klaytnTx.sigFeePayerRLP()); const sig = eip155sign(this.signingKey, sigFeePayerHash, Number(chainId)); klaytnTx.addFeePayerSig(sig); return klaytnTx.txHashRLP(); } async sendTransaction( transaction: TransactionRequest ): Promise<TransactionResponse> { const tx = await getTransactionRequest(transaction); if (!isKlaytnTxType(parseTxType(tx.type))) { return super.sendTransaction(tx); } const populatedTx = await this._populateTransaction(tx, false); const signedTx = await this.signTransaction(populatedTx); return this._sendKlaytnRawTransaction(signedTx); } async sendTransactionAsFeePayer( transactionOrRLP: TransactionRequest | string ): Promise<TransactionResponse> { const tx = await getTransactionRequest(transactionOrRLP); // Not a Klaytn FeePayerSig TxType; not supported assert( isFeePayerSigTxType(parseTxType(tx.type)), `sendTransactionAsFeePayer not supported for tx type ${tx.type}`, "UNSUPPORTED_OPERATION", { operation: "sendTransactionAsFeePayer", } ); const populatedTx = await this._populateTransaction(tx, true); const signedTx = await this.signTransactionAsFeePayer(populatedTx); return this._sendKlaytnRawTransaction(signedTx); } async _sendKlaytnRawTransaction( signedTx: string ): Promise<TransactionResponse> { assert( this.provider instanceof EthersJsonRpcApiProvider, "Provider is not JsonRpcProvider: cannot send klay_sendRawTransaction", "UNSUPPORTED_OPERATION", { operation: "_sendKlaytnRawTransaction", } ); const txhash = await this.provider.send("klay_sendRawTransaction", [ signedTx, ]); return pollTransactionInPool(txhash, this.provider); } async sendTransactions( transactions: TransactionRequest[] ): Promise<TransactionResponse[]> { const signedTxs = []; for (const transaction of transactions) { const tx = await getTransactionRequest(transaction); if (!isKlaytnTxType(parseTxType(tx.type))) { const populatedTx = await super.populateTransaction(tx); const signedTx = await super.signTransaction(populatedTx); signedTxs.push(signedTx); } else { const populatedTx = await this._populateTransaction(tx, false); const signedTx = await this.signTransaction(populatedTx); signedTxs.push(signedTx); } } return this._sendKlaytnRawTransactions(signedTxs); } async _sendKlaytnRawTransactions( signedTxs: string[] ): Promise<TransactionResponse[]> { assert( this.provider instanceof EthersJsonRpcApiProvider, "Provider is not JsonRpcProvider: cannot send klay_sendRawTransactions", "UNSUPPORTED_OPERATION", { operation: "_sendKlaytnRawTransactions", } ); const txhashes = await this.provider.send("klay_sendRawTransactions", [ signedTxs ]) as string[]; return Promise.all(txhashes.map((txhash) => pollTransactionInPool(txhash, this.provider!))); // can assume this.provider != null because we asserted above. } } // EthersJsonRpcSigner cannot be subclassed because of the constructorGuard. // Instead, we re-create the class by copying the implementation. export class JsonRpcSigner extends EthersJsonRpcSigner { // implements EthersJsonRpcSigner constructor(provider: EthersJsonRpcApiProvider, address: string) { super(provider, address); } isKaikas(): boolean { if (this.provider instanceof EthersWeb3Provider) { // The EIP-1193 provider, usually injected as window.ethereum or window.klaytn. const injectedProvider = this.provider.provider as ExternalProvider; return injectedProvider.isKaikas === true; } return false; } // @ethers/providers/src.ts/provider-jsonrpc.ts:JsonRpcSigner.connect override connect(_provider: Provider): EthersJsonRpcSigner { assert( false, "cannot alter JSON-RPC Signer connection", "UNSUPPORTED_OPERATION" ); } connectUnchecked(): EthersJsonRpcSigner { return new UncheckedJsonRpcSigner(this.provider, this.address); } // If underlying EIP-1193 provider is Kaikas, return the KIP-97 signed message in which // the message is prefixed with "\x19Klaytn Signed Message:\n" + len(message) before signing. // https://kips.kaia.io/KIPs/kip-97 // // Otherwise, return the ERC-191 signed message in which the message is prefixed with // "\x19Ethereum Signed Message:\n" + len(message) before signing. // https://eips.ethereum.org/EIPS/eip-191 // // @ethers/providers/src.ts/provider-jsonrpc.ts:JsonRpcSigner.signMessage override async signMessage(message: string | Uint8Array): Promise<string> { const data = typeof message === "string" ? toUtf8Bytes(message) : message; const address = await this.getAddress(); try { if (this.isKaikas()) { // KIP-97 states that the prefixed message signature should be accessible // through the personal_sign method, but Kaikas provides it as eth_sign. return this.provider.send("eth_sign", [ address.toLowerCase(), hexlify(data), ]); } else { // Otherwise, use the standard personal_sign for ERC-191. return this.provider.send("personal_sign", [ hexlify(data), address.toLowerCase(), ]); } } catch (error: any) { catchUserRejectedSigning(error, "signMessage", address, message); throw error; } } // Return the signature of the message without prefix via the eth_sign method. // This operation is deprecated in favor of signMessage (ERC-191 or KIP-97). // // Some providers may support this operation for backward compatibility, or reject it. // In Kaikas, eth_sign is reserved for KIP-97 purpose, so the legacy sign message is decisively not supported. // - If the provider is Kaikas, always throw an error. // - If the provider is not Kaikas, try the eth_sign method. // - If the provider rejects the operation, throw an error. // - If the provider accepts the operation, return the signature. // https://docs.metamask.io/wallet/concepts/signing-methods/#eth_sign // https://support.metamask.io/hc/en-us/articles/14764161421467-What-is-eth-sign-and-why-is-it-a-risk- // @ethers/providers/src.ts/provider-jsonrpc.ts:JsonRpcSigner._legacySignMessage async _legacySignMessage(message: Uint8Array | string): Promise<string> { assert( !this.isKaikas(), "Kaikas does not support the prefix-less legacy sign message", "UNSUPPORTED_OPERATION", { operation: "_legacySignMessage" } ); const data = typeof message === "string" ? toUtf8Bytes(message) : message; const address = await this.getAddress(); try { return this.provider.send("eth_sign", [ address.toLowerCase(), hexlify(data), ]); } catch (error: any) { catchUserRejectedSigning(error, "_legacySignMessage", address, message); throw error; } } // Return the signature of the structured data according to EIP-712 via the eth_signTypedData_v4 method. // https://eips.ethereum.org/EIPS/eip-712 // https://docs.metamask.io/wallet/how-to/sign-data/#use-eth_signtypeddata_v4 async _signTypedData( domain: TypedDataDomain, types: Record<string, Array<TypedDataField>>, value: Record<string, any> ): Promise<string> { assert( !this.isKaikas(), "Kaikas does not support the EIP-712 typed structured data signing", "UNSUPPORTED_OPERATION", { operation: "_signTypedData" } ); // @ethers/providers/src.ts/provider-jsonrpc.ts:JsonRpcSigner._signTypedData // Populate any ENS names (in-place) const populated = await TypedDataEncoder.resolveNames( domain, types, value, (name: string) => { return this.provider.resolveName(name) as Promise<string>; } ); const address = await this.getAddress(); try { return this.provider.send("eth_signTypedData_v4", [ address.toLowerCase(), JSON.stringify( TypedDataEncoder.getPayload(populated.domain, types, populated.value) ), ]); } catch (error: any) { catchUserRejectedSigning(error, "_signTypedData", address, { domain: populated.domain, types, value: populated.value, }); throw error; } } override async populateTransaction( transaction: TransactionRequest ): Promise<TransactionLike<string>> { const tx = await getTransactionRequest(transaction); // Not a Klaytn TxType; fallback to ethers.Signer.populateTransaction() if (!isKlaytnTxType(parseTxType(tx.type))) { return super.populateTransaction(tx); } await populateFrom(tx, await this.getAddress()); await populateTo(tx, this.provider); await populateNonce(tx, this.provider, await this.getAddress()); await populateGasLimit(tx, this.provider); await populateGasPrice(tx, this.provider); await populateChainId(tx, this.provider); return tx; } // Return the signed transaction as a string but do not send it. override async signTransaction( transaction: TransactionRequest ): Promise<string> { assert( this.isKaikas(), "signing transactions is only supported in Kaikas", "UNSUPPORTED_OPERATION", { operation: "signTransaction" } ); const tx = await getTransactionRequest(transaction); await populateFrom(tx, await this.getAddress()); await populateGasLimit(tx, this.provider); await populateTo(tx, this.provider); const rpcTx = getRpcTxObject(tx); if (this.isKaikas()) { rpcTx.type = getKaikasTxType(rpcTx.type); } try { // Kaikas returns the web3-style signed transaction object. const signedTx = await this.provider.send("klay_signTransaction", [ rpcTx, ]); return Promise.resolve(signedTx.rawTransaction); } catch (error: any) { catchUserRejectedTransaction(error, "signTransaction", transaction); throw error; } } override async sendTransaction( transaction: TransactionRequest ): Promise<TransactionResponse> { const txhash = await this.sendUncheckedTransaction(transaction); return pollTransactionInPool(txhash, this.provider); } async sendUncheckedTransaction( transaction: TransactionRequest ): Promise<string> { const tx = await getTransactionRequest(transaction); await populateFrom(tx, await this.getAddress()); await populateGasLimit(tx, this.provider); await populateTo(tx, this.provider); const rpcTx = getRpcTxObject(tx); if (this.isKaikas()) { rpcTx.type = getKaikasTxType(rpcTx.type); } try { if (this.isKaikas()) { return this.provider.send("klay_sendTransaction", [rpcTx]); } else { return this.provider.send("eth_sendTransaction", [rpcTx]); } } catch (error: any) { catchUserRejectedTransaction(error, "sendTransaction", tx); throw error; } } async unlock(password: string): Promise<boolean> { const address = await this.getAddress(); return this.provider.send("personal_unlockAccount", [ address.toLowerCase(), password, null, ]); } } // Variant of JsonRpcSigner where it does not wait for the transaction to be in the txpool. // @ethers/providers/src.ts/provider-jsonrpc.ts:UncheckedJsonRpcSigner class UncheckedJsonRpcSigner extends JsonRpcSigner { override async sendTransaction( transaction: TransactionRequest ): Promise<TransactionResponse> { const txhash = await this.sendUncheckedTransaction(transaction); return Promise.resolve({ hash: txhash, nonce: null, gasLimit: null, gasPrice: null, data: null, value: null, chainId: null, confirmations: 0, from: null, wait: (confirmations?: number) => { return this.provider.waitForTransaction(txhash, confirmations); }, } as unknown as TransactionResponse); // forcefully cast to TransactionResponse } } function catchUserRejectedSigning( error: any, action: any, from: any, messageData: any ) { assert( !(typeof error.message === "string" && error.message.match(/user denied/i)), "user rejected signing", "ACTION_REJECTED", { action: "signTransaction", reason: "rejected", info: { from, messageData, action, }, } ); } function catchUserRejectedTransaction( error: any, action: any, transaction: any ) { assert( !(typeof error.message === "string" && error.message.match(/user denied/i)), "user rejected signing", "ACTION_REJECTED", { action: "sendTransaction", reason: "rejected", info: { transaction, }, } ); }