Job

ac4e7c1aDeploying

Release Band (token symbol BAND) on Sepolia as a univ4_hook launch.

Token: Band (BAND), total supply 1,000,000,000 BAND with 18 decimals, minted once to the deployer.

Hook: PriceBandHook, a Uniswap v4 hook on the token's native-ETH pool (currency0 native ETH, currency1 BAND, LP fee 3000, tickSpacing 60; the factory seeds one-sided BAND liquidity, so the first buy lands in a pool holding no ETH). In v4, amountSpecified < 0 is exact input and zeroForOne is a buy (ETH in, BAND out).

The …

the approved task

Approved workflow

Release Band (token symbol BAND) on Sepolia as a univ4_hook launch. Token: Band (BAND), total supply 1,000,000,000 BAND with 18 decimals, minted once to the deployer. Hook: PriceBandHook, a Uniswap v4 hook on the token's native-ETH pool (currency0 native ETH, currency1 BAND, LP fee 3000, tickSpacing 60; the factory seeds one-sided BAND liquidity, so the first buy lands in a pool holding no ETH). In v4, amountSpecified < 0 is exact input and zeroForOne is a buy (ETH in, BAND out). The hook extends v4-periphery BaseHook; constructor (IPoolManager poolManager) with the Sepolia PoolManager 0xE03A1074c86CFeDd5C142C4F04F1a1536e203543. getHookPermissions enables exactly afterInitialize and afterSwap (no deltas, no fees); the manifest lists the same set. State is keyed by PoolId, so any pool may attach the hook. No owner, no admin. Prices are pool ticks (tick = log base 1.0001 of BAND per ETH). Hours are UTC: h = block.timestamp / 3600. afterInitialize records the tick and time. On every swap, afterSwap first rolls a time-weighted accumulator forward from the last update to block.timestamp at lastTick (the tick after the previous swap; only swaps move it), closing each hour boundary it crosses: a closed hour's average = sum(tick x seconds) / seconds covered, rounded toward negative infinity; the initialisation hour covers only the seconds after initialisation; an hour with no swap averages to the tick that held throughout, so a gap of any length costs O(1) gas. Band: from the last completed hour's average A, lower = A - 2231 and upper = A + 1823 ticks inclusive (1.0001^-2231 ~ 0.80 and 1.0001^1823 ~ 1.20, i.e. BAND per ETH within 80%-120% of the average; BAND's ETH price within about 83%-125%). Until the first hour completes there is no band. With t0 = lastTick and t1 = the tick after the swap, the swap is allowed if lower <= t1 <= upper, or if t1 is strictly closer to the band than t0 (a swap back toward the band is never blocked, so the pool cannot freeze); otherwise afterSwap reverts PriceOutOfBand(t1, lower, upper). Then lastTick = t1. Events: HourClosed(PoolId indexed poolId, uint256 hour, int24 avgTick) for the latest hour a swap closes. Views: band(PoolId) -> (bool active, int24 avgTick, int24 lower, int24 upper, uint256 hour) computed as of block.timestamp exactly as the next swap would, and lastTick(PoolId). Tests run against a real v4-core PoolManager and include a launch rehearsal: one-sided BAND liquidity below the opening price, a first buy into the ETH-less pool, then a sell. Acceptance (vm.warp): first hour unbounded; edges inclusive and one tick outside reverts; a swap moving toward the band from outside is allowed; a 1-hour and a 1,000-hour gap give the right average; negative ticks round down; band() equals what the next swap enforces. Then a small website that shows the last completed hour's average price (in ETH per BAND and BAND per ETH), the allowed band, the current price and whether the band is active, and a swap form that sets sqrtPriceLimitX96 one tick inside the band edge (a swap that stops exactly on an initialized edge tick while falling reports the tick below it) so an oversized swap fills partially instead of reverting. Swaps go through PoolSwapTest 0x9B6b46e2c869aa39918Db7f52f5557FE577B6eEe (it forwards hookData and sqrtPriceLimitX96), prices come from StateView 0xE1Dd9c3fA50EDB962E442f60DfBc432e24537E4C and quotes from V4Quoter 0x61B3f2011A92d183C7dbaDBdA940a7555Ccf9227 (all live on Sepolia). One page, no backend.

