Skip to content

Commit a58dc2c

Browse files
feat(platform)!: token shielded pools
A token can own its own Orchard shielded pool from protocol version 14. `TokenConfiguration::V1` adds `hasShieldedPool`; contracts with the flag get a pool at `[Tokens, TOKEN_SHIELDED_POOLS_KEY, token_id]` laid out like the credit pool, and three batch token transitions (TokenShield, TokenUnshield, TokenShieldedTransfer) move tokens into, out of and inside it. The identity signs and pays the fee in credits; the token id, owner id and, for an unshield, recipient and amount are bound into the Orchard sighash; pool balances are a term of the token conservation check; touched pools have their anchors recorded and pruned at block end. The six shielded queries take an optional token_id, the proof verifier and SDK route on it, and DPP builders plus wasm bindings expose the new transitions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 0db4c36 commit a58dc2c

227 files changed

Lines changed: 11807 additions & 361 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

book/src/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
- [Data Contracts](data-model/data-contracts.md)
5757
- [Documents](data-model/documents.md)
5858
- [Identities](data-model/identities.md)
59+
- [Token Shielded Pools](data-model/token-shielded-pools.md)
5960

6061
# Drive
6162

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# Token Shielded Pools
2+
3+
From protocol version 14 a token can own a shielded pool: an Orchard pool that holds that token
4+
instead of credits. Holders move tokens between their identity balance and the pool with three
5+
token transitions inside a batch, and transfer inside the pool without revealing amounts or
6+
counterparties. This chapter describes the storage, the configuration flag, the transitions, the
7+
validation rules, the block end bookkeeping, the queries and the client builders.
8+
9+
## Why one pool per token
10+
11+
The Orchard construction Platform uses for credits has no asset base: a note carries a value
12+
but not an asset id, and the value balance the circuit proves is a single number. Mixing tokens
13+
in one pool would let a spend of token A create a note of token B. A token therefore gets its
14+
own pool, and each pool is a copy of the credit pool's layout rooted under the token.
15+
16+
## Storage layout
17+
18+
The credit shielded pool lives at `[ShieldedBalances(52)]/"M"`. Token pools live under the
19+
tokens tree:
20+
21+
```text
22+
[Tokens(16)]
23+
[TOKEN_SHIELDED_POOLS_KEY(224)] BigSumTree
24+
[token_id] SumTree (the pool)
25+
NOTES[128] CommitmentTree, chunk power 11 (the note commitments and ciphertexts)
26+
NULLIFIERS[64] ProvableCountTree (spent nullifiers)
27+
ANCHORS_IN_POOL[192] anchor -> block height
28+
TOTAL_BALANCE[32] SumItem (tokens currently shielded)
29+
ANCHORS_BY_HEIGHT[96] block height -> anchor (for pruning)
30+
```
31+
32+
The five children use the same keys as the credit pool, so the drive primitives that insert
33+
notes and nullifiers, read balances and record anchors are shared: every credit pool method has
34+
a pool-agnostic form taking the pool path, and a token twin that supplies the token's path. The
35+
root of all token pools is a BigSumTree so the amount of every token that is shielded is one
36+
sum, which the token conservation check reads.
37+
38+
The root tree is created by the version 14 upgrade transition (`transition_to_version_14`) on
39+
an existing chain and by `create_initial_state_structure` version 4 on a new one. A pool's five
40+
trees are created when a contract with the flag is inserted or updated.
41+
42+
## Configuration
43+
44+
`TokenConfiguration` gains a format version 1 whose only addition over version 0 is
45+
`hasShieldedPool: bool`. A version 0 configuration behaves as `hasShieldedPool: false`.
46+
The format version is admitted by
47+
`dpp.contract_versions.token_versions.token_configuration_format`: protocol versions 13 and
48+
below allow only version 0, protocol version 14 allows versions 0 and 1. Contract create and
49+
update reject a token configuration outside the bounds with `UnsupportedVersionError`, so a
50+
pre-14 network never stores the flag.
51+
52+
The flag is immutable. A contract update that changes it is rejected with
53+
`DataContractTokenConfigurationUpdateError` for `hasShieldedPool`, because a pool that was
54+
enabled can hold notes that would become unspendable, and a pool that is enabled late would
55+
need a tree created under an existing token.
56+
57+
## Transitions
58+
59+
The three operations are `TokenTransition` variants inside a `Batch` transition, like every
60+
other token operation. The identity signs the batch and pays the fee in credits. Tokens cannot
61+
pay fees, so unlike the credit pool nothing is carved from the bundle's value balance.
62+
63+
| Transition | Flags | Value balance | Extra sighash data | Effect |
64+
|---|---|---|---|---|
65+
| `TokenShield` | outputs only | `-amount` | none | `amount` leaves the owner's balance and enters the pool as new notes. |
66+
| `TokenUnshield` | spends and outputs | `+amount` | `token_id, owner_id, recipient_id, amount` | Notes are spent; `amount` is credited to `recipient_id`; change comes back as new notes. |
67+
| `TokenShieldedTransfer` | spends and outputs | `0` | `token_id, owner_id` | Notes are spent and recreated; the pool balance is unchanged. |
68+
69+
Each transition carries the Orchard bundle (`actions`, `anchor`, `proof`, `binding_signature`)
70+
next to the token base transition (`token_id`, contract id, contract position, identity
71+
contract nonce). The extra sighash data is bound into the Orchard sighash by the client and
72+
recomputed by consensus from the transition's own fields, so a bundle proven for one token,
73+
owner, recipient or amount cannot be replayed with another. The layouts are in
74+
`dpp::shielded::sighash` (`token_unshield_extra_sighash_data`,
75+
`token_shielded_transfer_extra_sighash_data`).
76+
77+
The shield bundle has no spends, so its anchor is not checked against the pool; the client
78+
builds it against the empty tree. Spending bundles must name an anchor the pool has recorded.
79+
80+
## Validation
81+
82+
Structure validation checks the amount bounds, the action count against
83+
`SystemLimits::max_shielded_transition_actions`, the encrypted note sizes, a non-empty proof and
84+
a non-zero anchor.
85+
86+
State validation runs in this order, and the first failure is returned:
87+
88+
1. The token base transition (contract exists, position valid, nonce).
89+
2. `hasShieldedPool` on the token's configuration, else `TokenShieldedPoolNotEnabledError`.
90+
3. Shield: the owner holds `amount`, the owner's account is not frozen, the token is not
91+
paused. Unshield: the token is not paused, the recipient identity exists, the recipient's
92+
account is not frozen unless the token allows transfers to frozen balances. Shielded
93+
transfer: the token is not paused.
94+
4. Spending bundles: the anchor is recorded in the pool (`InvalidAnchorError`), no nullifier
95+
repeats within the bundle or is already spent (`NullifierAlreadySpentError`), and for an
96+
unshield the pool holds `amount`.
97+
5. Proof verification. The fee for it, `compute_shielded_verification_fee(actions)`, is added
98+
as a precalculated operation before the Halo 2 proof and binding signature are checked, so a
99+
failed proof is a paid failure: the identity is charged, its nonce advances, and nothing
100+
else moves.
101+
102+
Batch state validation does not run in `CheckTx`. The mempool admission path
103+
(`CheckTxProofVerifier`) verifies the proofs of batch token transitions keyed by the identity
104+
contract nonce, so a proof is verified once per nonce before the block and not again for the
105+
same submission.
106+
107+
The transitions are gated on the protocol version: below 14 `validate_is_allowed` rejects a
108+
batch carrying any of them with `StateTransitionNotActiveError`.
109+
110+
## Execution and conservation
111+
112+
The drive operations are composites of the credit pool primitives re-rooted under the token:
113+
114+
- shield: remove `amount` from the owner's token balance, append the notes, add `amount` to
115+
the pool's `TOTAL_BALANCE`;
116+
- unshield: insert the nullifiers, append the notes, subtract `amount` from the pool balance,
117+
add `amount` to the recipient's token balance;
118+
- shielded transfer: insert the nullifiers, append the notes.
119+
120+
The total supply of a token never changes. `calculate_total_tokens_balance` version 1 reads
121+
the token pools BigSumTree and the block end conservation check requires
122+
`identity balances + pool balances == total supply`.
123+
124+
## Block end
125+
126+
Every successful or paid token pool transition records its pool in
127+
`StateTransitionsProcessingResult::token_shielded_pools_touched`. At block end
128+
`record_token_shielded_pool_anchors` (enabled by `DRIVE_ABCI_METHOD_VERSIONS_V10`) records
129+
each touched pool's current anchor if the commitment tree changed and prunes that pool's
130+
anchors older than `shielded_anchor_retention_blocks`, always keeping the newest one. Pruning
131+
is driven by touches rather than by an interval because there can be many pools; an idle pool
132+
keeps a valid anchor to spend against.
133+
134+
## Queries
135+
136+
The six shielded pool queries (`getShieldedPoolState`, `getShieldedNotesCount`,
137+
`getShieldedAnchors`, `getMostRecentShieldedAnchor`, `getShieldedEncryptedNotes`,
138+
`getShieldedNullifiers`) take an optional `token_id`. Without it they target the credit pool;
139+
with a 32-byte token id they target that token's pool and answer with the same response shape.
140+
A token id is rejected with `InvalidArgument` before protocol version 14 or when it is not 32
141+
bytes. The proof verifier routes on the same field to the token twins of the verify functions
142+
(`verify_token_shielded_pool_state` and the rest), and the Rust SDK exposes
143+
`TokenShieldedPoolQuery`, `TokenShieldedEncryptedNotesQuery` and `TokenShieldedNullifiersQuery`.
144+
145+
A token pool transition proves its execution like the token transfer it resembles: a shield
146+
proves the owner's balance, an unshield proves the recipient's balance, and a shielded transfer
147+
proves the spent nullifiers in the token's pool.
148+
149+
## Client builders
150+
151+
`dpp::shielded::builder` provides `build_token_shield_transition`,
152+
`build_token_unshield_transition` and `build_token_shielded_transfer_transition`. They prove
153+
the bundle, bind the extra sighash data, and call the batch constructors
154+
(`new_token_shield_transition` and siblings) which sign with the identity key. The wasm
155+
bindings expose `TokenShieldTransition`, `TokenUnshieldTransition` and
156+
`TokenShieldedTransferTransition`, and `TokenConfiguration` accepts `hasShieldedPool` and
157+
reports `formatVersion`.
158+
159+
## Fees
160+
161+
See [Shielded Transaction Fees](../fees/shielded-fees.md#token-shielded-pool-fees). In short:
162+
the identity pays the metered cost of the writes plus the proof verification fee, exactly like
163+
`ShieldFromIdentity`, for all three transitions.

book/src/fees/shielded-fees.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,22 @@ The fee is derived differently depending on the shielded transition type:
4646
| **ShieldFromIdentity** | `fee = metered(storage + processing) + shielded_verification_fee`, paid from the funding identity's balance | Identity balance to pool (protocol version 14). Charged exactly like `Shield`, but on the identity side: the identity signature covers the whole outputs-only bundle, the metered note writes and identity writes go through the standard identity-paid path (`IdentityCreditTransferToAddresses` model), and only the ZK compute fee is added as `additional_fixed_fee_cost`. `user_fee_increase` applies. The identity must hold `amount + fee`; consensus rejects a short balance with `IdentityInsufficientBalanceError`. The pool and the identity are both balance trees, so no system-credit adjustment is emitted. See [Entry-Transition Fees](#entry-transition-fees-shield-shieldfromassetlock-and-shieldfromidentity). |
4747
| **IdentityTopUpFromShieldedPool** | `fee = compute_shielded_identity_top_up_fee(num_actions)` = `compute_minimum_shielded_fee(num_actions) + identity_balance_storage_fee`, carved from `value_balance` | Shielded pool to an EXISTING identity's balance (protocol version 14). `value_balance` (the transition's `topUpAmount`) is the gross amount leaving the pool; the identity receives `topUpAmount - fee` and validation requires `topUpAmount >= fee`. Same flat pool-paid model as `Unshield`, with the identity balance write as a flat component built like `Unshield`'s address write but calibrated to its measured cost: the top-up rewrites the existing identity's balance element and its Merk path (320 replaced bytes, 175,320 credits of processing, no storage), folded into one flat figure with headroom like the other shielded components, so `identity_balance_storage_fee = 8 x per_byte_rate` (`SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES`). The target identity and gross amount are bound into the Orchard sighash; the identity must already exist; no system-credit adjustment. |
4848

