From 29542e4c98d508e8b1b9d7e8fac7da620b5078f5 Mon Sep 17 00:00:00 2001 From: JohnGuilding Date: Fri, 28 Apr 2023 21:33:30 +0100 Subject: [PATCH] Use deterministic bundle hashes in aggregator --- aggregator/src/app/BundleRouter.ts | 14 ++ aggregator/src/app/BundleService.ts | 66 +++++- aggregator/src/app/BundleTable.ts | 43 +++- aggregator/test/BundleService.test.ts | 198 +++++++++++++++++- .../test/BundleServiceSubmitting.test.ts | 54 +++++ aggregator/test/BundleTable.test.ts | 1 + aggregator/test/helpers/Fixture.ts | 2 +- 7 files changed, 362 insertions(+), 16 deletions(-) diff --git a/aggregator/src/app/BundleRouter.ts b/aggregator/src/app/BundleRouter.ts index 90529f38..beea1d5b 100644 --- a/aggregator/src/app/BundleRouter.ts +++ b/aggregator/src/app/BundleRouter.ts @@ -39,5 +39,19 @@ export default function BundleRouter(bundleService: BundleService) { }, ); + router.get( + "aggregateBundle/:subBundleHash", + (ctx) => { + const bundleRows = bundleService.lookupAggregateBundle(ctx.params.subBundleHash!); + + if (bundleRows === nil || !bundleRows?.length) { + ctx.response.status = 404; + return; + } + + ctx.response.body = bundleRows; + }, + ); + return router; } diff --git a/aggregator/src/app/BundleService.ts b/aggregator/src/app/BundleService.ts index 441e614e..cea91a8e 100644 --- a/aggregator/src/app/BundleService.ts +++ b/aggregator/src/app/BundleService.ts @@ -1,10 +1,12 @@ import { BigNumber, + BigNumberish, BlsWalletSigner, BlsWalletWrapper, Bundle, delay, ethers, + Operation, Semaphore, } from "../../deps.ts"; @@ -18,7 +20,7 @@ import * as env from "../env.ts"; import runQueryGroup from "./runQueryGroup.ts"; import EthereumService from "./EthereumService.ts"; import AppEvent from "./AppEvent.ts"; -import BundleTable, { BundleRow, makeHash } from "./BundleTable.ts"; +import BundleTable, { BundleRow } from "./BundleTable.ts"; import plus from "./helpers/plus.ts"; import AggregationStrategy from "./AggregationStrategy.ts"; import nil from "../helpers/nil.ts"; @@ -27,6 +29,11 @@ export type AddBundleResponse = { hash: string } | { failures: TransactionFailure[]; }; +type BundleWithoutSignature = { + publicKey: [BigNumberish, BigNumberish, BigNumberish, BigNumberish]; + operation: Omit +}; + export default class BundleService { static defaultConfig = { bundleQueryLimit: env.BUNDLE_QUERY_LIMIT, @@ -174,7 +181,7 @@ export default class BundleService { } return await this.runQueryGroup(async () => { - const hash = makeHash(); + const hash = await this.hashBundle(bundle); this.bundleTable.add({ status: "pending", @@ -202,15 +209,21 @@ export default class BundleService { return this.bundleTable.findBundle(hash); } + lookupAggregateBundle(subBundleHash: string) { + const subBundle = this.bundleTable.findBundle(subBundleHash); + return this.bundleTable.findAggregateBundle(subBundle?.aggregateHash!) + } + receiptFromBundle(bundle: BundleRow) { if (!bundle.receipt) { return nil; } - const { receipt, hash } = bundle; + const { receipt, hash, aggregateHash } = bundle; return { bundleHash: hash, + aggregateBundleHash: aggregateHash, to: receipt.to, from: receipt.from, contractAddress: receipt.contractAddress, @@ -231,6 +244,46 @@ export default class BundleService { }; } + async hashBundle(bundle: Bundle): Promise { + const bundleSubHashes = await this.#hashSubBundles(bundle); + const concatenatedHashes = bundleSubHashes.join(""); + return ethers.utils.keccak256(ethers.utils.toUtf8Bytes(concatenatedHashes)); + } + + async #hashSubBundles(bundle: Bundle): Promise> { + const bundlesWithoutSignature: Array = + bundle.operations.map((operation, index) => ({ + publicKey: bundle.senderPublicKeys[index], + operation: { + nonce: operation.nonce, + actions: operation.actions, + }, + })); + + const serializedBundlesWithoutSignature = bundlesWithoutSignature.map( + bundleWithoutSignature => { + return JSON.stringify({ + publicKey: bundleWithoutSignature.publicKey, + operation: bundleWithoutSignature.operation, + }); + } + ); + + const hashes = await Promise.all(serializedBundlesWithoutSignature.map(async (serializedBundleWithoutSignature) => { + const bundleHash = ethers.utils.keccak256( + ethers.utils.toUtf8Bytes(serializedBundleWithoutSignature) + ); + const chainId = (await this.ethereumService.provider.getNetwork()).chainId; + + const encoding = ethers.utils.defaultAbiCoder.encode( + ['bytes32', 'uint256'], + [bundleHash, chainId]) + return ethers.utils.keccak256(encoding) + })); + + return hashes + } + async runSubmission() { this.submissionsInProgress++; @@ -276,6 +329,13 @@ export default class BundleService { }, }); + if (aggregateBundle) { + const aggregateBundleHash = await this.hashBundle(aggregateBundle); + for (const row of includedRows) { + row.aggregateHash = aggregateBundleHash; + } + } + for (const failedRow of failedRows) { this.emit({ type: "failed-row", diff --git a/aggregator/src/app/BundleTable.ts b/aggregator/src/app/BundleTable.ts index 41ae567e..22e3d1df 100644 --- a/aggregator/src/app/BundleTable.ts +++ b/aggregator/src/app/BundleTable.ts @@ -30,6 +30,7 @@ type RawRow = { nextEligibilityDelay: string; submitError: string | null; receipt: string | null; + aggregateHash: string | null; }; const BundleStatuses = ["pending", "confirmed", "failed"] as const; @@ -44,17 +45,12 @@ type Row = { nextEligibilityDelay: BigNumber; submitError?: string; receipt?: ethers.ContractReceipt; + aggregateHash?: string; }; type InsertRow = Omit; type InsertRawRow = Omit; -export function makeHash() { - const buf = new Uint8Array(32); - crypto.getRandomValues(buf); - return ethers.utils.hexlify(buf); -} - export type BundleRow = Row; function fromRawRow(rawRow: RawRow | sqlite.Row): Row { @@ -68,6 +64,7 @@ function fromRawRow(rawRow: RawRow | sqlite.Row): Row { nextEligibilityDelay: rawRow[5] as string, submitError: rawRow[6] as string | null, receipt: rawRow[7] as string | null, + aggregateHash: rawRow[8] as string | null, }; } @@ -99,6 +96,7 @@ function fromRawRow(rawRow: RawRow | sqlite.Row): Row { nextEligibilityDelay: BigNumber.from(rawRow.nextEligibilityDelay), submitError: rawRow.submitError ?? nil, receipt, + aggregateHash: rawRow.aggregateHash ?? nil, }; } @@ -109,6 +107,7 @@ function toInsertRawRow(row: InsertRow): InsertRawRow { bundle: JSON.stringify(bundleToDto(row.bundle)), eligibleAfter: toUint256Hex(row.eligibleAfter), nextEligibilityDelay: toUint256Hex(row.nextEligibilityDelay), + aggregateHash: row.aggregateHash ?? null, receipt: JSON.stringify(row.receipt), }; } @@ -123,6 +122,7 @@ function toRawRow(row: Row): RawRow { nextEligibilityDelay: toUint256Hex(row.nextEligibilityDelay), submitError: row.submitError ?? null, receipt: JSON.stringify(row.receipt), + aggregateHash: row.aggregateHash ?? null, }; } @@ -140,10 +140,11 @@ export default class BundleTable { eligibleAfter TEXT NOT NULL, nextEligibilityDelay TEXT NOT NULL, submitError TEXT, - receipt TEXT + receipt TEXT, + aggregateHash TEXT ) `); - } + } dbQuery(sql: string, params?: sqlite.QueryParameterSet) { this.onQuery(sql, params); @@ -164,7 +165,8 @@ export default class BundleTable { eligibleAfter, nextEligibilityDelay, submitError, - receipt + receipt, + aggregateHash ) VALUES ( :id, :status, @@ -173,7 +175,8 @@ export default class BundleTable { :eligibleAfter, :nextEligibilityDelay, :submitError, - :receipt + :receipt, + :aggregateHash ) `, { @@ -184,6 +187,7 @@ export default class BundleTable { ":nextEligibilityDelay": rawRow.nextEligibilityDelay, ":submitError": rawRow.submitError, ":receipt": rawRow.receipt, + ":aggregateHash": rawRow.aggregateHash, }, ); } @@ -202,7 +206,8 @@ export default class BundleTable { eligibleAfter = :eligibleAfter, nextEligibilityDelay = :nextEligibilityDelay, submitError = :submitError, - receipt = :receipt + receipt = :receipt, + aggregateHash = :aggregateHash WHERE id = :id `, @@ -215,6 +220,7 @@ export default class BundleTable { ":nextEligibilityDelay": rawRow.nextEligibilityDelay, ":submitError": rawRow.submitError, ":receipt": rawRow.receipt, + ":aggregateHash": rawRow.aggregateHash, }, ); } @@ -255,6 +261,21 @@ export default class BundleTable { return rows.map(fromRawRow)[0]; } + findAggregateBundle(aggregateHash: string): Row[] | nil { + const rows = this.dbQuery( + ` + SELECT * from bundles + WHERE + aggregateHash = :aggregateHash AND + status = 'confirmed' + ORDER BY id ASC + `, + { ":aggregateHash": aggregateHash }, + ); + + return rows.map(fromRawRow); + } + count(): number { const result = this.dbQuery("SELECT COUNT(*) FROM bundles")[0][0]; assert(typeof result === "number"); diff --git a/aggregator/test/BundleService.test.ts b/aggregator/test/BundleService.test.ts index 80c3945d..c61af8fa 100644 --- a/aggregator/test/BundleService.test.ts +++ b/aggregator/test/BundleService.test.ts @@ -1,4 +1,4 @@ -import { assertBundleSucceeds, assertEquals, Operation } from "./deps.ts"; +import { assertBundleSucceeds, assertEquals, ethers, Operation } from "./deps.ts"; import Fixture from "./helpers/Fixture.ts"; @@ -211,6 +211,202 @@ Fixture.test("adds bundle with future nonce", async (fx) => { assertEquals(bundleService.bundleTable.count(), 1); }); +Fixture.test("Same bundle produces same hash", async (fx) => { + const bundleService = fx.createBundleService(); + const [wallet] = await fx.setupWallets(1); + const nonce = await wallet.Nonce(); + + const firstBundle = wallet.sign({ + nonce, + gas: 100000, + actions: [ + { + ethValue: 0, + contractAddress: fx.testErc20.address, + encodedFunction: fx.testErc20.interface.encodeFunctionData( + "mint", + [wallet.address, "3"], + ), + }, + ], + }); + + const secondBundle = wallet.sign({ + nonce, + gas: 999999, + actions: [ + { + ethValue: 0, + contractAddress: fx.testErc20.address, + encodedFunction: fx.testErc20.interface.encodeFunctionData( + "mint", + [wallet.address, "3"], + ), + }, + ], + }); + + const firstBundleHash = await bundleService.hashBundle(firstBundle); + const secondBundleHash = await bundleService.hashBundle(secondBundle); + + assertEquals(firstBundleHash, secondBundleHash); +}); + +Fixture.test("hashes bundle with single operation", async (fx) => { + const bundleService = fx.createBundleService(); + const [wallet] = await fx.setupWallets(1); + const nonce = await wallet.Nonce(); + + const bundle = wallet.sign({ + nonce, + gas: 100000, + actions: [ + { + ethValue: 0, + contractAddress: fx.testErc20.address, + encodedFunction: fx.testErc20.interface.encodeFunctionData( + "mint", + [wallet.address, "3"], + ), + }, + ], + }); + + const expectedSubBundleHashes = await Promise.all(bundle.operations.map(async (operation, index) => { + const bundlesWithoutSignature = { + publicKey: bundle.senderPublicKeys[index], + operation: { + nonce: operation.nonce, + actions: operation.actions, + }, + } + + const serializedBundle = JSON.stringify({ + publicKey: bundlesWithoutSignature.publicKey, + operation: bundlesWithoutSignature.operation, + }); + + const bundleHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(serializedBundle)) + const chainId = (await bundleService.ethereumService.provider.getNetwork()).chainId; + + const encoding = ethers.utils.defaultAbiCoder.encode( + ['bytes32', 'uint256'], + [bundleHash, chainId]) + return ethers.utils.keccak256(encoding) + })); + + const concatenatedHashes = expectedSubBundleHashes.join(""); + const expectedBundleHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(concatenatedHashes)); + + const hash = await bundleService.hashBundle(bundle); + + assertEquals(hash, expectedBundleHash); +}); + +Fixture.test("hashes bundle with multiple operations", async (fx) => { + const bundleService = fx.createBundleService(); + const [wallet] = await fx.setupWallets(1); + const nonce = await wallet.Nonce(); + + const bundle = fx.blsWalletSigner.aggregate([ + wallet.sign({ + nonce, + gas: 1_000_000, + actions: [ + { + ethValue: 0, + contractAddress: fx.testErc20.address, + encodedFunction: fx.testErc20.interface.encodeFunctionData( + "mint", + [wallet.address, 3], + ), + }, + ], + }), + wallet.sign({ + nonce: nonce.add(1), + gas: 1_000_000, + actions: [ + { + ethValue: 0, + contractAddress: fx.testErc20.address, + encodedFunction: fx.testErc20.interface.encodeFunctionData( + "mint", + [wallet.address, 5], + ), + }, + ], + }), + ]); + + const expectedSubBundleHashes = await Promise.all(bundle.operations.map(async (operation, index) => { + const bundlesWithoutSignature = { + publicKey: bundle.senderPublicKeys[index], + operation: { + nonce: operation.nonce, + actions: operation.actions, + }, + } + + const serializedBundle = JSON.stringify({ + publicKey: bundlesWithoutSignature.publicKey, + operation: bundlesWithoutSignature.operation, + }); + + const bundleHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(serializedBundle)) + + const chainId = (await bundleService.ethereumService.provider.getNetwork()).chainId; + const encoding = ethers.utils.defaultAbiCoder.encode( + ['bytes32', 'uint256'], + [bundleHash, chainId]) + + return ethers.utils.keccak256(encoding) + })); + + const concatenatedHashes = expectedSubBundleHashes.join(""); + const expectedBundleHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(concatenatedHashes)); + + const hash = await bundleService.hashBundle(bundle); + + assertEquals(hash, expectedBundleHash); +}); + +Fixture.test("hashes empty bundle", async (fx) => { + const bundleService = fx.createBundleService(); + const bundle = fx.blsWalletSigner.aggregate([]); + + const expectedSubBundleHashes = bundle.operations.map(async (operation, index) => { + const bundlesWithoutSignature = { + publicKey: bundle.senderPublicKeys[index], + operation: { + nonce: operation.nonce, + actions: operation.actions, + }, + } + + const serializedBundle = JSON.stringify({ + publicKey: bundlesWithoutSignature.publicKey, + operation: bundlesWithoutSignature.operation, + }); + + const bundleHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(serializedBundle)) + + const chainId = (await bundleService.ethereumService.provider.getNetwork()).chainId; + const encoding = ethers.utils.defaultAbiCoder.encode( + ['bytes32', 'uint256'], + [bundleHash, chainId]) + + return ethers.utils.keccak256(encoding) + }) + + const concatenatedHashes = expectedSubBundleHashes.join(""); + const expectedBundleHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(concatenatedHashes)); + + const hash = await bundleService.hashBundle(bundle); + + assertEquals(hash, expectedBundleHash); +}); + // TODO (merge-ok): Add a mechanism for limiting the number of stored // transactions (and add a new test for it). // Fixture.test( diff --git a/aggregator/test/BundleServiceSubmitting.test.ts b/aggregator/test/BundleServiceSubmitting.test.ts index 84af1fe5..767a0124 100644 --- a/aggregator/test/BundleServiceSubmitting.test.ts +++ b/aggregator/test/BundleServiceSubmitting.test.ts @@ -358,3 +358,57 @@ Fixture.test("updates status of failing bundle when its eligibility delay is lar const failedBundleRow = await bundleService.bundleTable.findBundle(res.hash); assertEquals(failedBundleRow?.status, "failed"); }); + +Fixture.test("Retrieves all sub bundles included in a submitted bundle from single a sub bundle", async (fx) => { + const bundleService = fx.createBundleService( + bundleServiceConfig, + aggregationStrategyConfig, + ); + + const wallets = await fx.setupWallets(3); + const firstWallet = wallets[0]; + const nonce = await firstWallet.Nonce(); + + const bundles = await Promise.all( + wallets.map((wallet) => + wallet.signWithGasEstimate({ + nonce, + actions: [ + { + ethValue: 0, + contractAddress: fx.testErc20.address, + encodedFunction: fx.testErc20.interface.encodeFunctionData( + "mint", + [firstWallet.address, 1], + ), + }, + ], + }) + ), + ); + + const subBundleHashes = await Promise.all(bundles.map(async (bundle) => { + const res = await bundleService.add(bundle); + + if ("failures" in res) { + throw new Error("Bundle failed to be created"); + } + + return res.hash; + })); + + await bundleService.submissionTimer.trigger(); + await bundleService.waitForConfirmations(); + + const aggregateBundle = bundleService.lookupAggregateBundle(subBundleHashes[0]); + + const firstSubBundle = bundleService.lookupBundle(subBundleHashes[0]); + const secondSubBundle = bundleService.lookupBundle(subBundleHashes[1]); + const thirdSubBundle = bundleService.lookupBundle(subBundleHashes[2]); + + const orderedSubBundles = [firstSubBundle, secondSubBundle, thirdSubBundle].sort((a, b) => a!.id! - b!.id!); + + assertEquals(aggregateBundle?.[0], orderedSubBundles[0]); + assertEquals(aggregateBundle?.[1], orderedSubBundles[1]); + assertEquals(aggregateBundle?.[2], orderedSubBundles[2]); +}); \ No newline at end of file diff --git a/aggregator/test/BundleTable.test.ts b/aggregator/test/BundleTable.test.ts index 7321f66e..9599d733 100644 --- a/aggregator/test/BundleTable.test.ts +++ b/aggregator/test/BundleTable.test.ts @@ -29,6 +29,7 @@ const sampleRows: BundleRow[] = [ nextEligibilityDelay: BigNumber.from(1), submitError: nil, receipt: nil, + aggregateHash: nil, }, ]; diff --git a/aggregator/test/helpers/Fixture.ts b/aggregator/test/helpers/Fixture.ts index 9282c0fd..27b74a85 100644 --- a/aggregator/test/helpers/Fixture.ts +++ b/aggregator/test/helpers/Fixture.ts @@ -52,7 +52,7 @@ export const aggregationStrategyDefaultTestConfig: AggregationStrategyConfig = { export default class Fixture { static test( name: string, - fn: (fx: Fixture) => Promise, + fn: (fx: Fixture) => Promise | void, ) { Deno.test({ name,