diff --git a/applications/tari_indexer/web_ui/src/routes/Substates/Substates.tsx b/applications/tari_indexer/web_ui/src/routes/Substates/Substates.tsx index 42fa881bf3..d4e825a2f2 100644 --- a/applications/tari_indexer/web_ui/src/routes/Substates/Substates.tsx +++ b/applications/tari_indexer/web_ui/src/routes/Substates/Substates.tsx @@ -242,12 +242,6 @@ function TransactionReceiptView({ data }: { data: any }) { {renderJson(data.events)} )} - {data.logs?.length > 0 && ( - <> - Logs ({data.logs.length}) - {renderJson(data.logs)} - - )} Diff Summary {renderJson(data.diff_summary)} diff --git a/applications/tari_ootle_app_utilities/src/fee_tables.rs b/applications/tari_ootle_app_utilities/src/fee_tables.rs index 1d585c9db8..699936c71c 100644 --- a/applications/tari_ootle_app_utilities/src/fee_tables.rs +++ b/applications/tari_ootle_app_utilities/src/fee_tables.rs @@ -168,3 +168,31 @@ pub const fn get_fee_table_by_network(network: Network) -> &'static FeeTable { Network::MainNet => &MAINNET_FEE_TABLE, } } + +#[cfg(test)] +mod tests { + use tari_engine_types::fees::{FEE_ESTIMATE_ALLOWANCE, MAX_EXHAUST_BURN_RATE_BPS}; + + use super::*; + + /// `FEE_ESTIMATE_ALLOWANCE` is restated in `tari_engine_types`, which cannot see a `FeeTable`. + /// Every shipped table must come in under it at the highest burn the estimate is derived + /// against, or a dry run under-states what a real submission costs. + #[test] + fn fee_estimate_allowance_covers_every_shipped_network() { + for network in [ + Network::MainNet, + Network::StageNet, + Network::NextNet, + Network::Igor, + Network::Esmeralda, + Network::LocalNet, + ] { + let derived = get_fee_table_by_network(network).fee_estimate_allowance(MAX_EXHAUST_BURN_RATE_BPS); + assert!( + derived <= FEE_ESTIMATE_ALLOWANCE, + "{network} needs an allowance of {derived}, above the restated {FEE_ESTIMATE_ALLOWANCE}" + ); + } + } +} diff --git a/applications/tari_wallet_cli/src/command/account.rs b/applications/tari_wallet_cli/src/command/account.rs index a34186ba22..2ae975a728 100644 --- a/applications/tari_wallet_cli/src/command/account.rs +++ b/applications/tari_wallet_cli/src/command/account.rs @@ -187,7 +187,9 @@ async fn handle_create_free_test_coins( let resp = client .create_free_test_coins(AccountsCreateFreeTestCoinsRequest { account: account.component_address.into(), - max_fee: args.fee.unwrap_or(1500), + // The claim creates the account and funds it, so it pays for a component, a vault, a + // badge and the receipt. Defaulted well clear of that; the unspent remainder is refunded. + max_fee: args.fee.unwrap_or(50_000), }) .await?; diff --git a/applications/tari_wallet_cli/src/command/validator.rs b/applications/tari_wallet_cli/src/command/validator.rs index c9b6c88db9..bfed18387b 100644 --- a/applications/tari_wallet_cli/src/command/validator.rs +++ b/applications/tari_wallet_cli/src/command/validator.rs @@ -99,7 +99,9 @@ pub async fn handle_claim_validator_fees( .map(|name| ComponentAddressOrName::from_str(&name)) .transpose()?, claim_key_index: None, - max_fee: max_fee.map(Into::into).unwrap_or(1500), + // A claim writes the fee pool, the destination vault and the receipt. Defaulted well + // clear of that; the unspent remainder is refunded. + max_fee: max_fee.map(Into::into).unwrap_or(50_000), shards: vec![shard], dry_run, output_to_revealed: revealed, diff --git a/bindings/package.json b/bindings/package.json index 2bfd9ab31a..c790bd6820 100644 --- a/bindings/package.json +++ b/bindings/package.json @@ -1,6 +1,6 @@ { "name": "@tari-project/ootle-ts-bindings", - "version": "1.49.0", + "version": "1.49.1", "private": false, "publishConfig": { "access": "public" diff --git a/bindings/src/index.ts b/bindings/src/index.ts index 924e0906c4..d634161883 100644 --- a/bindings/src/index.ts +++ b/bindings/src/index.ts @@ -12,39 +12,40 @@ export * from "./types/ArgDef"; export * from "./types/Assertion"; export * from "./types/AtomicCondition"; export * from "./types/AuthHook"; -export * from "./types/Blob"; export * from "./types/BlobHashes"; export * from "./types/Blobs"; -export * from "./types/Block"; +export * from "./types/Blob"; export * from "./types/BlockHeader"; export * from "./types/BlockId"; +export * from "./types/Block"; export * from "./types/BucketId"; export * from "./types/BuiltinPredicate"; export * from "./types/Bytes"; export * from "./types/CheckOrd"; export * from "./types/ClaimBurnOutputData"; -export * from "./types/ClaimedOutputTombstone"; export * from "./types/ClaimedOutputTombstoneAddress"; +export * from "./types/ClaimedOutputTombstone"; export * from "./types/Claims"; export * from "./types/Command"; export * from "./types/CommitmentSignatureBytes"; -export * from "./types/Committee"; +export * from "./types/CommitmentValueProof"; export * from "./types/CommitteeInfo"; export * from "./types/CommitteeMember"; -export * from "./types/Component"; +export * from "./types/Committee"; export * from "./types/ComponentAccessRules"; export * from "./types/ComponentAddress"; export * from "./types/ComponentBody"; export * from "./types/ComponentHeader"; export * from "./types/ComponentKey"; export * from "./types/ComponentReference"; -export * from "./types/ConfidentialOutput"; -export * from "./types/ConfidentialOutputAddress"; +export * from "./types/Component"; export * from "./types/ConfidentialOutputAddressContents"; +export * from "./types/ConfidentialOutputAddress"; export * from "./types/ConfidentialOutputStatement"; +export * from "./types/ConfidentialOutput"; export * from "./types/ConfidentialWithdrawProof"; -export * from "./types/Covenant"; export * from "./types/CovenantBalanceClaim"; +export * from "./types/Covenant"; export * from "./types/Crud"; export * from "./types/CurrentInputView"; export * from "./types/Decision"; @@ -58,8 +59,8 @@ export * from "./types/Epoch"; export * from "./types/Era"; export * from "./types/Event"; export * from "./types/EvictNodeAtom"; -export * from "./types/Evidence"; export * from "./types/EvidenceInputLockData"; +export * from "./types/Evidence"; export * from "./types/ExecuteResult"; export * from "./types/ExtraData"; export * from "./types/FeeBreakdown"; @@ -76,12 +77,12 @@ export * from "./types/Hash64"; export * from "./types/HashAlg"; export * from "./types/IndexedValue"; export * from "./types/IndexedWellKnownTypes"; -export * from "./types/Instruction"; export * from "./types/InstructionArg"; export * from "./types/InstructionResult"; +export * from "./types/Instruction"; export * from "./types/LeaderFee"; -export * from "./types/LockFlag"; export * from "./types/LockedEpoch"; +export * from "./types/LockFlag"; export * from "./types/LogEntry"; export * from "./types/LogLevel"; export * from "./types/Memo"; @@ -92,12 +93,12 @@ export * from "./types/MinotariBurnClaimProof"; export * from "./types/Network"; export * from "./types/NftCheck"; export * from "./types/NodeHeight"; -export * from "./types/NonFungible"; -export * from "./types/NonFungibleAddress"; export * from "./types/NonFungibleAddressContents"; +export * from "./types/NonFungibleAddress"; export * from "./types/NonFungibleContainer"; export * from "./types/NonFungibleId"; export * from "./types/NonFungibleToken"; +export * from "./types/NonFungible"; export * from "./types/NumPreshards"; export * from "./types/OotleAddress"; export * from "./types/Ordering"; @@ -107,8 +108,8 @@ export * from "./types/OwnerRule"; export * from "./types/PayRef"; export * from "./types/PedersenCommitmentBytes"; export * from "./types/PeerAddress"; -export * from "./types/Permission"; export * from "./types/Permissions"; +export * from "./types/Permission"; export * from "./types/PrecisionAmount"; export * from "./types/ProofId"; export * from "./types/ProposalCertificate"; @@ -116,42 +117,40 @@ export * from "./types/PrunedTransaction"; export * from "./types/PrunedTransactionV1"; export * from "./types/PrunedUnsealedTransactionV1"; export * from "./types/PrunedUnsignedTransactionV1"; -export * from "./types/PublishedTemplate"; export * from "./types/PublishedTemplateAddress"; export * from "./types/PublishedTemplateMetadata"; +export * from "./types/PublishedTemplate"; export * from "./types/RangeProofBytes"; export * from "./types/ReadOnly"; export * from "./types/RejectReason"; export * from "./types/RequireRule"; -export * from "./types/Resource"; export * from "./types/ResourceAccessRules"; -export * from "./types/ResourceAddress"; export * from "./types/ResourceAddressRef"; +export * from "./types/ResourceAddress"; export * from "./types/ResourceContainer"; +export * from "./types/Resource"; export * from "./types/ResourceType"; export * from "./types/RestrictedAccessRule"; export * from "./types/RistrettoPublicKeyBytes"; export * from "./types/RuleRequirement"; export * from "./types/Scalar32Bytes"; export * from "./types/SchnorrSignatureBytes"; -export * from "./types/Shard"; -export * from "./types/ShardGroup"; export * from "./types/ShardGroupAccumulatedData"; export * from "./types/ShardGroupEvidence"; +export * from "./types/ShardGroup"; export * from "./types/ShardStateVersions"; +export * from "./types/Shard"; export * from "./types/SpendAuthorization"; export * from "./types/SpendCondition"; export * from "./types/SpendWitness"; export * from "./types/StateVersion"; +export * from "./types/StealthInputsStatement"; export * from "./types/StealthInput"; export * from "./types/StealthInputView"; -export * from "./types/StealthInputsStatement"; -export * from "./types/StealthOutputView"; export * from "./types/StealthOutputsStatement"; +export * from "./types/StealthOutputView"; export * from "./types/StealthTransferStatement"; export * from "./types/StealthUnspentOutput"; -export * from "./types/CommitmentValueProof"; -export * from "./types/Substate"; export * from "./types/SubstateAddress"; export * from "./types/SubstateCreated"; export * from "./types/SubstateDestroyed"; @@ -161,6 +160,7 @@ export * from "./types/SubstateLockType"; export * from "./types/SubstateOwnerRule"; export * from "./types/SubstateRecord"; export * from "./types/SubstateRequirement"; +export * from "./types/Substate"; export * from "./types/SubstateType"; export * from "./types/SubstateValue"; export * from "./types/TemplateDef"; @@ -168,19 +168,19 @@ export * from "./types/TemplateDefV1"; export * from "./types/TemplateFunction"; export * from "./types/TemplateMetadata"; export * from "./types/TimeoutCertificate"; -export * from "./types/Transaction"; export * from "./types/TransactionAtom"; export * from "./types/TransactionEnvelope"; export * from "./types/TransactionExecution"; export * from "./types/TransactionId"; export * from "./types/TransactionPoolRecord"; export * from "./types/TransactionPoolStage"; -export * from "./types/TransactionReceipt"; export * from "./types/TransactionReceiptAddress"; +export * from "./types/TransactionReceipt"; export * from "./types/TransactionResult"; export * from "./types/TransactionSealSignature"; export * from "./types/TransactionSignature"; export * from "./types/TransactionStatus"; +export * from "./types/Transaction"; export * from "./types/TransactionV1"; export * from "./types/TxRequestAction"; export * from "./types/Type"; @@ -189,11 +189,10 @@ export * from "./types/UnsealedTransactionV1"; export * from "./types/UnsignedTransaction"; export * from "./types/UnsignedTransactionV1"; export * from "./types/UnspentOutput"; -export * from "./types/UpSubstate"; export * from "./types/UpdateRule"; -export * from "./types/Utxo"; -export * from "./types/UtxoAddress"; +export * from "./types/UpSubstate"; export * from "./types/UtxoAddressContents"; +export * from "./types/UtxoAddress"; export * from "./types/UtxoBurnt"; export * from "./types/UtxoId"; export * from "./types/UtxoInputSelection"; @@ -201,18 +200,19 @@ export * from "./types/UtxoOutput"; export * from "./types/UtxoSpent"; export * from "./types/UtxoStateUpdateSet"; export * from "./types/UtxoTag"; +export * from "./types/Utxo"; export * from "./types/UtxoUnspent"; export * from "./types/UtxoUpdateSet"; -export * from "./types/ValidatorFeePool"; export * from "./types/ValidatorFeePoolAddress"; +export * from "./types/ValidatorFeePool"; export * from "./types/ValidatorFeeWithdrawal"; export * from "./types/ValidatorSignatureBytes"; export * from "./types/ValueKnowledgeProof"; -export * from "./types/Vault"; export * from "./types/VaultFreezeFlags"; export * from "./types/VaultId"; -export * from "./types/VersionedSubstateId"; +export * from "./types/Vault"; export * from "./types/VersionedSubstateIdLockIntent"; +export * from "./types/VersionedSubstateId"; export * from "./types/ViewableBalanceProof"; export * from "./types/VotePower"; export * from "./types/WalletTransaction"; @@ -223,10 +223,10 @@ export * from "./tari-indexer-client"; export * from "./validator-node-client"; export * from "./wallet-types"; export * from "./helpers/BigAmount"; -export * from "./helpers/NetworkByte"; export * from "./helpers/cbor"; export * from "./helpers/consts"; export * from "./helpers/enum"; export * from "./helpers/helpers"; +export * from "./helpers/NetworkByte"; export * from "./helpers/ootleAddress"; export * from "./helpers/tariTypeTag"; diff --git a/bindings/src/tari-indexer-client.ts b/bindings/src/tari-indexer-client.ts index 74696b6255..ac87b9f223 100644 --- a/bindings/src/tari-indexer-client.ts +++ b/bindings/src/tari-indexer-client.ts @@ -1,68 +1,68 @@ // Copyright 2026 The Tari Project // SPDX-License-Identifier: BSD-3-Clause -export * from "./types/tari-indexer-client/NetworkDescription"; -export * from "./types/tari-indexer-client/TemplateCatalogueItem"; -export * from "./types/tari-indexer-client/IndexerGetEpochManagerStatsResponse"; -export * from "./types/tari-indexer-client/GetLatestEpochCheckpointResponse"; -export * from "./types/tari-indexer-client/ListTransactionReceiptsResponse"; -export * from "./types/tari-indexer-client/NonFungibleSubstate"; -export * from "./types/tari-indexer-client/QueryTransactionEventsResponse"; -export * from "./types/tari-indexer-client/IndexerGetIdentityResponse"; -export * from "./types/tari-indexer-client/TemplateMeta"; -export * from "./types/tari-indexer-client/ListTemplateCatalogueResponse"; -export * from "./types/tari-indexer-client/IndexerGetSubstateRequest"; +export * from "./types/tari-indexer-client/IndexerSubmitTransactionResponse"; +export * from "./types/tari-indexer-client/GetTransactionReceiptResponse"; export * from "./types/tari-indexer-client/GetNetworkInfoResponse"; -export * from "./types/tari-indexer-client/GetTemplateDefinitionResponse"; -export * from "./types/tari-indexer-client/ListTemplatesRequest"; -export * from "./types/tari-indexer-client/GetNonFungiblesRequest"; +export * from "./types/tari-indexer-client/WatchedSubstateItem"; +export * from "./types/tari-indexer-client/GetUtxoUpdatesRequest"; +export * from "./types/tari-indexer-client/WatchedTemplateItem"; +export * from "./types/tari-indexer-client/ValidatorConsensusState"; +export * from "./types/tari-indexer-client/StreamTransactionEventsRequest"; +export * from "./types/tari-indexer-client/QueryTransactionEventsRequest"; +export * from "./types/tari-indexer-client/TransactionEntry"; +export * from "./types/tari-indexer-client/InspectSubstateRequest"; +export * from "./types/tari-indexer-client/ListSubstateItem"; +export * from "./types/tari-indexer-client/IndexerTransactionFinalizedResult"; +export * from "./types/tari-indexer-client/NetworkDescription"; +export * from "./types/tari-indexer-client/GetUtxoUpdatesResponse"; +export * from "./types/tari-indexer-client/TransactionResultSummary"; export * from "./types/tari-indexer-client/IndexerGetSubstateResponse"; +export * from "./types/tari-indexer-client/IndexerGetCommsStatsResponse"; export * from "./types/tari-indexer-client/ListWatchedTemplatesResponse"; -export * from "./types/tari-indexer-client/ListUtxosRequest"; -export * from "./types/tari-indexer-client/GetSubstatesRequest"; -export * from "./types/tari-indexer-client/GetUtxoUpdatesResponse"; -export * from "./types/tari-indexer-client/GetNetworkSyncStateResponse"; -export * from "./types/tari-indexer-client/IndexerSubmitTransactionRequest"; -export * from "./types/tari-indexer-client/ListWatchedSubstatesResponse"; -export * from "./types/tari-indexer-client/IndexerConnection"; export * from "./types/tari-indexer-client/ListValidatorsResponse"; -export * from "./types/tari-indexer-client/InspectSubstateResponse"; -export * from "./types/tari-indexer-client/InspectSubstateRequest"; -export * from "./types/tari-indexer-client/ValidatorConsensusState"; -export * from "./types/tari-indexer-client/GetUtxoUpdatesRequest"; +export * from "./types/tari-indexer-client/IndexerConnection"; +export * from "./types/tari-indexer-client/GetNonFungiblesRequest"; export * from "./types/tari-indexer-client/ListValidatorsRequest"; -export * from "./types/tari-indexer-client/IndexerGetCommsStatsResponse"; -export * from "./types/tari-indexer-client/ListUtxosResponse"; -export * from "./types/tari-indexer-client/IndexerGetConnectionsResponse"; -export * from "./types/tari-indexer-client/GetNonFungiblesResponse"; -export * from "./types/tari-indexer-client/ValidatorInfo"; -export * from "./types/tari-indexer-client/ValidatorStatus"; -export * from "./types/tari-indexer-client/SyncProgress"; -export * from "./types/tari-indexer-client/ListRecentTransactionsRequest"; -export * from "./types/tari-indexer-client/GetTransactionReceiptResponse"; -export * from "./types/tari-indexer-client/GetUtxosRequest"; export * from "./types/tari-indexer-client/GetResourceResponse"; -export * from "./types/tari-indexer-client/GetNetworkEconomicsResponse"; export * from "./types/tari-indexer-client/ListEpochCheckpointsResponse"; -export * from "./types/tari-indexer-client/IndexerGetTransactionResultResponse"; -export * from "./types/tari-indexer-client/TransactionEntry"; -export * from "./types/tari-indexer-client/ListTemplateCatalogueRequest"; +export * from "./types/tari-indexer-client/ListTransactionReceiptsRequest"; +export * from "./types/tari-indexer-client/GetUtxosRequest"; +export * from "./types/tari-indexer-client/ListRecentTransactionsResponse"; +export * from "./types/tari-indexer-client/ListWatchedSubstatesRequest"; +export * from "./types/tari-indexer-client/ValidatorInfo"; +export * from "./types/tari-indexer-client/GetLatestEpochCheckpointResponse"; +export * from "./types/tari-indexer-client/GetNetworkSyncStateResponse"; export * from "./types/tari-indexer-client/GetUtxosResponse"; -export * from "./types/tari-indexer-client/ListTemplatesResponse"; -export * from "./types/tari-indexer-client/TransactionResultSummary"; +export * from "./types/tari-indexer-client/ListUtxosResponse"; +export * from "./types/tari-indexer-client/GetNonFungiblesResponse"; +export * from "./types/tari-indexer-client/GetTemplateDefinitionResponse"; +export * from "./types/tari-indexer-client/ListTransactionReceiptsResponse"; +export * from "./types/tari-indexer-client/IndexerGetConnectionsResponse"; export * from "./types/tari-indexer-client/IndexerGetTransactionResponse"; +export * from "./types/tari-indexer-client/ListTemplateCatalogueRequest"; export * from "./types/tari-indexer-client/GetSubstatesResponse"; -export * from "./types/tari-indexer-client/IndexerGetTransactionResultRequest"; -export * from "./types/tari-indexer-client/WatchedSubstateItem"; -export * from "./types/tari-indexer-client/ListRecentTransactionsResponse"; -export * from "./types/tari-indexer-client/WatchedTemplateItem"; -export * from "./types/tari-indexer-client/QueryTransactionEventsRequest"; -export * from "./types/tari-indexer-client/IndexerTransactionFinalizedResult"; -export * from "./types/tari-indexer-client/IndexerReadyResponse"; -export * from "./types/tari-indexer-client/IndexerSubmitTransactionResponse"; -export * from "./types/tari-indexer-client/ListTransactionReceiptsRequest"; +export * from "./types/tari-indexer-client/TemplateCatalogueItem"; +export * from "./types/tari-indexer-client/ValidatorStatus"; +export * from "./types/tari-indexer-client/ListWatchedSubstatesResponse"; +export * from "./types/tari-indexer-client/IndexerSubmitTransactionRequest"; +export * from "./types/tari-indexer-client/ListTemplatesResponse"; +export * from "./types/tari-indexer-client/TemplateMeta"; +export * from "./types/tari-indexer-client/InspectSubstateResponse"; +export * from "./types/tari-indexer-client/ListRecentTransactionsRequest"; +export * from "./types/tari-indexer-client/GetSubstatesRequest"; +export * from "./types/tari-indexer-client/IndexerGetSubstateRequest"; +export * from "./types/tari-indexer-client/IndexerGetEpochManagerStatsResponse"; +export * from "./types/tari-indexer-client/NonFungibleSubstate"; +export * from "./types/tari-indexer-client/ListTemplateCatalogueResponse"; +export * from "./types/tari-indexer-client/ListTemplatesRequest"; +export * from "./types/tari-indexer-client/IndexerGetTransactionResultResponse"; export * from "./types/tari-indexer-client/IndexerConnectionDirection"; -export * from "./types/tari-indexer-client/ListSubstateItem"; -export * from "./types/tari-indexer-client/StreamTransactionEventsRequest"; -export * from "./types/tari-indexer-client/ListWatchedSubstatesRequest"; +export * from "./types/tari-indexer-client/IndexerGetIdentityResponse"; export * from "./types/tari-indexer-client/ListEpochCheckpointsRequest"; +export * from "./types/tari-indexer-client/ListUtxosRequest"; +export * from "./types/tari-indexer-client/GetNetworkEconomicsResponse"; +export * from "./types/tari-indexer-client/SyncProgress"; +export * from "./types/tari-indexer-client/IndexerGetTransactionResultRequest"; +export * from "./types/tari-indexer-client/QueryTransactionEventsResponse"; +export * from "./types/tari-indexer-client/IndexerReadyResponse"; diff --git a/bindings/src/types/AbortReason.ts b/bindings/src/types/AbortReason.ts index 96cdf1ee76..068152079e 100644 --- a/bindings/src/types/AbortReason.ts +++ b/bindings/src/types/AbortReason.ts @@ -9,4 +9,5 @@ export type AbortReason = | "OneOrMoreInputsNotFound" | "InsufficientFeesPaid" | "FeePaymentInMainIntent" - | "EpochExpired"; + | "EpochExpired" + | "ValidityWindowTooLong"; diff --git a/bindings/src/types/Amount.ts b/bindings/src/types/Amount.ts index 86e36c8431..4a1abffb18 100644 --- a/bindings/src/types/Amount.ts +++ b/bindings/src/types/Amount.ts @@ -1,9 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * A 128-bit signed amount. + * A 128-bit unsigned amount. * - * This is a general purpose signed integer, but is primarily used to represent the smallest unit of value in + * This is a general purpose unsigned integer, but is primarily used to represent the smallest unit of value in * resources/vaults etc. * * This allows Tari to support a massive number tokens within resources. diff --git a/bindings/src/types/CommitmentValueProof.ts b/bindings/src/types/CommitmentValueProof.ts index 4b3532c17c..bdcac14c05 100644 --- a/bindings/src/types/CommitmentValueProof.ts +++ b/bindings/src/types/CommitmentValueProof.ts @@ -4,7 +4,9 @@ import type { ValueKnowledgeProof } from "./ValueKnowledgeProof"; /** * Proof of knowledge of the opening to a commitment and that the commitment commits to a specific value. - * Currently used when burning UTXOs to allow the total supply to be adjusted. + * + * Used wherever a resource's total supply must account for a value that is otherwise hidden in a commitment: + * burning a stealth UTXO, and minting a confidential commitment. */ export type CommitmentValueProof = { /** diff --git a/bindings/src/types/ConfidentialOutput.ts b/bindings/src/types/ConfidentialOutput.ts index d5be8927fb..57700c99c5 100644 --- a/bindings/src/types/ConfidentialOutput.ts +++ b/bindings/src/types/ConfidentialOutput.ts @@ -5,7 +5,7 @@ import type { OutputBody } from "./OutputBody"; * A confidential output stored as its own substate, referenced by a vault's commitment list. * * Spend authorization is entirely controlled by the owning vault's access rules (unlike a stealth - * `Utxo`, which carries its own spend authorization), so no per-output spend key is held here. + * [`crate::utxo::Utxo`], which carries its own spend authorization), so no per-output spend key is held here. * Spending or burning downs this substate; the only in-place mutation is freezing. */ export type ConfidentialOutput = { output: OutputBody; is_frozen: boolean }; diff --git a/bindings/src/types/ExecuteResult.ts b/bindings/src/types/ExecuteResult.ts index 0621fdfeab..02d3146a36 100644 --- a/bindings/src/types/ExecuteResult.ts +++ b/bindings/src/types/ExecuteResult.ts @@ -19,8 +19,17 @@ export type ExecuteResult = { /** * Total WASM metering points consumed by the transaction across all calls, including failed/aborted execution. * Metering is deterministic, so every validator computes the identical value for the same transaction and - * pledged state. Used to enforce the per-block WASM points budget. Defaults to 0 when decoding executions - * persisted before this field existed. + * pledged state. Defaults to 0 when decoding executions persisted before this field existed. */ wasm_execution_points: bigint; + /** + * Total native-verification points (stealth transfers, confidential withdraws, burn claims) charged to the + * transaction, priced in WASM-point equivalents by [`NativeExecutionPoints`]. Deterministic for the same + * reason as [`Self::wasm_execution_points`] — the price is a pure function of the declared statement — so + * every validator computes the identical value. Defaults to 0 when decoding executions persisted before this + * field existed. + * + * [`NativeExecutionPoints`]: crate::limits::NativeExecutionPoints + */ + native_execution_points: bigint; }; diff --git a/bindings/src/types/FinalizeResult.ts b/bindings/src/types/FinalizeResult.ts index 2683060cf5..a8f592040f 100644 --- a/bindings/src/types/FinalizeResult.ts +++ b/bindings/src/types/FinalizeResult.ts @@ -13,4 +13,15 @@ export type FinalizeResult = { execution_results: Array; result: TransactionResult; fee_receipt: FeeReceipt; + /** + * What committing the whole transaction was priced at, which is what the commit-or-reject + * decision was made against. + * + * Equal to `fee_receipt.total_fees_charged()` on a full commit. When only the fee intent + * commits, the charges are re-derived over the fee checkpoint alone and so fall below what was + * paid; this keeps the figure the main intent was rejected for — the one a resubmission has to + * clear. Execution metadata rather than settled state, so it stays out of the persisted + * receipt, whose every field is priced into the storage charge of each transaction. + */ + total_fees_required: bigint; }; diff --git a/bindings/src/types/IndexedWellKnownTypes.ts b/bindings/src/types/IndexedWellKnownTypes.ts index 83cb939181..f0b773a174 100644 --- a/bindings/src/types/IndexedWellKnownTypes.ts +++ b/bindings/src/types/IndexedWellKnownTypes.ts @@ -2,6 +2,7 @@ import type { BucketId } from "./BucketId"; import type { ClaimedOutputTombstoneAddress } from "./ClaimedOutputTombstoneAddress"; import type { ComponentAddress } from "./ComponentAddress"; +import type { ConfidentialOutputAddress } from "./ConfidentialOutputAddress"; import type { Metadata } from "./Metadata"; import type { NonFungibleAddress } from "./NonFungibleAddress"; import type { ProofId } from "./ProofId"; @@ -27,4 +28,5 @@ export type IndexedWellKnownTypes = { utxos: Array; component_address_allocations: number[]; resource_address_allocations: number[]; + confidential_outputs: Array; }; diff --git a/bindings/src/types/LeaderFee.ts b/bindings/src/types/LeaderFee.ts index b8969f3925..990331c6bc 100644 --- a/bindings/src/types/LeaderFee.ts +++ b/bindings/src/types/LeaderFee.ts @@ -1,3 +1,19 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type LeaderFee = { fee: bigint; exhaust_burn: bigint }; +export type LeaderFee = { + /** + * The fee payable to the leader of each involved shard group. + */ + fee: bigint; + /** + * The amount burned for the whole transaction across all involved shard groups: the exhaust burn collected by + * the executor plus the indivisible remainder from dividing the transaction fee between the leaders + * (`fee * num_involved_shard_groups + exhaust_burn == transaction_fee + executor_exhaust_burn`). + * + * CONSENSUS RULE: this must equal the amount actually withheld from validators, exactly — the accumulated burn + * in block headers determines the total supply, so it must not be re-derived with lossy arithmetic. Each shard + * group accumulates only its portion of this into its block header burn total — see + * `Evidence::exhaust_burn_portion`. + */ + exhaust_burn: bigint; +}; diff --git a/bindings/src/types/ResourceContainer.ts b/bindings/src/types/ResourceContainer.ts index fea5a1593d..1cf10cf370 100644 --- a/bindings/src/types/ResourceContainer.ts +++ b/bindings/src/types/ResourceContainer.ts @@ -1,7 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Amount } from "./Amount"; import type { NonFungibleId } from "./NonFungibleId"; -import type { OutputBody } from "./OutputBody"; import type { PedersenCommitmentBytes } from "./PedersenCommitmentBytes"; import type { ResourceAddress } from "./ResourceAddress"; @@ -20,9 +19,9 @@ export type ResourceContainer = | { Confidential: { address: ResourceAddress; - commitments: { [key in PedersenCommitmentBytes]?: OutputBody }; + commitments: Array; revealed_amount: Amount; - locked_commitments: { [key in PedersenCommitmentBytes]?: OutputBody }; + locked_commitments: Array; locked_revealed_amount: Amount; }; } diff --git a/bindings/src/types/TransactionPoolRecord.ts b/bindings/src/types/TransactionPoolRecord.ts index 611e1c85b5..ec5adcb384 100644 --- a/bindings/src/types/TransactionPoolRecord.ts +++ b/bindings/src/types/TransactionPoolRecord.ts @@ -37,7 +37,7 @@ export type TransactionPoolRecord = { */ transaction_weight: bigint; /** - * The exhaust burn surcharge collected by the executor (`FeeReceipt::exhaust_burn_charged`), set from local + * The exhaust burn collected by the executor (`FeeReceipt::exhaust_burn_charged`), set from local * execution alongside `transaction_fee`. */ exhaust_burn: bigint; diff --git a/bindings/src/types/TransactionReceipt.ts b/bindings/src/types/TransactionReceipt.ts index d92e7d3f37..bce3d7980f 100644 --- a/bindings/src/types/TransactionReceipt.ts +++ b/bindings/src/types/TransactionReceipt.ts @@ -14,5 +14,17 @@ export type TransactionReceipt = { events: Array; fee_receipt: FeeReceipt; epoch: Epoch; + /** + * Commitment to the transaction's intent: every field the signers authorized (network, fee + * instructions, instructions, inputs, epoch bounds, flags and blob commitments), excluding the + * signatures themselves. + * + * The receipt is already bound to the transaction transitively — it is addressed by the + * transaction id — but reproducing that id requires the signatures and the seal signature, so + * establishing the link that way reveals the signers. This commitment is over the same + * projection minus the signatures, so whoever holds the transaction can link it to this + * receipt without revealing who authorized it. It identifies the intent, not the signers: + * transactions differing only in who signed them share a commitment. + */ intent_commitment: Hash32; }; diff --git a/bindings/src/types/UnsignedTransactionV1.ts b/bindings/src/types/UnsignedTransactionV1.ts index c14553c11d..c9e68bc3f2 100644 --- a/bindings/src/types/UnsignedTransactionV1.ts +++ b/bindings/src/types/UnsignedTransactionV1.ts @@ -13,6 +13,12 @@ export type UnsignedTransactionV1 = { */ inputs: Array; min_epoch: Epoch | null; + /** + * The last epoch in which this transaction may be sequenced. Mandatory: every transaction has a + * bounded lifetime, capped at `ConsensusConstants::max_transaction_validity_epochs` past the + * current epoch, so a transaction's death is deterministic and an aborted attempt cannot be + * retried indefinitely. + */ max_epoch: Epoch; is_seal_signer_authorized: boolean; dry_run: boolean; diff --git a/bindings/src/types/tari-indexer-client/GetNetworkEconomicsResponse.ts b/bindings/src/types/tari-indexer-client/GetNetworkEconomicsResponse.ts index 3befc72b85..07f48caa93 100644 --- a/bindings/src/types/tari-indexer-client/GetNetworkEconomicsResponse.ts +++ b/bindings/src/types/tari-indexer-client/GetNetworkEconomicsResponse.ts @@ -4,11 +4,36 @@ import type { Epoch } from "../Epoch"; export type GetNetworkEconomicsResponse = { current_epoch: Epoch; + /** + * Total XTR claimed (peg-in). + */ total_claimed: Amount; + /** + * Total exhaust burned, sourced from checkpoint headers (consensus-backed, complete since genesis). + * Kept as a cross-check against `receipt_exhaust_burned`. + */ total_exhaust_burned: Amount; + /** + * Total pre-burn execution fees `F`, summed from transaction receipts. + */ fee_volume: Amount; + /** + * Total exhaust burned summed from the same receipts as `fee_volume`; `receipt_exhaust_burned / + * fee_volume` is the exact realized burn rate, and this is the burn netted from `total_supply`. May + * transiently trail `total_exhaust_burned` while the receipt sync frontier catches up to the checkpoint + * frontier. + */ receipt_exhaust_burned: Amount; + /** + * Circulating L2 supply: `total_claimed - receipt_exhaust_burned`. + */ total_supply: Amount; + /** + * Number of transaction receipts the indexer has stored. + */ transaction_receipt_count: bigint; + /** + * The target exhaust burn rate in basis points in effect at `current_epoch`. + */ target_burn_rate_bps: number; }; diff --git a/bindings/src/types/tari-indexer-client/IndexerSubmitTransactionResponse.ts b/bindings/src/types/tari-indexer-client/IndexerSubmitTransactionResponse.ts index 2c03b6c88a..ca5b04822b 100644 --- a/bindings/src/types/tari-indexer-client/IndexerSubmitTransactionResponse.ts +++ b/bindings/src/types/tari-indexer-client/IndexerSubmitTransactionResponse.ts @@ -1,15 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ExecuteResult } from "../ExecuteResult"; import type { TransactionId } from "../TransactionId"; export type IndexerSubmitTransactionResponse = { /** - * The ID of the transaction that was dry-run + * The ID of the submitted transaction */ transaction_id: TransactionId; - /** - * The result of the dry-run execution, including any emitted events and state changes, but without a final - * decision or commitment to the ledger - */ - result: ExecuteResult; }; diff --git a/bindings/src/types/wallet-types/CallInstructionRequest.ts b/bindings/src/types/wallet-types/CallInstructionRequest.ts index d924575b59..489492d54e 100644 --- a/bindings/src/types/wallet-types/CallInstructionRequest.ts +++ b/bindings/src/types/wallet-types/CallInstructionRequest.ts @@ -7,7 +7,14 @@ export type CallInstructionRequest = { instructions: Array; fee_account: ComponentAddressOrName; max_fee: number | bigint | string; + /** + * Substates the instructions require as transaction inputs. Needed for any input that cannot be inferred + * from the instructions, e.g. a `ConfidentialOutput` named only by a commitment inside an opaque proof. + */ inputs: Array; + /** + * If true, inputs inferred from the instructions are added to `inputs`. + */ override_inputs: boolean | null; new_outputs: number | null; proof_ids: Array; diff --git a/bindings/src/types/wallet-types/SettingsGetResponse.ts b/bindings/src/types/wallet-types/SettingsGetResponse.ts index a1ec6a107e..c8d899a0d2 100644 --- a/bindings/src/types/wallet-types/SettingsGetResponse.ts +++ b/bindings/src/types/wallet-types/SettingsGetResponse.ts @@ -7,6 +7,16 @@ export type SettingsGetResponse = { network: NetworkInfo; advanced_ui_features: AdvancedUiFeatures; claimed_accounts: Array; + /** + * The network's current epoch, as the wallet daemon sees it. Callers that build transactions + * themselves need it to choose a `max_epoch` the network will accept. `None` when the indexer + * is unreachable — settings must stay readable while the network is down, not least so the + * caller can see and correct the indexer URL. + */ current_epoch: number | null; + /** + * How many epochs past `current_epoch` the wallet daemon stamps `max_epoch` when a caller does + * not choose one. + */ default_transaction_validity_epochs: number; }; diff --git a/bindings/src/types/wallet-types/StealthUtxosGetValueLookupInfoResponse.ts b/bindings/src/types/wallet-types/StealthUtxosGetValueLookupInfoResponse.ts index 812d3d42f8..f222df0e27 100644 --- a/bindings/src/types/wallet-types/StealthUtxosGetValueLookupInfoResponse.ts +++ b/bindings/src/types/wallet-types/StealthUtxosGetValueLookupInfoResponse.ts @@ -1,5 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Describes the value lookup table configured for confidential/stealth balance decryption. When no file is + * configured, `configured` is `false` and the remaining fields are `None`. + */ export type StealthUtxosGetValueLookupInfoResponse = { configured: boolean; path: string | null; diff --git a/bindings/src/types/wallet-types/TransactionDetectInputsRequest.ts b/bindings/src/types/wallet-types/TransactionDetectInputsRequest.ts index 1bc6a81492..fe2f32f0ca 100644 --- a/bindings/src/types/wallet-types/TransactionDetectInputsRequest.ts +++ b/bindings/src/types/wallet-types/TransactionDetectInputsRequest.ts @@ -1,6 +1,16 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { UnsignedTransaction } from "../UnsignedTransaction"; +/** + * Resolve a transaction's inputs without submitting it. + * + * Detection returns the **dependency closure** of everything the instructions + * reference (e.g. an account component pulls in every vault it holds), which + * is a superset of what execution will touch: the wallet cannot know intent + * without executing, so it over-approximates. Narrowing the set is the + * caller's job — every declared input adds transaction weight (fees) and must + * be locked by consensus, so the caller pays for anything left in. + */ export type TransactionDetectInputsRequest = { transaction: UnsignedTransaction; /** diff --git a/bindings/src/validator-node-client.ts b/bindings/src/validator-node-client.ts index c93f7b31bd..99f0277fb3 100644 --- a/bindings/src/validator-node-client.ts +++ b/bindings/src/validator-node-client.ts @@ -1,56 +1,56 @@ // Copyright 2026 The Tari Project // SPDX-License-Identifier: BSD-3-Clause -export * from "./types/validator-node-client/GetCommitteeRequest"; -export * from "./types/validator-node-client/VNTemplateMetadata"; -export * from "./types/validator-node-client/GetBlocksCountResponse"; -export * from "./types/validator-node-client/VNAddPeerRequest"; -export * from "./types/validator-node-client/GetCommitteeResponse"; -export * from "./types/validator-node-client/VNSubmitTransactionRequest"; -export * from "./types/validator-node-client/VNConnectionDirection"; -export * from "./types/validator-node-client/VNAddPeerResponse"; -export * from "./types/validator-node-client/VNCommitteeShardInfo"; -export * from "./types/validator-node-client/VNFunctionDef"; -export * from "./types/validator-node-client/LayerOneTransactionParams"; export * from "./types/validator-node-client/ValidatorNodeChange"; -export * from "./types/validator-node-client/PrepareLayerOneTransactionResponse"; -export * from "./types/validator-node-client/VNGetAllVnsResponse"; -export * from "./types/validator-node-client/TemplateAbi"; -export * from "./types/validator-node-client/GetMempoolStatsResponse"; -export * from "./types/validator-node-client/GetBlockResponse"; -export * from "./types/validator-node-client/VNLogLevel"; -export * from "./types/validator-node-client/VNGetAllVnsRequest"; -export * from "./types/validator-node-client/GetBaseLayerEpochChangesRequest"; -export * from "./types/validator-node-client/GetBaseLayerEpochChangesResponse"; -export * from "./types/validator-node-client/PrepareLayerOneTransactionRequest"; +export * from "./types/validator-node-client/VNSubmitTransactionRequest"; +export * from "./types/validator-node-client/GetFilteredBlocksCountRequest"; export * from "./types/validator-node-client/VNLogEntry"; export * from "./types/validator-node-client/GetShardKeyRequest"; -export * from "./types/validator-node-client/GetBlockRequest"; +export * from "./types/validator-node-client/VNGetTransactionResultResponse"; export * from "./types/validator-node-client/VNSubmitTransactionResponse"; -export * from "./types/validator-node-client/DryRunTransactionFinalizeResult"; -export * from "./types/validator-node-client/GetTransactionResponse"; -export * from "./types/validator-node-client/GetStateResponse"; -export * from "./types/validator-node-client/VNGetSubstateRequest"; -export * from "./types/validator-node-client/VNGetSubstateResponse"; -export * from "./types/validator-node-client/ListBlocksResponse"; -export * from "./types/validator-node-client/SubstateStatus"; -export * from "./types/validator-node-client/ValidatorNode"; -export * from "./types/validator-node-client/GetFilteredBlocksCountRequest"; -export * from "./types/validator-node-client/ListBlocksRequest"; -export * from "./types/validator-node-client/GetEpochManagerStatsResponse"; export * from "./types/validator-node-client/GetTxPoolResponse"; -export * from "./types/validator-node-client/VNConnection"; -export * from "./types/validator-node-client/GetConsensusStatusResponse"; export * from "./types/validator-node-client/GetTemplateResponse"; -export * from "./types/validator-node-client/VNGetConnectionsResponse"; -export * from "./types/validator-node-client/VNGetCommsStatsResponse"; -export * from "./types/validator-node-client/GetTemplateRequest"; +export * from "./types/validator-node-client/GetBlocksCountResponse"; +export * from "./types/validator-node-client/VNConnectionDirection"; +export * from "./types/validator-node-client/VNAddPeerRequest"; +export * from "./types/validator-node-client/PrepareLayerOneTransactionResponse"; +export * from "./types/validator-node-client/GetTransactionRequest"; export * from "./types/validator-node-client/GetStateRequest"; +export * from "./types/validator-node-client/VNGetIdentityResponse"; +export * from "./types/validator-node-client/VNFunctionDef"; +export * from "./types/validator-node-client/LayerOneTransactionParams"; export * from "./types/validator-node-client/GetBlocksRequest"; -export * from "./types/validator-node-client/VNGetTransactionResultRequest"; -export * from "./types/validator-node-client/GetTransactionRequest"; -export * from "./types/validator-node-client/VNGetTransactionResultResponse"; +export * from "./types/validator-node-client/GetTemplateRequest"; export * from "./types/validator-node-client/GetShardKeyResponse"; +export * from "./types/validator-node-client/GetBaseLayerEpochChangesResponse"; +export * from "./types/validator-node-client/VNGetCommsStatsResponse"; +export * from "./types/validator-node-client/GetTransactionResponse"; +export * from "./types/validator-node-client/GetCommitteeResponse"; +export * from "./types/validator-node-client/VNGetAllVnsRequest"; +export * from "./types/validator-node-client/GetCommitteeRequest"; +export * from "./types/validator-node-client/DryRunTransactionFinalizeResult"; +export * from "./types/validator-node-client/GetStateResponse"; +export * from "./types/validator-node-client/GetBaseLayerEpochChangesRequest"; +export * from "./types/validator-node-client/SubstateStatus"; +export * from "./types/validator-node-client/GetConsensusStatusResponse"; export * from "./types/validator-node-client/GetBlocksResponse"; -export * from "./types/validator-node-client/VNGetIdentityResponse"; +export * from "./types/validator-node-client/ListBlocksResponse"; +export * from "./types/validator-node-client/GetBlockRequest"; +export * from "./types/validator-node-client/VNGetTransactionResultRequest"; +export * from "./types/validator-node-client/VNTemplateMetadata"; +export * from "./types/validator-node-client/VNLogLevel"; +export * from "./types/validator-node-client/VNAddPeerResponse"; +export * from "./types/validator-node-client/VNConnection"; +export * from "./types/validator-node-client/ValidatorNode"; +export * from "./types/validator-node-client/PrepareLayerOneTransactionRequest"; +export * from "./types/validator-node-client/VNGetConnectionsResponse"; +export * from "./types/validator-node-client/ListBlocksRequest"; export * from "./types/validator-node-client/GetNetworkCommitteeResponse"; +export * from "./types/validator-node-client/VNGetSubstateRequest"; +export * from "./types/validator-node-client/GetMempoolStatsResponse"; +export * from "./types/validator-node-client/TemplateAbi"; +export * from "./types/validator-node-client/VNGetSubstateResponse"; +export * from "./types/validator-node-client/VNGetAllVnsResponse"; +export * from "./types/validator-node-client/GetEpochManagerStatsResponse"; +export * from "./types/validator-node-client/GetBlockResponse"; +export * from "./types/validator-node-client/VNCommitteeShardInfo"; diff --git a/bindings/src/wallet-types.ts b/bindings/src/wallet-types.ts index dabd0e4915..cc2a175728 100644 --- a/bindings/src/wallet-types.ts +++ b/bindings/src/wallet-types.ts @@ -1,201 +1,201 @@ // Copyright 2026 The Tari Project // SPDX-License-Identifier: BSD-3-Clause -export * from "./types/wallet-types/AccountsListResponse"; -export * from "./types/wallet-types/PublishTemplateMetadata"; -export * from "./types/wallet-types/AddressBookUpdateRequest"; -export * from "./types/wallet-types/SignTemplateMetadataResponse"; -export * from "./types/wallet-types/WalletGetInfoRequest"; -export * from "./types/wallet-types/AuthListSessionsResponse"; -export * from "./types/wallet-types/ClaimBurnResponse"; -export * from "./types/wallet-types/KeysSetActiveRequest"; -export * from "./types/wallet-types/AccountsGetBalancesResponse"; -export * from "./types/wallet-types/SubstatesGetResponse"; -export * from "./types/wallet-types/AccountsRenameResponse"; -export * from "./types/wallet-types/AddressBookGetRequest"; -export * from "./types/wallet-types/StealthTransferRequest"; -export * from "./types/wallet-types/BurnProofsListResponse"; -export * from "./types/wallet-types/AccountsCreateStealthTransferStatementRequest"; -export * from "./types/wallet-types/AddressBookListResponse"; -export * from "./types/wallet-types/AddressBookAddRequest"; -export * from "./types/wallet-types/AccountSetDefaultRequest"; -export * from "./types/wallet-types/TransactionGetAllResponse"; -export * from "./types/wallet-types/TransactionGetRequest"; -export * from "./types/wallet-types/KeyBranch"; -export * from "./types/wallet-types/KeyId"; -export * from "./types/wallet-types/AddressBookListRequest"; -export * from "./types/wallet-types/PayTo"; -export * from "./types/wallet-types/AccountSetDefaultResponse"; -export * from "./types/wallet-types/SubstatesListResponse"; -export * from "./types/wallet-types/AccountGetDefaultRequest"; -export * from "./types/wallet-types/StealthUtxosListResponse"; -export * from "./types/wallet-types/WebauthnAlreadyRegisteredResponse"; -export * from "./types/wallet-types/KeysListResponse"; -export * from "./types/wallet-types/ProofsGenerateResponse"; -export * from "./types/wallet-types/AccountsAssociateStealthResourceRequest"; -export * from "./types/wallet-types/TransactionGetAllRequest"; -export * from "./types/wallet-types/AccountsTransferResponse"; -export * from "./types/wallet-types/AuthLoginResponse"; -export * from "./types/wallet-types/WebauthnStartAuthResponse"; -export * from "./types/wallet-types/ClaimBurnRequest"; -export * from "./types/wallet-types/AccountsRenameRequest"; -export * from "./types/wallet-types/BalanceEntry"; -export * from "./types/wallet-types/KeysListRequest"; -export * from "./types/wallet-types/SubstatesGetRequest"; +export * from "./types/wallet-types/AddressBookUpdateResponse"; +export * from "./types/wallet-types/BalanceChange"; +export * from "./types/wallet-types/ConfidentialOutputsListRequest"; export * from "./types/wallet-types/ProofsFinalizeResponse"; -export * from "./types/wallet-types/ClaimBurnProof"; -export * from "./types/wallet-types/AuthRevokeTokenRequest"; -export * from "./types/wallet-types/TransactionWaitResultRequest"; -export * from "./types/wallet-types/PublishTemplateResponse"; -export * from "./types/wallet-types/TransactionSubmitResponse"; -export * from "./types/wallet-types/TemplatesListAuthoredRequest"; -export * from "./types/wallet-types/AccountsCreateRequest"; +export * from "./types/wallet-types/WalletGetInfoResponse"; +export * from "./types/wallet-types/SwapPoolGetExchangeRateResponse"; export * from "./types/wallet-types/CallInstructionRequest"; -export * from "./types/wallet-types/AuthGetMethodRequest"; -export * from "./types/wallet-types/AccountsGetBalanceChangesRequest"; -export * from "./types/wallet-types/WebauthnStartRegisterRequest"; -export * from "./types/wallet-types/AccountGetByKeyIndexRequest"; export * from "./types/wallet-types/AddressBookDeleteResponse"; -export * from "./types/wallet-types/WebRtcStartRequest"; -export * from "./types/wallet-types/WebRtcStartResponse"; -export * from "./types/wallet-types/AuthSessionInfo"; -export * from "./types/wallet-types/StealthUtxosListRequest"; +export * from "./types/wallet-types/AccountsListRequest"; +export * from "./types/wallet-types/AccountsGetBalanceChangesRequest"; +export * from "./types/wallet-types/AuthLoginResponse"; +export * from "./types/wallet-types/GetValidatorFeesRequest"; +export * from "./types/wallet-types/StealthUtxosListResponse"; +export * from "./types/wallet-types/AccountGetDefaultRequest"; +export * from "./types/wallet-types/AddressBookListResponse"; +export * from "./types/wallet-types/KeyBranch"; +export * from "./types/wallet-types/PublishTemplateResponse"; +export * from "./types/wallet-types/AccountsAssociateStealthResourceResponse"; +export * from "./types/wallet-types/WalletSubstateInfo"; +export * from "./types/wallet-types/WebauthnAlreadyRegisteredResponse"; +export * from "./types/wallet-types/AccountGetResponse"; +export * from "./types/wallet-types/SettingsSetRequest"; export * from "./types/wallet-types/StealthTransferResponse"; -export * from "./types/wallet-types/AdvancedUiFeatures"; +export * from "./types/wallet-types/ConfidentialOutputsListResponse"; +export * from "./types/wallet-types/StealthTransferRequest"; +export * from "./types/wallet-types/GetValidatorFeesResponse"; +export * from "./types/wallet-types/StealthUtxoSpendKeyId"; +export * from "./types/wallet-types/AccountsCreateStealthTransferStatementResponse"; +export * from "./types/wallet-types/ClaimBurnProof"; +export * from "./types/wallet-types/TransactionGetAllRequest"; +export * from "./types/wallet-types/WebauthnAlreadyRegisteredRequest"; +export * from "./types/wallet-types/BurnProofsListRequest"; +export * from "./types/wallet-types/AuthMethod"; export * from "./types/wallet-types/SwapPoolsListRequest"; -export * from "./types/wallet-types/SwapPoolInfo"; -export * from "./types/wallet-types/BurnProofsGetResponse"; -export * from "./types/wallet-types/WalletGetInfoResponse"; -export * from "./types/wallet-types/AuthoredTemplate"; +export * from "./types/wallet-types/StealthUtxosDecryptValueResponse"; +export * from "./types/wallet-types/MintFaucetNftRequest"; +export * from "./types/wallet-types/PublishTemplateRequest"; +export * from "./types/wallet-types/TransactionSubmitResponse"; +export * from "./types/wallet-types/StealthTransfer"; +export * from "./types/wallet-types/ProofsGenerateResponse"; export * from "./types/wallet-types/TransactionSubmitManifestResponse"; -export * from "./types/wallet-types/ProofsCancelResponse"; -export * from "./types/wallet-types/AccountGetResponse"; -export * from "./types/wallet-types/AddressBookUpdateResponse"; +export * from "./types/wallet-types/ListNftsResponse"; +export * from "./types/wallet-types/StealthUtxosGetValueLookupInfoRequest"; export * from "./types/wallet-types/ClaimValidatorFeesRequest"; -export * from "./types/wallet-types/WebauthnStartAuthRequest"; -export * from "./types/wallet-types/StealthTransfer"; +export * from "./types/wallet-types/PublishTemplateMetadata"; +export * from "./types/wallet-types/AuthLoginRequest"; +export * from "./types/wallet-types/EffectiveStatus"; +export * from "./types/wallet-types/AuthGetMethodResponse"; +export * from "./types/wallet-types/TransactionGetRequest"; export * from "./types/wallet-types/AuthRevokeTokenResponse"; -export * from "./types/wallet-types/WebauthnFinishRegisterResponse"; -export * from "./types/wallet-types/TransferFeeParams"; -export * from "./types/wallet-types/WebRtcStart"; -export * from "./types/wallet-types/TransactionWaitResultResponse"; -export * from "./types/wallet-types/AccountsAssociateStealthResourceResponse"; -export * from "./types/wallet-types/GetValidatorFeesRequest"; -export * from "./types/wallet-types/ConfidentialCreateOutputProofRequest"; -export * from "./types/wallet-types/KeysCreateResponse"; -export * from "./types/wallet-types/WebauthnFinishAuthRequest"; -export * from "./types/wallet-types/AccountsGetBalanceChangesResponse"; -export * from "./types/wallet-types/SwapPoolGetExchangeRateResponse"; -export * from "./types/wallet-types/MintFaucetNftResponse"; -export * from "./types/wallet-types/AuthCreateApiKeyResponse"; -export * from "./types/wallet-types/AddressBookEntry"; export * from "./types/wallet-types/ProofsFinalizeRequest"; -export * from "./types/wallet-types/BurnProofsGetRequest"; -export * from "./types/wallet-types/SwapPoolsListResponse"; -export * from "./types/wallet-types/BalanceChange"; -export * from "./types/wallet-types/ProofsCancelRequest"; -export * from "./types/wallet-types/StealthUtxosDecryptValueRequest"; +export * from "./types/wallet-types/TransactionRequestDecisionResponse"; +export * from "./types/wallet-types/PayFeeWithSwapParams"; +export * from "./types/wallet-types/WebauthnStartRegisterRequest"; export * from "./types/wallet-types/FeePoolDetails"; -export * from "./types/wallet-types/SettingsGetResponse"; -export * from "./types/wallet-types/NetworkInfo"; -export * from "./types/wallet-types/ClaimBurnProofContents"; -export * from "./types/wallet-types/ConfidentialViewVaultBalanceRequest"; -export * from "./types/wallet-types/ConfidentialOutputsListRequest"; -export * from "./types/wallet-types/ConfidentialOutputsListResponse"; -export * from "./types/wallet-types/ConfidentialOutputInfo"; -export * from "./types/wallet-types/GetValidatorFeesResponse"; -export * from "./types/wallet-types/TransferNftRequest"; -export * from "./types/wallet-types/AuthCredentials"; -export * from "./types/wallet-types/StealthUtxoSpendKeyId"; -export * from "./types/wallet-types/InputSelection"; -export * from "./types/wallet-types/TransactionGetResponse"; -export * from "./types/wallet-types/RefreshTokenHash"; -export * from "./types/wallet-types/AddressBookAddResponse"; -export * from "./types/wallet-types/ListNftsRequest"; -export * from "./types/wallet-types/TransactionSubmitRequest"; -export * from "./types/wallet-types/BurnProofsListRequest"; -export * from "./types/wallet-types/BalanceChangeSourceType"; -export * from "./types/wallet-types/AccountsCreateStealthTransferStatementResponse"; -export * from "./types/wallet-types/IssuedApiKey"; -export * from "./types/wallet-types/BadgeUsage"; -export * from "./types/wallet-types/AuthRefreshRequest"; -export * from "./types/wallet-types/AddressBookDeleteRequest"; -export * from "./types/wallet-types/AccountsListRequest"; -export * from "./types/wallet-types/TransactionSubmitDryRunResponse"; -export * from "./types/wallet-types/ConfidentialCreateOutputProofResponse"; +export * from "./types/wallet-types/AccountsTransferRequest"; export * from "./types/wallet-types/AccountOrKeyId"; -export * from "./types/wallet-types/TemplatesGetRequest"; -export * from "./types/wallet-types/AuthRevokeApiKeyResponse"; -export * from "./types/wallet-types/WebauthnFinishRegisterRequest"; -export * from "./types/wallet-types/ConfidentialViewVaultBalanceResponse"; -export * from "./types/wallet-types/WalletSubstateInfo"; -export * from "./types/wallet-types/SettingsSetRequest"; -export * from "./types/wallet-types/ListNftsResponse"; +export * from "./types/wallet-types/TransactionRequestValueSummary"; +export * from "./types/wallet-types/AddressBookAddRequest"; export * from "./types/wallet-types/DerivedKeyId"; -export * from "./types/wallet-types/TransactionSubmitManifestRequest"; -export * from "./types/wallet-types/TransactionClaimBurnResponse"; -export * from "./types/wallet-types/ClaimValidatorFeesResponse"; -export * from "./types/wallet-types/ConfidentialTransferResponse"; -export * from "./types/wallet-types/TemplatesGetResponse"; -export * from "./types/wallet-types/NewAccountData"; -export * from "./types/wallet-types/AccountsTransferRequest"; -export * from "./types/wallet-types/AccountsCreateOrGetResponse"; -export * from "./types/wallet-types/SignTemplateMetadataRequest"; -export * from "./types/wallet-types/AuthLoginRequest"; -export * from "./types/wallet-types/UtxoInfo"; -export * from "./types/wallet-types/AuthListApiKeysResponse"; -export * from "./types/wallet-types/AccountsCreateFreeTestCoinsRequest"; -export * from "./types/wallet-types/ConfidentialTransferRequest"; export * from "./types/wallet-types/SettingsSetResponse"; -export * from "./types/wallet-types/AuthListSessionsRequest"; -export * from "./types/wallet-types/BalanceChangeSource"; -export * from "./types/wallet-types/AuthRefreshResponse"; -export * from "./types/wallet-types/WebauthnAlreadyRegisteredRequest"; -export * from "./types/wallet-types/TransferStatementRequest"; -export * from "./types/wallet-types/WebauthnStartRegisterResponse"; -export * from "./types/wallet-types/PublishTemplateRequest"; -export * from "./types/wallet-types/GetNftRequest"; +export * from "./types/wallet-types/WebauthnFinishAuthRequest"; +export * from "./types/wallet-types/AdvancedUiFeatures"; +export * from "./types/wallet-types/KeysListRequest"; +export * from "./types/wallet-types/AccountsTransferResponse"; +export * from "./types/wallet-types/TransactionGetResultResponse"; +export * from "./types/wallet-types/StealthUtxosGetValueLookupInfoResponse"; +export * from "./types/wallet-types/AccountsAssociateStealthResourceRequest"; +export * from "./types/wallet-types/ClaimBurnRequest"; +export * from "./types/wallet-types/BurnProofsListResponse"; +export * from "./types/wallet-types/SignTemplateMetadataRequest"; export * from "./types/wallet-types/BurnProofFileInfo"; -export * from "./types/wallet-types/TransferOutput"; +export * from "./types/wallet-types/TransactionRequestCreateRequest"; +export * from "./types/wallet-types/KeysListResponse"; +export * from "./types/wallet-types/KeyId"; +export * from "./types/wallet-types/SubstatesGetRequest"; +export * from "./types/wallet-types/TransferFeeParams"; export * from "./types/wallet-types/SwapPoolGetExchangeRateRequest"; -export * from "./types/wallet-types/MintFaucetNftRequest"; -export * from "./types/wallet-types/AuthMethod"; +export * from "./types/wallet-types/TransactionSubmitRequest"; +export * from "./types/wallet-types/AccountsGetBalancesResponse"; +export * from "./types/wallet-types/TransactionGetAllResponse"; +export * from "./types/wallet-types/TransactionRequestGetRequest"; export * from "./types/wallet-types/AccountsCreateFreeTestCoinsResponse"; +export * from "./types/wallet-types/ListNftsRequest"; +export * from "./types/wallet-types/TransactionSubmitManifestRequest"; +export * from "./types/wallet-types/AuthListApiKeysResponse"; +export * from "./types/wallet-types/ConfidentialTransferResponse"; export * from "./types/wallet-types/AuthRevokeApiKeyRequest"; -export * from "./types/wallet-types/TransferNftResponse"; +export * from "./types/wallet-types/BurnProofsGetResponse"; +export * from "./types/wallet-types/TransactionGetResponse"; +export * from "./types/wallet-types/SubstatesGetResponse"; +export * from "./types/wallet-types/AccountsRenameRequest"; +export * from "./types/wallet-types/BurnProofsGetRequest"; +export * from "./types/wallet-types/AuthRevokeTokenRequest"; export * from "./types/wallet-types/AuthCreateApiKeyRequest"; -export * from "./types/wallet-types/AccountGetRequest"; -export * from "./types/wallet-types/KeysCreateRequest"; export * from "./types/wallet-types/ProofsGenerateRequest"; -export * from "./types/wallet-types/PayFeeWithSwapParams"; -export * from "./types/wallet-types/AddressBookGetResponse"; -export * from "./types/wallet-types/TransactionGetResultResponse"; -export * from "./types/wallet-types/KeysSetActiveResponse"; -export * from "./types/wallet-types/AuthGetMethodResponse"; +export * from "./types/wallet-types/MintFaucetNftResponse"; +export * from "./types/wallet-types/TransactionRequestInfo"; +export * from "./types/wallet-types/AuthListSessionsRequest"; +export * from "./types/wallet-types/TransactionRequestGetResponse"; +export * from "./types/wallet-types/TemplatesListAuthoredRequest"; +export * from "./types/wallet-types/AuthRefreshResponse"; +export * from "./types/wallet-types/AddressBookDeleteRequest"; +export * from "./types/wallet-types/AccountsListResponse"; +export * from "./types/wallet-types/WebRtcStartResponse"; +export * from "./types/wallet-types/AuthCreateApiKeyResponse"; export * from "./types/wallet-types/TransactionGetResultRequest"; +export * from "./types/wallet-types/WebauthnFinishRegisterRequest"; +export * from "./types/wallet-types/ProofsCancelResponse"; +export * from "./types/wallet-types/ClaimBurnProofContents"; +export * from "./types/wallet-types/AccountsCreateRequest"; +export * from "./types/wallet-types/TransactionClaimBurnResponse"; +export * from "./types/wallet-types/TransactionWaitResultRequest"; +export * from "./types/wallet-types/ClaimValidatorFeesResponse"; +export * from "./types/wallet-types/ConfidentialCreateOutputProofRequest"; +export * from "./types/wallet-types/KeysCreateRequest"; +export * from "./types/wallet-types/WebauthnStartAuthRequest"; +export * from "./types/wallet-types/TransactionRequestDecisionRequest"; +export * from "./types/wallet-types/NetworkInfo"; +export * from "./types/wallet-types/TransferStatementRequest"; +export * from "./types/wallet-types/AuthSessionInfo"; export * from "./types/wallet-types/AccountsCreateOrGetRequest"; -export * from "./types/wallet-types/AccountsCreateResponse"; +export * from "./types/wallet-types/SettingsGetResponse"; +export * from "./types/wallet-types/WebauthnFinishRegisterResponse"; +export * from "./types/wallet-types/TransactionSubmitDryRunResponse"; +export * from "./types/wallet-types/WalletGetInfoRequest"; +export * from "./types/wallet-types/StealthUtxosListRequest"; +export * from "./types/wallet-types/ClaimBurnResponse"; +export * from "./types/wallet-types/AccountsGetBalanceChangesResponse"; +export * from "./types/wallet-types/TransactionRequestListResponse"; +export * from "./types/wallet-types/WebRtcStartRequest"; +export * from "./types/wallet-types/TransactionRequestCreateResponse"; +export * from "./types/wallet-types/AuthRefreshRequest"; +export * from "./types/wallet-types/InputSelection"; +export * from "./types/wallet-types/ConfidentialViewVaultBalanceResponse"; +export * from "./types/wallet-types/AddressBookGetRequest"; +export * from "./types/wallet-types/WebRtcStart"; +export * from "./types/wallet-types/ConfidentialOutputInfo"; +export * from "./types/wallet-types/AccountSetDefaultRequest"; +export * from "./types/wallet-types/WebauthnStartRegisterResponse"; +export * from "./types/wallet-types/AddressBookGetResponse"; +export * from "./types/wallet-types/SignTemplateMetadataResponse"; +export * from "./types/wallet-types/ConfidentialCreateOutputProofResponse"; +export * from "./types/wallet-types/AccountsCreateStealthTransferStatementRequest"; +export * from "./types/wallet-types/SwapPoolInfo"; +export * from "./types/wallet-types/RefreshTokenHash"; +export * from "./types/wallet-types/TemplatesGetResponse"; export * from "./types/wallet-types/AccountsGetBalancesRequest"; +export * from "./types/wallet-types/AuthRevokeApiKeyResponse"; +export * from "./types/wallet-types/TransactionRequestListRequest"; +export * from "./types/wallet-types/AccountsCreateFreeTestCoinsRequest"; +export * from "./types/wallet-types/AccountGetByKeyIndexRequest"; +export * from "./types/wallet-types/TransactionDetectInputsRequest"; +export * from "./types/wallet-types/AddressBookListRequest"; +export * from "./types/wallet-types/AccountsRenameResponse"; +export * from "./types/wallet-types/KeysCreateResponse"; +export * from "./types/wallet-types/AccountsCreateOrGetResponse"; +export * from "./types/wallet-types/PayTo"; export * from "./types/wallet-types/AccountInfo"; -export * from "./types/wallet-types/AuthListApiKeysRequest"; +export * from "./types/wallet-types/AddressBookAddResponse"; +export * from "./types/wallet-types/TransferNftResponse"; export * from "./types/wallet-types/SubstatesListRequest"; -export * from "./types/wallet-types/ComponentAddressOrName"; -export * from "./types/wallet-types/TemplatesListAuthoredResponse"; -export * from "./types/wallet-types/StealthUtxosDecryptValueResponse"; -export * from "./types/wallet-types/StealthUtxosGetValueLookupInfoRequest"; -export * from "./types/wallet-types/StealthUtxosGetValueLookupInfoResponse"; -export * from "./types/wallet-types/EffectiveStatus"; -export * from "./types/wallet-types/TransactionRequestCreateRequest"; -export * from "./types/wallet-types/TransactionRequestCreateResponse"; -export * from "./types/wallet-types/TransactionRequestDecisionRequest"; -export * from "./types/wallet-types/TransactionRequestDecisionResponse"; -export * from "./types/wallet-types/TransactionRequestGetRequest"; -export * from "./types/wallet-types/TransactionRequestGetResponse"; -export * from "./types/wallet-types/TransactionRequestInfo"; -export * from "./types/wallet-types/TransactionRequestListRequest"; -export * from "./types/wallet-types/TransactionRequestListResponse"; -export * from "./types/wallet-types/TransactionRequestSubmitRequest"; +export * from "./types/wallet-types/BalanceEntry"; +export * from "./types/wallet-types/TemplatesGetRequest"; +export * from "./types/wallet-types/AddressBookUpdateRequest"; +export * from "./types/wallet-types/SwapPoolsListResponse"; +export * from "./types/wallet-types/AuthCredentials"; +export * from "./types/wallet-types/BalanceChangeSourceType"; +export * from "./types/wallet-types/ProofsCancelRequest"; +export * from "./types/wallet-types/BadgeUsage"; +export * from "./types/wallet-types/AccountSetDefaultResponse"; +export * from "./types/wallet-types/GetNftRequest"; +export * from "./types/wallet-types/SubstatesListResponse"; +export * from "./types/wallet-types/AuthListSessionsResponse"; +export * from "./types/wallet-types/StealthUtxosDecryptValueRequest"; +export * from "./types/wallet-types/WebauthnStartAuthResponse"; export * from "./types/wallet-types/TransactionRequestSubmitResponse"; -export * from "./types/wallet-types/TransactionRequestValueSummary"; -export * from "./types/wallet-types/TransactionDetectInputsRequest"; +export * from "./types/wallet-types/TransferNftRequest"; +export * from "./types/wallet-types/AddressBookEntry"; +export * from "./types/wallet-types/KeysSetActiveResponse"; +export * from "./types/wallet-types/ComponentAddressOrName"; +export * from "./types/wallet-types/AuthGetMethodRequest"; +export * from "./types/wallet-types/NewAccountData"; +export * from "./types/wallet-types/AuthListApiKeysRequest"; export * from "./types/wallet-types/TransactionDetectInputsResponse"; +export * from "./types/wallet-types/KeysSetActiveRequest"; +export * from "./types/wallet-types/TransactionRequestSubmitRequest"; +export * from "./types/wallet-types/ConfidentialViewVaultBalanceRequest"; +export * from "./types/wallet-types/UtxoInfo"; +export * from "./types/wallet-types/TemplatesListAuthoredResponse"; +export * from "./types/wallet-types/BalanceChangeSource"; +export * from "./types/wallet-types/TransferOutput"; +export * from "./types/wallet-types/ConfidentialTransferRequest"; +export * from "./types/wallet-types/AuthoredTemplate"; +export * from "./types/wallet-types/AccountsCreateResponse"; +export * from "./types/wallet-types/IssuedApiKey"; +export * from "./types/wallet-types/TransactionWaitResultResponse"; +export * from "./types/wallet-types/AccountGetRequest"; diff --git a/clients/javascript/indexer_client/package.json b/clients/javascript/indexer_client/package.json index b627e73107..68285cbde4 100644 --- a/clients/javascript/indexer_client/package.json +++ b/clients/javascript/indexer_client/package.json @@ -1,6 +1,6 @@ { "name": "@tari-project/indexer-client", - "version": "1.5.0", + "version": "1.6.0", "description": "Tari indexer REST API client library", "homepage": "https://github.com/tari-project/tari-ootle#readme", "bugs": { diff --git a/clients/javascript/wallet_daemon_client/package.json b/clients/javascript/wallet_daemon_client/package.json index 97f8a45267..7c046c29f7 100644 --- a/clients/javascript/wallet_daemon_client/package.json +++ b/clients/javascript/wallet_daemon_client/package.json @@ -1,6 +1,6 @@ { "name": "@tari-project/wallet_jrpc_client", - "version": "1.21.0", + "version": "1.22.0", "description": "Tari wallet JSON-RPC client library", "homepage": "https://github.com/tari-project/tari-ootle#readme", "bugs": { diff --git a/crates/consensus/src/consensus_constants.rs b/crates/consensus/src/consensus_constants.rs index 7821cd77fa..a00128d926 100644 --- a/crates/consensus/src/consensus_constants.rs +++ b/crates/consensus/src/consensus_constants.rs @@ -308,6 +308,29 @@ impl From for ConsensusConstants { #[cfg(test)] mod tests { + use tari_engine_types::fees::MAX_EXHAUST_BURN_RATE_BPS; + + /// `FeeReceipt::required_fees` is derived against `MAX_EXHAUST_BURN_RATE_BPS`. A network that + /// burns faster than that makes every dry-run estimate too low to submit with. + #[test] + fn every_shipped_network_stays_within_the_burn_rate_ceiling() { + for network in [ + Network::MainNet, + Network::StageNet, + Network::NextNet, + Network::Igor, + Network::Esmeralda, + Network::LocalNet, + ] { + let constants = super::ConsensusConstants::from(network); + assert!( + constants.exhaust_burn_rate_bps <= MAX_EXHAUST_BURN_RATE_BPS, + "{network} burns at {} bps, above the {MAX_EXHAUST_BURN_RATE_BPS} bps the fee estimate assumes", + constants.exhaust_burn_rate_bps + ); + } + } + use tari_engine_types::limits::{ ENGINE_LIMITS, MAX_NATIVE_POINTS_PER_TRANSACTION, diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 89d41173a7..494847e04a 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -45,6 +45,7 @@ wasm-cache = ["dep:bytes", "dep:memmap2"] # depends on tari_engine, so we can't list it with a published version here or # `cargo publish` can't resolve the manifest. tari_template_test_tooling = { path = "../template_test_tooling" } +tari_bor = { workspace = true, default-features = true, features = ["std"] } tari_transaction_manifest = { workspace = true } tari_ootle_transaction = { workspace = true, features = ["serde"] } tari_template_builtin = { workspace = true, features = ["templates", "state"] } diff --git a/crates/engine/src/fees/fee_module.rs b/crates/engine/src/fees/fee_module.rs index 9250e9322c..4ece67fc2d 100644 --- a/crates/engine/src/fees/fee_module.rs +++ b/crates/engine/src/fees/fee_module.rs @@ -7,7 +7,7 @@ use tari_template_lib::types::TemplateAddress; use super::FeeTable; use crate::{ - runtime::{RuntimeEvent, RuntimeModule, RuntimeModuleError, StateTracker}, + runtime::{ChargeableState, RuntimeEvent, RuntimeModule, RuntimeModuleError, StateTracker}, state_store::StateReader, }; @@ -47,6 +47,113 @@ impl FeeModule { Ok((base_bytes, charge)) } + /// Charges everything that is a function of the state being persisted, rather than of the work + /// done to produce it: storage, the template-publish premium, substate slots, the metering of + /// WASM and native execution, and the exhaust burn over the resulting total. + /// + /// Every charge here is *assigned*, not accumulated, so that running this again against a + /// different state replaces the result rather than doubling it. That is what lets a transaction + /// be gated on the cost of the state it asked to commit and then billed for the state that is + /// actually persisted — see [`RuntimeModule::on_before_persist`]. + fn charge_finalization_fees( + &self, + state: &mut ChargeableState<'_, TStore>, + ) -> Result<(), RuntimeModuleError> { + let mut counter = ByteCounter::new(); + let mut template_base_bytes = 0u64; + let mut template_charge = 0u64; + for substate in state.substates_to_persist().values() { + // A published template's binary is priced by the dedicated base + quadratic publish + // model, so keep it out of the flat per-byte storage tally. Accumulate the raw + // metrics here and apply the storage divisor once below. + if let SubstateValue::Template(template) = substate { + let (tpl_base_bytes, tpl_charge) = self.template_publish_metrics(template.binary.len())?; + template_base_bytes = template_base_bytes.checked_add(tpl_base_bytes).ok_or_else(|| { + RuntimeModuleError::Overflow("Overflow accumulating template base bytes".to_string()) + })?; + template_charge = template_charge.checked_add(tpl_charge).ok_or_else(|| { + RuntimeModuleError::Overflow("Overflow accumulating template publish premium".to_string()) + })?; + continue; + } + encode_into_writer(substate, &mut counter)?; + } + + // Finalization persists the transaction receipt on top of the mutated substates. It carries + // the transaction's events, so leaving it out of the tally would make that payload — the + // largest caller-controlled contribution to permanent state after the substates themselves — + // free. + let receipt_bytes = state + .transaction_receipt_size() + .map_err(|e| RuntimeModuleError::Runtime(e.to_string()))?; + let total_storage = counter + .get() + .checked_add(receipt_bytes) + .ok_or_else(|| RuntimeModuleError::Overflow("Overflow accumulating storage bytes".to_string()))?; + + let cost = self + .fee_table + .per_byte_storage_cost() + .checked_mul(total_storage as u64) + .ok_or_else(|| RuntimeModuleError::Overflow("Overflow calculating storage cost".to_string()))?; + let storage_cost = cost / self.fee_table.storage_cost_divisor(); + + let template_base_cost = self + .fee_table + .per_byte_storage_cost() + .checked_mul(template_base_bytes) + .ok_or_else(|| { + RuntimeModuleError::Overflow("Overflow calculating template base storage cost".to_string()) + })? / + self.fee_table.storage_cost_divisor(); + let template_publish_cost = template_base_cost + .checked_add(template_charge) + .ok_or_else(|| RuntimeModuleError::Overflow("Overflow calculating template publish cost".to_string()))?; + + // The receipt occupies a slot of its own — it is always newly created, since it is addressed + // by a transaction id that can only be finalized once. + let new_substate_count = state + .count_newly_created_substates() + .map_err(|e| RuntimeModuleError::Runtime(e.to_string()))? + .saturating_add(1); + let create_cost = (new_substate_count as u64) + .checked_mul(self.fee_table.per_substate_create_cost()) + .ok_or_else(|| RuntimeModuleError::Overflow("Overflow calculating substate create cost".to_string()))?; + + // WASM execution: charge once against the transaction's accumulated points so the divisor + // rounds against the total. Per-call rounding would let a transaction split work into + // sub-divisor chunks and pay zero for any single one (each `points/divisor` is `0`), even + // though the summed work is non-trivial. + let units = state.fee_state().accumulated_wasm_points() / self.fee_table.wasm_points_cost_divisor(); + let wasm_cost = units + .checked_mul(self.fee_table.per_wasm_point_cost()) + .ok_or_else(|| RuntimeModuleError::Overflow("Overflow calculating WASM execution cost".to_string()))?; + + // Native verification is priced in the same points and charged at the same rate, under its + // own source so the breakdown distinguishes crypto verification from template execution. + let native_units = state.fee_state().accumulated_native_points() / self.fee_table.wasm_points_cost_divisor(); + let native_cost = native_units + .checked_mul(self.fee_table.per_wasm_point_cost()) + .ok_or_else(|| RuntimeModuleError::Overflow("Overflow calculating native execution cost".to_string()))?; + + let fee_state = state.fee_state_mut(); + fee_state.set_charge(FeeSource::Storage, storage_cost); + fee_state.set_charge(FeeSource::TemplatePublish, template_publish_cost); + fee_state.set_charge(FeeSource::SubstateCreate, create_cost); + fee_state.set_charge(FeeSource::WasmExecution, wasm_cost); + fee_state.set_charge(FeeSource::NativeExecution, native_cost); + + // Exhaust burn: charged on top of the execution fee accrued so far, so leaders receive the execution fee in + // full and the burn amount is destroyed separately. The rate is seeded onto the fee state at execution time + // for the execution epoch. Zeroed first so that the total it is taken over never includes a burn from an + // earlier pass over a different state. + fee_state.set_charge(FeeSource::ExhaustBurn, 0); + let burn = calculate_burn_amount(fee_state.total_charges(), fee_state.burn_rate_bps())?; + fee_state.set_charge(FeeSource::ExhaustBurn, burn); + + Ok(()) + } + #[cfg(test)] fn template_publish_cost(&self, binary_len: usize) -> Result { let (base_bytes, charge) = self.template_publish_metrics(binary_len)?; @@ -110,98 +217,11 @@ impl RuntimeModule for FeeModule { } fn on_before_finalize(&self, track: &mut StateTracker) -> Result<(), RuntimeModuleError> { - let (total_storage, template_base_bytes, template_charge) = track.with_substates_to_persist(|changes| { - let mut counter = ByteCounter::new(); - let mut base_bytes = 0u64; - let mut charge = 0u64; - for substate in changes.values() { - // A published template's binary is priced by the dedicated base + quadratic publish - // model, so keep it out of the flat per-byte storage tally. Accumulate the raw - // metrics here and apply the storage divisor once below. - if let SubstateValue::Template(template) = substate { - let (tpl_base_bytes, tpl_charge) = self.template_publish_metrics(template.binary.len())?; - base_bytes = base_bytes.checked_add(tpl_base_bytes).ok_or_else(|| { - RuntimeModuleError::Overflow("Overflow accumulating template base bytes".to_string()) - })?; - charge = charge.checked_add(tpl_charge).ok_or_else(|| { - RuntimeModuleError::Overflow("Overflow accumulating template publish premium".to_string()) - })?; - continue; - } - encode_into_writer(substate, &mut counter)?; - } - Ok::<_, RuntimeModuleError>((counter.get(), base_bytes, charge)) - })?; - - // Finalization persists the transaction receipt on top of the mutated substates. It carries - // the transaction's events, so leaving it out of the tally would make that payload — the - // largest caller-controlled contribution to permanent state after the substates themselves — - // free. - let receipt_bytes = track - .transaction_receipt_size() - .map_err(|e| RuntimeModuleError::Runtime(e.to_string()))?; - let total_storage = total_storage - .checked_add(receipt_bytes) - .ok_or_else(|| RuntimeModuleError::Overflow("Overflow accumulating storage bytes".to_string()))?; - - let cost = self - .fee_table - .per_byte_storage_cost() - .checked_mul(total_storage as u64) - .ok_or_else(|| RuntimeModuleError::Overflow("Overflow calculating storage cost".to_string()))?; - track.add_fee_charge(FeeSource::Storage, cost / self.fee_table.storage_cost_divisor()); - - let template_base_cost = self - .fee_table - .per_byte_storage_cost() - .checked_mul(template_base_bytes) - .ok_or_else(|| { - RuntimeModuleError::Overflow("Overflow calculating template base storage cost".to_string()) - })? / - self.fee_table.storage_cost_divisor(); - let template_publish_cost = template_base_cost - .checked_add(template_charge) - .ok_or_else(|| RuntimeModuleError::Overflow("Overflow calculating template publish cost".to_string()))?; - if template_publish_cost > 0 { - track.add_fee_charge(FeeSource::TemplatePublish, template_publish_cost); - } - - // The receipt occupies a slot of its own — it is always newly created, since it is addressed - // by a transaction id that can only be finalized once. - let new_substate_count = track - .count_newly_created_substates() - .map_err(|e| RuntimeModuleError::Runtime(e.to_string()))? - .saturating_add(1); - let create_cost = (new_substate_count as u64) - .checked_mul(self.fee_table.per_substate_create_cost()) - .ok_or_else(|| RuntimeModuleError::Overflow("Overflow calculating substate create cost".to_string()))?; - track.add_fee_charge(FeeSource::SubstateCreate, create_cost); - - // WASM execution: charge once against the transaction's accumulated points so the divisor - // rounds against the total. Per-call rounding would let a transaction split work into - // sub-divisor chunks and pay zero for any single one (each `points/divisor` is `0`), even - // though the summed work is non-trivial. - let units = track.accumulated_wasm_points() / self.fee_table.wasm_points_cost_divisor(); - let wasm_cost = units - .checked_mul(self.fee_table.per_wasm_point_cost()) - .ok_or_else(|| RuntimeModuleError::Overflow("Overflow calculating WASM execution cost".to_string()))?; - track.add_fee_charge(FeeSource::WasmExecution, wasm_cost); - - // Native verification is priced in the same points and charged at the same rate, under its - // own source so the breakdown distinguishes crypto verification from template execution. - let native_units = track.accumulated_native_points() / self.fee_table.wasm_points_cost_divisor(); - let native_cost = native_units - .checked_mul(self.fee_table.per_wasm_point_cost()) - .ok_or_else(|| RuntimeModuleError::Overflow("Overflow calculating native execution cost".to_string()))?; - track.add_fee_charge(FeeSource::NativeExecution, native_cost); - - // Exhaust burn: charged on top of the execution fee accrued so far, so leaders receive the execution fee in - // full and the burn amount is destroyed separately. The rate is seeded onto the fee state at execution time - // for the execution epoch. - let burn = calculate_burn_amount(track.total_fee_charges(), track.fee_burn_rate_bps())?; - track.add_fee_charge(FeeSource::ExhaustBurn, burn); + self.charge_finalization_fees(&mut track.chargeable_state()) + } - Ok(()) + fn on_before_persist(&self, state: &mut ChargeableState<'_, TStore>) -> Result<(), RuntimeModuleError> { + self.charge_finalization_fees(state) } fn on_runtime_event( diff --git a/crates/engine/src/fees/fee_table.rs b/crates/engine/src/fees/fee_table.rs index 0e74ef3316..4a41a667cc 100644 --- a/crates/engine/src/fees/fee_table.rs +++ b/crates/engine/src/fees/fee_table.rs @@ -1,6 +1,35 @@ // Copyright 2023 The Tari Project // SPDX-License-Identifier: BSD-3-Clause +use tari_ootle_transaction::LITERAL_BYTE_DIVISOR; + +/// The narrowest `max_fee` a run can meter at is `1`, which a dry run clamps to. `Amount` encodes as +/// a native CBOR integer, so that is a single byte. +const NARROWEST_FEE_LITERAL_BYTES: u64 = 1; +/// The widest `max_fee` a submission can carry is `u64::MAX` — `FeeState::add_fee_payment_checked` +/// rejects a payment above it — which encodes as a leading byte over eight payload bytes. +const WIDEST_FEE_LITERAL_BYTES: u64 = 9; + +/// The span of encoded widths a `max_fee` can take. +const FEE_LITERAL_BYTE_SPAN: u64 = WIDEST_FEE_LITERAL_BYTES - NARROWEST_FEE_LITERAL_BYTES; + +/// The most transaction-weight units the `max_fee` literal's encoded width can move a transaction. +/// +/// `calc_args_weight` prices an instruction's literals as `literal_bytes / LITERAL_BYTE_DIVISOR`, so +/// widening the amount by `d` bytes lifts that quotient by at most `ceil(d / LITERAL_BYTE_DIVISOR)`, +/// attained when the narrow encoding sits just above a multiple of the divisor. +const MAX_FEE_LITERAL_WEIGHT_DRIFT: u64 = FEE_LITERAL_BYTE_SPAN.div_ceil(LITERAL_BYTE_DIVISOR); + +/// The most bytes `max_fee` can move the storage tally. +/// +/// The tally byte-counts the fee vault as it stands when finalization charges, which is before the +/// unspent payment is returned — so it counts `balance - max_fee`, whose width moves with `max_fee` +/// as surely as the literal's does. A payment must fit in `u64` +/// (`FeeState::add_fee_payment_checked`), so `max_fee` can only shift that residual across encoding +/// widths while the balance is itself in `u64` range; above it, every payment leaves a residual of +/// the same width. The span is therefore the literal's. +const MAX_FEE_RESIDUAL_BYTE_DRIFT: u64 = FEE_LITERAL_BYTE_SPAN; + #[derive(Debug, Clone)] pub struct FeeTable { pub per_transaction_weight_cost: u64, @@ -67,6 +96,28 @@ impl FeeTable { } } + /// The allowance a dry-run estimate must carry so that a real run of the same transaction at a + /// different `max_fee` cannot cost more than the estimate. + /// + /// Two charges read `max_fee` back, and they move in opposite directions, so both are counted. + /// The weight term is the fee literal's width drift at this table's weight cost. The storage + /// term is the fee vault's residual width drift at this table's byte cost, plus the rounding + /// boundary the storage divisor can land on. The burn re-multiplies their sum, being taken over + /// the running total, and the trailing `1` covers its own rounding boundary. + /// + /// `tari_engine_types::fees::FEE_ESTIMATE_ALLOWANCE` is the value this yields, restated where + /// `FeeReceipt::required_fees` can reach it. + pub const fn fee_estimate_allowance(&self, burn_rate_bps: u16) -> u64 { + let weight_drift = MAX_FEE_LITERAL_WEIGHT_DRIFT.saturating_mul(self.per_transaction_weight_cost); + let storage_drift = MAX_FEE_RESIDUAL_BYTE_DRIFT.saturating_mul(self.per_byte_storage_cost) / + non_zero(self.storage_cost_divisor) + + 1; + let drift = weight_drift.saturating_add(storage_drift); + drift + .saturating_add(drift.saturating_mul(burn_rate_bps as u64) / 10_000) + .saturating_add(1) + } + pub fn per_transaction_weight_cost(&self) -> u64 { self.per_transaction_weight_cost } @@ -124,8 +175,8 @@ impl FeeTable { } } -fn non_zero(divisor: u64) -> u64 { - divisor.max(1) +const fn non_zero(divisor: u64) -> u64 { + if divisor == 0 { 1 } else { divisor } } /// The WASM-execution fee rate extracted from a [`FeeTable`], plus the conversion from fees paid @@ -168,3 +219,65 @@ impl WasmMeteringRate { Some(u64::try_from(funded).unwrap_or(u64::MAX)) } } + +#[cfg(test)] +mod tests { + use tari_engine_types::fees::{FEE_ESTIMATE_ALLOWANCE, MAX_EXHAUST_BURN_RATE_BPS}; + use tari_template_lib::types::Amount; + + use super::*; + + /// The drift is computed from constant widths, so this holds the encoder to them. + #[test] + fn the_fee_literal_widths_match_the_encoder() { + assert_eq!(minicbor::len(Amount::from(1u64)) as u64, NARROWEST_FEE_LITERAL_BYTES); + assert_eq!(minicbor::len(Amount::from(u64::MAX)) as u64, WIDEST_FEE_LITERAL_BYTES); + // The width the instruction actually carries, which is what `calc_args_weight` prices. + assert_eq!( + tari_bor::encoded_len_via_writer(&Amount::from(u64::MAX)).unwrap() as u64, + WIDEST_FEE_LITERAL_BYTES + ); + assert_eq!(MAX_FEE_LITERAL_WEIGHT_DRIFT, 3); + } + + /// A table priced like the shipped ones: one microtari per weight unit and per stored byte. + fn shipped_like() -> FeeTable { + let mut table = FeeTable::zero_rated(); + table.per_transaction_weight_cost = 1; + table.per_byte_storage_cost = 1; + table.storage_cost_divisor = 1; + table + } + + #[test] + fn the_allowance_covers_both_directions_max_fee_moves() { + let mut table = shipped_like(); + + // Weight drift (3 units) + storage drift (8 bytes) + the storage rounding boundary. + assert_eq!(table.fee_estimate_allowance(0), 3 + 8 + 1 + 1); + // A full burn doubles all of it. + assert_eq!(table.fee_estimate_allowance(MAX_EXHAUST_BURN_RATE_BPS), 12 * 2 + 1); + + // The storage divisor scales the residual term down. + table.storage_cost_divisor = 4; + assert_eq!(table.fee_estimate_allowance(0), 3 + 8 / 4 + 1 + 1); + + // Each rate multiplies the whole drift. + table.storage_cost_divisor = 1; + table.per_transaction_weight_cost = 2; + assert_eq!(table.fee_estimate_allowance(0), 6 + 8 + 1 + 1); + table.per_byte_storage_cost = 2; + assert_eq!(table.fee_estimate_allowance(0), 6 + 16 + 1 + 1); + } + + /// `FEE_ESTIMATE_ALLOWANCE` is stated in `tari_engine_types`, which cannot see a `FeeTable`. It + /// holds for a table priced like the shipped ones at the highest burn the estimate is derived + /// against. + #[test] + fn the_restated_allowance_matches_the_derivation() { + assert_eq!( + FEE_ESTIMATE_ALLOWANCE, + shipped_like().fee_estimate_allowance(MAX_EXHAUST_BURN_RATE_BPS) + ); + } +} diff --git a/crates/engine/src/runtime/fee_state.rs b/crates/engine/src/runtime/fee_state.rs index b310d7d4bc..23d79fe717 100644 --- a/crates/engine/src/runtime/fee_state.rs +++ b/crates/engine/src/runtime/fee_state.rs @@ -93,6 +93,12 @@ impl FeeState { self.fee_charges.add(source, amount) } + /// Replaces the charge for `source`. Used by the fee module to recompute the finalization + /// charges once the state that will actually be persisted is known. + pub fn set_charge(&mut self, source: FeeSource, amount: u64) { + self.fee_charges.set(source, amount) + } + pub fn accumulate_wasm_points(&mut self, points: u64) { self.accumulated_wasm_points = self.accumulated_wasm_points.saturating_add(points); } diff --git a/crates/engine/src/runtime/impl.rs b/crates/engine/src/runtime/impl.rs index dea4bf9512..ac36a520b6 100644 --- a/crates/engine/src/runtime/impl.rs +++ b/crates/engine/src/runtime/impl.rs @@ -168,7 +168,7 @@ use crate::{ locking::{LockError, LockedSubstate}, pay_fee::PayFee, scope::PushCallFrame, - tracker::StateTracker, + tracker::{FinalizedState, StateTracker}, }, state_store::StateReader, template::LoadedTemplate, @@ -279,6 +279,34 @@ impl) -> Result { + self.invoke_modules_on_before_finalize()?; + let mut finalized = self.tracker.select_finalized_state(failure)?; + // A commit persists the very state the first pass charged against, so charging it again + // would recompute the same numbers from the same inputs. Only a fee-intent commit swaps the + // state out from under those charges, and only it needs them redone. + if !finalized.outcome().is_commit() { + self.invoke_modules_on_before_persist(&mut finalized)?; + } + self.tracker.finalize(finalized) + } + + fn invoke_modules_on_before_persist(&mut self, finalized: &mut FinalizedState) -> Result<(), RuntimeError> { + for module in self.modules.iter() { + module.on_before_persist(&mut finalized.chargeable_state())?; + } + Ok(()) + } + fn invoke_modules_on_runtime_event(&mut self, event: RuntimeEvent) -> Result<(), RuntimeError> { for module in self.modules.iter() { module.on_runtime_event(&mut self.tracker, &event)?; @@ -3454,16 +3482,12 @@ where fn finalize(&mut self) -> Result { self.invoke_modules_on_runtime_call("finalize")?; - // If the fee module is present, this will add substate storage fees - self.invoke_modules_on_before_finalize()?; - self.tracker.finalize(None) + self.finalize_with(None) } fn finalize_failure(&mut self, reason: RejectReason) -> Result { self.invoke_modules_on_runtime_call("finalize_failure")?; - // If the fee module is present, this will add substate storage fees - self.invoke_modules_on_before_finalize()?; - self.tracker.finalize(Some(reason)) + self.finalize_with(Some(reason)) } fn validate_finalized(&self) -> Result<(), RuntimeError> { diff --git a/crates/engine/src/runtime/mod.rs b/crates/engine/src/runtime/mod.rs index 91179afd3d..4df2229414 100644 --- a/crates/engine/src/runtime/mod.rs +++ b/crates/engine/src/runtime/mod.rs @@ -109,7 +109,8 @@ use tari_template_lib::{ stealth::StealthTransferStatement, }, }; -pub use tracker::StateTracker; +pub use tracker::{FinalizedState, StateTracker}; +pub use working_state::ChargeableState; use crate::runtime::{locking::LockedSubstate, scope::PushCallFrame}; diff --git a/crates/engine/src/runtime/module.rs b/crates/engine/src/runtime/module.rs index 678074e99f..575dfde374 100644 --- a/crates/engine/src/runtime/module.rs +++ b/crates/engine/src/runtime/module.rs @@ -3,7 +3,7 @@ use tari_template_lib::types::TemplateAddress; -use crate::runtime::StateTracker; +use crate::runtime::{ChargeableState, StateTracker}; pub trait RuntimeModule: Send + Sync { fn on_initialize(&self, _track: &mut StateTracker) -> Result<(), RuntimeModuleError> { @@ -32,10 +32,21 @@ pub trait RuntimeModule: Send + Sync { Ok(()) } + /// Invoked at the start of finalization, against the working state — before it is known + /// whether the transaction commits or falls back to a fee-intent commit. Charges added here + /// decide that outcome: they are what the paid-in-full check sees. fn on_before_finalize(&self, _track: &mut StateTracker) -> Result<(), RuntimeModuleError> { Ok(()) } + /// Invoked once the state that finalization will persist has been chosen, and before its fees + /// are settled. On a fee-intent commit that state is the fee checkpoint, not the working state + /// [`Self::on_before_finalize`] saw, so any charge that is a function of what gets persisted + /// must be recomputed here against `state`. + fn on_before_persist(&self, _state: &mut ChargeableState<'_, TStore>) -> Result<(), RuntimeModuleError> { + Ok(()) + } + fn on_runtime_event( &self, _track: &mut StateTracker, diff --git a/crates/engine/src/runtime/tracker.rs b/crates/engine/src/runtime/tracker.rs index 5c2c18cd7f..745680b1b4 100644 --- a/crates/engine/src/runtime/tracker.rs +++ b/crates/engine/src/runtime/tracker.rs @@ -20,13 +20,12 @@ // WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE // USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -use indexmap::IndexMap; use log::*; use tari_engine_types::{ commit_result::{FinalizeResult, RejectReason, TransactionResult}, component::{Component, ComponentBody, ComponentHeader}, events::Event, - fees::FeeSource, + fees::{FeeReceipt, FeeSource}, indexed_value::{IndexedValue, IndexedWellKnownTypes}, limits, lock::LockFlag, @@ -56,7 +55,7 @@ use crate::{ error::ArgumentValidationError, locking::LockedSubstate, scope::{CallScope, PushCallFrame}, - working_state::WorkingState, + working_state::{ChargeableState, WorkingState}, workspace::Workspace, }, state_store::StateReader, @@ -64,6 +63,28 @@ use crate::{ const LOG_TARGET: &str = "tari::ootle::engine::runtime::state_tracker"; +/// The state finalization will persist, detached from the tracker and awaiting fee settlement. +#[derive(Debug)] +pub struct FinalizedState { + state: WorkingState, + outcome: FinalizeOutcome, + /// Why the main intent was rejected, when this is a fee-intent commit. + reason: Option, + /// What committing the whole transaction was priced at, captured before the second charging + /// pass re-derives the charges over whichever state was chosen. + total_fees_required: u64, +} + +impl FinalizedState { + pub fn chargeable_state(&mut self) -> ChargeableState<'_, TStore> { + ChargeableState::new(&mut self.state) + } + + pub fn outcome(&self) -> FinalizeOutcome { + self.outcome + } +} + #[derive(Debug)] pub struct StateTracker { working_state: Option>, @@ -349,7 +370,20 @@ impl StateTracker { Ok(()) } - pub fn finalize(&mut self, failure: Option) -> Result { + /// Chooses the state that finalization will persist. + /// + /// A transaction that failed — either explicitly, or by not covering the fees charged against + /// the working state — commits only its fee intent, so the state to persist is the fee + /// checkpoint rather than the state execution ended on. The fee state carries over either way, + /// so the work done before the failure is still charged for. + /// + /// Split out from [`Self::finalize`] so the runtime can run its modules against the chosen state + /// before its fees are settled. + pub fn select_finalized_state( + &mut self, + failure: Option, + ) -> Result, RuntimeError> { + let total_fees_required = self.read_with(|state| state.fee_state().total_charges()); let failure = failure.or_else(|| { self.read_with(|state| { let fee_state = state.fee_state(); @@ -365,75 +399,112 @@ impl StateTracker { }) }); - if let Some(reason) = failure { - let mut checkpoint_state = self.take_fee_checkpoint().ok_or(RuntimeError::NoFeeCheckpoint)?; - // Preserve fee state across resets so that we can charge for fees incurred during execution before the - // failure - self.read_with(|state| { - // Fee state in `state` includes the payments and charges from the fee transaction - *checkpoint_state.fee_state_mut() = state.fee_state().clone(); + let Some(reason) = failure else { + // Finalise will always reset the state + return Ok(FinalizedState { + state: self.take_working_state()?, + outcome: FinalizeOutcome::Commit, + reason: None, + total_fees_required, }); - let mut substates_to_persist = checkpoint_state.take_mutated_substates(); - // Process fees and refunds based on the fee checkpoint state - let fee_receipt = checkpoint_state.finalize_fees_and_refunds(&mut substates_to_persist)?; - - let downed_utxos = checkpoint_state.take_downed_utxos(); - let downed_confidential_outputs = checkpoint_state.take_downed_confidential_outputs(); - let fee_withdrawals = checkpoint_state.take_validator_fee_withdrawals(); - - let mut diff = checkpoint_state.generate_substate_diff( - substates_to_persist, - downed_utxos, - downed_confidential_outputs, - fee_withdrawals, - )?; - let transaction_receipt = checkpoint_state.finalize_transaction_receipt( - FinalizeOutcome::FeeIntentCommit, - &diff, - fee_receipt.clone(), - )?; - diff.up( - SubstateId::TransactionReceipt(checkpoint_state.transaction_hash().into()), - Substate::new(0, transaction_receipt), - ); + }; + + let mut checkpoint_state = self.take_fee_checkpoint().ok_or(RuntimeError::NoFeeCheckpoint)?; + // Preserve fee state across resets so that we can charge for fees incurred during execution before the + // failure + self.read_with(|state| { + // Fee state in `state` includes the payments and charges from the fee transaction + *checkpoint_state.fee_state_mut() = state.fee_state().clone(); + }); + + Ok(FinalizedState { + state: checkpoint_state, + outcome: FinalizeOutcome::FeeIntentCommit, + reason: Some(reason), + total_fees_required, + }) + } + + pub fn finalize(&mut self, finalized: FinalizedState) -> Result { + let FinalizedState { + mut state, + outcome, + reason, + total_fees_required, + } = finalized; + + // Committing costs whatever the charges now say, and they have just been recomputed over + // this exact state. A payment that cannot cover that commits nothing: a fee-intent commit + // persists real state — substates, and a receipt carrying every event the intent emitted — + // and there is no shallower checkpoint left to fall back to. Letting it through is what + // would make an underfunded fee intent a way to write state for free. + // + // The commit path cannot fail here: its charges were tested against payments to reach it, + // and recomputing them over the same state does not change them. + let fee_state = state.fee_state(); + if !fee_state.is_dry_run() && !fee_state.is_paid_in_full() { + // Whatever rejected the main intent is why the transaction failed and stays the reason + // reported; the shortfall below only decides that not even the fee intent survives it. + let reason = reason.unwrap_or_else(|| { + RejectReason::InsufficientFeesPaid(format!( + "Committing requires {} but {} paid", + fee_state.total_charges(), + fee_state.total_payments() + )) + }); + + // Nothing is persisted and nothing is taken, but what committing *would* have cost is + // the number the payer needs to retry with — and when the main intent failed for its own + // reason, the breakdown is the only signal that fees were the second problem. Report the + // charges as metered, against no payment. + let fee_receipt = FeeReceipt::builder() + .with_cost_breakdown(state.fee_state_mut().take_fee_charges()) + .build(); return Ok(FinalizeResult::new( - checkpoint_state.transaction_hash(), - checkpoint_state.take_logs(), - checkpoint_state.take_events(), - TransactionResult::AcceptFeeRejectRest(diff, reason), + state.transaction_hash(), + state.take_logs(), + // Events describe state changes, and none of them happened. + Vec::new(), + TransactionResult::Reject(reason), fee_receipt, - )); + ) + .with_total_fees_required(total_fees_required)); } - // Finalise will always reset the state - let mut state = self.take_working_state()?; // Resolve the transfers to the fee pool resource and vault refunds let mut substates_to_persist = state.take_mutated_substates(); let fee_receipt = state.finalize_fees_and_refunds(&mut substates_to_persist)?; + let downed_utxos = state.take_downed_utxos(); let downed_confidential_outputs = state.take_downed_confidential_outputs(); let fee_withdrawals = state.take_validator_fee_withdrawals(); + let mut diff = state.generate_substate_diff( substates_to_persist, downed_utxos, downed_confidential_outputs, fee_withdrawals, )?; - let transaction_receipt = - state.finalize_transaction_receipt(FinalizeOutcome::Commit, &diff, fee_receipt.clone())?; + let transaction_receipt = state.finalize_transaction_receipt(outcome, &diff, fee_receipt.clone())?; diff.up( SubstateId::TransactionReceipt(state.transaction_hash().into()), Substate::new(0, transaction_receipt), ); + let result = match reason { + Some(reason) => TransactionResult::AcceptFeeRejectRest(diff, reason), + None => TransactionResult::Accept(diff), + }; + Ok(FinalizeResult::new( state.transaction_hash(), state.take_logs(), state.take_events(), - TransactionResult::Accept(diff), + result, fee_receipt, - )) + ) + .with_total_fees_required(total_fees_required)) } fn take_fee_checkpoint(&mut self) -> Option> { @@ -447,31 +518,16 @@ impl StateTracker { }) } - pub fn with_substates_to_persist) -> R, R>(&mut self, mut f: F) -> R { - self.write_with(|state| f(state.mutated_substates())) - } - - /// The storage footprint of the transaction receipt, which finalization persists in addition to - /// the substates seen by [`Self::with_substates_to_persist`]. - pub fn transaction_receipt_size(&mut self) -> Result { - self.write_with(|state| state.transaction_receipt_size()) - } - - /// Counts substates in the to-persist set that did not previously exist in the state store. - /// Used by the fee module to charge a slot-allocation premium on top of per-byte storage. - pub fn count_newly_created_substates(&self) -> Result { - self.read_with(|state| { - let store = state.store(); - let mut count = 0; - for id in store.mutated_substates().keys() { - match store.get_unmodified_substate(id) { - Ok(_) => {}, - Err(RuntimeError::SubstateNotFound { .. }) => count += 1, - Err(e) => return Err(e), - } - } - Ok(count) - }) + /// The working state, as a module may charge against it. + /// + /// The fee module uses this to compute its finalization charges before the state to persist has + /// been chosen; once it has been, the same computation runs against that state directly. + pub fn chargeable_state(&mut self) -> ChargeableState<'_, TStore> { + ChargeableState::new( + self.working_state + .as_mut() + .expect("BUG: chargeable_state called after finalize consumed working state"), + ) } pub fn are_fees_paid_in_full(&self) -> bool { diff --git a/crates/engine/src/runtime/working_state.rs b/crates/engine/src/runtime/working_state.rs index 3cec888c8b..4e88cc68a2 100644 --- a/crates/engine/src/runtime/working_state.rs +++ b/crates/engine/src/runtime/working_state.rs @@ -93,6 +93,47 @@ use crate::{ const LOG_TARGET: &str = "dan::engine::runtime::working_state"; +/// The view of a transaction's state that a runtime module is given when charging for it. +/// +/// Exposes what a charge computed from the state needs to read — what will be persisted, and the fee +/// state to record against — and nothing that would let a module alter the state it is pricing. It +/// also keeps [`WorkingState`] itself, which is a large internal surface, out of the public module +/// API that [`super::RuntimeModule`] defines. +pub struct ChargeableState<'a, TStore> { + state: &'a mut WorkingState, +} + +impl<'a, TStore> ChargeableState<'a, TStore> { + pub(super) fn new(state: &'a mut WorkingState) -> Self { + Self { state } + } +} + +impl ChargeableState<'_, TStore> { + /// The substates this state will persist, keyed by id. + pub fn substates_to_persist(&mut self) -> &IndexMap { + self.state.mutated_substates() + } + + /// How many of those substates did not previously exist in the state store. + pub fn count_newly_created_substates(&self) -> Result { + self.state.count_newly_created_substates() + } + + /// The storage footprint of the transaction receipt this state will finalize into. + pub fn transaction_receipt_size(&mut self) -> Result { + self.state.transaction_receipt_size() + } + + pub fn fee_state(&self) -> &FeeState { + self.state.fee_state() + } + + pub fn fee_state_mut(&mut self) -> &mut FeeState { + self.state.fee_state_mut() + } +} + #[derive(Debug, Clone)] pub(super) struct WorkingState { transaction_hash: Hash32, @@ -1316,6 +1357,20 @@ impl WorkingState { self.last_instruction_output = Some(output); } + /// Counts substates in the to-persist set that did not previously exist in the state store. + /// Used by the fee module to charge a slot-allocation premium on top of per-byte storage. + pub fn count_newly_created_substates(&self) -> Result { + let mut count = 0; + for id in self.store.mutated_substates().keys() { + match self.store.get_unmodified_substate(id) { + Ok(_) => {}, + Err(RuntimeError::SubstateNotFound { .. }) => count += 1, + Err(e) => return Err(e), + } + } + Ok(count) + } + /// The storage footprint of the transaction receipt this state will finalize into. /// /// The receipt is persisted like any other substate but is only built once fees are settled, so diff --git a/crates/engine/tests/fees.rs b/crates/engine/tests/fees.rs index 44d4ea687e..e9c3be16c4 100644 --- a/crates/engine/tests/fees.rs +++ b/crates/engine/tests/fees.rs @@ -1,10 +1,24 @@ // Copyright 2023 The Tari Project // SPDX-License-Identifier: BSD-3-Clause -use tari_engine_types::{commit_result::RejectReason, fees::FeeSource}; +use tari_crypto::ristretto::RistrettoSecretKey; +use tari_engine_types::{ + commit_result::{RejectReason, TransactionResult}, + fees::{FeeReceipt, FeeSource}, +}; use tari_ootle_transaction::{Epoch, Transaction, args}; -use tari_template_lib::types::{Amount, ComponentAddress, constants::STEALTH_TARI_RESOURCE_ADDRESS}; -use tari_template_test_tooling::{TemplateTest, support::assert_error::assert_reject_reason, xtr_faucet_component}; +use tari_template_lib::types::{ + Amount, + ComponentAddress, + NonFungibleAddress, + constants::STEALTH_TARI_RESOURCE_ADDRESS, +}; +use tari_template_test_tooling::{ + TemplateTest, + compile::compile_template, + support::assert_error::assert_reject_reason, + xtr_faucet_component, +}; const CRATE_PATH: &str = env!("CARGO_MANIFEST_DIR"); const TEMPLATE_PATHS: [&str; 1] = ["tests/templates/state"]; @@ -29,7 +43,7 @@ fn deducts_fees_from_payments_and_refunds_the_rest() { test.disable_fees(); // Check difference was refunded - let payment = result.finalize.fee_receipt; + let payment = result.finalize.fee_receipt.clone(); let new_balance = test .read_only_state_store() .get_vaults_for_account(account) @@ -196,6 +210,103 @@ fn failed_fee_transaction() { assert_eq!(new_balance, initial_balance); } +/// Runs a transaction that creates several components, paying `fee` towards it, and returns the +/// storage fee it was charged along with whether the main intent committed. +fn storage_charged_for_creating_components(fee: u64) -> (u64, bool) { + let mut test = TemplateTest::new(CRATE_PATH, TEMPLATE_PATHS); + let (account, owner_token, private_key) = test.create_funded_account(); + test.enable_fees(); + + let state_template = test.get_template_address("State"); + let mut builder = test.transaction().pay_fee_from_component(account, Amount::from(fee)); + for _ in 0..5 { + builder = builder.call_function(state_template, "new", args![]); + } + + let result = test.execute_expect_commit(builder.build_and_seal(&private_key), vec![owner_token]); + let committed = result.finalize.result.is_accept(); + ( + result.finalize.fee_receipt.fee_breakdown().get(FeeSource::Storage), + committed, + ) +} + +/// A transaction that cannot pay for its main intent commits only its fee intent, so the storage it +/// is charged for is the fee checkpoint's — not that of the state it built and abandoned. +#[test] +fn a_fee_intent_commit_is_not_charged_for_the_state_it_abandons() { + let (storage_rejected, committed) = storage_charged_for_creating_components(1_000); + assert!(!committed, "expected the main intent to be rejected"); + + let (storage_committed, committed) = storage_charged_for_creating_components(100_000); + assert!(committed, "expected the main intent to commit"); + + // The components only exist in the committed run, so only it pays for them. Charging the + // rejected run over the working state instead of the checkpoint would put the two within a few + // bytes of each other. + assert!( + storage_rejected * 2 < storage_committed, + "rejected run charged {storage_rejected} storage against the committed run's {storage_committed}" + ); +} + +/// A fee-intent commit persists real state, so it happens only when the payment covers what that +/// state costs. When it does not, nothing commits — there is no shallower checkpoint to fall back +/// to, and committing anyway would be a way to write state for free. +#[test] +fn an_unaffordable_fee_intent_commits_nothing() { + let mut test = TemplateTest::new(CRATE_PATH, TEMPLATE_PATHS); + let (account, owner_token, private_key) = test.create_empty_account(); + test.enable_fees(); + + // Enough for the fee intent's execution, nowhere near the state it writes. + const FEE: u64 = 100; + + let result = test + .try_execute( + test.transaction() + .with_fee_instructions_builder(|builder| { + builder + // Writes the account's vault and the faucet's, then pays a fraction of what + // storing them costs. + .call_method(xtr_faucet_component(), "take", args![account]) + .call_method(account, "pay_fee", args![FEE]) + }) + .build_and_seal(&private_key), + vec![owner_token], + ) + .unwrap(); + + assert!( + matches!( + result.finalize.result, + TransactionResult::Reject(RejectReason::InsufficientFeesPaid(_)) + ), + "actual result: {:?}", + result.finalize.result + ); + + // Nothing was written, so nothing was taken. + assert_eq!(result.finalize.fee_receipt.total_fees_paid(), 0); + + // What committing would have cost is still reported: it is the number the payer has to raise + // their fee to, and on a rejection there is nowhere else to read it from. + let charged = result.finalize.fee_receipt.total_fees_charged(); + assert!(charged > FEE, "expected the metered charges, got {charged}"); + assert!(result.finalize.fee_receipt.required_fees() > FEE); + + let balance = test + .read_only_state_store() + .get_vaults_for_account(account) + .unwrap() + .get(&STEALTH_TARI_RESOURCE_ADDRESS) + .map(|v| v.balance()); + assert!( + balance.is_none() || balance == Some(Amount::zero()), + "account was funded: {balance:?}" + ); +} + #[test] fn fail_partial_paid_fees() { let mut test = TemplateTest::new(CRATE_PATH, TEMPLATE_PATHS); @@ -205,10 +316,10 @@ fn fail_partial_paid_fees() { let orig_balance: Amount = test.call_method(account, "balance", args![STEALTH_TARI_RESOURCE_ADDRESS], vec![]); test.enable_fees(); - // Must cover the fee section's own cost (so the fee instructions succeed) yet stay smaller - // than the full transaction's fee, so the main instructions exhaust the compute the payment - // funds and trap. - const FEE_PAID: u64 = 100; + // Must cover what committing the fee intent costs — otherwise nothing commits at all — yet stay + // smaller than the full transaction's fee, so the main instructions exhaust the compute the + // payment funds and trap. + const FEE_PAID: u64 = 1000; let result = test.execute_expect_commit( Transaction::builder_localnet(Epoch(1)) @@ -227,25 +338,33 @@ fn fail_partial_paid_fees() { vec![owner_token, owner_token2], ); - let total_fees = result.finalize.fee_receipt.fee_breakdown().get_total(); - // The fee charged exceeds the fee paid, so the transaction is rejected. Execution stops as soon - // as the fees are determined insufficient, so the *execution* charges stay close to what was - // paid. Storage is excluded: it is charged at finalization for the substates and the receipt - // that a fee-intent commit still persists, whenever execution gave up. - let storage = result.finalize.fee_receipt.fee_breakdown().get(FeeSource::Storage); - assert!( - total_fees > FEE_PAID && total_fees - storage < FEE_PAID * 3, - "total fees: {total_fees}, of which storage: {storage}" - ); + // The main instructions cost more than was paid, so they are rejected and only the fee intent + // commits. let reason = result.expect_failure(); assert!( - matches!(reason, RejectReason::ExecutionFailure(msg) if msg.contains("Insufficient fees")), + matches!(reason, RejectReason::InsufficientFeesPaid(_)), "actual reason: {reason}" ); - // Check that the fee paid was deducted - let payment = result.finalize.fee_receipt; - assert!(!payment.is_paid_in_full()); + // What is charged is what the fee intent persisted, not what the abandoned instructions would + // have. That is below the payment, so the commit is affordable and the rest is refunded. + let payment = &result.finalize.fee_receipt; + let total_fees = payment.fee_breakdown().get_total(); + assert!( + total_fees < FEE_PAID, + "total fees {total_fees} exceeds the {FEE_PAID} paid" + ); + assert!(payment.is_paid_in_full()); + assert_eq!(payment.total_refunded(), FEE_PAID - total_fees); + + // The fee intent's own cost is below what was paid, so it cannot tell a resubmission what to + // clear. The result keeps the figure the main intent was rejected for. + assert!( + result.finalize.total_fees_required > FEE_PAID, + "the transaction was rejected for underpayment, so what it required must exceed the {FEE_PAID} paid" + ); + assert!(result.finalize.required_fees() > result.finalize.total_fees_required); + let new_balance = test .read_only_state_store() .get_vaults_for_account(account) @@ -253,7 +372,7 @@ fn fail_partial_paid_fees() { .get(&STEALTH_TARI_RESOURCE_ADDRESS) .unwrap() .balance(); - assert_eq!(new_balance, orig_balance - Amount::from(FEE_PAID)); + assert_eq!(new_balance, orig_balance - Amount::from(total_fees)); } #[test] @@ -301,9 +420,8 @@ fn fail_pay_less_fees_than_fee_transaction() { .call_method( account, "pay_fee".to_string(), - // Lands between the fee-instructions cost and the full transaction - // cost, so the fee section is accepted while the rest fails for - // InsufficientFeesPaid. + // Less than the fee instructions themselves cost to commit, so not + // even the fee intent survives. args![150], ) @@ -322,13 +440,15 @@ fn fail_pay_less_fees_than_fee_transaction() { test.disable_fees(); - let (diff, reason) = result.expect_fee_accept_transaction_reject(); - assert_reject_reason(reason, RejectReason::InsufficientFeesPaid(String::new())); - - let (_, s) = diff.up_iter().find(|(id, _)| id.is_vault()).expect("Account not found"); - assert_eq!( - s.substate_value().as_vault().unwrap().balance(), - orig_balance - Amount::from(150u64) + // The payment does not cover the state the fee intent writes, so nothing commits at all. + assert!( + matches!(result.finalize.result, TransactionResult::Reject(_)), + "actual result: {:?}", + result.finalize.result + ); + assert_reject_reason( + result.expect_failure(), + RejectReason::InsufficientFeesPaid(String::new()), ); // Fee was not deducted @@ -434,8 +554,8 @@ fn dangling_bucket_pay_fees() { test.enable_fees(); let result = test.execute_and_commit_on_success( - Transaction::builder_localnet(Epoch(1)) - .pay_fee_from_component(account, Amount::from(500u64)) + test.transaction() + .pay_fee_from_component(account, Amount::from(3000u64)) .call_method(account, "withdraw", args![STEALTH_TARI_RESOURCE_ADDRESS, 10]) .put_last_instruction_output_on_workspace("dangling_bucket") .build_and_seal(&private_key), @@ -463,6 +583,368 @@ fn dangling_bucket_pay_fees() { assert_eq!(orig_balance - new_balance, payment.total_fees_paid()); } +// A submitted `max_fee` is itself an input to what the transaction costs, so a dry run metered at +// one `max_fee` does not perfectly predict a real run at another. There are three quantities the +// cost could read `max_fee` through, and what a caller can rely on is which of them it does: +// +// - the encoded *width* of the fee literal, through `calc_args_weight`. Live, and the only term that can make a real +// run cost more than the dry run said. +// - the fee amount's *digit count*, through the `std.vault.pay_fee` event the persisted receipt carries. Neutralized — +// priced at its widest, so it buys nothing. +// - the *residual vault balance*'s width, since the finalization charges run before refunds. Live, and opposed to the +// first: a wider `max_fee` leaves a narrower residual. Nothing narrows the `max_fee` a dry run meters at, so a +// submission can move either term in either direction and the allowance has to cover both. +// +// `try_execute` never commits, so every run below starts from identical state and the whole delta +// is attributable to `max_fee`. Each test varies one of the three quantities and holds the other +// two fixed, so a failure names its mechanism rather than a total. + +/// The balance `create_funded_account` starts an account with, so the residual vault balance the +/// byte counter sees (`FUNDED - max_fee`) is predictable. +const FUNDED: u64 = 1_000_000_000; + +/// `calc_args_weight` prices an instruction's literal args by their encoded bytes, so the weight +/// charge reads `max_fee` through the width of its encoding. +fn amount_len(value: u64) -> u64 { + tari_bor::encode(&Amount::from(value)).unwrap().len() as u64 +} + +/// The `std.vault.pay_fee` event records the amount as a decimal string, so the receipt's size — and +/// with it the storage charge — would read `max_fee` through its digit count if the amount were not +/// priced at its widest. +fn digit_count(value: u64) -> u64 { + value.to_string().len() as u64 +} + +/// Meters `build(max_fee)` once per `max_fee`, from identical state each time. +fn meter_across_max_fees( + test: &mut TemplateTest, + max_fees: &[u64], + proofs: &[NonFungibleAddress], + build: impl Fn(u64) -> Transaction, +) -> Vec { + max_fees + .iter() + .map(|&max_fee| { + let result = test.try_execute(build(max_fee), proofs.to_vec()).unwrap(); + // A run that does not commit meters a different execution — only the fee intent — and + // would not be comparable with the others. + assert!( + matches!(result.finalize.result, TransactionResult::Accept(_)), + "max_fee {max_fee} did not commit, so this run is not comparable: {:?}", + result.finalize.result + ); + result.finalize.fee_receipt + }) + .collect() +} + +fn state_transaction<'a>( + test: &TemplateTest, + account: ComponentAddress, + key: &'a RistrettoSecretKey, +) -> impl Fn(u64) -> Transaction + use<'a> { + let template = test.get_template_address("State"); + let tx = test.transaction(); + move |max_fee| { + tx.clone() + .pay_fee_from_component(account, max_fee) + .call_function(template, "new", args![]) + .build_and_seal(key) + } +} + +/// `max_fee` is the only literal arg of the `pay_fee` instruction, and `calc_args_weight` prices an +/// instruction's literals at `total_bytes / LITERAL_BYTE_DIVISOR`. The whole weight of this +/// transaction is that one term, so it steps whenever the encoded width crosses the divisor. +#[test] +fn transaction_weight_follows_the_max_fee_literal_width() { + const LITERAL_BYTE_DIVISOR: u64 = 3; + // Straddles an encoding-width boundary while keeping the digit count and the residual balance's + // width fixed, so the weight charge is the only thing that can move. + const MAX_FEES: [u64; 2] = [65_535, 65_536]; + + let mut test = TemplateTest::new(CRATE_PATH, TEMPLATE_PATHS); + let (account, owner_token, key) = test.create_funded_account(); + test.enable_fees(); + let build = state_transaction(&test, account, &key); + let receipts = meter_across_max_fees(&mut test, &MAX_FEES, &[owner_token], build); + + assert_ne!(amount_len(MAX_FEES[0]), amount_len(MAX_FEES[1])); + assert_eq!(digit_count(MAX_FEES[0]), digit_count(MAX_FEES[1])); + + let per_weight = test.fee_table().per_transaction_weight_cost(); + for (max_fee, receipt) in MAX_FEES.iter().zip(&receipts) { + assert_eq!( + receipt.fee_breakdown().get(FeeSource::TransactionWeight), + (amount_len(*max_fee) / LITERAL_BYTE_DIVISOR) * per_weight, + "TransactionWeight at max_fee {max_fee}" + ); + assert_eq!( + receipt.fee_breakdown().get(FeeSource::Storage), + receipts[0].fee_breakdown().get(FeeSource::Storage), + "Storage must not move while the digit count and residual width hold" + ); + } +} + +/// The transaction receipt is part of the state a transaction pays to persist, and it carries the +/// `std.vault.pay_fee` event, whose payload records the amount as a decimal string. Charging that +/// verbatim would price permanent state by the digit count of `max_fee`, so the amount is priced at +/// its widest instead and the digit count buys nothing. +#[test] +fn storage_does_not_follow_the_max_fee_digit_count() { + // One encoding width and one residual width throughout, so the digit count is the only thing + // that varies. + const MAX_FEES: [u64; 4] = [65_536, 1_000_000, 100_000_000, 999_000_000]; + + let mut test = TemplateTest::new(CRATE_PATH, TEMPLATE_PATHS); + let (account, owner_token, key) = test.create_funded_account(); + test.enable_fees(); + let build = state_transaction(&test, account, &key); + let receipts = meter_across_max_fees(&mut test, &MAX_FEES, &[owner_token], build); + + assert_ne!( + digit_count(MAX_FEES[0]), + digit_count(MAX_FEES[MAX_FEES.len() - 1]), + "the digit count must actually vary for this to prove anything" + ); + for (max_fee, receipt) in MAX_FEES.iter().zip(&receipts) { + assert_eq!( + amount_len(*max_fee), + amount_len(MAX_FEES[0]), + "encoding width must hold" + ); + assert_eq!( + amount_len(FUNDED - max_fee), + amount_len(FUNDED - MAX_FEES[0]), + "residual width must hold" + ); + assert_eq!( + receipt.fee_breakdown().get(FeeSource::Storage), + receipts[0].fee_breakdown().get(FeeSource::Storage), + "Storage at max_fee {max_fee}" + ); + } +} + +/// The finalization charges run before `finalize_fees_and_refunds` returns the unspent payment, so +/// the fee vault is byte-counted holding `balance - max_fee`. A larger `max_fee` narrows that +/// residual and makes storage *cheaper* — the opposite direction to the digit-count term above. +#[test] +fn storage_follows_the_residual_vault_balance_width() { + // One encoding width and one digit count throughout, so the residual width is the only thing + // that varies. + const MAX_FEES: [u64; 3] = [999_940_000, 999_999_800, 999_999_990]; + + let mut test = TemplateTest::new(CRATE_PATH, TEMPLATE_PATHS); + let (account, owner_token, key) = test.create_funded_account(); + test.enable_fees(); + let build = state_transaction(&test, account, &key); + let receipts = meter_across_max_fees(&mut test, &MAX_FEES, &[owner_token], build); + + let per_byte = test.fee_table().per_byte_storage_cost(); + let divisor = test.fee_table().storage_cost_divisor(); + for (max_fee, receipt) in MAX_FEES.iter().zip(&receipts) { + assert_eq!( + amount_len(*max_fee), + amount_len(MAX_FEES[0]), + "encoding width must hold" + ); + assert_eq!(digit_count(*max_fee), digit_count(MAX_FEES[0]), "digit count must hold"); + let bytes_saved = amount_len(FUNDED - MAX_FEES[0]) - amount_len(FUNDED - max_fee); + assert_eq!( + receipts[0].fee_breakdown().get(FeeSource::Storage) - receipt.fee_breakdown().get(FeeSource::Storage), + bytes_saved * per_byte / divisor, + "Storage at max_fee {max_fee}" + ); + } +} + +/// Only `TransactionWeight` and `Storage` are `max_fee`-sensitive. Anything else moving would mean +/// the three mechanisms above do not account for the whole drift. +#[test] +fn no_charge_other_than_weight_and_storage_moves_with_max_fee() { + const MAX_FEES: [u64; 6] = [1_000, 65_535, 65_536, 100_000_000, FUNDED - 60_000, FUNDED - 10]; + + let mut test = TemplateTest::new(CRATE_PATH, TEMPLATE_PATHS); + let (account, owner_token, key) = test.create_funded_account(); + test.enable_fees(); + let build = state_transaction(&test, account, &key); + let receipts = meter_across_max_fees(&mut test, &MAX_FEES, &[owner_token], build); + + assert_only_weight_and_storage_move(&MAX_FEES, &receipts); +} + +/// A publish carries a blob and takes the dedicated `TemplatePublish` pricing path, so it is the +/// shape most likely to hide a fourth mechanism. It does not. +#[test] +fn a_template_publish_introduces_no_further_max_fee_sensitivity() { + // Every entry clears the publish cost; between them they move all three quantities. + const MAX_FEES: [u64; 4] = [400_000, 100_000_000, FUNDED - 60_000, FUNDED - 10]; + + let mut test = TemplateTest::new(CRATE_PATH, &[] as &[&str]); + let (account, owner_proof, key, _) = test.create_funded_account_with_keypair(); + let template = compile_template("tests/templates/hello_world", &[]).unwrap(); + test.enable_fees(); + + let tx = test.transaction(); + let receipts = meter_across_max_fees(&mut test, &MAX_FEES, &[owner_proof], |max_fee| { + tx.clone() + .pay_fee_from_component(account, max_fee) + .publish_template(template.clone().into_code()) + .build_and_seal(&key) + }); + + assert_only_weight_and_storage_move(&MAX_FEES, &receipts); +} + +/// The property a caller actually depends on, asserted as the promise itself rather than as a +/// number: what `required_fees` returns for *any* run must cover *every* other run. +/// +/// A dry run meters at whatever `max_fee` the caller submitted, and the submission built from it +/// uses a smaller one, so the estimate has to hold in both directions — asserting it only from the +/// cheapest run would assume the very thing the allowance exists to cover. The burn rate is varied +/// because the burn is taken over the running total and so re-multiplies both terms; a bound +/// established with the burn disabled would not hold on a live network. +#[test] +fn required_fees_covers_a_real_run_at_any_max_fee() { + // Spans every encoding width a fee above this transaction's cost can take, every residual + // width, and digit counts from four to nine. + // The smallest entry must still cover the transaction at a 100% burn, which roughly doubles it. + const MAX_FEES: [u64; 8] = [ + 2_000, + 65_535, + 65_536, + 1_000_000, + 100_000_000, + FUNDED - 60_000, + FUNDED - 200, + FUNDED - 10, + ]; + + for rate in [0u16, 500, 2_000, 10_000] { + let mut test = TemplateTest::new(CRATE_PATH, TEMPLATE_PATHS); + let (account, owner_token, key) = test.create_funded_account(); + test.enable_fees(); + test.set_burn_rate_bps(rate); + let build = state_transaction(&test, account, &key); + let receipts = meter_across_max_fees(&mut test, &MAX_FEES, &[owner_token], build); + + let dearest = receipts + .iter() + .map(|r| r.total_fees_charged()) + .max() + .expect("max_fees is not empty"); + for (max_fee, receipt) in MAX_FEES.iter().zip(&receipts) { + let estimate = receipt.required_fees(); + assert!( + dearest <= estimate, + "at {rate} bps, a dry run at max_fee {max_fee} estimates {estimate}, under the {dearest} that some \ + other max_fee costs" + ); + } + } +} + +/// The residual term's direction, which is what makes it harmless. Raising `max_fee` narrows the +/// balance left in the fee vault when the byte counter runs, so it can only ever take storage down. +#[test] +fn a_wider_max_fee_never_raises_the_storage_charge() { + // Ascending, at one encoding width, so only the residual moves and it only narrows. + const MAX_FEES: [u64; 4] = [65_536, FUNDED - 60_000, FUNDED - 200, FUNDED - 10]; + + let mut test = TemplateTest::new(CRATE_PATH, TEMPLATE_PATHS); + let (account, owner_token, key) = test.create_funded_account(); + test.enable_fees(); + let build = state_transaction(&test, account, &key); + let receipts = meter_across_max_fees(&mut test, &MAX_FEES, &[owner_token], build); + + for (pair, fees) in receipts.windows(2).zip(MAX_FEES.windows(2)) { + assert!( + pair[1].fee_breakdown().get(FeeSource::Storage) <= pair[0].fee_breakdown().get(FeeSource::Storage), + "storage rose between max_fee {} and {}", + fees[0], + fees[1] + ); + } + // The mechanism is live, not merely one-directional by accident. + assert!( + receipts[receipts.len() - 1].fee_breakdown().get(FeeSource::Storage) < + receipts[0].fee_breakdown().get(FeeSource::Storage) + ); +} + +fn assert_only_weight_and_storage_move(max_fees: &[u64], receipts: &[FeeReceipt]) { + let base = &receipts[0]; + for (max_fee, receipt) in max_fees.iter().zip(receipts) { + for (source, amount) in receipt.fee_breakdown().iter() { + if matches!(source, FeeSource::TransactionWeight | FeeSource::Storage) { + continue; + } + assert_eq!( + *amount, + base.fee_breakdown().get(*source), + "{source:?} moved between max_fee {} and {max_fee}", + max_fees[0] + ); + } + } +} + +/// The digit-count mechanism traced to its source: the event payload the receipt carries holds +/// `max_fee` itself, rendered in decimal. +#[test] +fn the_pay_fee_event_records_max_fee_in_decimal() { + const MAX_FEE: u64 = 123_456_789; + + let mut test = TemplateTest::new(CRATE_PATH, TEMPLATE_PATHS); + let (account, owner_token, key) = test.create_funded_account(); + test.enable_fees(); + let build = state_transaction(&test, account, &key); + let result = test.try_execute(build(MAX_FEE), vec![owner_token]).unwrap(); + + let pay_fee = result + .finalize + .events + .iter() + .find(|e| e.topic() == "std.vault.pay_fee") + .expect("pay_fee event"); + assert_eq!( + pay_fee.get_payload("amount"), + Some(MAX_FEE.to_string().as_str()), + "the event records the payment cap, not the fee actually charged" + ); +} + +/// The exhaust burn adds no mechanism of its own but re-multiplies the others, being taken over the +/// running total. At a 100% rate the compounding is exact: the total moves by twice the movement of +/// the charges beneath it. +#[test] +fn the_exhaust_burn_compounds_the_drift() { + const FULL_RATE_BPS: u16 = 10_000; + // Four bytes of residual width apart, so the drift beneath the burn is unambiguously non-zero. + const MAX_FEES: [u64; 2] = [65_536, FUNDED - 10]; + + let mut test = TemplateTest::new(CRATE_PATH, TEMPLATE_PATHS); + let (account, owner_token, key) = test.create_funded_account(); + test.enable_fees(); + test.set_burn_rate_bps(FULL_RATE_BPS); + let build = state_transaction(&test, account, &key); + let receipts = meter_across_max_fees(&mut test, &MAX_FEES, &[owner_token], build); + + let pre_burn = |r: &FeeReceipt| r.total_fees_charged() - r.fee_breakdown().get(FeeSource::ExhaustBurn); + let pre_burn_delta = pre_burn(&receipts[1]).abs_diff(pre_burn(&receipts[0])); + assert!(pre_burn_delta > 0, "the chosen max_fees must move the pre-burn charges"); + assert_eq!( + receipts[1] + .total_fees_charged() + .abs_diff(receipts[0].total_fees_charged()), + pre_burn_delta * 2, + "a 100% burn doubles whatever the max_fee-sensitive charges contribute" + ); +} + #[test] fn template_load_fee_charged_once_per_template_per_transaction() { let mut test = TemplateTest::new(CRATE_PATH, TEMPLATE_PATHS); @@ -484,7 +966,7 @@ fn template_load_fee_charged_once_per_template_per_transaction() { // Five State calls — same template touched five extra times. Without dedup, TemplateLoad // would scale with call count; with dedup it must match the single-call baseline. let many = test.execute_expect_success( - Transaction::builder_localnet(Epoch(1)) + test.transaction() .pay_fee_from_component(account, 1000u64) .call_method(state, "set", args![1u32]) .call_method(state, "set", args![2u32]) diff --git a/crates/engine_types/src/commit_result.rs b/crates/engine_types/src/commit_result.rs index ef368954d2..80dc7842f7 100644 --- a/crates/engine_types/src/commit_result.rs +++ b/crates/engine_types/src/commit_result.rs @@ -31,7 +31,7 @@ use tari_template_lib::types::{ComponentAddress, Hash32, ResourceAddress, Templa use crate::{ Epoch, events::Event, - fees::FeeReceipt, + fees::{FEE_ESTIMATE_ALLOWANCE, FeeReceipt}, instruction_result::InstructionResult, logs::LogEntry, resource::Resource, @@ -182,6 +182,17 @@ pub struct FinalizeResult { pub result: TransactionResult, #[n(5)] pub fee_receipt: FeeReceipt, + /// What committing the whole transaction was priced at, which is what the commit-or-reject + /// decision was made against. + /// + /// Equal to `fee_receipt.total_fees_charged()` on a full commit. When only the fee intent + /// commits, the charges are re-derived over the fee checkpoint alone and so fall below what was + /// paid; this keeps the figure the main intent was rejected for — the one a resubmission has to + /// clear. Execution metadata rather than settled state, so it stays out of the persisted + /// receipt, whose every field is priced into the storage charge of each transaction. + #[n(6)] + #[serde(default)] + pub total_fees_required: u64, } impl FinalizeResult { @@ -193,6 +204,7 @@ impl FinalizeResult { fee_receipt: FeeReceipt, ) -> Self { Self { + total_fees_required: fee_receipt.total_fees_charged(), transaction_hash, logs, events, @@ -202,6 +214,18 @@ impl FinalizeResult { } } + /// Records what committing the whole transaction was priced at, when that differs from what the + /// receipt was ultimately charged. + pub fn with_total_fees_required(mut self, amount: u64) -> Self { + self.total_fees_required = amount.max(self.fee_receipt.total_fees_charged()); + self + } + + /// The minimum `max_fee` a resubmission of this transaction has to carry. + pub fn required_fees(&self) -> u64 { + self.total_fees_required.saturating_add(FEE_ESTIMATE_ALLOWANCE) + } + pub fn new_rejected(transaction_hash: Hash32, reason: RejectReason) -> Self { Self { transaction_hash, @@ -210,6 +234,7 @@ impl FinalizeResult { execution_results: Vec::new(), result: TransactionResult::Reject(reason), fee_receipt: FeeReceipt::default(), + total_fees_required: 0, } } diff --git a/crates/engine_types/src/events.rs b/crates/engine_types/src/events.rs index dc1892a995..d70a94b364 100644 --- a/crates/engine_types/src/events.rs +++ b/crates/engine_types/src/events.rs @@ -30,6 +30,9 @@ use crate::substate::SubstateId; // Topics for builtin events emitted by the engine const STANDARD_TOPIC_PREFIX: &str = "std."; +/// The widest decimal an amount renders to, amounts being `u64` microtari. +const WIDEST_AMOUNT: &str = "18446744073709551615"; + fn std_event(object_name: &str, action_name: &str) -> String { format!("{}{}.{}", STANDARD_TOPIC_PREFIX, object_name, action_name) } @@ -96,6 +99,38 @@ impl Event { ) } + /// Whether this is the standard event `object_name` emits for `action_name`. Matches the topic + /// [`std_event`] builds, without building it. + pub fn is_std(&self, object_name: &str, action_name: &str) -> bool { + self.topic + .strip_prefix(STANDARD_TOPIC_PREFIX) + .and_then(|rest| rest.strip_prefix(object_name)) + .and_then(|rest| rest.strip_prefix('.')) + .is_some_and(|rest| rest == action_name) + } + + /// The bytes to add to this event's encoded length when pricing the permanent state a + /// transaction pays for, so that the price cannot depend on the fee being priced. + /// + /// A fee-payment event records the payment as a decimal string, so its length tracks `max_fee` + /// digit for digit. Charging that verbatim lets the storage price read back the very amount it + /// is pricing: a submission whose `max_fee` is wider than the one a dry run measured costs more + /// than that dry run reported, and the transaction is rejected for underpayment. Pricing it at + /// its widest breaks the loop — the same stand-in [`crate::fees::FeeReceipt::widest`] gets, for + /// the same reason. + /// + /// Only the engine's own fee event is neutralized here. A template that echoes an amount its + /// caller passed it is priced as written: that coupling is visible to whoever chose both the + /// `max_fee` and the template, and is theirs to account for. + pub fn charged_size_padding(&self) -> usize { + if !self.is_std("vault", "pay_fee") { + return 0; + } + self.get_payload("amount").map_or(0, |amount| { + minicbor::len(WIDEST_AMOUNT).saturating_sub(minicbor::len(amount)) + }) + } + pub fn validate_custom_topic>(topic: T) -> Result<(), String> { let s = topic.as_ref(); if topic.as_ref().starts_with(STANDARD_TOPIC_PREFIX) { @@ -154,3 +189,62 @@ impl Display for Event { ) } } + +#[cfg(test)] +mod tests { + use tari_template_lib::types::Hash32; + + use super::*; + + fn pay_fee_event(amount: &str) -> Event { + Event::std( + None, + Hash32::from_array([0u8; Hash32::LENGTH]), + "vault", + "pay_fee", + Metadata::from_iter([("amount", amount.to_string())]), + ) + } + + #[test] + fn is_std_recognises_the_topics_std_event_builds() { + let event = pay_fee_event("1000"); + assert_eq!(event.topic(), std_event("vault", "pay_fee")); + assert!(event.is_std("vault", "pay_fee")); + assert!(!event.is_std("vault", "deposit")); + assert!(!event.is_std("component", "pay_fee")); + // A prefix of the object name must not match. + assert!(!event.is_std("vau", "lt.pay_fee")); + } + + #[test] + fn a_template_cannot_reach_the_fee_topic() { + // What makes matching on the topic safe: only the engine can emit it. + assert!(Event::validate_custom_topic(std_event("vault", "pay_fee")).is_err()); + } + + #[test] + fn a_fee_payment_amount_is_priced_at_one_width_whatever_its_value() { + let widest = pay_fee_event(WIDEST_AMOUNT); + let expected = minicbor::len(&widest) + widest.charged_size_padding(); + for amount in ["1", "1000", "398287", "18446744073709551614"] { + let event = pay_fee_event(amount); + assert_eq!( + minicbor::len(&event) + event.charged_size_padding(), + expected, + "amount {amount} priced differently to the widest amount" + ); + } + } + + #[test] + fn an_amount_a_template_records_is_priced_as_written() { + let event = Event::custom( + None, + Hash32::from_array([0u8; Hash32::LENGTH]), + "mytemplate.paid".to_string(), + Metadata::from_iter([("amount", "398287".to_string())]), + ); + assert_eq!(event.charged_size_padding(), 0); + } +} diff --git a/crates/engine_types/src/fees.rs b/crates/engine_types/src/fees.rs index 379b7dc0bf..fc6adc7ec1 100644 --- a/crates/engine_types/src/fees.rs +++ b/crates/engine_types/src/fees.rs @@ -4,6 +4,35 @@ use indexmap::{IndexMap, map::Entry}; use serde::{Deserialize, Serialize}; +/// The exhaust burn rate that [`FEE_ESTIMATE_ALLOWANCE`] is derived against, in basis points. +/// +/// The burn is taken over the running fee total, so it re-multiplies every term that can make a +/// real run cost more than the dry run that estimated it. A network configured above this rate +/// under-states `FeeReceipt::required_fees`, and every submission built from a dry run is then +/// rejected as underpaid — `every_shipped_network_stays_within_the_burn_rate_ceiling` holds the +/// shipped networks to it. +pub const MAX_EXHAUST_BURN_RATE_BPS: u16 = 10_000; + +/// The allowance a dry-run estimate carries on top of what it metered, so that a real run of the +/// same transaction at a different `max_fee` can never cost more than the estimate. +/// +/// `max_fee` is itself an input to the cost, so the two runs meter differently. Two charges read it +/// back, and they move in opposite directions, so the allowance covers both. Transaction weight +/// prices the fee instruction's literal args by their encoded bytes, so a wider `max_fee` pushes +/// `literal_bytes / LITERAL_BYTE_DIVISOR` up by a bounded number of steps. The storage tally +/// byte-counts the fee vault before the unspent payment is returned, so it counts +/// `balance - max_fee`, and a wider `max_fee` narrows that. A dry run meters at whatever `max_fee` +/// the caller submitted — nothing narrows it — so either direction is reachable between a dry run +/// and the submission built from it. The exhaust burn reads nothing of its own but re-multiplies +/// both, being taken over the running total. +/// +/// The value is derived rather than chosen: `FeeTable::fee_estimate_allowance` computes it from +/// `per_transaction_weight_cost`, `per_byte_storage_cost`, `storage_cost_divisor`, +/// `LITERAL_BYTE_DIVISOR` and the burn rate, all of which live in crates downstream of this one. +/// `fee_estimate_allowance_covers_every_shipped_network` asserts this value covers every shipped +/// network at `MAX_EXHAUST_BURN_RATE_BPS`. +pub const FEE_ESTIMATE_ALLOWANCE: u64 = 25; + #[derive(Debug, Clone, Default)] pub struct FeeReceiptBuilder { /// The total amount of the fee payment(s) @@ -106,12 +135,24 @@ impl FeeReceipt { self.cost_breakdown.get_total() } - /// The minimum fee required to submit a transaction based on a dry run result. - /// This is `total_fees_charged + 1` to account for potential rounding differences in the storage cost calculation. - /// The storage fee depends on the vault balance at calculation time, which changes when a different max_fee is used - /// in the actual submission vs the dry run — this can shift `floor(total_bytes / 4)` by 1 at a rounding boundary. + /// The minimum fee to submit with, given what a dry run metered. + /// + /// A submission cannot simply use `total_fees_charged`: the `max_fee` it carries is itself an + /// input to the cost, so a real run meters slightly differently from the dry run that produced + /// the estimate. The allowance covers the whole of that difference. + /// + /// Two charges read `max_fee` back, in opposite directions. The transaction weight prices the fee + /// instruction's literal args by their encoded bytes, so it steps whenever the amount's width + /// crosses a multiple of the literal divisor. The storage tally byte-counts the fee vault before + /// the unspent payment is returned, so a wider `max_fee` leaves a narrower residual there. The + /// exhaust burn adds no reading of its own but re-multiplies both, being taken over the running + /// total. [`FEE_ESTIMATE_ALLOWANCE`] bounds the pair. + /// + /// This is a floor, not a recommendation. Overpayment is returned to the paying vault, so a + /// caller with a vault to refund to loses nothing by submitting above it — and one paying purely + /// by stealth reveal, where the overpayment is not refundable, has reason to sit on it. pub fn required_fees(&self) -> u64 { - self.total_fees_charged().saturating_add(1) + self.total_fees_charged().saturating_add(FEE_ESTIMATE_ALLOWANCE) } /// The total amount of fees refunded to the respective vaults @@ -274,13 +315,44 @@ impl FeeBreakdown { } } + /// Replaces whatever `source` has been charged so far. + /// + /// Charges accrued during execution accumulate with [`Self::add`], but the charges computed at + /// finalization are absolute functions of the state being persisted. They are recomputed once + /// that state is known, so they must be assignable rather than additive. + /// + /// A charge of zero leaves the source absent rather than recording a zero against it. The + /// breakdown is persisted inside every transaction receipt and rendered by every wallet, so a + /// source that never charged anything should not occupy a row. [`Self::get`] reads an absent + /// source as zero, so the two are indistinguishable to a reader. + pub fn set(&mut self, source: FeeSource, amount: u64) { + if amount == 0 { + self.breakdown.shift_remove(&source); + return; + } + match self.breakdown.entry(source) { + Entry::Occupied(entry) => { + *entry.into_mut() = amount; + }, + Entry::Vacant(entry) => { + entry.insert(amount); + self.breakdown.sort_keys(); + }, + } + } + /// Returns an iterator over the fee breakdown in a canonical order. pub fn iter(&self) -> impl Iterator { self.breakdown.iter() } + /// Saturating, so a breakdown that somehow exceeds `u64` reports the ceiling rather than + /// wrapping to a total below the charges it is made of. Individual charges are checked as they + /// are computed, so reaching it means something upstream already went wrong. pub fn get_total(&self) -> u64 { - self.breakdown.values().sum() + self.breakdown + .values() + .fold(0u64, |acc, amount| acc.saturating_add(*amount)) } pub fn get(&self, source: FeeSource) -> u64 { diff --git a/crates/engine_types/src/indexed_value.rs b/crates/engine_types/src/indexed_value.rs index 4a714a11dd..8153cc5a3b 100644 --- a/crates/engine_types/src/indexed_value.rs +++ b/crates/engine_types/src/indexed_value.rs @@ -433,69 +433,78 @@ pub enum WellKnownTariValue { impl FromTagAndValue for WellKnownTariValue { type Error = IndexedValueError; - fn try_from_tag_and_value(tag: u64, value: &tari_bor::Value) -> Result + fn try_from_tag_and_value(tag: u64, value: &tari_bor::Value) -> Result, Self::Error> where Self: Sized { - let tag = BinaryTag::from_u64(tag).ok_or(IndexedValueError::InvalidTag(tag))?; + // Standard CBOR tags share the value tree with Tari's: an `Amount` above `u64::MAX` encodes + // as a bignum, tag 2. Those carry no substate reference, so they are not this type's to + // claim. + let Some(tag) = BinaryTag::from_u64(tag) else { + return Ok(None); + }; match tag { BinaryTag::ComponentAddress => { let component_address: ObjectKey = value.decoded()?; - Ok(Self::ComponentAddress(component_address.into())) + Ok(Some(Self::ComponentAddress(component_address.into()))) }, BinaryTag::BucketId => { let bucket_id: u32 = value.decoded()?; - Ok(Self::BucketId(bucket_id.into())) + Ok(Some(Self::BucketId(bucket_id.into()))) }, BinaryTag::ResourceAddress => { let resource_address: ObjectKey = value.decoded()?; - Ok(Self::ResourceAddress(resource_address.into())) + Ok(Some(Self::ResourceAddress(resource_address.into()))) }, BinaryTag::TransactionReceipt => { let tx_receipt_hash: Hash32 = value.decoded()?; - Ok(Self::TransactionReceiptAddress(tx_receipt_hash.into())) + Ok(Some(Self::TransactionReceiptAddress(tx_receipt_hash.into()))) }, BinaryTag::NonFungibleAddress => { let non_fungible_address: NonFungibleAddressContents = value.decoded()?; - Ok(Self::NonFungibleAddress(non_fungible_address.into())) + Ok(Some(Self::NonFungibleAddress(non_fungible_address.into()))) }, BinaryTag::Metadata => { let metadata: BTreeMap = value.decoded()?; - Ok(Self::Metadata(metadata.into())) + Ok(Some(Self::Metadata(metadata.into()))) }, BinaryTag::VaultId => { let vault_id: ObjectKey = value.decoded()?; - Ok(Self::VaultId(vault_id.into())) + Ok(Some(Self::VaultId(vault_id.into()))) }, BinaryTag::ProofId => { let value: u32 = value.decoded()?; - Ok(Self::ProofId(value.into())) + Ok(Some(Self::ProofId(value.into()))) }, BinaryTag::ClaimedOutputTombstoneAddress => { let value: ObjectKey = value.decoded()?; - Ok(Self::ClaimedOutputTombstoneAddress(value.into())) + Ok(Some(Self::ClaimedOutputTombstoneAddress(value.into()))) }, BinaryTag::TemplateAddress => { let value: Hash32 = value.decoded()?; - Ok(Self::PublishedTemplateAddress(value.into())) + Ok(Some(Self::PublishedTemplateAddress(value.into()))) }, BinaryTag::ValidatorNodeFeePool => { let value: [u8; 32] = value.decoded()?; - Ok(Self::ValidatorNodeFeePool(value.into())) + Ok(Some(Self::ValidatorNodeFeePool(value.into()))) }, BinaryTag::AllocatedComponentAddress => { let value = value.decoded()?; - Ok(Self::ComponentAddressAllocation(ComponentAddressAllocation::new(value))) + Ok(Some(Self::ComponentAddressAllocation(ComponentAddressAllocation::new( + value, + )))) }, BinaryTag::AllocatedResourceAddress => { let value = value.decoded()?; - Ok(Self::ResourceAddressAllocation(ResourceAddressAllocation::new(value))) + Ok(Some(Self::ResourceAddressAllocation(ResourceAddressAllocation::new( + value, + )))) }, BinaryTag::Utxo => { let value: UtxoAddressContents = value.decoded()?; - Ok(Self::Utxo(value.into())) + Ok(Some(Self::Utxo(value.into()))) }, BinaryTag::ConfidentialOutput => { let value: ConfidentialOutputAddressContents = value.decoded()?; - Ok(Self::ConfidentialOutput(value.into())) + Ok(Some(Self::ConfidentialOutput(value.into()))) }, } } @@ -601,8 +610,6 @@ impl ValueVisitor for IndexedValueVisitor { pub enum IndexedValueError { #[error("Bor error: {0}")] BorError(#[from] BorError), - #[error("Invalid tag: {0}")] - InvalidTag(u64), #[error("{0}")] Custom(String), } @@ -756,6 +763,35 @@ mod tests { assert_eq!(buckets, vec![1.into(), 2.into()]); } + /// An `Amount` above `u64::MAX` encodes as a CBOR bignum, which is a tagged value the indexer + /// has no claim on. It must walk past it rather than reject the whole value. + #[test] + fn a_standard_cbor_tag_is_walked_past() { + use tari_template_lib::types::Amount; + + let huge = Amount::new(u128::from(u64::MAX) + 1); + let value = tari_bor::to_value(&huge).unwrap(); + assert!( + matches!(value, tari_bor::Value::Tag(2, _)), + "the test needs a bignum to be meaningful: {value:?}" + ); + + let indexed = IndexedValue::from_value(value).unwrap(); + assert!(indexed.component_addresses().is_empty()); + } + + /// Walking past an unclaimed tag must still descend into it, or a substate reference nested + /// under one would go unlocked. + #[test] + fn a_reference_under_an_unclaimed_tag_is_still_found() { + let addr = ComponentAddress::new(new_object_key()); + // Tag 42 is not a `BinaryTag`, so nothing claims it. + let value = tari_bor::Value::Tag(42, Box::new(tari_bor::to_value(&addr).unwrap())); + + let indexed = IndexedValue::from_value(value).unwrap(); + assert!(indexed.component_addresses().contains(&addr)); + } + #[test] fn it_diffs_two_indexed_values() { let v1 = IndexedWellKnownTypes::from_value(&cbor!({ diff --git a/crates/engine_types/src/transaction_receipt.rs b/crates/engine_types/src/transaction_receipt.rs index 8d69151fa6..ccabca1fdd 100644 --- a/crates/engine_types/src/transaction_receipt.rs +++ b/crates/engine_types/src/transaction_receipt.rs @@ -85,13 +85,22 @@ impl TransactionReceipt { /// from this bound feeds into. Measuring it exactly would require a fixed point, so the two /// parts that are not yet known are replaced by worst-case stand-ins — a fee receipt whose /// amounts all encode at full varint width, and a max-width version on each diff-summary entry. - /// Everything else (`events`, `fee_withdrawals`, `epoch`, `intent_commitment`) is measured as it - /// will actually be encoded. + /// + /// Events are measured as they will actually be encoded, save for the amount a fee-payment event + /// records: that renders `max_fee` in decimal, so measuring it as written would reintroduce the + /// very fixed point the fee receipt's stand-in exists to avoid. It is priced at its widest — see + /// [`Event::charged_size_padding`]. `fee_withdrawals`, `epoch` and `intent_commitment` are + /// measured as they will actually be encoded. /// /// `upped` is the substates that will appear in the [`DiffSummary`] — one entry each. Nothing /// joins that set after the charge is computed: fee settlement only mutates substates already in /// it, and building the diff can only drop entries, never add them. The receipt is up'd after /// its own summary is built, so it is absent from both. + /// + /// That holds only when `upped` comes from the same state the receipt is built from. Spending a + /// UTXO or confidential output removes it from the state that spent it, so a state which never + /// ran that instruction can carry an entry a later one has dropped — a fee-intent commit is + /// exactly that case. Callers must bound the state whose receipt they are pricing. pub fn encoded_size_upper_bound<'a>( events: &[Event], fee_withdrawals: &[ValidatorFeeWithdrawal], @@ -111,6 +120,7 @@ impl TransactionReceipt { let ctx = &mut (); let mut len = minicbor::len(&diff_summary); len += boxed_slice::cbor_len(events, ctx); + len += events.iter().map(Event::charged_size_padding).sum::(); len += boxed_slice::cbor_len(fee_withdrawals, ctx); len += minicbor::len(FeeReceipt::widest()); len += minicbor::len(epoch); diff --git a/crates/ootle_sdk_core/fixtures/arg_dsl/amount.json b/crates/ootle_sdk_core/fixtures/arg_dsl/amount.json index a5fd64a005..88a72d019c 100644 --- a/crates/ootle_sdk_core/fixtures/arg_dsl/amount.json +++ b/crates/ootle_sdk_core/fixtures/arg_dsl/amount.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "encode_arg", @@ -13,6 +13,6 @@ } }, "expected": { - "encoded_arg_bytes": "821a000f424000" + "encoded_arg_bytes": "1a000f4240" } } diff --git a/crates/ootle_sdk_core/fixtures/arg_dsl/amount_above_2_pow_33.json b/crates/ootle_sdk_core/fixtures/arg_dsl/amount_above_2_pow_33.json index feeda83fbc..ea9471748d 100644 --- a/crates/ootle_sdk_core/fixtures/arg_dsl/amount_above_2_pow_33.json +++ b/crates/ootle_sdk_core/fixtures/arg_dsl/amount_above_2_pow_33.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "encode_arg", @@ -13,6 +13,6 @@ } }, "expected": { - "encoded_arg_bytes": "821b000000040000000700" + "encoded_arg_bytes": "1b0000000400000007" } } diff --git a/crates/ootle_sdk_core/fixtures/cosign/seal_with_auth.json b/crates/ootle_sdk_core/fixtures/cosign/seal_with_auth.json index 8a581ae2d5..89f85764fc 100644 --- a/crates/ootle_sdk_core/fixtures/cosign/seal_with_auth.json +++ b/crates/ootle_sdk_core/fixtures/cosign/seal_with_auth.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "cosign_seal_with_auth", @@ -95,7 +95,7 @@ "CallMethod": { "args": [ { - "Literal": "821909c400" + "Literal": "1909c4" } ], "call": { @@ -127,7 +127,7 @@ "Literal": "d88358203232323232323232323232323232323232323232323232323232323232323232" }, { - "Literal": "821a000f424000" + "Literal": "1a000f4240" } ], "call": { diff --git a/crates/ootle_sdk_core/fixtures/generic_build/call_function.json b/crates/ootle_sdk_core/fixtures/generic_build/call_function.json index d40766896d..b4dd1d50ca 100644 --- a/crates/ootle_sdk_core/fixtures/generic_build/call_function.json +++ b/crates/ootle_sdk_core/fixtures/generic_build/call_function.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "build_and_encode_instructions", @@ -60,7 +60,7 @@ "CallMethod": { "args": [ { - "Literal": "821907d000" + "Literal": "1907d0" } ], "call": { diff --git a/crates/ootle_sdk_core/fixtures/generic_build/call_method_transfer.json b/crates/ootle_sdk_core/fixtures/generic_build/call_method_transfer.json index 02f342dcac..84e6025730 100644 --- a/crates/ootle_sdk_core/fixtures/generic_build/call_method_transfer.json +++ b/crates/ootle_sdk_core/fixtures/generic_build/call_method_transfer.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "build_and_encode_instructions", @@ -81,7 +81,7 @@ "CallMethod": { "args": [ { - "Literal": "821909c400" + "Literal": "1909c4" } ], "call": { @@ -105,7 +105,7 @@ "Literal": "d88358207272727272727272727272727272727272727272727272727272727272727272" }, { - "Literal": "821a000f424000" + "Literal": "1a000f4240" } ], "call": { diff --git a/crates/ootle_sdk_core/fixtures/generic_build/create_account.json b/crates/ootle_sdk_core/fixtures/generic_build/create_account.json index 77583e956e..ec071246c0 100644 --- a/crates/ootle_sdk_core/fixtures/generic_build/create_account.json +++ b/crates/ootle_sdk_core/fixtures/generic_build/create_account.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "build_and_encode_instructions", @@ -60,7 +60,7 @@ "CallMethod": { "args": [ { - "Literal": "821907d000" + "Literal": "1907d0" } ], "call": { diff --git a/crates/ootle_sdk_core/fixtures/generic_build/faucet_claim.json b/crates/ootle_sdk_core/fixtures/generic_build/faucet_claim.json index 5e8b963f8e..daed86d21c 100644 --- a/crates/ootle_sdk_core/fixtures/generic_build/faucet_claim.json +++ b/crates/ootle_sdk_core/fixtures/generic_build/faucet_claim.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "build_and_encode_faucet_claim", @@ -115,7 +115,7 @@ "CallMethod": { "args": [ { - "Literal": "821907d000" + "Literal": "1907d0" } ], "call": { diff --git a/crates/ootle_sdk_core/fixtures/generic_build/publish_template.json b/crates/ootle_sdk_core/fixtures/generic_build/publish_template.json index 2ed424ba25..221a1aa712 100644 --- a/crates/ootle_sdk_core/fixtures/generic_build/publish_template.json +++ b/crates/ootle_sdk_core/fixtures/generic_build/publish_template.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "build_and_encode_instructions", @@ -65,7 +65,7 @@ "CallMethod": { "args": [ { - "Literal": "821907d000" + "Literal": "1907d0" } ], "call": { diff --git a/crates/ootle_sdk_core/fixtures/generic_build/self_funding_faucet.json b/crates/ootle_sdk_core/fixtures/generic_build/self_funding_faucet.json index 86e2df0289..ea85b7182b 100644 --- a/crates/ootle_sdk_core/fixtures/generic_build/self_funding_faucet.json +++ b/crates/ootle_sdk_core/fixtures/generic_build/self_funding_faucet.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "build_and_encode_instructions", @@ -109,7 +109,7 @@ "CallMethod": { "args": [ { - "Literal": "821907d000" + "Literal": "1907d0" } ], "call": { diff --git a/crates/ootle_sdk_core/fixtures/generic_build/workspace_pipe.json b/crates/ootle_sdk_core/fixtures/generic_build/workspace_pipe.json index 1187f50090..ecbbb0d7b9 100644 --- a/crates/ootle_sdk_core/fixtures/generic_build/workspace_pipe.json +++ b/crates/ootle_sdk_core/fixtures/generic_build/workspace_pipe.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "build_and_encode_instructions", @@ -87,7 +87,7 @@ "CallMethod": { "args": [ { - "Literal": "821907d000" + "Literal": "1907d0" } ], "call": { @@ -111,7 +111,7 @@ "Literal": "d88358207272727272727272727272727272727272727272727272727272727272727272" }, { - "Literal": "821a0007a12000" + "Literal": "1a0007a120" } ], "call": { diff --git a/crates/ootle_sdk_core/fixtures/parse_finalized_result/dry_run.json b/crates/ootle_sdk_core/fixtures/parse_finalized_result/dry_run.json index f7dd1c4f9d..3a4fcd8d7e 100644 --- a/crates/ootle_sdk_core/fixtures/parse_finalized_result/dry_run.json +++ b/crates/ootle_sdk_core/fixtures/parse_finalized_result/dry_run.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "parse_finalized_result", @@ -112,7 +112,7 @@ ] }, "epoch": 11, - "estimated_fee": 9007199254742000, + "estimated_fee": 9007199254742024, "events": [ { "payload": [ diff --git a/crates/ootle_sdk_core/fixtures/public_transfer/large_amount.json b/crates/ootle_sdk_core/fixtures/public_transfer/large_amount.json index d031a6af34..0cac689ad6 100644 --- a/crates/ootle_sdk_core/fixtures/public_transfer/large_amount.json +++ b/crates/ootle_sdk_core/fixtures/public_transfer/large_amount.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "build_and_encode_public_transfer", @@ -50,7 +50,7 @@ "CallMethod": { "args": [ { - "Literal": "821909c400" + "Literal": "1909c4" } ], "call": { @@ -74,7 +74,7 @@ "Literal": "d88358202222222222222222222222222222222222222222222222222222222222222222" }, { - "Literal": "821b00200000000f120600" + "Literal": "1b00200000000f1206" } ], "call": { diff --git a/crates/ootle_sdk_core/fixtures/public_transfer/sample_single_key_basic.json b/crates/ootle_sdk_core/fixtures/public_transfer/sample_single_key_basic.json index 7246f8533e..1f0a4fa114 100644 --- a/crates/ootle_sdk_core/fixtures/public_transfer/sample_single_key_basic.json +++ b/crates/ootle_sdk_core/fixtures/public_transfer/sample_single_key_basic.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "build_and_encode_public_transfer", @@ -50,7 +50,7 @@ "CallMethod": { "args": [ { - "Literal": "821907d000" + "Literal": "1907d0" } ], "call": { @@ -74,7 +74,7 @@ "Literal": "d8835820bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, { - "Literal": "821b002000000000303900" + "Literal": "1b0020000000003039" } ], "call": { diff --git a/crates/ootle_sdk_core/fixtures/public_transfer/single_key_basic.json b/crates/ootle_sdk_core/fixtures/public_transfer/single_key_basic.json index db07031d1b..e4c35e5320 100644 --- a/crates/ootle_sdk_core/fixtures/public_transfer/single_key_basic.json +++ b/crates/ootle_sdk_core/fixtures/public_transfer/single_key_basic.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "build_and_encode_public_transfer", @@ -50,7 +50,7 @@ "CallMethod": { "args": [ { - "Literal": "821909c400" + "Literal": "1909c4" } ], "call": { @@ -74,7 +74,7 @@ "Literal": "d88358202222222222222222222222222222222222222222222222222222222222222222" }, { - "Literal": "821a000f424000" + "Literal": "1a000f4240" } ], "call": { diff --git a/crates/ootle_sdk_core/fixtures/resolve_public_transfer/large_amount.json b/crates/ootle_sdk_core/fixtures/resolve_public_transfer/large_amount.json index c8415a69ad..f52cc267de 100644 --- a/crates/ootle_sdk_core/fixtures/resolve_public_transfer/large_amount.json +++ b/crates/ootle_sdk_core/fixtures/resolve_public_transfer/large_amount.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "resolve_and_encode_public_transfer", @@ -92,7 +92,7 @@ "CallMethod": { "args": [ { - "Literal": "821909c400" + "Literal": "1909c4" } ], "call": { @@ -124,7 +124,7 @@ "Literal": "d88358203232323232323232323232323232323232323232323232323232323232323232" }, { - "Literal": "821b00200000000f120600" + "Literal": "1b00200000000f1206" } ], "call": { diff --git a/crates/ootle_sdk_core/fixtures/resolve_public_transfer/single_key_basic.json b/crates/ootle_sdk_core/fixtures/resolve_public_transfer/single_key_basic.json index e55c9d4db9..aee1e6beed 100644 --- a/crates/ootle_sdk_core/fixtures/resolve_public_transfer/single_key_basic.json +++ b/crates/ootle_sdk_core/fixtures/resolve_public_transfer/single_key_basic.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "resolve_and_encode_public_transfer", @@ -92,7 +92,7 @@ "CallMethod": { "args": [ { - "Literal": "821909c400" + "Literal": "1909c4" } ], "call": { @@ -124,7 +124,7 @@ "Literal": "d88358203232323232323232323232323232323232323232323232323232323232323232" }, { - "Literal": "821a000f424000" + "Literal": "1a000f4240" } ], "call": { diff --git a/crates/ootle_sdk_core/fixtures/stealth_transfer/account_key_seal_with_revealed_input.json b/crates/ootle_sdk_core/fixtures/stealth_transfer/account_key_seal_with_revealed_input.json index 85d11e9fb8..afc0622bd3 100644 --- a/crates/ootle_sdk_core/fixtures/stealth_transfer/account_key_seal_with_revealed_input.json +++ b/crates/ootle_sdk_core/fixtures/stealth_transfer/account_key_seal_with_revealed_input.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "build_and_encode_stealth_transfer", @@ -59,7 +59,7 @@ "CallMethod": { "args": [ { - "Literal": "821907d000" + "Literal": "1907d0" } ], "call": { @@ -78,7 +78,7 @@ "Literal": "d88358200101010101010101010101010101010101010101010101010101010101010101" }, { - "Literal": "821a000f424000" + "Literal": "1a000f4240" } ], "call": { diff --git a/crates/ootle_sdk_core/fixtures/stealth_transfer/revealed_output_multi.json b/crates/ootle_sdk_core/fixtures/stealth_transfer/revealed_output_multi.json index 183031064e..fde4c61491 100644 --- a/crates/ootle_sdk_core/fixtures/stealth_transfer/revealed_output_multi.json +++ b/crates/ootle_sdk_core/fixtures/stealth_transfer/revealed_output_multi.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "build_and_encode_stealth_transfer", @@ -71,7 +71,7 @@ "CallMethod": { "args": [ { - "Literal": "821907d000" + "Literal": "1907d0" } ], "call": { @@ -90,7 +90,7 @@ "Literal": "d88358200101010101010101010101010101010101010101010101010101010101010101" }, { - "Literal": "821a002625a000" + "Literal": "1a002625a0" } ], "call": { diff --git a/crates/ootle_sdk_core/fixtures/stealth_transfer/revealed_output_single.json b/crates/ootle_sdk_core/fixtures/stealth_transfer/revealed_output_single.json index 5d09a60025..441de557ee 100644 --- a/crates/ootle_sdk_core/fixtures/stealth_transfer/revealed_output_single.json +++ b/crates/ootle_sdk_core/fixtures/stealth_transfer/revealed_output_single.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "build_and_encode_stealth_transfer", @@ -59,7 +59,7 @@ "CallMethod": { "args": [ { - "Literal": "821907d000" + "Literal": "1907d0" } ], "call": { @@ -78,7 +78,7 @@ "Literal": "d88358200101010101010101010101010101010101010101010101010101010101010101" }, { - "Literal": "821a0016e36000" + "Literal": "1a0016e360" } ], "call": { diff --git a/crates/ootle_sdk_core/fixtures/stealth_transfer/stealth_seal_with_input.json b/crates/ootle_sdk_core/fixtures/stealth_transfer/stealth_seal_with_input.json index 6f6739e716..79e1e8e752 100644 --- a/crates/ootle_sdk_core/fixtures/stealth_transfer/stealth_seal_with_input.json +++ b/crates/ootle_sdk_core/fixtures/stealth_transfer/stealth_seal_with_input.json @@ -3,7 +3,7 @@ "schema_version": 1, "provenance": { "core_version": "0.39.0", - "git_rev": "9f4833febee5c633a6fb2f0bd61dd1ce6d33c481", + "git_rev": "dee549eb268a89412bde31f685359207b7789ab3", "generated_by": "ootle_sdk_core golden-vector generator" }, "operation": "build_and_encode_stealth_transfer", @@ -85,7 +85,7 @@ "CallMethod": { "args": [ { - "Literal": "821907d000" + "Literal": "1907d0" } ], "call": { diff --git a/crates/ootle_sdk_core/src/result.rs b/crates/ootle_sdk_core/src/result.rs index 13ea4f2b50..ea47a1a7c8 100644 --- a/crates/ootle_sdk_core/src/result.rs +++ b/crates/ootle_sdk_core/src/result.rs @@ -194,8 +194,9 @@ struct WireDryRunResult { /// /// A dry-run executes fully (so it carries the same events / diff / logs an accepted transaction would) /// but is never committed; the surfaced `estimated_fee` is the engine's -/// [`tari_engine_types::fees::FeeReceipt::required_fees`] — `total_fees_charged + 1`, the minimum -/// `max_fee` to use for the real submission. A deserialize failure is an [`OotleSdkError::Parse`]. +/// [`tari_engine_types::fees::FeeReceipt::required_fees`] — the metered cost plus the allowance for +/// the drift a differing `max_fee` introduces, i.e. the minimum `max_fee` the real submission can +/// use. A deserialize failure is an [`OotleSdkError::Parse`]. pub fn parse_dry_run_result(raw: &str) -> Result { let value: serde_json::Value = serde_json::from_str(raw).map_err(|e| OotleSdkError::Parse(format!("indexer dry-run result JSON: {e}")))?; @@ -362,6 +363,7 @@ mod tests { logs: vec![LogEntry::new(LogLevel::Info, "hello".to_string())], execution_results: Vec::new(), result, + total_fees_required: fee_receipt.total_fees_charged(), fee_receipt, }; @@ -468,13 +470,15 @@ mod tests { } /// A dry-run result parses through the shape-dispatching `parse_finalized_result` and surfaces a - /// `estimated_fee` of `required_fees() == total_fees_charged + 1`, alongside the metered receipt, - /// events, diff, and logs the full execution produced. + /// `estimated_fee` of `required_fees()`, alongside the metered receipt, events, diff, and logs + /// the full execution produced. #[test] fn parses_dry_run_with_estimated_fee() { let exec = execute_result(TransactionResult::Accept(accept_diff()), Some(5)); - // The metered total: Initial(7) + Storage((1<<53)+11) = (1<<53)+18; required = +1. - let expected_estimate = ((1u64 << 53) + 18) + 1; + // The metered total: Initial(7) + Storage((1<<53)+11) = (1<<53)+18, plus the fee-estimate + // allowance the engine adds on top. + let expected_estimate = exec.finalize.fee_receipt.required_fees(); + assert!(expected_estimate > (1u64 << 53) + 18, "the estimate must clear 2^53"); let json = dry_run_wire_json(exec); // Both the shape-dispatching entry point and the explicit dry-run parser agree. diff --git a/crates/ootle_sdk_core/src/types/result.rs b/crates/ootle_sdk_core/src/types/result.rs index 6b18233a3b..be33809c9a 100644 --- a/crates/ootle_sdk_core/src/types/result.rs +++ b/crates/ootle_sdk_core/src/types/result.rs @@ -231,8 +231,9 @@ pub struct FinalizedResult { /// The estimated minimum fee (in microtari), surfaced **only for a dry-run** result. /// /// A dry-run executes the transaction without committing and meters the fee; this is the engine's - /// [`tari_engine_types::fees::FeeReceipt::required_fees`] (`total_fees_charged + 1`) — the minimum - /// `max_fee` to use for the real submission. It is a native `u64` (never a float). A committed + /// [`tari_engine_types::fees::FeeReceipt::required_fees`] — the metered cost plus the allowance for + /// the drift a differing `max_fee` introduces, i.e. the minimum `max_fee` the real submission can + /// use. It is a native `u64` (never a float). A committed /// (non-dry-run) result leaves this `None` and exposes the realized fee in `fee_receipt` instead /// (additive field, omitted from JSON when absent so existing committed-result fixtures are /// unchanged). diff --git a/crates/ootle_sdk_core/tests/golden_vectors.rs b/crates/ootle_sdk_core/tests/golden_vectors.rs index 0e11b9e863..63a731c36e 100644 --- a/crates/ootle_sdk_core/tests/golden_vectors.rs +++ b/crates/ootle_sdk_core/tests/golden_vectors.rs @@ -507,6 +507,7 @@ fn parse_execute_result( logs: vec![LogEntry::new(LogLevel::Info, "transfer executed".to_string())], execution_results: Vec::new(), result, + total_fees_required: parse_fee_receipt().total_fees_charged(), fee_receipt: parse_fee_receipt(), }; @@ -598,8 +599,8 @@ fn parse_dry_run_wire_json(result: tari_engine_types::commit_result::ExecuteResu } /// Dry-run vector: an executed-but-uncommitted transaction whose `estimated_fee` is surfaced -/// (`required_fees == total_fees_charged + 1`). Proves the additive field rides the existing parse op, -/// the `> 2^53` fee stays a bare u64, and the committed fixtures (which omit `estimated_fee`) are +/// (`required_fees`, the metered cost plus the dry-run fee allowance). Proves the additive field rides the existing +/// parse op, the `> 2^53` fee stays a bare u64, and the committed fixtures (which omit `estimated_fee`) are /// unaffected. fn parse_dry_run_fixture_seed() -> Fixture { use tari_engine_types::commit_result::TransactionResult; diff --git a/crates/tari_bor/src/walker.rs b/crates/tari_bor/src/walker.rs index 25880efa4f..2bdb8e2faa 100644 --- a/crates/tari_bor/src/walker.rs +++ b/crates/tari_bor/src/walker.rs @@ -33,8 +33,11 @@ where match value { Value::Integer(_) | Value::Bytes(_) | Value::Float(_) | Value::Text(_) | Value::Bool(_) | Value::Null => {}, Value::Tag(tag, val) => { - let val = T::try_from_tag_and_value(*tag, val)?; - let flow = visitor.visit(val)?; + // A tag the visitor does not claim still wraps a value that may contain ones it does. + let Some(claimed) = T::try_from_tag_and_value(*tag, val)? else { + return walk_all_depth(val, visitor, max_depth, depth + 1); + }; + let flow = visitor.visit(claimed)?; return Ok(flow); }, Value::Array(values) => { @@ -76,6 +79,8 @@ impl Result, E>, T, E> ValueVisitor for F { pub trait FromTagAndValue { type Error; - fn try_from_tag_and_value(tag: u64, value: &Value) -> Result + /// `None` when `tag` is not one this type represents — a standard CBOR tag, say — leaving the + /// walker to descend into the tagged value rather than fail on it. + fn try_from_tag_and_value(tag: u64, value: &Value) -> Result, Self::Error> where Self: Sized; } diff --git a/crates/template_lib_types/src/amount/amount.rs b/crates/template_lib_types/src/amount/amount.rs index 4fe30ef1ee..abbf5d84de 100644 --- a/crates/template_lib_types/src/amount/amount.rs +++ b/crates/template_lib_types/src/amount/amount.rs @@ -13,10 +13,7 @@ impl minicbor::Encode for Amount { e: &mut minicbor::Encoder, _ctx: &mut C, ) -> Result<(), minicbor::encode::Error> { - let [lo, hi] = self.to_le_digits(); - e.array(2)?; - e.u64(lo)?; - e.u64(hi)?; + e.u128(self.0)?; Ok(()) } } @@ -24,42 +21,9 @@ impl minicbor::Encode for Amount { impl<'b, C> minicbor::Decode<'b, C> for Amount { fn decode(d: &mut minicbor::Decoder<'b>, _ctx: &mut C) -> Result { use minicbor::data::Type; - let ty = d.datatype()?; - match ty { - Type::Array | Type::ArrayIndef => { - let n = d.array()?; - let mut digits = [0u64; 2]; - match n { - Some(len) => { - if len != 2 { - return Err(minicbor::decode::Error::message("Amount: expected 2-element array")); - } - for slot in &mut digits { - *slot = d.u64()?; - } - }, - None => { - let mut idx = 0usize; - loop { - if matches!(d.datatype()?, Type::Break) { - d.skip()?; - break; - } - if idx >= 2 { - return Err(minicbor::decode::Error::message("Amount: too many elements")); - } - digits[idx] = d.u64()?; - idx += 1; - } - }, - } - Ok(Amount::from_le_digits(digits)) - }, - Type::U8 | Type::U16 | Type::U32 | Type::U64 => Ok(Amount::from(d.u64()?)), - Type::I8 | Type::I16 | Type::I32 | Type::I64 => { - let v = d.i64()?; - Amount::try_from(v).map_err(|e| minicbor::decode::Error::message(format!("Amount: {}", e))) - }, + match d.datatype()? { + // Amounts also arrive as decimal strings, from callers whose own encoding cannot carry a + // 128-bit integer. Type::String => { let s = d.str()?; s.parse::() @@ -73,28 +37,20 @@ impl<'b, C> minicbor::Decode<'b, C> for Amount { s.parse::() .map_err(|e| minicbor::decode::Error::message(format!("Amount: invalid string '{}': {}", s, e))) }, - other => Err(minicbor::decode::Error::message(format!( - "Amount: unexpected CBOR datatype {:?}", - other - ))), + _ => d.u128().map(Amount::new), } } } impl minicbor::CborLen for Amount { fn cbor_len(&self, ctx: &mut C) -> usize { - let [lo, hi] = self.to_le_digits(); - // array(2) + two u64 encodings - let mut total = >::cbor_len(&2u64, ctx); - total += >::cbor_len(&lo, ctx); - total += >::cbor_len(&hi, ctx); - total + >::cbor_len(&self.0, ctx) } } -/// A 128-bit signed amount. +/// A 128-bit unsigned amount. /// -/// This is a general purpose signed integer, but is primarily used to represent the smallest unit of value in +/// This is a general purpose unsigned integer, but is primarily used to represent the smallest unit of value in /// resources/vaults etc. /// /// This allows Tari to support a massive number tokens within resources. diff --git a/crates/template_lib_types/src/amount/serde.rs b/crates/template_lib_types/src/amount/serde.rs index 03aeeb547d..3f808ead7a 100644 --- a/crates/template_lib_types/src/amount/serde.rs +++ b/crates/template_lib_types/src/amount/serde.rs @@ -156,10 +156,16 @@ mod tests { let decoded: Amount = decode_exact(&cbor).unwrap(); assert_eq!(decoded, amount); - // Raw format - let cbor = encode(&[123u64, 0]).unwrap(); + // Above u64::MAX the encoder switches to a bignum, and the decoder has to follow it back. + let amount = Amount::from(u128::from(u64::MAX) + 1); + let cbor = encode(&amount).unwrap(); + let decoded: Amount = decode_exact(&cbor).unwrap(); + assert_eq!(decoded, amount); + + let amount = Amount::MAX; + let cbor = encode(&amount).unwrap(); let decoded: Amount = decode_exact(&cbor).unwrap(); - assert_eq!(decoded, 123); + assert_eq!(decoded, amount); // Decode directly from a number. let amount = 123i32; diff --git a/crates/template_test_tooling/src/template_test.rs b/crates/template_test_tooling/src/template_test.rs index 4f7ecf2290..e1dc2d8d52 100644 --- a/crates/template_test_tooling/src/template_test.rs +++ b/crates/template_test_tooling/src/template_test.rs @@ -112,14 +112,15 @@ pub struct TemplateTest { state_store: MemoryStateStore, enable_fees: bool, fee_table: FeeTable, + burn_rate_bps: u16, virtual_substates: HashMap, key_seed: u8, auto_add_proofs_from_signers: bool, /// Uniquifies each transaction built via [`Self::transaction`]. The transaction id excludes /// the seal signature, so two transactions with identical bodies sealed by the same test key - /// are the *same* transaction — and would collide on id-derived addresses. A distinct - /// `max_epoch` per transaction keeps bodies distinct; the engine does not evaluate epoch - /// bounds, so this has no behavioural effect. + /// are the *same* transaction — and would collide on id-derived addresses. Feeding this to the + /// nonce keeps their bodies distinct, and keeps them reproducible across runs the way a random + /// nonce would not. transaction_seq: Cell, } @@ -254,6 +255,7 @@ impl TemplateTest { per_template_size_premium_unit_cost: 100, per_template_publish_cost: 250_000, }, + burn_rate_bps: 0, key_seed: 1, auto_add_proofs_from_signers: true, } @@ -336,6 +338,13 @@ impl TemplateTest { self } + /// Sets the exhaust burn rate applied to the transaction's accrued fees. Defaults to zero, so + /// tests see no burn unless they ask for one. + pub fn set_burn_rate_bps(&mut self, rate_bps: u16) -> &mut Self { + self.burn_rate_bps = rate_bps; + self + } + /// Sets a virtual substate (e.g. `CurrentEpoch`) that is available to transactions during execution. pub fn set_virtual_substate(&mut self, address: VirtualSubstateId, value: VirtualSubstate) -> &mut Self { self.virtual_substates.insert(address, value); @@ -737,7 +746,7 @@ impl TemplateTest { Arc::from(modules.into_boxed_slice()), Arc::new(AlwaysPassesProofVerifier), wasm_metering_rate, - 0, + self.burn_rate_bps, false, ); @@ -792,12 +801,22 @@ impl TemplateTest { /// Returns a new [`TransactionBuilder`] configured for the local test network. /// Use this to construct custom transactions with multiple instructions. /// - /// Each returned builder carries a distinct `max_epoch` so that otherwise-identical - /// transactions produce distinct transaction ids (see [`Self::transaction_seq`]). + /// The builder is valid for the epoch the harness is executing in, and carries a distinct nonce + /// so that otherwise-identical transactions get distinct ids (see [`Self::transaction_seq`]). pub fn transaction(&self) -> TransactionBuilder { let seq = self.transaction_seq.get(); self.transaction_seq.set(seq + 1); - Transaction::builder(Network::LocalNet, Epoch(seq)) + Transaction::builder(Network::LocalNet, self.current_epoch()).with_nonce(seq) + } + + /// The epoch transactions execute in, as injected via [`Self::set_virtual_substate`]. Tests that + /// remove the virtual substate entirely still have to build transactions, so those fall back to + /// the genesis epoch. + pub fn current_epoch(&self) -> Epoch { + match self.virtual_substates.get(&VirtualSubstateId::CurrentEpoch) { + Some(VirtualSubstate::CurrentEpoch(epoch)) => Epoch(*epoch), + _ => Epoch(0), + } } /// Executes a transaction. Panics if the transaction is not finalized (fee transaction fails). Does not panic if diff --git a/crates/transaction/src/v1/transaction.rs b/crates/transaction/src/v1/transaction.rs index 3b5d46fae6..4867d379fe 100644 --- a/crates/transaction/src/v1/transaction.rs +++ b/crates/transaction/src/v1/transaction.rs @@ -394,13 +394,18 @@ fn calc_stealth_statement_weight(statement: &StealthTransferStatement) -> u64 { witness_bytes / SPEND_WITNESS_BYTE_DIVISOR } +/// Inline literal args carry their bytes directly in the instruction, so they are priced by size, +/// consistent with blob/log byte costing. Applied once across an instruction's whole literal +/// payload. +/// +/// Public because the dry-run fee allowance is derived from it: the encoded width of the `max_fee` +/// literal is the one term that can make a real run weigh more than the dry run that estimated it. +pub const LITERAL_BYTE_DIVISOR: u64 = 3; + fn calc_args_weight(args: &[InstructionArg]) -> u64 { // Workspace and blob refs are cheap — just an index. Blob payloads are charged at the // transaction level by `calc_blobs_weight`, so we don't double-count them here. const NON_LITERAL_WEIGHT: u64 = 1; - // Inline literal args carry their bytes directly in the instruction, so price them by size, - // consistent with blob/log byte costing. - const LITERAL_BYTE_DIVISOR: u64 = 3; // Accumulate the raw literal bytes and apply the divisor once across the whole instruction. // Dividing per-argument would let a large literal be split into many small ones so each share diff --git a/crates/wallet/ootle-rs/examples/stealth_transfer.rs b/crates/wallet/ootle-rs/examples/stealth_transfer.rs index 963f11f576..a376b3df87 100644 --- a/crates/wallet/ootle-rs/examples/stealth_transfer.rs +++ b/crates/wallet/ootle-rs/examples/stealth_transfer.rs @@ -32,7 +32,7 @@ async fn main() { // .init(); // This is the address that we will transfer to (Feel free to change this another address!) - let recipient = address!( "otl_loc_1em0npr8f3uzs3fznygglr4eujwl0qgr6e30hu3whr9fpfh2w99r743uxd7qg6f7jchgm6pdht7lpcwcggcjgfejfpd4jgvfmzve9vec5xep5t" ); + let recipient = address!( "otl_loc_1c62vh8e5cx3uwyypdp2gsxvywa97vy26z3mk4337ajhqw5fhqgm0cwcc07fn6gs34sqkeddzhcjwnsc6g3eeeyhv5heatuwg8l7lzmsk2kzfy" ); let indexer_api_url = default_indexer_url(recipient.network()); @@ -71,19 +71,23 @@ async fn main() { // Send some TARI to another address. You can replace TARI with any other fungible token resource address. let tari_token = TARI_TOKEN; // resource_address!("resource_0123456789abcdef..."); - // The faucet funds are split across two outputs so that the transfer below spends two stealth inputs. One of them - // seals that transaction and the other must attach an authorization signature committing to the seal signer's - // one-time public key. + // The faucet funds are split across two outputs because the transfer below is split across two statements: a + // small one in the fee intent that sources the fee, and the sixteen-output one in the main intent that the fee + // then pays for. Each statement spends its own input. One of the two inputs seals that transaction and the other + // attaches an authorization signature committing to the seal signer's one-time public key. // The revealed amount each transaction reserves to pay its fee. It is deliberately generous rather than fitted to // a dry-run estimate: the budget is part of the transfer statement, so spending an estimate would change the // transaction it was estimated from. Whatever is not charged is refunded (see the receipt's overcharge line). // - // It has to cover the second transfer's sixteen outputs, whose aggregated range proof alone prices at roughly - // 98,000 µT under the testnet schedule (~6,000 µT per output plus the fixed per-statement cost). + // It also has to fund the *compute allowance* the sixteen-output statement verifies under, not just the fee that + // statement is charged: the allowance is what the payment buys, at the fee table's point rate. const FEE_BUDGET: u64 = 250_000; - const FIRST_INPUT_AMOUNT: u64 = 6 * TARI; - const SECOND_INPUT_AMOUNT: u64 = 10 * TARI + FEE_BUDGET - FIRST_INPUT_AMOUNT; + // Spent by the fee-intent statement: the budget it reveals to pay the fee, plus a stealth output so the statement + // has somewhere to put the remainder. + const FEE_INPUT_AMOUNT: u64 = FEE_BUDGET + TARI; + // Spent by the main-intent statement, which fans it out into sixteen outputs. + const TRANSFER_INPUT_AMOUNT: u64 = 10 * TARI + FEE_BUDGET - FEE_INPUT_AMOUNT; // // This builder creates a stealth transfer statement (spend proof). This is added to the transaction later. let (faucet_transfer, required_signers) = StealthTransfer::new(tari_token, &provider) // Tell the transfer to expect 10 TARI (plus a fee budget for this transaction and the transfer below) as revealed funds from a bucket (the faucet looks at this value and automatically provides the bucket). @@ -94,10 +98,10 @@ async fn main() { // but a supporting wallet that holds the secret key would be able to spend the output. // You can specify any address here and split up into many outputs as needed, as long as ∑inputs == ∑outputs. .to_stealth_output( - Output::new(sender_address.clone(), tari_token, const_nonzero_u64!(FIRST_INPUT_AMOUNT)) + Output::new(sender_address.clone(), tari_token, const_nonzero_u64!(FEE_INPUT_AMOUNT)) ) .to_stealth_output( - Output::new(sender_address.clone(), tari_token, const_nonzero_u64!(SECOND_INPUT_AMOUNT)) + Output::new(sender_address.clone(), tari_token, const_nonzero_u64!(TRANSFER_INPUT_AMOUNT)) ) .prepare() .await @@ -127,31 +131,47 @@ async fn main() { let pending_tx = provider.send_transaction(transaction).await.unwrap(); print_fancy_results("Faucet transfer", &pending_tx).await; - // Then we'll send it to the recipient - // This builder creates a stealth transfer statement (spend proof). This is added to the transaction later. + // Then we'll send it to the recipient, across two statements. // - // One statement may carry up to 16 stealth outputs, and all of them share a single aggregated range proof. That - // is what makes fanning out inside one statement cheaper than splitting the same outputs across several - // transfers, each of which would pay the fixed per-statement cost and need its own change output. Here the - // budget goes to two outputs worth spending plus fourteen dust ones. + // The fee intent runs on a fixed credit of compute before anything has been paid — enough to source a fee, and no + // more. Verifying sixteen outputs costs several times that, so a statement that size cannot live there: it is the + // main intent that the fee, once paid, buys the compute allowance for. The engine allows the fee intent exactly + // one transfer statement for this reason, and this is the shape it expects — a small statement that reveals the + // fee, then the real transfer. + + // The fee-sourcing statement: reveal the budget to pay the fee, and put the remainder in a stealth output so the + // statement balances. Two outputs verify well inside the pre-payment credit. + let (fee_transfer, fee_signers) = StealthTransfer::new(tari_token, &provider) + .spend_stealth_input(sender_address.clone(), inputs_to_spend[0].commitment()) + .to_revealed_output(FEE_BUDGET) + .to_stealth_output(Output::new( + sender_address.clone(), + tari_token, + const_nonzero_u64!(FEE_INPUT_AMOUNT - FEE_BUDGET), + )) + .prepare() + .await + .unwrap(); + + // The transfer itself. One statement may carry up to 16 stealth outputs, and all of them share a single + // aggregated range proof. That is what makes fanning out inside one statement cheaper than splitting the same + // outputs across several transfers, each of which would pay the fixed per-statement cost and need its own change + // output. Here the input goes to two outputs worth spending plus fourteen dust ones. const DUST_OUTPUT_COUNT: u64 = 14; // Dust in the literal sense: each of these holds 1 µT while costing ~6,000 µT of range-proof verification to // create. Fine for showing the fan-out, ruinous as a spending habit. const DUST_AMOUNT: u64 = 1; const RECIPIENT_AMOUNT: u64 = 8 * TARI; // The change absorbs the dust so that ∑inputs == ∑outputs still holds. - const CHANGE_AMOUNT: u64 = 2 * TARI - DUST_OUTPUT_COUNT * DUST_AMOUNT; + const CHANGE_AMOUNT: u64 = TRANSFER_INPUT_AMOUNT - RECIPIENT_AMOUNT - DUST_OUTPUT_COUNT * DUST_AMOUNT; let transfer_builder = StealthTransfer::new(tari_token, &provider) - // Spend both existing stealth inputs that are controlled by the sender address. - // Together these are worth 10 TARI plus the second transaction's fee budget. - .spend_stealth_input(sender_address.clone(), inputs_to_spend[0].commitment()) + // Spend the other stealth input controlled by the sender address. This statement pays no fee of its own — the + // fee-sourcing statement above covers the whole transaction. .spend_stealth_input(sender_address.clone(), inputs_to_spend[1].commitment()) - // The transfer will output the fee budget as revealed funds to pay for the fee. - .to_revealed_output(FEE_BUDGET) // Spend to a new output (8 TARI) that we'll generate for the recipient address. .to_stealth_output( - Output::new(recipient, tari_token, const_nonzero_u64!(RECIPIENT_AMOUNT)) + Output::new(recipient.clone(), tari_token, const_nonzero_u64!(RECIPIENT_AMOUNT)) // NOTE: this memo is stored on-chain, and longer memos increase fees. It is encrypted so that only the recipient can read it. .with_memo_message("transfer from ootle-rs!") ) @@ -161,7 +181,7 @@ async fn main() { // Fan the rest of the statement out into dust, taking the output count up to the per-statement maximum. let transfer_builder = (0..DUST_OUTPUT_COUNT).fold(transfer_builder, |builder, _| { builder.to_stealth_output(Output::new( - sender_address.clone(), + recipient.clone(), tari_token, NonZeroU64::new(DUST_AMOUNT).expect("DUST_AMOUNT is non-zero"), )) @@ -169,17 +189,20 @@ async fn main() { // Load the inputs from the provider to build the transfer statement. NOTE: this will error if the total input // amounts != total output amounts. - let (transfer, required_signers) = transfer_builder.prepare().await.unwrap(); + let (transfer, transfer_signers) = transfer_builder.prepare().await.unwrap(); // We'll generate an unsigned transaction directly using the Transaction builder. In future, we may make this // easier. let unsigned_tx = Transaction::builder(provider.network(), max_epoch) .with_fee_instructions_builder(|builder| { builder - .stealth_transfer(tari_token, transfer) + .stealth_transfer(tari_token, fee_transfer) .put_last_instruction_output_on_workspace("fees") .pay_fee_from_bucket("fees") }) + // The sixteen-output statement, funded by the fee the instructions above just paid. It reveals nothing, so it + // leaves no bucket behind to account for. + .stealth_transfer(tari_token, transfer) // This isn't necessary because all transactions implicitly use TARI for fees, but you'd need to include this if other resources are being used .add_input(tari_token) // Add the UTXO substates as inputs. These will be DOWNed (destroyed) if the transaction is successful. @@ -187,8 +210,11 @@ async fn main() { .add_input(UtxoAddress::new(tari_token, inputs_to_spend[1].commitment().into())) .build_unsigned(); - // This authorizer adds the required (stealth) signatures to spend inputs - let authorizer = provider.wallet().stealth_authorizer(required_signers); + // Both statements are spent by one transaction, which has a single seal: merging their requirements settles which + // input seals it and leaves the other to authorize against that seal signer's one-time public key. + let authorizer = provider + .wallet() + .stealth_authorizer(fee_signers.merge(transfer_signers)); let result = provider .sign_and_send_dry_run_with(&authorizer, unsigned_tx.clone()) diff --git a/crates/wallet/ootle-rs/src/stealth/spec.rs b/crates/wallet/ootle-rs/src/stealth/spec.rs index 143aa7799f..56dc6ebb80 100644 --- a/crates/wallet/ootle-rs/src/stealth/spec.rs +++ b/crates/wallet/ootle-rs/src/stealth/spec.rs @@ -56,6 +56,25 @@ pub enum SealSource { Ephemeral, } +impl SealSource { + /// Resolves two seals down to the one that seals the transaction, along with the stealth signer + /// displaced in the process — which must then authorize instead. See + /// [`SignatureRequirements::merge`] for the ordering and why it holds. + fn take_precedence_over(self, other: Self) -> (Self, Option) { + match (self, other) { + (Self::AccountKey, Self::StealthInput(displaced)) | (Self::StealthInput(displaced), Self::AccountKey) => { + (Self::AccountKey, Some(displaced)) + }, + (Self::AccountKey, _) | (_, Self::AccountKey) => (Self::AccountKey, None), + (Self::StealthInput(seal), Self::StealthInput(displaced)) => (Self::StealthInput(seal), Some(displaced)), + (Self::StealthInput(seal), Self::Ephemeral) | (Self::Ephemeral, Self::StealthInput(seal)) => { + (Self::StealthInput(seal), None) + }, + (Self::Ephemeral, Self::Ephemeral) => (Self::Ephemeral, None), + } + } +} + /// Which key seals a stealth transfer transaction, and which stealth signers must additionally authorize it. /// /// Every stealth input has to be authorized by its owner's one-time key. One of those signers seals the transaction — @@ -116,6 +135,31 @@ impl SignatureRequirements { &self.seal } + /// Combines the requirements of two statements carried by one transaction. + /// + /// A transaction has a single seal, so one of the two gives way. An account key outranks a + /// stealth input, since a transaction that touches the account component must be sealed by the + /// account key whatever else it does, and either outranks an ephemeral key, which exists only + /// for the case where nothing needs signing. A stealth signer whose seal gives way still has to + /// authorize its own input, so it joins the authorizers. + /// + /// Splitting a transfer across the fee intent and the main intent is what makes this necessary: + /// the fee intent may carry one statement, and any further statement belongs in the main intent + /// where the fee just paid funds its verification. + #[must_use] + pub fn merge(self, other: Self) -> Self { + let (seal, displaced) = self.seal.take_precedence_over(other.seal); + let mut authorizers = self.authorizers; + authorizers.extend(displaced); + authorizers.extend(other.authorizers); + // A sealing input's seal signature is its own authorization, so it must not also be asked + // for one. + if let SealSource::StealthInput(seal_signer) = &seal { + authorizers.swap_remove(seal_signer); + } + Self { seal, authorizers } + } + pub fn into_parts(self) -> (SealSource, IndexSet) { (self.seal, self.authorizers) } @@ -352,4 +396,96 @@ mod tests { assert_eq!(spec.authorizers().len(), 0); } } + + /// Merging is what lets one transaction carry a fee-sourcing statement and a second, larger one + /// whose verification the fee pays for. The transaction still has a single seal, so the cases + /// below fix which of the two survives and what becomes of the signer it displaces. + mod merging_two_statements { + use super::*; + + /// The displaced statement's seal signer still owns an input, so it has to authorize. + #[test] + fn a_displaced_stealth_seal_becomes_an_authorizer() { + let fee = SignatureRequirements::stealth_seal(signers([1])); + let transfer = SignatureRequirements::stealth_seal(signers([2])); + + let merged = fee.merge(transfer); + + assert_eq!(merged.seal(), &SealSource::StealthInput(signer_from_seed(1))); + assert_eq!(merged.authorizers().cloned().collect::>(), vec![ + signer_from_seed(2) + ]); + } + + /// Authorizers from both statements are carried over, not just the displaced seal signer. + #[test] + fn authorizers_from_both_statements_are_kept() { + let fee = SignatureRequirements::stealth_seal(signers([1, 2])); + let transfer = SignatureRequirements::stealth_seal(signers([3, 4])); + + let merged = fee.merge(transfer); + + assert_eq!(merged.seal(), &SealSource::StealthInput(signer_from_seed(1))); + assert_eq!(merged.authorizers().cloned().collect::>(), vec![ + signer_from_seed(2), + signer_from_seed(3), + signer_from_seed(4), + ]); + } + + /// A transaction touching the account component must be sealed by the account key, whichever + /// statement needed it and whatever the other one asked for. + #[test] + fn an_account_key_seal_outranks_a_stealth_one() { + let fee = SignatureRequirements::stealth_seal(signers([1])); + let transfer = SignatureRequirements::account_key_seal_with(signers([2])); + + let merged = fee.clone().merge(transfer.clone()); + assert_eq!(merged.seal(), &SealSource::AccountKey); + // Nothing seals on its behalf now, so signer 1 authorizes too. + assert_eq!(merged.authorizers().cloned().collect::>(), vec![ + signer_from_seed(1), + signer_from_seed(2), + ]); + + // The same holds whichever way round the two are merged. + assert_eq!(transfer.merge(fee).seal(), &SealSource::AccountKey); + } + + /// An ephemeral seal exists only where nothing needs signing, so it yields to anything real. + #[test] + fn an_ephemeral_seal_yields() { + let ephemeral = SignatureRequirements::stealth_seal(IndexSet::new()); + let stealth = SignatureRequirements::stealth_seal(signers([1])); + + assert_eq!( + ephemeral.clone().merge(stealth.clone()).seal(), + &SealSource::StealthInput(signer_from_seed(1)) + ); + assert_eq!( + stealth.merge(ephemeral.clone()).seal(), + &SealSource::StealthInput(signer_from_seed(1)) + ); + assert_eq!(ephemeral.clone().merge(ephemeral).seal(), &SealSource::Ephemeral); + } + + /// The sealing signature doubles as that input's authorization, so the seal signer must not + /// also be asked for one. + #[test] + fn the_sealing_signer_is_never_also_an_authorizer() { + let fee = SignatureRequirements::stealth_seal(signers([1])); + let transfer = SignatureRequirements::account_key_seal_with(signers([1])); + + // Account key seals, so signer 1 appears only once, as an authorizer. + let merged = fee.clone().merge(transfer); + assert_eq!(merged.authorizers().cloned().collect::>(), vec![ + signer_from_seed(1) + ]); + + // Signer 1 seals, so it is not also listed. + let merged = fee.merge(SignatureRequirements::stealth_seal(signers([1]))); + assert_eq!(merged.seal(), &SealSource::StealthInput(signer_from_seed(1))); + assert_eq!(merged.authorizers().len(), 0); + } + } } diff --git a/utilities/traffic-sim/src/sim.rs b/utilities/traffic-sim/src/sim.rs index 23c4303f13..a21bece5fc 100644 --- a/utilities/traffic-sim/src/sim.rs +++ b/utilities/traffic-sim/src/sim.rs @@ -252,7 +252,7 @@ impl TrafficSim { attach_sender_address: false, pay_ref: None, }], - max_fee: 10000, + max_fee: crate::MAX_FEE, dry_run: false, }) .await?; @@ -299,7 +299,7 @@ impl TrafficSim { .client .create_free_test_coins(AccountsCreateFreeTestCoinsRequest { account: resp.account.component_address.into(), - max_fee: 1500, + max_fee: crate::MAX_FEE, }) .await?; AccountWithAddress::new(resp.account, resp.address) @@ -457,7 +457,7 @@ impl TrafficSim { client .create_free_test_coins(AccountsCreateFreeTestCoinsRequest { account: (*account.component_address()).into(), - max_fee: 1500, + max_fee: crate::MAX_FEE, }) .await?; }