49+
### Token shielded pool fees
50+
51+
Token pools (protocol version 14, see [Token Shielded Pools](../data-model/token-shielded-pools.md))
52+
hold tokens, and tokens cannot pay fees, so none of the three token pool transitions carves a
53+
fee from the bundle. They are `TokenTransition` variants inside a `Batch`, and the batch's
54+
signing identity pays in credits through the standard identity-paid path.
55+
56+
| Transition | Fee Formula | Explanation |
57+
|---|---|---|
58+
| **TokenShield** | `fee = metered(storage + processing) + shielded_verification_fee`, paid by the signing identity | Same model as `ShieldFromIdentity`: the note appends, the identity token balance write and the pool balance write are metered, and `compute_shielded_verification_fee(num_actions)` is added as a precalculated operation before the proof is verified. `value_balance` is `-amount` in tokens and carries no fee. |
59+
| **TokenUnshield** | `fee = metered(storage + processing) + shielded_verification_fee`, paid by the signing identity | `value_balance` equals the unshielded token amount exactly; the recipient receives the full amount. Nullifier inserts, note appends and the two balance writes are metered. |
60+
| **TokenShieldedTransfer** | `fee = metered(storage + processing) + shielded_verification_fee`, paid by the signing identity | `value_balance` is exactly zero; consensus rejects any other value. Only the nullifier inserts and note appends are metered. |
61+
62+
Because the verification fee is charged before the proof is checked, an invalid proof is a paid
63+
failure: the identity is charged, its identity contract nonce advances, and no token moves.
64+
4965
For `ShieldedTransfer`, the client constructs the bundle so that `total_spent −
5066
total_output = desired_fee`. The Orchard circuit proves that value is conserved
5167
(inputs = outputs + value_balance), and the binding signature cryptographically

