From 92bad88261a5d8a538535a7d5528162fe5010527 Mon Sep 17 00:00:00 2001 From: Richard Moore Date: Wed, 27 Mar 2024 14:43:57 -0400 Subject: [PATCH] Added EIP-4844 broadcast support. --- package.json | 6 +- src.ts/_version.ts | 2 +- src.ts/ethers.ts | 1 + src.ts/providers/abstract-signer.ts | 4 +- src.ts/providers/provider.ts | 46 ++++- src.ts/transaction/index.ts | 4 +- src.ts/transaction/transaction.ts | 272 +++++++++++++++++++++++----- 7 files changed, 284 insertions(+), 51 deletions(-) diff --git a/package.json b/package.json index 387e314af..bb770d3c9 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "url": "https://www.buymeacoffee.com/ricmoo" } ], - "gitHead": "556fdd91d9b6bf7db4041bb099e66b2080e1a985", + "gitHead": "12772e9498b70f8538838f30e16f3792ea90e173", "homepage": "https://ethers.org", "keywords": [ "ethereum", @@ -106,7 +106,7 @@ "name": "ethers", "publishConfig": { "access": "public", - "tag": "latest" + "tag": "next" }, "repository": { "type": "git", @@ -131,5 +131,5 @@ "test-esm": "mocha --trace-warnings --reporter ./reporter.cjs ./lib.esm/_tests/test-*.js" }, "sideEffects": false, - "version": "6.11.1" + "version": "6.12.0-beta.1" } diff --git a/src.ts/_version.ts b/src.ts/_version.ts index 41b134f1a..35dd76572 100644 --- a/src.ts/_version.ts +++ b/src.ts/_version.ts @@ -3,4 +3,4 @@ /** * The current version of Ethers. */ -export const version: string = "6.11.1"; +export const version: string = "6.12.0-beta.1"; diff --git a/src.ts/ethers.ts b/src.ts/ethers.ts index 1f5c54d8c..a02a9817b 100644 --- a/src.ts/ethers.ts +++ b/src.ts/ethers.ts @@ -177,6 +177,7 @@ export type { export type { AccessList, AccessListish, AccessListEntry, + Blob, BlobLike, KzgLibrary, TransactionLike } from "./transaction/index.js"; diff --git a/src.ts/providers/abstract-signer.ts b/src.ts/providers/abstract-signer.ts index afd9aa223..3087624d8 100644 --- a/src.ts/providers/abstract-signer.ts +++ b/src.ts/providers/abstract-signer.ts @@ -194,8 +194,8 @@ export abstract class AbstractSigner

+ + /** + * The maximum fee per blob gas (see [[link-eip-4844]]). + */ + maxFeePerBlobGas?: null | BigNumberish; + + /** + * Any blobs to include in the transaction (see [[link-eip-4844]]). + */ + blobs?: null | Array; + + /** + * An external library for computing the KZG commitments and + * proofs necessary for EIP-4844 transactions (see [[link-eip-4844]]). + * + * This is generally ``null``, unless you are creating BLOb + * transactions. + */ + kzg?: null | KzgLibrary; + // Todo? //gasMultiplier?: number; }; @@ -332,7 +359,7 @@ export function copyRequest(req: TransactionRequest): PreparedTransactionRequest if (req.data) { result.data = hexlify(req.data); } - const bigIntKeys = "chainId,gasLimit,gasPrice,maxFeePerGas,maxPriorityFeePerGas,value".split(/,/); + const bigIntKeys = "chainId,gasLimit,gasPrice,maxFeePerBlobGas,maxFeePerGas,maxPriorityFeePerGas,value".split(/,/); for (const key of bigIntKeys) { if (!(key in req) || (req)[key] == null) { continue; } result[key] = getBigInt((req)[key], `request.${ key }`); @@ -358,6 +385,19 @@ export function copyRequest(req: TransactionRequest): PreparedTransactionRequest result.customData = req.customData; } + if ("blobVersionedHashes" in req && req.blobVersionedHashes) { + result.blobVersionedHashes = req.blobVersionedHashes.slice(); + } + + if ("kzg" in req) { result.kzg = req.kzg; } + + if ("blobs" in req && req.blobs) { + result.blobs = req.blobs.map((b) => { + if (isBytesLike(b)) { return hexlify(b); } + return Object.assign({ }, b); + }); + } + return result; } diff --git a/src.ts/transaction/index.ts b/src.ts/transaction/index.ts index a67c044de..d67055f6d 100644 --- a/src.ts/transaction/index.ts +++ b/src.ts/transaction/index.ts @@ -28,4 +28,6 @@ export { accessListify } from "./accesslist.js"; export { computeAddress, recoverAddress } from "./address.js"; export { Transaction } from "./transaction.js"; -export type { TransactionLike } from "./transaction.js"; +export type { + Blob, BlobLike, KzgLibrary, TransactionLike +} from "./transaction.js"; diff --git a/src.ts/transaction/transaction.ts b/src.ts/transaction/transaction.ts index b9ec51d75..1413902aa 100644 --- a/src.ts/transaction/transaction.ts +++ b/src.ts/transaction/transaction.ts @@ -1,10 +1,12 @@ import { getAddress } from "../address/index.js"; import { ZeroAddress } from "../constants/addresses.js"; -import { keccak256, Signature, SigningKey } from "../crypto/index.js"; +import { + keccak256, sha256, Signature, SigningKey +} from "../crypto/index.js"; import { concat, decodeRlp, encodeRlp, getBytes, getBigInt, getNumber, hexlify, - assert, assertArgument, isHexString, toBeArray, zeroPadValue + assert, assertArgument, isBytesLike, isHexString, toBeArray, zeroPadValue } from "../utils/index.js"; import { accessListify } from "./accesslist.js"; @@ -23,6 +25,7 @@ const BN_28 = BigInt(28) const BN_35 = BigInt(35); const BN_MAX_UINT = BigInt("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); +const BLOB_SIZE = 4096 * 32; /** * A **TransactionLike** is an object which is appropriate as a loose @@ -109,6 +112,61 @@ export interface TransactionLike { * The versioned hashes (see [[link-eip-4844]]). */ blobVersionedHashes?: null | Array; + + /** + * The blobs (if any) attached to this transaction (see [[link-eip-4844]]). + */ + blobs?: null | Array + + /** + * An external library for computing the KZG commitments and + * proofs necessary for EIP-4844 transactions (see [[link-eip-4844]]). + * + * This is generally ``null``, unless you are creating BLOb + * transactions. + */ + kzg?: null | KzgLibrary; +} + +/** + * A full-valid BLOb object for [[link-eip-4844]] transactions. + * + * The commitment and proof should have been computed using a + * KZG library. + */ +export interface Blob { + data: string; + proof: string; + commitment: string; +} + +/** + * A BLOb object that can be passed for [[link-eip-4844]] + * transactions. + * + * It may have had its commitment and proof already provided + * or rely on an attached [[KzgLibrary]] to compute them. + */ +export type BlobLike = BytesLike | { + data: BytesLike; + proof: BytesLike; + commitment: BytesLike; +}; + +/** + * A KZG Library with the necessary functions to compute + * BLOb commitments and proofs. + */ +export interface KzgLibrary { + blobToKzgCommitment: (blob: Uint8Array) => Uint8Array; + computeBlobKzgProof: (blob: Uint8Array, commitment: Uint8Array) => Uint8Array; +} + +function getVersionedHash(version: number, hash: BytesLike): string { + let versioned = version.toString(16); + while (versioned.length < 2) { versioned = "0" + versioned; } + versioned += sha256(hash).substring(4); + return "0x" + versioned; } function handleAddress(value: string): null | string { @@ -199,13 +257,13 @@ function _parseLegacy(data: Uint8Array): TransactionLike { v }); - tx.hash = keccak256(data); + //tx.hash = keccak256(data); } return tx; } -function _serializeLegacy(tx: Transaction, sig?: Signature): string { +function _serializeLegacy(tx: Transaction, sig: null | Signature): string { const fields: Array = [ formatNumber(tx.nonce, "nonce"), formatNumber(tx.gasPrice || 0, "gasPrice"), @@ -302,14 +360,14 @@ function _parseEip1559(data: Uint8Array): TransactionLike { // Unsigned EIP-1559 Transaction if (fields.length === 9) { return tx; } - tx.hash = keccak256(data); + //tx.hash = keccak256(data); _parseEipSignature(tx, fields.slice(9)); return tx; } -function _serializeEip1559(tx: Transaction, sig?: Signature): string { +function _serializeEip1559(tx: Transaction, sig: null | Signature): string { const fields: Array = [ formatNumber(tx.chainId, "chainId"), formatNumber(tx.nonce, "nonce"), @@ -352,14 +410,14 @@ function _parseEip2930(data: Uint8Array): TransactionLike { // Unsigned EIP-2930 Transaction if (fields.length === 8) { return tx; } - tx.hash = keccak256(data); + //tx.hash = keccak256(data); _parseEipSignature(tx, fields.slice(8)); return tx; } -function _serializeEip2930(tx: Transaction, sig?: Signature): string { +function _serializeEip2930(tx: Transaction, sig: null | Signature): string { const fields: any = [ formatNumber(tx.chainId, "chainId"), formatNumber(tx.nonce, "nonce"), @@ -381,10 +439,36 @@ function _serializeEip2930(tx: Transaction, sig?: Signature): string { } function _parseEip4844(data: Uint8Array): TransactionLike { - const fields: any = decodeRlp(getBytes(data).slice(1)); + let fields: any = decodeRlp(getBytes(data).slice(1)); + + let typeName = "3"; + + let blobs: null | Array = null; + + // Parse the network format + if (fields.length === 4 && Array.isArray(fields[0])) { + typeName = "3 (network format)"; + const fBlobs = fields[1], fCommits = fields[2], fProofs = fields[3]; + assertArgument(Array.isArray(fBlobs), "invalid network format: blobs not an array", "fields[1]", fBlobs); + assertArgument(Array.isArray(fCommits), "invalid network format: commitments not an array", "fields[2]", fCommits); + assertArgument(Array.isArray(fProofs), "invalid network format: proofs not an array", "fields[3]", fProofs); + assertArgument(fBlobs.length === fCommits.length, "invalid network format: blobs/commitments length mismatch", "fields", fields); + assertArgument(fBlobs.length === fProofs.length, "invalid network format: blobs/proofs length mismatch", "fields", fields); + + blobs = [ ]; + for (let i = 0; i < fields[1].length; i++) { + blobs.push({ + data: fBlobs[i], + commitment: fCommits[i], + proof: fProofs[i], + }); + } + + fields = fields[0]; + } assertArgument(Array.isArray(fields) && (fields.length === 11 || fields.length === 14), - "invalid field count for transaction type: 3", "data", hexlify(data)); + `invalid field count for transaction type: ${ typeName }`, "data", hexlify(data)); const tx: TransactionLike = { type: 3, @@ -402,7 +486,9 @@ function _parseEip4844(data: Uint8Array): TransactionLike { blobVersionedHashes: fields[10] }; - assertArgument(tx.to != null, "invalid address for transaction type: 3", "data", data); + if (blobs) { tx.blobs = blobs; } + + assertArgument(tx.to != null, `invalid address for transaction type: ${ typeName }`, "data", data); assertArgument(Array.isArray(tx.blobVersionedHashes), "invalid blobVersionedHashes: must be an array", "data", data); for (let i = 0; i < tx.blobVersionedHashes.length; i++) { @@ -412,14 +498,16 @@ function _parseEip4844(data: Uint8Array): TransactionLike { // Unsigned EIP-4844 Transaction if (fields.length === 11) { return tx; } - tx.hash = keccak256(data); + // @TODO: Do we need to do this? This is only called internally + // and used to verify hashes; it might save time to not do this + //tx.hash = keccak256(concat([ "0x03", encodeRlp(fields) ])); _parseEipSignature(tx, fields.slice(11)); return tx; } -function _serializeEip4844(tx: Transaction, sig?: Signature): string { +function _serializeEip4844(tx: Transaction, sig: null | Signature, blobs: null | Array): string { const fields: Array = [ formatNumber(tx.chainId, "chainId"), formatNumber(tx.nonce, "nonce"), @@ -438,6 +526,20 @@ function _serializeEip4844(tx: Transaction, sig?: Signature): string { fields.push(formatNumber(sig.yParity, "yParity")); fields.push(toBeArray(sig.r)); fields.push(toBeArray(sig.s)); + + // We have blobs; return the network wrapped format + if (blobs) { + return concat([ + "0x03", + encodeRlp([ + fields, + blobs.map((b) => b.data), + blobs.map((b) => b.commitment), + blobs.map((b) => b.proof), + ]) + ]); + } + } return concat([ "0x03", encodeRlp(fields)]); @@ -471,6 +573,8 @@ export class Transaction implements TransactionLike { #accessList: null | AccessList; #maxFeePerBlobGas: null | bigint; #blobVersionedHashes: null | Array; + #kzg: null | KzgLibrary; + #blobs: null | Array; /** * The transaction type. @@ -651,7 +755,7 @@ export class Transaction implements TransactionLike { } /** - * The BLOB versioned hashes for Cancun transactions. + * The BLOb versioned hashes for Cancun transactions. */ get blobVersionedHashes(): null | Array { // @TODO: Mutation is inconsistent; if unset, the returned value @@ -671,6 +775,89 @@ export class Transaction implements TransactionLike { this.#blobVersionedHashes = value; } + /** + * The BLObs for the Transaction, if any. + * + * If ``blobs`` is non-``null``, then the [[seriailized]] + * will return the network formatted sidecar, otherwise it + * will return the standard [[link-eip-2718]] payload. The + * [[unsignedSerialized]] is unaffected regardless. + * + * When setting ``blobs``, either fully valid [[Blob]] objects + * may be specified (i.e. correctly padded, with correct + * committments and proofs) or a raw [[BytesLike]] may + * be provided. + * + * If raw [[BytesLike]] are provided, the [[kzg]] property **must** + * be already set. The blob will be correctly padded and the + * [[KzgLibrary]] will be used to compute the committment and + * proof for the blob. + * + * Setting this automatically populates [[blobVersionedHashes]], + * overwriting any existing values. Setting this to ``null`` + * does **not** remove the [[blobVersionedHashes]], leaving them + * present. + */ + get blobs(): null | Array { + if (this.#blobs == null) { return null; } + return this.#blobs.map((b) => Object.assign({ }, b)); + } + set blobs(_blobs: null | Array) { + if (_blobs == null) { + this.#blobs = null; + return; + } + + const blobs: Array = [ ]; + const versionedHashes: Array = [ ]; + for (let i = 0; i < _blobs.length; i++) { + const blob = _blobs[i]; + + if (isBytesLike(blob)) { + assert(this.#kzg, "adding a raw blob requires a KZG library", "UNSUPPORTED_OPERATION", { + operation: "set blobs()" + }); + + let data = getBytes(blob); + assertArgument(data.length <= BLOB_SIZE, "blob is too large", `blobs[${ i }]`, blob); + + // Pad blob if necessary + if (data.length !== BLOB_SIZE) { + const padded = new Uint8Array(BLOB_SIZE); + padded.set(data); + data = padded; + } + + const commit = this.#kzg.blobToKzgCommitment(data); + const proof = hexlify(this.#kzg.computeBlobKzgProof(data, commit)); + + blobs.push({ + data: hexlify(data), + commitment: hexlify(commit), + proof + }); + versionedHashes.push(getVersionedHash(1, commit)); + + } else { + const commit = hexlify(blob.commitment); + blobs.push({ + data: hexlify(blob.data), + commitment: commit, + proof: hexlify(blob.proof) + }); + versionedHashes.push(getVersionedHash(1, commit)); + } + } + + this.#blobs = blobs; + this.#blobVersionedHashes = versionedHashes; + } + + get kzg(): null | KzgLibrary { return this.#kzg; } + set kzg(kzg: null | KzgLibrary) { + this.#kzg = kzg; + } + /** * Creates a new Transaction with default values. */ @@ -689,6 +876,8 @@ export class Transaction implements TransactionLike { this.#accessList = null; this.#maxFeePerBlobGas = null; this.#blobVersionedHashes = null; + this.#blobs = null; + this.#kzg = null; } /** @@ -696,7 +885,7 @@ export class Transaction implements TransactionLike { */ get hash(): null | string { if (this.signature == null) { return null; } - return keccak256(this.serialized); + return keccak256(this.#getSerialized(true, false)); } /** @@ -735,6 +924,24 @@ export class Transaction implements TransactionLike { return this.signature != null; } + #getSerialized(signed: boolean, sidecar: boolean): string { + assert(!signed || this.signature != null, "cannot serialize unsigned transaction; maybe you meant .unsignedSerialized", "UNSUPPORTED_OPERATION", { operation: ".serialized"}); + + const sig = signed ? this.signature: null; + switch (this.inferType()) { + case 0: + return _serializeLegacy(this, sig); + case 1: + return _serializeEip2930(this, sig); + case 2: + return _serializeEip1559(this, sig); + case 3: + return _serializeEip4844(this, sig, sidecar ? this.blobs: null); + } + + assert(false, "unsupported transaction type", "UNSUPPORTED_OPERATION", { operation: ".serialized" }); + } + /** * The serialized transaction. * @@ -742,20 +949,7 @@ export class Transaction implements TransactionLike { * use [[unsignedSerialized]]. */ get serialized(): string { - assert(this.signature != null, "cannot serialize unsigned transaction; maybe you meant .unsignedSerialized", "UNSUPPORTED_OPERATION", { operation: ".serialized"}); - - switch (this.inferType()) { - case 0: - return _serializeLegacy(this, this.signature); - case 1: - 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" }); + return this.#getSerialized(true, true); } /** @@ -765,18 +959,7 @@ export class Transaction implements TransactionLike { * authorize this transaction. */ get unsignedSerialized(): string { - switch (this.inferType()) { - case 0: - return _serializeLegacy(this); - case 1: - return _serializeEip2930(this); - case 2: - return _serializeEip1559(this); - case 3: - return _serializeEip4844(this); - } - - assert(false, "unsupported transaction type", "UNSUPPORTED_OPERATION", { operation: ".unsignedSerialized" }); + return this.#getSerialized(false, false); } /** @@ -963,8 +1146,15 @@ export class Transaction implements TransactionLike { 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; } + + // This will get overwritten by blobs, if present if (tx.blobVersionedHashes != null) { result.blobVersionedHashes = tx.blobVersionedHashes; } + // Make sure we assign the kzg before assigning blobs, which + // require the library in the event raw blob data is provided. + if (tx.kzg != null) { result.kzg = tx.kzg; } + if (tx.blobs != null) { result.blobs = tx.blobs; } + if (tx.hash != null) { assertArgument(result.isSigned(), "unsigned transaction cannot define hash", "tx", tx); assertArgument(result.hash === tx.hash, "hash mismatch", "tx", tx);