A transfer is the atomic unit of value movement in the ledger. It consumes existing postings and creates new ones, preserving per-asset conservation.
There are two layers:
A movement is the fundamental building block:
struct Movement {
from: AccountId, // account being debited
to: AccountId, // account being credited
asset: AssetId, // asset to transfer
amount: Cent, // amount (may be negative for offset postings)
}
Every operation (pay, deposit, withdraw) is expressed as one or more movements. The resolve step aggregates net debits per (account, asset) and selects postings only for accounts with a positive net debit.
Transfer value between two accounts.
TransferBuilder::new()
.pay(from, to, asset, amount)
.build()
Produces one movement:
| from | to | asset | amount |
|---|---|---|---|
| A | B | USD | 50 |
Resolve selects postings from A to cover 50, creates a +50 posting on B, and returns change to A if the selected postings exceed 50.
Fund an account from a system/external source. Creates an offset posting on the source and a credit on the target.
TransferBuilder::new()
.deposit(to, asset, amount, external)
.build()
Produces two movements:
| from | to | asset | amount |
|---|---|---|---|
| external | external | USD | -100 |
| external | to | USD | +100 |
The first movement creates a -100 offset posting on the external account. The second creates a +100 posting on the target account.
Net debit on the external account: -100 + 100 = 0. No posting selection is needed: the offset is created directly.
Conservation: created sum = -100 + 100 = 0. Consumed sum = 0. Both sides balance.
Move value from an account to an external destination.
TransferBuilder::new()
.withdraw(from, asset, amount, external)
.build()
Produces one movement:
| from | to | asset | amount |
|---|---|---|---|
| A | external | USD | 50 |
Resolve selects postings from A to cover 50, creates a +50 posting on the external account, and returns change to A.
For operations that don't fit the convenience methods:
TransferBuilder::new()
.movement(from, to, asset, amount)
.build()
The resolve step converts a Transfer (intent) into an Envelope (concrete
postings) using a two-pass algorithm:
For each movement:
NewPosting { owner: to, asset, value: amount } with
payer: Some(from) when from != to(from, asset)For each (account, asset) pair where net debit > 0:
CappedOverdraft / UncappedOverdraft accounts: consume all positive
postings and create a negative posting for the shortfall
(net_debit − total_positive). The CappedOverdraft floor is enforced
later in validation.InsufficientFunds.Pairs with net debit <= 0 (e.g. the external account in a deposit) are skipped. No posting selection needed.
Aggregating debits before selection means that multiple movements debiting the same account share one selection pass. For example, if a transfer contains two payments from account A (50 + 30), the resolve selects postings once for 80 rather than twice.
After resolution, the result is an Envelope:
struct Envelope {
consumes: Vec<PostingId>, // postings to deactivate
creates: Vec<NewPosting>, // new postings to create
account_snapshots: Vec<AccountSnapshotId>,
book: BookId,
user_data: UserData,
metadata: Metadata,
}
The envelope is content-addressed: its EnvelopeId is the double-SHA-256 of
its canonical binary serialization. This provides idempotency (committing the
same envelope twice returns the cached receipt) and tamper evidence.
The TransferBuilder provides a fluent API for constructing transfers:
let transfer = TransferBuilder::new()
.deposit(alice, usd, Cent::from(1000), bank)
.pay(alice, bob, usd, Cent::from(200))
.book(sales_book)
.metadata(metadata)
.build();
A single transfer can contain multiple movements of different types. All movements execute atomically.
Transfer → resolve → Envelope → reserve → finalize(validate → write) → Receipt
Resolution is read-only; commit(transfer) resolves then runs the two-step
envelope saga (reserve → finalize) with automatic retry and LIFO compensation.
Validation runs inside the finalize step, immediately before the writes.
Envelope → reserve → finalize(validate → write) → Receipt
ledger.commit_envelope(envelope) runs the same saga for an envelope you
already hold (e.g. a hand-built multi-asset/FX envelope, or a reversal).
reverse() uses it. There is no separate single-pass "atomic" path.
reverse(transfer_id) creates a compensating envelope that:
This undoes the operation while preserving the full audit trail. No postings are deleted.
Every envelope passes through validate_and_plan() before being applied. The
validation steps are:
sum(consumed) == sum(created)NoOverdraft accounts (allowed on
overdraft/system/external)Validation runs inside the finalize step, immediately before it writes (the
last-step floor / freeze-close re-check). The finalize step then applies the
effects through a sequence of dumb, idempotent store primitives
(deactivate_postings → insert_postings → store_transfer → append_event),
verifying every end-state. There is no single transaction; crash-safety comes
from a phase-tracked write-ahead PendingSaga record plus recover()
roll-forward. The CappedOverdraft floor is re-checked as that last step
and is best-effort (not strictly atomic) under concurrency: two transfers
that each pass the floor check against the same pre-transfer balance can
both commit and jointly push the account below its floor. Per-asset
conservation still holds in that case (the negative postings are real
value owed, not minted). The overdraft floor is the only guard with this
property; double-spend prevention is exact (see
crates/kuatia/tests/concurrency.rs, which asserts the exact guarantees
and documents the floor race with an ignored test). See
architecture.md.
See architecture.md for details on each check.