Skip to content
Closed
Show file tree
Hide file tree
Changes from 52 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
35926bc
feat(platform)!: introduce protocol version 15 with drive table v10 f…
DCG-Claude Sep 12, 2026
9ca1e39
feat(drive)!: require fee history for storage refunds and credit thei…
DCG-Claude Sep 12, 2026
8860f1b
test(drive): pin fee history handling of calculate_fee v0 and v1
DCG-Claude Sep 12, 2026
dc166c9
test(drive): cover the recorded-owner refund credit primitive
DCG-Claude Sep 12, 2026
fea0e57
test(drive): close group actions through the production funnel with f…
DCG-Claude Sep 12, 2026
5c07b34
test(drive): pin the protocol 12 schema strip as the recorded refund …
DCG-Claude Sep 12, 2026
b087724
docs(book): describe fee history and refund ownership from protocol v…
DCG-Claude Sep 12, 2026
39cbdb7
test(drive): pass the fee history where tests replace epoch-flagged d…
DCG-Claude Sep 12, 2026
2c8f0fc
test(drive): name the per-owner refund fixtures for clippy
DCG-Claude Sep 12, 2026
6b0eeb7
fix(drive): report refund credits that repay identity debt for the pr…
DCG-Claude Sep 12, 2026
f6448f2
docs(book): say lifecycle refund settlement is not yet wired at proto…
DCG-Claude Sep 12, 2026
bc113d8
docs(book): attribute storage refunds to the recorded owner in the fe…
DCG-Claude Sep 12, 2026
2fc11c1
refactor(drive): read each refund owner once when crediting its balance
DCG-Claude Sep 12, 2026
346d481
test(drive): unban, unsuspend and replace suspensions through the pro…
DCG-Claude Sep 22, 2026
58d6eef
fix(drive): price ephemeral TTL bytes in calculate_fee v1 as v0 does
DCG-Claude Sep 22, 2026
b064de5
test(drive): close the structure fixture's group action with fee hist…
DCG-Claude Sep 22, 2026
1576eb4
fix(drive)!: price a batch before committing the transaction drive owns
DCG-Claude Sep 23, 2026
5fb247a
test(drive): assert a rejected refund pricing leaves state untouched …
DCG-Claude Sep 23, 2026
2b059a3
fix(drive)!: price document and contract writes before committing the…
DCG-Claude Sep 24, 2026
11b781d
test(drive): assert rejected document and contract pricing leaves sta…
DCG-Claude Sep 24, 2026
27628d9
fix(drive)!: price add_document, index-only deletes and warning repla…
DCG-Claude Sep 27, 2026
8b4caed
test(drive): assert rejected add_document, index-only delete and warn…
DCG-Claude Sep 27, 2026
e1850c7
refactor(drive): take the repaid debt of a storage refund from the ba…
DCG-Claude Sep 27, 2026
0c25a08
fix(drive): price the contract fetch when adding a document by contra…
DCG-Claude Sep 27, 2026
f8a73fd
fix(drive)!: price contested document inserts before committing
DCG-Claude Sep 27, 2026
fe715fa
test(drive): assert a rejected second contender leaves a no-locking c…
DCG-Claude Sep 27, 2026
8e5f847
test(drive): give the refund regression fixtures fixed owner ids
DCG-Claude Sep 27, 2026
7a6ecd0
test(drive): pass the fee history in fixtures that remove flagged byt…
DCG-Claude Oct 19, 2026
5590340
fix(drive)!: price a removal of only system bytes without the fee his…
DCG-Claude Oct 19, 2026
7487910
docs(drive): say why the in-place edits to shipped generations are inert
DCG-Claude Oct 19, 2026
29b76e0
refactor(platform): write the protocol 15 drive tables as struct updates
DCG-Claude Oct 20, 2026
b561115
refactor(drive): write refund owners' negative credit through the ver…
DCG-Claude Oct 20, 2026
37b525f
test(drive): strip more than the refund dust limit in the migration f…
DCG-Claude Oct 20, 2026
8fbf2b2
refactor(drive): give price-before-commit its own apply contract gene…
DCG-Claude Oct 28, 2026
f500212
refactor(drive): reach the protocol 15 fee generation only through ca…
DCG-Claude Oct 28, 2026
a6b051d
refactor(drive): keep the protocol 15 fee and batch generations behin…
DCG-Claude Oct 28, 2026
9768b75
docs(drive): record protocol 15 refund fee history and owner credits …
DCG-Claude Oct 28, 2026
0203d33
test(drive): pin that the genesis epoch's empty fee history prices ow…
DCG-Claude Oct 28, 2026
eb6bf5c
fix(drive)!: reject an empty fee history after the genesis epoch
DCG-Claude Oct 28, 2026
b139754
docs(drive): name generation 2 as protocol 15's apply contract genera…
DCG-Claude Oct 28, 2026
826ac2d
test(drive-abci): record a changed fee version where fast-forwarding …
DCG-Claude Oct 30, 2026
b87d6a3
refactor(drive): give price-before-commit its own apply_drive_operati…
DCG-Claude Nov 1, 2026
1934b4f
docs(platform): drop a stale generation from protocol 15's drive comment
DCG-Claude Nov 2, 2026
a481ffd
test(drive): seed debt in the positive-balance refund credit test
DCG-Claude Nov 2, 2026
81731a0
docs(drive): record the apply contract wrapper's double-counted read …
DCG-Claude Nov 2, 2026
af6e224
Merge branch 'v5.1-dev' into dashvm/r12-02
QuantumExplorer Nov 3, 2026
851dffb
fix(drive): price an applied contract's lookup once in the protocol 1…
QuantumExplorer Nov 3, 2026
c2528db
chore: merge v5.1-dev into dashvm/r12-02
DCG-Claude Nov 4, 2026
84bbe73
chore: merge v5.1-dev into dashvm/r12-02
DCG-Claude Nov 4, 2026
5dd3139
chore: merge v5.1-dev into dashvm/r12-02
DCG-Claude Nov 4, 2026
3d896d9
chore: merge v5.1-dev into dashvm/r12-02
DCG-Claude Nov 4, 2026
796c051
chore: merge v5.1-dev into dashvm/r12-02
DCG-Claude Nov 5, 2026
3451a35
chore: merge v5.1-dev into dashvm/r12-02
DCG-Claude Nov 6, 2026
e58c9b1
chore: merge v5.1-dev into dashvm/r12-02
DCG-Claude Nov 6, 2026
0351b3b
chore: merge v5.1-dev into dashvm/r12-02
DCG-Claude Nov 7, 2026
6c62f8a
chore: merge v5.1-dev into dashvm/r12-02 and narrow to recorded-owner…
DCG-Claude Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,23 @@
gains `DataContractCompiledMaxSizeExceededError` and `ProtocolValidationOperation`
gains `ContractCompiledSizeValidation`, so struct literals and exhaustive matches
need them. See [#5579](https://github.com/dashpay/platform/pull/5579).
- **drive (breaking, protocol version 15):** from protocol version 15 a storage refund
for owner-attributed bytes is priced only with the fee history of the block that
removes the bytes, on every fee version number. A missing history is an internal
error instead of a silent fallback to the first generation's storage rates; a
removal of only system bytes needs none. Every shipped fee schedule shares fee
version number 1 and the same storage rates, so refund credits are unchanged for
shipped inputs. Drive's fee-returning entry points that open their own transaction
when the caller passes none (`apply_drive_operations` and the document, contract,
moderation and group writers) now price a batch before committing it, so a pricing
error leaves nothing written. Rust API:
`Drive::credit_storage_refunds_to_owners_operations` credits each recorded owner of
a refund without consulting keys or permissions, clearing an owner's debt first, and
returns a `StorageRefundCreditOutcome` whose `processing_pool_share()` is the
refunds without an owner balance plus the repaid debt, for the caller's processing
pool write; settling refunds on block lifecycle paths comes in a follow-up.
`DriveIdentityUpdateMethodVersions` gains `credit_storage_refunds_to_owners`, so
struct literals need it. See [#4706](https://github.com/dashpay/platform/pull/4706).

### [5.0.3](https://github.com/dashpay/platform/compare/v5.0.2...v5.0.3) (2026-11-03)

Expand Down
46 changes: 44 additions & 2 deletions book/src/fees/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ in `FeeStorageVersion`:
| `storage_seek_cost` | 2,000 | Cost of a single disk seek |

Storage fees are **refundable**: when data is deleted, a portion of the original
storage fee is returned to the identity that paid it (see [Refunds](#refunds)
below).
storage fee becomes a refund for the owner recorded in the stored bytes' storage
flags, which is not always the identity that paid the fee (see
[Refunds](#refunds) below).

The documents of a type that declares a `ttl` (protocol version 14) are the exception: they
carry no storage flags and refund nothing, their bytes are priced for the time they live, and
Expand Down Expand Up @@ -485,6 +486,38 @@ out to proposers.
There is a **dust limit**: refunds below 32 bytes worth of storage credits are
discarded to prevent micro-refund spam.

### Fee history and refund ownership (protocol version 15 onward)

A refund is priced with the fee history of the block that removes the bytes:
the `previous_fee_versions` map platform state carries, which the epoch change
hook extends whenever the fee version number changes. `Drive::calculate_fee`
v1 (`DRIVE_VERSION_V10`) consults that history for every owner-attributed
storage removal, on every fee version number, and returns an internal error
when a caller passes none. Earlier generations priced fee version number 1
against an empty history, so a caller that forgot the history silently
refunded at the first generation's storage rates; from protocol version 15
that omission halts instead of mispricing. Every shipped schedule shares fee
version number 1 and the same storage rates, so the credits themselves are
unchanged for every shipped input.

Refunds follow the recorded owner in the element's storage flags.
`Drive::credit_storage_refunds_to_owners_operations` credits each owner that
has a balance element without consulting any key or permission, so a frozen
but existing owner still receives its bookkeeping refund. Two shares of a
refund never reach a balance and are reported for the caller instead: the
part that clears an owner's negative credit (identity debt, which lives
outside the credit sum trees) and the part whose owner has no balance element
(the native stand-in for a wiped owner). The caller moves both into the
current epoch's processing pool with a single pool write and records every
refund against its storage epoch in the pending epoch refunds, so the credit
conservation check stays balanced. This primitive is the settlement step
block lifecycle paths that remove owner-attributed bytes are meant to use in
the block that removes them; at protocol version 15 the vote poll end cleanup
does not yet price or settle its refunds, and wiring it up is a separate
change. The protocol 12 schema migration, which shrank stored contracts
without refunding the stripped bytes, ran once at that activation and is the
recorded historical exception; it replays exactly as executed.

## Epoch-Based Fee Distribution

Fees do not go directly to the block proposer. Instead, they accumulate in
Expand Down Expand Up @@ -571,6 +604,15 @@ Fee versions are stored in the `FEE_VERSIONS` array and looked up by number. The
`uses_version_fee_multiplier_permille` field allows a global scaling factor
(permille = divide by 1000; a value of 1000 means no change).

`fee_version_number` keys the persisted fee history that refunds are priced
against. A schedule that changes storage rates needs a new number, because the
refund code resolves the schedule for an epoch through the history and (in
generations before protocol version 15) shortcut number 1 to the first
generation's rates. `FEE_VERSION1` and `FEE_VERSION2` share number 1 because
only a non-storage group changed between them; a test in `rs-drive`'s fee
operation module pins that every shipped schedule keeps the first generation's
storage rates.

## Key Source Files

| File | Contents |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ mod pre_programmed_distribution {
);

fast_forward_to_block(&platform, 100, 40, 42, 1, false);
// The fast-forward records the epoch 1 fee history, so read the state again.
let platform_state = platform.state.load();

let claim_transition = BatchTransition::new_token_claim_transition(
token_id,
Expand Down Expand Up @@ -221,6 +223,8 @@ mod pre_programmed_distribution {
);

fast_forward_to_block(&platform, 100, 40, 42, 1, false);
// The fast-forward records the epoch 1 fee history, so read the state again.
let platform_state = platform.state.load();

let claim_transition = BatchTransition::new_token_claim_transition(
token_id,
Expand Down Expand Up @@ -503,6 +507,8 @@ mod pre_programmed_distribution {
);

fast_forward_to_block(&platform, 100, 40, 42, 1, false);
// The fast-forward records the epoch 1 fee history, so read the state again.
let platform_state = platform.state.load();

let claim_transition = BatchTransition::new_token_claim_transition(
token_id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,11 +291,14 @@ mod tests {
use dpp::dashcore::Network;
use dpp::data_contract::document_type::random_document::CreateRandomDocument;
use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters};
use dpp::fee::default_costs::CachedEpochIndexFeeVersions;
use dpp::identifier::Identifier;
use dpp::platform_value::Value;
use dpp::tests::json_document::json_document_to_contract;
use dpp::version::fee::FeeVersion;
use drive::drive::contract::moderation::types::ContractDocumentRemovalEntry;
use drive::util::test_helpers::with_likes_read_whole_through_by_liker;
use std::collections::BTreeMap;

const YAPPR_CONTRACT_PATH: &str =
"../rs-drive/tests/supporting_files/contract/yappr-likes/yappr-likes-contract.json";
Expand Down Expand Up @@ -555,6 +558,9 @@ mod tests {
fn should_report_a_deleted_post_of_a_deletable_document_join() {
let (platform, state, version, contract) =
setup_yappr_state_at(YAPPR_DELETABLE_POSTS_CONTRACT_PATH);
// The post is owner-flagged, so pricing its removal needs the fee history of the
// removing block, as every production caller passes.
let fee_history: CachedEpochIndexFeeVersions = BTreeMap::from([(0, FeeVersion::first())]);
platform
.drive
.delete_document_for_contract(
Expand All @@ -565,7 +571,7 @@ mod tests {
true,
None,
version,
None,
Some(&fee_history),
)
.expect("expected to delete the post");

Expand Down
24 changes: 24 additions & 0 deletions packages/rs-drive-abci/src/test/helpers/fast_forward_to_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,30 @@ pub(crate) fn fast_forward_to_block(
let protocol_version = platform_state.current_protocol_version_in_consensus();
let platform_version = PlatformVersion::get(protocol_version).unwrap();

// On a chain the epoch change hook records the fee version at every epoch change
// after genesis: the first entry at the first one (epoch 1), when the history is
// still empty, and later a fee version whose number differs from the last one
// recorded, at the epoch being entered. Fast-forwarding past genesis skips the
// hook, so record what it would have: from protocol version 15 an empty history
// after the genesis epoch is an error.
if epoch_index > 0 {
let fee_version = &platform_version.fee_version;
let record_at = match platform_state.previous_fee_versions().iter().last() {
None => Some(1),
Some((_, last_fee_version))
if last_fee_version.fee_version_number != fee_version.fee_version_number =>
{
Some(epoch_index)
}
Some(_) => None,
};
if let Some(record_at) = record_at {
platform_state
.previous_fee_versions_mut()
.insert(record_at, fee_version);
}
}

let block_info = BlockInfo {
time_ms, //less than 2 weeks
height,
Expand Down
3 changes: 2 additions & 1 deletion packages/rs-drive/src/cache/system_contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ impl SystemDataContracts {
/// `perform_events_on_first_block_of_protocol_change`). From protocol version 15 a contract
/// stored compiled (format version 2) keeps the meaning it was compiled with, and applying
/// it again writes nothing while its format version 1 bytes are unchanged
/// (`apply_contract_with_serialization` 1): a change that compiles an unchanged system
/// (the operations of `apply_contract_with_serialization` 1, which generation 2, the one
/// protocol version 15 selects, reuses): a change that compiles an unchanged system
/// contract differently rewrites it with its new compiled bytes, as
/// `apply_contract_rebuilding_document_types` does, not by applying it again.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use std::borrow::Cow;

mod v0;
mod v1;
mod v2;

/// Drive contract application methods.
impl Drive {
Expand Down Expand Up @@ -87,9 +88,18 @@ impl Drive {
transaction,
platform_version,
),
2 => self.apply_contract_with_serialization_v2(
contract,
contract_serialization,
block_info,
apply,
storage_flags,
transaction,
platform_version,
),
version => Err(Error::Drive(DriveError::UnknownVersionMismatch {
method: "apply_contract_with_serialization".to_string(),
known_versions: vec![0, 1],
known_versions: vec![0, 1, 2],
received: version,
})),
}
Expand Down Expand Up @@ -152,7 +162,9 @@ impl Drive {
transaction,
platform_version,
),
1 => self.apply_contract_with_serialization_operations_v1(
// Generation 2 changes only when its wrapper commits, so it builds the
// operations as generation 1 does.
1 | 2 => self.apply_contract_with_serialization_operations_v1(
contract,
contract_serialization,
block_info,
Expand All @@ -163,7 +175,7 @@ impl Drive {
),
version => Err(Error::Drive(DriveError::UnknownVersionMismatch {
method: "apply_contract_with_serialization_operations".to_string(),
known_versions: vec![0, 1],
known_versions: vec![0, 1, 2],
received: version,
})),
}
Expand All @@ -173,7 +185,9 @@ impl Drive {
#[cfg(test)]
mod tests {
use crate::drive::contract::paths::{contract_keeping_history_root_path, contract_root_path};
use crate::drive::contract::serialize_contract_for_storage;
use crate::drive::Drive;
use crate::fees::op::LowLevelDriveOperation;
use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure;
use dpp::block::block_info::BlockInfo;
use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters};
Expand Down Expand Up @@ -284,4 +298,75 @@ mod tests {
apply_at(&drive, &contract, 0, PlatformVersion::latest());
assert_eq!(stored_bytes(&drive, &contract)[0], 2);
}

#[test]
fn should_price_the_lookup_of_an_unchanged_contract_once_from_protocol_version_15() {
let platform_version = PlatformVersion::latest();
let drive = setup_drive_with_initial_state_structure(None);
let contract = fixture_contract(platform_version);
apply_at(&drive, &contract, 0, platform_version);
let stored = stored_bytes(&drive, &contract);
let serialization = serialize_contract_for_storage(&contract, platform_version)
.expect("expected to serialize the contract");
let block_info = BlockInfo::default();

// Applying an unchanged contract only looks it up
let lookup = drive
.apply_contract_with_serialization_operations(
&contract,
serialization.clone(),
&block_info,
&mut None,
None,
None,
platform_version,
)
.expect("expected the operations");
assert!(!lookup.is_empty());
assert!(lookup.iter().all(|operation| matches!(
operation,
LowLevelDriveOperation::CalculatedCostOperation(_)
)));
let lookup_fee = Drive::calculate_fee(
None,
Some(lookup),
&block_info.epoch,
drive.config.epochs_per_era,
platform_version,
None,
)
.expect("expected to price the lookup");
assert!(lookup_fee.processing_fee > 0);

let fee = drive
.apply_contract_with_serialization(
&contract,
serialization.clone(),
block_info,
true,
None,
None,
platform_version,
)
.expect("expected to apply the contract");
assert_eq!(fee, lookup_fee);
assert_eq!(stored_bytes(&drive, &contract), stored);

// Generation 1 pushed the lookup's cost again after applying the batch
let generation_1_fee = drive
.apply_contract_with_serialization_v1(
&contract,
serialization,
block_info,
true,
None,
None,
platform_version,
)
.expect("expected to apply the contract");
assert_eq!(
generation_1_fee.processing_fee,
2 * lookup_fee.processing_fee
);
}
}
Loading
Loading