@kaiachain/ethers-ext
Version:
ethers.js extension for kaia blockchain
639 lines (574 loc) • 20.3 kB
text/typescript
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,
},
}
);
}