Skip to content

Instantly share code, notes, and snippets.

@pkoch
Created March 3, 2026 20:31
Show Gist options
  • Select an option

  • Save pkoch/b7dacd8c4cf6db843f7f5b32efe228a9 to your computer and use it in GitHub Desktop.

Select an option

Save pkoch/b7dacd8c4cf6db843f7f5b32efe228a9 to your computer and use it in GitHub Desktop.
diff --git a/script/initial-distribution/README.md b/script/initial-distribution/README.md
index 7bb3e0a..7a27bd1 100644
--- a/script/initial-distribution/README.md
+++ b/script/initial-distribution/README.md
@@ -40,7 +40,7 @@ These scripts interact with several on-chain contracts:
- **CCADisbursementTracker** -- Bookkeeping contract for the CCA flow. It
bridges the gap between what the CCA thinks should be disbursed and what's
actually disbursed by our scripts. It has a
- `recordDisbursement(beneficiary, amount, txHash)` function that records the
+ `recordDisbursements(recipients, amounts, txHashes)` function that records
disbursement on-chain for clear traceability of bonuses and vesting splits.
- **BatchCaller** -- Plumbing. EIP-7702 delegation target that lets the
disburser EOA execute multiple `disburse` calls in a single transaction. Used
@@ -134,7 +134,7 @@ The CCA flow uses two separate disbursers:
- **`TDE_DISBURSER_PRIVATE_KEY`** -- signs `TDEDisbursement.disburse()` calls
and IDOS token approvals.
- **`TRACKER_DISBURSER_PRIVATE_KEY`** -- signs
- `CCADisbursementTracker.recordDisbursement()` calls and
+ `CCADisbursementTracker.recordDisbursements()` calls and
`CCA.exitBid()`/`claimTokens()` calls.
```bash
diff --git a/script/initial-distribution/fork-run.sh b/script/initial-distribution/fork-run.sh
old mode 100644
new mode 100755
index fbd38ee..f51df69
--- a/script/initial-distribution/fork-run.sh
+++ b/script/initial-distribution/fork-run.sh
@@ -15,10 +15,10 @@ ANVIL_PORT="${ANVIL_PORT:-8545}"
ANVIL_RPC="http://127.0.0.1:${ANVIL_PORT}"
ARB1_RPC="https://arb1.arbitrum.io/rpc"
-TDE_DISBURSEMENT=0xdf24F4Ca9984807577d13f5ef24eD26e5AFc7083
-CCA_TRACKER=0xb628B89067E8f7Dfc2cB528a72BcfF7d5cEDcE29
-CCA=0xc27F8a94Df88C4f57B09067e07EA6bC11CA47e11
-IDOS_TOKEN=0x68731d6F14B827bBCfFbEBb62b19Daa18de1d79c
+TDE_DISBURSEMENT=0x4d428cecf85667e1cb90d24d1130683c78df48b5
+CCA_TRACKER=0xFbEf5ABeD8117f98988C5f694312b32b2A038dC2
+CCA=0x8Ad0b34755A333027Ca6cb03Cc96E9E7b6d74320
+IDOS_TOKEN=0x48fb081aeedb1a0a6143e716a839b94e927f82be
# ── Start Anvil ──────────────────────────────────────────────────────────────
diff --git a/script/initial-distribution/src/abis.ts b/script/initial-distribution/src/abis.ts
index c8e96d6..4654d37 100644
--- a/script/initial-distribution/src/abis.ts
+++ b/script/initial-distribution/src/abis.ts
@@ -12,11 +12,11 @@ export const trackerAbi = [
},
{
type: "function",
- name: "recordDisbursement",
+ name: "recordDisbursements",
inputs: [
- { name: "to", type: "address" },
- { name: "value", type: "uint256" },
- { name: "txHash", type: "bytes32" },
+ { name: "recipients", type: "address[]" },
+ { name: "values", type: "uint256[]" },
+ { name: "txHashes", type: "bytes32[]" },
],
outputs: [],
stateMutability: "nonpayable",
diff --git a/script/initial-distribution/src/cca.ts b/script/initial-distribution/src/cca.ts
index 61570a4..16b83fd 100644
--- a/script/initial-distribution/src/cca.ts
+++ b/script/initial-distribution/src/cca.ts
@@ -197,7 +197,7 @@ async function recordOnTracker(to: Address, ccaAmount: bigint, txHash: Hex): Pro
return (
await receiptFor(
publicClient,
- await trackerContract.write.recordDisbursement([to, ccaAmount, txHash]),
+ await trackerContract.write.recordDisbursements([[to], [ccaAmount], [txHash]]),
)
).transactionHash;
}
diff --git a/src/CCADisbursementTracker.sol b/src/CCADisbursementTracker.sol
index 84c97ba..3522370 100644
--- a/src/CCADisbursementTracker.sol
+++ b/src/CCADisbursementTracker.sol
@@ -7,7 +7,7 @@ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
// @dev As per CCA's own tests, the CCA can leave up to 1e18 wei of dust in the
// contract after sweep+claim.
-uint256 constant MAX_ALLOWABLE_DUST_WEI = 1e18;
+uint256 constant MAX_ALLOWABLE_DUST_WEI = 1;
/**
* @title CCADisbursementTracker
@@ -44,9 +44,9 @@ uint256 constant MAX_ALLOWABLE_DUST_WEI = 1e18;
* Note well: `claimTokensBatch` makes things harder to track 1:1 with this contract, since that can make a single
* transfer that contains bids from different phases of the sale.
*
- * The disburser should call the `recordDisbursement` function to record each disbursement, providing the txHash where
- * the actual disbursement took place and the amount disbursed, so that the original token distribution can be verified.
- * A disburser might choose to split disbursements in tranches, either because the allocation was done in different
+ * The disburser should call the `recordDisbursements` function to record disbursements, providing the txHash where the
+ * actual disbursement took place and the amount disbursed, so that the original token distribution can be verified. A
+ * disburser might choose to split disbursements in tranches, either because the allocation was done in different
* phases of the sales, or for any other reason. The uniqueness and non-zero-ness of txHash isn't enforced
* by design to allow for flexibility in the disburser's strategy.
*/
@@ -149,7 +149,7 @@ contract CCADisbursementTracker is ERC20 {
}
/// @notice Returns true when the sale is fully disbursed (total supply is zero and all missing disbursements recorded).
- /// @dev Expects sweepUnsoldTokens to have been called, all bid tokens claimed, and all disbursements recorded via recordDisbursement.
+ /// @dev Expects sweepUnsoldTokens to have been called, all bid tokens claimed, and all disbursements recorded via recordDisbursements.
function saleFullyDisbursed() public view returns (bool) {
return saleFullyClaimed() && _totalMissingDisbursements == 0;
}
@@ -174,6 +174,7 @@ contract CCADisbursementTracker is ERC20 {
error OnlyDisburserCanRecordDisbursements();
error OverdisbursementDetected();
error SaleNotFullyClaimed();
+ error ArrayLengthMismatch();
event MissingDisbursementRecorded(address indexed to, uint256 value);
event DisbursementCompleted(address indexed to, uint256 value, bytes32 txHash);
@@ -241,26 +242,39 @@ contract CCADisbursementTracker is ERC20 {
return result;
}
- /// @notice Records a single disbursement made off-chain, reducing the missing disbursement balance.
+ /// @notice Records disbursements made off-chain, reducing the missing disbursement balances.
/// @dev Only callable by the disburser after the sale is fully claimed. Reverts on overdisbursement or zero amounts.
- /// @param to Address the disbursement was made to
- /// @param value Amount disbursed
- /// @param txHash Transaction hash where the on-chain disbursement occurred
- function recordDisbursement(address to, uint256 value, bytes32 txHash) external {
- if (msg.sender != _DISBURSER) revert OnlyDisburserCanRecordDisbursements();
+ /// @param recipients Addresses to record disbursements for
+ /// @param values Amounts disbursed to each recipient
+ /// @param txHashes Transaction hashes where the on-chain disbursements occurred
+ function recordDisbursements(address[] calldata recipients, uint256[] calldata values, bytes32[] calldata txHashes)
+ external
+ {
if (!saleFullyClaimed()) revert SaleNotFullyClaimed();
- if (to == address(0)) revert NoZeroAddressRecipientAllowed();
- if (value == 0) revert NoZeroDisbursementsAllowed();
- if (_missingDisbursements[to] < value) revert OverdisbursementDetected();
- // txHash is intentionally not checked for 0x0 or uniqueness to allow for flexibility.
+ if (msg.sender != _DISBURSER) revert OnlyDisburserCanRecordDisbursements();
+ uint256 len = recipients.length;
+ if (len != values.length || len != txHashes.length) revert ArrayLengthMismatch();
- unchecked {
- _missingDisbursements[to] -= value;
+ for (uint256 i; i < len;) {
+ address to = recipients[i];
+ uint256 value = values[i];
+ if (to == address(0)) revert NoZeroAddressRecipientAllowed();
+ if (value == 0) revert NoZeroDisbursementsAllowed();
+ if (_missingDisbursements[to] < value) revert OverdisbursementDetected();
+ // txHash is intentionally not checked for 0x0 or uniqueness to allow for flexibility.
+
+ unchecked {
+ _missingDisbursements[to] -= value;
+ }
_totalMissingDisbursements -= value;
- }
- _disbursements[to].push(Disbursement({value: value, txHash: txHash}));
+ _disbursements[to].push(Disbursement({value: value, txHash: txHashes[i]}));
- emit DisbursementCompleted(to, value, txHash);
+ emit DisbursementCompleted(to, value, txHashes[i]);
+
+ unchecked {
+ ++i;
+ }
+ }
}
/// @dev Reverts; this contract does not accept ETH.
diff --git a/test/CCADisbursementTracker.invariant.sol b/test/CCADisbursementTracker.invariant.sol
index 13afbdc..a342cb4 100644
--- a/test/CCADisbursementTracker.invariant.sol
+++ b/test/CCADisbursementTracker.invariant.sol
@@ -32,8 +32,15 @@ contract DisbursementHandler is Test {
uint256 value = bound(valueSeed, 1, available);
+ address[] memory recipientsArr = new address[](1);
+ recipientsArr[0] = recipient;
+ uint256[] memory values = new uint256[](1);
+ values[0] = value;
+ bytes32[] memory txHashes = new bytes32[](1);
+ txHashes[0] = txHash;
+
vm.prank(disburser);
- tracker.recordDisbursement(recipient, value, txHash);
+ tracker.recordDisbursements(recipientsArr, values, txHashes);
ghostTotalDisbursed += value;
ghostDisbursedTo[recipient] += value;
diff --git a/test/CCADisbursementTracker.t.sol b/test/CCADisbursementTracker.t.sol
index f7fc739..0246046 100644
--- a/test/CCADisbursementTracker.t.sol
+++ b/test/CCADisbursementTracker.t.sol
@@ -184,7 +184,13 @@ contract CCADisbursementTrackerUnitTest is Test {
function _recordSingle(address to, uint256 value, bytes32 txHash) internal {
vm.prank(disburser_);
- tracker.recordDisbursement(to, value, txHash);
+ address[] memory recipients = new address[](1);
+ recipients[0] = to;
+ uint256[] memory values = new uint256[](1);
+ values[0] = value;
+ bytes32[] memory txHashes = new bytes32[](1);
+ txHashes[0] = txHash;
+ tracker.recordDisbursements(recipients, values, txHashes);
}
function test_RecordDisbursement_RevertsIfSaleNotFullyClaimed() public {
@@ -197,9 +203,15 @@ contract CCADisbursementTrackerUnitTest is Test {
function test_RecordDisbursement_RevertsIfNotDisburser() public {
_completeSale();
+ address[] memory r = new address[](1);
+ r[0] = holder1;
+ uint256[] memory v = new uint256[](1);
+ v[0] = 1;
+ bytes32[] memory h = new bytes32[](1);
+ h[0] = bytes32(uint256(1));
vm.prank(nobody);
vm.expectRevert(CCADisbursementTracker.OnlyDisburserCanRecordDisbursements.selector);
- tracker.recordDisbursement(holder1, 1, bytes32(uint256(1)));
+ tracker.recordDisbursements(r, v, h);
}
function test_RecordDisbursement_RevertsOnZeroAddress() public {
@@ -661,6 +673,12 @@ contract CCADisbursementTrackerIntegrationTest is Test {
function _recordSingle(address to, uint256 value, bytes32 txHash) internal {
vm.prank(disburser_);
- tracker.recordDisbursement(to, value, txHash);
+ address[] memory recipients = new address[](1);
+ recipients[0] = to;
+ uint256[] memory values = new uint256[](1);
+ values[0] = value;
+ bytes32[] memory txHashes = new bytes32[](1);
+ txHashes[0] = txHash;
+ tracker.recordDisbursements(recipients, values, txHashes);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment