Use deterministic bundle hashes in aggregator

This commit is contained in:
JohnGuilding
2023-04-28 21:33:30 +01:00
parent 1ff60d5dd1
commit 29542e4c98
7 changed files with 362 additions and 16 deletions

View File

@@ -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;
}

View File

@@ -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<Operation, "gas">
};
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<string> {
const bundleSubHashes = await this.#hashSubBundles(bundle);
const concatenatedHashes = bundleSubHashes.join("");
return ethers.utils.keccak256(ethers.utils.toUtf8Bytes(concatenatedHashes));
}
async #hashSubBundles(bundle: Bundle): Promise<Array<string>> {
const bundlesWithoutSignature: Array<BundleWithoutSignature> =
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",

View File

@@ -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<Row, "id">;
type InsertRawRow = Omit<RawRow, "id">;
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");

View File

@@ -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(

View File

@@ -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]);
});

View File

@@ -29,6 +29,7 @@ const sampleRows: BundleRow[] = [
nextEligibilityDelay: BigNumber.from(1),
submitError: nil,
receipt: nil,
aggregateHash: nil,
},
];

View File

@@ -52,7 +52,7 @@ export const aggregationStrategyDefaultTestConfig: AggregationStrategyConfig = {
export default class Fixture {
static test(
name: string,
fn: (fx: Fixture) => Promise<void>,
fn: (fx: Fixture) => Promise<void> | void,
) {
Deno.test({
name,