diff --git a/packages/contracts/source/contracts/crypto/transactions.ts b/packages/contracts/source/contracts/crypto/transactions.ts index adf151c7aa..a8756b201f 100644 --- a/packages/contracts/source/contracts/crypto/transactions.ts +++ b/packages/contracts/source/contracts/crypto/transactions.ts @@ -35,6 +35,8 @@ export interface TransactionData { v?: number; r?: string; s?: string; + rlpPrefix?: number; + legacySecondSignature?: string; transactionIndex?: number; @@ -64,6 +66,7 @@ export interface TransactionJson { v?: number; r?: string; s?: string; + rlpPrefix?: number; transactionIndex?: number; gasUsed?: number; diff --git a/packages/crypto-transaction/source/deserializer.ts b/packages/crypto-transaction/source/deserializer.ts index b5771b2e0b..ec604b35ab 100644 --- a/packages/crypto-transaction/source/deserializer.ts +++ b/packages/crypto-transaction/source/deserializer.ts @@ -1,20 +1,55 @@ import { inject, injectable } from "@mainsail/container"; import { Contracts, Identifiers } from "@mainsail/contracts"; import { BigNumber } from "@mainsail/utils"; -import { decodeRlp, ethers, getAddress } from "ethers"; +import { decodeRlp, ethers, getAddress, RlpStructuredData } from "ethers"; @injectable() export class Deserializer implements Contracts.Crypto.TransactionDeserializer { @inject(Identifiers.Cryptography.Transaction.TypeFactory) private readonly transactionTypeFactory!: Contracts.Transactions.TransactionTypeFactory; + @inject(Identifiers.Cryptography.Configuration) + private readonly configuration!: Contracts.Crypto.Configuration; + public async deserialize(serialized: Buffer | string): Promise { const data = {} as Contracts.Crypto.TransactionData; - const encodedRlp = - "0x" + (typeof serialized === "string" ? serialized.slice(2) : serialized.toString("hex").slice(2)); + let rlpBuffer = + typeof serialized === "string" + ? Buffer.from(serialized.startsWith("0x") ? serialized.slice(2) : serialized, "hex") + : serialized; + + // Remove type prefix (e.g. 02) if it's a EIP1559 tx (`decodeRlp` expects input to be without) + let prefix: number | undefined = undefined; + if (rlpBuffer[0] < 0xc0) { + prefix = rlpBuffer[0]; + if (prefix !== 0x02) { + throw new Error("expected EIP1559 transaction"); + } + + rlpBuffer = rlpBuffer.subarray(1); + } + + const decoded = decodeRlp(new Uint8Array(rlpBuffer)); + + if (prefix !== undefined) { + this.#decodeEIP1559Transaction(decoded, data); + serialized = Buffer.concat([Buffer.from([prefix]), rlpBuffer]); + } else { + this.#decodeLegacyTransaction(decoded, data); + serialized = rlpBuffer; + } - const decoded = decodeRlp(encodedRlp); + const instance: Contracts.Crypto.Transaction = this.transactionTypeFactory.create(data); + instance.serialized = serialized; + + return instance; + } + + #decodeEIP1559Transaction(decoded: RlpStructuredData, data: Contracts.Crypto.TransactionData): void { + if (decoded.length < 9 || decoded.length > 13) { + throw new Error("RLP data out of range"); + } const recipientAddressRaw = this.#parseAddress(decoded[5].toString()); @@ -31,6 +66,7 @@ export class Deserializer implements Contracts.Crypto.TransactionDeserializer { data.to = recipientAddressRaw ? getAddress(recipientAddressRaw) : undefined; data.value = this.#parseBigNumber(decoded[6].toString()); data.data = this.#parseData(decoded[7].toString()); + data.rlpPrefix = 0x02; // Signature if (decoded.length >= 12) { @@ -38,18 +74,48 @@ export class Deserializer implements Contracts.Crypto.TransactionDeserializer { data.r = decoded[10].toString().slice(2); data.s = decoded[11].toString().slice(2); - // Legacy second signature + // Legacy second signature is only supported for EIP-1559 transactions if (decoded.length === 13) { data.legacySecondSignature = decoded[12].toString().slice(2); } } + } - const instance: Contracts.Crypto.Transaction = this.transactionTypeFactory.create(data); + #decodeLegacyTransaction(decoded: RlpStructuredData, data: Contracts.Crypto.TransactionData): void { + if (decoded.length < 6 || decoded.length > 9) { + throw new Error("legacy RLP data out of range"); + } - const eip1559Prefix = "02"; // marker for Type 2 (EIP1559) transaction which is the standard nowadays - instance.serialized = Buffer.from(`${eip1559Prefix}${encodedRlp.slice(2)}`, "hex"); + // [nonce, gasPrice, gasLimit, to, value, data, v, r, s]; + data.nonce = BigNumber.make(this.#parseNumber(decoded[0].toString())); + data.gasPrice = this.#parseNumber(decoded[1].toString()); + data.gas = this.#parseNumber(decoded[2].toString()); - return instance; + const recipientAddressRaw = this.#parseAddress(decoded[3].toString()); + data.to = recipientAddressRaw ? getAddress(recipientAddressRaw) : undefined; + + data.value = this.#parseBigNumber(decoded[4].toString()); + data.data = this.#parseData(decoded[5].toString()); + + // NOTE: + // The chainId is encoded in 'v' which is part of the optional signature. + // In the case of absence default to the config for the chainId. + + // Signature + if (decoded.length >= 9) { + const v = this.#parseNumber(decoded[6].toString()); + const chainId = Math.floor((v - 35) / 2); + + data.network = chainId; + + const normalizedV = v - (chainId * 2 + 35); + data.v = normalizedV; + + data.r = decoded[7].toString().slice(2); + data.s = decoded[8].toString().slice(2); + } else { + data.network = this.configuration.get("network.chainId"); + } } #parseNumber(value: string): number { diff --git a/packages/crypto-transaction/source/serializer.ts b/packages/crypto-transaction/source/serializer.ts index b896968824..320ba2adc2 100644 --- a/packages/crypto-transaction/source/serializer.ts +++ b/packages/crypto-transaction/source/serializer.ts @@ -8,33 +8,69 @@ export class Serializer implements Contracts.Crypto.TransactionSerializer { transaction: Contracts.Crypto.Transaction, options: Contracts.Crypto.SerializeOptions = {}, ): Promise { - const fields = [ - toBeArray(transaction.data.network), // chainId - 0 - toBeArray(transaction.data.nonce.toBigInt()), // nonce - 1 - toBeArray(0), // maxPriorityFeePerGas - 2 - toBeArray(transaction.data.gasPrice), // maxFeePerGas - 3 - toBeArray(transaction.data.gas), // gasLimit - 4 - transaction.data.to || "0x", // to - 5 - toBeArray(transaction.data.value.toBigInt()), // value - 6 - transaction.data.data.startsWith("0x") ? transaction.data.data : `0x${transaction.data.data}`, // data - 7 - [], //accessList - 8 - ]; + let fields: Array = []; - if (transaction.data.v !== undefined && transaction.data.r && transaction.data.s && !options.excludeSignature) { - // 9, 10, 11 - fields.push(toBeArray(transaction.data.v), `0x${transaction.data.r}`, `0x${transaction.data.s}`); + const { rlpPrefix } = transaction.data; - if (transaction.data.legacySecondSignature) { - // 12 - fields.push(`0x${transaction.data.legacySecondSignature}`); + if (rlpPrefix) { + if (rlpPrefix !== 0x02) { + throw new Error("expected EIP1559 transaction"); } + + fields = [ + toBeArray(transaction.data.network), // chainId - 0 + toBeArray(transaction.data.nonce.toBigInt()), // nonce - 1 + toBeArray(0), // maxPriorityFeePerGas - 2 + toBeArray(transaction.data.gasPrice), // maxFeePerGas - 3 + toBeArray(transaction.data.gas), // gasLimit - 4 + transaction.data.to || "0x", // to - 5 + toBeArray(transaction.data.value.toBigInt()), // value - 6 + transaction.data.data.startsWith("0x") ? transaction.data.data : `0x${transaction.data.data}`, // data - 7 + [], //accessList - 8 + ]; + } else { + // Legacy encoding + fields = [ + toBeArray(transaction.data.nonce.toBigInt()), + toBeArray(transaction.data.gasPrice), + toBeArray(transaction.data.gas), + transaction.data.to || "0x", + toBeArray(transaction.data.value.toBigInt()), + transaction.data.data.startsWith("0x") ? transaction.data.data : `0x${transaction.data.data}`, + + // EIP-155 also requires chainId (`v`) + toBeArray(transaction.data.network), // v + toBeArray(0), // r + toBeArray(0), // s + ]; } - const rlpEncoded = encodeRlp(fields); + if (transaction.data.v !== undefined && transaction.data.r && transaction.data.s && !options.excludeSignature) { + if (rlpPrefix) { + // 9, 10, 11 + fields.push(toBeArray(transaction.data.v), `0x${transaction.data.r}`, `0x${transaction.data.s}`); + + if (transaction.data.legacySecondSignature) { + // 12 + fields.push(`0x${transaction.data.legacySecondSignature}`); + } + } else { + // Legacy with EIP-155 + const normalizedV = transaction.data.v; + const v = transaction.data.network * 2 + 35 + normalizedV; - const eip1559Prefix = "02"; // marker for Type 2 (EIP1559) transaction which is the standard nowadays + fields[6] = toBeArray(v); + fields[7] = `0x${transaction.data.r}`; + fields[8] = `0x${transaction.data.s}`; + } + } + + let encoded = Buffer.from(encodeRlp(fields).slice(2), "hex"); + if (rlpPrefix) { + encoded = Buffer.concat([Buffer.from([rlpPrefix]), encoded]); + } - transaction.serialized = Buffer.from(`${eip1559Prefix}${rlpEncoded.slice(2)}`, "hex"); + transaction.serialized = encoded; return transaction.serialized; } diff --git a/packages/crypto-transaction/source/utilities.ts b/packages/crypto-transaction/source/utilities.ts index a109045fe1..ce7583c537 100644 --- a/packages/crypto-transaction/source/utilities.ts +++ b/packages/crypto-transaction/source/utilities.ts @@ -19,31 +19,68 @@ export class Utils implements Contracts.Crypto.TransactionUtilities { transaction: Contracts.Crypto.TransactionData, options?: Contracts.Crypto.SerializeOptions, ): Promise { + let fields: Array = []; + + const { rlpPrefix } = transaction; + // based on EIP1559 encoding - const fields = [ - toBeArray(transaction.network), - toBeArray(transaction.nonce.toBigInt()), - toBeArray(0), // maxPriorityFeePerGas - toBeArray(transaction.gasPrice), // maxFeePerGas - toBeArray(transaction.gas), - transaction.to || "0x", - toBeArray(transaction.value.toBigInt()), - transaction.data.startsWith("0x") ? transaction.data : `0x${transaction.data}`, - [], // accessList is unused - ]; + if (rlpPrefix) { + if (rlpPrefix !== 0x02) { + throw new Error("expected EIP1559 transaction"); + } + + fields = [ + toBeArray(transaction.network), + toBeArray(transaction.nonce.toBigInt()), + toBeArray(0), // maxPriorityFeePerGas + toBeArray(transaction.gasPrice), // maxFeePerGas + toBeArray(transaction.gas), + transaction.to || "0x", + toBeArray(transaction.value.toBigInt()), + transaction.data.startsWith("0x") ? transaction.data : `0x${transaction.data}`, + [], // accessList is unused + ]; + } else { + // Legacy encoding + fields = [ + toBeArray(transaction.nonce.toBigInt()), + toBeArray(transaction.gasPrice), + toBeArray(transaction.gas), + transaction.to || "0x", + toBeArray(transaction.value.toBigInt()), + transaction.data.startsWith("0x") ? transaction.data : `0x${transaction.data}`, + + // EIP-155 also requires chainId (`v`) + toBeArray(transaction.network), // v + toBeArray(0), // r + toBeArray(0), // s + ]; + } if (options && !options.excludeSignature) { assert.number(transaction.v); assert.string(transaction.r); assert.string(transaction.s); - fields.push(toBeArray(transaction.v), `0x${transaction.r}`, `0x${transaction.s}`); + if (rlpPrefix) { + fields.push(toBeArray(transaction.v), `0x${transaction.r}`, `0x${transaction.s}`); + } else { + // Legacy with EIP-155 + const normalizedV = transaction.v; + const v = transaction.network * 2 + 35 + normalizedV; + + fields[6] = toBeArray(v); + fields[7] = `0x${transaction.r}`; + fields[8] = `0x${transaction.s}`; + } } - const eip1559Prefix = "02"; // marker for Type 2 (EIP1559) transaction which is the standard nowadays - const encoded = encodeRlp(fields).slice(2); // remove 0x prefix + let encoded = Buffer.from(encodeRlp(fields).slice(2), "hex"); + if (rlpPrefix) { + encoded = Buffer.concat([Buffer.from([rlpPrefix]), encoded]); + } - return Buffer.from(keccak256(Buffer.from(`${eip1559Prefix}${encoded}`, "hex")).slice(2), "hex"); + return Buffer.from(keccak256(encoded).slice(2), "hex"); } public async getHash(transaction: Contracts.Crypto.Transaction): Promise { diff --git a/packages/crypto-transaction/source/validation/schemas.ts b/packages/crypto-transaction/source/validation/schemas.ts index d01f85079f..2ace638ee9 100644 --- a/packages/crypto-transaction/source/validation/schemas.ts +++ b/packages/crypto-transaction/source/validation/schemas.ts @@ -43,6 +43,8 @@ export const transactionBaseSchema: SchemaObject = { r: { type: "string" }, + rlpPrefix: { maximum: 2, minimum: 0, type: "number" }, + // TODO: prefixed hex s: { type: "string" },