Skip to content

Commit 93423f0

Browse files
committed
Apply CIP-159 direct deposits per-transaction, not batch-wide
Each transaction's `txDirectDeposits` are now applied to the threaded `CertState` immediately after that transaction's `CERTS` step, replacing the batch-wide application introduced earlier on this branch. * `SUBLEDGER-V` applies `DirectDepositsOf stx` to `certState₁` after `CERTS` and emits the result. * `LEDGER-V` still applies the top-level transaction's deposits to `certState₂` after the top-level `CERTS`, but the premise now bounds `dom (DirectDepositsOf tx)` rather than the batch aggregate `dom (allDirectDeposits tx)`. * `certStateWithDDeps` is generalised to `∀ {ℓ} → Tx ℓ → CertState → CertState` so both rules can invoke it with the local `DirectDepositsOf`. The original draft of this branch argued that batch-wide application made CIP-159's phantom-asset prohibition manifest in the rule structure. Per-transaction application is equally safe: the prohibition is enforced separately by `NoPhantomWithdrawals` in the `Utxo` module, which bounds batch-wide withdrawal totals against the `accountBalances` field of `UTxOEnv`/`SubUTxOEnv` — fixed at the pre-batch snapshot `RewardsOf certState₀` regardless of when (or how) deposits are applied to the threaded `CertState`. Per-transaction application also mirrors the Haskell ledger executor's sequential per-transaction processing, which simplifies the conformance proof and avoids a structural gap between the spec and the implementation. A direct-deposit registration premise is added to each rule to prevent `applyDirectDeposits` from silently re-introducing a credential into `rewards` after deregistration earlier in the same transaction: * `SUBLEDGER-V` requires `dom (DirectDepositsOf stx) ⊆ dom (RewardsOf certState₁)`. * `LEDGER-V` requires `dom (DirectDepositsOf tx) ⊆ dom (RewardsOf certState₂)`. The check is local to each transaction's post-`CERTS` state, so a sub-transaction whose deposit targets a credential deregistered by an earlier sub-transaction in the same batch will fail the premise. `Computational-SUBLEDGER` and `Computational-LEDGER` are updated to dispatch on these decidable premises in `computeProof` and `completeness`. `depositsChange` is unaffected: it reads only the protocol-deposit fields of `DState`/`PState`/`GState`, which `applyDirectDeposits` does not touch. Refs: #1122
1 parent d1691c9 commit 93423f0

3 files changed

Lines changed: 241 additions & 258 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
### WIP
66

7-
- Apply batch-wide direct deposits to `CertState` in `LEDGER-V` output (CIP-159).
7+
- Apply per-transaction direct deposits to `CertState` after each `CERTS` step in `SUBLEDGER-V` and `LEDGER-V` (CIP-159).
88
- Forbid CIP-159 fields (`txDirectDeposits`, `txBalanceIntervals`) in legacy mode via explicit `UTXOW-legacy` premises.
99
- Allow partial withdrawals in `PRE-CERT` rule; define `applyWithdrawals` and `_≤ᵐ_` (CIP-159).
1010
- Extend `TxInfo` with `txDirectDeposits` and `txBalanceIntervals` fields (CIP-159).

src/Ledger/Dijkstra/Specification/Ledger.lagda.md

Lines changed: 116 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -280,17 +280,130 @@ UTxOEnv{.AgdaDatatype}/SubUTxOEnv{.AgdaDatatype}.
280280
for both enacted CC credentials and any newly proposed CC actions that appear in
281281
the final (post-subtransaction processing) `GovState`{.AgdaRecord}.
282282

