Skip to content

Commit

Permalink
Batch Drafting w. Fixed Fee (#14)
Browse files Browse the repository at this point in the history
This PR introduces the invoking of "draft" and "fine" in a single transaction as a Multisend. 
For simplification we still use the Fixed Fee Model. In a follow up, we will introduce dynamic fines calculated based on gas estimation.
  • Loading branch information
cowanator committed Jun 14, 2024
1 parent f3bbdbe commit ce99040
Show file tree
Hide file tree
Showing 7 changed files with 159 additions and 38 deletions.
1 change: 1 addition & 0 deletions .github/workflows/pull-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ jobs:
RPC_URL: https://rpc2.sepolia.org
BILLING_CONTRACT_ADDRESS: "0xF1436859a0F04A827b79F8c92736F6331ebB64A1"
ROLE_KEY: "0x6d622d6472616674000000000000000000000000000000000000000000000000"
FINE_FEE: 3
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"license": "MIT",
"dependencies": {
"@duneanalytics/client-sdk": "^0.1.5",
"@ethersproject/bytes": "^5.7.0",
"@types/node": "^20.11.30",
"ethers": "^6.12.0",
"loglevel": "^1.9.1",
Expand Down
2 changes: 2 additions & 0 deletions src/abis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ export const BILLING_CONTRACT_ABI = [
export const ROLE_MODIFIER_ABI = [
"function execTransactionWithRole(address to, uint256 value, bytes data, uint8 operation, bytes32 roleKey, bool shouldRevert) returns (bool success)",
];

export const MULTI_SEND_ABI = ["function multiSend(bytes memory transactions)"];
101 changes: 71 additions & 30 deletions src/billingContract.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ethers, formatEther } from "ethers";
import { BillingData, LatestBillingStatus, PaymentStatus } from "./types";
import { BILLING_CONTRACT_ABI, ROLE_MODIFIER_ABI } from "./abis";
import { MetaTransaction, encodeMulti } from "./multisend";

interface BillingInput {
addresses: `0x${string}`[];
Expand All @@ -12,6 +13,7 @@ export class BillingContract {
readonly contract: ethers.Contract;
readonly roleContract: ethers.Contract;
private roleKey?: string;
fineRecipient: ethers.AddressLike;
fineAmount: bigint;

constructor(
Expand All @@ -26,6 +28,7 @@ export class BillingContract {
ROLE_MODIFIER_ABI,
signer,
);
this.fineRecipient = signer.address;
this.roleKey = roleKey;
if (!roleKey) {
console.warn(
Expand Down Expand Up @@ -69,7 +72,7 @@ export class BillingContract {

async processPaymentStatuses(
paymentStatuses: LatestBillingStatus[],
): Promise<string[]> {
): Promise<string> {
const unpaidRecords = paymentStatuses.filter((record) => {
const { account, status, paidAmount, billedAmount } = record;
if (status == PaymentStatus.UNPAID) {
Expand All @@ -85,34 +88,49 @@ export class BillingContract {
}
return false;
});
// These drafts must be processed sequentially
// Otherwise the owner account nonce will not be incremented.
const resultHashes = [];
let txBatch: MetaTransaction[] = [];
for (const rec of unpaidRecords) {
console.log(`Executing draft for ${rec.account}...`);
const draftHash = await this.draft(
rec.account,
rec.billedAmount - rec.paidAmount,
console.log(`Attaching Draft and Fine for ${rec.account}...`);
txBatch.push(
await this.buildDraft(rec.account, rec.billedAmount - rec.paidAmount),
);
resultHashes.push(draftHash);
if (this.fineAmount > 0) {
console.log(`Executing fine for ${rec.account}...`);
const fineHash = await this.fine(rec.account, this.fineAmount);
resultHashes.push(fineHash);
txBatch.push(
await this.buildFine(
rec.account,
this.fineAmount,
this.fineRecipient,
),
);
}
}
return resultHashes;
console.log(txBatch);
const tx = await this.execWithRole(txBatch, this.roleKey!);
await tx.wait();
return tx.hash;
}

async buildDraft(
account: `0x${string}`,
amount: bigint,
): Promise<MetaTransaction> {
return {
to: await this.contract.getAddress(),
data: this.contract.interface.encodeFunctionData("draft", [
account,
amount,
]),
value: 0,
operation: 0,
};
}

async draft(account: `0x${string}`, amount: bigint): Promise<string> {
try {
let tx: ethers.ContractTransactionResponse;
if (this.roleKey) {
tx = await this.execWithRole(
this.contract.interface.encodeFunctionData("draft", [
account,
amount,
]),
[await this.buildDraft(account, amount)],
this.roleKey,
);
} else {
Expand All @@ -126,18 +144,33 @@ export class BillingContract {
}
}

async fine(account: `0x${string}`, amount: bigint): Promise<string> {
async buildFine(
account: ethers.AddressLike,
amount: bigint,
feeRecipient: ethers.AddressLike,
): Promise<MetaTransaction> {
return {
to: await this.contract.getAddress(),
data: this.contract.interface.encodeFunctionData("fine", [
account,
amount,
feeRecipient,
]),
value: 0,
operation: 0,
};
}

async fine(
account: `0x${string}`,
amount: bigint,
feeRecipient: `0x${string}`,
): Promise<string> {
try {
let tx: ethers.ContractTransactionResponse;
// Fee Recipient is MEVBlockerFeeTill Contract.
const feeRecipient = await this.contract.getAddress();
if (this.roleKey) {
tx = await this.execWithRole(
this.contract.interface.encodeFunctionData("fine", [
account,
amount,
feeRecipient,
]),
[await this.buildFine(account, amount, feeRecipient)],
this.roleKey,
);
} else {
Expand All @@ -152,14 +185,22 @@ export class BillingContract {
}

async execWithRole(
data: string,
metaTransactions: MetaTransaction[],
roleKey: string,
): Promise<ethers.ContractTransactionResponse> {
if (metaTransactions.length === 0)
throw new Error("No transactions to execute");

// Combine transactions into one.
const metaTx =
metaTransactions.length === 1
? metaTransactions[0]
: encodeMulti(metaTransactions);
return this.roleContract.execTransactionWithRole(
this.contract.getAddress(), // to
0, // value
data,
0, // operation
metaTx.to,
metaTx.value,
metaTx.data,
metaTx.operation,
roleKey,
true, // shouldRevert
);
Expand Down
66 changes: 66 additions & 0 deletions src/multisend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ethers } from "ethers";
import { hexDataLength } from "@ethersproject/bytes";
import { MULTI_SEND_ABI } from "./abis";

export const MULTISEND_141 = "0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526";
const MULTISEND_CALLONLY_141 = "0x9641d764fc13c8B624c04430C7356C1C7C8102e2";

export enum OperationType {
Call = 0,
DelegateCall = 1,
}
/**
*
*/
export interface MetaTransaction {
/// A `uint8` with `0` for a `call` or `1` for a `delegatecall` (=> 1 byte),
readonly operation?: OperationType;
/// `to` as an `address` (=> 20 bytes),
readonly to: string;
/// ETH value of the transaction (uint256)
readonly value: ethers.BigNumberish;
/// Transaction call data (bytes)
readonly data: string;
}

const remove0x = (hexString: string) => hexString.slice(2);

/**
* Encodes the MetaTransaction as packed bytes
*/
export function encodeMetaTransaction(metaTx: MetaTransaction): string {
const types = ["uint8", "address", "uint256", "uint256", "bytes"];
const values = [
// Default to CALL if operation is undefined
metaTx.operation ?? OperationType.Call,
metaTx.to,
metaTx.value,
hexDataLength(metaTx.data),
metaTx.data,
];
return ethers.solidityPacked(types, values);
}

export const encodeMulti = (
transactions: readonly MetaTransaction[],
multiSendContractAddress: string = transactions.some(
(t) => t.operation && t.operation === OperationType.DelegateCall,
)
? MULTISEND_141
: MULTISEND_CALLONLY_141,
): MetaTransaction => {
const transactionsEncoded =
"0x" + transactions.map(encodeMetaTransaction).map(remove0x).join("");

const multiSendContract = new ethers.Interface(MULTI_SEND_ABI);
const data = multiSendContract.encodeFunctionData("multiSend", [
transactionsEncoded,
]);

return {
operation: OperationType.DelegateCall,
to: multiSendContractAddress,
value: "0x00",
data,
};
};
14 changes: 6 additions & 8 deletions tests/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,16 @@ describe("e2e - Sepolia", () => {
it("Runs the drafting flow with mainnet data on Sepolia billing contract", async () => {
const paymentStatus = await dataFetcher.getPaymentStatus();
const billingContract = BillingContract.fromEnv();
const draftingHashes =
const draftHash =
await billingContract.processPaymentStatuses(paymentStatus);
// This is a non-deterministic test.
console.log("Drafting Hashes");
expect(draftingHashes.length).toEqual(2);
console.log("Drafting Hashes", draftHash);

const provider = billingContract.contract.runner!.provider;
draftingHashes.map(async (hash) => {
const receipt = await provider!.getTransactionReceipt(hash);
const logs = receipt?.logs;
expect(logs!.length).toEqual(1);
});
const receipt = await provider!.getTransactionReceipt(draftHash);
// 2 drafts + 2 fines + 2 safe module transactions.
const expectedLogs = 1 + 2 + 2 + 1;
expect(receipt?.logs!.length).toEqual(expectedLogs);
});

it.skip("e2e: successfully calls bill on BillingContract (with mock billing data)", async () => {
Expand Down
12 changes: 12 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,18 @@
deprecated "^0.0.2"
loglevel "^1.8.0"

"@ethersproject/bytes@^5.7.0":
version "5.7.0"
resolved "https://registry.yarnpkg.com/@ethersproject/bytes/-/bytes-5.7.0.tgz#a00f6ea8d7e7534d6d87f47188af1148d71f155d"
integrity sha512-nsbxwgFXWh9NyYWo+U8atvmMsSdKJprTcICAkvbBffT75qDocbuggBU0SJiVK2MuTrp0q+xvLkTnGMPK1+uA9A==
dependencies:
"@ethersproject/logger" "^5.7.0"

"@ethersproject/logger@^5.7.0":
version "5.7.0"
resolved "https://registry.yarnpkg.com/@ethersproject/logger/-/logger-5.7.0.tgz#6ce9ae168e74fecf287be17062b590852c311892"
integrity sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig==

"@istanbuljs/load-nyc-config@^1.0.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
Expand Down

0 comments on commit ce99040

Please sign in to comment.