diff --git a/docs.wrm/links/specs.txt b/docs.wrm/links/specs.txt index afa985121..33d8001bc 100644 --- a/docs.wrm/links/specs.txt +++ b/docs.wrm/links/specs.txt @@ -31,6 +31,7 @@ link-eip-2098 [EIP-2098](https://eips.ethereum.org/EIPS/eip-2098) link-eip-2304 [EIP-2304](https://eips.ethereum.org/EIPS/eip-2304) link-eip-2718 [EIP-2718](https://eips.ethereum.org/EIPS/eip-2718) link-eip-2930 [EIP-2930](https://eips.ethereum.org/EIPS/eip-2930) +link-eip-4844 [EIP-4844](https://eips.ethereum.org/EIPS/eip-4844) # Open Standards link-base58 [Base58](https://en.bitcoinwiki.org/wiki/Base58) diff --git a/src.ts/_tests/test-transaction.ts b/src.ts/_tests/test-transaction.ts index e4def2628..7e706c691 100644 --- a/src.ts/_tests/test-transaction.ts +++ b/src.ts/_tests/test-transaction.ts @@ -64,6 +64,15 @@ describe("Tests Unsigned Transaction Serializing", function() { assert.equal(tx.unsignedSerialized, test.unsignedLondon, "unsignedLondon"); }); } + + for (const test of tests) { + if (!test.unsignedCancun) { continue; } + it(`serialized unsigned cancun transaction: ${ test.name }`, function() { + const txData = Object.assign({ }, test.transaction, { type: 3 }); + const tx = Transaction.from(txData); + assert.equal(tx.unsignedSerialized, test.unsignedCancun, "unsignedCancun"); + }); + } }); describe("Tests Signed Transaction Serializing", function() { @@ -127,6 +136,20 @@ describe("Tests Signed Transaction Serializing", function() { assert.equal(tx.serialized, test.signedLondon, "signedLondon"); }); } + + for (const test of tests) { + if (!test.signedCancun) { continue; } + + it(`serialized signed Cancun transaction: ${ test.name }`, function() { + const txData = Object.assign({ }, test.transaction, { + type: 3, + signature: test.signatureCancun + }); + + const tx = Transaction.from(txData); + assert.equal(tx.serialized, test.signedCancun, "signedCancun"); + }); + } }); function assertTxUint(actual: null | bigint, _expected: undefined | string, name: string): void { @@ -227,6 +250,18 @@ describe("Tests Unsigned Transaction Parsing", function() { assertTxEqual(tx, expected); }); } + + for (const test of tests) { + if (!test.unsignedCancun) { continue; } + it(`parses unsigned Cancun transaction: ${ test.name }`, function() { + const tx = Transaction.from(test.unsignedCancun); + + const expected = addDefaults(test.transaction); + expected.gasPrice = null; + + assertTxEqual(tx, expected); + }); + } }); describe("Tests Signed Transaction Parsing", function() { @@ -277,6 +312,7 @@ describe("Tests Signed Transaction Parsing", function() { assert.equal(tx.isLegacy(), true, "isLegacy"); assert.equal(tx.isBerlin(), false, "isBerlin"); assert.equal(tx.isLondon(), false, "isLondon"); + assert.equal(tx.isCancun(), false, "isCancun"); assert.ok(!!tx.signature, "signature:!null") assert.equal(tx.signature.r, test.signatureEip155.r, "signature.r"); @@ -303,6 +339,7 @@ describe("Tests Signed Transaction Parsing", function() { assert.equal(tx.isLegacy(), false, "isLegacy"); assert.equal(tx.isBerlin(), true, "isBerlin"); assert.equal(tx.isLondon(), false, "isLondon"); + assert.equal(tx.isCancun(), false, "isCancun"); assert.ok(!!tx.signature, "signature:!null") assert.equal(tx.signature.r, test.signatureBerlin.r, "signature.r"); @@ -328,6 +365,7 @@ describe("Tests Signed Transaction Parsing", function() { assert.equal(tx.isLegacy(), false, "isLegacy"); assert.equal(tx.isBerlin(), false, "isBerlin"); assert.equal(tx.isLondon(), true, "isLondon"); + assert.equal(tx.isCancun(), false, "isCancun"); assert.ok(!!tx.signature, "signature:!null") assert.equal(tx.signature.r, test.signatureLondon.r, "signature.r"); @@ -339,6 +377,34 @@ describe("Tests Signed Transaction Parsing", function() { } }); } + + for (const test of tests) { + if (!test.signedCancun) { continue; } + it(`parses signed Cancun transaction: ${ test.name }`, function() { + let tx = Transaction.from(test.signedCancun); + + const expected = addDefaults(test.transaction); + expected.gasPrice = null; + + for (let i = 0; i < 2; i++) { + assertTxEqual(tx, expected); + + assert.equal(tx.typeName, "eip-4844", "typeName"); + assert.equal(tx.isLegacy(), false, "isLegacy"); + assert.equal(tx.isBerlin(), false, "isBerlin"); + assert.equal(tx.isLondon(), false, "isLondon"); + assert.equal(tx.isCancun(), true, "isCancun"); + + assert.ok(!!tx.signature, "signature:!null") + assert.equal(tx.signature.r, test.signatureCancun.r, "signature.r"); + assert.equal(tx.signature.s, test.signatureCancun.s, "signature.s"); + assert.equal(tx.signature.yParity, parseInt(test.signatureCancun.v), "signature.v"); + + // Test cloning + tx = tx.clone(); + } + }); + } }); describe("Tests Transaction Parameters", function() { diff --git a/src.ts/_tests/types.ts b/src.ts/_tests/types.ts index fd24e25de..ef2df89fd 100644 --- a/src.ts/_tests/types.ts +++ b/src.ts/_tests/types.ts @@ -217,11 +217,14 @@ export interface TestCaseTransaction { signedBerlin: string; unsignedLondon: string; signedLondon: string; + unsignedCancun: string; + signedCancun: string; signatureLegacy: TestCaseTransactionSig; signatureEip155: TestCaseTransactionSig; signatureBerlin: TestCaseTransactionSig; signatureLondon: TestCaseTransactionSig; + signatureCancun: TestCaseTransactionSig; } diff --git a/src.ts/transaction/transaction.ts b/src.ts/transaction/transaction.ts index 2131fd631..92157feda 100644 --- a/src.ts/transaction/transaction.ts +++ b/src.ts/transaction/transaction.ts @@ -1,9 +1,10 @@ import { getAddress } from "../address/index.js"; +import { ZeroAddress } from "../constants/addresses.js"; import { keccak256, Signature, SigningKey } from "../crypto/index.js"; import { concat, decodeRlp, encodeRlp, getBytes, getBigInt, getNumber, hexlify, - assert, assertArgument, toBeArray, zeroPadValue + assert, assertArgument, isHexString, toBeArray, zeroPadValue } from "../utils/index.js"; import { accessListify } from "./accesslist.js"; @@ -22,6 +23,7 @@ const BN_28 = BigInt(28) const BN_35 = BigInt(35); const BN_MAX_UINT = BigInt("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + /** * A **TransactionLike** is an object which is appropriate as a loose * input for many operations which will populate missing properties of @@ -97,6 +99,16 @@ export interface TransactionLike { * The access list for berlin and london transactions. */ accessList?: null | AccessListish; + + /** + * The maximum fee per blob gas (see [[link-eip-4844]]). + */ + maxFeePerBlobGas?: null | BigNumberish; + + /** + * The versioned hashes (see [[link-eip-4844]]). + */ + blobVersionedHashes?: null | Array; } function handleAddress(value: string): null | string { @@ -135,6 +147,14 @@ function formatAccessList(value: AccessListish): Array<[ string, Array ] return accessListify(value).map((set) => [ set.address, set.storageKeys ]); } +function formatHashes(value: Array, param: string): Array { + assertArgument(Array.isArray(value), `invalid ${ param }`, "value", value); + for (let i = 0; i < value.length; i++) { + assertArgument(isHexString(value[i], 32), "invalid ${ param } hash", `value[${ i }]`, value[i]); + } + return value; +} + function _parseLegacy(data: Uint8Array): TransactionLike { const fields: any = decodeRlp(data); @@ -186,13 +206,15 @@ function _parseLegacy(data: Uint8Array): TransactionLike { } function _serializeLegacy(tx: Transaction, sig?: Signature): string { + assertArgument(tx.isLegacy(), "internal check failed; !legacy", "tx", tx); + const fields: Array = [ - formatNumber(tx.nonce || 0, "nonce"), - formatNumber(tx.gasPrice || 0, "gasPrice"), - formatNumber(tx.gasLimit || 0, "gasLimit"), - ((tx.to != null) ? getAddress(tx.to): "0x"), - formatNumber(tx.value || 0, "value"), - (tx.data || "0x"), + formatNumber(tx.nonce, "nonce"), + formatNumber(tx.gasPrice, "gasPrice"), + formatNumber(tx.gasLimit, "gasLimit"), + (tx.to || "0x"), + formatNumber(tx.value, "value"), + tx.data, ]; let chainId = BN_0; @@ -265,14 +287,12 @@ function _parseEip1559(data: Uint8Array): TransactionLike { assertArgument(Array.isArray(fields) && (fields.length === 9 || fields.length === 12), "invalid field count for transaction type: 2", "data", hexlify(data)); - const maxPriorityFeePerGas = handleUint(fields[2], "maxPriorityFeePerGas"); - const maxFeePerGas = handleUint(fields[3], "maxFeePerGas"); const tx: TransactionLike = { type: 2, chainId: handleUint(fields[0], "chainId"), nonce: handleNumber(fields[1], "nonce"), - maxPriorityFeePerGas: maxPriorityFeePerGas, - maxFeePerGas: maxFeePerGas, + maxPriorityFeePerGas: handleUint(fields[2], "maxPriorityFeePerGas"), + maxFeePerGas: handleUint(fields[3], "maxFeePerGas"), gasPrice: null, gasLimit: handleUint(fields[4], "gasLimit"), to: handleAddress(fields[5]), @@ -291,17 +311,19 @@ function _parseEip1559(data: Uint8Array): TransactionLike { return tx; } -function _serializeEip1559(tx: TransactionLike, sig?: Signature): string { +function _serializeEip1559(tx: Transaction, sig?: Signature): string { + assertArgument(tx.isLondon(), "internal check failed; !london", "tx", tx); + const fields: Array = [ - formatNumber(tx.chainId || 0, "chainId"), - formatNumber(tx.nonce || 0, "nonce"), - formatNumber(tx.maxPriorityFeePerGas || 0, "maxPriorityFeePerGas"), - formatNumber(tx.maxFeePerGas || 0, "maxFeePerGas"), - formatNumber(tx.gasLimit || 0, "gasLimit"), - ((tx.to != null) ? getAddress(tx.to): "0x"), - formatNumber(tx.value || 0, "value"), - (tx.data || "0x"), - (formatAccessList(tx.accessList || [])) + formatNumber(tx.chainId, "chainId"), + formatNumber(tx.nonce, "nonce"), + formatNumber(tx.maxPriorityFeePerGas, "maxPriorityFeePerGas"), + formatNumber(tx.maxFeePerGas, "maxFeePerGas"), + formatNumber(tx.gasLimit, "gasLimit"), + (tx.to || "0x"), + formatNumber(tx.value, "value"), + tx.data, + formatAccessList(tx.accessList) ]; if (sig) { @@ -341,16 +363,18 @@ function _parseEip2930(data: Uint8Array): TransactionLike { return tx; } -function _serializeEip2930(tx: TransactionLike, sig?: Signature): string { +function _serializeEip2930(tx: Transaction, sig?: Signature): string { + assertArgument(tx.isBerlin(), "internal check failed; !berlin", "tx", tx); + const fields: any = [ - formatNumber(tx.chainId || 0, "chainId"), - formatNumber(tx.nonce || 0, "nonce"), - formatNumber(tx.gasPrice || 0, "gasPrice"), - formatNumber(tx.gasLimit || 0, "gasLimit"), - ((tx.to != null) ? getAddress(tx.to): "0x"), - formatNumber(tx.value || 0, "value"), - (tx.data || "0x"), - (formatAccessList(tx.accessList || [])) + formatNumber(tx.chainId, "chainId"), + formatNumber(tx.nonce, "nonce"), + formatNumber(tx.gasPrice, "gasPrice"), + formatNumber(tx.gasLimit, "gasLimit"), + (tx.to || "0x"), + formatNumber(tx.value, "value"), + tx.data, + formatAccessList(tx.accessList) ]; if (sig) { @@ -362,6 +386,71 @@ function _serializeEip2930(tx: TransactionLike, sig?: Signature): string { return concat([ "0x01", encodeRlp(fields)]); } +function _parseEip4844(data: Uint8Array): TransactionLike { + const fields: any = decodeRlp(getBytes(data).slice(1)); + + assertArgument(Array.isArray(fields) && (fields.length === 11 || fields.length === 14), + "invalid field count for transaction type: 3", "data", hexlify(data)); + + const tx: TransactionLike = { + type: 3, + chainId: handleUint(fields[0], "chainId"), + nonce: handleNumber(fields[1], "nonce"), + maxPriorityFeePerGas: handleUint(fields[2], "maxPriorityFeePerGas"), + maxFeePerGas: handleUint(fields[3], "maxFeePerGas"), + gasPrice: null, + gasLimit: handleUint(fields[4], "gasLimit"), + to: handleAddress(fields[5]), + value: handleUint(fields[6], "value"), + data: hexlify(fields[7]), + accessList: handleAccessList(fields[8], "accessList"), + maxFeePerBlobGas: handleUint(fields[9], "maxFeePerBlobGas"), + blobVersionedHashes: fields[10] + }; + + assertArgument(tx.to != null, "invalid address for transaction type: 3", "data", data); + + assertArgument(Array.isArray(tx.blobVersionedHashes), "invalid blobVersionedHashes: must be an array", "data", data); + for (let i = 0; i < tx.blobVersionedHashes.length; i++) { + assertArgument(isHexString(tx.blobVersionedHashes[i], 32), `invalid blobVersionedHash at index ${ i }: must be length 32`, "data", data); + } + + // Unsigned EIP-4844 Transaction + if (fields.length === 11) { return tx; } + + tx.hash = keccak256(data); + + _parseEipSignature(tx, fields.slice(11)); + + return tx; +} + +function _serializeEip4844(tx: Transaction, sig?: Signature): string { + assertArgument(tx.isCancun(), "internal check failed; !cancun", "tx", tx); + + const fields: Array = [ + formatNumber(tx.chainId, "chainId"), + formatNumber(tx.nonce, "nonce"), + formatNumber(tx.maxPriorityFeePerGas, "maxPriorityFeePerGas"), + formatNumber(tx.maxFeePerGas, "maxFeePerGas"), + formatNumber(tx.gasLimit, "gasLimit"), + tx.to, + formatNumber(tx.value, "value"), + tx.data, + (formatAccessList(tx.accessList)), + formatNumber(tx.maxFeePerBlobGas, "maxFeePerBlobGas"), + formatHashes(tx.blobVersionedHashes, "blobVersionedHashes") + ]; + + if (sig) { + fields.push(formatNumber(sig.yParity, "yParity")); + fields.push(toBeArray(sig.r)); + fields.push(toBeArray(sig.s)); + } + + return concat([ "0x03", encodeRlp(fields)]); +} + /** * A **Transaction** describes an operation to be executed on * Ethereum by an Externally Owned Account (EOA). It includes @@ -388,6 +477,8 @@ export class Transaction implements TransactionLike { #chainId: bigint; #sig: null | Signature; #accessList: null | AccessList; + #maxFeePerBlobGas: null | bigint; + #blobVersionedHashes: null | Array; /** * The transaction type. @@ -410,6 +501,9 @@ export class Transaction implements TransactionLike { case 2: case "london": case "eip-1559": this.#type = 2; break; + case 3: case "cancun": case "eip-4844": + this.#type = 3; + break; default: assertArgument(false, "unsupported transaction type", "type", value); } @@ -423,6 +517,7 @@ export class Transaction implements TransactionLike { case 0: return "legacy"; case 1: return "eip-2930"; case 2: return "eip-1559"; + case 3: return "eip-4844"; } return null; @@ -432,7 +527,11 @@ export class Transaction implements TransactionLike { * The ``to`` address for the transaction or ``null`` if the * transaction is an ``init`` transaction. */ - get to(): null | string { return this.#to; } + get to(): null | string { + const value = this.#to; + if (value == null && this.type === 3) { return ZeroAddress; } + return value; + } set to(value: null | string) { this.#to = (value == null) ? null: getAddress(value); } @@ -471,7 +570,7 @@ export class Transaction implements TransactionLike { get maxPriorityFeePerGas(): null | bigint { const value = this.#maxPriorityFeePerGas; if (value == null) { - if (this.type === 2) { return BN_0; } + if (this.type === 2 || this.type === 3) { return BN_0; } return null; } return value; @@ -487,7 +586,7 @@ export class Transaction implements TransactionLike { get maxFeePerGas(): null | bigint { const value = this.#maxFeePerGas; if (value == null) { - if (this.type === 2) { return BN_0; } + if (this.type === 2 || this.type === 3) { return BN_0; } return null; } return value; @@ -534,7 +633,11 @@ export class Transaction implements TransactionLike { get accessList(): null | AccessList { const value = this.#accessList || null; if (value == null) { - if (this.type === 1 || this.type === 2) { return [ ]; } + if (this.type === 1 || this.type === 2 || this.type === 3) { + // @TODO: in v7, this should assign the value or become + // a live object itself, otherwise mutation is inconsistent + return [ ]; + } return null; } return value; @@ -543,6 +646,39 @@ export class Transaction implements TransactionLike { this.#accessList = (value == null) ? null: accessListify(value); } + /** + * The max fee per blob gas for Cancun transactions. + */ + get maxFeePerBlobGas(): null | bigint { + const value = this.#maxFeePerBlobGas; + if (value == null && this.type === 3) { return BN_0; } + return value; + } + set maxFeePerBlobGas(value: null | BigNumberish) { + this.#maxFeePerBlobGas = (value == null) ? null: getBigInt(value, "maxFeePerBlobGas"); + } + + /** + * The BLOB versioned hashes for Cancun transactions. + */ + get blobVersionedHashes(): null | Array { + // @TODO: Mutation is inconsistent; if unset, the returned value + // cannot mutate the object, if set it can + let value = this.#blobVersionedHashes; + if (value == null && this.type === 3) { return [ ]; } + return value; + } + set blobVersionedHashes(value: null | Array) { + if (value != null) { + assertArgument(Array.isArray(value), "blobVersionedHashes must be an Array", "value", value); + value = value.slice(); + for (let i = 0; i < value.length; i++) { + assertArgument(isHexString(value[i], 32), "invalid blobVersionedHash", `value[${ i }]`, value[i]); + } + } + this.#blobVersionedHashes = value; + } + /** * Creates a new Transaction with default values. */ @@ -550,15 +686,17 @@ export class Transaction implements TransactionLike { this.#type = null; this.#to = null; this.#nonce = 0; - this.#gasLimit = BigInt(0); + this.#gasLimit = BN_0; this.#gasPrice = null; this.#maxPriorityFeePerGas = null; this.#maxFeePerGas = null; this.#data = "0x"; - this.#value = BigInt(0); - this.#chainId = BigInt(0); + this.#value = BN_0; + this.#chainId = BN_0; this.#sig = null; this.#accessList = null; + this.#maxFeePerBlobGas = null; + this.#blobVersionedHashes = null; } /** @@ -602,7 +740,6 @@ export class Transaction implements TransactionLike { * transaction are non-null. */ isSigned(): this is (Transaction & { type: number, typeName: string, from: string, signature: Signature }) { - //isSigned(): this is SignedTransaction { return this.signature != null; } @@ -622,6 +759,8 @@ export class Transaction implements TransactionLike { return _serializeEip2930(this, this.signature); case 2: return _serializeEip1559(this, this.signature); + case 3: + return _serializeEip4844(this, this.signature); } assert(false, "unsupported transaction type", "UNSUPPORTED_OPERATION", { operation: ".serialized" }); @@ -641,6 +780,8 @@ export class Transaction implements TransactionLike { return _serializeEip2930(this); case 2: return _serializeEip1559(this); + case 3: + return _serializeEip4844(this); } assert(false, "unsupported transaction type", "UNSUPPORTED_OPERATION", { operation: ".unsignedSerialized" }); @@ -651,7 +792,13 @@ export class Transaction implements TransactionLike { * supported transaction type. */ inferType(): number { - return (this.inferTypes().pop()); + const types = this.inferTypes(); + + // Prefer London (EIP-1559) over Cancun (BLOb) + if (types.indexOf(2) >= 0) { return 2; } + + // Return the highest inferred type + return (types.pop()); } /** @@ -664,6 +811,7 @@ export class Transaction implements TransactionLike { const hasGasPrice = this.gasPrice != null; const hasFee = (this.maxFeePerGas != null || this.maxPriorityFeePerGas != null); const hasAccessList = (this.accessList != null); + const hasBlob = (this.#maxFeePerBlobGas != null || this.#blobVersionedHashes); //if (hasGasPrice && hasFee) { // throw new Error("transaction cannot have gasPrice and maxFeePerGas"); @@ -695,10 +843,13 @@ export class Transaction implements TransactionLike { } else if (hasAccessList) { types.push(1); types.push(2); + } else if (hasBlob && this.to) { + types.push(3); } else { types.push(0); types.push(1); types.push(2); + types.push(3); } } @@ -736,10 +887,21 @@ export class Transaction implements TransactionLike { * This provides a Type Guard that the related properties are * non-null. */ - isLondon(): this is (Transaction & { type: 2, accessList: AccessList, maxFeePerGas: bigint, maxPriorityFeePerGas: bigint}) { + isLondon(): this is (Transaction & { type: 2, accessList: AccessList, maxFeePerGas: bigint, maxPriorityFeePerGas: bigint }) { return (this.type === 2); } + /** + * Returns true if this transaction is an [[link-eip-4844]] BLOB + * transaction. + * + * This provides a Type Guard that the related properties are + * non-null. + */ + isCancun(): this is (Transaction & { type: 3, to: string, accessList: AccessList, maxFeePerGas: bigint, maxPriorityFeePerGas: bigint, maxFeePerBlobGas: bigint, blobVersionedHashes: Array }) { + return (this.type === 3); + } + /** * Create a copy of this transaciton. */ @@ -790,6 +952,7 @@ export class Transaction implements TransactionLike { switch(payload[0]) { case 1: return Transaction.from(_parseEip2930(payload)); case 2: return Transaction.from(_parseEip1559(payload)); + case 3: return Transaction.from(_parseEip4844(payload)); } assert(false, "unsupported transaction type", "UNSUPPORTED_OPERATION", { operation: "from" }); } @@ -802,11 +965,13 @@ export class Transaction implements TransactionLike { if (tx.gasPrice != null) { result.gasPrice = tx.gasPrice; } if (tx.maxPriorityFeePerGas != null) { result.maxPriorityFeePerGas = tx.maxPriorityFeePerGas; } if (tx.maxFeePerGas != null) { result.maxFeePerGas = tx.maxFeePerGas; } + if (tx.maxFeePerBlobGas != null) { result.maxFeePerBlobGas = tx.maxFeePerBlobGas; } if (tx.data != null) { result.data = tx.data; } if (tx.value != null) { result.value = tx.value; } if (tx.chainId != null) { result.chainId = tx.chainId; } if (tx.signature != null) { result.signature = Signature.from(tx.signature); } if (tx.accessList != null) { result.accessList = tx.accessList; } + if (tx.blobVersionedHashes != null) { result.blobVersionedHashes = tx.blobVersionedHashes; } if (tx.hash != null) { assertArgument(result.isSigned(), "unsigned transaction cannot define hash", "tx", tx); diff --git a/testcases/transactions.json.gz b/testcases/transactions.json.gz index 7f6d75b7b..1b244f21d 100644 Binary files a/testcases/transactions.json.gz and b/testcases/transactions.json.gz differ