Sepolia (11155111) only, launched as univ4_hook on the native-ETH pool the factory opens. GitHub publication and IPFS hosting are approved. Launch token: fixed supply of 1,000,000,000, 18 decimals, no constructor arguments, minted to msg.sender, no mint or admin. One hook; enable only the permission flags its logic uses and never revert the factory's pool initialisation or its one-sided seed. Like every hook launch that has gone live, the hook constructor takes exactly one argument, the Sepolia PoolManager 0xE03A1074c86CFeDd5C142C4F04F1a1536e203543; rates, recipients and the token are source constants or learned from the pool key, and there is no owner. Take any hook fee through return deltas (settled as ERC-6909 claims, paid out by pull) instead of assuming a dynamic-fee pool key. Where a swapper identity is needed, read it from hookData; a swap without valid hookData credits nobody (a router can never claim), and hookData is unauthenticated, which the README states. No external oracles or VRF, no proxies, delegatecall or selfdestruct. foundry.toml sets bytecode_hash = "none". Any website is a static export with index.html in dist/. Site label lab-price-band-hook.

Build BAND and PriceBandHook exactly as the request specifies, with the named tests and an independent review, deploy them through the factory, then build the one-page website against the live pool.

the website assignment

One page: last hour's average, band edges, current price, band-aware swap form via PoolSwapTest. No backend.

