Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Settlement

The fourth stage, and the only one with side effects. It is mostly a list of things it refuses to do.

The backends

BackendWhat it doesStatus
dry-runRe-verifies the plan and reports every transfer that would happen. Moves nothing.default
evmValidates the config, builds the exact distributor call the plan translates into, then stops before signing.returns NotImplemented
[settlement]
backend = "dry-run"

dry-run is the default because the safe thing should be the default. It produces the same numbers a real settlement would; the difference is the broadcast, not the arithmetic.

Careful — the evm backend deliberately returns an error instead of pretending to broadcast. If you are reading the source and are tempted to “fix” that by returning a receipt: a settlement path that lies is worse than one that is missing. That refusal is the honest state of the project.

What settlement refuses

Every one of these is a way money could otherwise be lost:

  • A plan whose id does not match its contents. The id is re-derived from the plan before anything else happens. This catches an edited plan file, and it catches a plan built by a different version of the code.
  • A plan id already settled. The ledger is consulted, and an exclusive lock is held for the duration, so a retry or a concurrent job cannot pay twice. See Idempotence.
  • A transfer to the zero address. An unset placeholder in [wallets] is the common cause, and sending to it burns the money.
  • A round that reaches nobody. If the whole contributor pool is undistributed, settlement stops. --allow-undistributed overrides it, and exists for the case where you genuinely meant to send only the fees.

Dedalo holds no signing key

Not in CI, not in dedalo.toml, not on a maintainer’s machine. There is no flag that changes this, and there is no config key that names an environment variable holding one — settlement.signer_env was removed on purpose and must not come back.

What happens instead:

dedalo propose --plan ded106bd7281

prints the two transactions a round needs, with their calldata encoded, for somebody to execute from a multisig:

1. approve(claimContract, 1000000000)
   to     0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
   data   0x095ea7b3…

2. deposit(planId, merkleRoot, token, 1000000000)
   to     0x0000000000000000000000000000000000000000
   data   0xd0e30db0…

Nothing in that path opens a socket. A signer compares the printed calldata against a plan they can read, rather than trusting a tool they cannot.

The reason is narrow and worth stating: a key in CI can be reached by everything that can write a workflow. A pull request that edits a workflow file, a compromised action, a dependency with a build script — each of those becomes a path to the treasury the moment a key is in reach of a runner. There is no key in reach of a runner.

The pull model

A round is deposited once, against a Merkle root of its claims, and each contributor claims their own share.

dedalo plan     ─▶  a reviewed PayoutPlan, content-addressed
dedalo propose  ─▶  1. approve(claimContract, total)
                    2. deposit(planId, merkleRoot, token, total)
                        ↓
                    a multisig, signed by people who are not one person
                        ↓
                    contributors claim, each paying their own gas

Three holes in the obvious “loop over payees and send” design that this closes:

  • A contributor without a linked wallet is not a blocker. Their share sits in the round until they claim it.
  • The project pays one transaction’s gas, not one per payee.
  • A key in CI cannot drain the treasury, because there is no key in CI.

The vault

The rules a deployed contract enforces live in src/chain/vault as ordinary Rust, and they are pure: no storage, no clock, no caller. They take the state they need and return the state they produce.

That is what makes them testable over their whole domain instead of by deploying them somewhere and poking them. The deployable at src/chain/contract is an Arbitrum Stylus crate that compiles to WebAssembly and is deliberately thin — reading storage, moving a token, knowing the time. A reader checking whether the rules are correct should end up in vault, not in the binding.

The refusals are the specification

Refusal has one variant per way the vault says no, each with a test:

RefusalWhy it exists
RoundExistsReplay guard. A retried job proposing the same plan cannot fund it twice.
RoundUnknownNothing was deposited for this plan id.
NothingToDepositA round with no root or no total can never be claimed — money in, no way out.
ShortDeliveryThe token delivered less than promised. A fee-on-transfer token does this, and the round would pay early claimants and strand the rest.
AlreadyClaimedThis index of this round is already paid.
BadProofThe proof does not put this claim in this round’s tree.
ExceedsRoundThe claim is larger than what the round still holds.
NotExpiredThe claim window has not closed, so nothing may be swept.
NotDepositorOnly the account that funded a round may recover what is left.
Inconsistentclaimed exceeds total — unreachable through these functions, checked anyway, because it means something else wrote the state.
OverflowArithmetic would have wrapped.

A test asserts that no two refusals share a sentence, so a revert reason identifies exactly one rule.

The claim window is 180 days, fixed rather than chosen by the depositor. A depositor who could choose it could choose a window that closes before anybody claims.

The leaf encoding is pinned

chain::merkle::the_leaf_encoding_has_not_moved holds a root and a proof against a fixed fixture. A deployed vault verifies proofs against that encoding, so changing it silently would invalidate every round already deposited. Changing it deliberately is fine — the commit has to say why.

Status

Unaudited and undeployed. The vault’s rules are tested; the deployable compiles and fits the 24 KiB Stylus limit with room to spare; nothing has been deployed and no address in any shipped config is real.

What has to exist before real funds move is the list, and it is short enough to check.