Skip to content

Commit

Permalink
Dynamically Evaluated Fines (#16)
Browse files Browse the repository at this point in the history
This PR introduces a more sophisticated fine mechanism. The bundle of drafts is constructed as a multisend then a gas estimation is performed. The total transaction cost is then estimated as

txCost = gasEstimation * min(baseFeePerGas + maxPriorityFeePerGas, maxFeePerGas)

The fine amount is then

max((txCost * 2) / drafts.length, MIN_FINE);

where txCost * 2 represents the estimation of the bundle of both (drafts & fines), division by the number of drafts distributed the fine equally among the accounts being drafted.

So this fineAmount says, the fine is MIN_FINE unless the transaction cost for execution winds up being more.
  • Loading branch information
cowanator committed Jun 17, 2024
1 parent ce99040 commit c4a4b0a
Show file tree
Hide file tree
Showing 5 changed files with 110 additions and 42 deletions.
19 changes: 9 additions & 10 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
# Dune Details
BILLING_QUERY=3630322
FEE_QUERY=3605385
PAYMENT_QUERY=3742749
BOND_MAP="('0xa489faf6e337d997b8a23e2b6f3a8880b1b61e19', '0xfd39bc23d356a762cf80f60b7bc8d2a4b9bcfe67')"

# Node Details
## Testnet
RPC_URL=https://rpc.ankr.com/eth_sepolia
BILLING_CONTRACT_ADDRESS=0xF1436859a0F04A827b79F8c92736F6331ebB64A1

## Mainnet
# RPC_URL=https://rpc.ankr.com/eth
# BILLING_CONTRACT_ADDRESS=0x08Cd77fEB3fB28CC1606A91E0Ea2f5e3EABa1A9a

# Billing Config
BILLING_QUERY=3630322
FEE_QUERY=3605385

# Drafting Config
ROLE_KEY=
FINE_FEE=0
## This is $3.50 with ETH at $3500
FINE_MIN=0.001
PAYMENT_QUERY=3742749
BOND_MAP="('0xa489faf6e337d997b8a23e2b6f3a8880b1b61e19', '0xfd39bc23d356a762cf80f60b7bc8d2a4b9bcfe67')"

# Secrets
# Secrets: Required for both Billing & Drafting
DUNE_API_KEY=
# Required for Billing & Drafting
BILLER_PRIVATE_KEY=
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,31 +26,34 @@ yarn
cp .env.sample .env
```

Some values are filled, but others require secrets (`DUNE_API_KEY`, `BILLER_PRIVATE_KEY` for billing and `OWNER_PRIVATE_KEY` for drafting).
Some values are filled, but others require secrets (`DUNE_API_KEY`, `BILLER_PRIVATE_KEY` for billing and `ROLE_KEY` for drafting).

Run the Script:

```sh
# Billing: Requires `BILLER_PRIVATE_KEY`
yarn main billing
# Drafting: Requires `OWNER_PRIVATE_KEY`
# Drafting: Requires `BILLER_PRIVATE_KEY` & `ROLE_KEY`
yarn main drafting
```

## Docker

**Local build and run**
**Build**

```sh
docker build -t mb-billing .
docker run --rm --env-file .env mb-billing $PROGRAM
```

where `PROGRAM` is one of {billing, drafting}.

**Published image**
**Run**

```sh
export PROGRAM={billing, drafting}
# Local:
docker run --rm --env-file .env mb-billing $PROGRAM
# Published Image:
docker run --rm --env-file .env ghcr.io/cowanator/mb-billing:main $PROGRAM
```

where `PROGRAM` is one of {billing, drafting}.
77 changes: 54 additions & 23 deletions src/billingContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,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";
import { getTxCostForGas, maxBigInt, minBigInt } from "./gas";

interface BillingInput {
addresses: `0x${string}`[];
Expand All @@ -10,18 +11,21 @@ interface BillingInput {
}

export class BillingContract {
readonly provider: ethers.JsonRpcProvider;
readonly contract: ethers.Contract;
readonly roleContract: ethers.Contract;
private roleKey?: string;
fineRecipient: ethers.AddressLike;
fineAmount: bigint;
minFine: bigint;

constructor(
address: string,
provider: ethers.JsonRpcProvider,
signer: ethers.Wallet,
fineAmount: bigint,
roleKey?: string,
) {
this.provider = provider;
this.contract = new ethers.Contract(address, BILLING_CONTRACT_ABI, signer);
this.roleContract = new ethers.Contract(
"0xa2f93c12E697ABC34770CFAB2def5532043E26e9",
Expand All @@ -35,7 +39,7 @@ export class BillingContract {
`No ROLE_KEY provided, executing transactions as ${signer.address}`,
);
}
this.fineAmount = fineAmount;
this.minFine = fineAmount;
}

static fromEnv(): BillingContract {
Expand All @@ -44,15 +48,16 @@ export class BillingContract {
BILLER_PRIVATE_KEY,
BILLING_CONTRACT_ADDRESS,
ROLE_KEY,
FINE_FEE,
FINE_MIN,
} = process.env;
const provider = new ethers.JsonRpcProvider(RPC_URL!);
const signer = new ethers.Wallet(BILLER_PRIVATE_KEY!, provider);
const fineAmount = FINE_FEE ? BigInt(FINE_FEE) : 0n;
const minFine = FINE_MIN ? ethers.parseEther(FINE_MIN) : 0n;
return new BillingContract(
BILLING_CONTRACT_ADDRESS!,
provider,
signer,
fineAmount,
minFine,
ROLE_KEY,
);
}
Expand All @@ -72,7 +77,7 @@ export class BillingContract {

async processPaymentStatuses(
paymentStatuses: LatestBillingStatus[],
): Promise<string> {
): Promise<string | undefined> {
const unpaidRecords = paymentStatuses.filter((record) => {
const { account, status, paidAmount, billedAmount } = record;
if (status == PaymentStatus.UNPAID) {
Expand All @@ -88,26 +93,52 @@ export class BillingContract {
}
return false;
});
let txBatch: MetaTransaction[] = [];
for (const rec of unpaidRecords) {
console.log(`Attaching Draft and Fine for ${rec.account}...`);
txBatch.push(
await this.buildDraft(rec.account, rec.billedAmount - rec.paidAmount),
);
if (this.fineAmount > 0) {
txBatch.push(
await this.buildFine(
rec.account,
this.fineAmount,
this.fineRecipient,
),
let drafts: MetaTransaction[] = [];
let fines: MetaTransaction[] = [];
if (unpaidRecords.length > 0) {
for (const rec of unpaidRecords) {
console.log(`Attaching Draft for ${rec.account}...`);
drafts.push(
await this.buildDraft(rec.account, rec.billedAmount - rec.paidAmount),
);
}
const fineAmount = await this.evaluateFine(drafts);
for (const rec of unpaidRecords) {
console.log(`Attaching Fine for ${rec.account}...`);
fines.push(
await this.buildFine(rec.account, fineAmount, this.fineRecipient),
);
}
console.log(`Executing ${drafts.length} drafts & fines`);
const tx = await this.execWithRole([...drafts, ...fines], this.roleKey!);
await tx.wait();
return tx.hash;
} else {
console.log("No Drafts to execute!");
}
console.log(txBatch);
const tx = await this.execWithRole(txBatch, this.roleKey!);
await tx.wait();
return tx.hash;
return;
}

async evaluateFine(drafts: MetaTransaction[]): Promise<bigint> {
const metaTx = drafts.length > 1 ? encodeMulti(drafts) : drafts[0];
const gasEstimate =
await this.roleContract.execTransactionWithRole.estimateGas(
metaTx.to,
metaTx.value,
metaTx.data,
metaTx.operation,
this.roleKey,
true, // shouldRevert
);
const txCost = await getTxCostForGas(this.provider, gasEstimate);
// Larger of minFine and estimated txCost per account (2x because of Draft + Fine)
// So if the fine tx is more expensive than minFine we charge that.
const fineAmount = maxBigInt(
(txCost * 2n) / BigInt(drafts.length),
this.minFine,
);
console.log("Fine Amount:", fineAmount);
return fineAmount;
}

async buildDraft(
Expand Down
36 changes: 36 additions & 0 deletions src/gas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ethers } from "ethers";

export async function getTxCostForGas(
provider: ethers.JsonRpcProvider,
gasEstimate: bigint,
): Promise<bigint> {
const [{ maxPriorityFeePerGas, maxFeePerGas }, latestBlock] =
await Promise.all([provider.getFeeData(), provider.getBlock("latest")]);
if (!maxPriorityFeePerGas || !maxFeePerGas) {
throw new Error("no gas fee data");
}
const baseFeePerGas = latestBlock!.baseFeePerGas;
if (!baseFeePerGas) {
throw new Error("No base fee data");
}
const effectiveGasPrice = minBigInt(
baseFeePerGas + maxPriorityFeePerGas,
maxFeePerGas,
);

return gasEstimate * effectiveGasPrice;
}

export function minBigInt(a: bigint, b: bigint): bigint {
if (a < b) {
return a;
}
return b;
}

export function maxBigInt(a: bigint, b: bigint): bigint {
if (a > b) {
return a;
}
return b;
}
5 changes: 2 additions & 3 deletions tests/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,9 @@ describe("e2e - Sepolia", () => {
const draftHash =
await billingContract.processPaymentStatuses(paymentStatus);
// This is a non-deterministic test.
console.log("Drafting Hashes", draftHash);

console.log("Drafting Hashe", draftHash);
const provider = billingContract.contract.runner!.provider;
const receipt = await provider!.getTransactionReceipt(draftHash);
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);
Expand Down

0 comments on commit c4a4b0a

Please sign in to comment.