Two‑party revenue sharing with single‑shot accounting via the withdraw trick. This repository contains an Aiken validator with three endpoints — spend, withdraw, and publish — plus unit tests built with Mocktail.
Goal: when either owner withdraws rewards for themself, they must simultaneously pay the other owner their exact share, enforced on‑chain. Staking/publish endpoints are included to register & delegate the stake credential.
- Design highlights
- Parameters
- Endpoints
- Withdraw trick (why and how)
- Validation logic — step by step
- Rounding & percent math
- Edge cases & security notes
- Limitations
- How to build
- How to integrate (TX building tips)
- Tests
- Future improvements
- Two signers:
owner_oneandowner_twoidentified by pub key hashes. - Deterministic split: first owner's share is parameter percent with two‑decimal precision (e.g., 50.00% -> 5000).
- Single computation: heavy checks run once under the withdraw endpoint. The spend endpoint defers to the withdraw via the withdraw trick.
- No third‑party payouts: the withdraw logic rejects outputs to addresses other than the two owners.
- Symmetric enforcement: whichever owner signs the withdrawal, the other must receive at least their minimum due; the withdrawing owner must not take more than their maximum due.
- Optional staking: the publish endpoint allows stake registration/delegation when either owner signs.
The validator is parameterized as:
validator revenue_share(
owner_one: ByteArray, // pub key hash of owner #1
owner_two: ByteArray, // pub key hash of owner #2
percent: Int, // owner_one share * 100 (two decimals)
)owner_one/owner_twoare the payment credential pub key hashes.percent: two‑decimal precision of owner_one's percentage:- Example: 12.34% →
percent = 1234 - Example: 50.00% →
percent = 5000
- Example: 12.34% →
Internally the code builds a rational with new(percent, 10000), so the effective fraction is percent / 10000.
Minimal per‑UTxO check that delegates validation to the withdraw endpoint of the staking script referenced by this script's stake credential.
spend(_datum, _redeemer, utxo, self) {
let Transaction { inputs, .. } = self
expect Some(own_input) = find_input(inputs, utxo)
let own_address = own_input.output.address
expect Script(withdraw_script_hash) = own_address.payment_credential
// Defer heavy validation to the withdraw endpoint once per TX
spend(withdraw_script_hash, fn(_redeemer, _amount) { True }, self)
}Core revenue‑split enforcement. Calculates each owner's net withdrawal in this transaction and enforces bounds according to percent.
withdraw(_redeemer, _account, self) {
// ...filters, sums, difference between outputs and inputs per owner...
// owner_one must sign XOR owner_two must sign (not both)
// bounds:
// withdrawing owner's net <= their max share of total
// counterparty's net >= their min share of total
// and outputs must go only to the two owners
}Permits stake credential operations (register + delegate) if either owner signs.
publish(_redeemer, _certificate, self) {
has(extra_signatories, owner_one) || has(extra_signatories, owner_two) == True?
}Problem: per‑UTxO validator checks are repeated for each input, which is costly for multi‑input revenue accounting.
Solution: use the withdraw trick:
- The spend endpoint only checks the presence (and optionally redeemer/amount) of a withdrawal from this script's staking credential.
- The heavy logic runs once under the withdraw endpoint, where the full transaction context is available to compute global sums and enforce the split.
This reduces repeated computation while keeping strong guarantees on how funds are split when rewards are withdrawn.
Inside withdraw:
- Partition outputs to addresses owned by
owner_oneandowner_two(by payment credential). - Sum Lovelace paid to each owner:
total_ada_to_owner_{one,two}. - Partition inputs that are funded by each owner (usually only the withdrawing owner funds fee/change inputs):
input_list_owner_{one,two}. - Sum Lovelace from those inputs:
total_ada_inputted_owner_{one,two}. - Compute net withdrawals per owner:
net_one = total_ada_to_owner_one - total_ada_inputted_owner_one
net_two = total_ada_to_owner_two - total_ada_inputted_owner_two
total = net_one + net_two
This removes "self‑funding" noise (e.g., fee inputs, change) and captures just the net amount withdrawn for each owner in this TX.
- Forbid third‑party payouts: ensure no outputs go to payment credentials other than the two owners.
- Build rational percent:
p = percent / 10000. - Signature shape: exactly one owner signs via
extra_signatories:(True, False)→owner_onewithdrawing(False, True)→owner_twowithdrawing- anything else → fail
- Enforce bounds (with floor rounding):
- If
owner_onewithdraws:net_one <= floor(total * p)net_two >= floor(total * (1 - p))
- If
owner_twowithdraws:net_two <= floor(total * (1 - p))net_one >= floor(total * p)
- If
- All conditions must hold for the TX to validate.
Intuition: the withdrawing owner cannot take more than their share; the counterparty must receive at least their share. Either party can withdraw for both, but the math enforces fairness on‑chain.
percentuses two decimals of precision (×100). Internallynew(percent, 10000)makes the exact rational.- Multiplications are done in rational space and then floored to Lovelace.
- Using
<=for the withdrawer and>=for the counterparty avoids over‑capture due to rounding down. It also permits voluntary over‑payment to the counterparty (see Notes below).
percent = 5000→p = 0.5total = 10_000_000- Bounds: each side's share is
floor(5_000_000)
- Single signer: exactly one of the two owners must be in
extra_signatories. Both signing or neither signing → fail. - Outputs only to owners: any third‑party payment causes fail.
- Netting logic: subtracting inputs prevents an owner from artificially inflating their "received" amount by funding the TX from their own wallet.
- Rounding: tiny remainders due to floor always favor the counterparty, never allow the withdrawing owner to exceed their share.
- Voluntary generosity: the rules allow the withdrawing owner to pay more than required to the counterparty (because counterparty has a
>=check). If you want exact equality, see Future improvements. - Lovelace‑only: the logic uses
lovelace_of(...). Non‑ADA assets are ignored by the revenue split rules.
- ADA only accounting.
- Two owners only; extending to N owners requires generalizing the math & checks.
- Exact‑split not enforced; bounds allow the withdrawer to take ≤ their share and give the counterparty ≥ their share. (This is intentional to be safe under rounding; can be changed.)
- No time/window logic; the contract doesn't track epochs or frequency.
aiken fmt
aiken buildaiken testNote: Replace
ai kenwithaiken— spacing shown only to avoid accidental copy/paste in some shells.
Place the validator sources and the provided tests in your Aiken project layout (validators/, tests/, etc.). Ensure the helper libraries (mocktail, cocktail) are available in your aiken.toml.
High‑level guidance (Mesh/Lucid/CLI abstractions vary):
- Construct a withdrawal from the script's stake credential (the same script), even if the withdrawal Lovelace is 0 — this is the classic withdraw‑zero pattern that triggers the heavy checks once.
- Add one owner's signature only.
- Outputs:
- Pay both owners in the intended split (Lovelace only).
- Do not include other payment credentials.
- Inputs:
- It's fine if the withdrawing owner funds fees or supplies change; the validator nets this out.
- For publish (stake ops):
- Build a certificate:
RegisterAndDelegateCredential{ credential: Script(…stake hash…), delegate: …, deposit: 2_000_000 }. - Ensure at least one owner signs.
- Build a certificate:
Remember: if you skip (1), spend will fail because it expects a matching withdraw for the same script hash.
The suite in tests uses mocktail builders to assemble synthetic transactions:
- Pass: zero withdrawal (both spend and withdraw) — ensures logic doesn't break on 0 Lovelace.
- Pass: second owner withdraws — only
owner_twosigns; math enforcesowner_one≥ p share. - Pass: non‑zero withdrawal — both endpoints validate with
withdraw_lovelace > 0. - Fail: wrong percent split — violates the share bounds.
- Fail: outputs to more than two owners — third‑party output is detected and rejected.
- Pass/Fail: publish — certificate allowed with an owner signature; rejected otherwise.
Each test constructs a base TX with:
- Owner outputs (
tx_outto each owner address) - Optional third‑party output
- Owner inputs (
tx_in), plus a script input to simulate the withdrawal context - A
script_withdrawalentry with the script hash and amount - Extra signatories and optional certificate
- Exact split mode: enforce
net_one == floor(total * p)andnet_two == total - net_one(with robust rounding strategy). Today we allow ≤/≥ bounds for safety. - Multi‑asset support: generalize from
lovelace_ofto value‑level accounting across assets. - N‑party generalization: accept a vector of
(pkh, bps)and enforce a full distribution. - Anti‑griefing knobs: minimum counterparty payout, epoch‑frequency limits, or timelocks.
- Redeemer‑gated endpoints: require explicit redeemer tags for spend vs withdraw vs publish opcodes.
- Withdraw deferral in spend — calls helper
stake_validator.spend(withdraw_script_hash, ...). - Owner partitioning — filter by
VerificationKey(owner_one/owner_two)on outputs and inputs. - Net computation —
foldlover inputs, subtract fromlovelace_of(get_all_value_to(...)). - Bounds —
floor(mul(from_int(total), percent_rational))and its complement. - Publish —
has(extra_signatories, owner_i)disjunction.
MIT License.
This code and README are provided for educational purposes. Audit before use in production. On‑chain behavior depends on full transaction construction and network rules at the time of submission.