packages/dapi-grpc/protos/platform/v0/platform.proto

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3316,11 +3316,15 @@ message GetRecentCompactedAddressBalanceChangesResponse {
33163316

33173317
// --- Shielded Pool Queries ---
33183318

3319+
// Every shielded pool query below targets the credit shielded pool unless `token_id` is set,
3320+
// in which case it targets that token's own shielded pool (protocol version 14+; the 32-byte
3321+
// token id). Responses have the same shape for both pools.
33193322
message GetShieldedEncryptedNotesRequest {
33203323
message GetShieldedEncryptedNotesRequestV0 {
33213324
uint64 start_index = 1;
33223325
uint32 count = 2;
33233326
bool prove = 3;
3327+
optional bytes token_id = 4; // target a token's shielded pool instead of the credit pool
33243328
}
33253329
oneof version { GetShieldedEncryptedNotesRequestV0 v0 = 1; }
33263330
}
@@ -3348,6 +3352,7 @@ message GetShieldedEncryptedNotesResponse {
33483352
message GetShieldedAnchorsRequest {
33493353
message GetShieldedAnchorsRequestV0 {
33503354
bool prove = 1;
3355+
optional bytes token_id = 2; // target a token's shielded pool instead of the credit pool
33513356
}
33523357
oneof version { GetShieldedAnchorsRequestV0 v0 = 1; }
33533358
}
@@ -3369,6 +3374,7 @@ message GetShieldedAnchorsResponse {
33693374
message GetMostRecentShieldedAnchorRequest {
33703375
message GetMostRecentShieldedAnchorRequestV0 {
33713376
bool prove = 1;
3377+
optional bytes token_id = 2; // target a token's shielded pool instead of the credit pool
33723378
}
33733379
oneof version { GetMostRecentShieldedAnchorRequestV0 v0 = 1; }
33743380
}
@@ -3387,6 +3393,7 @@ message GetMostRecentShieldedAnchorResponse {
33873393
message GetShieldedPoolStateRequest {
33883394
message GetShieldedPoolStateRequestV0 {
33893395
bool prove = 1;
3396+
optional bytes token_id = 2; // target a token's shielded pool instead of the credit pool
33903397
}
33913398
oneof version { GetShieldedPoolStateRequestV0 v0 = 1; }
33923399
}
@@ -3412,6 +3419,7 @@ message GetShieldedPoolStateResponse {
34123419
message GetShieldedNotesCountRequest {
34133420
message GetShieldedNotesCountRequestV0 {
34143421
bool prove = 1;
3422+
optional bytes token_id = 2; // target a token's shielded pool instead of the credit pool
34153423
}
34163424
oneof version { GetShieldedNotesCountRequestV0 v0 = 1; }
34173425
}
@@ -3431,6 +3439,7 @@ message GetShieldedNullifiersRequest {
34313439
message GetShieldedNullifiersRequestV0 {
34323440
repeated bytes nullifiers = 1;
34333441
bool prove = 2;
3442+
optional bytes token_id = 3; // target a token's shielded pool instead of the credit pool
34343443
}
34353444
oneof version { GetShieldedNullifiersRequestV0 v0 = 1; }
34363445
}

packages/rs-dpp/src/balances/total_tokens_balance/mod.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ pub struct TotalTokensBalance {
99
pub total_tokens_in_platform: SumTokenAmount,
1010
/// all the tokens in identity token balances
1111
pub total_identity_token_balances: SumTokenAmount,
12+
/// all the tokens held in token shielded pools (0 before protocol version 14)
13+
pub total_token_shielded_pool_balances: SumTokenAmount,
1214
}
1315

1416
impl fmt::Display for TotalTokensBalance {
@@ -21,9 +23,14 @@ impl fmt::Display for TotalTokensBalance {
2123
)?;
2224
writeln!(
2325
f,
24-
" total_identity_token_balances: {}",
26+
" total_identity_token_balances: {},",
2527
self.total_identity_token_balances
2628
)?;
29+
writeln!(
30+
f,
31+
" total_token_shielded_pool_balances: {}",
32+
self.total_token_shielded_pool_balances
33+
)?;
2734
write!(f, "}}")
2835
}
2936
}
@@ -34,6 +41,7 @@ impl TotalTokensBalance {
3441
let TotalTokensBalance {
3542
total_tokens_in_platform,
3643
total_identity_token_balances,
44+
total_token_shielded_pool_balances,
3745
} = *self;
3846

3947
if total_tokens_in_platform < 0 {
@@ -48,6 +56,32 @@ impl TotalTokensBalance {
4856
));
4957
}
5058

51-
Ok(total_tokens_in_platform == total_identity_token_balances)
59+
if total_token_shielded_pool_balances < 0 {
60+
return Err(ProtocolError::CriticalCorruptedCreditsCodeExecution(
61+
"Tokens in shielded pools are less than 0".to_string(),
62+
));
63+
}
64+
65+
let total_balances = total_identity_token_balances
66+
.checked_add(total_token_shielded_pool_balances)
67+
.ok_or_else(|| {
68+
ProtocolError::CriticalCorruptedCreditsCodeExecution(
69+
"Overflow adding identity and shielded pool token balances".to_string(),
70+
)
71+
})?;
72+
73+
Ok(total_tokens_in_platform == total_balances)
74+
}
75+
76+
/// The balance side of the conservation equation: identity balances plus shielded pool
77+
/// balances. Errors on overflow.
78+
pub fn total_balances(&self) -> Result<SumTokenAmount, ProtocolError> {
79+
self.total_identity_token_balances
80+
.checked_add(self.total_token_shielded_pool_balances)
81+
.ok_or_else(|| {
82+
ProtocolError::CriticalCorruptedCreditsCodeExecution(
83+
"Overflow adding identity and shielded pool token balances".to_string(),
84+
)
85+
})
5286
}
5387
}

0 commit comments

Comments
 (0)