283+
### Direct Deposit Application (CIP-159)
284+
285+
Each transaction's direct deposits (`txDirectDeposits`{.AgdaField}) are applied to
286+
the `CertState`{.AgdaRecord} immediately after that transaction's
287+
`CERTS`{.AgdaDatatype} step, before the rule emits its output state.
288+
289+
+ In `SUBLEDGER-V`{.AgdaInductiveConstructor}, after `CERTS`{.AgdaDatatype} produces
290+
`certState₁`, the deposits in `DirectDepositsOf stx` are credited to the
291+
`rewards`{.AgdaField} map of `certState₁`'s `dState`{.AgdaField}, yielding the
292+
`certStateFinal` that appears in the final `LedgerState`{.AgdaRecord} of the
293+
`SUBLEDGER`{.AgdaDatatype} relation.
294+
+ In `LEDGER-V`{.AgdaInductiveConstructor}, after the top-level
295+
`CERTS`{.AgdaDatatype} produces `certState₂`, the deposits in
296+
`DirectDepositsOf tx` are credited the same way, yielding the `certStateFinal`
297+
that appears in the final `LedgerState`{.AgdaRecord} of the
298+
`LEDGER`{.AgdaDatatype} relation.
299+
300+
The helper `certStateWithDDeps`{.AgdaFunction} performs the per-transaction update.
301+
It uses `applyDirectDeposits`{.AgdaFunction} (from the `Certs`{.AgdaModule} module),
302+
which adds deposit amounts to the `DState`{.AgdaRecord} `rewards`{.AgdaField} map
303+
via `∪⁺`{.AgdaFunction} (union with addition).
304+
305+
```agda
306+
certStateWithDDeps : ∀ {ℓ} → Tx ℓ → CertState → CertState
307+
certStateWithDDeps tx cs = record cs { dState = applyDirectDeposits (DirectDepositsOf tx) (DStateOf cs) }
308+
```
309+
310+
`certStateWithDDeps`{.AgdaFunction} is invoked once per transaction in the batch
311+
(once per sub-transaction inside `SUBLEDGER-V`{.AgdaInductiveConstructor}, plus
312+
once for the top-level transaction inside `LEDGER-V`{.AgdaInductiveConstructor})
313+
on the *post-`CERTS`{.AgdaDatatype}* `CertState`{.AgdaRecord} for that
314+
transaction. (Since `applyDirectDeposits`{.AgdaFunction} only modifies
315+
`rewards`{.AgdaField}, and leaves `voteDelegs`{.AgdaField},
316+
`stakeDelegs`{.AgdaField}, `deposits`{.AgdaField}, `pState`{.AgdaField}, and
317+
`gState`{.AgdaField} unchanged, applying it per-transaction is
318+
equivalent to aggregating all direct deposits in the batch via `∪⁺`{.AgdaFunction}
319+
and applying the sum at the end.)
320+
321+
`depositsChange`{.AgdaFunction} is computed from `certState₀`{.AgdaBound},
322+
`certState₁`{.AgdaBound}, and `certState₂`{.AgdaBound} (not from
323+
`certStateFinal`{.AgdaBound}) because it measures *protocol* deposit movements
324+
(registration/deregistration of credentials, DReps, pools), which live in the
325+
`deposits`{.AgdaField} fields of `DState`{.AgdaRecord}/`PState`{.AgdaRecord}/
326+
`GState`{.AgdaRecord} — not in `rewards`{.AgdaField}. Since
327+
`applyDirectDeposits`{.AgdaFunction} only updates `rewards`{.AgdaField}, the two
328+
notions of "deposit" remain cleanly disjoint.
329+
330+
`rmOrphanDRepVotes`{.AgdaFunction} in `LEDGER-V`{.AgdaInductiveConstructor}
331+
receives `certStateFinal`{.AgdaBound} (rather than `certState₂`{.AgdaBound}) so
332+
that it sees the post-deposit `DRep`{.AgdaInductiveConstructor} state. In
333+
practice the result is the same either way, since `applyDirectDeposits`{.AgdaFunction}
334+
does not modify `dreps`{.AgdaField}.
335+
336+
#### Direct deposit registration premise
337+
338+
Each rule additionally requires that the credentials targeted by that
339+
transaction's direct deposits are registered in the *post-`CERTS`{.AgdaDatatype}*
340+
`CertState`{.AgdaRecord}.
341+
342+
+ `SUBLEDGER-V`{.AgdaInductiveConstructor} requires
343+
`dom (DirectDepositsOf stx) ⊆ dom (RewardsOf certState₁)`.
344+
+ `LEDGER-V`{.AgdaInductiveConstructor} requires
345+
`dom (DirectDepositsOf tx) ⊆ dom (RewardsOf certState₂)`.
346+
347+
Without this premise, `applyDirectDeposits`{.AgdaFunction} could silently
348+
re-introduce a credential into `rewards`{.AgdaField} that had been deregistered
349+
earlier in the same transaction's `CERTS`{.AgdaDatatype} step (and thus is no
350+
longer present in `voteDelegs`{.AgdaField}, `stakeDelegs`{.AgdaField}, or
351+
`deposits`{.AgdaField}), producing an inconsistent `DState`{.AgdaRecord}. The
352+
domain check rules this out at phase 1. Note that the check is performed
353+
against the post-`CERTS`{.AgdaDatatype} state of the *same* transaction, so
354+
deregistrations performed by *prior* sub-transactions in the batch are
355+
correctly accounted for; a sub-transaction whose deposit targets a credential
356+
deregistered by an earlier sub-transaction will fail this premise.
357+
358+
### Design Rationale: Per-transaction Direct Deposit Application
359+
360+
Here we justify the choice to apply direct deposits per-transaction, instead of
361+
aggregating and applying them batch-wide at the end of
362+
`LEDGER-V`{.AgdaInductiveConstructor}.
363+
364+
+ **Phantom asset prevention is enforced by `NoPhantomWithdrawals`{.AgdaFunction}**.
365+
366+
CIP-159 forbids "phantom asset" attacks in which a sub-transaction's direct
367+
deposit inflates the balance available to a later sub-transaction's withdrawal
368+
within the same batch. This restriction is enforced in the `Utxo`{.AgdaModule}
369+
module by the `NoPhantomWithdrawals`{.AgdaFunction} predicate, which bounds
370+
*batch-wide* withdrawal totals (per reward address) by the
371+
`accountBalances`{.AgdaField} field of `UTxOEnv`{.AgdaRecord} and
372+
`SubUTxOEnv`{.AgdaRecord} — the *pre-batch* snapshot `RewardsOf
373+
certState₀`{.AgdaBound}. Because `accountBalances`{.AgdaField} is fixed at the
374+
pre-batch value and never updated by direct deposit application, the CIP-159
375+
phantom-asset prohibition holds regardless of whether deposits are applied
376+
per-transaction or batch-wide.
377+
378+
+ **CIP-118 script context isolation is preserved by `accountBalances`{.AgdaField}**.
379+
380+
CIP-118 requires that Plutus scripts in one sub-transaction do not see other
381+
sub-transactions or the top-level transaction in their context. In the current
382+
spec, `accountBalances`{.AgdaField} (used for balance-interval checks and any
383+
future Plutus context derived from this field) is held fixed at
384+
`RewardsOf certState₀`{.AgdaBound} across the entire batch, so every
385+
sub-transaction sees the same pre-batch balances regardless of when deposits of
386+
other sub-transactions are applied.
387+
388+
+ **`depositsChange`{.AgdaFunction} remains orthogonal**.
389+
390+
`calculateDepositsChange`{.AgdaFunction} reads only the `deposits`{.AgdaField}
391+
fields of `DState`{.AgdaRecord}/`PState`{.AgdaRecord}/`GState`{.AgdaRecord}, which
392+
`applyDirectDeposits`{.AgdaFunction} does not touch. Whether direct deposits are
393+
applied per-transaction or batch-wide, `depositsChange`{.AgdaFunction} is
394+
unaffected.
395+
283396
```agda
284397
data _⊢_⇀⦇_,SUBLEDGER⦈_ : SubLedgerEnv → LedgerState → SubLevelTx → LedgerState → Type where
285398
286399
SUBLEDGER-V :
287400
∙ isTopLevelValid ≡ true
288401
∙ ⟦ slot , pp , treasury , utxo₀ , isTopLevelValid , allScripts , accountBalances ⟧ ⊢ utxoState₀ ⇀⦇ stx ,SUBUTXOW⦈ utxoState₁
289402
∙ ⟦ epoch slot , pp , ListOfGovVotesOf stx , WithdrawalsOf stx , allColdCreds govState₀ enactState ⟧ ⊢ certState₀ ⇀⦇ DCertsOf stx ,CERTS⦈ certState₁
403+
∙ dom (DirectDepositsOf stx) ⊆ dom (RewardsOf certState₁)
290404
∙ ⟦ TxIdOf stx , epoch slot , pp , ppolicy , enactState , certState₁ , dom (RewardsOf certState₁) ⟧ ⊢ govState₀ ⇀⦇ GovProposals+Votes stx ,GOVS⦈ govState₁
291405
────────────────────────────────
292-
⟦ slot , ppolicy , pp , enactState , treasury , utxo₀ , isTopLevelValid , allScripts , accountBalances ⟧ ⊢ ⟦ utxoState₀ , govState₀ , certState₀ ⟧ ⇀⦇ stx ,SUBLEDGER⦈ ⟦ utxoState₁ , govState₁ , certState₁ ⟧
293-
406+
⟦ slot , ppolicy , pp , enactState , treasury , utxo₀ , isTopLevelValid , allScripts , accountBalances ⟧ ⊢ ⟦ utxoState₀ , govState₀ , certState₀ ⟧ ⇀⦇ stx ,SUBLEDGER⦈ ⟦ utxoState₁ , govState₁ , certStateWithDDeps stx certState₁ ⟧
294407
SUBLEDGER-I :
295408
∙ isTopLevelValid ≡ false
296409
∙ ⟦ slot , pp , treasury , utxo₀ , isTopLevelValid , allScripts , accountBalances ⟧ ⊢ utxoState₀ ⇀⦇ stx ,SUBUTXOW⦈ utxoState₀
@@ -299,123 +412,8 @@ data _⊢_⇀⦇_,SUBLEDGER⦈_ : SubLedgerEnv → LedgerState → SubLevelTx
299412
300413
_⊢_⇀⦇_,SUBLEDGERS⦈_ : SubLedgerEnv → LedgerState → List SubLevelTx → LedgerState → Type
301414
_⊢_⇀⦇_,SUBLEDGERS⦈_ = ReflexiveTransitiveClosure {sts = _⊢_⇀⦇_,SUBLEDGER⦈_}
302-
```
303415
304-
**Direct Deposit Application (CIP-159)**. After all sub-rule transitions
305-
(`SUBLEDGERS`{.AgdaDatatype}, `CERTS`{.AgdaDatatype}, `GOVS`{.AgdaDatatype},
306-
`UTXOW`{.AgdaDatatype}), batch-wide direct deposits are applied to the final
307-
`CertState`{.AgdaRecord}. The function `applyDirectDeposits`{.AgdaFunction} (from
308-
the `Certs`{.AgdaModule} module) adds deposit amounts to the `DState`
309-
`rewards` map (stake credential reward account balances) via `∪⁺`,
310-
and `allDirectDeposits`{.AgdaFunction} (from the `Transaction`{.AgdaModule} module)
311-
aggregates direct deposits across the top-level transaction and all sub-transactions.
312416
313-
Direct deposits are applied *after* withdrawal processing (in `CERTS`{.AgdaDatatype})
314-
to ensure that withdrawals are checked against pre-batch balances. This prevents
315-
phantom asset attacks where a deposit from one sub-transaction inflates the balance
316-
available for withdrawal by another sub-transaction in the same batch.
317-
318-
```agda
319-
certStateWithDDeps : TopLevelTx → CertState → CertState
320-
certStateWithDDeps tx cs = record cs { dState = applyDirectDeposits (allDirectDeposits tx) (DStateOf cs) }
321-
```
322-
323-
`depositsChange`{.AgdaFunction} is computed from `certStateᵢ` (`i ∈ {0,1,2}`)
324-
(not `certStateFinal`) since it represents net deposit change across the batch
325-
(not direct deposit value transfers) and reflects registration/deregistration.
326-
327-
`rmOrphanDRepVotes` uses `certStateFinal` (not `certState₂`) so it sees
328-
the post-deposit `DRep` state. (In practice, `applyDirectDeposits`{.AgdaFunction}
329-
only modifies rewards, so `rmOrphanDRepVotes` would produce the same result either
330-
way, but using `certStateFinal` is semantically correct.)
331-
332-
**Deposits target post-batch registered accounts**. `SUBUTXO`{.AgdaDatatype}
333-
and `UTXO`{.AgdaDatatype} check that direct-deposit targets are registered in the
334-
*pre-batch* balances, but they cannot account for deregistrations that occur during
335-
the batch. Therefore, we add a new premise, `dom (allDirectDeposits tx) ⊆ dom (RewardsOf
336-
certState₂)`, to check that `applyDirectDeposits` does not silently re-introduce a
337-
deregistered credential into the `rewards` map without re-registering it via
338-
`voteDelegs`, `stakeDelegs`, or `deposits`, as that would result in an inconsistent
339-
`DState`. The premise rules this out at phase 1.
340-
341-
### Design Rationale: Batch-wide Direct Deposit Application
342-
343-
A natural alternative to applying direct deposits batch-wide (as above) is to
344-
interleave them with the per-sub-transaction processing performed by
345-
`SUBLEDGERS`{.AgdaDatatype}; that is, in each `SUBLEDGER-V`{.AgdaInductiveConstructor}
346-
step, apply that sub-transaction's `DirectDepositsOf stx` to the threaded
347-
`CertState`{.AgdaRecord} after running `CERTS`{.AgdaDatatype}/`GOVS`{.AgdaDatatype}
348-
for the sub-transaction.
349-
350-
**The CIP forbids this alternative, and the rule structure
351-
adopted here makes the prohibition manifest rather than imposing it as an extra
352-
premise.**
353-
354-
+ **CIP-159 rules out per-sub-transaction application**.
355-
356-
CIP-159 states that, "to prevent ... *phantom asset* attacks, transactions can
357-
only withdraw funds that exist in the account *before* the overall transaction
358-
is run. This means later sub-txs cannot withdraw assets that were deposited
359-
by prior sub-txs in the same overall transaction." Per-sub-transaction
360-
application would let sub-transaction `N`'s `PRE-CERT`{.AgdaDatatype} step
361-
authorize withdrawals against a `rewards`{.AgdaField} map already inflated by
362-
sub-transactions `1..N−1`'s deposits — exactly the situation the CIP forbids.
363-
364-
+ **Phantom asset prevention follows structurally**.
365-
366-
Because `applyDirectDeposits`{.AgdaFunction} never runs against the
367-
`CertState`{.AgdaRecord} threaded through `SUBLEDGERS`{.AgdaDatatype}, every
368-
`PRE-CERT`{.AgdaDatatype} step in the batch checks each withdrawal `amt` against
369-
`rewards`{.AgdaField} as updated only by `applyWithdrawals`{.AgdaFunction} and
370-
by registration/deregistration certificates. Summed across the batch this
371-
yields, for every reward address, total withdrawals bounded by the pre-batch
372-
balance (i.e., the `NoPhantomWithdrawals`{.AgdaFunction} property) *as a
373-
consequence of the rule structure* rather than as a separate premise that would
374-
need to be added and discharged.
375-
376-
+ **Alignment with CIP-118 script context isolation**.
377-
378-
CIP-118 requires that "Plutus scripts in one sub-transaction do not see other
379-
sub-transactions or the top-level transaction in their context." Holding the
380-
`accountBalances`{.AgdaField} field of `SubLedgerEnv`{.AgdaRecord} and
381-
`SubUTxOEnv`{.AgdaRecord} fixed at the pre-batch snapshot `RewardsOf certState₀`
382-
ensures that every sub-transaction's balance-interval check (and any future Plutus
383-
context derived from this field) sees the same balances independently of the
384-
surrounding sub-transactions. Per-sub-transaction application would make these
385-
views order-dependent across sub-transactions.
386-
387-
+ **Direct deposits are a diff, not a sequence of state updates**.
388-
389-
CIP-159 specifies that the `direct_deposits` field carries only the diff to be
390-
applied (e.g., `0.1 ADA`, not the resulting `100.1 ADA` balance). Aggregating
391-
each sub-transaction's diff via `∪⁺` (union-with-addition) and applying the
392-
sum once is the natural reading: the batch-level
393-
`allDirectDeposits`{.AgdaFunction} is the diff from pre-batch to post-batch.
394-
Per-sub-transaction application would impose an artificial sequential
395-
interpretation on what is logically a commutative aggregation.
396-
397-
+ **Determinism and reorderability**.
398-
399-
CIP-159 motivates direct deposits in part by their freedom from contention:
400-
deposits into the same account from different parties can be processed "in any
401-
order and fully parallel." Batch-wide application makes this manifest at the
402-
rule level: the order of sub-transactions in the batch does not affect the
403-
`accountBalances`{.AgdaField} visible to any sub-transaction's
404-
`SUBUTXO`{.AgdaDatatype} or `CERTS`{.AgdaDatatype} step, nor the final
405-
`rewards`{.AgdaField} reached after the deposit step.
406-
407-
+ **`depositsChange`{.AgdaFunction} remains orthogonal**.
408-
409-
`calculateDepositsChange`{.AgdaFunction} measures *protocol* deposit movements
410-
(registration/deregistration of credentials, DReps, pools), which live in the
411-
`deposits`{.AgdaField} fields of `DState`{.AgdaRecord}/`PState`{.AgdaRecord}/
412-
`GState`{.AgdaRecord} — not in `rewards`{.AgdaField}. Because
413-
`applyDirectDeposits`{.AgdaFunction} touches only `rewards`{.AgdaField} and
414-
runs *after* `calculateDepositsChange`{.AgdaFunction}, the two notions of
415-
"deposit" remain cleanly disjoint without case analysis.
416-
417-
418-
```agda
419417
data _⊢_⇀⦇_,LEDGER⦈_ : LedgerEnv → LedgerState → TopLevelTx → LedgerState → Type where
420418
421419
LEDGER-V :
@@ -434,7 +432,7 @@ data _⊢_⇀⦇_,LEDGER⦈_ : LedgerEnv → LedgerState → TopLevelTx → Ledg
434432
∙ IsValidFlagOf tx ≡ true
435433
∙ ⟦ slot , ppolicy , pp , enactState , treasury , utxo₀ , IsValidFlagOf tx , allScripts , RewardsOf certState₀ ⟧ ⊢ ⟦ utxoState₀ , govState₀ , certState₀ ⟧ ⇀⦇ SubTransactionsOf tx ,SUBLEDGERS⦈ ⟦ utxoState₁ , govState₁ , certState₁ ⟧
436434
∙ ⟦ epoch slot , pp , ListOfGovVotesOf tx , WithdrawalsOf tx , allColdCreds govState₁ enactState ⟧ ⊢ certState₁ ⇀⦇ DCertsOf tx ,CERTS⦈ certState₂
437-
∙ dom (allDirectDeposits tx) ⊆ dom (RewardsOf certState₂)
435+
∙ dom (DirectDepositsOf tx) ⊆ dom (RewardsOf certState₂)
438436
∙ ⟦ TxIdOf tx , epoch slot , pp , ppolicy , enactState , certState₂ , dom (RewardsOf certState₂) ⟧ ⊢ govState₁ ⇀⦇ GovProposals+Votes tx ,GOVS⦈ govState₂
439437
∙ ⟦ slot , pp , treasury , utxo₀ , depositsChange , allScripts , RewardsOf certState₀ ⟧ ⊢ utxoState₁ ⇀⦇ tx ,UTXOW⦈ utxoState₂
440438
────────────────────────────────

0 commit comments

Comments
 (0)