mirror of
https://github.com/getwax/bls-wallet.git
synced 2026-01-08 23:28:21 -05:00
Use deterministic bundle hashes in aggregator
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -29,6 +29,7 @@ const sampleRows: BundleRow[] = [
|
||||
nextEligibilityDelay: BigNumber.from(1),
|
||||
submitError: nil,
|
||||
receipt: nil,
|
||||
aggregateHash: nil,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user