From ce19dd81791d0202a404290cde66c1258bde325e Mon Sep 17 00:00:00 2001 From: IndiaJonathan Date: Tue, 20 Aug 2024 11:14:34 -0500 Subject: [PATCH] ETH V4 Signing (#329) Adding ETH V4 signing capability --- chain-api/package.json | 1 - chain-api/src/ethers/address.ts | 194 +++++ chain-api/src/ethers/constants/hashes.ts | 6 + chain-api/src/ethers/crypto/keccak_256.ts | 60 ++ chain-api/src/ethers/crypto/signing-key.ts | 224 ++++++ chain-api/src/ethers/errors.ts | 650 +++++++++++++++ chain-api/src/ethers/hash/id.ts | 17 + chain-api/src/ethers/hash/typed-data.ts | 761 ++++++++++++++++++ chain-api/src/ethers/properties.ts | 52 ++ chain-api/src/ethers/readme.md | 5 + chain-api/src/ethers/signature.ts | 396 +++++++++ chain-api/src/ethers/transaction/address.ts | 29 + chain-api/src/{utils => ethers}/type-utils.ts | 13 + chain-api/src/ethers/utils/data.ts | 217 +++++ chain-api/src/ethers/utils/maths.ts | 262 ++++++ chain-api/src/ethers/utils/utf8.ts | 351 ++++++++ chain-api/src/utils/index.ts | 2 +- chain-api/src/utils/signatures.ts | 34 +- .../src/GalachainConnectClient.spec.ts | 132 ++- chain-connect/src/GalachainConnectClient.ts | 15 +- chain-connect/src/Utils.spec.ts | 160 ++++ chain-connect/src/Utils.ts | 85 ++ licenses/licenses.csv | 2 +- package-lock.json | 26 +- tsconfig.base.json | 2 +- verify_copyright.sh | 4 +- 26 files changed, 3669 insertions(+), 31 deletions(-) create mode 100644 chain-api/src/ethers/address.ts create mode 100644 chain-api/src/ethers/constants/hashes.ts create mode 100644 chain-api/src/ethers/crypto/keccak_256.ts create mode 100644 chain-api/src/ethers/crypto/signing-key.ts create mode 100644 chain-api/src/ethers/errors.ts create mode 100644 chain-api/src/ethers/hash/id.ts create mode 100644 chain-api/src/ethers/hash/typed-data.ts create mode 100644 chain-api/src/ethers/properties.ts create mode 100644 chain-api/src/ethers/readme.md create mode 100644 chain-api/src/ethers/signature.ts create mode 100644 chain-api/src/ethers/transaction/address.ts rename chain-api/src/{utils => ethers}/type-utils.ts (81%) create mode 100644 chain-api/src/ethers/utils/data.ts create mode 100644 chain-api/src/ethers/utils/maths.ts create mode 100644 chain-api/src/ethers/utils/utf8.ts create mode 100644 chain-connect/src/Utils.spec.ts create mode 100644 chain-connect/src/Utils.ts diff --git a/chain-api/package.json b/chain-api/package.json index 980993c6e5..0152d9f0a6 100644 --- a/chain-api/package.json +++ b/chain-api/package.json @@ -33,7 +33,6 @@ "format": "prettier --config ../.prettierrc 'src/**/*.ts' --write", "test": "jest" }, - "devDependencies": {}, "nyc": { "extension": [ ".ts", diff --git a/chain-api/src/ethers/address.ts b/chain-api/src/ethers/address.ts new file mode 100644 index 0000000000..3ae42563a6 --- /dev/null +++ b/chain-api/src/ethers/address.ts @@ -0,0 +1,194 @@ +import { keccak256 } from "js-sha3"; + +import { assertArgument } from "./errors"; +import { getBytes } from "./utils/data"; + +const BN_0 = BigInt(0); +const BN_36 = BigInt(36); + +function getChecksumAddress(address: string): string { + address = address.toLowerCase(); + + const chars = address.substring(2).split(""); + + const expanded = new Uint8Array(40); + for (let i = 0; i < 40; i++) { + expanded[i] = chars[i].charCodeAt(0); + } + + const hashed = getBytes(keccak256(expanded)); + + for (let i = 0; i < 40; i += 2) { + if (hashed[i >> 1] >> 4 >= 8) { + chars[i] = chars[i].toUpperCase(); + } + if ((hashed[i >> 1] & 0x0f) >= 8) { + chars[i + 1] = chars[i + 1].toUpperCase(); + } + } + + return "0x" + chars.join(""); +} + +// See: https://en.wikipedia.org/wiki/International_Bank_Account_Number + +// Create lookup table +const ibanLookup: { [character: string]: string } = {}; +for (let i = 0; i < 10; i++) { + ibanLookup[String(i)] = String(i); +} +for (let i = 0; i < 26; i++) { + ibanLookup[String.fromCharCode(65 + i)] = String(10 + i); +} + +// How many decimal digits can we process? (for 64-bit float, this is 15) +// i.e. Math.floor(Math.log10(Number.MAX_SAFE_INTEGER)); +const safeDigits = 15; + +function ibanChecksum(address: string): string { + address = address.toUpperCase(); + address = address.substring(4) + address.substring(0, 2) + "00"; + + let expanded = address + .split("") + .map((c) => { + return ibanLookup[c]; + }) + .join(""); + + // Javascript can handle integers safely up to 15 (decimal) digits + while (expanded.length >= safeDigits) { + const block = expanded.substring(0, safeDigits); + expanded = (parseInt(block, 10) % 97) + expanded.substring(block.length); + } + + let checksum = String(98 - (parseInt(expanded, 10) % 97)); + while (checksum.length < 2) { + checksum = "0" + checksum; + } + + return checksum; +} + +const Base36 = (function () { + const result: Record = {}; + for (let i = 0; i < 36; i++) { + const key = "0123456789abcdefghijklmnopqrstuvwxyz"[i]; + result[key] = BigInt(i); + } + return result; +})(); + +function fromBase36(value: string): bigint { + value = value.toLowerCase(); + + let result = BN_0; + for (let i = 0; i < value.length; i++) { + result = result * BN_36 + Base36[value[i]]; + } + return result; +} + +/** + * Returns a normalized and checksumed address for %%address%%. + * This accepts non-checksum addresses, checksum addresses and + * [[getIcapAddress]] formats. + * + * The checksum in Ethereum uses the capitalization (upper-case + * vs lower-case) of the characters within an address to encode + * its checksum, which offers, on average, a checksum of 15-bits. + * + * If %%address%% contains both upper-case and lower-case, it is + * assumed to already be a checksum address and its checksum is + * validated, and if the address fails its expected checksum an + * error is thrown. + * + * If you wish the checksum of %%address%% to be ignore, it should + * be converted to lower-case (i.e. ``.toLowercase()``) before + * being passed in. This should be a very rare situation though, + * that you wish to bypass the safegaurds in place to protect + * against an address that has been incorrectly copied from another + * source. + * + * @example: + * // Adds the checksum (via upper-casing specific letters) + * getAddress("0x8ba1f109551bd432803012645ac136ddd64dba72") + * //_result: + * + * // Converts ICAP address and adds checksum + * getAddress("XE65GB6LDNXYOFTX0NSV3FUWKOWIXAMJK36"); + * //_result: + * + * // Throws an error if an address contains mixed case, + * // but the checksum fails + * getAddress("0x8Ba1f109551bD432803012645Ac136ddd64DBA72") + * //_error: + */ +export function getAddress(address: string): string { + assertArgument(typeof address === "string", "invalid address", "address", address); + + if (address.match(/^(0x)?[0-9a-fA-F]{40}$/)) { + // Missing the 0x prefix + if (!address.startsWith("0x")) { + address = "0x" + address; + } + + const result = getChecksumAddress(address); + + // It is a checksummed address with a bad checksum + assertArgument( + !address.match(/([A-F].*[a-f])|([a-f].*[A-F])/) || result === address, + "bad address checksum", + "address", + address + ); + + return result; + } + + // Maybe ICAP? (we only support direct mode) + if (address.match(/^XE[0-9]{2}[0-9A-Za-z]{30,31}$/)) { + // It is an ICAP address with a bad checksum + assertArgument( + address.substring(2, 4) === ibanChecksum(address), + "bad icap checksum", + "address", + address + ); + + let result = fromBase36(address.substring(4)).toString(16); + while (result.length < 40) { + result = "0" + result; + } + return getChecksumAddress("0x" + result); + } + + assertArgument(false, "invalid address", "address", address); +} + +/** + * The [ICAP Address format](link-icap) format is an early checksum + * format which attempts to be compatible with the banking + * industry [IBAN format](link-wiki-iban) for bank accounts. + * + * It is no longer common or a recommended format. + * + * @example: + * getIcapAddress("0x8ba1f109551bd432803012645ac136ddd64dba72"); + * //_result: + * + * getIcapAddress("XE65GB6LDNXYOFTX0NSV3FUWKOWIXAMJK36"); + * //_result: + * + * // Throws an error if the ICAP checksum is wrong + * getIcapAddress("XE65GB6LDNXYOFTX0NSV3FUWKOWIXAMJK37"); + * //_error: + */ +export function getIcapAddress(address: string): string { + //let base36 = _base16To36(getAddress(address).substring(2)).toUpperCase(); + let base36 = BigInt(getAddress(address)).toString(36).toUpperCase(); + while (base36.length < 30) { + base36 = "0" + base36; + } + return "XE" + ibanChecksum("XE00" + base36) + base36; +} diff --git a/chain-api/src/ethers/constants/hashes.ts b/chain-api/src/ethers/constants/hashes.ts new file mode 100644 index 0000000000..64faad942e --- /dev/null +++ b/chain-api/src/ethers/constants/hashes.ts @@ -0,0 +1,6 @@ +/** + * A constant for the zero hash. + * + * (**i.e.** ``"0x0000000000000000000000000000000000000000000000000000000000000000"``) + */ +export const ZeroHash = "0x0000000000000000000000000000000000000000000000000000000000000000"; diff --git a/chain-api/src/ethers/crypto/keccak_256.ts b/chain-api/src/ethers/crypto/keccak_256.ts new file mode 100644 index 0000000000..e20c7a8804 --- /dev/null +++ b/chain-api/src/ethers/crypto/keccak_256.ts @@ -0,0 +1,60 @@ +/** + * Cryptographic hashing functions + * + * @_subsection: api/crypto:Hash Functions [about-crypto-hashing] + */ + +/* + I changed this to save us a bit of space rather than import another keccak lib + import { keccak_256 } from "@noble/hashes/sha3"; +*/ +import { keccak256 as keccak256_js_sha3 } from "js-sha3"; + +import { BytesLike, getBytes, hexlify } from "../utils/data"; + +let locked = false; + +const _keccak256 = function (data: Uint8Array): Uint8Array { + return new Uint8Array(keccak256_js_sha3.arrayBuffer(data)); +}; + +let __keccak256: (data: Uint8Array) => BytesLike = _keccak256; + +/** + * Compute the cryptographic KECCAK256 hash of %%data%%. + * + * The %%data%% **m + * ust** be a data representation, to compute the + * hash of UTF-8 data use the [[id]] function. + * + * @returns DataHexstring + * @example: + * keccak256("0x") + * //_result: + * + * keccak256("0x1337") + * //_result: + * + * keccak256(new Uint8Array([ 0x13, 0x37 ])) + * //_result: + * + * // Strings are assumed to be DataHexString, otherwise it will + * // throw. To hash UTF-8 data, see the note above. + * keccak256("Hello World") + * //_error: + */ +export function keccak256(_data: BytesLike): string { + const data = getBytes(_data, "data"); + return hexlify(__keccak256(data)); +} +keccak256._ = _keccak256; +keccak256.lock = function (): void { + locked = true; +}; +keccak256.register = function (func: (data: Uint8Array) => BytesLike) { + if (locked) { + throw new TypeError("keccak256 is locked"); + } + __keccak256 = func; +}; +Object.freeze(keccak256); diff --git a/chain-api/src/ethers/crypto/signing-key.ts b/chain-api/src/ethers/crypto/signing-key.ts new file mode 100644 index 0000000000..2b247bbc8c --- /dev/null +++ b/chain-api/src/ethers/crypto/signing-key.ts @@ -0,0 +1,224 @@ +/** + * Add details about signing here. + * + * @_subsection: api/crypto:Signing [about-signing] + */ + +/* + I changed this to save us a bit of space rather than import another keccak lib +//import { secp256k1 } from "@noble/curves/secp256k1"; +*/ +//I used to use this: +import { ec as EC, ec } from "elliptic"; + +import { assertArgument } from "../errors"; +import { Signature, SignatureLike } from "../signature"; +import { BytesLike, dataLength, getBytes, getBytesCopy, hexlify } from "../utils/data"; +import { toBeHex } from "../utils/maths"; + +/** + * A **SigningKey** provides high-level access to the elliptic curve + * cryptography (ECC) operations and key management. + */ +export class SigningKey { + #privateKey: string; + ecSecp256k1: ec; + + /** + * Creates a new **SigningKey** for %%privateKey%%. + */ + constructor(privateKey: BytesLike) { + assertArgument(dataLength(privateKey) === 32, "invalid private key", "privateKey", "[REDACTED]"); + this.#privateKey = hexlify(privateKey); + this.ecSecp256k1 = new EC("secp256k1"); + } + + /** + * The private key. + */ + get privateKey(): string { + return this.#privateKey; + } + + /** + * The uncompressed public key. + * + * This will always begin with the prefix ``0x04`` and be 132 + * characters long (the ``0x`` prefix and 130 hexadecimal nibbles). + */ + get publicKey(): string { + return SigningKey.computePublicKey(this.#privateKey); + } + + /** + * The compressed public key. + * + * This will always begin with either the prefix ``0x02`` or ``0x03`` + * and be 68 characters long (the ``0x`` prefix and 33 hexadecimal + * nibbles) + */ + get compressedPublicKey(): string { + return SigningKey.computePublicKey(this.#privateKey, true); + } + + /** + * Return the signature of the signed %%digest%%. + */ + sign(digest: BytesLike): Signature { + assertArgument(dataLength(digest) === 32, "invalid digest length", "digest", digest); + + const keyPair = this.ecSecp256k1.keyFromPrivate(this.#privateKey.substring(2)); + const sig = keyPair.sign(getBytesCopy(digest), { canonical: true }); + + return Signature.from({ + r: toBeHex(sig.r, 32), + s: toBeHex(sig.s, 32), + v: sig.recoveryParam ? 0x1c : 0x1b + }); + } + + /** + * Returns the [[link-wiki-ecdh]] shared secret between this + * private key and the %%other%% key. + * + * The %%other%% key may be any type of key, a raw public key, + * a compressed/uncompressed pubic key or aprivate key. + * + * Best practice is usually to use a cryptographic hash on the + * returned value before using it as a symetric secret. + * + * @example: + * sign1 = new SigningKey(id("some-secret-1")) + * sign2 = new SigningKey(id("some-secret-2")) + * + * // Notice that privA.computeSharedSecret(pubB)... + * sign1.computeSharedSecret(sign2.publicKey) + * //_result: + * + * // ...is equal to privB.computeSharedSecret(pubA). + * sign2.computeSharedSecret(sign1.publicKey) + * //_result: + */ + computeSharedSecret(other: BytesLike): string { + const pubKey = SigningKey.computePublicKey(other); + const keyPair = this.ecSecp256k1.keyFromPrivate(this.#privateKey.substring(2)); + const sharedSecret = keyPair.derive( + this.ecSecp256k1.keyFromPublic(getBytes(pubKey).slice(1)).getPublic() + ); + + const sharedSecretArray = sharedSecret.toArray("be", 32); // Get array of bytes in big-endian order + return hexlify(new Uint8Array(sharedSecretArray)); + } + + /** + * Compute the public key for %%key%%, optionally %%compressed%%. + * + * The %%key%% may be any type of key, a raw public key, a + * compressed/uncompressed public key or private key. + * + * @example: + * sign = new SigningKey(id("some-secret")); + * + * // Compute the uncompressed public key for a private key + * SigningKey.computePublicKey(sign.privateKey) + * //_result: + * + * // Compute the compressed public key for a private key + * SigningKey.computePublicKey(sign.privateKey, true) + * //_result: + * + * // Compute the uncompressed public key + * SigningKey.computePublicKey(sign.publicKey, false); + * //_result: + * + * // Compute the Compressed a public key + * SigningKey.computePublicKey(sign.publicKey, true); + * //_result: + */ + /** + * Compute the public key for %%key%%, optionally %%compressed%%. + * + * The %%key%% may be any type of key, a raw public key, a + * compressed/uncompressed public key or private key. + */ + static computePublicKey(key: BytesLike, compressed = false): string { + let bytes = getBytes(key, "key"); + + // private key + if (bytes.length === 32) { + const ec = new EC("secp256k1"); + const keyPair = ec.keyFromPrivate(bytes); + const pubKey = keyPair.getPublic(compressed, "hex"); + return "0x" + pubKey; + } + + // raw public key; use uncompressed key with 0x04 prefix + if (bytes.length === 64) { + const pub = new Uint8Array(65); + pub[0] = 0x04; + pub.set(bytes, 1); + bytes = pub; + } + + const ec = new EC("secp256k1"); + const point = ec.keyFromPublic(bytes).getPublic(); + return "0x" + point.encode("hex", compressed); + } + + /** + * Returns the public key for the private key which produced the + * %%signature%% for the given %%digest%%. + * + * @example: + * key = new SigningKey(id("some-secret")) + * digest = id("hello world") + * sig = key.sign(digest) + * + * // Notice the signer public key... + * key.publicKey + * //_result: + * + * // ...is equal to the recovered public key + * SigningKey.recoverPublicKey(digest, sig) + * //_result: + * + */ + static recoverPublicKey(digest: BytesLike, signature: SignatureLike): string { + assertArgument(dataLength(digest) === 32, "invalid digest length", "digest", digest); + + const sig = Signature.from(signature); + const recoveryParam = sig.yParity === 1 ? 1 : 0; + + const ec = new EC("secp256k1"); + const recoveredKey = ec.recoverPubKey( + getBytesCopy(digest), + { + r: sig.r.substring(2), + s: sig.s.substring(2), + recoveryParam: sig.yParity + }, + recoveryParam + ); + + return "0x" + recoveredKey.encode("hex", false); + } + + /** + * Returns the point resulting from adding the ellipic curve points + * %%p0%% and %%p1%%. + * + * This is not a common function most developers should require, but + * can be useful for certain privacy-specific techniques. + * + * For example, it is used by [[HDNodeWallet]] to compute child + * addresses from parent public keys and chain codes. + */ + static addPoints(p0: BytesLike, p1: BytesLike, compressed = false): string { + const ec = new EC("secp256k1"); + const pub0 = ec.keyFromPublic(SigningKey.computePublicKey(p0).substring(2), "hex").getPublic(); + const pub1 = ec.keyFromPublic(SigningKey.computePublicKey(p1).substring(2), "hex").getPublic(); + const sum = pub0.add(pub1); + + return "0x" + sum.encode("hex", compressed); + } +} diff --git a/chain-api/src/ethers/errors.ts b/chain-api/src/ethers/errors.ts new file mode 100644 index 0000000000..d015ea02dd --- /dev/null +++ b/chain-api/src/ethers/errors.ts @@ -0,0 +1,650 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * All errors in ethers include properties to ensure they are both + * human-readable (i.e. ``.message``) and machine-readable (i.e. ``.code``). + * + * The [[isError]] function can be used to check the error ``code`` and + * provide a type guard for the properties present on that error interface. + * + * @_section: api/utils/errors:Errors [about-errors] + */ +import { defineProperties } from "./properties.js"; + +/** + * An error may contain additional properties, but those must not + * conflict with any implicit properties. + */ +export type ErrorInfo = Omit & { shortMessage?: string }; + +function stringify(value: any): any { + if (value == null) { + return "null"; + } + + if (Array.isArray(value)) { + return "[ " + value.map(stringify).join(", ") + " ]"; + } + + if (value instanceof Uint8Array) { + const HEX = "0123456789abcdef"; + let result = "0x"; + for (let i = 0; i < value.length; i++) { + result += HEX[value[i] >> 4]; + result += HEX[value[i] & 0xf]; + } + return result; + } + + if (typeof value === "object" && typeof value.toJSON === "function") { + return stringify(value.toJSON()); + } + + switch (typeof value) { + case "boolean": + case "symbol": + return value.toString(); + case "bigint": + return BigInt(value).toString(); + case "number": + return value.toString(); + case "string": + return JSON.stringify(value); + case "object": { + const keys = Object.keys(value); + keys.sort(); + return "{ " + keys.map((k) => `${stringify(k)}: ${stringify(value[k])}`).join(", ") + " }"; + } + } + + return `[ COULD NOT SERIALIZE ]`; +} + +/** + * All errors emitted by ethers have an **ErrorCode** to help + * identify and coalesce errors to simplify programmatic analysis. + * + * Each **ErrorCode** is the %%code%% proerty of a coresponding + * [[EthersError]]. + * + * **Generic Errors** + * + * **``"UNKNOWN_ERROR"``** - see [[UnknownError]] + * + * **``"NOT_IMPLEMENTED"``** - see [[NotImplementedError]] + * + * **``"UNSUPPORTED_OPERATION"``** - see [[UnsupportedOperationError]] + * + * **``"NETWORK_ERROR"``** - see [[NetworkError]] + * + * **``"SERVER_ERROR"``** - see [[ServerError]] + * + * **``"TIMEOUT"``** - see [[TimeoutError]] + * + * **``"BAD_DATA"``** - see [[BadDataError]] + * + * **``"CANCELLED"``** - see [[CancelledError]] + * + * **Operational Errors** + * + * **``"BUFFER_OVERRUN"``** - see [[BufferOverrunError]] + * + * **``"NUMERIC_FAULT"``** - see [[NumericFaultError]] + * + * **Argument Errors** + * + * **``"INVALID_ARGUMENT"``** - see [[InvalidArgumentError]] + * + * **``"MISSING_ARGUMENT"``** - see [[MissingArgumentError]] + * + * **``"UNEXPECTED_ARGUMENT"``** - see [[UnexpectedArgumentError]] + * + * **``"VALUE_MISMATCH"``** - //unused// + * + * **Blockchain Errors** + * + * **``"CALL_EXCEPTION"``** - see [[CallExceptionError]] + * + * **``"INSUFFICIENT_FUNDS"``** - see [[InsufficientFundsError]] + * + * **``"NONCE_EXPIRED"``** - see [[NonceExpiredError]] + * + * **``"REPLACEMENT_UNDERPRICED"``** - see [[ReplacementUnderpricedError]] + * + * **``"TRANSACTION_REPLACED"``** - see [[TransactionReplacedError]] + * + * **``"UNCONFIGURED_NAME"``** - see [[UnconfiguredNameError]] + * + * **``"OFFCHAIN_FAULT"``** - see [[OffchainFaultError]] + * + * **User Interaction Errors** + * + * **``"ACTION_REJECTED"``** - see [[ActionRejectedError]] + */ +export type ErrorCode = + // Generic Errors + | "UNKNOWN_ERROR" + | "NOT_IMPLEMENTED" + | "UNSUPPORTED_OPERATION" + | "NETWORK_ERROR" + | "SERVER_ERROR" + | "TIMEOUT" + | "BAD_DATA" + | "CANCELLED" + + // Operational Errors + | "BUFFER_OVERRUN" + | "NUMERIC_FAULT" + + // Argument Errors + | "INVALID_ARGUMENT" + | "MISSING_ARGUMENT" + | "UNEXPECTED_ARGUMENT" + | "VALUE_MISMATCH" + + // Blockchain Errors + | "CALL_EXCEPTION" + | "INSUFFICIENT_FUNDS" + | "NONCE_EXPIRED" + | "REPLACEMENT_UNDERPRICED" + | "TRANSACTION_REPLACED" + | "UNCONFIGURED_NAME" + | "OFFCHAIN_FAULT" + + // User Interaction + | "ACTION_REJECTED"; + +/** + * All errors in Ethers include properties to assist in + * machine-readable errors. + */ +export interface EthersError extends Error { + /** + * The string error code. + */ + code: ErrorCode; + + /** + * A short message describing the error, with minimal additional + * details. + */ + shortMessage: string; + + /** + * Additional info regarding the error that may be useful. + * + * This is generally helpful mostly for human-based debugging. + */ + info?: Record; + + /** + * Any related error. + */ + error?: Error; +} + +// Generic Errors + +/** + * This Error is a catch-all for when there is no way for Ethers to + * know what the underlying problem is. + */ +export interface UnknownError extends EthersError<"UNKNOWN_ERROR"> { + [key: string]: any; +} + +/** + * This Error is mostly used as a stub for functionality that is + * intended for the future, but is currently not implemented. + */ +export interface NotImplementedError extends EthersError<"NOT_IMPLEMENTED"> { + /** + * The attempted operation. + */ + operation: string; +} + +/** + * This Error indicates that the attempted operation is not supported. + * + * This could range from a specific JSON-RPC end-point not supporting + * a feature to a specific configuration of an object prohibiting the + * operation. + * + * For example, a [[Wallet]] with no connected [[Provider]] is unable + * to send a transaction. + */ +export interface UnsupportedOperationError extends EthersError<"UNSUPPORTED_OPERATION"> { + /** + * The attempted operation. + */ + operation: string; +} + +/** + * This Error indicates a problem connecting to a network. + */ +export interface NetworkError extends EthersError<"NETWORK_ERROR"> { + /** + * The network event. + */ + event: string; +} + +/** + * This Error indicates that a provided set of data cannot + * be correctly interpreted. + */ +export interface BadDataError extends EthersError<"BAD_DATA"> { + /** + * The data. + */ + value: any; +} + +/** + * This Error indicates that the operation was cancelled by a + * programmatic call, for example to ``cancel()``. + */ +export type CancelledError = EthersError<"CANCELLED">; + +// Operational Errors + +/** + * This Error indicates an attempt was made to read outside the bounds + * of protected data. + * + * Most operations in Ethers are protected by bounds checks, to mitigate + * exploits when parsing data. + */ +export interface BufferOverrunError extends EthersError<"BUFFER_OVERRUN"> { + /** + * The buffer that was overrun. + */ + buffer: Uint8Array; + + /** + * The length of the buffer. + */ + length: number; + + /** + * The offset that was requested. + */ + offset: number; +} + +/** + * This Error indicates an operation which would result in incorrect + * arithmetic output has occurred. + * + * For example, trying to divide by zero or using a ``uint8`` to store + * a negative value. + */ +export interface NumericFaultError extends EthersError<"NUMERIC_FAULT"> { + /** + * The attempted operation. + */ + operation: string; + + /** + * The fault reported. + */ + fault: string; + + /** + * The value the operation was attempted against. + */ + value: any; +} + +// Argument Errors + +/** + * This Error indicates an incorrect type or value was passed to + * a function or method. + */ +export interface InvalidArgumentError extends EthersError<"INVALID_ARGUMENT"> { + /** + * The name of the argument. + */ + argument: string; + + /** + * The value that was provided. + */ + value: any; + + info?: Record; +} + +/** + * This Error indicates there were too few arguments were provided. + */ +export interface MissingArgumentError extends EthersError<"MISSING_ARGUMENT"> { + /** + * The number of arguments received. + */ + count: number; + + /** + * The number of arguments expected. + */ + expectedCount: number; +} + +/** + * This Error indicates too many arguments were provided. + */ +export interface UnexpectedArgumentError extends EthersError<"UNEXPECTED_ARGUMENT"> { + /** + * The number of arguments received. + */ + count: number; + + /** + * The number of arguments expected. + */ + expectedCount: number; +} + +// Blockchain Errors + +/** + * The action that resulted in the call exception. + */ +export type CallExceptionAction = + | "call" + | "estimateGas" + | "getTransactionResult" + | "sendTransaction" + | "unknown"; + +/** + * The related transaction that caused the error. + */ +export type CallExceptionTransaction = { + to: null | string; + from?: string; + data: string; +}; + +/** + * This Error indicates an ENS name was used, but the name has not + * been configured. + * + * This could indicate an ENS name is unowned or that the current + * address being pointed to is the [[ZeroAddress]]. + */ +export interface UnconfiguredNameError extends EthersError<"UNCONFIGURED_NAME"> { + /** + * The ENS name that was requested + */ + value: string; +} + +/** + * This Error indicates a request was rejected by the user. + * + * In most clients (such as MetaMask), when an operation requires user + * authorization (such as ``signer.sendTransaction``), the client + * presents a dialog box to the user. If the user denies the request + * this error is thrown. + */ +export interface ActionRejectedError extends EthersError<"ACTION_REJECTED"> { + /** + * The requested action. + */ + action: + | "requestAccess" + | "sendTransaction" + | "signMessage" + | "signTransaction" + | "signTypedData" + | "unknown"; + + /** + * The reason the action was rejected. + * + * If there is already a pending request, some clients may indicate + * there is already a ``"pending"`` action. This prevents an app + * from spamming the user. + */ + reason: "expired" | "rejected" | "pending"; +} + +/** + * Returns true if the %%error%% matches an error thrown by ethers + * that matches the error %%code%%. + * + * In TypeScript environments, this can be used to check that %%error%% + * matches an EthersError type, which means the expected properties will + * be set. + * + * @See [ErrorCodes](api:ErrorCode) + * @example + * try { + * // code.... + * } catch (e) { + * if (isError(e, "CALL_EXCEPTION")) { + * // The Type Guard has validated this object + * console.log(e.data); + * } + * } + */ +export function isError>(error: any, code: K): error is T { + return error && (error).code === code; +} + +/** + * Returns a new Error configured to the format ethers emits errors, with + * the %%message%%, [[api:ErrorCode]] %%code%% and additional properties + * for the corresponding EthersError. + * + * Each error in ethers includes the version of ethers, a + * machine-readable [[ErrorCode]], and depending on %%code%%, additional + * required properties. The error message will also include the %%message%%, + * ethers version, %%code%% and all additional properties, serialized. + */ +export function makeError(message: string, code: K, info?: ErrorInfo): T { + const shortMessage = message; + + { + const details: Array = []; + if (info) { + if ("message" in info || "code" in info || "name" in info) { + throw new Error(`value will overwrite populated values: ${stringify(info)}`); + } + for (const key in info) { + if (key === "shortMessage") { + continue; + } + const value = info[>key]; + // try { + details.push(key + "=" + stringify(value)); + // } catch (error: any) { + // console.log("MMM", error.message); + // details.push(key + "=[could not serialize object]"); + // } + } + } + details.push(`code=${code}`); + + if (details.length) { + message += " (" + details.join(", ") + ")"; + } + } + + let error; + switch (code) { + case "INVALID_ARGUMENT": + error = new TypeError(message); + break; + case "NUMERIC_FAULT": + case "BUFFER_OVERRUN": + error = new RangeError(message); + break; + default: + error = new Error(message); + } + + defineProperties(error, { code }); + + if (info) { + Object.assign(error, info); + } + + if ((error).shortMessage == null) { + defineProperties(error, { shortMessage }); + } + + return error; +} + +/** + * Throws an EthersError with %%message%%, %%code%% and additional error + * %%info%% when %%check%% is falsish.. + * + * @see [[api:makeError]] + */ +export function assert>( + check: unknown, + message: string, + code: K, + info?: ErrorInfo +): asserts check { + if (!check) { + throw makeError(message, code, info); + } +} + +/** + * A simple helper to simply ensuring provided arguments match expected + * constraints, throwing if not. + * + * In TypeScript environments, the %%check%% has been asserted true, so + * any further code does not need additional compile-time checks. + */ +export function assertArgument(check: unknown, message: string, name: string, value: unknown): asserts check { + assert(check, message, "INVALID_ARGUMENT", { argument: name, value: value }); +} + +export function assertArgumentCount(count: number, expectedCount: number, message?: string): void { + if (message == null) { + message = ""; + } + if (message) { + message = ": " + message; + } + + assert(count >= expectedCount, "missing arguemnt" + message, "MISSING_ARGUMENT", { + count: count, + expectedCount: expectedCount + }); + + assert(count <= expectedCount, "too many arguments" + message, "UNEXPECTED_ARGUMENT", { + count: count, + expectedCount: expectedCount + }); +} + +const _normalizeForms = ["NFD", "NFC", "NFKD", "NFKC"].reduce( + (accum, form) => { + try { + // General test for normalize + /* c8 ignore start */ + if ("test".normalize(form) !== "test") { + throw new Error("bad"); + } + /* c8 ignore stop */ + + if (form === "NFD") { + const check = String.fromCharCode(0xe9).normalize("NFD"); + const expected = String.fromCharCode(0x65, 0x0301); + /* c8 ignore start */ + if (check !== expected) { + throw new Error("broken"); + } + /* c8 ignore stop */ + } + + accum.push(form); + // eslint-disable-next-line no-empty + } catch (error) {} + + return accum; + }, + >[] +); + +/** + * A conditional type that transforms the [[ErrorCode]] T into + * its EthersError type. + * + * @flatworm-skip-docs + */ +export type CodedEthersError = T extends "UNKNOWN_ERROR" + ? UnknownError + : T extends "NOT_IMPLEMENTED" + ? NotImplementedError + : T extends "UNSUPPORTED_OPERATION" + ? UnsupportedOperationError + : T extends "NETWORK_ERROR" + ? NetworkError + : T extends "BAD_DATA" + ? BadDataError + : T extends "CANCELLED" + ? CancelledError + : T extends "BUFFER_OVERRUN" + ? BufferOverrunError + : T extends "NUMERIC_FAULT" + ? NumericFaultError + : T extends "INVALID_ARGUMENT" + ? InvalidArgumentError + : T extends "MISSING_ARGUMENT" + ? MissingArgumentError + : T extends "UNEXPECTED_ARGUMENT" + ? UnexpectedArgumentError + : T extends "UNCONFIGURED_NAME" + ? UnconfiguredNameError + : T extends "ACTION_REJECTED" + ? ActionRejectedError + : never; + +/** + * Throws if the normalization %%form%% is not supported. + */ +export function assertNormalize(form: string): void { + assert( + _normalizeForms.indexOf(form) >= 0, + "platform missing String.prototype.normalize", + "UNSUPPORTED_OPERATION", + { + operation: "String.prototype.normalize", + info: { form } + } + ); +} + +/** + * Many classes use file-scoped values to guard the constructor, + * making it effectively private. This facilitates that pattern + * by ensuring the %%givenGaurd%% matches the file-scoped %%guard%%, + * throwing if not, indicating the %%className%% if provided. + */ +export function assertPrivate(givenGuard: any, guard: any, className?: string): void { + if (className == null) { + className = ""; + } + if (givenGuard !== guard) { + let method = className, + operation = "new"; + if (className) { + method += "."; + operation += " " + className; + } + assert(false, `private constructor; use ${method}from* methods`, "UNSUPPORTED_OPERATION", { + operation + }); + } +} diff --git a/chain-api/src/ethers/hash/id.ts b/chain-api/src/ethers/hash/id.ts new file mode 100644 index 0000000000..ffa8f74ac0 --- /dev/null +++ b/chain-api/src/ethers/hash/id.ts @@ -0,0 +1,17 @@ +import { keccak256 } from "../crypto/keccak_256"; +import { toUtf8Bytes } from "../utils/utf8"; + +/** + * A simple hashing function which operates on UTF-8 strings to + * compute an 32-byte identifier. + * + * This simply computes the [UTF-8 bytes](toUtf8Bytes) and computes + * the [[keccak256]]. + * + * @example: + * id("hello world") + * //_result: + */ +export function id(value: string): string { + return keccak256(toUtf8Bytes(value)); +} diff --git a/chain-api/src/ethers/hash/typed-data.ts b/chain-api/src/ethers/hash/typed-data.ts new file mode 100644 index 0000000000..054c9ba793 --- /dev/null +++ b/chain-api/src/ethers/hash/typed-data.ts @@ -0,0 +1,761 @@ +/* eslint-disable prefer-const */ + +/* eslint-disable no-empty */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +// import { keccak256 } from "../../crypto/keccak_256"; +import { getAddress } from "../address"; +import { keccak256 } from "../crypto/keccak_256"; +import { assertArgument } from "../errors"; +import { defineProperties } from "../properties"; +import { SignatureLike } from "../signature"; +import { recoverAddress } from "../transaction/address"; +import { BigNumberish } from "../type-utils"; +import { BytesLike, concat, getBytes, hexlify, isHexString, zeroPadValue } from "../utils/data"; +import { getBigInt, mask, toBeHex, toQuantity, toTwos } from "../utils/maths"; +import { id } from "./id"; + +const padding = new Uint8Array(32); +padding.fill(0); + +const BN__1 = BigInt(-1); +const BN_0 = BigInt(0); +const BN_1 = BigInt(1); +const BN_MAX_UINT256 = BigInt("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + +// @TODO: in v7, verifyingContract should be an AddressLike and use resolveAddress + +/** + * The domain for an [[link-eip-712]] payload. + */ +export interface TypedDataDomain { + /** + * The human-readable name of the signing domain. + */ + name?: null | string; + + /** + * The major version of the signing domain. + */ + version?: null | string; + + /** + * The chain ID of the signing domain. + */ + chainId?: null | BigNumberish; + + /** + * The the address of the contract that will verify the signature. + */ + verifyingContract?: null | string; + + /** + * A salt used for purposes decided by the specific domain. + */ + salt?: null | BytesLike; +} + +/** + * A specific field of a structured [[link-eip-712]] type. + */ +export interface TypedDataField { + /** + * The field name. + */ + name: string; + + /** + * The type of the field. + */ + type: string; +} + +function hexPadRight(value: BytesLike): string { + const bytes = getBytes(value); + const padOffset = bytes.length % 32; + if (padOffset) { + return concat([bytes, padding.slice(padOffset)]); + } + return hexlify(bytes); +} + +const hexTrue = toBeHex(BN_1, 32); +const hexFalse = toBeHex(BN_0, 32); + +const domainFieldTypes: Record = { + name: "string", + version: "string", + chainId: "uint256", + verifyingContract: "address", + salt: "bytes32" +}; + +const domainFieldNames: Array = ["name", "version", "chainId", "verifyingContract", "salt"]; + +function checkString(key: string): (value: any) => string { + return function (value: any) { + assertArgument( + typeof value === "string", + `invalid domain value for ${JSON.stringify(key)}`, + `domain.${key}`, + value + ); + return value; + }; +} + +const domainChecks: Record any> = { + name: checkString("name"), + version: checkString("version"), + chainId: function (_value: any) { + const value = getBigInt(_value, "domain.chainId"); + assertArgument(value >= 0, "invalid chain ID", "domain.chainId", _value); + if (Number.isSafeInteger(value)) { + return Number(value); + } + return toQuantity(value); + }, + verifyingContract: function (value: any) { + try { + return getAddress(value).toLowerCase(); + } catch (error) {} + assertArgument(false, `invalid domain value "verifyingContract"`, "domain.verifyingContract", value); + }, + salt: function (value: any) { + const bytes = getBytes(value, "domain.salt"); + assertArgument(bytes.length === 32, `invalid domain value "salt"`, "domain.salt", value); + return hexlify(bytes); + } +}; + +function getBaseEncoder(type: string): null | ((value: any) => string) { + // intXX and uintXX + { + const match = type.match(/^(u?)int(\d+)$/); + if (match) { + const signed = match[1] === ""; + + const width = parseInt(match[2]); + assertArgument( + width % 8 === 0 && width !== 0 && width <= 256 && match[2] === String(width), + "invalid numeric width", + "type", + type + ); + + const boundsUpper = mask(BN_MAX_UINT256, signed ? width - 1 : width); + const boundsLower = signed ? (boundsUpper + BN_1) * BN__1 : BN_0; + + return function (_value: BigNumberish) { + const value = getBigInt(_value, "value"); + + assertArgument( + value >= boundsLower && value <= boundsUpper, + `value out-of-bounds for ${type}`, + "value", + value + ); + + return toBeHex(signed ? toTwos(value, 256) : value, 32); + }; + } + } + + // bytesXX + { + const match = type.match(/^bytes(\d+)$/); + if (match) { + const width = parseInt(match[1]); + assertArgument( + width !== 0 && width <= 32 && match[1] === String(width), + "invalid bytes width", + "type", + type + ); + + return function (value: BytesLike) { + const bytes = getBytes(value); + assertArgument(bytes.length === width, `invalid length for ${type}`, "value", value); + return hexPadRight(value); + }; + } + } + + switch (type) { + case "address": + return function (value: string) { + return zeroPadValue(getAddress(value), 32); + }; + case "bool": + return function (value: boolean) { + return !value ? hexFalse : hexTrue; + }; + case "bytes": + return function (value: BytesLike) { + return keccak256(value); + }; + case "string": + return function (value: string) { + return id(value); + }; + } + + return null; +} + +function encodeType(name: string, fields: Array): string { + return `${name}(${fields.map(({ name, type }) => type + " " + name).join(",")})`; +} + +type ArrayResult = { + base: string; // The base type + index?: string; // the full Index (if any) + array?: { + // The Array... (if index) + base: string; // ...base type (same as above) + prefix: string; // ...sans the final Index + count: number; // ...the final Index (-1 for dynamic) + }; +}; + +// foo[][3] => { base: "foo", index: "[][3]", array: { +// base: "foo", prefix: "foo[]", count: 3 } } +function splitArray(type: string): ArrayResult { + const match = type.match(/^([^\x5b]*)((\x5b\d*\x5d)*)(\x5b(\d*)\x5d)$/); + if (match) { + return { + base: match[1], + index: match[2] + match[4], + array: { + base: match[1], + prefix: match[1] + match[2], + count: match[5] ? parseInt(match[5]) : -1 + } + }; + } + + return { base: type }; +} + +/** + * A **TypedDataEncode** prepares and encodes [[link-eip-712]] payloads + * for signed typed data. + * + * This is useful for those that wish to compute various components of a + * typed data hash, primary types, or sub-components, but generally the + * higher level [[Signer-signTypedData]] is more useful. + */ +export class TypedDataEncoder { + /** + * The primary type for the structured [[types]]. + * + * This is derived automatically from the [[types]], since no + * recursion is possible, once the DAG for the types is consturcted + * internally, the primary type must be the only remaining type with + * no parent nodes. + */ + readonly primaryType!: string; + + readonly #types: string; + + /** + * The types. + */ + get types(): Record> { + return JSON.parse(this.#types); + } + + readonly #fullTypes: Map; + + readonly #encoderCache: Map string>; + + /** + * Create a new **TypedDataEncoder** for %%types%%. + * + * This performs all necessary checking that types are valid and + * do not violate the [[link-eip-712]] structural constraints as + * well as computes the [[primaryType]]. + */ + constructor(_types: Record>) { + this.#fullTypes = new Map(); + this.#encoderCache = new Map(); + + // Link struct types to their direct child structs + const links: Map> = new Map(); + + // Link structs to structs which contain them as a child + const parents: Map> = new Map(); + + // Link all subtypes within a given struct + const subtypes: Map> = new Map(); + + const types: Record> = {}; + Object.keys(_types).forEach((type) => { + types[type] = _types[type].map(({ name, type }) => { + // Normalize the base type (unless name conflict) + let { base, index } = splitArray(type); + if (base === "int" && !_types["int"]) { + base = "int256"; + } + if (base === "uint" && !_types["uint"]) { + base = "uint256"; + } + + return { name, type: base + (index || "") }; + }); + + links.set(type, new Set()); + parents.set(type, []); + subtypes.set(type, new Set()); + }); + this.#types = JSON.stringify(types); + + for (const name in types) { + const uniqueNames: Set = new Set(); + + for (const field of types[name]) { + // Check each field has a unique name + assertArgument( + !uniqueNames.has(field.name), + `duplicate variable name ${JSON.stringify(field.name)} in ${JSON.stringify(name)}`, + "types", + _types + ); + uniqueNames.add(field.name); + + // Get the base type (drop any array specifiers) + const baseType = splitArray(field.type).base; + assertArgument( + baseType !== name, + `circular type reference to ${JSON.stringify(baseType)}`, + "types", + _types + ); + + // Is this a base encoding type? + const encoder = getBaseEncoder(baseType); + if (encoder) { + continue; + } + + assertArgument(parents.has(baseType), `unknown type ${JSON.stringify(baseType)}`, "types", _types); + + // Add linkage + (parents.get(baseType) as Array).push(name); + (links.get(name) as Set).add(baseType); + } + } + + // Deduce the primary type + const primaryTypes = Array.from(parents.keys()).filter( + (n) => (parents.get(n) as Array).length === 0 + ); + assertArgument(primaryTypes.length !== 0, "missing primary type", "types", _types); + assertArgument( + primaryTypes.length === 1, + `ambiguous primary types or unused types: ${primaryTypes.map((t) => JSON.stringify(t)).join(", ")}`, + "types", + _types + ); + + defineProperties(this, { primaryType: primaryTypes[0] }); + + // Check for circular type references + function checkCircular(type: string, found: Set) { + assertArgument(!found.has(type), `circular type reference to ${JSON.stringify(type)}`, "types", _types); + + found.add(type); + + for (const child of links.get(type) as Set) { + if (!parents.has(child)) { + continue; + } + + // Recursively check children + checkCircular(child, found); + + // Mark all ancestors as having this decendant + for (const subtype of found) { + (subtypes.get(subtype) as Set).add(child); + } + } + + found.delete(type); + } + checkCircular(this.primaryType, new Set()); + + // Compute each fully describe type + for (const [name, set] of subtypes) { + const st = Array.from(set); + st.sort(); + this.#fullTypes.set( + name, + encodeType(name, types[name]) + st.map((t) => encodeType(t, types[t])).join("") + ); + } + } + + /** + * Returnthe encoder for the specific %%type%%. + */ + getEncoder(type: string): (value: any) => string { + let encoder = this.#encoderCache.get(type); + if (!encoder) { + encoder = this.#getEncoder(type); + this.#encoderCache.set(type, encoder); + } + return encoder; + } + + #getEncoder(type: string): (value: any) => string { + // Basic encoder type (address, bool, uint256, etc) + { + const encoder = getBaseEncoder(type); + if (encoder) { + return encoder; + } + } + + // Array + const array = splitArray(type).array; + if (array) { + const subtype = array.prefix; + const subEncoder = this.getEncoder(subtype); + return (value: Array) => { + assertArgument( + array.count === -1 || array.count === value.length, + `array length mismatch; expected length ${array.count}`, + "value", + value + ); + + let result = value.map(subEncoder); + if (this.#fullTypes.has(subtype)) { + result = result.map(keccak256); + } + + return keccak256(concat(result)); + }; + } + + // Struct + const fields = this.types[type]; + if (fields) { + const encodedType = id(this.#fullTypes.get(type) as string); + return (value: Record) => { + const values = fields.map(({ name, type }) => { + const result = this.getEncoder(type)(value[name]); + if (this.#fullTypes.has(type)) { + return keccak256(result); + } + return result; + }); + values.unshift(encodedType); + return concat(values); + }; + } + + assertArgument(false, `unknown type: ${type}`, "type", type); + } + + /** + * Return the full type for %%name%%. + */ + encodeType(name: string): string { + const result = this.#fullTypes.get(name); + assertArgument(result, `unknown type: ${JSON.stringify(name)}`, "name", name); + return result; + } + + /** + * Return the encoded %%value%% for the %%type%%. + */ + encodeData(type: string, value: any): string { + return this.getEncoder(type)(value); + } + + /** + * Returns the hash of %%value%% for the type of %%name%%. + */ + hashStruct(name: string, value: Record): string { + return keccak256(this.encodeData(name, value)); + } + + /** + * Return the fulled encoded %%value%% for the [[types]]. + */ + encode(value: Record): string { + return this.encodeData(this.primaryType, value); + } + + /** + * Return the hash of the fully encoded %%value%% for the [[types]]. + */ + hash(value: Record): string { + return this.hashStruct(this.primaryType, value); + } + + /** + * @_ignore: + */ + _visit(type: string, value: any, callback: (type: string, data: any) => any): any { + // Basic encoder type (address, bool, uint256, etc) + { + const encoder = getBaseEncoder(type); + if (encoder) { + return callback(type, value); + } + } + + // Array + const array = splitArray(type).array; + if (array) { + assertArgument( + array.count === -1 || array.count === value.length, + `array length mismatch; expected length ${array.count}`, + "value", + value + ); + return value.map((v: any) => this._visit(array.prefix, v, callback)); + } + + // Struct + const fields = this.types[type]; + if (fields) { + return fields.reduce( + (accum, { name, type }) => { + accum[name] = this._visit(type, value[name], callback); + return accum; + }, + >{} + ); + } + + assertArgument(false, `unknown type: ${type}`, "type", type); + } + + /** + * Call %%calback%% for each value in %%value%%, passing the type and + * component within %%value%%. + * + * This is useful for replacing addresses or other transformation that + * may be desired on each component, based on its type. + */ + visit(value: Record, callback: (type: string, data: any) => any): any { + return this._visit(this.primaryType, value, callback); + } + + /** + * Create a new **TypedDataEncoder** for %%types%%. + */ + static from(types: Record>): TypedDataEncoder { + return new TypedDataEncoder(types); + } + + /** + * Return the primary type for %%types%%. + */ + static getPrimaryType(types: Record>): string { + return TypedDataEncoder.from(types).primaryType; + } + + /** + * Return the hashed struct for %%value%% using %%types%% and %%name%%. + */ + static hashStruct( + name: string, + types: Record>, + value: Record + ): string { + return TypedDataEncoder.from(types).hashStruct(name, value); + } + + /** + * Return the domain hash for %%domain%%. + */ + static hashDomain(domain: TypedDataDomain): string { + const domainFields: Array = []; + for (const name in domain) { + if ((>domain)[name] == null) { + continue; + } + const type = domainFieldTypes[name]; + assertArgument(type, `invalid typed-data domain key: ${JSON.stringify(name)}`, "domain", domain); + domainFields.push({ name, type }); + } + + domainFields.sort((a, b) => { + return domainFieldNames.indexOf(a.name) - domainFieldNames.indexOf(b.name); + }); + + return TypedDataEncoder.hashStruct("EIP712Domain", { EIP712Domain: domainFields }, domain); + } + + /** + * Return the fully encoded [[link-eip-712]] %%value%% for %%types%% with %%domain%%. + */ + static encode( + domain: TypedDataDomain, + types: Record>, + value: Record + ): string { + return concat(["0x1901", TypedDataEncoder.hashDomain(domain), TypedDataEncoder.from(types).hash(value)]); + } + + /** + * Return the hash of the fully encoded [[link-eip-712]] %%value%% for %%types%% with %%domain%%. + */ + static hash( + domain: TypedDataDomain, + types: Record>, + value: Record + ): string { + return keccak256(TypedDataEncoder.encode(domain, types, value)); + } + + // Replaces all address types with ENS names with their looked up address + /** + * Resolves to the value from resolving all addresses in %%value%% for + * %%types%% and the %%domain%%. + */ + static async resolveNames( + domain: TypedDataDomain, + types: Record>, + value: Record, + resolveName: (name: string) => Promise + ): Promise<{ domain: TypedDataDomain; value: any }> { + // Make a copy to isolate it from the object passed in + domain = Object.assign({}, domain); + + // Allow passing null to ignore value + for (const key in domain) { + if ((>domain)[key] == null) { + delete (>domain)[key]; + } + } + + // Look up all ENS names + const ensCache: Record = {}; + + // Do we need to look up the domain's verifyingContract? + if (domain.verifyingContract && !isHexString(domain.verifyingContract, 20)) { + ensCache[domain.verifyingContract] = "0x"; + } + + // We are going to use the encoder to visit all the base values + const encoder = TypedDataEncoder.from(types); + + // Get a list of all the addresses + encoder.visit(value, (type: string, value: any) => { + if (type === "address" && !isHexString(value, 20)) { + ensCache[value] = "0x"; + } + return value; + }); + + // Lookup each name + for (const name in ensCache) { + ensCache[name] = await resolveName(name); + } + + // Replace the domain verifyingContract if needed + if (domain.verifyingContract && ensCache[domain.verifyingContract]) { + domain.verifyingContract = ensCache[domain.verifyingContract]; + } + + // Replace all ENS names with their address + value = encoder.visit(value, (type: string, value: any) => { + if (type === "address" && ensCache[value]) { + return ensCache[value]; + } + return value; + }); + + return { domain, value }; + } + + /** + * Returns the JSON-encoded payload expected by nodes which implement + * the JSON-RPC [[link-eip-712]] method. + */ + static getPayload( + domain: TypedDataDomain, + types: Record>, + value: Record + ): any { + // Validate the domain fields + TypedDataEncoder.hashDomain(domain); + + // Derive the EIP712Domain Struct reference type + const domainValues: Record = {}; + const domainTypes: Array<{ name: string; type: string }> = []; + + domainFieldNames.forEach((name) => { + const value = (domain)[name]; + if (value == null) { + return; + } + domainValues[name] = domainChecks[name](value); + domainTypes.push({ name, type: domainFieldTypes[name] }); + }); + + const encoder = TypedDataEncoder.from(types); + + // Get the normalized types + types = encoder.types; + + const typesWithDomain = Object.assign({}, types); + assertArgument( + typesWithDomain.EIP712Domain == null, + "types must not contain EIP712Domain type", + "types.EIP712Domain", + types + ); + + typesWithDomain.EIP712Domain = domainTypes; + + // Validate the data structures and types + encoder.encode(value); + + return { + types: typesWithDomain, + domain: domainValues, + primaryType: encoder.primaryType, + message: encoder.visit(value, (type: string, value: any) => { + // bytes + if (type.match(/^bytes(\d*)/)) { + return hexlify(getBytes(value)); + } + + // uint or int + if (type.match(/^u?int/)) { + return getBigInt(value).toString(); + } + + switch (type) { + case "address": + return value.toLowerCase(); + case "bool": + return !!value; + case "string": + assertArgument(typeof value === "string", "invalid string", "value", value); + return value; + } + + assertArgument(false, "unsupported type", "type", type); + }) + }; + } +} + +/** + * Compute the address used to sign the typed data for the %%signature%%. + */ +export function verifyTypedData( + domain: TypedDataDomain, + types: Record>, + value: Record, + signature: SignatureLike +): string { + return recoverAddress(TypedDataEncoder.hash(domain, types, value), signature); +} diff --git a/chain-api/src/ethers/properties.ts b/chain-api/src/ethers/properties.ts new file mode 100644 index 0000000000..69840342ac --- /dev/null +++ b/chain-api/src/ethers/properties.ts @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Property helper functions. + * + * @_subsection api/utils:Properties [about-properties] + */ + +function checkType(value: any, type: string, name: string): void { + const types = type.split("|").map((t) => t.trim()); + for (let i = 0; i < types.length; i++) { + switch (type) { + case "any": + return; + case "bigint": + case "boolean": + case "number": + case "string": + if (typeof value === type) { + return; + } + } + } + + const error: any = new Error(`invalid value for type ${type}`); + error.code = "INVALID_ARGUMENT"; + error.argument = `value.${name}`; + error.value = value; + + throw error; +} + +/** + * Assigns the %%values%% to %%target%% as read-only values. + * + * It %%types%% is specified, the values are checked. + */ +export function defineProperties( + target: T, + values: { [K in keyof T]?: T[K] }, + types?: { [K in keyof T]?: string } +): void { + for (const key in values) { + const value = values[key]; + + const type = types ? types[key] : null; + if (type) { + checkType(value, type, key); + } + + Object.defineProperty(target, key, { enumerable: true, value, writable: false }); + } +} diff --git a/chain-api/src/ethers/readme.md b/chain-api/src/ethers/readme.md new file mode 100644 index 0000000000..04428c2f48 --- /dev/null +++ b/chain-api/src/ethers/readme.md @@ -0,0 +1,5 @@ +# Ethers Directory + +## Overview + +This directory contains specific files from the ethers library, which have been manually imported rather than installing the entire library via npm. The decision to include only the necessary files from the ethers library helps keep the overall project size down. \ No newline at end of file diff --git a/chain-api/src/ethers/signature.ts b/chain-api/src/ethers/signature.ts new file mode 100644 index 0000000000..90511b38c1 --- /dev/null +++ b/chain-api/src/ethers/signature.ts @@ -0,0 +1,396 @@ +import { ZeroHash } from "./constants/hashes.js"; +import { assertArgument, assertPrivate } from "./errors.js"; +import { BigNumberish, Numeric } from "./type-utils.js"; +import { BytesLike, concat, dataLength, getBytes, hexlify, isHexString, zeroPadValue } from "./utils/data.js"; +import { getBigInt, getNumber, toBeArray } from "./utils/maths.js"; + +// Constants +const BN_0 = BigInt(0); +const BN_1 = BigInt(1); +const BN_2 = BigInt(2); +const BN_27 = BigInt(27); +const BN_28 = BigInt(28); +const BN_35 = BigInt(35); + +const _guard = {}; + +// @TODO: Allow Uint8Array + +/** + * A SignatureLike + * + * @_docloc: api/crypto:Signing + */ +export type SignatureLike = + | Signature + | string + | { + r: string; + s: string; + v: BigNumberish; + yParity?: 0 | 1; + yParityAndS?: string; + } + | { + r: string; + yParityAndS: string; + yParity?: 0 | 1; + s?: string; + v?: number; + } + | { + r: string; + s: string; + yParity: 0 | 1; + v?: BigNumberish; + yParityAndS?: string; + }; + +function toUint256(value: BigNumberish): string { + return zeroPadValue(toBeArray(value), 32); +} + +/** + * A Signature @TODO + * + * + * @_docloc: api/crypto:Signing + */ +export class Signature { + #r: string; + #s: string; + #v: 27 | 28; + #networkV: null | bigint; + + /** + * The ``r`` value for a signautre. + * + * This represents the ``x`` coordinate of a "reference" or + * challenge point, from which the ``y`` can be computed. + */ + get r(): string { + return this.#r; + } + set r(value: BytesLike) { + assertArgument(dataLength(value) === 32, "invalid r", "value", value); + this.#r = hexlify(value); + } + + /** + * The ``s`` value for a signature. + */ + get s(): string { + return this.#s; + } + set s(_value: BytesLike) { + assertArgument(dataLength(_value) === 32, "invalid s", "value", _value); + const value = hexlify(_value); + assertArgument(parseInt(value.substring(0, 3)) < 8, "non-canonical s", "value", value); + this.#s = value; + } + + /** + * The ``v`` value for a signature. + * + * Since a given ``x`` value for ``r`` has two possible values for + * its correspondin ``y``, the ``v`` indicates which of the two ``y`` + * values to use. + * + * It is normalized to the values ``27`` or ``28`` for legacy + * purposes. + */ + get v(): 27 | 28 { + return this.#v; + } + set v(value: BigNumberish) { + const v = getNumber(value, "value"); + assertArgument(v === 27 || v === 28, "invalid v", "v", value); + this.#v = v; + } + + /** + * The EIP-155 ``v`` for legacy transactions. For non-legacy + * transactions, this value is ``null``. + */ + get networkV(): null | bigint { + return this.#networkV; + } + + /** + * The chain ID for EIP-155 legacy transactions. For non-legacy + * transactions, this value is ``null``. + */ + get legacyChainId(): null | bigint { + const v = this.networkV; + if (v == null) { + return null; + } + return Signature.getChainId(v); + } + + /** + * The ``yParity`` for the signature. + * + * See ``v`` for more details on how this value is used. + */ + get yParity(): 0 | 1 { + return this.v === 27 ? 0 : 1; + } + + /** + * The [[link-eip-2098]] compact representation of the ``yParity`` + * and ``s`` compacted into a single ``bytes32``. + */ + get yParityAndS(): string { + // The EIP-2098 compact representation + const yParityAndS = getBytes(this.s); + if (this.yParity) { + yParityAndS[0] |= 0x80; + } + return hexlify(yParityAndS); + } + + /** + * The [[link-eip-2098]] compact representation. + */ + get compactSerialized(): string { + return concat([this.r, this.yParityAndS]); + } + + /** + * The serialized representation. + */ + get serialized(): string { + return concat([this.r, this.s, this.yParity ? "0x1c" : "0x1b"]); + } + + /** + * @private + */ + constructor(guard: any, r: string, s: string, v: 27 | 28) { + assertPrivate(guard, _guard, "Signature"); + this.#r = r; + this.#s = s; + this.#v = v; + this.#networkV = null; + } + + [Symbol.for("nodejs.util.inspect.custom")](): string { + return `Signature { r: "${this.r}", s: "${this.s}", yParity: ${this.yParity}, networkV: ${this.networkV} }`; + } + + /** + * Returns a new identical [[Signature]]. + */ + clone(): Signature { + const clone = new Signature(_guard, this.r, this.s, this.v); + if (this.networkV) { + clone.#networkV = this.networkV; + } + return clone; + } + + /** + * Returns a representation that is compatible with ``JSON.stringify``. + */ + toJSON(): any { + const networkV = this.networkV; + return { + _type: "signature", + networkV: networkV != null ? networkV.toString() : null, + r: this.r, + s: this.s, + v: this.v + }; + } + + /** + * Compute the chain ID from the ``v`` in a legacy EIP-155 transactions. + * + * @example: + * Signature.getChainId(45) + * //_result: + * + * Signature.getChainId(46) + * //_result: + */ + static getChainId(v: BigNumberish): bigint { + const bv = getBigInt(v, "v"); + + // The v is not an EIP-155 v, so it is the unspecified chain ID + if (bv == BN_27 || bv == BN_28) { + return BN_0; + } + + // Bad value for an EIP-155 v + assertArgument(bv >= BN_35, "invalid EIP-155 v", "v", v); + + return (bv - BN_35) / BN_2; + } + + /** + * Compute the ``v`` for a chain ID for a legacy EIP-155 transactions. + * + * Legacy transactions which use [[link-eip-155]] hijack the ``v`` + * property to include the chain ID. + * + * @example: + * Signature.getChainIdV(5, 27) + * //_result: + * + * Signature.getChainIdV(5, 28) + * //_result: + * + */ + static getChainIdV(chainId: BigNumberish, v: 27 | 28): bigint { + return getBigInt(chainId) * BN_2 + BigInt(35 + v - 27); + } + + /** + * Compute the normalized legacy transaction ``v`` from a ``yParirty``, + * a legacy transaction ``v`` or a legacy [[link-eip-155]] transaction. + * + * @example: + * // The values 0 and 1 imply v is actually yParity + * Signature.getNormalizedV(0) + * //_result: + * + * // Legacy non-EIP-1559 transaction (i.e. 27 or 28) + * Signature.getNormalizedV(27) + * //_result: + * + * // Legacy EIP-155 transaction (i.e. >= 35) + * Signature.getNormalizedV(46) + * //_result: + * + * // Invalid values throw + * Signature.getNormalizedV(5) + * //_error: + */ + static getNormalizedV(v: BigNumberish): 27 | 28 { + const bv = getBigInt(v); + + if (bv === BN_0 || bv === BN_27) { + return 27; + } + if (bv === BN_1 || bv === BN_28) { + return 28; + } + + assertArgument(bv >= BN_35, "invalid v", "v", v); + + // Otherwise, EIP-155 v means odd is 27 and even is 28 + return bv & BN_1 ? 27 : 28; + } + + /** + * Creates a new [[Signature]]. + * + * If no %%sig%% is provided, a new [[Signature]] is created + * with default values. + * + * If %%sig%% is a string, it is parsed. + */ + static from(sig?: SignatureLike): Signature { + function assertError(check: unknown, message: string): asserts check { + assertArgument(check, message, "signature", sig); + } + + if (sig == null) { + return new Signature(_guard, ZeroHash, ZeroHash, 27); + } + + if (typeof sig === "string") { + const bytes = getBytes(sig, "signature"); + if (bytes.length === 64) { + const r = hexlify(bytes.slice(0, 32)); + const s = bytes.slice(32, 64); + const v = s[0] & 0x80 ? 28 : 27; + s[0] &= 0x7f; + return new Signature(_guard, r, hexlify(s), v); + } + + if (bytes.length === 65) { + const r = hexlify(bytes.slice(0, 32)); + const s = bytes.slice(32, 64); + assertError((s[0] & 0x80) === 0, "non-canonical s"); + const v = Signature.getNormalizedV(bytes[64]); + return new Signature(_guard, r, hexlify(s), v); + } + + assertError(false, "invalid raw signature length"); + } + + if (sig instanceof Signature) { + return sig.clone(); + } + + // Get r + const _r = sig.r; + assertError(_r != null, "missing r"); + const r = toUint256(_r); + + // Get s; by any means necessary (we check consistency below) + const s = (function (s?: string, yParityAndS?: string) { + if (s != null) { + return toUint256(s); + } + + if (yParityAndS != null) { + assertError(isHexString(yParityAndS, 32), "invalid yParityAndS"); + const bytes = getBytes(yParityAndS); + bytes[0] &= 0x7f; + return hexlify(bytes); + } + + assertError(false, "missing s"); + })(sig.s, sig.yParityAndS); + assertError((getBytes(s)[0] & 0x80) == 0, "non-canonical s"); + + // Get v; by any means necessary (we check consistency below) + const { networkV, v } = (function ( + _v?: BigNumberish, + yParityAndS?: string, + yParity?: Numeric + ): { networkV?: bigint; v: 27 | 28 } { + if (_v != null) { + const v = getBigInt(_v); + return { + networkV: v >= BN_35 ? v : undefined, + v: Signature.getNormalizedV(v) + }; + } + + if (yParityAndS != null) { + assertError(isHexString(yParityAndS, 32), "invalid yParityAndS"); + return { v: getBytes(yParityAndS)[0] & 0x80 ? 28 : 27 }; + } + + if (yParity != null) { + switch (getNumber(yParity, "sig.yParity")) { + case 0: + return { v: 27 }; + case 1: + return { v: 28 }; + } + assertError(false, "invalid yParity"); + } + + assertError(false, "missing v"); + })(sig.v, sig.yParityAndS, sig.yParity); + + const result = new Signature(_guard, r, s, v); + if (networkV) { + result.#networkV = networkV; + } + + // If multiple of v, yParity, yParityAndS we given, check they match + assertError( + sig.yParity == null || getNumber(sig.yParity, "sig.yParity") === result.yParity, + "yParity mismatch" + ); + assertError(sig.yParityAndS == null || sig.yParityAndS === result.yParityAndS, "yParityAndS mismatch"); + + return result; + } +} diff --git a/chain-api/src/ethers/transaction/address.ts b/chain-api/src/ethers/transaction/address.ts new file mode 100644 index 0000000000..0fe6197ce7 --- /dev/null +++ b/chain-api/src/ethers/transaction/address.ts @@ -0,0 +1,29 @@ +import { keccak256 } from "js-sha3"; + +import { getAddress } from "../address"; +import { SigningKey } from "../crypto/signing-key"; +import { SignatureLike } from "../signature"; +import { BytesLike } from "../utils/data"; + +/** + * Returns the address for the %%key%%. + * + * The key may be any standard form of public key or a private key. + */ +export function computeAddress(key: string | SigningKey): string { + let pubkey: string; + if (typeof key === "string") { + pubkey = SigningKey.computePublicKey(key, false); + } else { + pubkey = key.publicKey; + } + return getAddress(keccak256("0x" + pubkey.substring(4)).substring(26)); +} + +/** + * Returns the recovered address for the private key that was + * used to sign %%digest%% that resulted in %%signature%%. + */ +export function recoverAddress(digest: BytesLike, signature: SignatureLike): string { + return computeAddress(SigningKey.recoverPublicKey(digest, signature)); +} diff --git a/chain-api/src/utils/type-utils.ts b/chain-api/src/ethers/type-utils.ts similarity index 81% rename from chain-api/src/utils/type-utils.ts rename to chain-api/src/ethers/type-utils.ts index c7f51d4daa..4a9beb4d7f 100644 --- a/chain-api/src/utils/type-utils.ts +++ b/chain-api/src/ethers/type-utils.ts @@ -26,3 +26,16 @@ type NonFunctionPropertiesAndReplaceRecursive = { }; export type ConstructorArgs = NonFunctionPropertiesAndReplaceRecursive; + +/** + * The following types are from ethers maths.ts + */ +/** + * Any type that can be used where a numeric value is needed. + */ +export type Numeric = number | bigint; + +/** + * Any type that can be used where a big number is needed. + */ +export type BigNumberish = string | Numeric; diff --git a/chain-api/src/ethers/utils/data.ts b/chain-api/src/ethers/utils/data.ts new file mode 100644 index 0000000000..0a69965900 --- /dev/null +++ b/chain-api/src/ethers/utils/data.ts @@ -0,0 +1,217 @@ +/** + * Some data helpers. + * + * + * @_subsection api/utils:Data Helpers [about-data] + */ +import { assert, assertArgument } from "../errors.js"; + +/** + * Some data helpers. + * + * + * @_subsection api/utils:Data Helpers [about-data] + */ + +/** + * A [[HexString]] whose length is even, which ensures it is a valid + * representation of binary data. + */ +export type DataHexString = string; + +/** + * A string which is prefixed with ``0x`` and followed by any number + * of case-agnostic hexadecimal characters. + * + * It must match the regular expression ``/0x[0-9A-Fa-f]*\/``. + */ +export type HexString = string; + +/** + * An object that can be used to represent binary data. + */ +export type BytesLike = DataHexString | Uint8Array; + +function _getBytes(value: BytesLike, name?: string, copy?: boolean): Uint8Array { + if (value instanceof Uint8Array) { + if (copy) { + return new Uint8Array(value); + } + return value; + } + + if (typeof value === "string" && value.match(/^0x(?:[0-9a-f][0-9a-f])*$/i)) { + const result = new Uint8Array((value.length - 2) / 2); + let offset = 2; + for (let i = 0; i < result.length; i++) { + result[i] = parseInt(value.substring(offset, offset + 2), 16); + offset += 2; + } + return result; + } + + assertArgument(false, "invalid BytesLike value", name || "value", value); +} + +/** + * Get a typed Uint8Array for %%value%%. If already a Uint8Array + * the original %%value%% is returned; if a copy is required use + * [[getBytesCopy]]. + * + * @see: getBytesCopy + */ +export function getBytes(value: BytesLike, name?: string): Uint8Array { + return _getBytes(value, name, false); +} + +/** + * Get a typed Uint8Array for %%value%%, creating a copy if necessary + * to prevent any modifications of the returned value from being + * reflected elsewhere. + * + * @see: getBytes + */ +export function getBytesCopy(value: BytesLike, name?: string): Uint8Array { + return _getBytes(value, name, true); +} + +/** + * Returns true if %%value%% is a valid [[HexString]]. + * + * If %%length%% is ``true`` or a //number//, it also checks that + * %%value%% is a valid [[DataHexString]] of %%length%% (if a //number//) + * bytes of data (e.g. ``0x1234`` is 2 bytes). + */ +export function isHexString(value: any, length?: number | boolean): value is `0x${string}` { + if (typeof value !== "string" || !value.match(/^0x[0-9A-Fa-f]*$/)) { + return false; + } + + if (typeof length === "number" && value.length !== 2 + 2 * length) { + return false; + } + if (length === true && value.length % 2 !== 0) { + return false; + } + + return true; +} + +/** + * Returns true if %%value%% is a valid representation of arbitrary + * data (i.e. a valid [[DataHexString]] or a Uint8Array). + */ +export function isBytesLike(value: any): value is BytesLike { + return isHexString(value, true) || value instanceof Uint8Array; +} + +const HexCharacters = "0123456789abcdef"; + +/** + * Returns a [[DataHexString]] representation of %%data%%. + */ +export function hexlify(data: BytesLike): string { + const bytes = getBytes(data); + + let result = "0x"; + for (let i = 0; i < bytes.length; i++) { + const v = bytes[i]; + result += HexCharacters[(v & 0xf0) >> 4] + HexCharacters[v & 0x0f]; + } + return result; +} + +/** + * Returns a [[DataHexString]] by concatenating all values + * within %%data%%. + */ +export function concat(datas: ReadonlyArray): string { + return "0x" + datas.map((d) => hexlify(d).substring(2)).join(""); +} + +/** + * Returns the length of %%data%%, in bytes. + */ +export function dataLength(data: BytesLike): number { + if (isHexString(data, true)) { + return (data.length - 2) / 2; + } + return getBytes(data).length; +} + +/** + * Returns a [[DataHexString]] by slicing %%data%% from the %%start%% + * offset to the %%end%% offset. + * + * By default %%start%% is 0 and %%end%% is the length of %%data%%. + */ +export function dataSlice(data: BytesLike, start?: number, end?: number): string { + const bytes = getBytes(data); + if (end != null && end > bytes.length) { + assert(false, "cannot slice beyond data bounds", "BUFFER_OVERRUN", { + buffer: bytes, + length: bytes.length, + offset: end + }); + } + return hexlify(bytes.slice(start == null ? 0 : start, end == null ? bytes.length : end)); +} + +/** + * Return the [[DataHexString]] result by stripping all **leading** + ** zero bytes from %%data%%. + */ +export function stripZerosLeft(data: BytesLike): string { + let bytes = hexlify(data).substring(2); + while (bytes.startsWith("00")) { + bytes = bytes.substring(2); + } + return "0x" + bytes; +} + +function zeroPad(data: BytesLike, length: number, left: boolean): string { + const bytes = getBytes(data); + assert(length >= bytes.length, "padding exceeds data length", "BUFFER_OVERRUN", { + buffer: new Uint8Array(bytes), + length: length, + offset: length + 1 + }); + + const result = new Uint8Array(length); + result.fill(0); + if (left) { + result.set(bytes, length - bytes.length); + } else { + result.set(bytes, 0); + } + + return hexlify(result); +} + +/** + * Return the [[DataHexString]] of %%data%% padded on the **left** + * to %%length%% bytes. + * + * If %%data%% already exceeds %%length%%, a [[BufferOverrunError]] is + * thrown. + * + * This pads data the same as **values** are in Solidity + * (e.g. ``uint128``). + */ +export function zeroPadValue(data: BytesLike, length: number): string { + return zeroPad(data, length, true); +} + +/** + * Return the [[DataHexString]] of %%data%% padded on the **right** + * to %%length%% bytes. + * + * If %%data%% already exceeds %%length%%, a [[BufferOverrunError]] is + * thrown. + * + * This pads data the same as **bytes** are in Solidity + * (e.g. ``bytes16``). + */ +export function zeroPadBytes(data: BytesLike, length: number): string { + return zeroPad(data, length, false); +} diff --git a/chain-api/src/ethers/utils/maths.ts b/chain-api/src/ethers/utils/maths.ts new file mode 100644 index 0000000000..7a170b88f8 --- /dev/null +++ b/chain-api/src/ethers/utils/maths.ts @@ -0,0 +1,262 @@ +/** + * Some mathematic operations. + * + * @_subsection: api/utils:Math Helpers [about-maths] + */ +import BN from "bn.js"; + +import { assert, assertArgument } from "../errors.js"; +import { hexlify, isBytesLike } from "./data.js"; +import type { BytesLike } from "./data.js"; + +/** + * Any type that can be used where a numeric value is needed. + */ +export type Numeric = number | bigint; + +/** + * Any type that can be used where a big number is needed. + */ +export type BigNumberish = string | Numeric | BN; + +const BN_0 = BigInt(0); +const BN_1 = BigInt(1); + +//const BN_Max256 = (BN_1 << BigInt(256)) - BN_1; + +// IEEE 754 support 53-bits of mantissa +const maxValue = 0x1fffffffffffff; + +/** + * Convert %%value%% from a twos-compliment representation of %%width%% + * bits to its value. + * + * If the highest bit is ``1``, the result will be negative. + */ +export function fromTwos(_value: BigNumberish, _width: Numeric): bigint { + const value = getUint(_value, "value"); + const width = BigInt(getNumber(_width, "width")); + + assert(value >> width === BN_0, "overflow", "NUMERIC_FAULT", { + operation: "fromTwos", + fault: "overflow", + value: _value + }); + + // Top bit set; treat as a negative value + if (value >> (width - BN_1)) { + const mask = (BN_1 << width) - BN_1; + return -((~value & mask) + BN_1); + } + + return value; +} + +/** + * Convert %%value%% to a twos-compliment representation of + * %%width%% bits. + * + * The result will always be positive. + */ +export function toTwos(_value: BigNumberish, _width: Numeric): bigint { + let value = getBigInt(_value, "value"); + const width = BigInt(getNumber(_width, "width")); + + const limit = BN_1 << (width - BN_1); + + if (value < BN_0) { + value = -value; + assert(value <= limit, "too low", "NUMERIC_FAULT", { + operation: "toTwos", + fault: "overflow", + value: _value + }); + const mask = (BN_1 << width) - BN_1; + return (~value & mask) + BN_1; + } else { + assert(value < limit, "too high", "NUMERIC_FAULT", { + operation: "toTwos", + fault: "overflow", + value: _value + }); + } + + return value; +} + +/** + * Mask %%value%% with a bitmask of %%bits%% ones. + */ +export function mask(_value: BigNumberish, _bits: Numeric): bigint { + const value = getUint(_value, "value"); + const bits = BigInt(getNumber(_bits, "bits")); + return value & ((BN_1 << bits) - BN_1); +} + +/** + * Gets a BigInt from %%value%%. If it is an invalid value for + * a BigInt, then an ArgumentError will be thrown for %%name%%. + */ +export function getBigInt(value: BigNumberish, name?: string): bigint { + switch (typeof value) { + case "bigint": + return value; + case "number": + assertArgument(Number.isInteger(value), "underflow", name || "value", value); + assertArgument(value >= -maxValue && value <= maxValue, "overflow", name || "value", value); + return BigInt(value); + case "string": + try { + if (value === "") { + throw new Error("empty string"); + } + if (value[0] === "-" && value[1] !== "-") { + return -BigInt(value.substring(1)); + } + return BigInt(value); + } catch (e: any) { + assertArgument(false, `invalid BigNumberish string: ${e.message}`, name || "value", value); + } + } + assertArgument(false, "invalid BigNumberish value", name || "value", value); +} + +/** + * Returns %%value%% as a bigint, validating it is valid as a bigint + * value and that it is positive. + */ +export function getUint(value: BigNumberish, name?: string): bigint { + const result = getBigInt(value, name); + assert(result >= BN_0, "unsigned value cannot be negative", "NUMERIC_FAULT", { + fault: "overflow", + operation: "getUint", + value + }); + return result; +} + +const Nibbles = "0123456789abcdef"; + +/* + * Converts %%value%% to a BigInt. If %%value%% is a Uint8Array, it + * is treated as Big Endian data. + */ +export function toBigInt(value: BigNumberish | Uint8Array): bigint { + if (value instanceof Uint8Array) { + let result = "0x0"; + for (const v of value) { + result += Nibbles[v >> 4]; + result += Nibbles[v & 0x0f]; + } + return BigInt(result); + } + + return getBigInt(value); +} + +/** + * Gets a //number// from %%value%%. If it is an invalid value for + * a //number//, then an ArgumentError will be thrown for %%name%%. + */ +export function getNumber(value: BigNumberish, name?: string): number { + switch (typeof value) { + case "bigint": + assertArgument(value >= -maxValue && value <= maxValue, "overflow", name || "value", value); + return Number(value); + case "number": + assertArgument(Number.isInteger(value), "underflow", name || "value", value); + assertArgument(value >= -maxValue && value <= maxValue, "overflow", name || "value", value); + return value; + case "string": + try { + if (value === "") { + throw new Error("empty string"); + } + return getNumber(BigInt(value), name); + } catch (e: any) { + assertArgument(false, `invalid numeric string: ${e.message}`, name || "value", value); + } + } + assertArgument(false, "invalid numeric value", name || "value", value); +} + +/** + * Converts %%value%% to a number. If %%value%% is a Uint8Array, it + * is treated as Big Endian data. Throws if the value is not safe. + */ +export function toNumber(value: BigNumberish | Uint8Array): number { + return getNumber(toBigInt(value)); +} + +/** + * Converts %%value%% to a Big Endian hexstring, optionally padded to + * %%width%% bytes. + */ +export function toBeHex(_value: BigNumberish, _width?: Numeric): string { + const value = getUint(_value, "value"); + + let result = value.toString(16); + + if (_width == null) { + // Ensure the value is of even length + if (result.length % 2) { + result = "0" + result; + } + } else { + const width = getNumber(_width, "width"); + assert(width * 2 >= result.length, `value exceeds width (${width} bytes)`, "NUMERIC_FAULT", { + operation: "toBeHex", + fault: "overflow", + value: _value + }); + + // Pad the value to the required width + while (result.length < width * 2) { + result = "0" + result; + } + } + + return "0x" + result; +} + +/** + * Converts %%value%% to a Big Endian Uint8Array. + */ +export function toBeArray(_value: BigNumberish): Uint8Array { + const value = getUint(_value, "value"); + + if (value === BN_0) { + return new Uint8Array([]); + } + + let hex = value.toString(16); + if (hex.length % 2) { + hex = "0" + hex; + } + + const result = new Uint8Array(hex.length / 2); + for (let i = 0; i < result.length; i++) { + const offset = i * 2; + result[i] = parseInt(hex.substring(offset, offset + 2), 16); + } + + return result; +} + +/** + * Returns a [[HexString]] for %%value%% safe to use as a //Quantity//. + * + * A //Quantity// does not have and leading 0 values unless the value is + * the literal value `0x0`. This is most commonly used for JSSON-RPC + * numeric values. + */ +export function toQuantity(value: BytesLike | BigNumberish): string { + let result = hexlify(isBytesLike(value) ? value : toBeArray(value)).substring(2); + while (result.startsWith("0")) { + result = result.substring(1); + } + if (result === "") { + result = "0"; + } + return "0x" + result; +} diff --git a/chain-api/src/ethers/utils/utf8.ts b/chain-api/src/ethers/utils/utf8.ts new file mode 100644 index 0000000000..3e896beedf --- /dev/null +++ b/chain-api/src/ethers/utils/utf8.ts @@ -0,0 +1,351 @@ +/** + * Using strings in Ethereum (or any security-basd system) requires + * additional care. These utilities attempt to mitigate some of the + * safety issues as well as provide the ability to recover and analyse + * strings. + * + * @_subsection api/utils:Strings and UTF-8 [about-strings] + */ +import { assertArgument, assertNormalize } from "../errors.js"; +import { BytesLike, getBytes } from "./data.js"; + +/////////////////////////////// + +/** + * The stanard normalization forms. + */ +export type UnicodeNormalizationForm = "NFC" | "NFD" | "NFKC" | "NFKD"; + +/** + * When using the UTF-8 error API the following errors can be intercepted + * and processed as the %%reason%% passed to the [[Utf8ErrorFunc]]. + * + * **``"UNEXPECTED_CONTINUE"``** - a continuation byte was present where there + * was nothing to continue. + * + * **``"BAD_PREFIX"``** - an invalid (non-continuation) byte to start a + * UTF-8 codepoint was found. + * + * **``"OVERRUN"``** - the string is too short to process the expected + * codepoint length. + * + * **``"MISSING_CONTINUE"``** - a missing continuation byte was expected but + * not found. The %%offset%% indicates the index the continuation byte + * was expected at. + * + * **``"OUT_OF_RANGE"``** - the computed code point is outside the range + * for UTF-8. The %%badCodepoint%% indicates the computed codepoint, which was + * outside the valid UTF-8 range. + * + * **``"UTF16_SURROGATE"``** - the UTF-8 strings contained a UTF-16 surrogate + * pair. The %%badCodepoint%% is the computed codepoint, which was inside the + * UTF-16 surrogate range. + * + * **``"OVERLONG"``** - the string is an overlong representation. The + * %%badCodepoint%% indicates the computed codepoint, which has already + * been bounds checked. + * + * + * @returns string + */ +export type Utf8ErrorReason = + | "UNEXPECTED_CONTINUE" + | "BAD_PREFIX" + | "OVERRUN" + | "MISSING_CONTINUE" + | "OUT_OF_RANGE" + | "UTF16_SURROGATE" + | "OVERLONG"; + +/** + * A callback that can be used with [[toUtf8String]] to analysis or + * recovery from invalid UTF-8 data. + * + * Parsing UTF-8 data is done through a simple Finite-State Machine (FSM) + * which calls the ``Utf8ErrorFunc`` if a fault is detected. + * + * The %%reason%% indicates where in the FSM execution the fault + * occurred and the %%offset%% indicates where the input failed. + * + * The %%bytes%% represents the raw UTF-8 data that was provided and + * %%output%% is the current array of UTF-8 code-points, which may + * be updated by the ``Utf8ErrorFunc``. + * + * The value of the %%badCodepoint%% depends on the %%reason%%. See + * [[Utf8ErrorReason]] for details. + * + * The function should return the number of bytes that should be skipped + * when control resumes to the FSM. + */ +export type Utf8ErrorFunc = ( + reason: Utf8ErrorReason, + offset: number, + bytes: Uint8Array, + output: Array, + badCodepoint?: number +) => number; + +function errorFunc( + reason: Utf8ErrorReason, + offset: number, + bytes: Uint8Array, + output: Array, + badCodepoint?: number +): number { + assertArgument(false, `invalid codepoint at offset ${offset}; ${reason}`, "bytes", bytes); +} + +function ignoreFunc( + reason: Utf8ErrorReason, + offset: number, + bytes: Uint8Array, + output: Array, + badCodepoint?: number +): number { + // If there is an invalid prefix (including stray continuation), skip any additional continuation bytes + if (reason === "BAD_PREFIX" || reason === "UNEXPECTED_CONTINUE") { + let i = 0; + for (let o = offset + 1; o < bytes.length; o++) { + if (bytes[o] >> 6 !== 0x02) { + break; + } + i++; + } + return i; + } + + // This byte runs us past the end of the string, so just jump to the end + // (but the first byte was read already read and therefore skipped) + if (reason === "OVERRUN") { + return bytes.length - offset - 1; + } + + // Nothing to skip + return 0; +} + +function replaceFunc( + reason: Utf8ErrorReason, + offset: number, + bytes: Uint8Array, + output: Array, + badCodepoint?: number +): number { + // Overlong representations are otherwise "valid" code points; just non-deistingtished + if (reason === "OVERLONG") { + assertArgument( + typeof badCodepoint === "number", + "invalid bad code point for replacement", + "badCodepoint", + badCodepoint + ); + output.push(badCodepoint); + return 0; + } + + // Put the replacement character into the output + output.push(0xfffd); + + // Otherwise, process as if ignoring errors + return ignoreFunc(reason, offset, bytes, output, badCodepoint); +} + +/** + * A handful of popular, built-in UTF-8 error handling strategies. + * + * **``"error"``** - throws on ANY illegal UTF-8 sequence or + * non-canonical (overlong) codepoints (this is the default) + * + * **``"ignore"``** - silently drops any illegal UTF-8 sequence + * and accepts non-canonical (overlong) codepoints + * + * **``"replace"``** - replace any illegal UTF-8 sequence with the + * UTF-8 replacement character (i.e. ``"\\ufffd"``) and accepts + * non-canonical (overlong) codepoints + * + * @returns: Record<"error" | "ignore" | "replace", Utf8ErrorFunc> + */ +export const Utf8ErrorFuncs: Readonly> = Object.freeze({ + error: errorFunc, + ignore: ignoreFunc, + replace: replaceFunc +}); + +// http://stackoverflow.com/questions/13356493/decode-utf-8-with-javascript#13691499 +function getUtf8CodePoints(_bytes: BytesLike, onError?: Utf8ErrorFunc): Array { + if (onError == null) { + onError = Utf8ErrorFuncs.error; + } + + const bytes = getBytes(_bytes, "bytes"); + + const result: Array = []; + let i = 0; + + // Invalid bytes are ignored + while (i < bytes.length) { + const c = bytes[i++]; + + // 0xxx xxxx + if (c >> 7 === 0) { + result.push(c); + continue; + } + + // Multibyte; how many bytes left for this character? + let extraLength: null | number = null; + let overlongMask: null | number = null; + + // 110x xxxx 10xx xxxx + if ((c & 0xe0) === 0xc0) { + extraLength = 1; + overlongMask = 0x7f; + + // 1110 xxxx 10xx xxxx 10xx xxxx + } else if ((c & 0xf0) === 0xe0) { + extraLength = 2; + overlongMask = 0x7ff; + + // 1111 0xxx 10xx xxxx 10xx xxxx 10xx xxxx + } else if ((c & 0xf8) === 0xf0) { + extraLength = 3; + overlongMask = 0xffff; + } else { + if ((c & 0xc0) === 0x80) { + i += onError("UNEXPECTED_CONTINUE", i - 1, bytes, result); + } else { + i += onError("BAD_PREFIX", i - 1, bytes, result); + } + continue; + } + + // Do we have enough bytes in our data? + if (i - 1 + extraLength >= bytes.length) { + i += onError("OVERRUN", i - 1, bytes, result); + continue; + } + + // Remove the length prefix from the char + let res: null | number = c & ((1 << (8 - extraLength - 1)) - 1); + + for (let j = 0; j < extraLength; j++) { + const nextChar = bytes[i]; + + // Invalid continuation byte + if ((nextChar & 0xc0) != 0x80) { + i += onError("MISSING_CONTINUE", i, bytes, result); + res = null; + break; + } + + res = (res << 6) | (nextChar & 0x3f); + i++; + } + + // See above loop for invalid continuation byte + if (res === null) { + continue; + } + + // Maximum code point + if (res > 0x10ffff) { + i += onError("OUT_OF_RANGE", i - 1 - extraLength, bytes, result, res); + continue; + } + + // Reserved for UTF-16 surrogate halves + if (res >= 0xd800 && res <= 0xdfff) { + i += onError("UTF16_SURROGATE", i - 1 - extraLength, bytes, result, res); + continue; + } + + // Check for overlong sequences (more bytes than needed) + if (res <= overlongMask) { + i += onError("OVERLONG", i - 1 - extraLength, bytes, result, res); + continue; + } + + result.push(res); + } + + return result; +} + +// http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array + +/** + * Returns the UTF-8 byte representation of %%str%%. + * + * If %%form%% is specified, the string is normalized. + */ +export function toUtf8Bytes(str: string, form?: UnicodeNormalizationForm): Uint8Array { + assertArgument(typeof str === "string", "invalid string value", "str", str); + + if (form != null) { + assertNormalize(form); + str = str.normalize(form); + } + + const result: Array = []; + for (let i = 0; i < str.length; i++) { + const c = str.charCodeAt(i); + + if (c < 0x80) { + result.push(c); + } else if (c < 0x800) { + result.push((c >> 6) | 0xc0); + result.push((c & 0x3f) | 0x80); + } else if ((c & 0xfc00) == 0xd800) { + i++; + const c2 = str.charCodeAt(i); + + assertArgument(i < str.length && (c2 & 0xfc00) === 0xdc00, "invalid surrogate pair", "str", str); + + // Surrogate Pair + const pair = 0x10000 + ((c & 0x03ff) << 10) + (c2 & 0x03ff); + result.push((pair >> 18) | 0xf0); + result.push(((pair >> 12) & 0x3f) | 0x80); + result.push(((pair >> 6) & 0x3f) | 0x80); + result.push((pair & 0x3f) | 0x80); + } else { + result.push((c >> 12) | 0xe0); + result.push(((c >> 6) & 0x3f) | 0x80); + result.push((c & 0x3f) | 0x80); + } + } + + return new Uint8Array(result); +} + +//export +function _toUtf8String(codePoints: Array): string { + return codePoints + .map((codePoint) => { + if (codePoint <= 0xffff) { + return String.fromCharCode(codePoint); + } + codePoint -= 0x10000; + return String.fromCharCode(((codePoint >> 10) & 0x3ff) + 0xd800, (codePoint & 0x3ff) + 0xdc00); + }) + .join(""); +} + +/** + * Returns the string represented by the UTF-8 data %%bytes%%. + * + * When %%onError%% function is specified, it is called on UTF-8 + * errors allowing recovery using the [[Utf8ErrorFunc]] API. + * (default: [error](Utf8ErrorFuncs)) + */ +export function toUtf8String(bytes: BytesLike, onError?: Utf8ErrorFunc): string { + return _toUtf8String(getUtf8CodePoints(bytes, onError)); +} + +/** + * Returns the UTF-8 code-points for %%str%%. + * + * If %%form%% is specified, the string is normalized. + */ +export function toUtf8CodePoints(str: string, form?: UnicodeNormalizationForm): Array { + return getUtf8CodePoints(toUtf8Bytes(str, form)); +} diff --git a/chain-api/src/utils/index.ts b/chain-api/src/utils/index.ts index 1294c7405e..a0985a049c 100644 --- a/chain-api/src/utils/index.ts +++ b/chain-api/src/utils/index.ts @@ -20,7 +20,7 @@ import signatures from "./signatures"; export * from "./chain-decorators"; export * from "./error"; -export * from "./type-utils"; +export * from "../ethers/type-utils"; export { deserialize, diff --git a/chain-api/src/utils/signatures.ts b/chain-api/src/utils/signatures.ts index 39b0096312..3e1b31ccb4 100644 --- a/chain-api/src/utils/signatures.ts +++ b/chain-api/src/utils/signatures.ts @@ -18,6 +18,7 @@ import { ec as EC, ec } from "elliptic"; import Signature from "elliptic/lib/elliptic/ec/signature"; import { keccak256 } from "js-sha3"; +import { TypedDataEncoder } from "../ethers/hash/typed-data"; import { ValidationFailedError } from "./error"; import serialize from "./serialize"; @@ -27,7 +28,31 @@ export class InvalidSignatureFormatError extends ValidationFailedError {} class InvalidDataHashError extends ValidationFailedError {} +// Type definitions +type EIP712Domain = Record; +type EIP712Types = Record; +type EIP712Value = Record; + +interface EIP712Object { + domain: EIP712Domain; + types: EIP712Types; + value: EIP712Value; +} + +// Type guard to check if an object is EIP712Object +function isEIP712Object(obj: object): obj is EIP712Object { + return obj && typeof obj === "object" && "domain" in obj && "types" in obj; +} + +function getEIP712PayloadToSign(obj: EIP712Object): string { + return TypedDataEncoder.encode(obj.domain, obj.types, obj); +} + function getPayloadToSign(obj: object): string { + if (isEIP712Object(obj)) { + return getEIP712PayloadToSign(obj); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { signature, trace, ...plain } = instanceToPlain(obj); return serialize(plain); @@ -362,7 +387,7 @@ function getDERSignature(obj: object, privateKey: Buffer): string { return signSecp256k1(calculateKeccak256(data), privateKey, "DER"); } -function recoverPublicKey(signature: string, obj: object, prefix = ""): string { +function recoverPublicKey(signature: string, obj: object, prefix?: string): string { const signatureObj = parseSecp256k1Signature(signature); const recoveryParam = signatureObj.recoveryParam; if (recoveryParam === undefined) { @@ -370,8 +395,13 @@ function recoverPublicKey(signature: string, obj: object, prefix = ""): string { throw new InvalidSignatureFormatError(message, { signature }); } - const data = Buffer.concat([Buffer.from(prefix), Buffer.from(getPayloadToSign(obj))]); + const dataString = getPayloadToSign(obj); + const data = dataString.startsWith("0x") + ? Buffer.from(dataString.slice(2), "hex") + : Buffer.from((prefix ?? "") + dataString); + const dataHash = Buffer.from(keccak256.hex(data), "hex"); + const publicKeyObj = ecSecp256k1.recoverPubKey(dataHash, signatureObj, recoveryParam); return publicKeyObj.encode("hex", false); } diff --git a/chain-connect/src/GalachainConnectClient.spec.ts b/chain-connect/src/GalachainConnectClient.spec.ts index bea87b01ec..2db880562a 100644 --- a/chain-connect/src/GalachainConnectClient.spec.ts +++ b/chain-connect/src/GalachainConnectClient.spec.ts @@ -12,12 +12,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TokenInstanceKey } from "@gala-chain/api"; -import { TransferTokenDto } from "@gala-chain/api"; -import { plainToInstance } from "class-transformer"; +import { LockTokenRequestParams, TransferTokenParams, signatures } from "@gala-chain/api"; +import { ethers } from "ethers"; import { EventEmitter } from "events"; import { GalachainConnectClient } from "./GalachainConnectClient"; +import { generateEIP712Types } from "./Utils"; global.fetch = jest.fn((url: string, options?: Record) => Promise.resolve({ @@ -39,6 +39,8 @@ class EthereumMock extends EventEmitter { return Promise.resolve([sampleAddr]); } else if (request.method === "personal_sign") { return Promise.resolve("sampleSignature"); + } else if (request.method === "eth_signTypedData_v4") { + return Promise.resolve("sampleSignature"); } else { throw new Error(`Method not mocked: ${request.method}`); } @@ -48,18 +50,18 @@ window.ethereum = new EthereumMock(); describe("GalachainConnectClient", () => { it("test full flow", async () => { - const dto = plainToInstance(TransferTokenDto, { + const dto: TransferTokenParams = { quantity: "1", to: "client|63580d94c574ad78b121c267", - tokenInstance: plainToInstance(TokenInstanceKey, { + tokenInstance: { additionalKey: "none", category: "Unit", collection: "GALA", instance: "0", type: "none" - }), + }, uniqueKey: "26d4122e-34c8-4639-baa6-4382b398e68e" - }); + }; // call connect const client = new GalachainConnectClient("https://example.com"); @@ -74,7 +76,7 @@ describe("GalachainConnectClient", () => { }, Request: { options: { - body: '{"prefix":"\\u0019Ethereum Signed Message:\\n279","quantity":{"c":[1],"e":0,"s":1},"signature":"sampleSignature","to":"client|63580d94c574ad78b121c267","tokenInstance":{"additionalKey":"none","category":"Unit","collection":"GALA","instance":"0","type":"none"},"uniqueKey":"26d4122e-34c8-4639-baa6-4382b398e68e"}', + body: '{"domain":{"name":"Galachain"},"prefix":"\\u0019Ethereum Signed Message:\\n261","quantity":"1","signature":"sampleSignature","to":"client|63580d94c574ad78b121c267","tokenInstance":{"additionalKey":"none","category":"Unit","collection":"GALA","instance":"0","type":"none"},"types":{"TransferToken":[{"name":"quantity","type":"string"},{"name":"to","type":"string"},{"name":"tokenInstance","type":"tokenInstance"},{"name":"uniqueKey","type":"string"}],"tokenInstance":[{"name":"additionalKey","type":"string"},{"name":"category","type":"string"},{"name":"collection","type":"string"},{"name":"instance","type":"string"},{"name":"type","type":"string"}]},"uniqueKey":"26d4122e-34c8-4639-baa6-4382b398e68e"}', headers: { "Content-Type": "application/json" }, @@ -99,4 +101,118 @@ describe("GalachainConnectClient", () => { expect(consoleSpy).toHaveBeenCalledWith("Accounts changed:", accounts); consoleSpy.mockRestore(); }); + + it("should properly recover signature", async () => { + const params: LockTokenRequestParams = { + quantity: "1", + tokenInstance: { + collection: "GALA", + category: "Unit", + additionalKey: "none", + instance: "0", + type: "none" + } + }; + + const privateKey = "0x311e3750b1b698e70a2b37fd08b68fdcb389f955faea163f6ffa5be65cd0c251"; + + const client = new GalachainConnectClient("https://example.com"); + + const prefix = client.calculatePersonalSignPrefix(params); + const prefixedPayload = { prefix, ...params }; + const wallet = new ethers.Wallet(privateKey); + const dto = signatures.getPayloadToSign(prefixedPayload); + + const signature = await wallet.signMessage(dto); + console.log(signature); + + const publickKey = signatures.recoverPublicKey(signature, { ...prefixedPayload, signature }, prefix); + const ethAddress = signatures.getEthAddress(publickKey); + expect(ethAddress).toBe("e737c4D3072DA526f3566999e0434EAD423d06ec"); + }); + it("should properly recover signature", async () => { + const params: LockTokenRequestParams = { + quantity: "1", + tokenInstance: { + collection: "GALA", + category: "Unit", + additionalKey: "none", + instance: "0", + type: "none" + } + }; + + const privateKey = "0x311e3750b1b698e70a2b37fd08b68fdcb389f955faea163f6ffa5be65cd0c251"; + + const client = new GalachainConnectClient("https://example.com"); + + const prefix = client.calculatePersonalSignPrefix(params); + const prefixedPayload = { prefix, ...params }; + const wallet = new ethers.Wallet(privateKey); + const dto = signatures.getPayloadToSign(prefixedPayload); + + const signature = await wallet.signMessage(dto); + console.log(signature); + + const publickKey = signatures.recoverPublicKey(signature, { ...prefixedPayload, signature }, prefix); + const ethAddress = signatures.getEthAddress(publickKey); + expect(ethAddress).toBe("e737c4D3072DA526f3566999e0434EAD423d06ec"); + }); + it("should properly recover signature for typed signing", async () => { + const params: LockTokenRequestParams = { + quantity: "1", + tokenInstance: { + collection: "GALA", + category: "Unit", + additionalKey: "none", + instance: "0", + type: "none" + } + }; + + const privateKey = "0x311e3750b1b698e70a2b37fd08b68fdcb389f955faea163f6ffa5be65cd0c251"; + + const client = new GalachainConnectClient("https://example.com"); + + const prefix = client.calculatePersonalSignPrefix(params); + const prefixedPayload = { prefix, ...params }; + const wallet = new ethers.Wallet(privateKey); + + const types = generateEIP712Types("LockTokenRequest", prefixedPayload); + + const signature = await wallet.signTypedData({}, types, prefixedPayload); + + const publicKey = ethers.verifyTypedData({}, types, prefixedPayload, signature); + expect(publicKey).toBe("0xe737c4D3072DA526f3566999e0434EAD423d06ec"); + }); + it("should properly recover signature for typed signing using signature utils", async () => { + const params: LockTokenRequestParams = { + quantity: "1", + tokenInstance: { + collection: "GALA", + category: "Unit", + additionalKey: "none", + instance: "0", + type: "none" + } + }; + + const privateKey = "0x311e3750b1b698e70a2b37fd08b68fdcb389f955faea163f6ffa5be65cd0c251"; + + const client = new GalachainConnectClient("https://example.com"); + + const prefix = client.calculatePersonalSignPrefix(params); + const prefixedPayload = { prefix, ...params }; + const wallet = new ethers.Wallet(privateKey); + + const types = generateEIP712Types("LockTokenRequest", params); + + const domain = {}; + + const signature = await wallet.signTypedData(domain, types, prefixedPayload); + + const publicKey = signatures.recoverPublicKey(signature, { ...prefixedPayload, types, domain }); + const ethAddress = signatures.getEthAddress(publicKey); + expect(ethAddress).toBe("e737c4D3072DA526f3566999e0434EAD423d06ec"); + }); }); diff --git a/chain-connect/src/GalachainConnectClient.ts b/chain-connect/src/GalachainConnectClient.ts index 8c7ce7c7aa..c579af0203 100644 --- a/chain-connect/src/GalachainConnectClient.ts +++ b/chain-connect/src/GalachainConnectClient.ts @@ -15,6 +15,8 @@ import { ChainCallDTO, ConstructorArgs, serialize, signatures } from "@gala-chain/api"; import { BrowserProvider, Eip1193Provider, getAddress } from "ethers"; +import { generateEIP712Types } from "./Utils"; + interface ExtendedEip1193Provider extends Eip1193Provider { on(event: "accountsChanged", handler: (accounts: string[]) => void): void; } @@ -143,14 +145,17 @@ export class GalachainConnectClient extends CustomEventEmitter { try { if (sign === true) { + const domain = { name: "Galachain" }; + const types = generateEIP712Types(method, payload); + const prefix = this.calculatePersonalSignPrefix(payload); - const prefixedPayload = { ...payload, prefix }; - const dto = signatures.getPayloadToSign(prefixedPayload); + const prefixedPayload = { prefix, ...payload }; const signer = await this.#provider.getSigner(); - const signature = await signer.provider.send("personal_sign", [this.#ethAddress, dto]); - return await this.submit(url, method, { ...prefixedPayload, signature }, headers); + const signature = await signer.signTypedData(domain, types, prefixedPayload); + + return await this.submit(url, method, { ...prefixedPayload, signature, types, domain }, headers); } return await this.submit(url, method, payload, headers); @@ -189,7 +194,7 @@ export class GalachainConnectClient extends CustomEventEmitter { return id ? { Hash: id, ...data } : data; } - private calculatePersonalSignPrefix(payload: object): string { + public calculatePersonalSignPrefix(payload: object): string { const payloadLength = signatures.getPayloadToSign(payload).length; const prefix = "\u0019Ethereum Signed Message:\n" + payloadLength; diff --git a/chain-connect/src/Utils.spec.ts b/chain-connect/src/Utils.spec.ts new file mode 100644 index 0000000000..7484f5cdb5 --- /dev/null +++ b/chain-connect/src/Utils.spec.ts @@ -0,0 +1,160 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { LockTokenRequestParams, LockTokensParams } from "@gala-chain/api"; +import { ethers } from "ethers"; + +import { generateEIP712Types } from "./Utils"; + +describe("EIP-712 Signing", () => { + it("should correctly generate EIP-712 types and values and sign the data for single types", async () => { + const params: LockTokenRequestParams = { + quantity: "1", + tokenInstance: { + collection: "GALA", + category: "Unit", + additionalKey: "none", + instance: "0", + type: "none" + } + }; + const types = generateEIP712Types("LockTokenRequest", params); + + const expectedTypes = { + LockTokenRequest: [ + { name: "quantity", type: "string" }, + { name: "tokenInstance", type: "tokenInstance" } + ], + tokenInstance: [ + { name: "collection", type: "string" }, + { name: "category", type: "string" }, + { name: "additionalKey", type: "string" }, + { name: "instance", type: "string" }, + { name: "type", type: "string" } + ] + }; + + expect(types).toMatchObject(expectedTypes); + + console.log("EIP-712 Types:", types); + + const subchainRpcUrl = "https://rpc.foo"; + const privateKey = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + const provider = new ethers.JsonRpcProvider(subchainRpcUrl); + const wallet = new ethers.Wallet(privateKey, provider); + + const signature = await wallet.signTypedData({}, types, params); + + console.log("Signature:", signature); + + // Assert that the signature is a valid string + expect(typeof signature).toBe("string"); + expect(signature).toMatch(/^0x[a-fA-F0-9]{130}$/); // Simple regex to match the format of a signature + }); + it("should correctly generate EIP-712 types and values and sign the data for arrays", async () => { + const params: LockTokensParams = { + tokenInstances: [ + { + quantity: "1", + tokenInstanceKey: { + collection: "GALA", + category: "Unit", + additionalKey: "none", + instance: "0", + type: "none" + } + } + ] + }; + const types = generateEIP712Types("LockTokensRequest", params); + + const expectedTypes = { + LockTokensRequest: [{ name: "tokenInstances", type: "tokenInstances[]" }], + tokenInstances: [ + { name: "quantity", type: "string" }, + { name: "tokenInstanceKey", type: "tokenInstanceKey" } + ], + tokenInstanceKey: [ + { name: "collection", type: "string" }, + { name: "category", type: "string" }, + { name: "additionalKey", type: "string" }, + { name: "instance", type: "string" }, + { name: "type", type: "string" } + ] + }; + + expect(types).toMatchObject(expectedTypes); + + console.log("EIP-712 Types:", types); + + const subchainRpcUrl = "https://rpc.foo"; + const privateKey = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + const provider = new ethers.JsonRpcProvider(subchainRpcUrl); + const wallet = new ethers.Wallet(privateKey, provider); + + const signature = await wallet.signTypedData({}, types, params); + + console.log("Signature:", signature); + + // Assert that the signature is a valid string + expect(typeof signature).toBe("string"); + expect(signature).toMatch(/^0x[a-fA-F0-9]{130}$/); // Simple regex to match the format of a signature + }); + it("should correctly generate EIP-712 types and values and sign the data for arrays with multiple values", async () => { + const params: LockTokensParams = { + tokenInstances: [ + { + quantity: "1", + tokenInstanceKey: { + collection: "GALA", + category: "Unit", + additionalKey: "none", + instance: "0", + type: "none" + } + }, + { + quantity: "1", + tokenInstanceKey: { + collection: "GALA", + category: "Unit", + additionalKey: "none", + instance: "0", + type: "none" + } + } + ] + }; + const types = generateEIP712Types("LockTokensRequest", params); + + const expectedTypes = { + LockTokensRequest: [{ name: "tokenInstances", type: "tokenInstances[]" }], + tokenInstances: [ + { name: "quantity", type: "string" }, + { name: "tokenInstanceKey", type: "tokenInstanceKey" } + ], + tokenInstanceKey: [ + { name: "collection", type: "string" }, + { name: "category", type: "string" }, + { name: "additionalKey", type: "string" }, + { name: "instance", type: "string" }, + { name: "type", type: "string" } + ] + }; + + expect(types).toMatchObject(expectedTypes); + }); +}); diff --git a/chain-connect/src/Utils.ts b/chain-connect/src/Utils.ts new file mode 100644 index 0000000000..c02fc27a93 --- /dev/null +++ b/chain-connect/src/Utils.ts @@ -0,0 +1,85 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +type EIP712Types = Record>; +type EIP712Value = Record; + +export function generateEIP712Types(typeName: string, params: T): EIP712Types { + const types: EIP712Types = {}; + types[typeName] = []; + + function addField(name: string, fieldValue: unknown, parentTypeName: string, onlyGetType = false) { + if (Array.isArray(fieldValue)) { + //Take the type of the first element + addField(name, fieldValue[0], parentTypeName, true); + if (!onlyGetType) types[parentTypeName].push({ name, type: name + "[]" }); + } else if (typeof fieldValue === "object" && fieldValue !== null) { + if (types[name]) { + throw new Error("Name collisions not yet supported"); + } + types[name] = []; + Object.entries(fieldValue).forEach(([key, value]) => { + addField(key, value, name); + }); + if (!onlyGetType) types[parentTypeName].push({ name, type: name }); + } else { + let eipType: string; + switch (typeof fieldValue) { + case "string": + eipType = "string"; + break; + case "number": + eipType = "uint256"; + break; + case "boolean": + eipType = "bool"; + break; + default: + throw new Error(`Unsupported type, ${typeof fieldValue}, value: ${fieldValue}`); + } + if (!onlyGetType) types[parentTypeName].push({ name, type: eipType }); + } + } + + Object.entries(params as Record).forEach(([key, value]) => { + addField(key, value, typeName); + }); + + return types; +} + +export function capitalizeFirstLetter(string: string): string { + return string.charAt(0).toUpperCase() + string.slice(1); +} + +export function generateEIP712Value(params: T): EIP712Value { + const value: EIP712Value = {}; + + function addField(name: string, field: unknown) { + if (Array.isArray(field) || (typeof field === "object" && field !== null)) { + Object.entries(field).forEach(([key, val]) => { + addField(`${name}${capitalizeFirstLetter(key)}`, val); + }); + } else { + value[name] = field; + } + } + + Object.entries(params as Record).forEach(([key, field]) => { + addField(key, field); + }); + + return value; +} diff --git a/licenses/licenses.csv b/licenses/licenses.csv index c0ee642bf5..fe6a5301be 100644 --- a/licenses/licenses.csv +++ b/licenses/licenses.csv @@ -1073,7 +1073,7 @@ "estree-walker@3.0.3","estree-walker","3.0.3","https://github.com/Rich-Harris/estree-walker","Traverse an ESTree-compliant AST","MIT" "esutils@2.0.3","esutils","2.0.3","https://github.com/estools/esutils","utility box for ECMAScript language tools","BSD-2-Clause" "etag@1.8.1","etag","1.8.1","https://github.com/jshttp/etag","Create simple HTTP ETags","MIT" -"ethers@6.13.1","ethers","6.13.1","https://github.com/ethers-io/ethers.js","A complete and compact Ethereum library, for dapps, wallets and any other tools.","MIT" +"ethers@6.13.2","ethers","6.13.2","https://github.com/ethers-io/ethers.js","A complete and compact Ethereum library, for dapps, wallets and any other tools.","MIT" "event-lite@0.1.3","event-lite","0.1.3","https://github.com/kawanet/event-lite","Light-weight EventEmitter (less than 1KB when gzipped)","MIT" "eventemitter3@4.0.7","eventemitter3","4.0.7","https://github.com/primus/eventemitter3","EventEmitter3 focuses on performance while maintaining a Node.js AND browser compatible interface.","MIT" "execa@5.1.1","execa","5.1.1","https://github.com/sindresorhus/execa","Process execution for humans","MIT" diff --git a/package-lock.json b/package-lock.json index 377f61a280..1f913b685c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,8 +85,7 @@ "openapi3-ts": "^3.2.0", "reflect-metadata": "^0.1.13", "tslib": "^2.6.2" - }, - "devDependencies": {} + } }, "chain-cli": { "name": "@gala-chain/cli", @@ -1665,7 +1664,8 @@ }, "node_modules/@adraffy/ens-normalize": { "version": "1.10.1", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==" }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", @@ -6183,7 +6183,8 @@ }, "node_modules/@noble/curves": { "version": "1.2.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", "dependencies": { "@noble/hashes": "1.3.2" }, @@ -6193,7 +6194,8 @@ }, "node_modules/@noble/hashes": { "version": "1.3.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", "engines": { "node": ">= 16" }, @@ -18071,7 +18073,8 @@ }, "node_modules/aes-js": { "version": "4.0.0-beta.5", - "license": "MIT" + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==" }, "node_modules/agent-base": { "version": "6.0.2", @@ -22700,7 +22703,9 @@ } }, "node_modules/ethers": { - "version": "6.13.1", + "version": "6.13.2", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.13.2.tgz", + "integrity": "sha512-9VkriTTed+/27BGuY1s0hf441kqwHJ1wtN2edksEtiRvXx+soxRX3iSXTfFqq2+YwrOqbDoTHjIhQnjJRlzKmg==", "funding": [ { "type": "individual", @@ -22711,7 +22716,6 @@ "url": "https://www.buymeacoffee.com/ricmoo" } ], - "license": "MIT", "dependencies": { "@adraffy/ens-normalize": "1.10.1", "@noble/curves": "1.2.0", @@ -22727,11 +22731,13 @@ }, "node_modules/ethers/node_modules/@types/node": { "version": "18.15.13", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.13.tgz", + "integrity": "sha512-N+0kuo9KgrUQ1Sn/ifDXsvg0TTleP7rIy4zOBGECxAljqvqfqpTfzx0Q1NUedOixRMBfe2Whhb056a42cWs26Q==" }, "node_modules/ethers/node_modules/tslib": { "version": "2.4.0", - "license": "0BSD" + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" }, "node_modules/event-lite": { "version": "0.1.3", diff --git a/tsconfig.base.json b/tsconfig.base.json index 1a891f4fd8..f3ccb7b308 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -13,7 +13,7 @@ "experimentalDecorators": true, "declaration": true, "declarationMap": false, - "sourceMap": false, + "sourceMap": true, "importHelpers": true, "esModuleInterop": true, "strictNullChecks": true, diff --git a/verify_copyright.sh b/verify_copyright.sh index b0d2e1fdc2..755d31d4be 100755 --- a/verify_copyright.sh +++ b/verify_copyright.sh @@ -19,8 +19,8 @@ copyright_pattern="Copyright (c|\(c\)) Gala Games Inc" # Find all files that need copyright in the current repository -# respecting .gitignore -files=$(git ls-files | grep -E '\.js$|\.ts$|\.sh$') +# respecting .gitignore and ignoring any ethers folder +files=$(git ls-files | grep -E '\.js$|\.ts$|\.sh$' | grep -v '/ethers/') # Array to store files missing the copyright header missing_copyright_files=()