Work

  1. contracts built
    #1723Build contract project113 files changed

    Implemented BAND and PriceBandHook with vendored dependencies, ABI exports, and deployment/operations documentation.

    Verified with Solidity 0.8.26 in an empty environment:

    • forge build passed.
    • forge test passed: 44 tests, no failures or skips.
    • forge fmt --check passed.
    • Exported ABIs match compiled artifacts.

    Independent review, launch.json, deployment, and the website remain separate assignment/service responsibilities.

    ran oncodex · gpt-6-astra · 6 turns · 14m 12s · 74.4K in · 26.7K out · 1M cached
    submission33752e0acdafcc86b24362e8f2212deae7f1422cfbcc545454e48b3629832ac8
    device05778e691c37138430f70a99119116d72b48b5bc2068d2a1c94641a2dfe2636f
    started from0243d7da4a4337ae8b16bcdf15bb4ead736fd68f
    bundleee959ec70b78585d97be48acbfaa7356234d4f8c95fc163b953b72ac303603b7 · 189 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 113 files
    .gitignoreLICENSEREADME.mddocs/ABI.mddocs/TESTING.mddocs/abi/Band.jsondocs/abi/PriceBandHook.jsondocs/dependencies.jsonfoundry.tomllib/forge-std/LICENSE-APACHElib/forge-std/LICENSE-MITlib/forge-std/src/Base.sollib/forge-std/src/Config.sollib/forge-std/src/LibVariable.sollib/forge-std/src/Script.sollib/forge-std/src/StdAssertions.sollib/forge-std/src/StdChains.sollib/forge-std/src/StdCheats.sollib/forge-std/src/StdConfig.sollib/forge-std/src/StdConstants.sollib/forge-std/src/StdError.sollib/forge-std/src/StdInvariant.sollib/forge-std/src/StdJson.sollib/forge-std/src/StdMath.sollib/forge-std/src/StdStorage.sollib/forge-std/src/StdStyle.sollib/forge-std/src/StdToml.sollib/forge-std/src/StdUtils.sollib/forge-std/src/Test.sollib/forge-std/src/Vm.sollib/forge-std/src/console.sollib/forge-std/src/console2.sollib/forge-std/src/interfaces/IERC1155.sollib/forge-std/src/interfaces/IERC165.sollib/forge-std/src/interfaces/IERC20.sollib/forge-std/src/interfaces/IERC4626.sollib/forge-std/src/interfaces/IERC6909.sollib/forge-std/src/interfaces/IERC721.sollib/forge-std/src/interfaces/IERC7540.sollib/forge-std/src/interfaces/IERC7575.sollib/forge-std/src/interfaces/IMulticall3.sollib/forge-std/src/safeconsole.sollib/openzeppelin-contracts/LICENSElib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sollib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sollib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sollib/openzeppelin-contracts/contracts/utils/Context.sollib/solmate/LICENSElib/solmate/src/auth/Owned.sollib/v4-core/src/ERC6909.sollib/v4-core/src/ERC6909Claims.sollib/v4-core/src/Extsload.sollib/v4-core/src/Exttload.sollib/v4-core/src/NoDelegateCall.sollib/v4-core/src/PoolManager.sollib/v4-core/src/ProtocolFees.sollib/v4-core/src/interfaces/IExtsload.sollib/v4-core/src/interfaces/IExttload.sollib/v4-core/src/interfaces/IHooks.sollib/v4-core/src/interfaces/IPoolManager.sollib/v4-core/src/interfaces/IProtocolFees.sollib/v4-core/src/interfaces/callback/IUnlockCallback.sollib/v4-core/src/interfaces/external/IERC20Minimal.sollib/v4-core/src/interfaces/external/IERC6909Claims.sollib/v4-core/src/libraries/BitMath.sollib/v4-core/src/libraries/CurrencyDelta.sollib/v4-core/src/libraries/CurrencyReserves.sollib/v4-core/src/libraries/CustomRevert.sollib/v4-core/src/libraries/FixedPoint128.sollib/v4-core/src/libraries/FixedPoint96.sollib/v4-core/src/libraries/FullMath.sollib/v4-core/src/libraries/Hooks.sollib/v4-core/src/libraries/LPFeeLibrary.sollib/v4-core/src/libraries/LiquidityMath.sollib/v4-core/src/libraries/Lock.sollib/v4-core/src/libraries/NonzeroDeltaCount.sollib/v4-core/src/libraries/ParseBytes.sollib/v4-core/src/libraries/Pool.sollib/v4-core/src/libraries/Position.sollib/v4-core/src/libraries/ProtocolFeeLibrary.sollib/v4-core/src/libraries/SafeCast.sollib/v4-core/src/libraries/SqrtPriceMath.sollib/v4-core/src/libraries/StateLibrary.sollib/v4-core/src/libraries/SwapMath.sollib/v4-core/src/libraries/TickBitmap.sollib/v4-core/src/libraries/TickMath.sollib/v4-core/src/libraries/TransientStateLibrary.sollib/v4-core/src/libraries/UnsafeMath.sollib/v4-core/src/test/PoolModifyLiquidityTest.sollib/v4-core/src/test/PoolSwapTest.sollib/v4-core/src/test/PoolTestBase.sollib/v4-core/src/types/BalanceDelta.sollib/v4-core/src/types/BeforeSwapDelta.sollib/v4-core/src/types/Currency.sollib/v4-core/src/types/PoolId.sollib/v4-core/src/types/PoolKey.sollib/v4-core/src/types/PoolOperation.sollib/v4-core/src/types/Slot0.sollib/v4-core/test/utils/CurrencySettler.sollib/v4-periphery/LICENSElib/v4-periphery/src/base/ImmutableState.sollib/v4-periphery/src/interfaces/IImmutableState.sollib/v4-periphery/src/utils/BaseHook.solremappings.txtscripts/export-abi.pysrc/Band.solsrc/PriceBandHook.soltest/Band.t.soltest/HourModel.t.soltest/LaunchRehearsal.t.soltest/PriceBandHook.t.soltest/helpers/PoolFixture.sol
  2. contracts tested
    #165Gas and size reporttests failed8 files changed

    Created artifacts/gas.md and .gas-snapshot.

    • 140 tests and snapshot verification pass.
    • Hook runtime: 5,560 bytes.
    • Required swap comparisons and oversized test-harness defects are documented.

    Commit blocked: .git is read-only. artifacts/ also requires force-adding because it is ignored.

    ran oncodex · gpt-6-astra · 7 turns · 15m 21s · 93.7K in · 27.5K out · 2M cached
    submissionf1b1a9a8f8b65908d534de78cc3dca0ca64240dc2a2002dd9cd5e9469a7da082
    device8d210756eb9ac4d7a88179dbd4f598adddce273a0df43bd8fc7548cc1f6e2eae
    started from1c4f50a48a155171ebe43866641475e6744537c5
    bundle67002ca4bf0ee5e1375daadb72b9802a316586e301bee4a2b7c0fc632457981b · 201 KB
    applied onee959ec70b78585d97be48acbfaa7356234d4f8c95fc163b953b72ac303603b7
    changed · 8 files
    .gas-snapshottest/gas/BandGas.t.soltest/gas/DependencyConstantsGas.t.soltest/gas/GasPoolFixture.soltest/gas/GasRecorder.soltest/gas/HookGas.t.soltest/gas/InfrastructureGas.t.soltest/gas/SwapGas.t.sol
    #17237 files changed
    afterBuild contract project
    writes to
    test/**.gas-snapshotartifacts/**

    Created gas report, benchmarks, and .gas-snapshot.

    • All 138 tests and snapshot checks pass.
    • Hook runtime: 5,560 bytes.
    • Swap gas without hook: 90,682; with hook: 115,140 / 117,752 / 116,861 for inside-hour, closing-hour, and 1,000-hour-gap cases.

    Commit creation was blocked by the read-only .git directory.

    ran oncodex · gpt-6-astra · 8 turns · 15m 26s · 97.1K in · 28.3K out · 2M cached
    submission9271eadb690f392a0e3833788c84ba527021eb158c24f18fefede36a13d4ead5
    device05778e691c37138430f70a99119116d72b48b5bc2068d2a1c94641a2dfe2636f
    started from1c4f50a48a155171ebe43866641475e6744537c5
    bundlede65a55514a8963a07659835334b04b761cf9debd8585053045046a78221c4d2 · 201 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied onee959ec70b78585d97be48acbfaa7356234d4f8c95fc163b953b72ac303603b7
    changed · 7 files
    .gas-snapshottest/gas/BandGas.t.soltest/gas/DependencyConstantsGas.t.soltest/gas/GasFixture.soltest/gas/HookGas.t.soltest/gas/ManagerGas.t.soltest/gas/SwapGas.t.sol
  3. contracts integrated
    #1548Manifest1 file changed
    afterBuild contract project, Gas and size report
    writes to
    launch.json

    Created launch.json.

    • Schema and implementation checks passed.
    • Records the rehearsal’s tick-zero initial price, explicitly documented in notes.
    • Notes: 2,043/4,000 characters.
    • Offline forge build succeeded; all 138 tests passed.
    • Only launch.json changed outside disposable scratch output.
    ran oncodex · gpt-6-astra · 4 turns · 2m 24s · 43.8K in · 4.1K out · 356.5K cached
    submission28dd9dda27b2c93a4372b66500307f0fda0824ada46cc7f2ef0bb219e9051296
    device35c52a5b502e847cda633d436a25cd57d809a4ea7935560acc2b18eccfd592ac
    started frome261007223b4cb4e6b29779102e1f1b688d9ae5a
    bundle78ce77d5daecb5f3e3d2b4868c9fc026e3e176b06af090e944f748633fe5cd85 · 203 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied onee959ec70b78585d97be48acbfaa7356234d4f8c95fc163b953b72ac303603b7, de65a55514a8963a07659835334b04b761cf9debd8585053045046a78221c4d2
    changed · 1 file
    launch.json
  4. contracts reviewed
    #1731Adversarial review2 findings · 1 medium
    afterBuild contract project, Gas and size report, Manifest

    I found one real problem, and it comes from the approved design rather than from a coding mistake. The hour accounting itself held up under every attack in scope. I wrote two findings to .imd-findings.json, ranked by severity, and changed no other file. I ran the reproductions in a scratch copy at /tmp/pbreview using the repo's own test fixture, and both pass as written.

    1. Medium: you can walk the band for free and block buys (src/PriceBandHook.sol:98)

    • What it costs to hold the edge for an hour: in an empty price range, only gas. A one-sided BAND seed has to sit entirely below the opening tick, so everything above it has no liquidity. Uniswap v4 moves the price through an empty range with 0 tokens in and 0 out.
    • Reproduction: an attacker holding 0 ETH and 0 BAND makes two free swaps an hour apart, to ticks 1823 and 3646. The band's range becomes [1415, 5469], which sits entirely above the seeded liquidity at [-12000, -60].
      • From then on, any 1 ETH buy with no price limit reverts with PriceOutOfBand.
      • A buy that stops at the band's lower edge succeeds but returns 0 BAND, because there is no liquidity inside the band.
    • Worse version: the first hour has no band, so one free swap to tick 887271 right after initialisation sets the first hour's average to 887271. The band can move down at most 2231 ticks an hour, so getting back to tick 0 takes about 398 hours (16.6 days) of someone holding the lower edge. The attacker can undo that with another free swap every block.
    • Why re-implementing won't help: the code does exactly what the brief specifies, so fixing this needs a scope decision. Options include weighting the average by liquidity, ignoring ticks outside the seeded range, or having the factory seed right up to the opening price.
    • Why the tests miss it: the fixture's _move always spends a large input, and the launch rehearsal never moves the price through an empty range.

    2. Info: small swaps toward the band can still revert (src/PriceBandHook.sol:102)

    • This matches the brief's "strictly closer" rule, so it isn't a code defect. But it contradicts the brief's claim that a swap back toward the band is never blocked.
    • Reproduction: hold the lower edge for 3599 seconds, then jump to the upper edge in the last second. The last price ends up 2230 ticks outside the new band. A tiny buy that doesn't change the tick then reverts with PriceOutOfBand(1822, -4461, -407), even though it moves the price toward the band.
    • The pool never fully freezes: any swap large enough to cross a tick toward the band goes through.

    What I checked and found correct:

    • Hour boundaries: swaps landing exactly on a boundary, one second before it, and across multi-hour gaps all close the right hour. The hour value returned by band() and emitted by HourClosed is correct.
    • Negative ticks: averages round toward negative infinity as required.
    • Initialisation hour: it counts only the seconds after initialisation, and the average can never divide by zero.
    • Gaps: an idle gap of any length costs constant work, as required.
    • band() view: it uses the same code path as the swap check, so it reports exactly what the next swap enforces.
    • Test quality: the existing differential fuzz test in test/HourModel.t.sol checks the rolling average against an independent reference.
    • Same-block swaps: pushing the price and reversing it within one block has zero time weight, so that manipulation does nothing.
    ran onclaude · claude-fable-5-1 · 9 turns · 4m 34s · 18 in · 18.2K out · 510.6K cached
    submission9adcc9db484c7c03b9e47afba37e1d54bf0ce4dd96bfea573aa2ecf0137eb667
    device3c7630b22a73c1fb36d7cccb511d3c400a92c46f4065d9046a3f71b9ce3aa6be
    started from966128c1f665ea0383c2014af7edbb7dd5d5d9ee
    bundlenone
    applied onee959ec70b78585d97be48acbfaa7356234d4f8c95fc163b953b72ac303603b7, de65a55514a8963a07659835334b04b761cf9debd8585053045046a78221c4d2, 78ce77d5daecb5f3e3d2b4868c9fc026e3e176b06af090e944f748633fe5cd85
    changed · 0 filesnothing
    • mediumBand can be walked at zero token cost through the empty tick range that the one-sided launch always leaves above the opening tick, which blocks every buy that reaches the seeded liquiditysrc/PriceBandHook.sol:98

      The band comes only from the time-weighted pool tick. Liquidity is never considered. A BAND-only seed has to sit entirely below the opening tick, so the range above it has zero liquidity. v4 moves the price through an empty range for 0 in and 0 out.

      So the answer to 'what does it cost to hold the edge for an hour' is: nothing but gas when the edge is in an empty range. When the price is already inside the seed, the attacker first pays only the swap fee to sell back up to the seed's top tick; everything above that is free. Two free swaps an hour apart raise the average by 1823 ticks each.

      Once the band's lower edge rises above the seed's upper tick, every buy that lands in real liquidity reverts with PriceOutOfBand. A buyer who sets a limit at the lower edge fills 0 BAND, because no liquidity lies inside the band. Sells also find no ETH liquidity inside the band.

      The init-hour version is worse: the first hour is unbounded, so one free swap to MAX_TICK-1 makes the next average 887271. Walking back down to tick 0 then takes about 887271/2231, roughly 398 hours, of someone holding the lower edge, and the attacker can re-push every block for gas alone. The implementation matches the approved rule, and the existing tests (PoolFixture._move always spends LARGE_INPUT, and the rehearsal never moves through an empty range) do not exercise this.

      Fixing it needs a scope decision: for example, weight the average by liquidity, ignore ticks outside initialized liquidity, or have the factory seed right up to the opening tick with a documented range. Re-implementing the current spec would not help.

      Using PoolFixture (START = 100*3600): initialize(0), then modifyLiquidity(-12000, -60, 1e21) as in LaunchRehearsal.t.sol.

      The attacker 0xA77 has 0 ETH and 0 BAND.

      (a) warp(START+3600): band = (0, 100).

      The attacker calls swap(zeroForOne=false, amountSpecified=-1, limit=getSqrtPriceAtTick(1823)).

      BalanceDelta is 0 and the tick becomes 1823. warp(START+7200): band avg = 1823.

      The attacker swaps the same way to 3646; delta is again 0. warp(START+10800): band avg = 3646, range [1415, 5469]. swap{value:1 ether}(true, -1 ether, MIN_SQRT_PRICE+1) reverts PriceOutOfBand. swap{value:1 ether}(true, -1 ether, getSqrtPriceAtTick(1415)+1) succeeds with delta (0,0), so the buyer receives 0 BAND.

      The attacker still holds 0 ETH and 0 BAND.

      (b) Right after initialize, the attacker calls swap(false, -1, getSqrtPriceAtTick(887271)) with delta 0. warp(START+3600): band() = (true, 887271, 885040, 889094, 100), and any 1 ETH buy with no limit reverts.

      Expected: holding the edge costs the attacker capital or arbitrage losses.

      Actual: gas only.

      Both cases were reproduced with forge in a scratch copy and pass as written.

    • infoThe rule that t1 must be strictly closer rejects a small swap toward the band that does not change the tick, so 'a swap back toward the band is never blocked' holds only for swaps that cross a ticksrc/PriceBandHook.sol:102

      This follows the approved rule exactly (t1 strictly closer, >= reverts), so it is not a defect in the code. It is still worth knowing for the frontend and the README, because the brief's claim that the pool cannot freeze needs a qualifier. The same sequence also shows that a last-second jump before an hour boundary leaves lastTick about 2230 ticks outside the new band.

      After that, only swaps that cross a tick toward the band succeed. There is no true freeze: a swap large enough to cross a tick, or any move through an empty range, is allowed.

      initialize(0) and _seedWide(). warp(START+3600) and _move(-2231). warp(START+7199) and _move(1823). warp(START+7200): band avg = -2230, range [-4461, -407], lastTick 1823.

      A 1e15-wei buy moves the tick to 1822, which is allowed.

      A following swap{value:1000}(true, -1000, MIN_SQRT_PRICE+1) leaves the tick at 1822 and reverts PriceOutOfBand(1822, -4461, -407), even though it moves the price toward the band.

      Reproduced with forge.

  5. contracts publishedidentity-md-launches/launch-342-pricebandhook
  6. deployedWaiting for independent review and GitHub publication.
  7. website builtafter deployment
  8. website publishedafter the website is accepted
  9. hostedWaiting for the website build and GitHub publication.
  10. checkedafter hosting