diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b7014ac --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +SHAPE_SEPOLIA_RPC_URL=https://shape-sepolia.g.alchemy.com/v2/YOUR_KEY +GASBACK_ADDRESS=0x21e34c5bea9253CDCd57671A1970BB31df4aBe83 +SPLITTER_ADDRESS=0x658E643b379b52Cd21605bFaF9C81e84713d8427 +GASBACK_TEST_CALLER_ADDRESS=0xA53D127f193858f5ef2Cf50dd1B3A94198ef811d +GASBACK_LIVE_PROBE_ADDRESS= +PRIVATE_KEY= +MAX_WEI_SPEND=1000000000000000 +MAX_GAS_TO_BURN=120000 +REPORT_PATH=gasback-live-report.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b03213..8674100 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,12 +5,22 @@ on: branches: [main] paths: - '**.sol' + - '**.ts' - '**.yml' + - '.env.example' + - 'bun.lock' + - 'package.json' + - 'tsconfig.json' push: branches: [main] paths: - '**.sol' + - '**.ts' - '**.yml' + - '.env.example' + - 'bun.lock' + - 'package.json' + - 'tsconfig.json' jobs: tests: name: Forge Testing @@ -34,12 +44,44 @@ jobs: - name: Run Tests with ${{ matrix.profile }} run: > ( [ "${{ matrix.profile }}" = "regular" ] && - forge test + forge test --disable-labels ) || ( [ "${{ matrix.profile }}" = "intense" ] && - forge test --fuzz-runs 5000 + forge test --disable-labels --fuzz-runs 5000 ) + gasback-live-system: + name: Gasback live system checks + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.9 + + - name: Install Foundry Dependencies + run: forge install + + - name: Install TypeScript Dependencies + run: bun install --frozen-lockfile + + - name: Run TypeScript Tests + run: bun run lint + + - name: Run Local Gasback Suite + run: bun run gasback:local + + - name: Run Fork Gasback Suite + run: bun run gasback:fork + codespell: runs-on: ${{ matrix.os }} strategy: @@ -57,4 +99,3 @@ jobs: check_filenames: true ignore_words_list: usera skip: ./.git,package-lock.json,ackee-blockchain-solady-report.pdf,EIP712Mock.sol - diff --git a/.gitignore b/.gitignore index 198f4cb..db81bc5 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,8 @@ wake-coverage.cov create2 # Coverage report -report \ No newline at end of file +report + +# Gasback live test reports +gasback-live-report.json +gasback-live-report.*.json diff --git a/README.md b/README.md index 7b1d7e5..1e1a64c 100644 --- a/README.md +++ b/README.md @@ -8,33 +8,37 @@ A barebones implementation of a gasback contract that implements [RIP-7767](http - The `baseFeeVault` is deployed at `0x4200000000000000000000000000000000000019`. - The `WITHDRAWAL_NETWORK` of the `baseFeeVault` is set to `1`. +- The `baseFeeVault` recipient is set to `ShapePaymentSplitter`. +- `Gasback` receives only its configured share from `ShapePaymentSplitter`. ### Via script -See `script/Delegate7702.s.sol` for an automated script that can help you deploy. +See `script/DeployGasback.s.sol` and `script/DeployShapePaymentSplitter.s.sol` for deployment scripts. -This script requires you to have the private key of the `baseFeeVault` recipient in your environment. +These scripts require you to have `PRIVATE_KEY` in your environment. For more information on how to run a foundry script, see `https://getfoundry.sh/guides/scripting-with-solidity`. ### Manual steps -1. Deploy the `gasback` contract which will be used as an implementation via EIP-7702. +1. Deploy the `Gasback` contract. -2. Use EIP-7702 to make the EOA `RECIPIENT` of the `baseFeeVault` delegated to the `gasback` implementation. - After delegating, use the EOA to call functions on itself to initialize the parameters: - +2. Deploy `ShapePaymentSplitter` with `Gasback` as one of the payees. + +3. Set the `baseFeeVault` recipient to the deployed `ShapePaymentSplitter`. + +4. Configure `Gasback` via authorized calls: + + - `setBaseFeeVault(address)` + `0x4200000000000000000000000000000000000019` + - `setBaseFeeVaultShareNumerator(uint256)` + `600000000000000000` (`0.6 ether`) and ensure it matches the splitter allocation for `Gasback`. - `setGasbackRatioNumerator(uint256)` - `900000000000000000` + Must be less than or equal to `setBaseFeeVaultShareNumerator`. - `setGasbackMaxBaseFee(uint256)` `115792089237316195423570985008687907853269984665640564039457584007913129639935` - - `setBaseFeeVault(address)` - `0x4200000000000000000000000000000000000019` -4. Put or leave some ETH into the EOA `RECIPIENT`, which will be the actual `gasback` contract. - The ETH will act as a buffer that will be temporarily dished out to contracts calling the EOA `RECIPIENT` in the span of a single block. +5. Put or leave some ETH in `Gasback`. + The ETH acts as a buffer that is temporarily dished out to contracts calling `Gasback` in the span of a single block. The base fees collected in a block will only be accrued into the `baseFeeVault` at the end of a block. - Try not to empty ETH from the `RECIPIENT` when you are actually taking out ETH from it. - -5. For better discoverabiity (for the devX), deploy the `gasbackBeacon` and use the system address to set the EOA `RECIPIENT`. - The exact CREATE2 instructions are in [`./deployments.md`](./deployments.md). + Try not to empty ETH from `Gasback` while actively serving gasback payouts. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..0c44644 --- /dev/null +++ b/bun.lock @@ -0,0 +1,53 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "gasback-by-vectorized", + "dependencies": { + "viem": "^2.33.3", + }, + "devDependencies": { + "@types/bun": "^1.3.1", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], + + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="], + + "@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], + + "@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], + + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + + "@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + + "abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + + "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], + + "ox": ["ox@0.14.20", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "viem": ["viem@2.48.11", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.14.20", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-+WZ5E0dBS6GtKb+1wEk5DeYRRRW42+pFnXCo67Ydodf42sBwO+hu3wnQy66lc4MKmHz+llPVdbyehYr9oTE2iw=="], + + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + } +} diff --git a/foundry.toml b/foundry.toml index 06c721a..2cc06bf 100644 --- a/foundry.toml +++ b/foundry.toml @@ -4,7 +4,7 @@ # The Default Profile [profile.default] -solc_version = "0.8.30" +solc_version = "0.8.28" evm_version = "prague" auto_detect_solc = false optimizer = true diff --git a/live/gasback-live.test.ts b/live/gasback-live.test.ts new file mode 100644 index 0000000..6845856 --- /dev/null +++ b/live/gasback-live.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test"; +import { + DENOMINATOR, + assertSpendWithinBudget, + buildCanaryGasValues, + computeOracle, + parseWei, + normalizePrivateKeyInput, + receiptFee, + stringifyReport, +} from "./gasback-live"; +import type { TransactionReceipt } from "viem"; + +describe("gasback oracle", () => { + test("computes payout and accrued delta with integer rounding", () => { + const result = computeOracle({ + gasToBurn: 333n, + baseFee: 100n, + ratioNumerator: 600_000_000_000_000_000n, + shareNumerator: 700_000_000_000_000_000n, + maxBaseFee: 1_000n, + gasbackBalanceBefore: 1_000_000n, + }); + + expect(result.ethFromGas).toBe(33_300n); + expect(result.expectedPayout).toBe(19_980n); + expect(result.expectedShare).toBe(23_310n); + expect(result.expectedAccruedDelta).toBe(3_330n); + expect(result.passThrough).toBe(false); + }); + + test("passes through when base fee exceeds max", () => { + const result = computeOracle({ + gasToBurn: 30_000n, + baseFee: 101n, + ratioNumerator: DENOMINATOR, + shareNumerator: DENOMINATOR, + maxBaseFee: 100n, + gasbackBalanceBefore: 3_030_000n, + }); + + expect(result.expectedPayout).toBe(0n); + expect(result.expectedAccruedDelta).toBe(0n); + expect(result.passThrough).toBe(true); + }); + + test("passes through when the local gasback buffer is insufficient", () => { + const result = computeOracle({ + gasToBurn: 30_000n, + baseFee: 100n, + ratioNumerator: DENOMINATOR, + shareNumerator: DENOMINATOR, + maxBaseFee: 100n, + gasbackBalanceBefore: 2_999_999n, + }); + + expect(result.expectedPayout).toBe(0n); + expect(result.expectedAccruedDelta).toBe(0n); + expect(result.passThrough).toBe(true); + }); +}); + +describe("canary guards", () => { + test("selects zero, small, and bounded medium canaries", () => { + expect(buildCanaryGasValues(120_000n)).toEqual([0n, 30_000n, 120_000n]); + expect(buildCanaryGasValues(30_000n)).toEqual([0n, 30_000n]); + expect(buildCanaryGasValues(1n)).toEqual([0n]); + }); + + test("parses decimal and hex wei values", () => { + expect(parseWei("123", "VALUE")).toBe(123n); + expect(parseWei("0x10", "VALUE")).toBe(16n); + expect(() => parseWei("", "VALUE")).toThrow("VALUE is empty"); + expect(() => parseWei("-1", "VALUE")).toThrow("VALUE must be non-negative"); + expect(() => parseWei("1.2", "VALUE")).toThrow("VALUE must be a decimal or hex integer"); + }); + + test("normalizes private keys without accepting malformed input", () => { + const raw = "1".repeat(64); + expect(normalizePrivateKeyInput(raw)).toBe(`0x${raw}`); + expect(normalizePrivateKeyInput(`0x${raw}`)).toBe(`0x${raw}`); + expect(() => normalizePrivateKeyInput("0x1")).toThrow("PRIVATE_KEY must be 32 bytes"); + }); + + test("rejects estimated spend over the remaining budget", () => { + expect(() => assertSpendWithinBudget(10n, 10n, "case")).not.toThrow(); + expect(() => assertSpendWithinBudget(11n, 10n, "case")).toThrow( + "case estimated cost 11 exceeds remaining budget 10", + ); + }); +}); + +describe("reporting", () => { + test("serializes bigint values as strings", () => { + expect(stringifyReport({ value: 1n })).toBe('{\n "value": "1"\n}'); + }); + + test("adds OP Stack fee fields to execution fees when present", () => { + const receipt = { + gasUsed: 10n, + effectiveGasPrice: 20n, + l1Fee: "0x64", + } as unknown as TransactionReceipt; + expect(receiptFee(receipt)).toEqual({ + executionFee: 200n, + extraReceiptFee: 100n, + totalFee: 300n, + }); + }); +}); diff --git a/live/gasback-live.ts b/live/gasback-live.ts new file mode 100644 index 0000000..240f0f3 --- /dev/null +++ b/live/gasback-live.ts @@ -0,0 +1,905 @@ +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { + createPublicClient, + createWalletClient, + decodeEventLog, + defineChain, + encodeDeployData, + getAddress, + http, + isAddress, + keccak256, + parseAbi, + type Abi, + type Address, + type Hex, + type PublicClient, + type TransactionReceipt, + type WalletClient, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +export const SHAPE_SEPOLIA_CHAIN_ID = 11011; +export const DEFAULT_GASBACK_ADDRESS = "0x21e34c5bea9253CDCd57671A1970BB31df4aBe83"; +export const DEFAULT_SPLITTER_ADDRESS = "0x658E643b379b52Cd21605bFaF9C81e84713d8427"; +export const DEFAULT_GASBACK_TEST_CALLER_ADDRESS = + "0xA53D127f193858f5ef2Cf50dd1B3A94198ef811d"; +export const DENOMINATOR = 1_000_000_000_000_000_000n; + +const gasbackAbi = parseAbi([ + "function GASBACK_RATIO_DENOMINATOR() view returns (uint256)", + "function gasbackRatioNumerator() view returns (uint256)", + "function baseFeeVaultShareNumerator() view returns (uint256)", + "function gasbackMaxBaseFee() view returns (uint256)", + "function baseFeeVault() view returns (address)", + "function accrued() view returns (uint256)", +]); + +const splitterAbi = parseAbi([ + "function totalShares() view returns (uint256)", + "function shares(address account) view returns (uint256)", + "function releasable(address account) view returns (uint256)", +]); + +const testCallerAbi = parseAbi(["function GASBACK() view returns (address)"]); + +const vaultAddressAbi = parseAbi([ + "function recipient() view returns (address)", + "function RECIPIENT() view returns (address)", +]); + +const vaultUintAbi = parseAbi([ + "function withdrawalNetwork() view returns (uint256)", + "function WITHDRAWAL_NETWORK() view returns (uint256)", +]); + +export type GasbackOracleInput = { + gasToBurn: bigint; + baseFee: bigint; + ratioNumerator: bigint; + shareNumerator: bigint; + maxBaseFee: bigint; + gasbackBalanceBefore: bigint; +}; + +export type GasbackOracleOutput = { + ethFromGas: bigint; + expectedShare: bigint; + expectedPayout: bigint; + expectedAccruedDelta: bigint; + passThrough: boolean; +}; + +export type LiveConfig = { + rpcUrl: string; + gasback: Address; + splitter: Address; + testCaller: Address; + probe?: Address; + maxWeiSpend?: bigint; + maxGasToBurn?: bigint; + reportPath: string; +}; + +type ForgeArtifact = { + abi: Abi; + bytecode: { object: Hex }; + deployedBytecode: { object: Hex }; +}; + +type GasbackState = { + ratioNumerator: bigint; + shareNumerator: bigint; + maxBaseFee: bigint; + baseFeeVault: Address; + accrued: bigint; + balance: bigint; +}; + +type AuditReport = { + chainId: number; + addresses: { + gasback: Address; + splitter: Address; + testCaller: Address; + }; + deployment: { + gasbackCodeHash: Hex; + localGasbackCodeHash: Hex; + gasbackCodeMatchesArtifact: boolean; + gasbackCodeBytes: number; + splitterCodeBytes: number; + testCallerCodeBytes: number; + }; + gasback: { + ratioNumerator: string; + baseFeeVaultShareNumerator: string; + gasbackMaxBaseFee: string; + baseFeeVault: Address; + baseFeeVaultCodeBytes: number; + }; + splitter: { + totalShares: string; + gasbackShares: string; + impliedGasbackShareNumerator: string; + matchesGasbackShareNumerator: boolean; + releasableToGasback: string; + }; + testCaller: { + gasback: Address; + matchesGasback: boolean; + }; + vault: { + recipientSupported: boolean; + recipient?: Address; + recipientMatchesSplitter?: boolean; + withdrawalNetworkSupported: boolean; + withdrawalNetwork?: string; + withdrawalNetworkIsL2?: boolean; + }; + failures: string[]; +}; + +type ProbeEvent = { + gasToBurn: bigint; + blockBaseFee: bigint; + payout: bigint; + accruedBefore: bigint; + accruedAfter: bigint; + gasbackBalanceBefore: bigint; + gasbackBalanceAfter: bigint; +}; + +type CanaryResult = { + gasToBurn: string; + transactionHash: Hex; + blockNumber: string; + payout: string; + accruedDelta: string; + expectedPayout: string; + expectedAccruedDelta: string; + gasbackBalanceBefore: string; + gasbackBalanceAfter: string; + probeBalanceDelta: string; + executionFee: string; + extraReceiptFee: string; + totalFee: string; + netProbeGainAfterFees: string; + profitable: boolean; + failures: string[]; +}; + +type GasbackLiveReport = { + generatedAt: string; + mode: "report" | "canary"; + audit: AuditReport; + probe?: Address; + canaries: CanaryResult[]; + failures: string[]; +}; + +export class GasbackLiveError extends Error { + constructor(message: string) { + super(message); + this.name = "GasbackLiveError"; + } +} + +export function computeOracle(input: GasbackOracleInput): GasbackOracleOutput { + const ethFromGas = input.gasToBurn * input.baseFee; + const expectedShare = (ethFromGas * input.shareNumerator) / DENOMINATOR; + let expectedPayout = (ethFromGas * input.ratioNumerator) / DENOMINATOR; + let expectedAccruedDelta = expectedShare - expectedPayout; + let passThrough = false; + + if (input.baseFee > input.maxBaseFee || expectedPayout > input.gasbackBalanceBefore) { + expectedPayout = 0n; + expectedAccruedDelta = 0n; + passThrough = true; + } + + return { ethFromGas, expectedShare, expectedPayout, expectedAccruedDelta, passThrough }; +} + +export function buildCanaryGasValues(maxGasToBurn: bigint): bigint[] { + const candidates = [0n, 30_000n, 120_000n] + .filter((value) => value <= maxGasToBurn) + .filter((value, index, values) => values.indexOf(value) === index); + if (candidates.length === 0 || candidates[0] !== 0n) { + candidates.unshift(0n); + } + return candidates; +} + +export function parseWei(value: string, name: string): bigint { + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new GasbackLiveError(`${name} is empty`); + } + try { + const parsed = trimmed.startsWith("0x") ? BigInt(trimmed) : BigInt(trimmed); + if (parsed < 0n) { + throw new GasbackLiveError(`${name} must be non-negative`); + } + return parsed; + } catch (error) { + if (error instanceof GasbackLiveError) { + throw error; + } + throw new GasbackLiveError(`${name} must be a decimal or hex integer`); + } +} + +export function normalizePrivateKeyInput(value: string): Hex { + return normalizePrivateKey(value); +} + +function normalizePrivateKey(privateKey: string): Hex { + const normalized = privateKey.startsWith("0x") ? privateKey : `0x${privateKey}`; + if (!/^0x[0-9a-fA-F]{64}$/.test(normalized)) { + throw new GasbackLiveError("PRIVATE_KEY must be 32 bytes"); + } + return normalized as Hex; +} + +function readPrivateKey(): Hex { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) { + throw new GasbackLiveError("PRIVATE_KEY is required"); + } + return normalizePrivateKey(privateKey); +} + +export function assertSpendWithinBudget(cost: bigint, remainingBudget: bigint, label: string) { + if (cost > remainingBudget) { + throw new GasbackLiveError( + `${label} estimated cost ${cost.toString()} exceeds remaining budget ${remainingBudget.toString()}`, + ); + } +} + +export function receiptFee(receipt: TransactionReceipt): { + executionFee: bigint; + extraReceiptFee: bigint; + totalFee: bigint; +} { + const executionFee = receipt.gasUsed * receipt.effectiveGasPrice; + const extraReceiptFee = ["l1Fee", "l1DataFee", "operatorFee"].reduce((sum, key) => { + const value = (receipt as unknown as Record)[key]; + return sum + unknownWei(value); + }, 0n); + return { executionFee, extraReceiptFee, totalFee: executionFee + extraReceiptFee }; +} + +export function stringifyReport(report: unknown): string { + return JSON.stringify( + report, + (_key, value) => (typeof value === "bigint" ? value.toString() : value), + 2, + ); +} + +async function main() { + const mode = process.argv[2]; + if (mode === "local") { + await runProcess("forge", ["test", "--offline", "--disable-labels"]); + return; + } + if (mode === "fork") { + const args = ["test", "--disable-labels", "--match-path", "test/GasbackLiveFork.t.sol"]; + if (!process.env.SHAPE_SEPOLIA_RPC_URL) { + args.splice(1, 0, "--offline"); + } + await runProcess("forge", args); + return; + } + if (mode === "report") { + const config = readConfig(false); + const report = await buildReport(config); + writeReport(config.reportPath, report); + assertNoFailures(report.failures); + return; + } + if (mode === "canary") { + const config = readConfig(true); + const report = await buildCanaryReport(config); + writeReport(config.reportPath, report); + assertNoFailures(report.failures); + return; + } + + throw new GasbackLiveError("Usage: bun run live/gasback-live.ts "); +} + +function readConfig(requireCanary: boolean): LiveConfig { + const rpcUrl = process.env.SHAPE_SEPOLIA_RPC_URL; + if (!rpcUrl) { + throw new GasbackLiveError("SHAPE_SEPOLIA_RPC_URL is required"); + } + + const config: LiveConfig = { + rpcUrl, + gasback: readAddress("GASBACK_ADDRESS", DEFAULT_GASBACK_ADDRESS), + splitter: readAddress("SPLITTER_ADDRESS", DEFAULT_SPLITTER_ADDRESS), + testCaller: readAddress("GASBACK_TEST_CALLER_ADDRESS", DEFAULT_GASBACK_TEST_CALLER_ADDRESS), + probe: process.env.GASBACK_LIVE_PROBE_ADDRESS + ? readAddress("GASBACK_LIVE_PROBE_ADDRESS", process.env.GASBACK_LIVE_PROBE_ADDRESS) + : undefined, + maxWeiSpend: process.env.MAX_WEI_SPEND + ? parseWei(process.env.MAX_WEI_SPEND, "MAX_WEI_SPEND") + : undefined, + maxGasToBurn: process.env.MAX_GAS_TO_BURN + ? parseWei(process.env.MAX_GAS_TO_BURN, "MAX_GAS_TO_BURN") + : undefined, + reportPath: process.env.REPORT_PATH ?? "gasback-live-report.json", + }; + + if (requireCanary) { + if (!process.env.PRIVATE_KEY) { + throw new GasbackLiveError("PRIVATE_KEY is required for canary mode"); + } + if (config.maxWeiSpend === undefined) { + throw new GasbackLiveError("MAX_WEI_SPEND is required for canary mode"); + } + if (config.maxGasToBurn === undefined) { + throw new GasbackLiveError("MAX_GAS_TO_BURN is required for canary mode"); + } + } + + return config; +} + +async function buildReport(config: LiveConfig): Promise { + const client = publicClient(config); + const audit = await runAudit(config, client); + return { + generatedAt: new Date().toISOString(), + mode: "report", + audit, + canaries: [], + failures: [...audit.failures], + }; +} + +async function buildCanaryReport(config: LiveConfig): Promise { + const client = publicClient(config); + const wallet = walletClient(config); + const audit = await runAudit(config, client); + const report: GasbackLiveReport = { + generatedAt: new Date().toISOString(), + mode: "canary", + audit, + canaries: [], + failures: [...audit.failures], + }; + + if (report.failures.length !== 0) { + return report; + } + + let remainingBudget = config.maxWeiSpend ?? 0n; + const account = privateKeyToAccount(readPrivateKey()); + const probe = await resolveProbe(config, client, wallet, account.address, remainingBudget); + report.probe = probe.address; + remainingBudget -= probe.estimatedCost; + + const gasValues = buildCanaryGasValues(config.maxGasToBurn ?? 0n); + for (const gasToBurn of gasValues) { + const result = await runCanaryCase({ + config, + client, + wallet, + probeAddress: probe.address, + account: account.address, + gasToBurn, + remainingBudget, + }); + remainingBudget -= result.estimatedCost; + report.canaries.push(result.result); + report.failures.push(...result.result.failures); + } + + return report; +} + +async function runAudit(config: LiveConfig, client: PublicClient): Promise { + const gasbackArtifact = loadArtifact("out/Gasback.sol/Gasback.json"); + const chainId = await client.getChainId(); + const gasbackCode = await getCode(client, config.gasback); + const splitterCode = await getCode(client, config.splitter); + const testCallerCode = await getCode(client, config.testCaller); + + const state = await readGasbackState(client, config.gasback); + const [denominator, totalShares, gasbackShares, releasableToGasback, testCallerGasback] = + await Promise.all([ + client.readContract({ + address: config.gasback, + abi: gasbackAbi, + functionName: "GASBACK_RATIO_DENOMINATOR", + }), + client.readContract({ + address: config.splitter, + abi: splitterAbi, + functionName: "totalShares", + }), + client.readContract({ + address: config.splitter, + abi: splitterAbi, + functionName: "shares", + args: [config.gasback], + }), + client.readContract({ + address: config.splitter, + abi: splitterAbi, + functionName: "releasable", + args: [config.gasback], + }), + client.readContract({ + address: config.testCaller, + abi: testCallerAbi, + functionName: "GASBACK", + }), + ]); + + const baseFeeVaultCode = await getCode(client, state.baseFeeVault); + const impliedShare = totalShares === 0n ? 0n : (gasbackShares * DENOMINATOR) / totalShares; + const liveCodeHash = keccak256(gasbackCode); + const localCodeHash = keccak256(gasbackArtifact.deployedBytecode.object); + const vault = await readVaultAudit(client, state.baseFeeVault, config.splitter); + + const report: AuditReport = { + chainId, + addresses: { + gasback: config.gasback, + splitter: config.splitter, + testCaller: config.testCaller, + }, + deployment: { + gasbackCodeHash: liveCodeHash, + localGasbackCodeHash: localCodeHash, + gasbackCodeMatchesArtifact: liveCodeHash === localCodeHash, + gasbackCodeBytes: byteLength(gasbackCode), + splitterCodeBytes: byteLength(splitterCode), + testCallerCodeBytes: byteLength(testCallerCode), + }, + gasback: { + ratioNumerator: state.ratioNumerator.toString(), + baseFeeVaultShareNumerator: state.shareNumerator.toString(), + gasbackMaxBaseFee: state.maxBaseFee.toString(), + baseFeeVault: state.baseFeeVault, + baseFeeVaultCodeBytes: byteLength(baseFeeVaultCode), + }, + splitter: { + totalShares: totalShares.toString(), + gasbackShares: gasbackShares.toString(), + impliedGasbackShareNumerator: impliedShare.toString(), + matchesGasbackShareNumerator: impliedShare === state.shareNumerator, + releasableToGasback: releasableToGasback.toString(), + }, + testCaller: { + gasback: getAddress(testCallerGasback), + matchesGasback: getAddress(testCallerGasback) === config.gasback, + }, + vault, + failures: [], + }; + + if (chainId !== SHAPE_SEPOLIA_CHAIN_ID) { + report.failures.push(`wrong chain id: expected ${SHAPE_SEPOLIA_CHAIN_ID}, got ${chainId}`); + } + if (gasbackCode === "0x") report.failures.push("gasback has no code"); + if (splitterCode === "0x") report.failures.push("splitter has no code"); + if (testCallerCode === "0x") report.failures.push("test caller has no code"); + if (!report.deployment.gasbackCodeMatchesArtifact) { + report.failures.push("gasback deployed bytecode does not match local artifact"); + } + if (denominator !== DENOMINATOR) { + report.failures.push(`unexpected denominator: ${denominator.toString()}`); + } + if (state.ratioNumerator > state.shareNumerator) { + report.failures.push("gasback ratio numerator exceeds base fee vault share numerator"); + } + if (state.shareNumerator > DENOMINATOR) { + report.failures.push("base fee vault share numerator exceeds denominator"); + } + if (baseFeeVaultCode === "0x") { + report.failures.push("base fee vault has no code"); + } + if (totalShares === 0n) { + report.failures.push("splitter total shares is zero"); + } + if (gasbackShares === 0n) { + report.failures.push("splitter gives gasback zero shares"); + } + if (impliedShare !== state.shareNumerator) { + report.failures.push( + `splitter implied gasback share ${impliedShare.toString()} does not match gasback share numerator ${state.shareNumerator.toString()}`, + ); + } + if (!report.testCaller.matchesGasback) { + report.failures.push("test caller points at a different gasback address"); + } + if (vault.recipientSupported && !vault.recipientMatchesSplitter) { + report.failures.push("base fee vault recipient does not match splitter"); + } + if (vault.withdrawalNetworkSupported && !vault.withdrawalNetworkIsL2) { + report.failures.push("base fee vault withdrawal network is not 1"); + } + + return report; +} + +async function runCanaryCase(input: { + config: LiveConfig; + client: PublicClient; + wallet: WalletClient; + probeAddress: Address; + account: Address; + gasToBurn: bigint; + remainingBudget: bigint; +}): Promise<{ estimatedCost: bigint; result: CanaryResult }> { + if (input.config.maxGasToBurn !== undefined && input.gasToBurn > input.config.maxGasToBurn) { + throw new GasbackLiveError( + `gasToBurn ${input.gasToBurn.toString()} exceeds MAX_GAS_TO_BURN ${input.config.maxGasToBurn.toString()}`, + ); + } + + const state = await readGasbackState(input.client, input.config.gasback); + const block = await input.client.getBlock(); + const baseFee = block.baseFeePerGas ?? 0n; + const preOracle = computeOracle({ + gasToBurn: input.gasToBurn, + baseFee, + ratioNumerator: state.ratioNumerator, + shareNumerator: state.shareNumerator, + maxBaseFee: state.maxBaseFee, + gasbackBalanceBefore: state.balance, + }); + + if (input.gasToBurn !== 0n && preOracle.passThrough) { + throw new GasbackLiveError( + `refusing canary gasToBurn ${input.gasToBurn.toString()} because the current buffer would pass through`, + ); + } + + const probeArtifact = loadArtifact("out/GasbackLiveProbe.sol/GasbackLiveProbe.json"); + await input.client.simulateContract({ + address: input.probeAddress, + abi: probeArtifact.abi, + functionName: "probe", + args: [input.gasToBurn], + account: input.account, + }); + const gas = await input.client.estimateContractGas({ + address: input.probeAddress, + abi: probeArtifact.abi, + functionName: "probe", + args: [input.gasToBurn], + account: input.account, + }); + const estimatedCost = gas * (await feeCap(input.client)); + assertSpendWithinBudget( + estimatedCost, + input.remainingBudget, + `probe(${input.gasToBurn.toString()})`, + ); + + const probeBalanceBefore = await input.client.getBalance({ address: input.probeAddress }); + const hash = await input.wallet.writeContract({ + address: input.probeAddress, + abi: probeArtifact.abi, + functionName: "probe", + args: [input.gasToBurn], + account: input.account, + chain: shapeSepolia(input.config.rpcUrl), + }); + const receipt = await input.client.waitForTransactionReceipt({ hash }); + const probeBalanceAfter = await input.client.getBalance({ address: input.probeAddress }); + const event = decodeProbeResult(probeArtifact.abi, input.probeAddress, receipt); + const fee = receiptFee(receipt); + const accruedDelta = event.accruedAfter - event.accruedBefore; + const probeBalanceDelta = probeBalanceAfter - probeBalanceBefore; + const oracle = computeOracle({ + gasToBurn: event.gasToBurn, + baseFee: event.blockBaseFee, + ratioNumerator: state.ratioNumerator, + shareNumerator: state.shareNumerator, + maxBaseFee: state.maxBaseFee, + gasbackBalanceBefore: event.gasbackBalanceBefore, + }); + const netProbeGainAfterFees = probeBalanceDelta - fee.totalFee; + const failures: string[] = []; + + if (event.payout !== oracle.expectedPayout) { + failures.push( + `payout mismatch: expected ${oracle.expectedPayout.toString()}, got ${event.payout.toString()}`, + ); + } + if (accruedDelta !== oracle.expectedAccruedDelta) { + failures.push( + `accrued delta mismatch: expected ${oracle.expectedAccruedDelta.toString()}, got ${accruedDelta.toString()}`, + ); + } + if (probeBalanceDelta !== event.payout) { + failures.push( + `probe balance delta ${probeBalanceDelta.toString()} does not equal payout ${event.payout.toString()}`, + ); + } + if (netProbeGainAfterFees > 0n) { + failures.push( + `profitable canary observed: net probe gain after fees ${netProbeGainAfterFees.toString()}`, + ); + } + + return { + estimatedCost, + result: { + gasToBurn: event.gasToBurn.toString(), + transactionHash: receipt.transactionHash, + blockNumber: receipt.blockNumber.toString(), + payout: event.payout.toString(), + accruedDelta: accruedDelta.toString(), + expectedPayout: oracle.expectedPayout.toString(), + expectedAccruedDelta: oracle.expectedAccruedDelta.toString(), + gasbackBalanceBefore: event.gasbackBalanceBefore.toString(), + gasbackBalanceAfter: event.gasbackBalanceAfter.toString(), + probeBalanceDelta: probeBalanceDelta.toString(), + executionFee: fee.executionFee.toString(), + extraReceiptFee: fee.extraReceiptFee.toString(), + totalFee: fee.totalFee.toString(), + netProbeGainAfterFees: netProbeGainAfterFees.toString(), + profitable: netProbeGainAfterFees > 0n, + failures, + }, + }; +} + +async function resolveProbe( + config: LiveConfig, + client: PublicClient, + wallet: WalletClient, + account: Address, + remainingBudget: bigint, +): Promise<{ address: Address; estimatedCost: bigint }> { + const artifact = loadArtifact("out/GasbackLiveProbe.sol/GasbackLiveProbe.json"); + if (config.probe) { + const code = await getCode(client, config.probe); + if (code === "0x") { + throw new GasbackLiveError("GASBACK_LIVE_PROBE_ADDRESS has no code"); + } + const target = await client.readContract({ + address: config.probe, + abi: artifact.abi, + functionName: "GASBACK", + }); + if (getAddress(target as Address) !== config.gasback) { + throw new GasbackLiveError("GASBACK_LIVE_PROBE_ADDRESS points at a different gasback"); + } + return { address: config.probe, estimatedCost: 0n }; + } + + const data = encodeDeployData({ + abi: artifact.abi, + bytecode: artifact.bytecode.object, + args: [config.gasback], + }); + const gas = await client.estimateGas({ account, data }); + const estimatedCost = gas * (await feeCap(client)); + assertSpendWithinBudget(estimatedCost, remainingBudget, "GasbackLiveProbe deployment"); + + const hash = await wallet.deployContract({ + abi: artifact.abi, + bytecode: artifact.bytecode.object, + args: [config.gasback], + account, + chain: shapeSepolia(config.rpcUrl), + }); + const receipt = await client.waitForTransactionReceipt({ hash }); + if (!receipt.contractAddress) { + throw new GasbackLiveError("probe deployment receipt did not include a contract address"); + } + return { address: getAddress(receipt.contractAddress), estimatedCost }; +} + +async function readGasbackState(client: PublicClient, gasback: Address): Promise { + const [ + ratioNumerator, + shareNumerator, + maxBaseFee, + baseFeeVault, + accrued, + balance, + ] = await Promise.all([ + client.readContract({ address: gasback, abi: gasbackAbi, functionName: "gasbackRatioNumerator" }), + client.readContract({ + address: gasback, + abi: gasbackAbi, + functionName: "baseFeeVaultShareNumerator", + }), + client.readContract({ address: gasback, abi: gasbackAbi, functionName: "gasbackMaxBaseFee" }), + client.readContract({ address: gasback, abi: gasbackAbi, functionName: "baseFeeVault" }), + client.readContract({ address: gasback, abi: gasbackAbi, functionName: "accrued" }), + client.getBalance({ address: gasback }), + ]); + return { + ratioNumerator, + shareNumerator, + maxBaseFee, + baseFeeVault: getAddress(baseFeeVault), + accrued, + balance, + }; +} + +async function readVaultAudit( + client: PublicClient, + vault: Address, + expectedRecipient: Address, +): Promise { + const recipient = await tryReadVaultAddress(client, vault, "recipient"); + const recipientFallback = recipient.supported + ? recipient + : await tryReadVaultAddress(client, vault, "RECIPIENT"); + const network = await tryReadVaultUint(client, vault, "withdrawalNetwork"); + const networkFallback = network.supported + ? network + : await tryReadVaultUint(client, vault, "WITHDRAWAL_NETWORK"); + + return { + recipientSupported: recipientFallback.supported, + recipient: recipientFallback.value, + recipientMatchesSplitter: recipientFallback.value + ? getAddress(recipientFallback.value) === expectedRecipient + : undefined, + withdrawalNetworkSupported: networkFallback.supported, + withdrawalNetwork: networkFallback.value?.toString(), + withdrawalNetworkIsL2: networkFallback.value === undefined ? undefined : networkFallback.value === 1n, + }; +} + +async function tryReadVaultAddress( + client: PublicClient, + address: Address, + functionName: "recipient" | "RECIPIENT", +): Promise<{ supported: boolean; value?: Address }> { + try { + const value = await client.readContract({ + address, + abi: vaultAddressAbi, + functionName, + }); + return { supported: true, value: getAddress(value) }; + } catch { + return { supported: false }; + } +} + +async function tryReadVaultUint( + client: PublicClient, + address: Address, + functionName: "withdrawalNetwork" | "WITHDRAWAL_NETWORK", +): Promise<{ supported: boolean; value?: bigint }> { + try { + const value = await client.readContract({ + address, + abi: vaultUintAbi, + functionName, + }); + return { supported: true, value }; + } catch { + return { supported: false }; + } +} + +function publicClient(config: LiveConfig): PublicClient { + return createPublicClient({ + chain: shapeSepolia(config.rpcUrl), + transport: http(config.rpcUrl), + }); +} + +function walletClient(config: LiveConfig): WalletClient { + return createWalletClient({ + account: privateKeyToAccount(readPrivateKey()), + chain: shapeSepolia(config.rpcUrl), + transport: http(config.rpcUrl), + }); +} + +function shapeSepolia(rpcUrl: string) { + return defineChain({ + id: SHAPE_SEPOLIA_CHAIN_ID, + name: "Shape Sepolia", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: [rpcUrl] } }, + }); +} + +async function getCode(client: PublicClient, address: Address): Promise { + return (await client.getBytecode({ address })) ?? "0x"; +} + +async function feeCap(client: PublicClient): Promise { + const fees = await client.estimateFeesPerGas().catch(() => undefined); + if (fees?.maxFeePerGas !== undefined) { + return fees.maxFeePerGas; + } + return client.getGasPrice(); +} + +function decodeProbeResult(abi: Abi, probe: Address, receipt: TransactionReceipt): ProbeEvent { + for (const log of receipt.logs) { + if (getAddress(log.address) !== probe) continue; + try { + const decoded = decodeEventLog({ abi, data: log.data, topics: log.topics }); + if (decoded.eventName !== "ProbeResult") continue; + return decoded.args as unknown as ProbeEvent; + } catch { + continue; + } + } + throw new GasbackLiveError("ProbeResult event not found in transaction receipt"); +} + +function loadArtifact(path: string): ForgeArtifact { + if (!existsSync(path)) { + throw new GasbackLiveError(`missing artifact ${path}; run forge build first`); + } + return JSON.parse(readFileSync(path, "utf8")) as ForgeArtifact; +} + +function readAddress(name: string, fallback: string): Address { + const value = process.env[name] ?? fallback; + if (!isAddress(value)) { + throw new GasbackLiveError(`${name} is not a valid address`); + } + return getAddress(value); +} + +function byteLength(hex: Hex): number { + return hex === "0x" ? 0 : (hex.length - 2) / 2; +} + +function unknownWei(value: unknown): bigint { + if (typeof value === "bigint") return value; + if (typeof value === "number") return BigInt(value); + if (typeof value === "string" && value.length !== 0) return BigInt(value); + return 0n; +} + +function assertNoFailures(failures: string[]) { + if (failures.length !== 0) { + throw new GasbackLiveError(failures.join("\n")); + } +} + +function writeReport(path: string, report: GasbackLiveReport) { + const parent = dirname(path); + if (parent !== "." && !existsSync(parent)) { + mkdirSync(parent, { recursive: true }); + } + writeFileSync(path, `${stringifyReport(report)}\n`); +} + +function runProcess(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: "inherit", env: process.env }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new GasbackLiveError(`${command} ${args.join(" ")} exited with ${code}`)); + } + }); + }); +} + +if (import.meta.main) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cd57840 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "gasback-by-vectorized", + "private": true, + "type": "module", + "scripts": { + "gasback:local": "bun run live/gasback-live.ts local", + "gasback:fork": "bun run live/gasback-live.ts fork", + "gasback:report": "bun run live/gasback-live.ts report", + "gasback:canary": "bun run live/gasback-live.ts canary", + "test:ts": "bun test live", + "check-types": "tsc --noEmit", + "lint": "bun test live && tsc --noEmit" + }, + "dependencies": { + "viem": "^2.33.3" + }, + "devDependencies": { + "@types/bun": "^1.3.1", + "typescript": "^5.9.3" + } +} diff --git a/script/Delegate7702.s.sol b/script/Delegate7702.s.sol index f87e87a..bf02656 100644 --- a/script/Delegate7702.s.sol +++ b/script/Delegate7702.s.sol @@ -23,7 +23,7 @@ contract Delegate7702Script is Script { vm.startBroadcast(privateKey); Gasback(payable(deployer)).noop(); - Gasback(payable(deployer)).setGasbackRatioNumerator(900000000000000000); + Gasback(payable(deployer)).setGasbackRatioNumerator(600000000000000000); Gasback(payable(deployer)).setGasbackMaxBaseFee(type(uint256).max); Gasback(payable(deployer)).setBaseFeeVault(0x4200000000000000000000000000000000000019); vm.stopBroadcast(); diff --git a/script/DeployGasback.s.sol b/script/DeployGasback.s.sol new file mode 100644 index 0000000..24fd868 --- /dev/null +++ b/script/DeployGasback.s.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.7; + +import {Script} from "forge-std/Script.sol"; +import {Gasback} from "../src/Gasback.sol"; + +contract DeployGasbackScript is Script { + function run() external returns (Gasback deployed) { + uint256 privateKey = uint256(vm.envBytes32("PRIVATE_KEY")); + + vm.startBroadcast(privateKey); + deployed = new Gasback(); + vm.stopBroadcast(); + } +} diff --git a/script/DeployGasbackStack.s.sol b/script/DeployGasbackStack.s.sol new file mode 100644 index 0000000..cecffb7 --- /dev/null +++ b/script/DeployGasbackStack.s.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script, console2} from "forge-std/Script.sol"; +import {Gasback} from "../src/Gasback.sol"; +import {ShapePaymentSplitter} from "../src/ShapePaymentSplitter.sol"; +import {GasbackTestCaller} from "../src/test/GasbackTestCaller.sol"; + +contract DeployGasbackStackScript is Script { + error MissingExtraShares(); + error MissingExtraPayees(); + error ExtraPayeesAndSharesLengthMismatch(uint256 payeesLength, uint256 sharesLength); + error GasbackShareIsZero(); + + function run() + external + returns (Gasback gasback, ShapePaymentSplitter splitter, GasbackTestCaller caller) + { + uint256 privateKey = uint256(vm.envBytes32("PRIVATE_KEY")); + uint256 gasbackShare = vm.envOr("GASBACK_SPLITTER_SHARE", uint256(1)); + + if (gasbackShare == 0) revert GasbackShareIsZero(); + + bool hasExtraPayees = vm.envExists("EXTRA_SPLITTER_PAYEES"); + bool hasExtraShares = vm.envExists("EXTRA_SPLITTER_SHARES"); + if (hasExtraPayees != hasExtraShares) { + if (hasExtraPayees) revert MissingExtraShares(); + revert MissingExtraPayees(); + } + + address[] memory extraPayees; + uint256[] memory extraShares; + if (hasExtraPayees) { + extraPayees = vm.envAddress("EXTRA_SPLITTER_PAYEES", ","); + extraShares = vm.envUint("EXTRA_SPLITTER_SHARES", ","); + if (extraPayees.length != extraShares.length) { + revert ExtraPayeesAndSharesLengthMismatch(extraPayees.length, extraShares.length); + } + } + + vm.startBroadcast(privateKey); + + gasback = new Gasback(); + + address[] memory payees = new address[](extraPayees.length + 1); + uint256[] memory shares = new uint256[](extraShares.length + 1); + payees[0] = address(gasback); + shares[0] = gasbackShare; + + for (uint256 i = 0; i < extraPayees.length; i++) { + payees[i + 1] = extraPayees[i]; + shares[i + 1] = extraShares[i]; + } + + splitter = new ShapePaymentSplitter(payees, shares); + caller = new GasbackTestCaller(address(gasback)); + + vm.stopBroadcast(); + + console2.log("Gasback:", address(gasback)); + console2.log("ShapePaymentSplitter:", address(splitter)); + console2.log("GasbackTestCaller:", address(caller)); + } +} diff --git a/script/DeployGasbackTestCaller.s.sol b/script/DeployGasbackTestCaller.s.sol new file mode 100644 index 0000000..9e24906 --- /dev/null +++ b/script/DeployGasbackTestCaller.s.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script, console} from "forge-std/Script.sol"; +import {GasbackTestCaller} from "../src/test/GasbackTestCaller.sol"; + +contract DeployGasbackTestCallerScript is Script { + error WrongChain(uint256 chainId); + + uint256 internal constant SHAPE_SEPOLIA_CHAIN_ID = 11011; + + function run() external returns (GasbackTestCaller deployed) { + if (block.chainid != SHAPE_SEPOLIA_CHAIN_ID) revert WrongChain(block.chainid); + + uint256 privateKey = uint256(vm.envBytes32("PRIVATE_KEY")); + address gasback = vm.envAddress("GASBACK_ADDRESS"); + + vm.startBroadcast(privateKey); + deployed = new GasbackTestCaller(gasback); + vm.stopBroadcast(); + + console.log("GasbackTestCaller deployed at:", address(deployed)); + console.log("Gasback target:", gasback); + } +} diff --git a/script/DeployShapePaymentSplitter.s.sol b/script/DeployShapePaymentSplitter.s.sol new file mode 100644 index 0000000..c08b8d2 --- /dev/null +++ b/script/DeployShapePaymentSplitter.s.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script, console} from "forge-std/Script.sol"; +import {ShapePaymentSplitter} from "../src/ShapePaymentSplitter.sol"; + +contract DeployShapePaymentSplitterScript is Script { + function run() external returns (ShapePaymentSplitter deployed) { + uint256 privateKey = uint256(vm.envBytes32("PRIVATE_KEY")); + + address[] memory payees = new address[](2); + uint256[] memory shares = new uint256[](2); + + /// @notice Replace with actual payee addresses + payees[0] = 0x1234567890123456789012345678901234567890; + payees[1] = 0x1234567890123456789012345678901234567891; + + /// @notice Replace with actual share amounts + shares[0] = 50; + shares[1] = 50; + + vm.startBroadcast(privateKey); + deployed = new ShapePaymentSplitter(payees, shares); + vm.stopBroadcast(); + + console.log("ShapePaymentSplitter deployed at:", address(deployed)); + console.log("Payee 1:", payees[0], "Shares:", shares[0]); + console.log("Payee 2:", payees[1], "Shares:", shares[1]); + } +} diff --git a/script/TestGasbackTestCaller.s.sol b/script/TestGasbackTestCaller.s.sol new file mode 100644 index 0000000..d273630 --- /dev/null +++ b/script/TestGasbackTestCaller.s.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script, console2} from "forge-std/Script.sol"; +import {GasbackTestCaller} from "../src/test/GasbackTestCaller.sol"; + +interface IGasbackRead { + function gasbackRatioNumerator() external view returns (uint256); + function baseFeeVaultShareNumerator() external view returns (uint256); + function gasbackMaxBaseFee() external view returns (uint256); + function accrued() external view returns (uint256); +} + +contract TestGasbackTestCallerScript is Script { + error WrongChain(uint256 chainId); + error InvalidCallerAddress(address caller); + error InvalidGasbackAddress(address gasback); + error CallerBalanceDecreased(uint256 beforeBalance, uint256 afterBalance); + error CallerBalanceDeltaMismatch(uint256 expectedDelta, uint256 observedDelta); + error AccruedDecreased(uint256 beforeAccrued, uint256 afterAccrued); + error UnexpectedZeroGasResult(uint256 payout, uint256 accruedDelta); + error PayoutExceedsTrackedShare(uint256 payout, uint256 trackedFromGas); + error UnexpectedPayoutWithZeroRatio(uint256 payout); + error EqualRatioShareMismatch(uint256 payout, uint256 trackedFromGas); + error ExpectedPassThrough(uint256 realizedBaseFee, uint256 maxBaseFee, uint256 payout); + + uint256 internal constant SHAPE_SEPOLIA_CHAIN_ID = 11011; + uint256 internal constant DEFAULT_GAS_TO_BURN = 30_000; + address internal constant DEFAULT_SHAPE_SEPOLIA_CALLER = + 0xA53D127f193858f5ef2Cf50dd1B3A94198ef811d; + + function run() external { + if (block.chainid != SHAPE_SEPOLIA_CHAIN_ID) revert WrongChain(block.chainid); + + uint256 privateKey = uint256(vm.envBytes32("PRIVATE_KEY")); + address callerAddress = + vm.envOr("GASBACK_TEST_CALLER_ADDRESS", DEFAULT_SHAPE_SEPOLIA_CALLER); + if (callerAddress.code.length == 0) revert InvalidCallerAddress(callerAddress); + + GasbackTestCaller caller = GasbackTestCaller(payable(callerAddress)); + address gasbackAddress = caller.GASBACK(); + if (gasbackAddress.code.length == 0) revert InvalidGasbackAddress(gasbackAddress); + + IGasbackRead gasback = IGasbackRead(gasbackAddress); + uint256 gasToBurn = vm.envOr("GAS_TO_BURN", DEFAULT_GAS_TO_BURN); + + console2.log("Shape Sepolia chain id:", block.chainid); + console2.log("GasbackTestCaller:", callerAddress); + console2.log("Gasback:", gasbackAddress); + console2.log("Configured gasToBurn for nonzero case:", gasToBurn); + + _runCase(privateKey, caller, gasback, 0); + _runCase(privateKey, caller, gasback, gasToBurn); + + console2.log("All checks passed."); + } + + function _runCase( + uint256 privateKey, + GasbackTestCaller caller, + IGasbackRead gasback, + uint256 gasToBurn + ) internal { + uint256 ratioNumerator = gasback.gasbackRatioNumerator(); + uint256 shareNumerator = gasback.baseFeeVaultShareNumerator(); + uint256 maxBaseFee = gasback.gasbackMaxBaseFee(); + + uint256 callerBalanceBefore = address(caller).balance; + uint256 accruedBefore = gasback.accrued(); + + vm.startBroadcast(privateKey); + uint256 payout = caller.burnGas(gasToBurn); + vm.stopBroadcast(); + + uint256 callerBalanceAfter = address(caller).balance; + uint256 accruedAfter = gasback.accrued(); + + if (callerBalanceAfter < callerBalanceBefore) { + revert CallerBalanceDecreased(callerBalanceBefore, callerBalanceAfter); + } + if (accruedAfter < accruedBefore) { + revert AccruedDecreased(accruedBefore, accruedAfter); + } + + uint256 callerBalanceDelta = callerBalanceAfter - callerBalanceBefore; + uint256 accruedDelta = accruedAfter - accruedBefore; + + if (callerBalanceDelta != payout) { + revert CallerBalanceDeltaMismatch(payout, callerBalanceDelta); + } + + if (gasToBurn == 0) { + if (payout != 0 || accruedDelta != 0) { + revert UnexpectedZeroGasResult(payout, accruedDelta); + } + + console2.log("Case gasToBurn:", gasToBurn); + console2.log("Payout:", payout); + console2.log("Accrued delta:", accruedDelta); + return; + } + + uint256 trackedFromGas = accruedDelta + payout; + if (ratioNumerator == 0 && payout != 0) { + revert UnexpectedPayoutWithZeroRatio(payout); + } + if (payout > trackedFromGas) { + revert PayoutExceedsTrackedShare(payout, trackedFromGas); + } + if (ratioNumerator == shareNumerator && payout != trackedFromGas) { + revert EqualRatioShareMismatch(payout, trackedFromGas); + } + if (block.basefee > maxBaseFee && trackedFromGas != 0) { + revert ExpectedPassThrough(block.basefee, maxBaseFee, payout); + } + + console2.log("Case gasToBurn:", gasToBurn); + console2.log("Payout:", payout); + console2.log("Accrued delta:", accruedDelta); + console2.log("Tracked from gas (accrued + payout):", trackedFromGas); + console2.log("Ratio numerator used:", ratioNumerator); + console2.log("Vault share numerator used:", shareNumerator); + console2.log("Max base fee used:", maxBaseFee); + } +} diff --git a/src/Gasback.sol b/src/Gasback.sol index 504438e..00178de 100644 --- a/src/Gasback.sol +++ b/src/Gasback.sol @@ -30,14 +30,12 @@ contract Gasback { // recipient of the base fee vault, it can be configured to auto-pull // funds from the base fee vault when it runs out of ETH. address baseFeeVault; - // The minimum balance of the base fee vault. - uint256 minVaultBalance; - // The amount of ETH accrued by taking a cut from the gas burned. + // The amount of ETH accrued by taking a cut from the gas burned (after the base fee vault share has been taken). uint256 accrued; - // The recipient of the accrued ETH. - address accruedRecipient; // A mapping of addresses authorized to withdraw the accrued ETH. mapping(address => bool) accuralWithdrawers; + // The numerator for the share of the base fee vault. + uint256 baseFeeVaultShareNumerator; } /// @dev Returns a pointer to the storage struct. @@ -56,11 +54,10 @@ contract Gasback { constructor() payable { GasbackStorage storage $ = _getGasbackStorage(); - $.gasbackRatioNumerator = 0.8 ether; + $.gasbackRatioNumerator = 0.6 ether; $.gasbackMaxBaseFee = type(uint256).max; $.baseFeeVault = 0x4200000000000000000000000000000000000019; - $.minVaultBalance = 0.42 ether; - $.accruedRecipient = 0x4200000000000000000000000000000000000019; + $.baseFeeVaultShareNumerator = 0.6 ether; } /*«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-*/ @@ -82,9 +79,9 @@ contract Gasback { return _getGasbackStorage().baseFeeVault; } - /// @dev The minimum balance of the base fee vault that allows a pull withdrawal. - function minVaultBalance() public view virtual returns (uint256) { - return _getGasbackStorage().minVaultBalance; + /// @dev The numerator for the share of the base fee vault. + function baseFeeVaultShareNumerator() public view virtual returns (uint256) { + return _getGasbackStorage().baseFeeVaultShareNumerator; } /*«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-*/ @@ -123,27 +120,6 @@ contract Gasback { return true; } - /// @dev Withdraws from the accrued amount to the accrued recipient. - function withdrawAccruedToAccruedRecipient(uint256 amount) public virtual returns (bool) { - // Checked math prevents underflow. - _getGasbackStorage().accrued -= amount; - - address accruedRecipient = _getGasbackStorage().accruedRecipient; - /// @solidity memory-safe-assembly - assembly { - if iszero(call(gas(), accruedRecipient, amount, 0x00, 0x00, 0x00, 0x00)) { - revert(0x00, 0x00) - } - } - return true; - } - - /// @dev Sets the accrued recipient. - function setAccruedRecipient(address value) public onlySystemOrThis returns (bool) { - _getGasbackStorage().accruedRecipient = value; - return true; - } - /*«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-*/ /* ADMIN FUNCTIONS */ /*-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»*/ @@ -159,8 +135,10 @@ contract Gasback { /// @dev Sets the numerator for the gasback ratio. function setGasbackRatioNumerator(uint256 value) public onlySystemOrThis returns (bool) { + GasbackStorage storage $ = _getGasbackStorage(); require(value <= GASBACK_RATIO_DENOMINATOR); - _getGasbackStorage().gasbackRatioNumerator = value; + require(value <= $.baseFeeVaultShareNumerator); + $.gasbackRatioNumerator = value; return true; } @@ -176,9 +154,12 @@ contract Gasback { return true; } - /// @dev Sets the minimum balance of the base fee vault. - function setMinVaultBalance(uint256 value) public onlySystemOrThis returns (bool) { - _getGasbackStorage().minVaultBalance = value; + /// @dev Sets the numerator for the share of the base fee vault. + function setBaseFeeVaultShareNumerator(uint256 value) public onlySystemOrThis returns (bool) { + GasbackStorage storage $ = _getGasbackStorage(); + require(value <= GASBACK_RATIO_DENOMINATOR); + require(value >= $.gasbackRatioNumerator); + $.baseFeeVaultShareNumerator = value; return true; } @@ -216,23 +197,23 @@ contract Gasback { GasbackStorage storage $ = _getGasbackStorage(); uint256 ethFromGas = gasToBurn * block.basefee; + uint256 ethFromVaultShare = + (ethFromGas * $.baseFeeVaultShareNumerator) / GASBACK_RATIO_DENOMINATOR; uint256 ethToGive = (ethFromGas * $.gasbackRatioNumerator) / GASBACK_RATIO_DENOMINATOR; uint256 selfBalance = address(this).balance; // If the contract has insufficient ETH, try to pull from the base fee vault. - if (ethToGive > selfBalance) { + if (ethToGive > selfBalance && block.basefee <= $.gasbackMaxBaseFee) { address vault = $.baseFeeVault; - uint256 minBalance = $.minVaultBalance; + uint256 shortfall = ethToGive - selfBalance; + uint256 vaultBalance = vault.balance; + uint256 expectedShare = + (vaultBalance * $.baseFeeVaultShareNumerator) / GASBACK_RATIO_DENOMINATOR; /// @solidity memory-safe-assembly assembly { - if extcodesize(vault) { - // If the vault has sufficient ETH, pull from it. - if gt(balance(vault), add(sub(ethToGive, selfBalance), minBalance)) { - mstore(0x00, 0x3ccfd60b) // `withdraw()`. - pop(call(gas(), vault, 0, 0x1c, 0x04, 0x00, 0x00)) - // Return ETH to vault to ensure that it has `minBalance`. - pop(call(gas(), vault, minBalance, 0x00, 0x00, 0x00, 0x00)) - } + if and(extcodesize(vault), iszero(lt(expectedShare, shortfall))) { + mstore(0x00, 0x3ccfd60b) // `withdraw()`. + pop(call(gas(), vault, 0, 0x1c, 0x04, 0x00, 0x00)) } } } @@ -244,8 +225,10 @@ contract Gasback { gasToBurn = 0; } - unchecked { - $.accrued += ethFromGas - ethToGive; + if (gasToBurn != 0) { + unchecked { + $.accrued += ethFromVaultShare - ethToGive; + } } /// @solidity memory-safe-assembly diff --git a/src/ShapePaymentSplitter.sol b/src/ShapePaymentSplitter.sol new file mode 100644 index 0000000..59f6fef --- /dev/null +++ b/src/ShapePaymentSplitter.sol @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +/** + * @title ShapePaymentSplitter + * @dev This contract, forked from OpenZeppelin's PaymentSplitter, allows for splitting Ether payments among a group of accounts. + * It has been modified by Shape to remove ERC20 interactions, focusing solely on Ether distribution. + * + * The split can be in equal parts or in any other arbitrary proportion, specified by assigning shares to each account. + * Each account can claim an amount proportional to their percentage of total shares. The share distribution is set at + * contract deployment and cannot be updated thereafter. + * + * ShapePaymentSplitter follows a _push payment_ model. Incoming Ether triggers an attempt to release funds to all payees. + * + * The sender of Ether to this contract does not need to be aware of the split mechanism, as it is handled transparently. + */ +contract ShapePaymentSplitter { + event PayeeAdded(address account, uint256 shares); + event PaymentReleased(address to, uint256 amount); + event PaymentReceived(address from, uint256 amount); + event PaymentFailed(address to, uint256 amount, bytes reason); + + error FailedToSendValue(); + error PayeesAndSharesLengthMismatch(); + error NoPayees(); + error AccountAlreadyHasShares(); + error AccountIsTheZeroAddress(); + error SharesAreZero(); + error AccountHasNoShares(); + error AccountIsNotDuePayment(); + error InsufficientBalance(); + + uint256 private _totalShares; + uint256 private _totalReleased; + + mapping(address => uint256) private _shares; + mapping(address => uint256) private _released; + address[] private _payees; + + /** + * @dev Creates an instance of `ShapePaymentSplitter` where each account in `payees` is assigned the number of shares at + * the matching position in the `shares` array. + * + * All addresses in `payees` must be non-zero. Both arrays must have the same non-zero length, and there must be no + * duplicates in `payees`. + */ + constructor(address[] memory payees_, uint256[] memory shares_) payable { + if (payees_.length != shares_.length) revert PayeesAndSharesLengthMismatch(); + if (payees_.length == 0) revert NoPayees(); + + for (uint256 i = 0; i < payees_.length; i++) { + _addPayee(payees_[i], shares_[i]); + } + } + + /** + * @dev The Ether received will be logged with {PaymentReceived} events. Note that these events are not fully + * reliable: it's possible for a contract to receive Ether without triggering this function. This only affects the + * reliability of the events, and not the actual splitting of Ether. + * + * To learn more about this see the Solidity documentation for + * https://solidity.readthedocs.io/en/latest/contracts.html#fallback-function[fallback + * functions]. + */ + receive() external payable { + _distribute(0, _payees.length); + emit PaymentReceived(msg.sender, msg.value); + } + + /** + * @dev Getter for the total shares held by payees. + */ + function totalShares() public view returns (uint256) { + return _totalShares; + } + + /** + * @dev Getter for the total amount of Ether already released. + */ + function totalReleased() public view returns (uint256) { + return _totalReleased; + } + + /** + * @dev Getter for the amount of shares held by an account. + */ + function shares(address account) public view returns (uint256) { + return _shares[account]; + } + + /** + * @dev Getter for the amount of Ether already released to a payee. + */ + function released(address account) public view returns (uint256) { + return _released[account]; + } + + /** + * @dev Getter for the address of the payee number `index`. + */ + function payee(uint256 index) public view returns (address) { + return _payees[index]; + } + + /** + * @dev Getter for the addresses of the payees. + */ + function payees() public view returns (address[] memory) { + return _payees; + } + + /** + * @dev Getter for the amount of payee's releasable Ether. + */ + function releasable(address account) public view returns (uint256) { + uint256 totalReceived = address(this).balance + totalReleased(); + return _pendingPayment(account, totalReceived, released(account)); + } + + /** + * @dev Attempts to release payments for a slice of payees, skipping zero-due payees and emitting failures instead of + * reverting on send failures. + */ + function distribute(uint256 start, uint256 end) public { + _distribute(start, end); + } + + /** + * @dev Triggers a transfer to `account` of the amount of Ether they are owed, according to their percentage of the + * total shares and their previous withdrawals. + */ + function release(address payable account) public { + if (_shares[account] == 0) revert AccountHasNoShares(); + + uint256 payment = releasable(account); + + if (payment == 0) revert AccountIsNotDuePayment(); + + // _totalReleased is the sum of all values in _released. + // If "_totalReleased += payment" does not overflow, then "_released[account] += payment" cannot overflow. + _totalReleased += payment; + unchecked { + _released[account] += payment; + } + + _sendValue(account, payment); + + emit PaymentReleased(account, payment); + } + + /** + * @dev internal logic for computing the pending payment of an `account` given the token historical balances and + * already released amounts. + */ + function _pendingPayment(address account, uint256 totalReceived, uint256 alreadyReleased) + private + view + returns (uint256) + { + return (totalReceived * _shares[account]) / _totalShares - alreadyReleased; + } + + /** + * @dev Attempt to pay a slice of payees without reverting the whole call. + * Skips zero-due accounts and emits failures for accounts that revert on receive. + */ + function _distribute(uint256 start, uint256 end) private { + uint256 payeesLength = _payees.length; + if (end > payeesLength) { + end = payeesLength; + } + if (start >= end) { + return; + } + + for (uint256 i = start; i < end; i++) { + address payable account = payable(_payees[i]); + uint256 payment = releasable(account); + if (payment == 0) { + continue; + } + + try this.release(account) {} + catch (bytes memory reason) { + emit PaymentFailed(account, payment, reason); + } + } + } + + /** + * @dev Add a new payee to the contract. + * @param account The address of the payee to add. + * @param shares_ The number of shares owned by the payee. + */ + function _addPayee(address account, uint256 shares_) private { + if (account == address(0)) revert AccountIsTheZeroAddress(); + if (shares_ == 0) revert SharesAreZero(); + if (_shares[account] != 0) revert AccountAlreadyHasShares(); + + _payees.push(account); + _shares[account] = shares_; + _totalShares = _totalShares + shares_; + emit PayeeAdded(account, shares_); + } + + /** + * @dev Replacement for Solidity's `transfer`: sends `amount` wei to + * `recipient`, forwarding all available gas and reverting on errors. + * + * https://eips.ethereum.org/EIPS/eip-1884[EIP1884] increases the gas cost + * of certain opcodes, possibly making contracts go over the 2300 gas limit + * imposed by `transfer`, making them unable to receive funds via + * `transfer`. {sendValue} removes this limitation. + * + * https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/[Learn more]. + * + * IMPORTANT: because control is transferred to `recipient`, care must be + * taken to not create reentrancy vulnerabilities. Consider using + * {ReentrancyGuard} or the + * https://solidity.readthedocs.io/en/v0.8.20/security-considerations.html#use-the-checks-effects-interactions-pattern[checks-effects-interactions pattern]. + */ + function _sendValue(address payable recipient, uint256 amount) private { + if (address(this).balance < amount) { + revert InsufficientBalance(); + } + + (bool success,) = recipient.call{value: amount}(""); + if (!success) { + revert FailedToSendValue(); + } + } +} diff --git a/src/test/GasbackLiveProbe.sol b/src/test/GasbackLiveProbe.sol new file mode 100644 index 0000000..add8584 --- /dev/null +++ b/src/test/GasbackLiveProbe.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +interface IGasbackLiveProbeTarget { + function accrued() external view returns (uint256); +} + +contract GasbackLiveProbe { + error NotOwner(); + error ZeroAddress(); + error InvalidGasbackAddress(); + error GasbackCallFailed(); + error UnexpectedReturnData(); + error WithdrawFailed(); + + event ProbeResult( + uint256 gasToBurn, + uint256 blockBaseFee, + uint256 payout, + uint256 accruedBefore, + uint256 accruedAfter, + uint256 gasbackBalanceBefore, + uint256 gasbackBalanceAfter + ); + + address public immutable GASBACK; + address public owner; + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + constructor(address gasback) { + if (gasback == address(0)) revert ZeroAddress(); + if (gasback.code.length == 0) revert InvalidGasbackAddress(); + GASBACK = gasback; + owner = msg.sender; + } + + function probe(uint256 gasToBurn) external returns (uint256 payout) { + address gasback = GASBACK; + uint256 accruedBefore = IGasbackLiveProbeTarget(gasback).accrued(); + uint256 gasbackBalanceBefore = gasback.balance; + + (bool success, bytes memory data) = gasback.call(abi.encode(gasToBurn)); + if (!success) revert GasbackCallFailed(); + if (data.length != 32) revert UnexpectedReturnData(); + payout = abi.decode(data, (uint256)); + + emit ProbeResult( + gasToBurn, + block.basefee, + payout, + accruedBefore, + IGasbackLiveProbeTarget(gasback).accrued(), + gasbackBalanceBefore, + gasback.balance + ); + } + + function withdraw(address payable to, uint256 amount) external onlyOwner { + if (to == address(0)) revert ZeroAddress(); + (bool success,) = to.call{value: amount}(""); + if (!success) revert WithdrawFailed(); + } + + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) revert ZeroAddress(); + owner = newOwner; + } + + receive() external payable {} +} diff --git a/src/test/GasbackTestCaller.sol b/src/test/GasbackTestCaller.sol new file mode 100644 index 0000000..031d7be --- /dev/null +++ b/src/test/GasbackTestCaller.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +contract GasbackTestCaller { + error NotOwner(); + error ZeroAddress(); + error InvalidGasbackAddress(); + error GasbackCallFailed(); + error UnexpectedReturnData(); + error WithdrawFailed(); + + event GasbackCalled(address indexed caller, uint256 gasToBurn, uint256 ethReceived); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event Withdrawal(address indexed to, uint256 amount); + + address public immutable GASBACK; + address public owner; + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + constructor(address gasback_) { + if (gasback_ == address(0)) revert ZeroAddress(); + if (gasback_.code.length == 0) revert InvalidGasbackAddress(); + GASBACK = gasback_; + owner = msg.sender; + emit OwnershipTransferred(address(0), msg.sender); + } + + function burnGas(uint256 gasToBurn) external returns (uint256 ethReceived) { + (bool success, bytes memory data) = GASBACK.call(abi.encode(gasToBurn)); + if (!success) revert GasbackCallFailed(); + if (data.length != 32) revert UnexpectedReturnData(); + ethReceived = abi.decode(data, (uint256)); + emit GasbackCalled(msg.sender, gasToBurn, ethReceived); + } + + function withdraw(address payable to, uint256 amount) external onlyOwner { + if (to == address(0)) revert ZeroAddress(); + (bool success,) = to.call{value: amount}(""); + if (!success) revert WithdrawFailed(); + emit Withdrawal(to, amount); + } + + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) revert ZeroAddress(); + emit OwnershipTransferred(owner, newOwner); + owner = newOwner; + } + + receive() external payable {} +} diff --git a/test/Gasback.t.sol b/test/Gasback.t.sol index 1710cc2..caa0a49 100644 --- a/test/Gasback.t.sol +++ b/test/Gasback.t.sol @@ -21,7 +21,11 @@ contract GasbackTest is SoladyTest { vm.prank(pranker); (bool success,) = address(gasback).call(abi.encode(gasToBurn)); assertTrue(success); - assertEq(pranker.balance, (gasToBurn * baseFee * 0.8 ether) / 1 ether); + assertEq( + pranker.balance, + (gasToBurn * baseFee * gasback.gasbackRatioNumerator()) + / gasback.GASBACK_RATIO_DENOMINATOR() + ); } function testConvertGasback() public { @@ -59,46 +63,4 @@ contract GasbackTest is SoladyTest { assertTrue(success); assertEq(pranker.balance, 0); } - - function testConvertGasbackMinVaultBalance() public { - address system = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; - uint256 minVaultBalance = 50 ether; - vm.prank(system); - gasback.setMinVaultBalance(minVaultBalance); - - uint256 gasToBurn = 333; - - address pranker = address(111); - assertEq(pranker.balance, 0); - vm.prank(pranker); - (bool success,) = address(gasback).call(abi.encode(gasToBurn)); - assertTrue(success); - assertEq(pranker.balance, 0); - } - - function testConvertGasbackWithAccruedToAccruedRecipient() public { - address system = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; - vm.prank(system); - gasback.setAccruedRecipient(address(42)); - - uint256 baseFee = 1 ether; - uint256 gasToBurn = 333; - - address pranker = address(111); - vm.fee(baseFee); - vm.deal(pranker, 1000 ether); - - vm.prank(pranker); - (bool success,) = address(gasback).call(abi.encode(gasToBurn)); - assertTrue(success); - - uint256 accrued = gasback.accrued(); - - assertNotEq(accrued, 0); - - vm.prank(pranker); - gasback.withdrawAccruedToAccruedRecipient(accrued); - - assertEq(address(42).balance, accrued); - } } diff --git a/test/GasbackExtended.t.sol b/test/GasbackExtended.t.sol new file mode 100644 index 0000000..a6c6957 --- /dev/null +++ b/test/GasbackExtended.t.sol @@ -0,0 +1,720 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +import "./utils/SoladyTest.sol"; +import {Gasback} from "../src/Gasback.sol"; + +contract RejectingReceiver { + receive() external payable { + revert(); + } +} + +contract RejectingCaller { + function trigger(address target, uint256 gasToBurn) external returns (uint256 ethToGive) { + (bool success, bytes memory data) = target.call(abi.encode(gasToBurn)); + require(success); + ethToGive = abi.decode(data, (uint256)); + } + + receive() external payable { + revert(); + } +} + +contract AcceptingCaller { + function trigger(address target, uint256 gasToBurn) external returns (uint256 ethToGive) { + (bool success, bytes memory data) = target.call(abi.encode(gasToBurn)); + require(success); + ethToGive = abi.decode(data, (uint256)); + } + + receive() external payable {} +} + +contract GasbackExtendedTest is SoladyTest { + address internal constant SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; + address internal constant DEFAULT_BASE_FEE_VAULT = 0x4200000000000000000000000000000000000019; + uint256 internal constant DENOMINATOR = 1 ether; + + Gasback public gasback; + + function setUp() public { + gasback = new Gasback(); + } + + function _callFallback(address caller, uint256 gasToBurn) + internal + returns (bool success, uint256 ethToGive) + { + vm.prank(caller); + bytes memory data; + (success, data) = address(gasback).call(abi.encode(gasToBurn)); + if (success) { + ethToGive = abi.decode(data, (uint256)); + } + } + + function _accrueViaPayout(uint256 baseFee, uint256 gasToBurn) + internal + returns (uint256 accruedDelta) + { + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; + uint256 beforeAccrued = gasback.accrued(); + + vm.deal(address(gasback), ethToGive); + vm.fee(baseFee); + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xA11CE), gasToBurn); + assertTrue(success); + assertEq(returnedEthToGive, ethToGive); + + accruedDelta = gasback.accrued() - beforeAccrued; + assertEq(accruedDelta, ethFromVaultShare - ethToGive); + } + + function _configureBaseFeeVault(address vault, uint256 shareNumerator) internal { + vm.startPrank(SYSTEM_ADDRESS); + gasback.setBaseFeeVault(vault); + gasback.setBaseFeeVaultShareNumerator(shareNumerator); + vm.stopPrank(); + } + + function test_constructorDefaults() public { + assertEq(gasback.gasbackRatioNumerator(), 0.6 ether); + assertEq(gasback.gasbackMaxBaseFee(), type(uint256).max); + assertEq(gasback.baseFeeVault(), DEFAULT_BASE_FEE_VAULT); + assertEq(gasback.baseFeeVaultShareNumerator(), 0.6 ether); + assertEq(gasback.accrued(), 0); + assertEq(gasback.GASBACK_RATIO_DENOMINATOR(), DENOMINATOR); + assertFalse(gasback.isAuthorizedAccuralWithdrawer(address(this))); + } + + function test_receiveAcceptsEth() public { + vm.deal(address(this), 1 ether); + (bool success,) = address(gasback).call{value: 1 ether}(""); + assertTrue(success); + assertEq(address(gasback).balance, 1 ether); + } + + function test_noopAcceptsEthAndReturnsTrue() public { + vm.deal(address(this), 1 ether); + bool success = gasback.noop{value: 1 ether}(); + assertTrue(success); + assertEq(address(gasback).balance, 1 ether); + } + + function test_revert_onlySystemOrThis() public { + address user = address(0xBEEF); + vm.startPrank(user); + vm.expectRevert(); + gasback.setGasbackRatioNumerator(1); + vm.expectRevert(); + gasback.setGasbackMaxBaseFee(1); + vm.expectRevert(); + gasback.setBaseFeeVault(address(1)); + vm.expectRevert(); + gasback.setBaseFeeVaultShareNumerator(1); + vm.expectRevert(); + gasback.setAccuralWithdrawer(address(1), true); + vm.expectRevert(); + gasback.withdraw(address(1), 1); + vm.stopPrank(); + } + + function test_systemCanCallAdminFunctions() public { + vm.deal(address(gasback), 1 ether); + + vm.startPrank(SYSTEM_ADDRESS); + assertTrue(gasback.setBaseFeeVaultShareNumerator(0.9 ether)); + assertTrue(gasback.setGasbackRatioNumerator(0.9 ether)); + assertTrue(gasback.setGasbackMaxBaseFee(123)); + assertTrue(gasback.setBaseFeeVault(address(0x1234))); + assertTrue(gasback.setAccuralWithdrawer(address(0x99), true)); + assertTrue(gasback.withdraw(address(0xA11CE), 0.2 ether)); + vm.stopPrank(); + + assertEq(gasback.gasbackRatioNumerator(), 0.9 ether); + assertEq(gasback.baseFeeVaultShareNumerator(), 0.9 ether); + assertEq(gasback.gasbackMaxBaseFee(), 123); + assertEq(gasback.baseFeeVault(), address(0x1234)); + assertTrue(gasback.isAuthorizedAccuralWithdrawer(address(0x99))); + assertEq(address(0xA11CE).balance, 0.2 ether); + } + + function test_selfCanCallAdminFunctions() public { + vm.deal(address(gasback), 1 ether); + + vm.prank(address(gasback)); + assertTrue(gasback.setBaseFeeVaultShareNumerator(1 ether)); + vm.prank(address(gasback)); + assertTrue(gasback.setGasbackRatioNumerator(1 ether)); + vm.prank(address(gasback)); + assertTrue(gasback.setGasbackMaxBaseFee(77)); + vm.prank(address(gasback)); + assertTrue(gasback.setBaseFeeVault(address(0x4321))); + vm.prank(address(gasback)); + assertTrue(gasback.setAccuralWithdrawer(address(this), true)); + vm.prank(address(gasback)); + assertTrue(gasback.withdraw(address(0xB0B), 0.25 ether)); + + assertEq(gasback.gasbackRatioNumerator(), 1 ether); + assertEq(gasback.baseFeeVaultShareNumerator(), 1 ether); + assertEq(gasback.gasbackMaxBaseFee(), 77); + assertEq(gasback.baseFeeVault(), address(0x4321)); + assertTrue(gasback.isAuthorizedAccuralWithdrawer(address(this))); + assertEq(address(0xB0B).balance, 0.25 ether); + } + + function test_revert_setGasbackRatioNumeratorAboveDenominator() public { + vm.prank(SYSTEM_ADDRESS); + vm.expectRevert(); + gasback.setGasbackRatioNumerator(DENOMINATOR + 1); + } + + function test_revert_setBaseFeeVaultShareNumeratorAboveDenominator() public { + vm.prank(SYSTEM_ADDRESS); + vm.expectRevert(); + gasback.setBaseFeeVaultShareNumerator(DENOMINATOR + 1); + } + + function test_revert_setGasbackRatioNumeratorAboveBaseFeeVaultShare() public { + vm.startPrank(SYSTEM_ADDRESS); + assertTrue(gasback.setBaseFeeVaultShareNumerator(0.7 ether)); + vm.expectRevert(); + gasback.setGasbackRatioNumerator(0.700000000000000001 ether); + vm.stopPrank(); + } + + function test_revert_setBaseFeeVaultShareNumeratorBelowGasbackRatio() public { + vm.startPrank(SYSTEM_ADDRESS); + assertTrue(gasback.setBaseFeeVaultShareNumerator(0.9 ether)); + assertTrue(gasback.setGasbackRatioNumerator(0.8 ether)); + vm.expectRevert(); + gasback.setBaseFeeVaultShareNumerator(0.79 ether); + vm.stopPrank(); + } + + function test_revert_fallbackInvalidCalldataLength() public { + vm.prank(address(1)); + (bool success0,) = address(gasback).call(new bytes(1)); + assertFalse(success0); + + vm.prank(address(1)); + (bool success1,) = address(gasback).call(new bytes(31)); + assertFalse(success1); + + vm.prank(address(1)); + (bool success2,) = address(gasback).call(abi.encode(uint256(1), uint256(2))); + assertFalse(success2); + } + + function test_fallbackPaysCallerAndAccruesCut() public { + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; + + vm.deal(address(gasback), ethToGive); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, ethToGive); + assertEq(address(0xB0B).balance, ethToGive); + assertEq(gasback.accrued(), ethFromVaultShare - ethToGive); + assertEq(address(gasback).balance, 0); + } + + function test_fallbackWithZeroRatioAccruesAll() public { + vm.prank(SYSTEM_ADDRESS); + gasback.setGasbackRatioNumerator(0); + + uint256 baseFee = 13; + uint256 gasToBurn = 101; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; + + vm.fee(baseFee); + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, 0); + assertEq(address(0xB0B).balance, 0); + assertEq(gasback.accrued(), ethFromVaultShare); + } + + function test_fallbackZeroGasToBurnNoops() public { + vm.deal(address(gasback), 1 ether); + vm.fee(123); + + uint256 beforeBalance = address(gasback).balance; + uint256 beforeAccrued = gasback.accrued(); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), 0); + + assertTrue(success); + assertEq(returnedEthToGive, 0); + assertEq(address(0xB0B).balance, 0); + assertEq(address(gasback).balance, beforeBalance); + assertEq(gasback.accrued(), beforeAccrued); + } + + function test_fallbackPassThroughWhenInsufficientBalance() public { + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 beforeAccrued = gasback.accrued(); + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; + + vm.deal(address(gasback), ethToGive - 1); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, 0); + assertEq(address(0xB0B).balance, 0); + assertEq(gasback.accrued(), beforeAccrued); + assertEq(address(gasback).balance, ethToGive - 1); + } + + function test_fallbackPassThroughWhenBaseFeeAboveMax() public { + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 beforeAccrued = gasback.accrued(); + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; + + vm.prank(SYSTEM_ADDRESS); + gasback.setGasbackMaxBaseFee(baseFee - 1); + + vm.deal(address(gasback), ethToGive); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, 0); + assertEq(address(0xB0B).balance, 0); + assertEq(gasback.accrued(), beforeAccrued); + assertEq(address(gasback).balance, ethToGive); + } + + function test_revert_fallbackOnEthFromGasOverflow() public { + vm.fee(2); + vm.prank(address(1)); + (bool success,) = address(gasback).call(abi.encode(type(uint256).max)); + assertFalse(success); + } + + function test_revert_fallbackWhenCannotBurnRequestedGas() public { + vm.fee(0); + vm.prank(address(1)); + (bool success,) = address(gasback).call(abi.encode(type(uint256).max)); + assertFalse(success); + } + + function test_fallbackAccruedIsAdditiveAcrossCalls() public { + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; + + vm.deal(address(gasback), 2 * ethToGive); + vm.fee(baseFee); + + (bool success0,) = _callFallback(address(0x1111), gasToBurn); + (bool success1,) = _callFallback(address(0x2222), gasToBurn); + + assertTrue(success0); + assertTrue(success1); + assertEq(address(0x1111).balance, ethToGive); + assertEq(address(0x2222).balance, ethToGive); + assertEq(gasback.accrued(), 2 * (ethFromVaultShare - ethToGive)); + assertEq( + gasback.accrued() + address(0x1111).balance + address(0x2222).balance, + 2 * ethFromVaultShare + ); + assertEq(address(gasback).balance, 0); + } + + function test_fallbackPullsFromBaseFeeVaultWhenShareCoversShortfall() public { + address vault = address(0xA001); + vm.etch(vault, hex"33ff00"); + _configureBaseFeeVault(vault, DENOMINATOR); + + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; + + vm.deal(vault, ethToGive); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, ethToGive); + assertEq(address(0xB0B).balance, ethToGive); + assertEq(gasback.accrued(), ethFromVaultShare - ethToGive); + assertEq(vault.balance, 0); + } + + function test_fallbackPullsFromVaultWhenExpectedShareEqualsShortfall() public { + address vault = address(0xA005); + vm.etch(vault, hex"33ff00"); + _configureBaseFeeVault(vault, DENOMINATOR); + + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; + + vm.deal(vault, ethToGive); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, ethToGive); + assertEq(address(0xB0B).balance, ethToGive); + assertEq(gasback.accrued(), ethFromVaultShare - ethToGive); + assertEq(vault.balance, 0); + } + + function test_fallbackDoesNotPullFromVaultWhenExpectedShareBelowShortfall() public { + address vault = address(0xA002); + vm.etch(vault, hex"60016000550000"); + _configureBaseFeeVault(vault, DENOMINATOR); + + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 beforeAccrued = gasback.accrued(); + + vm.deal(vault, 499); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, 0); + assertEq(address(0xB0B).balance, 0); + assertEq(gasback.accrued(), beforeAccrued); + assertEq(uint256(vm.load(vault, bytes32(0))), 0); + assertEq(vault.balance, 499); + } + + function test_fallbackAttemptedVaultPullWithoutTransferFallsBackToPassThrough() public { + address vault = address(0xA003); + vm.etch(vault, hex"60016000550000"); + _configureBaseFeeVault(vault, DENOMINATOR); + + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 beforeAccrued = gasback.accrued(); + + vm.deal(vault, 1000); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, 0); + assertEq(address(0xB0B).balance, 0); + assertEq(gasback.accrued(), beforeAccrued); + assertEq(uint256(vm.load(vault, bytes32(0))), 1); + assertEq(vault.balance, 1000); + } + + function test_fallbackHighBaseFeeSkipsVaultPull() public { + address vault = address(0xA004); + vm.etch(vault, hex"60016000550000"); + + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 beforeAccrued = gasback.accrued(); + + vm.startPrank(SYSTEM_ADDRESS); + gasback.setBaseFeeVault(vault); + gasback.setBaseFeeVaultShareNumerator(DENOMINATOR); + gasback.setGasbackMaxBaseFee(baseFee - 1); + vm.stopPrank(); + + vm.deal(vault, 1000); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, 0); + assertEq(gasback.accrued(), beforeAccrued); + assertEq(uint256(vm.load(vault, bytes32(0))), 0); + } + + function test_fallbackForceSendsWhenCallerRejectsEth() public { + RejectingCaller caller = new RejectingCaller(); + + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; + + vm.deal(address(gasback), ethToGive); + vm.fee(baseFee); + + uint256 returnedEthToGive = caller.trigger(address(gasback), gasToBurn); + + assertEq(returnedEthToGive, ethToGive); + assertEq(address(caller).balance, ethToGive); + assertEq(gasback.accrued(), ethFromVaultShare - ethToGive); + } + + function test_fallbackPaysAcceptingContractCaller() public { + AcceptingCaller caller = new AcceptingCaller(); + + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; + + vm.deal(address(gasback), ethToGive); + vm.fee(baseFee); + + uint256 returnedEthToGive = caller.trigger(address(gasback), gasToBurn); + + assertEq(returnedEthToGive, ethToGive); + assertEq(address(caller).balance, ethToGive); + assertEq(gasback.accrued(), ethFromVaultShare - ethToGive); + assertEq(address(gasback).balance, 0); + } + + function test_fallbackSkipsEthSendWhenCallerRejectsAndEthToGiveIsZero() public { + RejectingCaller caller = new RejectingCaller(); + vm.prank(SYSTEM_ADDRESS); + gasback.setGasbackRatioNumerator(0); + + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; + + vm.fee(baseFee); + uint256 returnedEthToGive = caller.trigger(address(gasback), gasToBurn); + + assertEq(returnedEthToGive, 0); + assertEq(address(caller).balance, 0); + assertEq(gasback.accrued(), ethFromVaultShare); + } + + function test_revert_withdrawWhenRecipientRejectsEth() public { + RejectingReceiver rejector = new RejectingReceiver(); + vm.deal(address(gasback), 1 ether); + + vm.prank(SYSTEM_ADDRESS); + vm.expectRevert(); + gasback.withdraw(address(rejector), 0.1 ether); + + assertEq(address(gasback).balance, 1 ether); + } + + function test_revert_withdrawWhenAmountExceedsBalance() public { + vm.prank(SYSTEM_ADDRESS); + vm.expectRevert(); + gasback.withdraw(address(1), 1); + } + + function test_withdrawAccruedAuthorizedSuccess() public { + vm.prank(SYSTEM_ADDRESS); + gasback.setGasbackRatioNumerator(0.5 ether); + + uint256 accruedAmount = _accrueViaPayout(10, 100); + vm.deal(address(gasback), accruedAmount); + uint256 withdrawAmount = 40; + + vm.prank(SYSTEM_ADDRESS); + gasback.setAccuralWithdrawer(address(this), true); + + address recipient = address(0xCAFE); + uint256 before = recipient.balance; + bool success = gasback.withdrawAccrued(recipient, withdrawAmount); + + assertTrue(success); + assertEq(recipient.balance - before, withdrawAmount); + assertEq(gasback.accrued(), accruedAmount - withdrawAmount); + } + + function test_revert_withdrawAccruedUnauthorized() public { + _accrueViaPayout(10, 100); + vm.expectRevert(); + gasback.withdrawAccrued(address(this), 1); + } + + function test_withdrawAccruedRequireBranchTrue_authorized() public { + vm.prank(SYSTEM_ADDRESS); + gasback.setGasbackRatioNumerator(0.5 ether); + + uint256 accruedAmount = _accrueViaPayout(10, 100); + vm.deal(address(gasback), accruedAmount); + + vm.prank(SYSTEM_ADDRESS); + gasback.setAccuralWithdrawer(address(this), true); + + address recipient = address(0xD00D); + uint256 beforeBalance = recipient.balance; + bool success = gasback.withdrawAccrued(recipient, 1); + + assertTrue(success); + assertEq(recipient.balance, beforeBalance + 1); + assertEq(gasback.accrued(), accruedAmount - 1); + } + + function test_withdrawAccruedRequireBranchFalse_unauthorizedReverts() public { + uint256 accruedAmount = _accrueViaPayout(10, 100); + vm.deal(address(gasback), accruedAmount); + + vm.expectRevert(); + gasback.withdrawAccrued(address(0xD00D), 1); + + assertEq(gasback.accrued(), accruedAmount); + } + + function test_setAccuralWithdrawerRevokeBlocksWithdrawAccrued() public { + _accrueViaPayout(10, 100); + + vm.startPrank(SYSTEM_ADDRESS); + gasback.setAccuralWithdrawer(address(this), true); + gasback.setAccuralWithdrawer(address(this), false); + vm.stopPrank(); + + assertFalse(gasback.isAuthorizedAccuralWithdrawer(address(this))); + + vm.expectRevert(); + gasback.withdrawAccrued(address(this), 1); + } + + function test_revert_withdrawAccruedUnderflow() public { + uint256 accruedAmount = _accrueViaPayout(10, 100); + + vm.prank(SYSTEM_ADDRESS); + gasback.setAccuralWithdrawer(address(this), true); + + vm.expectRevert(); + gasback.withdrawAccrued(address(this), accruedAmount + 1); + + assertEq(gasback.accrued(), accruedAmount); + } + + function test_revert_withdrawAccruedUnderflowWhenShareEqualsRatio() public { + uint256 shareNumerator = gasback.baseFeeVaultShareNumerator(); + vm.prank(SYSTEM_ADDRESS); + gasback.setGasbackRatioNumerator(shareNumerator); + + uint256 accruedAmount = _accrueViaPayout(10, 100); + assertEq(accruedAmount, 0); + + vm.prank(SYSTEM_ADDRESS); + gasback.setAccuralWithdrawer(address(this), true); + + vm.expectRevert(); + gasback.withdrawAccrued(address(this), 1); + + assertEq(gasback.accrued(), 0); + } + + function test_revert_withdrawAccruedWhenRecipientRejectsEth() public { + RejectingReceiver rejector = new RejectingReceiver(); + uint256 accruedAmount = _accrueViaPayout(10, 100); + vm.deal(address(gasback), accruedAmount); + + vm.prank(SYSTEM_ADDRESS); + gasback.setAccuralWithdrawer(address(this), true); + + vm.expectRevert(); + gasback.withdrawAccrued(address(rejector), 1); + + assertEq(gasback.accrued(), accruedAmount); + } + + function test_revert_withdrawAccruedWhenBalanceInsufficient() public { + uint256 accruedAmount = _accrueViaPayout(10, 100); + + vm.prank(SYSTEM_ADDRESS); + gasback.setAccuralWithdrawer(address(this), true); + + vm.expectRevert(); + gasback.withdrawAccrued(address(0xCAFE), 1); + + assertEq(gasback.accrued(), accruedAmount); + } + + function testFuzz_fallbackPayoutAndAccrualWithSufficientBalance( + uint256 baseFee, + uint256 gasToBurn, + uint256 ratioNumerator + ) public { + baseFee = _bound(baseFee, 0, 1e12); + gasToBurn = _bound(gasToBurn, 0, 20000); + ratioNumerator = _bound(ratioNumerator, 0, DENOMINATOR); + + vm.startPrank(SYSTEM_ADDRESS); + gasback.setBaseFeeVaultShareNumerator(DENOMINATOR); + gasback.setGasbackRatioNumerator(ratioNumerator); + vm.stopPrank(); + + uint256 ethFromGas = baseFee * gasToBurn; + uint256 expectedEthFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; + uint256 expectedEthToGive = (ethFromGas * ratioNumerator) / DENOMINATOR; + + vm.deal(address(gasback), expectedEthToGive); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, expectedEthToGive); + assertEq(address(0xB0B).balance, expectedEthToGive); + assertEq(gasback.accrued(), expectedEthFromVaultShare - expectedEthToGive); + } + + function testFuzz_fallbackPassThroughOnInsufficientBalance(uint256 baseFee, uint256 gasToBurn) + public + { + baseFee = _bound(baseFee, 1, 1e12); + gasToBurn = _bound(gasToBurn, 1, 20000); + uint256 beforeAccrued = gasback.accrued(); + + vm.fee(baseFee); + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, 0); + assertEq(address(0xB0B).balance, 0); + assertEq(gasback.accrued(), beforeAccrued); + } + + function testRevertSetBaseFeeVaultShareNumeratorAboveDenominator() public { + address system = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; + vm.prank(system); + vm.expectRevert(); + gasback.setBaseFeeVaultShareNumerator(1 ether + 1); + } +} diff --git a/test/GasbackLiveFork.t.sol b/test/GasbackLiveFork.t.sol new file mode 100644 index 0000000..23bc01c --- /dev/null +++ b/test/GasbackLiveFork.t.sol @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "./utils/SoladyTest.sol"; +import {GasbackLiveProbe} from "../src/test/GasbackLiveProbe.sol"; + +interface IGasbackLiveFork { + function GASBACK_RATIO_DENOMINATOR() external view returns (uint256); + function gasbackRatioNumerator() external view returns (uint256); + function baseFeeVaultShareNumerator() external view returns (uint256); + function gasbackMaxBaseFee() external view returns (uint256); + function baseFeeVault() external view returns (address); + function accrued() external view returns (uint256); + function setBaseFeeVault(address value) external returns (bool); + function setGasbackMaxBaseFee(uint256 value) external returns (bool); +} + +interface IShapePaymentSplitterLiveFork { + function totalShares() external view returns (uint256); + function shares(address account) external view returns (uint256); + function releasable(address account) external view returns (uint256); +} + +interface IGasbackTestCallerLiveFork { + function GASBACK() external view returns (address); +} + +contract RejectingLiveCaller { + function trigger(address target, uint256 gasToBurn) external returns (uint256 payout) { + (bool success, bytes memory data) = target.call(abi.encode(gasToBurn)); + require(success); + payout = abi.decode(data, (uint256)); + } + + receive() external payable { + revert(); + } +} + +contract GasbackLiveForkTest is SoladyTest { + address internal constant SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; + address internal constant GASBACK = 0x21E34c5bea9253CDCd57671A1970BB31df4aBe83; + address internal constant SPLITTER = 0x658e643B379b52cD21605bfAf9c81e84713D8427; + address internal constant TEST_CALLER = 0xA53D127f193858f5ef2Cf50dd1B3A94198ef811d; + uint256 internal constant SHAPE_SEPOLIA_CHAIN_ID = 11011; + uint256 internal constant DENOMINATOR = 1 ether; + + IGasbackLiveFork internal gasback; + IShapePaymentSplitterLiveFork internal splitter; + + function setUp() public { + if (!vm.envExists("SHAPE_SEPOLIA_RPC_URL")) { + vm.skip(true, "SHAPE_SEPOLIA_RPC_URL not set"); + return; + } + + vm.createSelectFork(vm.envString("SHAPE_SEPOLIA_RPC_URL")); + gasback = IGasbackLiveFork(GASBACK); + splitter = IShapePaymentSplitterLiveFork(SPLITTER); + } + + function test_liveDeploymentConfiguration() public view { + assertEq(block.chainid, SHAPE_SEPOLIA_CHAIN_ID); + assertGt(GASBACK.code.length, 0); + assertGt(SPLITTER.code.length, 0); + assertGt(TEST_CALLER.code.length, 0); + assertEq(keccak256(GASBACK.code), keccak256(vm.getDeployedCode("Gasback.sol:Gasback"))); + assertEq(IGasbackTestCallerLiveFork(TEST_CALLER).GASBACK(), GASBACK); + + uint256 ratio = gasback.gasbackRatioNumerator(); + uint256 share = gasback.baseFeeVaultShareNumerator(); + assertLe(ratio, share); + assertLe(share, DENOMINATOR); + assertEq(gasback.GASBACK_RATIO_DENOMINATOR(), DENOMINATOR); + assertGt(gasback.baseFeeVault().code.length, 0); + } + + function test_liveSplitterShareMatchesGasbackShareNumerator() public view { + uint256 totalShares = splitter.totalShares(); + uint256 gasbackShares = splitter.shares(GASBACK); + assertGt(totalShares, 0); + assertGt(gasbackShares, 0); + assertEq((gasbackShares * DENOMINATOR) / totalShares, gasback.baseFeeVaultShareNumerator()); + } + + function test_liveBaseFeeVaultConfigurationWhenAbiSupported() public view { + address vault = gasback.baseFeeVault(); + (bool hasRecipient, address recipient) = _tryReadAddress(vault, "recipient()"); + if (hasRecipient) { + assertEq(recipient, SPLITTER); + } + + (bool hasWithdrawalNetwork, uint256 withdrawalNetwork) = + _tryReadUint(vault, "withdrawalNetwork()"); + if (hasWithdrawalNetwork) { + assertEq(withdrawalNetwork, 1); + } + } + + function test_forkProbePayoutOracleWithFundedBuffer() public { + uint256 baseFee = _boundedBaseFee(); + uint256 gasToBurn = 30_000; + vm.fee(baseFee); + + (uint256 expectedPayout, uint256 expectedAccruedDelta) = + _expectedPayoutAndAccruedDelta(baseFee, gasToBurn); + + uint256 accruedBefore = gasback.accrued(); + vm.deal(GASBACK, expectedPayout + 1 ether); + + GasbackLiveProbe probe = new GasbackLiveProbe(GASBACK); + uint256 probeBalanceBefore = address(probe).balance; + uint256 payout = probe.probe(gasToBurn); + + assertEq(payout, expectedPayout); + assertEq(address(probe).balance - probeBalanceBefore, expectedPayout); + assertEq(gasback.accrued() - accruedBefore, expectedAccruedDelta); + } + + function test_forkPassThroughWhenBufferIsInsufficientAndVaultDisabled() public { + uint256 baseFee = _boundedBaseFee(); + uint256 gasToBurn = 30_000; + vm.fee(baseFee); + vm.prank(SYSTEM_ADDRESS); + gasback.setBaseFeeVault(address(0)); + + (uint256 expectedPayout,) = _expectedPayoutAndAccruedDelta(baseFee, gasToBurn); + if (expectedPayout == 0) { + expectedPayout = 1; + } + + vm.deal(GASBACK, expectedPayout - 1); + uint256 accruedBefore = gasback.accrued(); + + GasbackLiveProbe probe = new GasbackLiveProbe(GASBACK); + uint256 payout = probe.probe(gasToBurn); + + assertEq(payout, 0); + assertEq(address(probe).balance, 0); + assertEq(gasback.accrued(), accruedBefore); + assertEq(GASBACK.balance, expectedPayout - 1); + } + + function test_forkPassThroughWhenBaseFeeExceedsMax() public { + uint256 baseFee = 100; + uint256 gasToBurn = 30_000; + vm.fee(baseFee); + vm.prank(SYSTEM_ADDRESS); + gasback.setGasbackMaxBaseFee(baseFee - 1); + + (uint256 expectedPayout,) = _expectedPayoutAndAccruedDelta(baseFee, gasToBurn); + vm.deal(GASBACK, expectedPayout + 1 ether); + uint256 accruedBefore = gasback.accrued(); + + GasbackLiveProbe probe = new GasbackLiveProbe(GASBACK); + uint256 payout = probe.probe(gasToBurn); + + assertEq(payout, 0); + assertEq(address(probe).balance, 0); + assertEq(gasback.accrued(), accruedBefore); + } + + function test_forkRepeatedSameBlockCallsAreAdditive() public { + uint256 baseFee = _boundedBaseFee(); + uint256 gasToBurn = 12_000; + uint256 calls = 3; + vm.fee(baseFee); + + (uint256 expectedPayout, uint256 expectedAccruedDelta) = + _expectedPayoutAndAccruedDelta(baseFee, gasToBurn); + + vm.deal(GASBACK, calls * expectedPayout + 1 ether); + uint256 accruedBefore = gasback.accrued(); + GasbackLiveProbe probe = new GasbackLiveProbe(GASBACK); + + for (uint256 i = 0; i < calls; i++) { + assertEq(probe.probe(gasToBurn), expectedPayout); + } + + assertEq(address(probe).balance, calls * expectedPayout); + assertEq(gasback.accrued() - accruedBefore, calls * expectedAccruedDelta); + } + + function test_forkBoundedStressSweep() public { + uint256[5] memory gasValues = [uint256(0), 1, 30_000, 120_000, 250_000]; + uint256[3] memory baseFees = [uint256(0), 1, _boundedBaseFee()]; + GasbackLiveProbe probe = new GasbackLiveProbe(GASBACK); + + for (uint256 i = 0; i < baseFees.length; i++) { + vm.fee(baseFees[i]); + for (uint256 j = 0; j < gasValues.length; j++) { + (uint256 expectedPayout, uint256 expectedAccruedDelta) = + _expectedPayoutAndAccruedDelta(baseFees[i], gasValues[j]); + uint256 accruedBefore = gasback.accrued(); + uint256 probeBalanceBefore = address(probe).balance; + vm.deal(GASBACK, expectedPayout + 1 ether); + + uint256 payout = probe.probe(gasValues[j]); + + assertEq(payout, expectedPayout); + assertEq(address(probe).balance - probeBalanceBefore, expectedPayout); + assertEq(gasback.accrued() - accruedBefore, expectedAccruedDelta); + } + } + } + + function test_forkRejectingReceiverStillReceivesPayout() public { + uint256 baseFee = _boundedBaseFee(); + uint256 gasToBurn = 30_000; + vm.fee(baseFee); + (uint256 expectedPayout,) = _expectedPayoutAndAccruedDelta(baseFee, gasToBurn); + vm.deal(GASBACK, expectedPayout + 1 ether); + + RejectingLiveCaller caller = new RejectingLiveCaller(); + uint256 payout = caller.trigger(GASBACK, gasToBurn); + + assertEq(payout, expectedPayout); + assertEq(address(caller).balance, expectedPayout); + } + + function test_forkInvalidCalldataReverts() public { + (bool success,) = GASBACK.call(hex"01"); + assertFalse(success); + } + + function test_forkSplitterReleasableReadDoesNotRevert() public view { + splitter.releasable(GASBACK); + } + + function _boundedBaseFee() internal view returns (uint256 baseFee) { + baseFee = gasback.gasbackMaxBaseFee(); + if (baseFee == 0) { + return 0; + } + if (baseFee > 1 gwei) { + return 1 gwei; + } + } + + function _expectedPayoutAndAccruedDelta(uint256 baseFee, uint256 gasToBurn) + internal + view + returns (uint256 expectedPayout, uint256 expectedAccruedDelta) + { + uint256 ethFromGas = baseFee * gasToBurn; + uint256 expectedShare = (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; + expectedPayout = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; + expectedAccruedDelta = expectedShare - expectedPayout; + } + + function _tryReadAddress(address target, string memory signature) + internal + view + returns (bool ok, address value) + { + bytes memory data; + (ok, data) = target.staticcall(abi.encodeWithSignature(signature)); + if (ok && data.length == 32) { + value = abi.decode(data, (address)); + } else { + ok = false; + } + } + + function _tryReadUint(address target, string memory signature) + internal + view + returns (bool ok, uint256 value) + { + bytes memory data; + (ok, data) = target.staticcall(abi.encodeWithSignature(signature)); + if (ok && data.length == 32) { + value = abi.decode(data, (uint256)); + } else { + ok = false; + } + } +} diff --git a/test/ShapePaymentSplitter.t.sol b/test/ShapePaymentSplitter.t.sol new file mode 100644 index 0000000..7317f5b --- /dev/null +++ b/test/ShapePaymentSplitter.t.sol @@ -0,0 +1,462 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +import "./utils/SoladyTest.sol"; +import {ShapePaymentSplitter} from "../src/ShapePaymentSplitter.sol"; + +contract RejectingPayee { + receive() external payable { + revert("I reject ETH"); + } +} + +contract ReentrantPayee { + ShapePaymentSplitter public splitter; + bool public didReenter; + + function setSplitter(ShapePaymentSplitter splitter_) external { + splitter = splitter_; + } + + receive() external payable { + if (!didReenter) { + didReenter = true; + (bool success,) = address(splitter).call{value: 1}(""); + require(success, "reenter failed"); + } + } +} + +contract ShapePaymentSplitterTest is SoladyTest { + event PaymentFailed(address to, uint256 amount, bytes reason); + + ShapePaymentSplitter public splitter; + + /// @dev fuzz helpers + + // Struct to reduce stack depth in fuzz tests + struct FuzzTestState { + address[] fuzzPayees; + uint256[] fuzzShares; + uint256[] initialBalances; + uint256 totalSharesSum; + uint256 cumulativeTotalPaid; + ShapePaymentSplitter fuzzSplitter; + } + + function _createFuzzTestState(uint8 numPayees, uint256 addrOffset) + internal + returns (FuzzTestState memory state) + { + state.fuzzPayees = new address[](numPayees); + state.fuzzShares = new uint256[](numPayees); + state.initialBalances = new uint256[](numPayees); + + for (uint256 i = 0; i < numPayees; i++) { + state.fuzzPayees[i] = vm.addr(i + addrOffset); + state.fuzzShares[i] = (i % 100) + 1; + state.totalSharesSum += state.fuzzShares[i]; + } + + state.fuzzSplitter = new ShapePaymentSplitter(state.fuzzPayees, state.fuzzShares); + + for (uint256 i = 0; i < numPayees; i++) { + state.initialBalances[i] = state.fuzzPayees[i].balance; + } + } + + function _sendPaymentAndUpdateState(FuzzTestState memory state, uint256 paymentAmount) + internal + { + state.cumulativeTotalPaid += paymentAmount; + vm.deal(address(this), paymentAmount); + (bool success,) = address(state.fuzzSplitter).call{value: paymentAmount}(""); + assertTrue(success); + } + + function _verifyPayeeBalances(FuzzTestState memory state, uint8 numPayees) internal view { + for (uint256 i = 0; i < numPayees; i++) { + uint256 actualReceived = state.fuzzPayees[i].balance - state.initialBalances[i]; + uint256 expectedReceived = + (state.cumulativeTotalPaid * state.fuzzShares[i]) / state.totalSharesSum; + assertEq(actualReceived, expectedReceived); + } + } + + address[] public payees = new address[](3); + uint256[] public shares = new uint256[](3); + + uint256 private _deployerKey = 1; + + uint256 private _payee1Key = 2; + uint256 private _payee2Key = 3; + uint256 private _payee3Key = 4; + + address private deployer = vm.addr(_deployerKey); + + address private payee1 = vm.addr(_payee1Key); + address private payee2 = vm.addr(_payee2Key); + address private payee3 = vm.addr(_payee3Key); + + uint256 public shares1 = 48; + uint256 public shares2 = 42; + uint256 public shares3 = 10; + + function setUp() public { + payees[0] = payee1; + payees[1] = payee2; + payees[2] = payee3; + + shares[0] = shares1; + shares[1] = shares2; + shares[2] = shares3; + + splitter = new ShapePaymentSplitter(payees, shares); + } + + function test_read_public_variables() public { + assertEq(splitter.payees().length, 3); + assertEq(splitter.totalShares(), 100); + assertEq(splitter.shares(payee1), shares1); + assertEq(splitter.shares(payee2), shares2); + assertEq(splitter.shares(payee3), shares3); + assertEq(splitter.payee(0), payee1); + assertEq(splitter.payee(1), payee2); + assertEq(splitter.payee(2), payee3); + } + + function test_balances_after_payment() public { + uint256 paymentAmount = 10 ether; + + // Record balances before + uint256 balanceBefore1 = payee1.balance; + uint256 balanceBefore2 = payee2.balance; + uint256 balanceBefore3 = payee3.balance; + + // Send ETH to the splitter (triggers receive() which releases to all payees) + vm.deal(address(this), paymentAmount); + (bool success,) = address(splitter).call{value: paymentAmount}(""); + assertTrue(success, "Payment to splitter failed"); + + // Record balances after + uint256 balanceAfter1 = payee1.balance; + uint256 balanceAfter2 = payee2.balance; + uint256 balanceAfter3 = payee3.balance; + + // Calculate expected amounts based on shares + uint256 totalShares = splitter.totalShares(); + uint256 expectedPayment1 = (paymentAmount * shares1) / totalShares; + uint256 expectedPayment2 = (paymentAmount * shares2) / totalShares; + uint256 expectedPayment3 = (paymentAmount * shares3) / totalShares; + + // Verify balance changes match expected payments + assertEq( + balanceAfter1 - balanceBefore1, expectedPayment1, "Payee1 received incorrect amount" + ); + assertEq( + balanceAfter2 - balanceBefore2, expectedPayment2, "Payee2 received incorrect amount" + ); + assertEq( + balanceAfter3 - balanceBefore3, expectedPayment3, "Payee3 received incorrect amount" + ); + + // Verify the exact amounts (48%, 42%, 10% of 10 ether) + assertEq(balanceAfter1 - balanceBefore1, 4.8 ether, "Payee1 should receive 4.8 ether"); + assertEq(balanceAfter2 - balanceBefore2, 4.2 ether, "Payee2 should receive 4.2 ether"); + assertEq(balanceAfter3 - balanceBefore3, 1 ether, "Payee3 should receive 1 ether"); + } + + function test_receive_allows_small_payment() public { + uint256 paymentAmount = 1 wei; + + uint256 balanceBefore1 = payee1.balance; + uint256 balanceBefore2 = payee2.balance; + uint256 balanceBefore3 = payee3.balance; + + vm.deal(address(this), paymentAmount); + (bool success,) = address(splitter).call{value: paymentAmount}(""); + assertTrue(success, "Payment to splitter failed"); + + assertEq(payee1.balance, balanceBefore1); + assertEq(payee2.balance, balanceBefore2); + assertEq(payee3.balance, balanceBefore3); + assertEq(address(splitter).balance, paymentAmount); + } + + function test_receive_skips_failed_payee_emits_failure() public { + RejectingPayee rejecter = new RejectingPayee(); + + address[] memory localPayees = new address[](2); + localPayees[0] = address(rejecter); + localPayees[1] = payee1; + + uint256[] memory localShares = new uint256[](2); + localShares[0] = 50; + localShares[1] = 50; + + ShapePaymentSplitter localSplitter = new ShapePaymentSplitter(localPayees, localShares); + + uint256 paymentAmount = 1 ether; + uint256 payee1Before = payee1.balance; + + vm.deal(address(this), paymentAmount); + vm.expectEmit(true, true, true, true); + emit PaymentFailed( + address(rejecter), + 0.5 ether, + abi.encodeWithSelector(ShapePaymentSplitter.FailedToSendValue.selector) + ); + + (bool success,) = address(localSplitter).call{value: paymentAmount}(""); + assertTrue(success, "Payment to splitter failed"); + + assertEq(payee1.balance - payee1Before, 0.5 ether); + assertEq(address(localSplitter).balance, 0.5 ether); + } + + function test_receive_allows_reentrant_payee() public { + ReentrantPayee reentrant = new ReentrantPayee(); + + address[] memory localPayees = new address[](2); + localPayees[0] = address(reentrant); + localPayees[1] = payee1; + + uint256[] memory localShares = new uint256[](2); + localShares[0] = 1; + localShares[1] = 1; + + ShapePaymentSplitter localSplitter = new ShapePaymentSplitter(localPayees, localShares); + reentrant.setSplitter(localSplitter); + + uint256 paymentAmount = 1 ether; + uint256 payee1Before = payee1.balance; + + vm.deal(address(this), paymentAmount); + (bool success,) = address(localSplitter).call{value: paymentAmount}(""); + assertTrue(success, "Payment to splitter failed"); + + assertTrue(reentrant.didReenter()); + assertEq(payee1.balance - payee1Before, 0.5 ether); + assertEq(address(localSplitter).balance, 1 wei); + } + + function test_distribute_noop_start_gte_end() public { + uint256 paymentAmount = 1 ether; + + uint256 balanceBefore1 = payee1.balance; + uint256 balanceBefore2 = payee2.balance; + uint256 balanceBefore3 = payee3.balance; + + vm.deal(address(splitter), paymentAmount); + splitter.distribute(2, 2); + + assertEq(payee1.balance, balanceBefore1); + assertEq(payee2.balance, balanceBefore2); + assertEq(payee3.balance, balanceBefore3); + assertEq(address(splitter).balance, paymentAmount); + } + + function test_distribute_clamps_end_to_payees_length() public { + uint256 paymentAmount = 1 ether; + + uint256 balanceBefore1 = payee1.balance; + uint256 balanceBefore2 = payee2.balance; + uint256 balanceBefore3 = payee3.balance; + + vm.deal(address(splitter), paymentAmount); + splitter.distribute(0, 10); + + uint256 totalShares = splitter.totalShares(); + uint256 expectedPayment1 = (paymentAmount * shares1) / totalShares; + uint256 expectedPayment2 = (paymentAmount * shares2) / totalShares; + uint256 expectedPayment3 = (paymentAmount * shares3) / totalShares; + + assertEq(payee1.balance - balanceBefore1, expectedPayment1); + assertEq(payee2.balance - balanceBefore2, expectedPayment2); + assertEq(payee3.balance - balanceBefore3, expectedPayment3); + assertEq(address(splitter).balance, 0); + } + + function test_distribute_invariants_with_failed_payee() public { + RejectingPayee rejecter = new RejectingPayee(); + + address[] memory localPayees = new address[](2); + localPayees[0] = address(rejecter); + localPayees[1] = payee1; + + uint256[] memory localShares = new uint256[](2); + localShares[0] = 50; + localShares[1] = 50; + + ShapePaymentSplitter localSplitter = new ShapePaymentSplitter(localPayees, localShares); + + uint256 paymentAmount = 1 ether; + uint256 payee1Before = payee1.balance; + + vm.deal(address(localSplitter), paymentAmount); + localSplitter.distribute(0, 2); + + assertEq(payee1.balance - payee1Before, 0.5 ether); + assertEq(localSplitter.released(payee1), 0.5 ether); + assertEq(localSplitter.released(address(rejecter)), 0); + assertEq(localSplitter.totalReleased(), 0.5 ether); + assertEq(address(localSplitter).balance, 0.5 ether); + assertEq(localSplitter.releasable(address(rejecter)), 0.5 ether); + assertEq(localSplitter.releasable(payee1), 0); + } + + function testFuzz_balances_after_payment(uint8 numPayees, uint256 paymentAmount) public { + numPayees = uint8(bound(numPayees, 1, 50)); + paymentAmount = bound(paymentAmount, 1 ether, 1000 ether); + + FuzzTestState memory state = _createFuzzTestState(numPayees, 100); + + _sendPaymentAndUpdateState(state, paymentAmount); + _verifyPayeeBalances(state, numPayees); + + assertLe(address(state.fuzzSplitter).balance, uint256(numPayees)); + } + + function testFuzz_balances_after_multiple_payments( + uint8 numPayees, + uint256[9] memory paymentAmounts + ) public { + numPayees = uint8(bound(numPayees, 1, 50)); + + FuzzTestState memory state = _createFuzzTestState(numPayees, 200); + + for (uint256 p = 0; p < 9; p++) { + uint256 paymentAmount = bound(paymentAmounts[p], 0.1 ether, 10 ether); + _sendPaymentAndUpdateState(state, paymentAmount); + _verifyPayeeBalances(state, numPayees); + } + + assertLe(address(state.fuzzSplitter).balance, uint256(numPayees) * 9); + } + + /// @dev deployment revert tests + + function test_revert_deploy_empty_payees() public { + address[] memory emptyPayees = new address[](0); + uint256[] memory emptyShares = new uint256[](0); + + vm.expectRevert(ShapePaymentSplitter.NoPayees.selector); + new ShapePaymentSplitter(emptyPayees, emptyShares); + } + + function test_revert_deploy_length_mismatch_more_payees() public { + address[] memory morePayees = new address[](3); + morePayees[0] = payee1; + morePayees[1] = payee2; + morePayees[2] = payee3; + + uint256[] memory fewerShares = new uint256[](2); + fewerShares[0] = 50; + fewerShares[1] = 50; + + vm.expectRevert(ShapePaymentSplitter.PayeesAndSharesLengthMismatch.selector); + new ShapePaymentSplitter(morePayees, fewerShares); + } + + function test_revert_deploy_length_mismatch_more_shares() public { + address[] memory fewerPayees = new address[](2); + fewerPayees[0] = payee1; + fewerPayees[1] = payee2; + + uint256[] memory moreShares = new uint256[](3); + moreShares[0] = 40; + moreShares[1] = 40; + moreShares[2] = 20; + + vm.expectRevert(ShapePaymentSplitter.PayeesAndSharesLengthMismatch.selector); + new ShapePaymentSplitter(fewerPayees, moreShares); + } + + function test_revert_deploy_zero_address_payee() public { + address[] memory badPayees = new address[](2); + badPayees[0] = payee1; + badPayees[1] = address(0); + + uint256[] memory validShares = new uint256[](2); + validShares[0] = 50; + validShares[1] = 50; + + vm.expectRevert(ShapePaymentSplitter.AccountIsTheZeroAddress.selector); + new ShapePaymentSplitter(badPayees, validShares); + } + + function test_revert_deploy_zero_shares() public { + address[] memory validPayees = new address[](2); + validPayees[0] = payee1; + validPayees[1] = payee2; + + uint256[] memory badShares = new uint256[](2); + badShares[0] = 100; + badShares[1] = 0; + + vm.expectRevert(ShapePaymentSplitter.SharesAreZero.selector); + new ShapePaymentSplitter(validPayees, badShares); + } + + function test_revert_deploy_duplicate_payee() public { + address[] memory duplicatePayees = new address[](3); + duplicatePayees[0] = payee1; + duplicatePayees[1] = payee2; + duplicatePayees[2] = payee1; // duplicate + + uint256[] memory validShares = new uint256[](3); + validShares[0] = 40; + validShares[1] = 40; + validShares[2] = 20; + + vm.expectRevert(ShapePaymentSplitter.AccountAlreadyHasShares.selector); + new ShapePaymentSplitter(duplicatePayees, validShares); + } + + function test_revert_release_account_has_no_shares() public { + address nonPayee = vm.addr(999); + + vm.expectRevert(ShapePaymentSplitter.AccountHasNoShares.selector); + splitter.release(payable(nonPayee)); + } + + function test_revert_release_account_not_due_payment() public { + // No ETH sent to splitter, so payee1 has 0 releasable + vm.expectRevert(ShapePaymentSplitter.AccountIsNotDuePayment.selector); + splitter.release(payable(payee1)); + } + + function test_revert_release_insufficient_balance() public { + // Manipulate storage to create an impossible state where totalReleased > 0 but balance = 0 + // _totalReleased is at storage slot 1 + vm.store(address(splitter), bytes32(uint256(1)), bytes32(uint256(100 ether))); + + // Now releasable(payee1) = (0 + 100 ether) * 48 / 100 - 0 = 48 ether + // But balance is 0, so _sendValue will revert + vm.expectRevert(ShapePaymentSplitter.InsufficientBalance.selector); + splitter.release(payable(payee1)); + } + + function test_revert_release_failed_to_send_value() public { + // Create a contract that rejects ETH + RejectingPayee rejecter = new RejectingPayee(); + + address[] memory rejectorPayees = new address[](1); + rejectorPayees[0] = address(rejecter); + + uint256[] memory rejectorShares = new uint256[](1); + rejectorShares[0] = 100; + + ShapePaymentSplitter rejectorSplitter = + new ShapePaymentSplitter(rejectorPayees, rejectorShares); + + // Send ETH to the splitter - it should emit a failure but not revert + vm.deal(address(this), 1 ether); + (bool success,) = address(rejectorSplitter).call{value: 1 ether}(""); + assertTrue(success); + + // Direct release should still revert since the payee rejects ETH + vm.expectRevert(ShapePaymentSplitter.FailedToSendValue.selector); + rejectorSplitter.release(payable(address(rejecter))); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c4772fc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": false, + "isolatedModules": true, + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2023", + "types": ["bun"] + }, + "include": ["live/**/*.ts"] +}