From 5613b9c9946e27b3dfde04cf8031fb5537f56841 Mon Sep 17 00:00:00 2001 From: hude Date: Fri, 29 May 2026 17:59:02 +0900 Subject: [PATCH 01/19] WIP storage billing decimal credits --- Cargo.lock | 12 + crates/vfs_canister/Cargo.toml | 1 + crates/vfs_canister/src/lib.rs | 68 +- crates/vfs_canister/src/tests.rs | 80 +- crates/vfs_canister/vfs.did | 21 +- crates/vfs_cli_core/src/commands.rs | 5 +- crates/vfs_client/src/lib.rs | 188 +++- .../migrations/index_db/011_to_latest.sql | 11 +- .../index_db/fresh_index_schema.sql | 11 +- crates/vfs_runtime/src/lib.rs | 956 +++++++++++++++--- crates/vfs_runtime/tests/database_service.rs | 69 +- .../vfs_runtime/tests/database_service_pbt.rs | 27 +- .../tests/database_service_pbt_ext.rs | 33 +- crates/vfs_types/src/fs.rs | 24 +- docs/DB_LIFECYCLE.md | 21 +- .../scripts/check-candid-drift.mjs | 6 +- extensions/wiki-clipper/src/vfs-actor.js | 10 +- .../wiki-clipper/tests/offscreen.test.mjs | 12 +- .../wiki-clipper/tests/settings.test.mjs | 2 +- wikibrowser/app/credits/credits-client.tsx | 2 +- wikibrowser/components/wiki-browser.tsx | 6 +- wikibrowser/lib/credits-state.ts | 7 +- wikibrowser/lib/credits-url.ts | 11 +- wikibrowser/lib/credits-wallet.ts | 29 +- wikibrowser/lib/vfs-client.ts | 28 +- wikibrowser/lib/vfs-idl.ts | 21 +- wikibrowser/scripts/candid-shapes.mjs | 21 +- wikibrowser/scripts/check-credits.mjs | 6 +- wikibrowser/scripts/generate-vfs-idl.mjs | 1 + 29 files changed, 1341 insertions(+), 348 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 42070121..c9ab0101 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1389,6 +1389,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ic-cdk-timers" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6852b9c1d4a82ff50fc7318599298aee8bfb082bd7e9fe7e5c1420692b2170f7" +dependencies = [ + "ic-cdk-executor", + "ic0", + "slotmap", +] + [[package]] name = "ic-certification" version = "3.2.0" @@ -3487,6 +3498,7 @@ dependencies = [ "canbench-rs", "candid", "ic-cdk 0.20.1", + "ic-cdk-timers", "ic-http-certification", "ic-sqlite-vfs", "ic-stable-structures", diff --git a/crates/vfs_canister/Cargo.toml b/crates/vfs_canister/Cargo.toml index 2973e335..eae48484 100644 --- a/crates/vfs_canister/Cargo.toml +++ b/crates/vfs_canister/Cargo.toml @@ -13,6 +13,7 @@ canbench-rs = ["dep:canbench-rs", "dep:serde_json"] canbench-rs = { version = "0.4.1", optional = true } candid = "0.10.26" ic-cdk = "0.20.0" +ic-cdk-timers = "1.0.0" ic-http-certification = "3.2.0" ic-stable-structures = "0.7.2" serde = { version = "1.0.228", features = ["derive"] } diff --git a/crates/vfs_canister/src/lib.rs b/crates/vfs_canister/src/lib.rs index e1d86db0..bab2d6e3 100644 --- a/crates/vfs_canister/src/lib.rs +++ b/crates/vfs_canister/src/lib.rs @@ -8,6 +8,8 @@ use std::collections::BTreeMap; use std::fs::create_dir_all; #[cfg(not(target_arch = "wasm32"))] use std::path::PathBuf; +#[cfg(target_arch = "wasm32")] +use std::time::Duration; #[cfg(any(target_arch = "wasm32", test))] use candid::utils::decode_args; @@ -15,6 +17,8 @@ use candid::{CandidType, Decode, Deserialize, Nat, Principal, export_service}; #[cfg(not(test))] use ic_cdk::call::Call; use ic_cdk::{init, post_upgrade, query, update}; +#[cfg(target_arch = "wasm32")] +use ic_cdk_timers::set_timer_interval; use ic_http_certification::{ CERTIFICATE_EXPRESSION_HEADER_NAME, DefaultCelBuilder, DefaultResponseCertification, HttpCertification, HttpCertificationPath, HttpCertificationTree, HttpCertificationTreeEntry, @@ -26,6 +30,8 @@ use ic_sqlite_vfs::{Db, DbHandle}; use ic_stable_structures::DefaultMemoryImpl; #[cfg(target_arch = "wasm32")] use ic_stable_structures::memory_manager::{MemoryId, MemoryManager}; +#[cfg(target_arch = "wasm32")] +use vfs_runtime::STORAGE_BILLING_INTERVAL_MS; use vfs_runtime::{ CreditsPendingLedgerDetailsInput, DatabaseCreditPurchaseWithLedgerDetails, DatabaseMeta, RequiredRole, VfsService, @@ -278,6 +284,7 @@ enum TransferFromError { fn init_hook(config: CreditsConfig) { initialize_or_trap(Some(config)); certify_http_responses(); + schedule_storage_billing_timer(); } #[post_upgrade] @@ -285,6 +292,7 @@ fn post_upgrade_hook() { let config = post_upgrade_credits_config_arg().unwrap_or_else(|error| ic_cdk::trap(&error)); initialize_upgrade_or_trap(config); certify_http_responses(); + schedule_storage_billing_timer(); } #[query] @@ -440,9 +448,9 @@ fn list_databases() -> Result, String> { #[query] fn preview_database_credit_purchase( database_id: String, - credits: u64, + credit_units: u64, ) -> Result { - with_service(|service| service.preview_database_credit_purchase(&database_id, credits)) + with_service(|service| service.preview_database_credit_purchase(&database_id, credit_units)) } #[query] @@ -469,7 +477,7 @@ fn icrc21_canister_call_consent_message( } }; let preview = match with_service(|service| { - service.preview_database_credit_purchase(&purchase.database_id, purchase.credits) + service.preview_database_credit_purchase(&purchase.database_id, purchase.credit_units) }) { Ok(preview) => preview, Err(error) => return icrc21_unsupported(error), @@ -490,7 +498,7 @@ fn icrc21_canister_call_consent_message( consent_message: Icrc21ConsentMessage::GenericDisplayMessage(format!( "# Purchase Kinic database credits\n\nDatabase: `{database_id}`\n\nCredits: `{credits}`\n\nPayment: `{payment}` KINIC\n\nLedger transfer fee in allowance: `{fee}` KINIC\n\nSpender canister: `{spender}`", database_id = purchase.database_id, - credits = purchase.credits, + credits = format_credit_units(purchase.credit_units), payment = format_e8s(preview.payment_amount_e8s), fee = format_e8s(preview.ledger_fee_e8s), spender = canister_principal().to_text() @@ -509,7 +517,7 @@ async fn purchase_database_credits( let ledger = Principal::from_text(&config.kinic_ledger_canister_id) .map_err(|error| format!("invalid KINIC ledger canister id: {error}"))?; let preview = with_service(|service| { - service.preview_database_credit_purchase(&request.database_id, request.credits) + service.preview_database_credit_purchase(&request.database_id, request.credit_units) })?; validate_credit_purchase_expectations(&request, &preview)?; let ledger_fee_e8s = preview.ledger_fee_e8s; @@ -521,7 +529,7 @@ async fn purchase_database_credits( DatabaseCreditPurchaseWithLedgerDetails { database_id: &request.database_id, caller: &caller, - credits: request.credits, + credit_units: request.credit_units, expected_payment_amount_e8s: request.expected_payment_amount_e8s, expected_config_version: request.expected_config_version, ledger: CreditsPendingLedgerDetailsInput { @@ -562,7 +570,7 @@ async fn purchase_database_credits( operation_id, &request.database_id, &caller, - request.credits, + request.credit_units, ) }) .map_err(|error| credit_purchase_local_apply_error(operation_id, block_index, error))?; @@ -584,7 +592,7 @@ async fn purchase_database_credits( operation_id, &request.database_id, &caller, - request.credits, + request.credit_units, block_index, now, ) @@ -592,7 +600,7 @@ async fn purchase_database_credits( .map_err(|error| credit_purchase_local_apply_error(operation_id, block_index, error))?; Ok(CreditsPurchaseResult { block_index, - balance_credits: balance, + balance_credit_units: balance, }) } LedgerTransferFromOutcome::LedgerErr(error) => { @@ -601,7 +609,7 @@ async fn purchase_database_credits( operation_id, &request.database_id, &caller, - request.credits, + request.credit_units, ) }); Err(error) @@ -612,7 +620,7 @@ async fn purchase_database_credits( operation_id, &request.database_id, &caller, - request.credits, + request.credit_units, now, ) }) { @@ -674,6 +682,14 @@ fn query_index_sql_json(sql: String, limit: u32) -> Result Result<(), String> { + require_controller_caller()?; + with_service(|service| { + service.settle_database_storage_charges(&canister_principal().to_text(), now_millis()) + }) +} + #[update] async fn repair_database_credit_purchase_complete( database_id: String, @@ -704,7 +720,7 @@ async fn repair_database_credit_purchase_complete( .map_err(|error| credit_purchase_local_apply_error(operation_id, ledger_block_index, error))?; Ok(CreditsPurchaseResult { block_index: ledger_block_index, - balance_credits: balance, + balance_credit_units: balance, }) } @@ -1137,6 +1153,21 @@ fn initialize_upgrade_or_trap(config: Option) { initialize_service_for_upgrade(config).unwrap_or_else(|error| ic_cdk::trap(&error)); } +fn schedule_storage_billing_timer() { + #[cfg(target_arch = "wasm32")] + { + let interval_ms = u64::try_from(STORAGE_BILLING_INTERVAL_MS).unwrap_or(24 * 60 * 60 * 1000); + set_timer_interval(Duration::from_millis(interval_ms), || async { + if let Err(error) = with_service(|service| { + service + .settle_database_storage_charges(&canister_principal().to_text(), now_millis()) + }) { + ic_cdk::println!("storage billing settle failed: {error}"); + } + }); + } +} + fn initialize_service_with_config(config: Option) -> Result<(), String> { initialize_sqlite_storage()?; #[cfg(not(target_arch = "wasm32"))] @@ -1357,6 +1388,19 @@ fn format_e8s(amount_e8s: u64) -> String { format!("{whole}.{fraction}") } +fn format_credit_units(units: u64) -> String { + let whole = units / 1000; + let fractional = units % 1000; + if fractional == 0 { + return whole.to_string(); + } + let mut fraction = format!("{fractional:03}"); + while fraction.ends_with('0') { + fraction.pop(); + } + format!("{whole}.{fraction}") +} + fn caller_text() -> String { #[cfg(test)] { diff --git a/crates/vfs_canister/src/tests.rs b/crates/vfs_canister/src/tests.rs index e3746198..4a02d14e 100644 --- a/crates/vfs_canister/src/tests.rs +++ b/crates/vfs_canister/src/tests.rs @@ -38,8 +38,8 @@ use super::{ read_node, read_node_context, rename_database, repair_database_credit_purchase_cancel, repair_database_credit_purchase_complete, revoke_database_access, search_node_paths, search_nodes, set_ledger_transaction_for_test, set_next_ledger_transfer_from_outcome_for_test, - set_test_caller_principal_for_test, source_evidence, status, transfer_from_error_outcome, - write_database_restore_chunk, write_node, write_nodes, + set_test_caller_principal_for_test, settle_database_storage_charges, source_evidence, status, + transfer_from_error_outcome, write_database_restore_chunk, write_node, write_nodes, }; fn install_test_service() { @@ -116,12 +116,12 @@ fn block_on_ready(future: impl Future) -> T { } } -fn credit_purchase_request(database_id: &str, credits: u64) -> DatabaseCreditPurchaseRequest { - let preview = preview_database_credit_purchase(database_id.to_string(), credits) +fn credit_purchase_request(database_id: &str, credit_units: u64) -> DatabaseCreditPurchaseRequest { + let preview = preview_database_credit_purchase(database_id.to_string(), credit_units) .expect("credit purchase preview should load"); DatabaseCreditPurchaseRequest { database_id: database_id.to_string(), - credits, + credit_units, expected_payment_amount_e8s: preview.payment_amount_e8s, expected_config_version: preview.config_version, } @@ -199,8 +199,8 @@ fn explicit_credits_config() -> CreditsConfig { CreditsConfig { kinic_ledger_canister_id: "aaaaa-aa".to_string(), sns_governance_id: "rrkah-fqaaa-aaaaa-aaaaq-cai".to_string(), - credits_per_kinic: 1_000, - min_update_credits: 1, + credit_units_per_kinic: 1_000, + min_update_credit_units: 1, } } @@ -225,7 +225,7 @@ fn controller_can_query_index_sql_json() { set_test_caller_principal_for_test(Principal::management_canister()); let result = query_index_sql_json( - "SELECT json_object('credit_purchase_credits', COALESCE(SUM(amount_credits), 0)) FROM database_credit_ledger WHERE kind = 'credit_purchase' LIMIT 1".to_string(), + "SELECT json_object('credit_purchase_credits', COALESCE(SUM(amount_credit_units), 0)) FROM database_credit_ledger WHERE kind = 'credit_purchase' LIMIT 1".to_string(), 10, ) .expect("controller should query index SQL"); @@ -251,6 +251,18 @@ fn index_sql_json_rejects_non_controller_callers() { assert!(error.contains("caller is not a canister controller")); } +#[test] +fn settle_database_storage_charges_rejects_non_controller_callers() { + install_test_service(); + let non_controller = Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai") + .expect("valid non-controller principal"); + set_test_caller_principal_for_test(non_controller); + + let error = settle_database_storage_charges().expect_err("non-controller should reject"); + + assert!(error.contains("caller is not a canister controller")); +} + #[test] fn index_sql_json_rejects_anonymous_callers() { install_test_service(); @@ -268,7 +280,7 @@ fn index_sql_json_rejects_mutating_and_multi_statement_sql() { set_test_caller_principal_for_test(Principal::management_canister()); for sql in [ - "UPDATE database_credit_accounts SET balance_credits = 0", + "UPDATE database_credit_accounts SET balance_credit_units = 0", "DELETE FROM database_credit_ledger", "INSERT INTO database_credit_ledger (database_id) VALUES ('x')", "CREATE TABLE x (id INTEGER)", @@ -296,7 +308,7 @@ fn index_sql_json_requires_text_json_first_column() { assert!(error.contains("one non-null TEXT JSON column")); } -fn fund_database(database_id: &str, amount_credits: u64, ledger_block_index: u64) { +fn fund_database(database_id: &str, amount_credit_units: u64, ledger_block_index: u64) { let principal = Principal::management_canister().to_text(); SERVICE.with(|slot| { let service = slot.borrow(); @@ -305,7 +317,7 @@ fn fund_database(database_id: &str, amount_credits: u64, ledger_block_index: u64 .begin_database_credit_purchase( database_id, &principal, - amount_credits, + amount_credit_units, 1_700_000_000_000, ) .expect("database credit purchase should begin"); @@ -314,7 +326,7 @@ fn fund_database(database_id: &str, amount_credits: u64, ledger_block_index: u64 operation_id, database_id, &principal, - amount_credits, + amount_credit_units, ) .expect("database credit purchase should be marked completed"); if service @@ -331,7 +343,7 @@ fn fund_database(database_id: &str, amount_credits: u64, ledger_block_index: u64 operation_id, database_id, &principal, - amount_credits, + amount_credit_units, ledger_block_index, 1_700_000_000_000, ) @@ -424,7 +436,7 @@ fn purchase_database_credits_credits_completed_transfer_from() { .expect("completed transfer-from should credit database"); assert_eq!(result.block_index, 42); - assert_eq!(result.balance_credits, 500); + assert_eq!(result.balance_credit_units, 500); assert_eq!( database_status_and_mount(&database.database_id).0, DatabaseStatus::Active @@ -448,13 +460,13 @@ fn preview_database_credit_purchase_rejects_invalid_target_before_approve() { let preview = preview_database_credit_purchase(database.database_id.clone(), 500) .expect("preview should accept"); - assert_eq!(preview.payment_amount_e8s, 50_000_000); + assert_eq!(preview.payment_amount_e8s, 50_000); assert_eq!(preview.ledger_fee_e8s, KINIC_LEDGER_FEE_E8S); - assert_eq!(preview.credits_per_kinic, 1_000); + assert_eq!(preview.credit_units_per_kinic, 1_000_000); assert_eq!(preview.config_version, 1); let zero = preview_database_credit_purchase(database.database_id.clone(), 0) .expect_err("zero amount should reject"); - assert!(zero.contains("credit purchase credits must be positive")); + assert!(zero.contains("credit purchase credit units must be positive")); let overflow = preview_database_credit_purchase(database.database_id.clone(), i64::MAX as u64) .expect_err("payment amount overflow should reject before approve"); assert!(overflow.contains("credit purchase payment amount overflow")); @@ -477,8 +489,8 @@ fn purchase_database_credits_rejects_balance_overflow_before_ledger_call() { .expect("service should be installed") .update_credits_config( CreditsConfigUpdate { - credits_per_kinic: 100_000_000, - min_update_credits: 1, + credit_units_per_kinic: 100_000_000, + min_update_credit_units: 1, }, &test_governance_principal().to_text(), ) @@ -490,7 +502,7 @@ fn purchase_database_credits_rejects_balance_overflow_before_ledger_call() { let error = block_on_ready(purchase_database_credits(DatabaseCreditPurchaseRequest { database_id: database.database_id, - credits: 1, + credit_units: 1, expected_payment_amount_e8s: 1, expected_config_version: 1, })) @@ -515,8 +527,8 @@ fn purchase_database_credits_rejects_stale_preview_before_ledger_call() { .expect("service should be installed") .update_credits_config( CreditsConfigUpdate { - credits_per_kinic: 2_000, - min_update_credits: 1, + credit_units_per_kinic: 2_000, + min_update_credit_units: 1, }, &test_governance_principal().to_text(), ) @@ -586,8 +598,8 @@ fn purchase_database_credits_records_ambiguous_transfer_from() { .entries; assert_eq!(entries.len(), 1); assert_eq!(entries[0].kind, "credit_purchase_ambiguous"); - assert_eq!(entries[0].amount_credits, 500); - assert_eq!(entries[0].balance_after_credits, 0); + assert_eq!(entries[0].amount_credit_units, 500); + assert_eq!(entries[0].balance_after_credit_units, 0); assert_eq!(entries[0].ledger_block_index, None); assert_eq!( database_status_and_mount(&database.database_id), @@ -641,7 +653,7 @@ fn purchase_database_credits_mount_failure_keeps_pending_operation_for_repair() )) .expect("verified complete should retry mount and credit"); - assert_eq!(result.balance_credits, 500); + assert_eq!(result.balance_credit_units, 500); assert_eq!( database_status_and_mount(&database.database_id).0, DatabaseStatus::Active @@ -695,7 +707,7 @@ fn repair_complete_succeeds_after_activation_started_and_credit_apply_failed() { )) .expect("repair complete should finish activation and credit"); - assert_eq!(result.balance_credits, 600); + assert_eq!(result.balance_credit_units, 600); assert_eq!( database_status_and_mount(&database.database_id).0, DatabaseStatus::Active @@ -795,7 +807,7 @@ fn authenticated_caller_can_complete_verified_ambiguous_credit_purchase() { )) .expect("authenticated caller should complete verified credit purchase"); assert_eq!(result.block_index, 77); - assert_eq!(result.balance_credits, 500); + assert_eq!(result.balance_credit_units, 500); assert_eq!( database_status_and_mount(&database_id).0, DatabaseStatus::Active @@ -959,7 +971,7 @@ fn purchase_database_credits_allows_non_owner_payer() { .expect("non-owner payer should fund DB"); assert_eq!(result.block_index, 43); - assert_eq!(result.balance_credits, 700); + assert_eq!(result.balance_credit_units, 700); assert_eq!( last_ledger_from_for_test().expect("ledger from should be recorded"), IcrcAccount { @@ -992,8 +1004,8 @@ fn icrc21_purchase_database_credits_returns_consent_message() { } }; assert!(message.contains(&database.database_id)); - assert!(message.contains("Credits: `500`")); - assert!(message.contains("Payment: `0.5` KINIC")); + assert!(message.contains("Credits: `0.5`")); + assert!(message.contains("Payment: `0.0005` KINIC")); assert!(message.contains("Ledger transfer fee in allowance: `0.0001` KINIC")); assert!(message.contains("Spender canister:")); } @@ -1075,7 +1087,7 @@ fn purchase_database_credits_rejects_unknown_and_deleted_database() { let missing = block_on_ready(purchase_database_credits(DatabaseCreditPurchaseRequest { database_id: "missing".to_string(), - credits: 500, + credit_units: 500, expected_payment_amount_e8s: 50_000_000, expected_config_version: 1, })) @@ -1092,7 +1104,7 @@ fn purchase_database_credits_rejects_unknown_and_deleted_database() { let deleted = block_on_ready(purchase_database_credits(DatabaseCreditPurchaseRequest { database_id: database.database_id, - credits: 500, + credit_units: 500, expected_payment_amount_e8s: 50_000_000, expected_config_version: 1, })) @@ -1209,8 +1221,8 @@ fn install_low_balance_default_service() { service .update_credits_config( CreditsConfigUpdate { - credits_per_kinic: 1_000, - min_update_credits: 2_000_000, + credit_units_per_kinic: 1_000, + min_update_credit_units: 2_000_000, }, &test_governance_principal().to_text(), ) diff --git a/crates/vfs_canister/vfs.did b/crates/vfs_canister/vfs.did index 212c5b85..55c22ed2 100644 --- a/crates/vfs_canister/vfs.did +++ b/crates/vfs_canister/vfs.did @@ -26,38 +26,38 @@ type ChildNode = record { type CreateDatabaseRequest = record { name : text }; type CreateDatabaseResult = record { name : text; database_id : text }; type CreditsConfig = record { - credits_per_kinic : nat64; - min_update_credits : nat64; + min_update_credit_units : nat64; + credit_units_per_kinic : nat64; kinic_ledger_canister_id : text; sns_governance_id : text; }; type CreditsPurchaseResult = record { block_index : nat64; - balance_credits : nat64; + balance_credit_units : nat64; }; type RenameDatabaseRequest = record { name : text; database_id : text }; type DatabaseArchiveChunk = record { bytes : blob }; type DatabaseArchiveInfo = record { size_bytes : nat64; database_id : text }; type DatabaseCreditEntry = record { method : opt text; - credits_per_kinic : opt nat64; payment_amount_e8s : opt nat64; kind : text; - balance_after_credits : nat64; - amount_credits : int64; + credit_units_per_kinic : opt nat64; created_at_ms : int64; ledger_block_index : opt nat64; database_id : text; + amount_credit_units : int64; caller : text; cycles_delta : opt nat64; entry_id : nat64; + balance_after_credit_units : nat64; }; type DatabaseCreditEntryPage = record { entries : vec DatabaseCreditEntry; next_cursor : opt nat64; }; type DatabaseCreditPendingOperation = record { - credits : int64; + credit_units : int64; payment_amount_e8s : int64; to_owner : opt text; to_subaccount : opt blob; @@ -76,13 +76,13 @@ type DatabaseCreditPendingOperationPage = record { next_cursor : opt nat64; }; type DatabaseCreditPurchasePreview = record { - credits_per_kinic : nat64; payment_amount_e8s : nat64; + credit_units_per_kinic : nat64; ledger_fee_e8s : nat64; config_version : nat64; }; type DatabaseCreditPurchaseRequest = record { - credits : nat64; + credit_units : nat64; expected_config_version : nat64; database_id : text; expected_payment_amount_e8s : nat64; @@ -112,9 +112,9 @@ type DatabaseSummary = record { role : DatabaseRole; logical_size_bytes : nat64; credits_suspended_at_ms : opt int64; - credits_balance : opt nat64; database_id : text; archived_at_ms : opt int64; + credit_units_balance : opt nat64; }; type DeleteDatabaseRequest = record { database_id : text }; type DeleteNodeRequest = record { @@ -546,6 +546,7 @@ service : (CreditsConfig) -> { revoke_database_access : (text, text) -> (Result_1); search_node_paths : (SearchNodePathsRequest) -> (Result_28) query; search_nodes : (SearchNodesRequest) -> (Result_28) query; + settle_database_storage_charges : () -> (Result_1); source_evidence : (SourceEvidenceRequest) -> (Result_29) query; status : (text) -> (Status) query; update_credits_config : (blob) -> (Result_1); diff --git a/crates/vfs_cli_core/src/commands.rs b/crates/vfs_cli_core/src/commands.rs index aeb455ca..15a5c833 100644 --- a/crates/vfs_cli_core/src/commands.rs +++ b/crates/vfs_cli_core/src/commands.rs @@ -1193,11 +1193,12 @@ mod tests { Ok(vec![DatabaseSummary { database_id: "alpha".to_string(), name: "Alpha".to_string(), - status: DatabaseStatus::Hot, + status: DatabaseStatus::Active, role: DatabaseRole::Owner, logical_size_bytes: 42, + credit_units_balance: Some(1_000_000), + credits_suspended_at_ms: None, archived_at_ms: None, - deleted_at_ms: None, }]) } async fn begin_database_archive(&self, database_id: &str) -> Result { diff --git a/crates/vfs_client/src/lib.rs b/crates/vfs_client/src/lib.rs index 5073619e..d34a939a 100644 --- a/crates/vfs_client/src/lib.rs +++ b/crates/vfs_client/src/lib.rs @@ -12,15 +12,17 @@ use ic_agent::{ use k256::{SecretKey, pkcs8::DecodePrivateKey}; use vfs_types::{ AppendNodeRequest, CanisterHealth, ChildNode, CreateDatabaseRequest, CreateDatabaseResult, - DatabaseArchiveChunk, DatabaseArchiveInfo, DatabaseMember, DatabaseRestoreChunkRequest, - DatabaseRole, DatabaseSummary, DeleteNodeRequest, DeleteNodeResult, EditNodeRequest, - EditNodeResult, ExportSnapshotRequest, ExportSnapshotResponse, FetchUpdatesRequest, - FetchUpdatesResponse, GlobNodeHit, GlobNodesRequest, GraphLinksRequest, - GraphNeighborhoodRequest, IncomingLinksRequest, LinkEdge, ListChildrenRequest, - ListNodesRequest, MemoryManifest, MkdirNodeRequest, MkdirNodeResult, MoveNodeRequest, - MoveNodeResult, MultiEditNodeRequest, MultiEditNodeResult, Node, NodeContext, - NodeContextRequest, NodeEntry, OutgoingLinksRequest, QueryContext, QueryContextRequest, - RecentNodeHit, RecentNodesRequest, RenameDatabaseRequest, SearchNodeHit, + CreditsConfig, CreditsPurchaseResult, DatabaseArchiveChunk, DatabaseArchiveInfo, + DatabaseCreditEntryPage, DatabaseCreditPendingOperationPage, DatabaseCreditPurchasePreview, + DatabaseCreditPurchaseRequest, DatabaseMember, DatabaseRestoreChunkRequest, DatabaseRole, + DatabaseSummary, DeleteNodeRequest, DeleteNodeResult, EditNodeRequest, EditNodeResult, + ExportSnapshotRequest, ExportSnapshotResponse, FetchUpdatesRequest, FetchUpdatesResponse, + GlobNodeHit, GlobNodesRequest, GraphLinksRequest, GraphNeighborhoodRequest, + IncomingLinksRequest, LinkEdge, ListChildrenRequest, ListNodesRequest, MemoryManifest, + MkdirNodeRequest, MkdirNodeResult, MoveNodeRequest, MoveNodeResult, MultiEditNodeRequest, + MultiEditNodeResult, Node, NodeContext, NodeContextRequest, NodeEntry, OutgoingLinksRequest, + QueryContext, QueryContextRequest, RecentNodeHit, RecentNodesRequest, RenameDatabaseRequest, + SearchNodeHit, SearchNodePathsRequest, SearchNodesRequest, SourceEvidence, SourceEvidenceRequest, Status, WriteNodeRequest, WriteNodeResult, WriteNodesRequest, }; @@ -44,6 +46,72 @@ pub trait VfsApi: Sync { async fn rename_database(&self, _database_id: &str, _name: &str) -> Result<()> { Err(anyhow!("rename_database is not implemented by this client")) } + async fn purchase_database_credits( + &self, + _request: DatabaseCreditPurchaseRequest, + ) -> Result { + Err(anyhow!( + "purchase_database_credits is not implemented by this client" + )) + } + async fn preview_database_credit_purchase( + &self, + _database_id: &str, + _amount_credit_units: u64, + ) -> Result { + Err(anyhow!( + "preview_database_credit_purchase is not implemented by this client" + )) + } + async fn check_database_write_credits(&self, _database_id: &str) -> Result<()> { + Err(anyhow!( + "check_database_write_credits is not implemented by this client" + )) + } + async fn list_database_credit_entries( + &self, + _database_id: &str, + _cursor: Option, + _limit: u32, + ) -> Result { + Err(anyhow!( + "list_database_credit_entries is not implemented by this client" + )) + } + async fn list_database_credit_pending_operations( + &self, + _database_id: &str, + _cursor: Option, + _limit: u32, + ) -> Result { + Err(anyhow!( + "list_database_credit_pending_operations is not implemented by this client" + )) + } + async fn repair_database_credit_purchase_complete( + &self, + _database_id: &str, + _operation_id: u64, + _ledger_block_index: u64, + ) -> Result { + Err(anyhow!( + "repair_database_credit_purchase_complete is not implemented by this client" + )) + } + async fn repair_database_credit_purchase_cancel( + &self, + _database_id: &str, + _operation_id: u64, + ) -> Result<()> { + Err(anyhow!( + "repair_database_credit_purchase_cancel is not implemented by this client" + )) + } + async fn get_credits_config(&self) -> Result { + Err(anyhow!( + "get_credits_config is not implemented by this client" + )) + } async fn grant_database_access( &self, _database_id: &str, @@ -405,6 +473,108 @@ impl VfsApi for CanisterVfsClient { result.map_err(|error| anyhow!(error)) } + async fn purchase_database_credits( + &self, + request: DatabaseCreditPurchaseRequest, + ) -> Result { + let result: Result = + self.update("purchase_database_credits", &request).await?; + result.map_err(|error| anyhow!(error)) + } + + async fn preview_database_credit_purchase( + &self, + database_id: &str, + amount_credit_units: u64, + ) -> Result { + let result: Result = self + .query2( + "preview_database_credit_purchase", + &database_id.to_string(), + &amount_credit_units, + ) + .await?; + result.map_err(|error| anyhow!(error)) + } + + async fn check_database_write_credits(&self, database_id: &str) -> Result<()> { + let result: Result<(), String> = self + .query("check_database_write_credits", &database_id.to_string()) + .await?; + result.map_err(|error| anyhow!(error)) + } + + async fn list_database_credit_entries( + &self, + database_id: &str, + cursor: Option, + limit: u32, + ) -> Result { + let result: Result = self + .query3( + "list_database_credit_entries", + &database_id.to_string(), + &cursor, + &limit, + ) + .await?; + result.map_err(|error| anyhow!(error)) + } + + async fn list_database_credit_pending_operations( + &self, + database_id: &str, + cursor: Option, + limit: u32, + ) -> Result { + let result: Result = self + .query3( + "list_database_credit_pending_operations", + &database_id.to_string(), + &cursor, + &limit, + ) + .await?; + result.map_err(|error| anyhow!(error)) + } + + async fn repair_database_credit_purchase_complete( + &self, + database_id: &str, + operation_id: u64, + ledger_block_index: u64, + ) -> Result { + let result: Result = self + .update3( + "repair_database_credit_purchase_complete", + &database_id.to_string(), + &operation_id, + &ledger_block_index, + ) + .await?; + result.map_err(|error| anyhow!(error)) + } + + async fn repair_database_credit_purchase_cancel( + &self, + database_id: &str, + operation_id: u64, + ) -> Result<()> { + let result: Result<(), String> = self + .update2( + "repair_database_credit_purchase_cancel", + &database_id.to_string(), + &operation_id, + ) + .await?; + result.map_err(|error| anyhow!(error)) + } + + async fn get_credits_config(&self) -> Result { + let result: Result = self.query("get_credits_config", &()).await?; + result.map_err(|error| anyhow!(error)) + } + async fn grant_database_access( &self, database_id: &str, diff --git a/crates/vfs_runtime/migrations/index_db/011_to_latest.sql b/crates/vfs_runtime/migrations/index_db/011_to_latest.sql index b8991616..235f8dad 100644 --- a/crates/vfs_runtime/migrations/index_db/011_to_latest.sql +++ b/crates/vfs_runtime/migrations/index_db/011_to_latest.sql @@ -1,7 +1,8 @@ CREATE TABLE database_credit_accounts ( database_id TEXT PRIMARY KEY, - balance_credits INTEGER NOT NULL, + balance_credit_units INTEGER NOT NULL, suspended_at_ms INTEGER, + storage_charged_at_ms INTEGER, created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, FOREIGN KEY (database_id) REFERENCES databases(database_id) @@ -11,13 +12,13 @@ CREATE TABLE database_credit_ledger ( entry_id INTEGER PRIMARY KEY AUTOINCREMENT, database_id TEXT NOT NULL, kind TEXT NOT NULL, - amount_credits INTEGER NOT NULL, - balance_after_credits INTEGER NOT NULL, + amount_credit_units INTEGER NOT NULL, + balance_after_credit_units INTEGER NOT NULL, payment_amount_e8s INTEGER, caller TEXT NOT NULL, method TEXT, cycles_delta INTEGER, - credits_per_kinic INTEGER, + credit_units_per_kinic INTEGER, ledger_block_index INTEGER, created_at_ms INTEGER NOT NULL ); @@ -30,7 +31,7 @@ CREATE TABLE database_credit_pending_operations ( database_id TEXT NOT NULL, kind TEXT NOT NULL, caller TEXT NOT NULL, - credits INTEGER NOT NULL, + credit_units INTEGER NOT NULL, payment_amount_e8s INTEGER NOT NULL, from_owner TEXT, from_subaccount BLOB, diff --git a/crates/vfs_runtime/migrations/index_db/fresh_index_schema.sql b/crates/vfs_runtime/migrations/index_db/fresh_index_schema.sql index ecbfddf0..6fe215c7 100644 --- a/crates/vfs_runtime/migrations/index_db/fresh_index_schema.sql +++ b/crates/vfs_runtime/migrations/index_db/fresh_index_schema.sql @@ -106,8 +106,9 @@ CREATE TABLE database_restore_sessions ( CREATE TABLE database_credit_accounts ( database_id TEXT PRIMARY KEY, - balance_credits INTEGER NOT NULL, + balance_credit_units INTEGER NOT NULL, suspended_at_ms INTEGER, + storage_charged_at_ms INTEGER, created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, FOREIGN KEY (database_id) REFERENCES databases(database_id) @@ -117,13 +118,13 @@ CREATE TABLE database_credit_ledger ( entry_id INTEGER PRIMARY KEY AUTOINCREMENT, database_id TEXT NOT NULL, kind TEXT NOT NULL, - amount_credits INTEGER NOT NULL, - balance_after_credits INTEGER NOT NULL, + amount_credit_units INTEGER NOT NULL, + balance_after_credit_units INTEGER NOT NULL, payment_amount_e8s INTEGER, caller TEXT NOT NULL, method TEXT, cycles_delta INTEGER, - credits_per_kinic INTEGER, + credit_units_per_kinic INTEGER, ledger_block_index INTEGER, created_at_ms INTEGER NOT NULL ); @@ -136,7 +137,7 @@ CREATE TABLE database_credit_pending_operations ( database_id TEXT NOT NULL, kind TEXT NOT NULL, caller TEXT NOT NULL, - credits INTEGER NOT NULL, + credit_units INTEGER NOT NULL, payment_amount_e8s INTEGER NOT NULL, from_owner TEXT, from_subaccount BLOB, diff --git a/crates/vfs_runtime/src/lib.rs b/crates/vfs_runtime/src/lib.rs index 6a767659..84533a01 100644 --- a/crates/vfs_runtime/src/lib.rs +++ b/crates/vfs_runtime/src/lib.rs @@ -60,12 +60,14 @@ const INDEX_SCHEMA_VERSION_BILLING_PENDING_LEDGER_DETAILS: &str = const INDEX_SCHEMA_VERSION_ACTIVE_STATUS: &str = "database_index:016_active_status"; const INDEX_SCHEMA_VERSION_HARD_DELETE_DATABASES: &str = "database_index:017_hard_delete_databases"; const INDEX_SCHEMA_VERSION_CREDIT_LEDGER_ONLY: &str = "database_index:018_credit_ledger_only"; -const INDEX_SCHEMA_VERSION_FIXED_CYCLES_PER_CREDIT: &str = +const INDEX_SCHEMA_VERSION_FIXED_CYCLES_PER_CREDIT_UNIT: &str = "database_index:019_fixed_cycles_per_credit"; const INDEX_SCHEMA_VERSION_CREDITS_CONFIG_VERSION: &str = "database_index:020_credits_config_version"; const INDEX_SCHEMA_VERSION_CREDIT_PENDING_OPERATION_STATUS: &str = "database_index:021_credit_pending_operation_status"; +const INDEX_SCHEMA_VERSION_CREDIT_UNITS: &str = "database_index:022_credit_units"; +const INDEX_SCHEMA_VERSION_STORAGE_BILLING: &str = "database_index:023_storage_billing"; const PENDING_DATABASE_MOUNT_ID: u16 = 0; const DATABASE_SCHEMA_VERSION: &str = "vfs_store:current"; const MIN_DATABASE_MOUNT_ID: u16 = 11; @@ -82,9 +84,12 @@ const GENERATED_DATABASE_ID_HASH_CHARS: usize = 12; const FRESH_INDEX_SCHEMA_SQL: &str = include_str!("../migrations/index_db/fresh_index_schema.sql"); const INDEX_011_TO_LATEST_SQL: &str = include_str!("../migrations/index_db/011_to_latest.sql"); pub const KINIC_E8S_PER_TOKEN: u64 = 100_000_000; -pub const DEFAULT_CREDITS_PER_KINIC: u64 = 1_000; -pub const CYCLES_PER_CREDIT: u128 = 1_000_000_000; -pub const DEFAULT_MIN_UPDATE_CREDITS: u64 = 1; +pub const DEFAULT_CREDIT_UNITS_PER_KINIC: u64 = 1_000_000; +pub const CYCLES_PER_CREDIT_UNIT: u128 = 1_000_000; +pub const DEFAULT_MIN_UPDATE_CREDIT_UNITS: u64 = 1; +pub const STORAGE_BILLING_INTERVAL_MS: i64 = 24 * 60 * 60 * 1000; +pub const STORAGE_CYCLES_PER_GIB_SECOND: u128 = 127_000; +const GIB_BYTES: u128 = 1024 * 1024 * 1024; const DEFAULT_CREDITS_CONFIG_VERSION: u64 = 1; const MAX_DATABASE_NAME_CHARS: usize = 80; const FNV1A64_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; @@ -141,7 +146,7 @@ pub struct CreditsPendingLedgerDetailsInput<'a> { pub struct DatabaseCreditPurchaseWithLedgerDetails<'a> { pub database_id: &'a str, pub caller: &'a str, - pub credits: u64, + pub credit_units: u64, pub expected_payment_amount_e8s: u64, pub expected_config_version: u64, pub ledger: CreditsPendingLedgerDetailsInput<'a>, @@ -243,6 +248,33 @@ impl VfsService { }) } + pub fn settle_database_storage_charges(&self, caller: &str, now: i64) -> Result<(), String> { + let measured = self + .read_index(load_active_databases_for_storage_billing)? + .into_iter() + .map(|meta| { + let size = self.database_size(&meta)?; + Ok((meta.database_id, size)) + }) + .collect::, String>>()?; + self.write_index(|tx| { + let config = load_credits_config(tx)?; + for (database_id, size_bytes) in measured { + settle_database_storage_charge_in_tx( + tx, + StorageChargeInput { + database_id: &database_id, + caller, + size_bytes, + now, + config: &config, + }, + )?; + } + Ok(()) + }) + } + pub fn list_database_summaries_for_caller( &self, caller: &str, @@ -257,19 +289,19 @@ impl VfsService { pub fn preview_database_credit_purchase( &self, database_id: &str, - credits: u64, + credit_units: u64, ) -> Result { - let credits = credits_to_i64(credits)?; + let credit_units_i64 = credit_units_to_i64(credit_units)?; self.read_index(|conn| { let config = load_credits_config(conn)?; let config_version = load_credits_config_version(conn)?; - let payment_amount_e8s = payment_amount_e8s_for_credits(credits as u64, &config)?; + let payment_amount_e8s = payment_amount_e8s_for_credit_units(credit_units, &config)?; amount_to_i64(payment_amount_e8s)?; - validate_database_credit_purchase_for_conn(conn, database_id, credits)?; + validate_database_credit_purchase_for_conn(conn, database_id, credit_units_i64)?; Ok(DatabaseCreditPurchasePreview { payment_amount_e8s, ledger_fee_e8s: KINIC_LEDGER_FEE_E8S, - credits_per_kinic: config.credits_per_kinic, + credit_units_per_kinic: config.credit_units_per_kinic, config_version, }) }) @@ -285,13 +317,13 @@ impl VfsService { if caller != current.sns_governance_id { return Err("caller is not SNS governance".to_string()); } - let config_changed = current.credits_per_kinic != update.credits_per_kinic - || current.min_update_credits != update.min_update_credits; + let config_changed = current.credit_units_per_kinic != update.credit_units_per_kinic + || current.min_update_credit_units != update.min_update_credit_units; let next = CreditsConfig { kinic_ledger_canister_id: current.kinic_ledger_canister_id, sns_governance_id: current.sns_governance_id, - credits_per_kinic: update.credits_per_kinic, - min_update_credits: update.min_update_credits, + credit_units_per_kinic: update.credit_units_per_kinic, + min_update_credit_units: update.min_update_credit_units, }; validate_credits_config(&next)?; let next_version = if config_changed { @@ -302,8 +334,8 @@ impl VfsService { current_version }; self.write_index(|tx| { - set_credits_config_value(tx, "credits_per_kinic", next.credits_per_kinic)?; - set_credits_config_value(tx, "min_update_credits", next.min_update_credits)?; + set_credits_config_value(tx, "credit_units_per_kinic", next.credit_units_per_kinic)?; + set_credits_config_value(tx, "min_update_credit_units", next.min_update_credit_units)?; if config_changed { set_credits_config_value(tx, "config_version", next_version)?; } @@ -449,8 +481,9 @@ impl VfsService { }; tx.execute( "INSERT INTO database_credit_accounts - (database_id, balance_credits, suspended_at_ms, created_at_ms, updated_at_ms) - VALUES (?1, ?2, ?3, ?4, ?4)", + (database_id, balance_credit_units, suspended_at_ms, storage_charged_at_ms, + created_at_ms, updated_at_ms) + VALUES (?1, ?2, ?3, ?4, ?4, ?4)", params![ database_id, initial_credits_balance, @@ -494,8 +527,9 @@ impl VfsService { insert_initial_database_members(tx, database_id, caller, now)?; tx.execute( "INSERT INTO database_credit_accounts - (database_id, balance_credits, suspended_at_ms, created_at_ms, updated_at_ms) - VALUES (?1, 0, ?2, ?2, ?2)", + (database_id, balance_credit_units, suspended_at_ms, storage_charged_at_ms, + created_at_ms, updated_at_ms) + VALUES (?1, 0, ?2, NULL, ?2, ?2)", params![database_id, now], ) .map_err(|error| error.to_string())?; @@ -610,9 +644,9 @@ impl VfsService { pub fn validate_database_credit_purchase( &self, database_id: &str, - credits: u64, + credit_units: u64, ) -> Result<(), String> { - self.preview_database_credit_purchase(database_id, credits) + self.preview_database_credit_purchase(database_id, credit_units) .map(|_| ()) } @@ -620,15 +654,15 @@ impl VfsService { &self, database_id: &str, caller: &str, - credits: u64, + credit_units: u64, now: i64, ) -> Result { - let preview = self.preview_database_credit_purchase(database_id, credits)?; + let preview = self.preview_database_credit_purchase(database_id, credit_units)?; self.begin_database_credit_purchase_with_ledger_details( DatabaseCreditPurchaseWithLedgerDetails { database_id, caller, - credits, + credit_units, expected_payment_amount_e8s: preview.payment_amount_e8s, expected_config_version: preview.config_version, ledger: CreditsPendingLedgerDetailsInput { @@ -648,14 +682,15 @@ impl VfsService { &self, request: DatabaseCreditPurchaseWithLedgerDetails<'_>, ) -> Result { - let credits = credits_to_i64(request.credits)?; + let credit_units = credit_units_to_i64(request.credit_units)?; let ledger_fee = amount_to_i64(request.ledger.ledger_fee_e8s)?; let ledger_created_at_time = i64::try_from(request.ledger.ledger_created_at_time_ns) .map_err(|_| "ledger created_at_time exceeds i64".to_string())?; self.write_index(|tx| { let config = load_credits_config(tx)?; let config_version = load_credits_config_version(tx)?; - let payment_amount_e8s_u64 = payment_amount_e8s_for_credits(request.credits, &config)?; + let payment_amount_e8s_u64 = + payment_amount_e8s_for_credit_units(request.credit_units, &config)?; if request.expected_config_version != config_version { return Err(format!( "credits config changed: expected version {}, current version {}", @@ -669,14 +704,14 @@ impl VfsService { )); } let payment_amount_e8s = amount_to_i64(payment_amount_e8s_u64)?; - validate_database_credit_purchase_for_conn(tx, request.database_id, credits)?; + validate_database_credit_purchase_for_conn(tx, request.database_id, credit_units)?; insert_pending_credits_operation( tx, PendingCreditsOperationInsert { database_id: request.database_id, kind: "credit_purchase", caller: request.caller, - credits, + credit_units, payment_amount_e8s, ledger: PendingCreditsLedgerDetails { from_owner: request.ledger.from_owner, @@ -697,11 +732,11 @@ impl VfsService { operation_id: u64, database_id: &str, caller: &str, - credits: u64, + credit_units: u64, ledger_block_index: u64, now: i64, ) -> Result { - let credits = credits_to_i64(credits)?; + let credit_units_i64 = credit_units_to_i64(credit_units)?; let config = self.credits_config()?; self.write_index(|tx| { let operation = load_required_pending_credits_operation( @@ -711,7 +746,7 @@ impl VfsService { database_id, kind: "credit_purchase", caller, - credits, + credit_units: credit_units_i64, }, )?; require_pending_operation_status( @@ -722,15 +757,15 @@ impl VfsService { load_database_status(tx, database_id)?; complete_pending_database_activation(tx, database_id, now)?; let db_balance = database_balance_for_update(tx, database_id)?; - let next_database = checked_balance_add(db_balance, credits)?; + let next_database = checked_balance_add(db_balance, credit_units_i64)?; update_database_credits_balance(tx, database_id, next_database, &config, now)?; insert_database_ledger( tx, DatabaseLedgerInsert { database_id, kind: "credit_purchase", - amount_credits: credits, - balance_after_credits: next_database, + amount_credit_units: credit_units_i64, + balance_after_credit_units: next_database, payment_amount_e8s: Some(operation.payment_amount_e8s), caller, method: Some("purchase_database_credits"), @@ -750,10 +785,10 @@ impl VfsService { operation_id: u64, database_id: &str, caller: &str, - credits: u64, + credit_units: u64, now: i64, ) -> Result { - let credits = credits_to_i64(credits)?; + let credit_units_i64 = credit_units_to_i64(credit_units)?; self.write_index(|tx| { let operation = load_required_pending_credits_operation( tx, @@ -762,7 +797,7 @@ impl VfsService { database_id, kind: "credit_purchase", caller, - credits, + credit_units: credit_units_i64, }, )?; require_pending_operation_status( @@ -777,8 +812,8 @@ impl VfsService { DatabaseLedgerInsert { database_id, kind: "credit_purchase_ambiguous", - amount_credits: credits, - balance_after_credits: balance, + amount_credit_units: credit_units_i64, + balance_after_credit_units: balance, payment_amount_e8s: Some(operation.payment_amount_e8s), caller, method: Some("purchase_database_credits"), @@ -802,9 +837,9 @@ impl VfsService { operation_id: u64, database_id: &str, caller: &str, - credits: u64, + credit_units: u64, ) -> Result<(), String> { - let credits = credits_to_i64(credits)?; + let credit_units_i64 = credit_units_to_i64(credit_units)?; self.write_index(|tx| { let operation = load_required_pending_credits_operation( tx, @@ -813,7 +848,7 @@ impl VfsService { database_id, kind: "credit_purchase", caller, - credits, + credit_units: credit_units_i64, }, )?; require_pending_operation_status( @@ -834,9 +869,9 @@ impl VfsService { operation_id: u64, database_id: &str, caller: &str, - credits: u64, + credit_units: u64, ) -> Result<(), String> { - let credits = credits_to_i64(credits)?; + let credit_units_i64 = credit_units_to_i64(credit_units)?; self.write_index(|tx| { let operation = load_required_pending_credits_operation( tx, @@ -845,7 +880,7 @@ impl VfsService { database_id, kind: "credit_purchase", caller, - credits, + credit_units: credit_units_i64, }, )?; require_pending_operation_status( @@ -883,8 +918,8 @@ impl VfsService { }; let mut stmt = conn .prepare( - "SELECT entry_id, database_id, kind, amount_credits, balance_after_credits, - payment_amount_e8s, caller, method, cycles_delta, credits_per_kinic, + "SELECT entry_id, database_id, kind, amount_credit_units, balance_after_credit_units, + payment_amount_e8s, caller, method, cycles_delta, credit_units_per_kinic, ledger_block_index, created_at_ms FROM database_credit_ledger WHERE database_id = ?1 AND entry_id > ?2 @@ -939,7 +974,7 @@ impl VfsService { } let mut stmt = conn .prepare( - "SELECT operation_id, database_id, kind, caller, credits, payment_amount_e8s, + "SELECT operation_id, database_id, kind, caller, credit_units, payment_amount_e8s, from_owner, from_subaccount, to_owner, to_subaccount, ledger_fee_e8s, ledger_created_at_time_ns, operation_status, created_at_ms FROM database_credit_pending_operations @@ -1011,15 +1046,15 @@ impl VfsService { load_database_status(tx, database_id)?; complete_pending_database_activation(tx, database_id, now)?; let balance = database_balance_for_update(tx, database_id)?; - let next = checked_balance_add(balance, operation.credits)?; + let next = checked_balance_add(balance, operation.credit_units)?; update_database_credits_balance(tx, database_id, next, &config, now)?; insert_database_ledger( tx, DatabaseLedgerInsert { database_id, kind: "credit_purchase_repair_complete", - amount_credits: operation.credits, - balance_after_credits: next, + amount_credit_units: operation.credit_units, + balance_after_credit_units: next, payment_amount_e8s: Some(operation.payment_amount_e8s), caller: &operation.caller, method: Some("repair_database_credit_purchase_complete"), @@ -1078,8 +1113,8 @@ impl VfsService { DatabaseLedgerInsert { database_id, kind: "credit_purchase_repair_cancelled", - amount_credits: operation.credits, - balance_after_credits: balance, + amount_credit_units: operation.credit_units, + balance_after_credit_units: balance, payment_amount_e8s: Some(operation.payment_amount_e8s), caller, method: Some("repair_database_credit_purchase_cancel"), @@ -2691,6 +2726,8 @@ fn run_index_migrations_in_tx_for_upgrade( enum IndexSchemaState { Latest, NeedsPendingOperationStatus, + NeedsCreditUnits, + NeedsStorageBilling, Mainnet011, } @@ -2702,6 +2739,14 @@ fn ensure_existing_index_schema_is_latest( IndexSchemaState::Latest => validate_index_schema(conn), IndexSchemaState::NeedsPendingOperationStatus => { apply_pending_operation_status_index_migration(conn)?; + ensure_existing_index_schema_is_latest(conn, config) + } + IndexSchemaState::NeedsCreditUnits => { + apply_credit_units_index_migration(conn)?; + ensure_existing_index_schema_is_latest(conn, config) + } + IndexSchemaState::NeedsStorageBilling => { + apply_storage_billing_index_migration(conn)?; validate_index_schema(conn) } IndexSchemaState::Mainnet011 => { @@ -2719,12 +2764,16 @@ fn classify_existing_index_schema_state( conn: &Transaction<'_>, ) -> Result { if migration_applied_tx(conn, INDEX_SCHEMA_VERSION_CREDITS_CONFIG_VERSION)? { - return if migration_applied_tx(conn, INDEX_SCHEMA_VERSION_CREDIT_PENDING_OPERATION_STATUS)? - { - Ok(IndexSchemaState::Latest) - } else { - Ok(IndexSchemaState::NeedsPendingOperationStatus) - }; + if !migration_applied_tx(conn, INDEX_SCHEMA_VERSION_CREDIT_PENDING_OPERATION_STATUS)? { + return Ok(IndexSchemaState::NeedsPendingOperationStatus); + } + if !migration_applied_tx(conn, INDEX_SCHEMA_VERSION_CREDIT_UNITS)? { + return Ok(IndexSchemaState::NeedsCreditUnits); + } + if !migration_applied_tx(conn, INDEX_SCHEMA_VERSION_STORAGE_BILLING)? { + return Ok(IndexSchemaState::NeedsStorageBilling); + } + return Ok(IndexSchemaState::Latest); } if !migration_applied_tx(conn, INDEX_SCHEMA_VERSION_SOURCE_RUN_SESSIONS)? { return Err(format!( @@ -2756,8 +2805,9 @@ fn apply_mainnet_011_to_latest_index_migration( .map_err(|error| error.to_string())?; conn.execute( "INSERT INTO database_credit_accounts - (database_id, balance_credits, suspended_at_ms, created_at_ms, updated_at_ms) - SELECT database_id, 0, 0, 0, 0 FROM databases", + (database_id, balance_credit_units, suspended_at_ms, storage_charged_at_ms, + created_at_ms, updated_at_ms) + SELECT database_id, 0, 0, NULL, 0, 0 FROM databases", params![], ) .map_err(|error| error.to_string())?; @@ -2791,6 +2841,95 @@ fn apply_pending_operation_status_index_migration(conn: &Transaction<'_>) -> Res insert_schema_migration_now(conn, INDEX_SCHEMA_VERSION_CREDIT_PENDING_OPERATION_STATUS) } +fn apply_credit_units_index_migration(conn: &Transaction<'_>) -> Result<(), String> { + conn.execute( + "ALTER TABLE database_credit_accounts + RENAME COLUMN balance_credits TO balance_credit_units", + params![], + ) + .map_err(|error| error.to_string())?; + conn.execute( + "UPDATE database_credit_accounts + SET balance_credit_units = balance_credit_units * 1000", + params![], + ) + .map_err(|error| error.to_string())?; + conn.execute( + "ALTER TABLE database_credit_ledger + RENAME COLUMN amount_credits TO amount_credit_units", + params![], + ) + .map_err(|error| error.to_string())?; + conn.execute( + "ALTER TABLE database_credit_ledger + RENAME COLUMN balance_after_credits TO balance_after_credit_units", + params![], + ) + .map_err(|error| error.to_string())?; + conn.execute( + "ALTER TABLE database_credit_ledger + RENAME COLUMN credits_per_kinic TO credit_units_per_kinic", + params![], + ) + .map_err(|error| error.to_string())?; + conn.execute( + "UPDATE database_credit_ledger + SET amount_credit_units = amount_credit_units * 1000, + balance_after_credit_units = balance_after_credit_units * 1000, + credit_units_per_kinic = credit_units_per_kinic * 1000 + WHERE credit_units_per_kinic IS NOT NULL", + params![], + ) + .map_err(|error| error.to_string())?; + conn.execute( + "UPDATE database_credit_ledger + SET amount_credit_units = amount_credit_units * 1000, + balance_after_credit_units = balance_after_credit_units * 1000 + WHERE credit_units_per_kinic IS NULL", + params![], + ) + .map_err(|error| error.to_string())?; + conn.execute( + "ALTER TABLE database_credit_pending_operations + RENAME COLUMN credits TO credit_units", + params![], + ) + .map_err(|error| error.to_string())?; + conn.execute( + "UPDATE database_credit_pending_operations + SET credit_units = credit_units * 1000", + params![], + ) + .map_err(|error| error.to_string())?; + conn.execute( + "UPDATE credits_config + SET key = 'credit_units_per_kinic', + value = CAST(CAST(value AS INTEGER) * 1000 AS TEXT) + WHERE key = 'credits_per_kinic'", + params![], + ) + .map_err(|error| error.to_string())?; + conn.execute( + "UPDATE credits_config + SET key = 'min_update_credit_units', + value = CAST(CAST(value AS INTEGER) * 1000 AS TEXT) + WHERE key = 'min_update_credits'", + params![], + ) + .map_err(|error| error.to_string())?; + insert_schema_migration_now(conn, INDEX_SCHEMA_VERSION_CREDIT_UNITS) +} + +fn apply_storage_billing_index_migration(conn: &Transaction<'_>) -> Result<(), String> { + conn.execute( + "ALTER TABLE database_credit_accounts + ADD COLUMN storage_charged_at_ms INTEGER", + params![], + ) + .map_err(|error| error.to_string())?; + insert_schema_migration_now(conn, INDEX_SCHEMA_VERSION_STORAGE_BILLING) +} + fn create_schema_migrations(conn: &Transaction<'_>) -> Result<(), String> { conn.execute( "CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)", @@ -2828,25 +2967,25 @@ fn default_credits_config() -> CreditsConfig { CreditsConfig { kinic_ledger_canister_id: "aaaaa-aa".to_string(), sns_governance_id: "rrkah-fqaaa-aaaaa-aaaaq-cai".to_string(), - credits_per_kinic: DEFAULT_CREDITS_PER_KINIC, - min_update_credits: DEFAULT_MIN_UPDATE_CREDITS, + credit_units_per_kinic: DEFAULT_CREDIT_UNITS_PER_KINIC, + min_update_credit_units: DEFAULT_MIN_UPDATE_CREDIT_UNITS, } } fn validate_credits_config(config: &CreditsConfig) -> Result<(), String> { validate_principal_text(&config.kinic_ledger_canister_id)?; validate_principal_text(&config.sns_governance_id)?; - if config.credits_per_kinic == 0 { - return Err("credits_per_kinic must be positive".to_string()); + if config.credit_units_per_kinic == 0 { + return Err("credit_units_per_kinic must be positive".to_string()); } - if config.min_update_credits == 0 { - return Err("min_update_credits must be positive".to_string()); + if config.min_update_credit_units == 0 { + return Err("min_update_credit_units must be positive".to_string()); } - if !KINIC_E8S_PER_TOKEN.is_multiple_of(config.credits_per_kinic) { - return Err("credits_per_kinic must divide 100000000".to_string()); + if !KINIC_E8S_PER_TOKEN.is_multiple_of(config.credit_units_per_kinic) { + return Err("credit_units_per_kinic must divide 100000000".to_string()); } - amount_to_i64(config.credits_per_kinic)?; - amount_to_i64(config.min_update_credits)?; + amount_to_i64(config.credit_units_per_kinic)?; + amount_to_i64(config.min_update_credit_units)?; Ok(()) } @@ -2870,8 +3009,16 @@ fn insert_credits_config(conn: &Transaction<'_>, config: &CreditsConfig) -> Resu params!["sns_governance_id", config.sns_governance_id], ) .map_err(|error| error.to_string())?; - set_credits_config_value(conn, "credits_per_kinic", config.credits_per_kinic)?; - set_credits_config_value(conn, "min_update_credits", config.min_update_credits)?; + set_credits_config_value( + conn, + "credit_units_per_kinic", + config.credit_units_per_kinic, + )?; + set_credits_config_value( + conn, + "min_update_credit_units", + config.min_update_credit_units, + )?; Ok(()) } @@ -2914,9 +3061,11 @@ const INDEX_SCHEMA_VERSIONS: &[&str] = &[ INDEX_SCHEMA_VERSION_ACTIVE_STATUS, INDEX_SCHEMA_VERSION_HARD_DELETE_DATABASES, INDEX_SCHEMA_VERSION_CREDIT_LEDGER_ONLY, - INDEX_SCHEMA_VERSION_FIXED_CYCLES_PER_CREDIT, + INDEX_SCHEMA_VERSION_FIXED_CYCLES_PER_CREDIT_UNIT, INDEX_SCHEMA_VERSION_CREDITS_CONFIG_VERSION, INDEX_SCHEMA_VERSION_CREDIT_PENDING_OPERATION_STATUS, + INDEX_SCHEMA_VERSION_CREDIT_UNITS, + INDEX_SCHEMA_VERSION_STORAGE_BILLING, ]; const INDEX_SCHEMA_TABLES_WITHOUT_MIGRATIONS: &[&str] = &[ @@ -2942,9 +3091,11 @@ const POST_011_INDEX_SCHEMA_VERSIONS: &[&str] = &[ INDEX_SCHEMA_VERSION_ACTIVE_STATUS, INDEX_SCHEMA_VERSION_HARD_DELETE_DATABASES, INDEX_SCHEMA_VERSION_CREDIT_LEDGER_ONLY, - INDEX_SCHEMA_VERSION_FIXED_CYCLES_PER_CREDIT, + INDEX_SCHEMA_VERSION_FIXED_CYCLES_PER_CREDIT_UNIT, INDEX_SCHEMA_VERSION_CREDITS_CONFIG_VERSION, INDEX_SCHEMA_VERSION_CREDIT_PENDING_OPERATION_STATUS, + INDEX_SCHEMA_VERSION_CREDIT_UNITS, + INDEX_SCHEMA_VERSION_STORAGE_BILLING, ]; const POST_011_INDEX_SCHEMA_TABLES: &[&str] = &[ @@ -3116,7 +3267,12 @@ fn validate_index_schema(conn: &Transaction<'_>) -> Result<(), String> { ), ( "database_credit_accounts", - &["database_id", "balance_credits", "suspended_at_ms"][..], + &[ + "database_id", + "balance_credit_units", + "suspended_at_ms", + "storage_charged_at_ms", + ][..], ), ( "database_credit_ledger", @@ -3124,13 +3280,13 @@ fn validate_index_schema(conn: &Transaction<'_>) -> Result<(), String> { "entry_id", "database_id", "kind", - "amount_credits", - "balance_after_credits", + "amount_credit_units", + "balance_after_credit_units", "payment_amount_e8s", "caller", "method", "cycles_delta", - "credits_per_kinic", + "credit_units_per_kinic", "ledger_block_index", "created_at_ms", ][..], @@ -3142,7 +3298,7 @@ fn validate_index_schema(conn: &Transaction<'_>) -> Result<(), String> { "database_id", "kind", "caller", - "credits", + "credit_units", "payment_amount_e8s", "from_owner", "from_subaccount", @@ -3237,8 +3393,8 @@ fn load_credits_config(conn: &Connection) -> Result { Ok(CreditsConfig { kinic_ledger_canister_id: load_credits_config_text(conn, "kinic_ledger_canister_id")?, sns_governance_id: load_credits_config_text(conn, "sns_governance_id")?, - credits_per_kinic: load_credits_config_u64(conn, "credits_per_kinic")?, - min_update_credits: load_credits_config_u64(conn, "min_update_credits")?, + credit_units_per_kinic: load_credits_config_u64(conn, "credit_units_per_kinic")?, + min_update_credit_units: load_credits_config_u64(conn, "min_update_credit_units")?, }) } @@ -3303,20 +3459,24 @@ fn amount_to_i64(amount: u64) -> Result { i64::try_from(amount).map_err(|_| "amount exceeds i64 limit".to_string()) } -fn credits_to_i64(credits: u64) -> Result { - let credits = i64::try_from(credits).map_err(|_| "credits exceeds i64 limit".to_string())?; - if credits <= 0 { - return Err("credit purchase credits must be positive".to_string()); +fn credit_units_to_i64(credit_units: u64) -> Result { + let credit_units = + i64::try_from(credit_units).map_err(|_| "credit units exceeds i64 limit".to_string())?; + if credit_units <= 0 { + return Err("credit purchase credit units must be positive".to_string()); } - Ok(credits) + Ok(credit_units) } -pub fn payment_amount_e8s_for_credits(credits: u64, config: &CreditsConfig) -> Result { - let e8s_per_credit = KINIC_E8S_PER_TOKEN - .checked_div(config.credits_per_kinic) - .ok_or_else(|| "credits_per_kinic must be positive".to_string())?; - credits - .checked_mul(e8s_per_credit) +pub fn payment_amount_e8s_for_credit_units( + credit_units: u64, + config: &CreditsConfig, +) -> Result { + let e8s_per_credit_unit = KINIC_E8S_PER_TOKEN + .checked_div(config.credit_units_per_kinic) + .ok_or_else(|| "credit_units_per_kinic must be positive".to_string())?; + credit_units + .checked_mul(e8s_per_credit_unit) .ok_or_else(|| "credit purchase payment amount overflow".to_string()) } @@ -3340,7 +3500,7 @@ fn checked_balance_add(balance: i64, amount: i64) -> Result { fn validate_database_credit_purchase_for_conn( conn: &Connection, database_id: &str, - credits: i64, + credit_units: i64, ) -> Result<(), String> { let status = load_database_status(conn, database_id)?; if !database_has_owner(conn, database_id)? { @@ -3348,7 +3508,7 @@ fn validate_database_credit_purchase_for_conn( } let balance: i64 = conn .query_row( - "SELECT balance_credits FROM database_credit_accounts WHERE database_id = ?1", + "SELECT balance_credit_units FROM database_credit_accounts WHERE database_id = ?1", params![database_id], |row| crate::sqlite::row_get(row, 0), ) @@ -3357,7 +3517,7 @@ fn validate_database_credit_purchase_for_conn( .ok_or_else(|| format!("database credits account not found: {database_id}"))?; let pending_credit_purchase: i64 = conn .query_row( - "SELECT COALESCE(SUM(credits), 0) + "SELECT COALESCE(SUM(credit_units), 0) FROM database_credit_pending_operations WHERE database_id = ?1 AND kind = 'credit_purchase'", params![database_id], @@ -3368,7 +3528,7 @@ fn validate_database_credit_purchase_for_conn( return Err(format!("database activation is pending: {database_id}")); } let reserved = checked_balance_add(balance, pending_credit_purchase)?; - checked_balance_add(reserved, credits)?; + checked_balance_add(reserved, credit_units)?; Ok(()) } @@ -3379,7 +3539,7 @@ fn require_database_write_credits_available_for_conn( ) -> Result<(), String> { let (balance, suspended_at_ms): (i64, Option) = conn .query_row( - "SELECT balance_credits, suspended_at_ms + "SELECT balance_credit_units, suspended_at_ms FROM database_credit_accounts WHERE database_id = ?1", params![database_id], @@ -3396,7 +3556,7 @@ fn require_database_write_credits_available_for_conn( if suspended_at_ms.is_some() { return Err(format!("database credits are suspended: {database_id}")); } - if balance < credits_to_i64(config.min_update_credits)? { + if balance < credit_units_to_i64(config.min_update_credit_units)? { return Err(format!( "database credits balance is too low: {database_id}" )); @@ -3467,12 +3627,20 @@ fn complete_pending_database_activation( params![database_id, now], ) .map_err(|error| error.to_string())?; + conn.execute( + "UPDATE database_credit_accounts + SET storage_charged_at_ms = COALESCE(storage_charged_at_ms, ?2), + updated_at_ms = ?2 + WHERE database_id = ?1", + params![database_id, now], + ) + .map_err(|error| error.to_string())?; Ok(()) } fn database_balance_for_update(conn: &Transaction<'_>, database_id: &str) -> Result { conn.query_row( - "SELECT balance_credits FROM database_credit_accounts WHERE database_id = ?1", + "SELECT balance_credit_units FROM database_credit_accounts WHERE database_id = ?1", params![database_id], |row| crate::sqlite::row_get(row, 0), ) @@ -3488,7 +3656,7 @@ fn update_database_credits_balance( config: &CreditsConfig, now: i64, ) -> Result<(), String> { - let min = credits_to_i64(config.min_update_credits)?; + let min = credit_units_to_i64(config.min_update_credit_units)?; let suspended_at_ms = if balance >= min { None } else { Some(now) }; let values = vec![ crate::sqlite::text_value(database_id), @@ -3499,7 +3667,73 @@ fn update_database_credits_balance( crate::sqlite::execute_values( conn, "UPDATE database_credit_accounts - SET balance_credits = ?2, suspended_at_ms = ?3, updated_at_ms = ?4 + SET balance_credit_units = ?2, suspended_at_ms = ?3, updated_at_ms = ?4 + WHERE database_id = ?1", + &values, + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +fn load_storage_credit_account( + conn: &Connection, + database_id: &str, +) -> Result { + conn.query_row( + "SELECT balance_credit_units, suspended_at_ms, storage_charged_at_ms + FROM database_credit_accounts + WHERE database_id = ?1", + params![database_id], + |row| { + Ok(StorageCreditAccount { + balance_credit_units: crate::sqlite::row_get(row, 0)?, + suspended_at_ms: crate::sqlite::row_get(row, 1)?, + storage_charged_at_ms: crate::sqlite::row_get(row, 2)?, + }) + }, + ) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("database credits account not found: {database_id}")) +} + +fn update_database_logical_size( + conn: &Transaction<'_>, + database_id: &str, + size_bytes: u64, +) -> Result<(), String> { + conn.execute( + "UPDATE databases + SET logical_size_bytes = ?2 + WHERE database_id = ?1", + params![database_id, i64::try_from(size_bytes).unwrap_or(i64::MAX)], + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +fn update_database_storage_account( + conn: &Transaction<'_>, + database_id: &str, + balance_credit_units: i64, + suspended_at_ms: Option, + storage_charged_at_ms: i64, + now: i64, +) -> Result<(), String> { + let values = vec![ + crate::sqlite::text_value(database_id), + crate::sqlite::integer_value(balance_credit_units), + crate::sqlite::nullable_integer_value(suspended_at_ms), + crate::sqlite::integer_value(storage_charged_at_ms), + crate::sqlite::integer_value(now), + ]; + crate::sqlite::execute_values( + conn, + "UPDATE database_credit_accounts + SET balance_credit_units = ?2, + suspended_at_ms = ?3, + storage_charged_at_ms = ?4, + updated_at_ms = ?5 WHERE database_id = ?1", &values, ) @@ -3512,7 +3746,7 @@ struct PendingCreditsOperation { database_id: String, kind: String, caller: String, - credits: i64, + credit_units: i64, payment_amount_e8s: i64, from_owner: Option, from_subaccount: Option>, @@ -3537,7 +3771,7 @@ struct PendingCreditsOperationInsert<'a> { database_id: &'a str, kind: &'a str, caller: &'a str, - credits: i64, + credit_units: i64, payment_amount_e8s: i64, ledger: PendingCreditsLedgerDetails<'a>, operation_status: &'a str, @@ -3549,7 +3783,7 @@ struct PendingCreditsOperationMatch<'a> { database_id: &'a str, kind: &'a str, caller: &'a str, - credits: i64, + credit_units: i64, } fn insert_pending_credits_operation( @@ -3560,7 +3794,7 @@ fn insert_pending_credits_operation( crate::sqlite::text_value(operation.database_id), crate::sqlite::text_value(operation.kind), crate::sqlite::text_value(operation.caller), - crate::sqlite::integer_value(operation.credits), + crate::sqlite::integer_value(operation.credit_units), crate::sqlite::integer_value(operation.payment_amount_e8s), crate::sqlite::text_value(operation.ledger.from_owner), crate::sqlite::nullable_blob_value(operation.ledger.from_subaccount.map(Vec::from)), @@ -3574,7 +3808,7 @@ fn insert_pending_credits_operation( crate::sqlite::execute_values( conn, "INSERT INTO database_credit_pending_operations - (database_id, kind, caller, credits, payment_amount_e8s, from_owner, from_subaccount, + (database_id, kind, caller, credit_units, payment_amount_e8s, from_owner, from_subaccount, to_owner, to_subaccount, ledger_fee_e8s, ledger_created_at_time_ns, operation_status, created_at_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", @@ -3591,7 +3825,7 @@ fn load_pending_credits_operation( ) -> Result { let operation_id = i64::try_from(operation_id).map_err(|error| error.to_string())?; conn.query_row( - "SELECT operation_id, database_id, kind, caller, credits, payment_amount_e8s, + "SELECT operation_id, database_id, kind, caller, credit_units, payment_amount_e8s, from_owner, from_subaccount, to_owner, to_subaccount, ledger_fee_e8s, ledger_created_at_time_ns, operation_status, created_at_ms FROM database_credit_pending_operations @@ -3640,7 +3874,7 @@ fn load_required_pending_credits_operation( if operation.database_id != expected.database_id || operation.kind != expected.kind || operation.caller != expected.caller - || operation.credits != expected.credits + || operation.credit_units != expected.credit_units { return Err("pending credit operation mismatch".to_string()); } @@ -3685,7 +3919,7 @@ fn map_pending_credits_operation( database_id: crate::sqlite::row_get(row, 1)?, kind: crate::sqlite::row_get(row, 2)?, caller: crate::sqlite::row_get(row, 3)?, - credits: crate::sqlite::row_get(row, 4)?, + credit_units: crate::sqlite::row_get(row, 4)?, payment_amount_e8s: crate::sqlite::row_get(row, 5)?, from_owner: crate::sqlite::row_get(row, 6)?, from_subaccount: crate::sqlite::row_get(row, 7)?, @@ -3713,7 +3947,7 @@ fn pending_credits_operation_to_public( database_id: operation.database_id, kind: operation.kind, caller: operation.caller, - credits: operation.credits, + credit_units: operation.credit_units, payment_amount_e8s: operation.payment_amount_e8s, from_owner: operation.from_owner, from_subaccount: operation.from_subaccount, @@ -3728,8 +3962,8 @@ fn pending_credits_operation_to_public( struct DatabaseLedgerInsert<'a> { database_id: &'a str, kind: &'a str, - amount_credits: i64, - balance_after_credits: i64, + amount_credit_units: i64, + balance_after_credit_units: i64, payment_amount_e8s: Option, caller: &'a str, method: Option<&'a str>, @@ -3749,6 +3983,20 @@ struct DatabaseCharge<'a> { computed_charge: i64, } +struct StorageChargeInput<'a> { + database_id: &'a str, + caller: &'a str, + size_bytes: u64, + now: i64, + config: &'a CreditsConfig, +} + +struct StorageCreditAccount { + balance_credit_units: i64, + suspended_at_ms: Option, + storage_charged_at_ms: Option, +} + fn insert_database_ledger( conn: &Transaction<'_>, entry: DatabaseLedgerInsert<'_>, @@ -3756,8 +4004,8 @@ fn insert_database_ledger( let values = vec![ crate::sqlite::text_value(entry.database_id), crate::sqlite::text_value(entry.kind), - crate::sqlite::integer_value(entry.amount_credits), - crate::sqlite::integer_value(entry.balance_after_credits), + crate::sqlite::integer_value(entry.amount_credit_units), + crate::sqlite::integer_value(entry.balance_after_credit_units), crate::sqlite::nullable_integer_value(entry.payment_amount_e8s), crate::sqlite::text_value(entry.caller), entry @@ -3772,7 +4020,7 @@ fn insert_database_ledger( crate::sqlite::nullable_integer_value( entry .config - .map(|config| i64::try_from(config.credits_per_kinic).unwrap_or(i64::MAX)), + .map(|config| i64::try_from(config.credit_units_per_kinic).unwrap_or(i64::MAX)), ), crate::sqlite::nullable_integer_value( entry @@ -3784,8 +4032,8 @@ fn insert_database_ledger( crate::sqlite::execute_values( conn, "INSERT INTO database_credit_ledger - (database_id, kind, amount_credits, balance_after_credits, payment_amount_e8s, - caller, method, cycles_delta, credits_per_kinic, ledger_block_index, created_at_ms) + (database_id, kind, amount_credit_units, balance_after_credit_units, payment_amount_e8s, + caller, method, cycles_delta, credit_units_per_kinic, ledger_block_index, created_at_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", &values, ) @@ -3793,6 +4041,100 @@ fn insert_database_ledger( Ok(()) } +fn settle_database_storage_charge_in_tx( + tx: &Transaction<'_>, + input: StorageChargeInput<'_>, +) -> Result<(), String> { + let account = load_storage_credit_account(tx, input.database_id)?; + update_database_logical_size(tx, input.database_id, input.size_bytes)?; + let Some(charged_at_ms) = account.storage_charged_at_ms else { + update_database_storage_account( + tx, + input.database_id, + account.balance_credit_units, + account.suspended_at_ms, + input.now, + input.now, + )?; + return Ok(()); + }; + let elapsed_ms = input.now.saturating_sub(charged_at_ms); + if elapsed_ms < STORAGE_BILLING_INTERVAL_MS { + return Ok(()); + } + let storage_cycles = compute_storage_charge_cycles(input.size_bytes, elapsed_ms)?; + if storage_cycles == 0 { + update_database_storage_account( + tx, + input.database_id, + account.balance_credit_units, + account.suspended_at_ms, + input.now, + input.now, + )?; + return Ok(()); + } + let charge_units_u128 = storage_cycles.div_ceil(CYCLES_PER_CREDIT_UNIT); + let charge_units = i64::try_from(charge_units_u128) + .map_err(|_| "storage charge exceeds i64 limit".to_string())?; + + let paid_units = account.balance_credit_units.min(charge_units).max(0); + let next_balance = account.balance_credit_units.saturating_sub(paid_units); + let min_balance = credit_units_to_i64(input.config.min_update_credit_units)?; + let should_suspend = paid_units < charge_units || next_balance < min_balance; + let suspended_at_ms = if should_suspend { + account.suspended_at_ms.or(Some(input.now)) + } else { + None + }; + let newly_suspended = should_suspend && account.suspended_at_ms.is_none(); + update_database_storage_account( + tx, + input.database_id, + next_balance, + suspended_at_ms, + input.now, + input.now, + )?; + if paid_units > 0 { + insert_database_ledger( + tx, + DatabaseLedgerInsert { + database_id: input.database_id, + kind: "storage_charge", + amount_credit_units: -paid_units, + balance_after_credit_units: next_balance, + payment_amount_e8s: None, + caller: input.caller, + method: Some("storage_billing"), + cycles_delta: Some(storage_cycles), + config: Some(input.config), + ledger_block_index: None, + now: input.now, + }, + )?; + } + if newly_suspended { + insert_database_ledger( + tx, + DatabaseLedgerInsert { + database_id: input.database_id, + kind: "suspend", + amount_credit_units: 0, + balance_after_credit_units: next_balance, + payment_amount_e8s: None, + caller: input.caller, + method: Some("storage_billing"), + cycles_delta: Some(storage_cycles), + config: Some(input.config), + ledger_block_index: None, + now: input.now, + }, + )?; + } + Ok(()) +} + fn charge_database_update_in_tx( tx: &Transaction<'_>, charge: DatabaseCharge<'_>, @@ -3806,8 +4148,8 @@ fn charge_database_update_in_tx( DatabaseLedgerInsert { database_id: charge.database_id, kind: "charge", - amount_credits: -amount, - balance_after_credits: next, + amount_credit_units: -amount, + balance_after_credit_units: next, payment_amount_e8s: None, caller: charge.caller, method: Some(charge.method), @@ -3823,8 +4165,8 @@ fn charge_database_update_in_tx( DatabaseLedgerInsert { database_id: charge.database_id, kind: "suspend", - amount_credits: 0, - balance_after_credits: next, + amount_credit_units: 0, + balance_after_credit_units: next, payment_amount_e8s: None, caller: charge.caller, method: Some(charge.method), @@ -3839,10 +4181,25 @@ fn charge_database_update_in_tx( } fn compute_update_charge(cycles_delta: u128) -> Result { - let charge = cycles_delta.div_ceil(CYCLES_PER_CREDIT); + let charge = cycles_delta.div_ceil(CYCLES_PER_CREDIT_UNIT); i64::try_from(charge).map_err(|_| "cycle charge exceeds i64 limit".to_string()) } +fn compute_storage_charge_cycles(size_bytes: u64, elapsed_ms: i64) -> Result { + if elapsed_ms <= 0 || size_bytes == 0 { + return Ok(0); + } + let elapsed_seconds = u128::try_from(elapsed_ms / 1000) + .map_err(|_| "storage billing elapsed time is negative".to_string())?; + let byte_seconds = u128::from(size_bytes) + .checked_mul(elapsed_seconds) + .ok_or_else(|| "storage byte seconds overflow".to_string())?; + byte_seconds + .checked_mul(STORAGE_CYCLES_PER_GIB_SECOND) + .ok_or_else(|| "storage charge cycles overflow".to_string()) + .map(|cycles| cycles / GIB_BYTES) +} + fn page_limit(limit: u32) -> u32 { limit.clamp(1, 100) } @@ -3854,19 +4211,19 @@ fn map_database_credits_entry( let balance_after: i64 = crate::sqlite::row_get(row, 4)?; let payment_amount_e8s: Option = crate::sqlite::row_get(row, 5)?; let cycles_delta: Option = crate::sqlite::row_get(row, 8)?; - let credits_per_kinic: Option = crate::sqlite::row_get(row, 9)?; + let credit_units_per_kinic: Option = crate::sqlite::row_get(row, 9)?; let ledger_block_index: Option = crate::sqlite::row_get(row, 10)?; Ok(DatabaseCreditEntry { entry_id: entry_id.max(0) as u64, database_id: crate::sqlite::row_get(row, 1)?, kind: crate::sqlite::row_get(row, 2)?, - amount_credits: crate::sqlite::row_get(row, 3)?, - balance_after_credits: balance_after.max(0) as u64, + amount_credit_units: crate::sqlite::row_get(row, 3)?, + balance_after_credit_units: balance_after.max(0) as u64, payment_amount_e8s: payment_amount_e8s.map(|value| value.max(0) as u64), caller: crate::sqlite::row_get(row, 6)?, method: crate::sqlite::row_get(row, 7)?, cycles_delta: cycles_delta.map(|value| value.max(0) as u64), - credits_per_kinic: credits_per_kinic.map(|value| value.max(0) as u64), + credit_units_per_kinic: credit_units_per_kinic.map(|value| value.max(0) as u64), ledger_block_index: ledger_block_index.map(|value| value.max(0) as u64), created_at_ms: crate::sqlite::row_get(row, 11)?, }) @@ -4454,6 +4811,20 @@ fn load_databases(conn: &Connection) -> Result, String> { .map_err(|error| error.to_string()) } +fn load_active_databases_for_storage_billing( + conn: &Connection, +) -> Result, String> { + let mut stmt = conn.prepare( + "SELECT database_id, name, db_file_name, active_mount_id, schema_version, logical_size_bytes, status + FROM databases + WHERE status = 'active' AND active_mount_id IS NOT NULL + ORDER BY mount_id ASC", + ) + .map_err(|error| error.to_string())?; + crate::sqlite::query_map(&mut stmt, params![], map_database_meta) + .map_err(|error| error.to_string()) +} + fn load_database_infos(conn: &Connection) -> Result, String> { let mut stmt = conn .prepare( @@ -4487,7 +4858,7 @@ fn load_database_summaries_for_caller( let mut stmt = conn .prepare( "SELECT d.database_id, d.name, d.status, m.role, d.logical_size_bytes, - COALESCE(b.balance_credits, 0), b.suspended_at_ms, + COALESCE(b.balance_credit_units, 0), b.suspended_at_ms, d.archived_at_ms FROM databases d INNER JOIN database_members m ON m.database_id = d.database_id @@ -4498,14 +4869,14 @@ fn load_database_summaries_for_caller( .map_err(|error| error.to_string())?; crate::sqlite::query_map(&mut stmt, params![caller], |row| { let logical_size_bytes: i64 = crate::sqlite::row_get(row, 4)?; - let credits_balance: i64 = crate::sqlite::row_get(row, 5)?; + let credit_units_balance: i64 = crate::sqlite::row_get(row, 5)?; Ok(DatabaseSummary { database_id: crate::sqlite::row_get(row, 0)?, name: crate::sqlite::row_get(row, 1)?, status: status_from_db(&crate::sqlite::row_get::(row, 2)?)?, role: role_from_db(&crate::sqlite::row_get::(row, 3)?)?, logical_size_bytes: logical_size_bytes.max(0) as u64, - credits_balance: Some(credits_balance.max(0) as u64), + credit_units_balance: Some(credit_units_balance.max(0) as u64), credits_suspended_at_ms: crate::sqlite::row_get(row, 6)?, archived_at_ms: crate::sqlite::row_get(row, 7)?, }) @@ -4625,8 +4996,8 @@ mod tests { CreditsConfig { kinic_ledger_canister_id: "aaaaa-aa".to_string(), sns_governance_id: "rrkah-fqaaa-aaaaa-aaaaq-cai".to_string(), - credits_per_kinic: DEFAULT_CREDITS_PER_KINIC, - min_update_credits: DEFAULT_MIN_UPDATE_CREDITS, + credit_units_per_kinic: DEFAULT_CREDIT_UNITS_PER_KINIC, + min_update_credit_units: DEFAULT_MIN_UPDATE_CREDIT_UNITS, } } @@ -4860,12 +5231,18 @@ mod tests { .expect("governance config should insert"); conn.execute( "INSERT INTO credits_config (key, value) VALUES (?1, ?2)", - params!["credits_per_kinic", config.credits_per_kinic.to_string()], + params![ + "credit_units_per_kinic", + config.credit_units_per_kinic.to_string() + ], ) .expect("rate config should insert"); conn.execute( "INSERT INTO credits_config (key, value) VALUES (?1, ?2)", - params!["min_update_credits", config.min_update_credits.to_string()], + params![ + "min_update_credit_units", + config.min_update_credit_units.to_string() + ], ) .expect("minimum config should insert"); conn.execute( @@ -4883,14 +5260,14 @@ mod tests { .expect("database should insert"); conn.execute( "INSERT INTO database_credit_accounts - (database_id, balance_credits, suspended_at_ms, created_at_ms, updated_at_ms) + (database_id, balance_credit_units, suspended_at_ms, created_at_ms, updated_at_ms) VALUES ('db_old', 0, 1, 1, 1)", params![], ) .expect("account should insert"); conn.execute( "INSERT INTO database_credit_pending_operations - (database_id, kind, caller, credits, payment_amount_e8s, from_owner, from_subaccount, + (database_id, kind, caller, credit_units, payment_amount_e8s, from_owner, from_subaccount, to_owner, to_subaccount, ledger_fee_e8s, ledger_created_at_time_ns, created_at_ms) VALUES ('db_old', 'credit_purchase', '2vxsx-fae', 10, 1000, '2vxsx-fae', NULL, 'aaaaa-aa', NULL, 10000, 1700000000000000000, 1)", @@ -4975,7 +5352,7 @@ mod tests { let result = service .query_index_sql_json( - "SELECT json_object('credit_purchase_credits', COALESCE(SUM(amount_credits), 0)) FROM database_credit_ledger WHERE kind = 'credit_purchase' LIMIT 1", + "SELECT json_object('credit_purchase_credits', COALESCE(SUM(amount_credit_units), 0)) FROM database_credit_ledger WHERE kind = 'credit_purchase' LIMIT 1", 10, ) .expect("index SQL should query"); @@ -5034,7 +5411,7 @@ mod tests { #[test] fn index_sql_json_rejects_mutating_sql() { for sql in [ - "UPDATE database_credit_accounts SET balance_credits = 0", + "UPDATE database_credit_accounts SET balance_credit_units = 0", "DELETE FROM database_credit_ledger", "INSERT INTO database_credit_ledger (database_id) VALUES ('x')", "CREATE TABLE x (id INTEGER)", @@ -5073,4 +5450,305 @@ mod tests { assert!(error.contains("one non-null TEXT JSON column")); } + + #[test] + fn storage_billing_daily_units_match_subnet_rate() { + let one_gib_cycles = + compute_storage_charge_cycles(GIB_BYTES as u64, STORAGE_BILLING_INTERVAL_MS) + .expect("1GiB storage cycles should compute"); + assert_eq!(one_gib_cycles.div_ceil(CYCLES_PER_CREDIT_UNIT), 10_973); + + let ten_mib = 10 * 1024 * 1024; + let ten_mib_cycles = compute_storage_charge_cycles(ten_mib, STORAGE_BILLING_INTERVAL_MS) + .expect("10MiB storage cycles should compute"); + assert_eq!(ten_mib_cycles.div_ceil(CYCLES_PER_CREDIT_UNIT), 108); + } + + #[test] + fn storage_billing_rounds_sub_unit_cycles_up() { + let dir = tempdir().expect("tempdir should create"); + let service = VfsService::new( + dir.path().join("index.sqlite3"), + dir.path().join("databases"), + ); + service + .run_index_migrations() + .expect("index migrations should run"); + service + .create_database("alpha", "owner", 0) + .expect("database should create"); + set_test_database_balance(&service, "alpha", 1_000); + let config = service.credits_config().expect("config should load"); + + service + .write_index(|tx| { + settle_database_storage_charge_in_tx( + tx, + StorageChargeInput { + database_id: "alpha", + caller: "canister", + size_bytes: 1, + now: STORAGE_BILLING_INTERVAL_MS, + config: &config, + }, + ) + }) + .expect("storage charge should settle"); + + let (balance, charged_at, amount) = service + .read_index(|conn| { + let account = load_storage_credit_account(conn, "alpha")?; + let amount: i64 = conn + .query_row( + "SELECT amount_credit_units FROM database_credit_ledger + WHERE database_id = 'alpha' AND kind = 'storage_charge'", + params![], + |row| crate::sqlite::row_get(row, 0), + ) + .map_err(|error| error.to_string())?; + Ok(( + account.balance_credit_units, + account.storage_charged_at_ms, + amount, + )) + }) + .expect("account should load"); + assert_eq!(balance, 999); + assert_eq!(charged_at, Some(STORAGE_BILLING_INTERVAL_MS)); + assert_eq!(amount, -1); + } + + #[test] + fn storage_billing_zero_cycles_updates_cursor_without_ledger() { + let dir = tempdir().expect("tempdir should create"); + let service = VfsService::new( + dir.path().join("index.sqlite3"), + dir.path().join("databases"), + ); + service + .run_index_migrations() + .expect("index migrations should run"); + service + .create_database("alpha", "owner", 0) + .expect("database should create"); + set_test_database_balance(&service, "alpha", 1_000); + let config = service.credits_config().expect("config should load"); + + service + .write_index(|tx| { + settle_database_storage_charge_in_tx( + tx, + StorageChargeInput { + database_id: "alpha", + caller: "canister", + size_bytes: 0, + now: STORAGE_BILLING_INTERVAL_MS, + config: &config, + }, + ) + }) + .expect("storage charge should settle"); + + let (balance, charged_at, ledger_count) = service + .read_index(|conn| { + let account = load_storage_credit_account(conn, "alpha")?; + let ledger_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM database_credit_ledger WHERE database_id = 'alpha'", + params![], + |row| crate::sqlite::row_get(row, 0), + ) + .map_err(|error| error.to_string())?; + Ok(( + account.balance_credit_units, + account.storage_charged_at_ms, + ledger_count, + )) + }) + .expect("account should load"); + assert_eq!(balance, 1_000); + assert_eq!(charged_at, Some(STORAGE_BILLING_INTERVAL_MS)); + assert_eq!(ledger_count, 0); + } + + #[test] + fn storage_billing_skips_less_than_interval() { + let dir = tempdir().expect("tempdir should create"); + let service = VfsService::new( + dir.path().join("index.sqlite3"), + dir.path().join("databases"), + ); + service + .run_index_migrations() + .expect("index migrations should run"); + service + .create_database("alpha", "owner", 0) + .expect("database should create"); + set_test_database_balance(&service, "alpha", 1_000); + let config = service.credits_config().expect("config should load"); + + service + .write_index(|tx| { + settle_database_storage_charge_in_tx( + tx, + StorageChargeInput { + database_id: "alpha", + caller: "canister", + size_bytes: GIB_BYTES as u64, + now: STORAGE_BILLING_INTERVAL_MS - 1, + config: &config, + }, + ) + }) + .expect("storage charge should settle"); + + let (balance, charged_at) = service + .read_index(|conn| { + let account = load_storage_credit_account(conn, "alpha")?; + Ok((account.balance_credit_units, account.storage_charged_at_ms)) + }) + .expect("account should load"); + assert_eq!(balance, 1_000); + assert_eq!(charged_at, Some(0)); + } + + #[test] + fn storage_billing_suspends_when_balance_is_insufficient() { + let dir = tempdir().expect("tempdir should create"); + let service = VfsService::new( + dir.path().join("index.sqlite3"), + dir.path().join("databases"), + ); + service + .run_index_migrations() + .expect("index migrations should run"); + service + .create_database("alpha", "owner", 0) + .expect("database should create"); + set_test_database_balance(&service, "alpha", 100); + let config = service.credits_config().expect("config should load"); + + service + .write_index(|tx| { + settle_database_storage_charge_in_tx( + tx, + StorageChargeInput { + database_id: "alpha", + caller: "canister", + size_bytes: GIB_BYTES as u64, + now: STORAGE_BILLING_INTERVAL_MS, + config: &config, + }, + ) + }) + .expect("storage charge should settle"); + + service + .write_index(|tx| { + settle_database_storage_charge_in_tx( + tx, + StorageChargeInput { + database_id: "alpha", + caller: "canister", + size_bytes: GIB_BYTES as u64, + now: STORAGE_BILLING_INTERVAL_MS * 2, + config: &config, + }, + ) + }) + .expect("second storage charge should settle"); + + let (balance, suspended_at, charged_at, kinds, amount) = service + .read_index(|conn| { + let account = load_storage_credit_account(conn, "alpha")?; + let mut stmt = conn + .prepare( + "SELECT kind FROM database_credit_ledger + WHERE database_id = 'alpha' + ORDER BY entry_id ASC", + ) + .map_err(|error| error.to_string())?; + let kinds = crate::sqlite::query_map(&mut stmt, params![], |row| { + crate::sqlite::row_get::(row, 0) + }) + .map_err(|error| error.to_string())?; + let amount: i64 = conn + .query_row( + "SELECT amount_credit_units FROM database_credit_ledger + WHERE database_id = 'alpha' AND kind = 'storage_charge'", + params![], + |row| crate::sqlite::row_get(row, 0), + ) + .map_err(|error| error.to_string())?; + Ok(( + account.balance_credit_units, + account.suspended_at_ms, + account.storage_charged_at_ms, + kinds, + amount, + )) + }) + .expect("ledger should load"); + assert_eq!(balance, 0); + assert_eq!(suspended_at, Some(STORAGE_BILLING_INTERVAL_MS)); + assert_eq!(charged_at, Some(STORAGE_BILLING_INTERVAL_MS * 2)); + assert_eq!(kinds, vec!["storage_charge", "suspend"]); + assert_eq!(amount, -100); + } + + #[test] + fn storage_billing_loads_active_databases_only() { + let dir = tempdir().expect("tempdir should create"); + let service = VfsService::new( + dir.path().join("index.sqlite3"), + dir.path().join("databases"), + ); + service + .run_index_migrations() + .expect("index migrations should run"); + for (database_id, status, mount_id) in [ + ("active", "active", Some(11_i64)), + ("pending", "pending", Some(12_i64)), + ("archived", "archived", None), + ("deleted", "deleted", None), + ] { + service + .write_index(|tx| { + tx.execute( + "INSERT INTO databases + (database_id, name, db_file_name, mount_id, active_mount_id, status, + schema_version, logical_size_bytes, created_at_ms, updated_at_ms) + VALUES (?1, ?1, ?1, COALESCE(?3, 0), ?3, ?2, ?4, 0, 0, 0)", + params![database_id, status, mount_id, DATABASE_SCHEMA_VERSION], + ) + .map_err(|error| error.to_string())?; + Ok(()) + }) + .expect("database row should insert"); + } + + let database_ids = service + .read_index(load_active_databases_for_storage_billing) + .expect("storage billing databases should load") + .into_iter() + .map(|meta| meta.database_id) + .collect::>(); + + assert_eq!(database_ids, vec!["active"]); + } + + fn set_test_database_balance(service: &VfsService, database_id: &str, balance: i64) { + service + .write_index(|tx| { + tx.execute( + "UPDATE database_credit_accounts + SET balance_credit_units = ?2, suspended_at_ms = NULL + WHERE database_id = ?1", + params![database_id, balance], + ) + .map_err(|error| error.to_string())?; + Ok(()) + }) + .expect("test database balance should update"); + } } diff --git a/crates/vfs_runtime/tests/database_service.rs b/crates/vfs_runtime/tests/database_service.rs index 6c68d3d0..ca777647 100644 --- a/crates/vfs_runtime/tests/database_service.rs +++ b/crates/vfs_runtime/tests/database_service.rs @@ -61,7 +61,7 @@ fn mainnet_011_index_upgrades_to_latest() { .expect("database status should load"); let balance: i64 = conn .query_row( - "SELECT balance_credits FROM database_credit_accounts WHERE database_id = 'db_existing'", + "SELECT balance_credit_units FROM database_credit_accounts WHERE database_id = 'db_existing'", params![], |row| row.get(0), ) @@ -73,6 +73,22 @@ fn mainnet_011_index_upgrades_to_latest() { |row| row.get(0), ) .expect("database credits suspension should exist"); + let storage_columns: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('database_credit_accounts') + WHERE name = 'storage_charged_at_ms'", + params![], + |row| row.get(0), + ) + .expect("storage charged cursor column should load"); + let removed_storage_columns: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('database_credit_accounts') + WHERE name = 'storage_unbilled_cycles'", + params![], + |row| row.get(0), + ) + .expect("removed storage column count should load"); let pending_details_columns: i64 = conn .query_row( "SELECT COUNT(*) FROM pragma_table_info('database_credit_pending_operations') @@ -102,6 +118,8 @@ fn mainnet_011_index_upgrades_to_latest() { assert_eq!(status, "active"); assert_eq!(balance, 0); assert_eq!(suspended_at_ms, Some(0)); + assert_eq!(storage_columns, 1); + assert_eq!(removed_storage_columns, 0); assert_eq!(pending_details_columns, 6); assert_eq!(ledger_cycles_column_count, 0); assert_eq!(usage_table_count, 0); @@ -316,13 +334,13 @@ fn cycles_per_credit_ledger_schema_is_rejected() { entry_id INTEGER PRIMARY KEY AUTOINCREMENT, database_id TEXT NOT NULL, kind TEXT NOT NULL, - amount_credits INTEGER NOT NULL, - balance_after_credits INTEGER NOT NULL, + amount_credit_units INTEGER NOT NULL, + balance_after_credit_units INTEGER NOT NULL, payment_amount_e8s INTEGER, caller TEXT NOT NULL, method TEXT, cycles_delta INTEGER, - credits_per_kinic INTEGER, + credit_units_per_kinic INTEGER, cycles_per_credit INTEGER, ledger_block_index INTEGER, created_at_ms INTEGER NOT NULL @@ -461,7 +479,7 @@ fn database_member_count(root: &std::path::Path, database_id: &str) -> i64 { fn database_credits_balance(root: &std::path::Path, database_id: &str) -> i64 { let conn = Connection::open(root.join("index.sqlite3")).expect("index should open"); conn.query_row( - "SELECT balance_credits FROM database_credit_accounts WHERE database_id = ?1", + "SELECT balance_credit_units FROM database_credit_accounts WHERE database_id = ?1", params![database_id], |row| row.get(0), ) @@ -504,22 +522,27 @@ fn credit_database( service: &VfsService, database_id: &str, caller: &str, - amount_credits: u64, + amount_credit_units: u64, block_index: u64, now: i64, ) -> u64 { let operation_id = service - .begin_database_credit_purchase(database_id, caller, amount_credits, now) + .begin_database_credit_purchase(database_id, caller, amount_credit_units, now) .expect("database credit purchase should begin"); service - .mark_database_credit_purchase_completed(operation_id, database_id, caller, amount_credits) + .mark_database_credit_purchase_completed( + operation_id, + database_id, + caller, + amount_credit_units, + ) .expect("database credit purchase should be marked completed"); service .credit_database_purchase( operation_id, database_id, caller, - amount_credits, + amount_credit_units, block_index, now, ) @@ -1801,8 +1824,8 @@ fn credits_config_version_changes_only_for_effective_rate_updates() { service .update_credits_config( CreditsConfigUpdate { - credits_per_kinic: 1_000, - min_update_credits: 1, + credit_units_per_kinic: 1_000_000, + min_update_credit_units: 1, }, "rrkah-fqaaa-aaaaa-aaaaq-cai", ) @@ -1812,8 +1835,8 @@ fn credits_config_version_changes_only_for_effective_rate_updates() { service .update_credits_config( CreditsConfigUpdate { - credits_per_kinic: 2_000, - min_update_credits: 1, + credit_units_per_kinic: 2_000_000, + min_update_credit_units: 1, }, "rrkah-fqaaa-aaaaa-aaaaq-cai", ) @@ -1832,9 +1855,9 @@ fn credit_purchase_preview_returns_fixed_payment_inputs() { .preview_database_credit_purchase("alpha", 500) .expect("preview should succeed"); - assert_eq!(preview.payment_amount_e8s, 50_000_000); + assert_eq!(preview.payment_amount_e8s, 50_000); assert_eq!(preview.ledger_fee_e8s, KINIC_LEDGER_FEE_E8S); - assert_eq!(preview.credits_per_kinic, 1_000); + assert_eq!(preview.credit_units_per_kinic, 1_000_000); assert_eq!(preview.config_version, 1); } @@ -1850,7 +1873,7 @@ fn credit_purchase_begin_rejects_stale_expected_values_before_pending_create() { DatabaseCreditPurchaseWithLedgerDetails { database_id: "alpha", caller: "payer", - credits: 500, + credit_units: 500, expected_payment_amount_e8s: 50_000_001, expected_config_version: 1, ledger: CreditsPendingLedgerDetailsInput { @@ -1873,7 +1896,7 @@ fn credit_purchase_begin_rejects_stale_expected_values_before_pending_create() { DatabaseCreditPurchaseWithLedgerDetails { database_id: "alpha", caller: "payer", - credits: 500, + credit_units: 500, expected_payment_amount_e8s: 50_000_000, expected_config_version: 2, ledger: CreditsPendingLedgerDetailsInput { @@ -2199,8 +2222,8 @@ fn verified_complete_allows_authenticated_caller_and_owner_cancel() { .expect("cancel entries should load") .entries; assert_eq!(cancel_entries[1].caller, "owner"); - assert_eq!(cancel_entries[1].payment_amount_e8s, Some(70_000_000)); - assert_eq!(cancel_entries[1].balance_after_credits, 0); + assert_eq!(cancel_entries[1].payment_amount_e8s, Some(70_000)); + assert_eq!(cancel_entries[1].balance_after_credit_units, 0); assert_eq!(cancel_entries[1].ledger_block_index, None); } @@ -2252,12 +2275,12 @@ fn zero_cycle_charge_skips_credit_ledger() { ); service - .charge_database_update(&config, "alpha", "owner", "write_node", 1_000_000_000, 4) + .charge_database_update(&config, "alpha", "owner", "write_node", 1_000_000, 4) .expect("charged update should record credit ledger"); assert_eq!(database_credits_balance(&root, "alpha"), 499); service - .charge_database_update(&config, "alpha", "owner", "write_node", 1_000_000_001, 5) + .charge_database_update(&config, "alpha", "owner", "write_node", 1_000_001, 5) .expect("rounded-up update should record credit ledger"); assert_eq!(database_credits_balance(&root, "alpha"), 497); @@ -2267,9 +2290,9 @@ fn zero_cycle_charge_skips_credit_ledger() { .entries; assert_eq!(entries.len(), 3); assert_eq!(entries[1].kind, "charge"); - assert_eq!(entries[1].amount_credits, -1); + assert_eq!(entries[1].amount_credit_units, -1); assert_eq!(entries[2].kind, "charge"); - assert_eq!(entries[2].amount_credits, -2); + assert_eq!(entries[2].amount_credit_units, -2); } #[test] diff --git a/crates/vfs_runtime/tests/database_service_pbt.rs b/crates/vfs_runtime/tests/database_service_pbt.rs index f1890c42..4bbffbab 100644 --- a/crates/vfs_runtime/tests/database_service_pbt.rs +++ b/crates/vfs_runtime/tests/database_service_pbt.rs @@ -7,7 +7,7 @@ use proptest::prelude::*; use proptest::test_runner::{Config as ProptestConfig, FileFailurePersistence}; use sha2::{Digest, Sha256}; use tempfile::{TempDir, tempdir}; -use vfs_runtime::{CYCLES_PER_CREDIT, VfsService}; +use vfs_runtime::{CYCLES_PER_CREDIT_UNIT, VfsService}; use vfs_types::DatabaseStatus; const OWNER: &str = "owner"; @@ -90,13 +90,26 @@ fn credit_database( service: &VfsService, database_id: &str, caller: &str, - credits: u64, + credit_units: u64, block_index: u64, now: i64, ) -> Result { - let operation_id = service.begin_database_credit_purchase(database_id, caller, credits, now)?; - service.mark_database_credit_purchase_completed(operation_id, database_id, caller, credits)?; - service.credit_database_purchase(operation_id, database_id, caller, credits, block_index, now) + let operation_id = + service.begin_database_credit_purchase(database_id, caller, credit_units, now)?; + service.mark_database_credit_purchase_completed( + operation_id, + database_id, + caller, + credit_units, + )?; + service.credit_database_purchase( + operation_id, + database_id, + caller, + credit_units, + block_index, + now, + ) } fn status_and_mount(service: &VfsService, database_id: &str) -> (DatabaseStatus, Option) { @@ -145,7 +158,7 @@ fn database_bytes(root: &Path, service: &VfsService, database_id: &str) -> (Vec< fn charge_amount(cycles_delta: u128) -> u64 { cycles_delta - .div_ceil(CYCLES_PER_CREDIT) + .div_ceil(CYCLES_PER_CREDIT_UNIT) .try_into() .expect("generated charge fits u64") } @@ -158,7 +171,7 @@ fn assert_invariants(service: &VfsService, database_id: &str, model: &Model) { let database_after = database_entries .last() .expect("database ledger should not be empty") - .balance_after_credits; + .balance_after_credit_units; assert_eq!(database_after, model.database_credits); let (status, mount_id) = status_and_mount(service, database_id); diff --git a/crates/vfs_runtime/tests/database_service_pbt_ext.rs b/crates/vfs_runtime/tests/database_service_pbt_ext.rs index da0ae905..253b1697 100644 --- a/crates/vfs_runtime/tests/database_service_pbt_ext.rs +++ b/crates/vfs_runtime/tests/database_service_pbt_ext.rs @@ -9,7 +9,7 @@ use proptest::test_runner::{Config as ProptestConfig, FileFailurePersistence}; use rusqlite::{Connection, params}; use sha2::{Digest, Sha256}; use tempfile::{TempDir, tempdir}; -use vfs_runtime::{CYCLES_PER_CREDIT, DEFAULT_MIN_UPDATE_CREDITS, VfsService}; +use vfs_runtime::{CYCLES_PER_CREDIT_UNIT, DEFAULT_MIN_UPDATE_CREDIT_UNITS, VfsService}; use vfs_types::{DatabaseStatus, DeleteDatabaseRequest, NodeKind, WriteNodeRequest}; const OWNER: &str = "owner"; @@ -86,19 +86,32 @@ fn credit_database( service: &VfsService, database_id: &str, caller: &str, - credits: u64, + credit_units: u64, block_index: u64, now: i64, ) -> Result { - let operation_id = service.begin_database_credit_purchase(database_id, caller, credits, now)?; - service.mark_database_credit_purchase_completed(operation_id, database_id, caller, credits)?; - service.credit_database_purchase(operation_id, database_id, caller, credits, block_index, now) + let operation_id = + service.begin_database_credit_purchase(database_id, caller, credit_units, now)?; + service.mark_database_credit_purchase_completed( + operation_id, + database_id, + caller, + credit_units, + )?; + service.credit_database_purchase( + operation_id, + database_id, + caller, + credit_units, + block_index, + now, + ) } fn db_account(root: &Path, database_id: &str) -> (u64, Option) { let conn = Connection::open(root.join("index.sqlite3")).expect("index should open"); conn.query_row( - "SELECT balance_credits, suspended_at_ms + "SELECT balance_credit_units, suspended_at_ms FROM database_credit_accounts WHERE database_id = ?1", params![database_id], @@ -122,7 +135,7 @@ fn status_and_mount(service: &VfsService, database_id: &str) -> (DatabaseStatus, } fn charge_amount(cycles_delta: u128) -> u64 { - let variable = cycles_delta.div_ceil(CYCLES_PER_CREDIT); + let variable = cycles_delta.div_ceil(CYCLES_PER_CREDIT_UNIT); u64::try_from(variable).expect("generated charge fits u64") } @@ -130,7 +143,7 @@ fn assert_database_ledger_chain(root: &Path, database_id: &str, expected_balance let conn = Connection::open(root.join("index.sqlite3")).expect("index should open"); let mut stmt = conn .prepare( - "SELECT kind, amount_credits, balance_after_credits, method, cycles_delta + "SELECT kind, amount_credit_units, balance_after_credit_units, method, cycles_delta FROM database_credit_ledger WHERE database_id = ?1 ORDER BY entry_id ASC", @@ -322,10 +335,10 @@ proptest! { let (stored_balance, suspended_at_ms) = db_account(&env.root, &database_id); assert_eq!(stored_balance, database_balance); - assert_eq!(suspended_at_ms.is_some(), database_balance < DEFAULT_MIN_UPDATE_CREDITS); + assert_eq!(suspended_at_ms.is_some(), database_balance < DEFAULT_MIN_UPDATE_CREDIT_UNITS); assert_eq!( service.require_database_write_credits_available(&database_id).is_ok(), - database_balance >= DEFAULT_MIN_UPDATE_CREDITS + database_balance >= DEFAULT_MIN_UPDATE_CREDIT_UNITS ); assert_database_ledger_chain(&env.root, &database_id, database_balance); } diff --git a/crates/vfs_types/src/fs.rs b/crates/vfs_types/src/fs.rs index cb3addf6..21454066 100644 --- a/crates/vfs_types/src/fs.rs +++ b/crates/vfs_types/src/fs.rs @@ -57,7 +57,7 @@ pub struct DatabaseSummary { pub status: DatabaseStatus, pub role: DatabaseRole, pub logical_size_bytes: u64, - pub credits_balance: Option, + pub credit_units_balance: Option, pub credits_suspended_at_ms: Option, pub archived_at_ms: Option, } @@ -66,28 +66,28 @@ pub struct DatabaseSummary { pub struct CreditsConfig { pub kinic_ledger_canister_id: String, pub sns_governance_id: String, - pub credits_per_kinic: u64, - pub min_update_credits: u64, + pub credit_units_per_kinic: u64, + pub min_update_credit_units: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] pub struct CreditsConfigUpdate { - pub credits_per_kinic: u64, - pub min_update_credits: u64, + pub credit_units_per_kinic: u64, + pub min_update_credit_units: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] pub struct DatabaseCreditPurchasePreview { pub payment_amount_e8s: u64, pub ledger_fee_e8s: u64, - pub credits_per_kinic: u64, + pub credit_units_per_kinic: u64, pub config_version: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] pub struct DatabaseCreditPurchaseRequest { pub database_id: String, - pub credits: u64, + pub credit_units: u64, pub expected_payment_amount_e8s: u64, pub expected_config_version: u64, } @@ -95,7 +95,7 @@ pub struct DatabaseCreditPurchaseRequest { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] pub struct CreditsPurchaseResult { pub block_index: u64, - pub balance_credits: u64, + pub balance_credit_units: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] @@ -103,13 +103,13 @@ pub struct DatabaseCreditEntry { pub entry_id: u64, pub database_id: String, pub kind: String, - pub amount_credits: i64, - pub balance_after_credits: u64, + pub amount_credit_units: i64, + pub balance_after_credit_units: u64, pub payment_amount_e8s: Option, pub caller: String, pub method: Option, pub cycles_delta: Option, - pub credits_per_kinic: Option, + pub credit_units_per_kinic: Option, pub ledger_block_index: Option, pub created_at_ms: i64, } @@ -126,7 +126,7 @@ pub struct DatabaseCreditPendingOperation { pub database_id: String, pub kind: String, pub caller: String, - pub credits: i64, + pub credit_units: i64, pub payment_amount_e8s: i64, pub from_owner: Option, pub from_subaccount: Option>, diff --git a/docs/DB_LIFECYCLE.md b/docs/DB_LIFECYCLE.md index ce927041..fb0434fd 100644 --- a/docs/DB_LIFECYCLE.md +++ b/docs/DB_LIFECYCLE.md @@ -44,9 +44,9 @@ Only `active` DBs are available to normal VFS APIs. ## Size Tracking -`logical_size_bytes` tracks the SQLite file size for a database. +`logical_size_bytes` is the billable SQLite bytes for an active database. -It is updated after VFS mutations and restore finalization. It is useful for visibility and planning, but it is not a stable-memory credits or shrink metric. +It is updated after VFS mutations, restore finalization, and storage billing settle. SQLite free pages are included. Index DB bytes, canister heap, and shared management state are excluded. Deleting or archiving a DB releases the active mount. It does not imply that canister stable memory shrinks or that the stable-memory mount ID is reused. @@ -60,7 +60,7 @@ DB creation uses `create_database(display_name)`. It creates a generated `databa External ledger calls are limited to DB credit purchase: -- `preview_database_credit_purchase(database_id, credits)` returns `payment_amount_e8s`, `ledger_fee_e8s`, `credits_per_kinic`, and `config_version`. +- `preview_database_credit_purchase(database_id, credit_units)` returns `payment_amount_e8s`, `ledger_fee_e8s`, `credit_units_per_kinic`, and `config_version`. - `purchase_database_credits(DatabaseCreditPurchaseRequest)` pulls the KINIC payment from the caller through ICRC-2 `approve` + `icrc2_transfer_from` and mints credits into that DB credits balance. The request must include the previewed `expected_payment_amount_e8s` and `expected_config_version`; mismatch rejects before pending operation creation and before ledger transfer. The approved allowance must cover `payment_amount_e8s + ledger_fee_e8s`. Any authenticated caller can credit purchase an existing DB that still has an owner, including callers with no DB role. `preview_database_credit_purchase` is intentionally callable by anonymous callers so wallet UIs can validate a database target before requesting approval. The payer is recorded in the DB ledger entry. Reader and writer credits history redacts payer/caller principals, while DB owner and SNS governance can read full payer/caller details. Once the ledger call starts, completion, cancellation, or ambiguous recording resolves the started operation even if membership changes during the await. @@ -68,16 +68,25 @@ Any authenticated caller can credit purchase an existing DB that still has an ow Successful DB update calls are charged after execution. The charge is: ```text -ceil(cycles_delta / 1_000_000_000) +ceil(cycles_delta / 1_000_000) ``` -The default purchase rate is `1 KINIC = 1000 credits`, controlled by `credits_per_kinic`. Runtime consumption is fixed at `1 credit = 1_000_000_000 cycles`. Before a metered update, the caller role is checked first, then the DB credits balance must be at least `min_update_credits` and the DB must not be suspended. Non-members receive access errors without learning credits state. If the post-update charge exceeds the DB credits balance, the remaining balance is fully consumed, the DB is suspended, and the update result remains successful. +Credits are stored as integer credit units. `1 credit_unit = 0.001 credit = 1_000_000 cycles`; UI and CLI output divide units by `1000`. The default purchase rate is `1 KINIC = 1_000_000 credit_units`, controlled by `credit_units_per_kinic`. Before a metered update, the caller role is checked first, then the DB credits balance must be at least `min_update_credit_units` and the DB must not be suspended. Non-members receive access errors without learning credits state. If the post-update charge exceeds the DB credits balance, the remaining balance is fully consumed, the DB is suspended, and the update result remains successful. + +Storage billing settles every 24h from a canister timer, with controller-only `settle_database_storage_charges()` as recovery path. Only active DBs are charged. The 13-node subnet rate is fixed at `127_000 cycles / GiB / sec`: + +```text +storage_cycles = logical_size_bytes * elapsed_seconds * 127_000 / 2^30 +charge_units = ceil(storage_cycles / 1_000_000) +``` + +Storage charges write `kind = "storage_charge"` ledger entries for actually collected credit units. Cycles below 1 credit_unit and insufficient-balance unpaid cycles are not carried forward; each DB settle rounds up by less than one credit_unit. Insufficient balance consumes the remaining balance and suspends the DB. `database_credit_ledger` is the credits source of truth. Successful charged update calls are recorded there directly. Ledger-backed credit purchase and repair entries store ledger block indexes in `ledger_block_index`. Credits history redacts payer/caller principals for reader and writer callers. DB owner and SNS governance can read full credits history. Pending credit operations remain visible only to DB owner and SNS governance. New credits history fields must not carry payer/caller principals unless the same redaction policy is applied. -`kinic_ledger_canister_id` and `sns_governance_id` are fixed at init. SNS governance may update only rate and minimum-balance fields by calling `update_credits_config` with a Candid-encoded `CreditsConfigUpdate` blob. `config_version` starts at `1` and increments only when `credits_per_kinic` or `min_update_credits` actually changes. +`kinic_ledger_canister_id` and `sns_governance_id` are fixed at init. SNS governance may update only rate and minimum-balance fields by calling `update_credits_config` with a Candid-encoded `CreditsConfigUpdate` blob. `config_version` starts at `1` and increments only when `credit_units_per_kinic` or `min_update_credit_units` actually changes. `scripts/local/deploy_wiki.sh` carries local development init args. If `SNS_GOVERNANCE_ID` is unset, local deploy uses `icp identity principal`. The deploy script does not create a ledger canister by itself. Local credit purchase smoke should use `scripts/local/setup_kinic_ledger.sh` or `scripts/smoke/local_canister_archive_restore.sh`, which creates or validates a project-local ICRC ledger and deploys the wiki with that ledger ID. diff --git a/extensions/wiki-clipper/scripts/check-candid-drift.mjs b/extensions/wiki-clipper/scripts/check-candid-drift.mjs index 66f8ee2a..73bc349a 100644 --- a/extensions/wiki-clipper/scripts/check-candid-drift.mjs +++ b/extensions/wiki-clipper/scripts/check-candid-drift.mjs @@ -18,7 +18,7 @@ const expectedTypes = { role: "DatabaseRole", logical_size_bytes: "nat64", database_id: "text", - credits_balance: "opt nat64", + credit_units_balance: "opt nat64", credits_suspended_at_ms: "opt int64", archived_at_ms: "opt int64" } @@ -28,8 +28,8 @@ const expectedTypes = { fields: { kinic_ledger_canister_id: "text", sns_governance_id: "text", - credits_per_kinic: "nat64", - min_update_credits: "nat64" + credit_units_per_kinic: "nat64", + min_update_credit_units: "nat64" } }, CreateDatabaseRequest: { kind: "record", fields: { name: "text" } }, diff --git a/extensions/wiki-clipper/src/vfs-actor.js b/extensions/wiki-clipper/src/vfs-actor.js index 2d7adf0b..5917421a 100644 --- a/extensions/wiki-clipper/src/vfs-actor.js +++ b/extensions/wiki-clipper/src/vfs-actor.js @@ -30,15 +30,15 @@ function idlFactory({ IDL: idl }) { role: DatabaseRole, logical_size_bytes: idl.Nat64, database_id: idl.Text, - credits_balance: idl.Opt(idl.Nat64), + credit_units_balance: idl.Opt(idl.Nat64), credits_suspended_at_ms: idl.Opt(idl.Int64), archived_at_ms: idl.Opt(idl.Int64) }); const CreditsConfig = idl.Record({ kinic_ledger_canister_id: idl.Text, sns_governance_id: idl.Text, - credits_per_kinic: idl.Nat64, - min_update_credits: idl.Nat64 + credit_units_per_kinic: idl.Nat64, + min_update_credit_units: idl.Nat64 }); const CreateDatabaseRequest = idl.Record({ name: idl.Text }); const CreateDatabaseResult = idl.Record({ database_id: idl.Text, name: idl.Text }); @@ -170,7 +170,7 @@ function normalizeDatabaseSummary(raw) { role: variantKey(raw.role), status: normalizeDatabaseStatus(raw.status), logicalSizeBytes: raw.logical_size_bytes?.toString?.() ?? String(raw.logical_size_bytes ?? "0"), - creditsBalance: raw.credits_balance?.[0]?.toString?.() ?? "0", + creditsBalance: raw.credit_units_balance?.[0]?.toString?.() ?? "0", creditsSuspendedAtMs: raw.credits_suspended_at_ms?.[0]?.toString?.() ?? null }; } @@ -188,7 +188,7 @@ export async function getCreditsConfigOrNull(actor) { function normalizeCreditsConfig(raw) { return { - minUpdateCredits: raw.min_update_credits?.toString?.() ?? String(raw.min_update_credits ?? "0") + minUpdateCredits: raw.min_update_credit_units?.toString?.() ?? String(raw.min_update_credit_units ?? "0") }; } diff --git a/extensions/wiki-clipper/tests/offscreen.test.mjs b/extensions/wiki-clipper/tests/offscreen.test.mjs index bf237768..a8dddbd2 100644 --- a/extensions/wiki-clipper/tests/offscreen.test.mjs +++ b/extensions/wiki-clipper/tests/offscreen.test.mjs @@ -350,8 +350,8 @@ test("listWritableDatabases returns active writable database summaries", async ( Ok: { kinic_ledger_canister_id: "ryjl3-tyaaa-aaaaa-aaaba-cai", sns_governance_id: "rrkah-fqaaa-aaaaa-aaaaq-cai", - credits_per_kinic: 1n, - min_update_credits: 10_000n + credit_units_per_kinic: 1n, + min_update_credit_units: 10_000n } }; } @@ -404,7 +404,7 @@ function writeCreditsActorMethods({ databaseId = "team-db", balanceCredits = 20_ role: { Writer: null }, status: { Active: null }, logical_size_bytes: 0n, - credits_balance: [balanceCredits], + credit_units_balance: [balanceCredits], credits_suspended_at_ms: suspendedAtMs === null ? [] : [suspendedAtMs], archived_at_ms: [] } @@ -416,8 +416,8 @@ function writeCreditsActorMethods({ databaseId = "team-db", balanceCredits = 20_ Ok: { kinic_ledger_canister_id: "ryjl3-tyaaa-aaaaa-aaaba-cai", sns_governance_id: "rrkah-fqaaa-aaaaa-aaaaq-cai", - credits_per_kinic: 1n, - min_update_credits: 10_000n + credit_units_per_kinic: 1n, + min_update_credit_units: 10_000n } }; } @@ -431,7 +431,7 @@ function rawDatabase(databaseId, name, role, status) { role: { [role]: null }, status: { [status]: null }, logical_size_bytes: 0n, - credits_balance: [20_000n], + credit_units_balance: [20_000n], credits_suspended_at_ms: [], archived_at_ms: [] }; diff --git a/extensions/wiki-clipper/tests/settings.test.mjs b/extensions/wiki-clipper/tests/settings.test.mjs index 4638763f..60fd31d7 100644 --- a/extensions/wiki-clipper/tests/settings.test.mjs +++ b/extensions/wiki-clipper/tests/settings.test.mjs @@ -259,7 +259,7 @@ function rawDatabase(databaseId, role, status, nameOrBalance = 20_000n, creditsS role: { [role]: null }, status: { [status]: null }, logical_size_bytes: 0n, - credits_balance: [creditsBalance], + credit_units_balance: [creditsBalance], credits_suspended_at_ms: creditsSuspendedAtMs === null ? [] : [creditsSuspendedAtMs], archived_at_ms: [] }; diff --git a/wikibrowser/app/credits/credits-client.tsx b/wikibrowser/app/credits/credits-client.tsx index 2574aa1d..24d74047 100644 --- a/wikibrowser/app/credits/credits-client.tsx +++ b/wikibrowser/app/credits/credits-client.tsx @@ -91,7 +91,7 @@ export function CreditsClient({ canisterId, databaseId, initialCredits }: Credit setProvider(selectedProvider); setMessage(null); try { - const request = { canisterId, databaseId: parsedTarget.databaseId, credits: parsedAmount }; + const request = { canisterId, databaseId: parsedTarget.databaseId, creditUnits: parsedAmount }; const result = selectedProvider === "oisy" && activeOisyWallet ? await purchaseCreditsWithOisy(request, activeOisyWallet) diff --git a/wikibrowser/components/wiki-browser.tsx b/wikibrowser/components/wiki-browser.tsx index e58aca88..8bc46fd8 100644 --- a/wikibrowser/components/wiki-browser.tsx +++ b/wikibrowser/components/wiki-browser.tsx @@ -17,7 +17,7 @@ import { IngestPanel } from "@/components/ingest-panel"; import { QueryPanel } from "@/components/query-panel"; import { PanelHeader } from "@/components/panel"; import { AUTH_CLIENT_CREATE_OPTIONS, authLoginOptions } from "@/lib/auth"; -import { databaseCreditsDisabledReason, databaseCreditsHref, databaseCreditsView } from "@/lib/credits-state"; +import { databaseCreditsDisabledReason, databaseCreditsHref, databaseCreditsView, formatCredits } from "@/lib/credits-state"; import { readBrowserNodeCache } from "@/lib/browser-node-cache"; import { hrefForDatabaseSwitch, hrefForGraph, hrefForHelp, hrefForPath, hrefForSearch, parentPath } from "@/lib/paths"; import { nodeRequestKey } from "@/lib/request-keys"; @@ -1406,13 +1406,13 @@ function TopBar({ function DatabaseCreditsBadge({ credits, database }: { credits: ReturnType; database: DatabaseSummary | null }) { const title = database - ? `${database.name}: ${credits.label}; ${credits.balanceCredits.toString()} credits` + ? `${database.name}: ${credits.label}; ${formatCredits(credits.balanceCredits)}` : "Database credits unavailable"; const content = ( <> {credits.label} - {credits.balanceCredits.toString()} + {formatCredits(credits.balanceCredits)} ); const className = `hidden h-[38px] shrink-0 items-center gap-2 rounded-lg border px-3 text-sm md:flex ${databaseCreditsToneClass(credits.state)}`; diff --git a/wikibrowser/lib/credits-state.ts b/wikibrowser/lib/credits-state.ts index c10d0ff3..99cedbfc 100644 --- a/wikibrowser/lib/credits-state.ts +++ b/wikibrowser/lib/credits-state.ts @@ -105,6 +105,9 @@ function parseOptionalCredits(value: string | null | undefined): bigint { return BigInt(value); } -function formatCredits(value: bigint): string { - return `${value.toString()} credits`; +export function formatCredits(value: bigint): string { + const whole = value / 1000n; + const fraction = value % 1000n; + if (fraction === 0n) return `${whole.toString()} credits`; + return `${whole.toString()}.${fraction.toString().padStart(3, "0").replace(/0+$/, "")} credits`; } diff --git a/wikibrowser/lib/credits-url.ts b/wikibrowser/lib/credits-url.ts index 5a427e04..3fe23503 100644 --- a/wikibrowser/lib/credits-url.ts +++ b/wikibrowser/lib/credits-url.ts @@ -16,11 +16,12 @@ export function parseCreditsTarget(input: URLSearchParams): CreditsTarget | stri export function parseCreditsAmountInput(value: string): bigint | string { const trimmed = value.trim(); - if (!/^[0-9]+$/.test(trimmed)) return "credits must be a positive integer"; - const credits = BigInt(trimmed); - if (credits <= 0n) return "credits must be positive"; - if (credits > MAX_U64) return "credits must be <= u64::MAX"; - return credits; + const match = /^([0-9]+)(?:\.([0-9]{1,3}))?$/.exec(trimmed); + if (!match) return "credits must be a positive number with up to 3 decimals"; + const creditUnits = BigInt(match[1]) * 1000n + BigInt((match[2] ?? "").padEnd(3, "0") || "0"); + if (creditUnits <= 0n) return "credits must be positive"; + if (creditUnits > MAX_U64) return "credit units must be <= u64::MAX"; + return creditUnits; } export function purchaseQueryString(input: CreditsTarget): string { diff --git a/wikibrowser/lib/credits-wallet.ts b/wikibrowser/lib/credits-wallet.ts index 8b6cab37..4b77c5c5 100644 --- a/wikibrowser/lib/credits-wallet.ts +++ b/wikibrowser/lib/credits-wallet.ts @@ -12,7 +12,7 @@ type WalletProvider = "oisy" | "plug"; type CreditsPurchaseRequest = { canisterId: string; databaseId: string; - credits: bigint; + creditUnits: bigint; }; type CreditsPurchaseResult = { @@ -64,7 +64,7 @@ type PlugWallet = { }; type PlugVfsActor = { - purchase_database_credits: (request: DatabaseCreditPurchaseRequest) => Promise<{ Ok: { block_index: bigint; balance_credits: bigint } } | { Err: string }>; + purchase_database_credits: (request: DatabaseCreditPurchaseRequest) => Promise<{ Ok: { block_index: bigint; balance_credit_units: bigint } } | { Err: string }>; }; type LedgerActor = { @@ -193,11 +193,11 @@ export async function purchaseCreditsWithOisy(request: CreditsPurchaseRequest, c provider: "oisy", approveBlockIndex: approveBlockIndex.toString(), approvedAllowanceE8s: prepared.approvedAllowanceE8s.toString(), - creditedCredits: request.credits.toString(), + creditedCredits: formatCreditUnits(request.creditUnits), paymentAmountE8s: prepared.paymentAmountE8s.toString(), transferFeeE8s: prepared.transferFeeE8s.toString(), purchaseBlockIndex: purchase.blockIndex, - balanceCredits: purchase.balanceCredits + balanceCredits: purchase.balanceCredits ? formatCreditUnits(BigInt(purchase.balanceCredits)) : null }; } @@ -234,11 +234,11 @@ export async function purchaseCreditsWithPlug(request: CreditsPurchaseRequest, c provider: "plug", approveBlockIndex: approve.Ok.toString(), approvedAllowanceE8s: prepared.approvedAllowanceE8s.toString(), - creditedCredits: request.credits.toString(), + creditedCredits: formatCreditUnits(request.creditUnits), paymentAmountE8s: prepared.paymentAmountE8s.toString(), transferFeeE8s: prepared.transferFeeE8s.toString(), purchaseBlockIndex: purchase.block_index.toString(), - balanceCredits: purchase.balance_credits.toString() + balanceCredits: formatCreditUnits(purchase.balance_credit_units) }; } @@ -268,7 +268,7 @@ function rawApproveArgs(canisterId: string, allowanceE8s: bigint, expectedAllowa async function prepareCreditPurchase(request: CreditsPurchaseRequest, payer: string): Promise { assertConfiguredCreditsCanister(request.canisterId); const config = await getCreditsConfig(request.canisterId); - const preview = await previewDatabaseCreditPurchase(request.canisterId, request.databaseId, request.credits); + const preview = await previewDatabaseCreditPurchase(request.canisterId, request.databaseId, request.creditUnits); const transferFeeE8s = BigInt(preview.ledgerFeeE8s); const paymentAmountE8s = BigInt(preview.paymentAmountE8s); const approvedAllowanceE8s = allowanceForCreditPurchase(paymentAmountE8s, transferFeeE8s); @@ -278,7 +278,7 @@ async function prepareCreditPurchase(request: CreditsPurchaseRequest, payer: str kinicLedgerCanisterId: config.kinicLedgerCanisterId, purchaseRequest: { database_id: request.databaseId, - credits: request.credits, + credit_units: request.creditUnits, expected_payment_amount_e8s: paymentAmountE8s, expected_config_version: BigInt(preview.configVersion) }, @@ -362,7 +362,7 @@ function errorMessage(cause: unknown): string { function encodeCreditPurchaseArgs(request: DatabaseCreditPurchaseRequest): string { const PurchaseRequest = IDL.Record({ database_id: IDL.Text, - credits: IDL.Nat64, + credit_units: IDL.Nat64, expected_payment_amount_e8s: IDL.Nat64, expected_config_version: IDL.Nat64 }); @@ -414,7 +414,7 @@ function decodePurchaseResult(reply: Uint8Array): { blockIndex: string; balanceC const ok = Reflect.get(decoded, "Ok"); if (!isObject(ok)) throw new Error("wallet response result mismatch"); const blockIndex = Reflect.get(ok, "block_index"); - const balanceCredits = Reflect.get(ok, "balance_credits"); + const balanceCredits = Reflect.get(ok, "balance_credit_units"); if (typeof blockIndex !== "bigint" || typeof balanceCredits !== "bigint") { throw new Error("wallet response result mismatch"); } @@ -426,11 +426,18 @@ function decodePurchaseResult(reply: Uint8Array): { blockIndex: string; balanceC function purchaseResultType() { return IDL.Variant({ - Ok: IDL.Record({ block_index: IDL.Nat64, balance_credits: IDL.Nat64 }), + Ok: IDL.Record({ block_index: IDL.Nat64, balance_credit_units: IDL.Nat64 }), Err: IDL.Text }); } +function formatCreditUnits(units: bigint): string { + const whole = units / 1000n; + const fraction = units % 1000n; + if (fraction === 0n) return whole.toString(); + return `${whole.toString()}.${fraction.toString().padStart(3, "0").replace(/0+$/, "")}`; +} + function bytesFromUnknown(value: unknown, label: string): Uint8Array { if (value instanceof Uint8Array) return value; throw new Error(`${label} mismatch`); diff --git a/wikibrowser/lib/vfs-client.ts b/wikibrowser/lib/vfs-client.ts index b809686c..04f4c8db 100644 --- a/wikibrowser/lib/vfs-client.ts +++ b/wikibrowser/lib/vfs-client.ts @@ -61,20 +61,20 @@ type RawCanisterHealth = { type RawCreditsConfig = { kinic_ledger_canister_id: string; sns_governance_id: string; - credits_per_kinic: bigint; - min_update_credits: bigint; + credit_units_per_kinic: bigint; + min_update_credit_units: bigint; }; type RawDatabaseCreditPurchasePreview = { payment_amount_e8s: bigint; ledger_fee_e8s: bigint; - credits_per_kinic: bigint; + credit_units_per_kinic: bigint; config_version: bigint; }; export type DatabaseCreditPurchaseRequest = { database_id: string; - credits: bigint; + credit_units: bigint; expected_payment_amount_e8s: bigint; expected_config_version: bigint; }; @@ -85,7 +85,7 @@ type RawDatabaseSummary = { logical_size_bytes: bigint; database_id: string; name: string; - credits_balance: [] | [bigint]; + credit_units_balance: [] | [bigint]; credits_suspended_at_ms: [] | [bigint]; archived_at_ms: [] | [bigint]; }; @@ -110,7 +110,7 @@ type RawDatabaseCreditPendingOperation = { operation_id: bigint; database_id: string; kind: string; - credits: bigint; + credit_units: bigint; payment_amount_e8s: bigint; created_at_ms: bigint; }; @@ -280,7 +280,7 @@ type VfsActor = { >; incoming_links: (request: { database_id: string; path: string; limit: number }) => Promise<{ Ok: RawLinkEdge[] } | { Err: string }>; outgoing_links: (request: { database_id: string; path: string; limit: number }) => Promise<{ Ok: RawLinkEdge[] } | { Err: string }>; - preview_database_credit_purchase: (databaseId: string, credits: bigint) => Promise<{ Ok: RawDatabaseCreditPurchasePreview } | { Err: string }>; + preview_database_credit_purchase: (databaseId: string, creditUnits: bigint) => Promise<{ Ok: RawDatabaseCreditPurchasePreview } | { Err: string }>; graph_links: (request: { database_id: string; prefix: string; limit: number }) => Promise<{ Ok: RawLinkEdge[] } | { Err: string }>; graph_neighborhood: (request: { database_id: string; center_path: string; depth: number; limit: number }) => Promise<{ Ok: RawLinkEdge[] } | { Err: string }>; read_node_context: (request: { database_id: string; path: string; link_limit: number }) => Promise<{ Ok: [] | [RawNodeContext] } | { Err: string }>; @@ -428,10 +428,10 @@ export async function getCreditsConfig(canisterId: string): Promise { +export async function previewDatabaseCreditPurchase(canisterId: string, databaseId: string, creditUnits: bigint): Promise { return callVfs(async () => { const actor = await createVfsActor(canisterId); - const result = await actor.preview_database_credit_purchase(databaseId, credits); + const result = await actor.preview_database_credit_purchase(databaseId, creditUnits); if ("Err" in result) { throwCanisterError(result.Err); } @@ -907,8 +907,8 @@ function normalizeCreditsConfig(raw: RawCreditsConfig): CreditsConfig { return { kinicLedgerCanisterId: raw.kinic_ledger_canister_id, snsGovernanceId: raw.sns_governance_id, - creditsPerKinic: raw.credits_per_kinic.toString(), - minUpdateCredits: raw.min_update_credits.toString() + creditsPerKinic: raw.credit_units_per_kinic.toString(), + minUpdateCredits: raw.min_update_credit_units.toString() }; } @@ -916,7 +916,7 @@ function normalizeDatabaseCreditPurchasePreview(raw: RawDatabaseCreditPurchasePr return { paymentAmountE8s: raw.payment_amount_e8s.toString(), ledgerFeeE8s: raw.ledger_fee_e8s.toString(), - creditsPerKinic: raw.credits_per_kinic.toString(), + creditsPerKinic: raw.credit_units_per_kinic.toString(), configVersion: raw.config_version.toString() }; } @@ -928,7 +928,7 @@ function normalizeDatabaseSummary(raw: RawDatabaseSummary): DatabaseSummary { role: normalizeDatabaseRole(raw.role), status: normalizeDatabaseStatus(raw.status), logicalSizeBytes: raw.logical_size_bytes.toString(), - creditsBalance: raw.credits_balance[0]?.toString() ?? "0", + creditsBalance: raw.credit_units_balance[0]?.toString() ?? "0", creditsSuspendedAtMs: raw.credits_suspended_at_ms[0]?.toString() ?? null, archivedAtMs: raw.archived_at_ms[0]?.toString() ?? null }; @@ -948,7 +948,7 @@ function normalizeDatabaseCreditPendingOperation(raw: RawDatabaseCreditPendingOp operationId: raw.operation_id.toString(), databaseId: raw.database_id, kind: raw.kind, - credits: raw.credits.toString(), + credits: raw.credit_units.toString(), paymentAmountE8s: raw.payment_amount_e8s.toString(), createdAtMs: raw.created_at_ms.toString() }; diff --git a/wikibrowser/lib/vfs-idl.ts b/wikibrowser/lib/vfs-idl.ts index 02f6eb6d..6f9a73e4 100644 --- a/wikibrowser/lib/vfs-idl.ts +++ b/wikibrowser/lib/vfs-idl.ts @@ -22,26 +22,26 @@ export const idlFactory: ActorInterfaceFactory = ({ IDL: idl }) => { logical_size_bytes: idl.Nat64, database_id: idl.Text, name: idl.Text, - credits_balance: idl.Opt(idl.Nat64), + credit_units_balance: idl.Opt(idl.Nat64), credits_suspended_at_ms: idl.Opt(idl.Int64), archived_at_ms: idl.Opt(idl.Int64) }); const CreditsConfig = idl.Record({ - credits_per_kinic: idl.Nat64, - min_update_credits: idl.Nat64, + credit_units_per_kinic: idl.Nat64, + min_update_credit_units: idl.Nat64, kinic_ledger_canister_id: idl.Text, sns_governance_id: idl.Text }); - const CreditsPurchaseResult = idl.Record({ block_index: idl.Nat64, balance_credits: idl.Nat64 }); + const CreditsPurchaseResult = idl.Record({ block_index: idl.Nat64, balance_credit_units: idl.Nat64 }); const DatabaseCreditPurchasePreview = idl.Record({ payment_amount_e8s: idl.Nat64, ledger_fee_e8s: idl.Nat64, - credits_per_kinic: idl.Nat64, + credit_units_per_kinic: idl.Nat64, config_version: idl.Nat64 }); const DatabaseCreditPurchaseRequest = idl.Record({ database_id: idl.Text, - credits: idl.Nat64, + credit_units: idl.Nat64, expected_payment_amount_e8s: idl.Nat64, expected_config_version: idl.Nat64 }); @@ -83,14 +83,14 @@ export const idlFactory: ActorInterfaceFactory = ({ IDL: idl }) => { }); const DatabaseCreditEntry = idl.Record({ method: idl.Opt(idl.Text), - credits_per_kinic: idl.Opt(idl.Nat64), + credit_units_per_kinic: idl.Opt(idl.Nat64), payment_amount_e8s: idl.Opt(idl.Nat64), kind: idl.Text, created_at_ms: idl.Int64, - amount_credits: idl.Int64, + amount_credit_units: idl.Int64, ledger_block_index: idl.Opt(idl.Nat64), database_id: idl.Text, - balance_after_credits: idl.Nat64, + balance_after_credit_units: idl.Nat64, caller: idl.Text, cycles_delta: idl.Opt(idl.Nat64), entry_id: idl.Nat64 @@ -100,7 +100,7 @@ export const idlFactory: ActorInterfaceFactory = ({ IDL: idl }) => { next_cursor: idl.Opt(idl.Nat64) }); const DatabaseCreditPendingOperation = idl.Record({ - credits: idl.Int64, + credit_units: idl.Int64, payment_amount_e8s: idl.Int64, to_owner: idl.Opt(idl.Text), to_subaccount: idl.Opt(idl.Vec(idl.Nat8)), @@ -346,6 +346,7 @@ export const idlFactory: ActorInterfaceFactory = ({ IDL: idl }) => { rename_database: idl.Func([RenameDatabaseRequest], [ResultUnit], []), search_node_paths: idl.Func([SearchNodePathsRequest], [ResultSearch], ["query"]), search_nodes: idl.Func([SearchNodesRequest], [ResultSearch], ["query"]), + settle_database_storage_charges: idl.Func([], [ResultUnit], []), source_evidence: idl.Func([SourceEvidenceRequest], [ResultSourceEvidence], ["query"]), purchase_database_credits: idl.Func([DatabaseCreditPurchaseRequest], [ResultCreditsPurchase], []), write_node: idl.Func([WriteNodeRequest], [ResultWriteNode], []), diff --git a/wikibrowser/scripts/candid-shapes.mjs b/wikibrowser/scripts/candid-shapes.mjs index 8662e8ad..cfb00406 100644 --- a/wikibrowser/scripts/candid-shapes.mjs +++ b/wikibrowser/scripts/candid-shapes.mjs @@ -10,7 +10,7 @@ export const expectedTypes = { logical_size_bytes: "nat64", database_id: "text", name: "text", - credits_balance: "opt nat64", + credit_units_balance: "opt nat64", credits_suspended_at_ms: "opt int64", archived_at_ms: "opt int64" } @@ -18,22 +18,22 @@ export const expectedTypes = { CreditsConfig: { kind: "record", fields: { - credits_per_kinic: "nat64", - min_update_credits: "nat64", + credit_units_per_kinic: "nat64", + min_update_credit_units: "nat64", kinic_ledger_canister_id: "text", sns_governance_id: "text" } }, CreditsPurchaseResult: { kind: "record", - fields: { block_index: "nat64", balance_credits: "nat64" } + fields: { block_index: "nat64", balance_credit_units: "nat64" } }, DatabaseCreditPurchasePreview: { kind: "record", fields: { payment_amount_e8s: "nat64", ledger_fee_e8s: "nat64", - credits_per_kinic: "nat64", + credit_units_per_kinic: "nat64", config_version: "nat64" } }, @@ -41,7 +41,7 @@ export const expectedTypes = { kind: "record", fields: { database_id: "text", - credits: "nat64", + credit_units: "nat64", expected_payment_amount_e8s: "nat64", expected_config_version: "nat64" } @@ -113,14 +113,14 @@ export const expectedTypes = { kind: "record", fields: { method: "opt text", - credits_per_kinic: "opt nat64", + credit_units_per_kinic: "opt nat64", payment_amount_e8s: "opt nat64", kind: "text", created_at_ms: "int64", - amount_credits: "int64", + amount_credit_units: "int64", ledger_block_index: "opt nat64", database_id: "text", - balance_after_credits: "nat64", + balance_after_credit_units: "nat64", caller: "text", cycles_delta: "opt nat64", entry_id: "nat64" @@ -133,7 +133,7 @@ export const expectedTypes = { DatabaseCreditPendingOperation: { kind: "record", fields: { - credits: "int64", + credit_units: "int64", payment_amount_e8s: "int64", to_owner: "opt text", to_subaccount: "opt blob", @@ -503,6 +503,7 @@ export const expectedMethods = { search_node_paths: { input: ["SearchNodePathsRequest"], output: "ResultSearch", mode: "query" }, search_nodes: { input: ["SearchNodesRequest"], output: "ResultSearch", mode: "query" }, source_evidence: { input: ["SourceEvidenceRequest"], output: "ResultSourceEvidence", mode: "query" }, + settle_database_storage_charges: { input: [], output: "ResultUnit", mode: "update" }, purchase_database_credits: { input: ["DatabaseCreditPurchaseRequest"], output: "ResultCreditsPurchase", mode: "update" }, write_node: { input: ["WriteNodeRequest"], output: "ResultWriteNode", mode: "update" }, write_source_for_generation: { input: ["WriteSourceForGenerationRequest"], output: "ResultWriteSourceForGeneration", mode: "update" } diff --git a/wikibrowser/scripts/check-credits.mjs b/wikibrowser/scripts/check-credits.mjs index 2d2e09e8..bde068af 100644 --- a/wikibrowser/scripts/check-credits.mjs +++ b/wikibrowser/scripts/check-credits.mjs @@ -56,7 +56,7 @@ assert.match(wallet, /async function prepareCreditPurchase/); assert.match(wallet, /icrc2_allowance: idl\.Func\(\[allowanceArgs\], \[allowance\], \["query"\]\)/); assert.match(wallet, /icrc2_approve: idl\.Func\(\[approveArgs\], \[idl\.Variant\(\{ Ok: idl\.Nat, Err: approveError \}\)\], \[\]\)/); assert.doesNotMatch(wallet, /purchaseDatabaseCreditsFrom|notifyIdentity|wallet as unknown|OisyCanisterCaller/); -assert.match(wallet, /previewDatabaseCreditPurchase\(request\.canisterId, request\.databaseId, request\.credits\)/); +assert.match(wallet, /previewDatabaseCreditPurchase\(request\.canisterId, request\.databaseId, request\.creditUnits\)/); assert.match(wallet, /function allowanceForCreditPurchase\(amountE8s: bigint, transferFeeE8s: bigint\)/); assert.match(wallet, /return amountE8s \+ transferFeeE8s/); assert.doesNotMatch(wallet, /function paymentAmountE8sForCredits/); @@ -84,8 +84,8 @@ assert.match(client, /transfer fee/); assert.match(client, /Any authenticated wallet can purchase non-refundable credits/); assert.doesNotMatch(client, /withdraw KINIC|database balance/); assert.doesNotMatch(client, /credits canister does not match NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID/); -assert.match(url, /credits must be a positive integer/); -assert.match(url, /credits must be <= u64::MAX/); +assert.match(url, /credits must be a positive number with up to 3 decimals/); +assert.match(url, /credit units must be <= u64::MAX/); assert.match(url, /database_id is required/); assert.doesNotMatch(url, /params\.set\("amount_e8s"/); assert.match(idl, /get_credits_config/); diff --git a/wikibrowser/scripts/generate-vfs-idl.mjs b/wikibrowser/scripts/generate-vfs-idl.mjs index e59f69ae..47576170 100644 --- a/wikibrowser/scripts/generate-vfs-idl.mjs +++ b/wikibrowser/scripts/generate-vfs-idl.mjs @@ -161,6 +161,7 @@ const methodOrder = [ "rename_database", "search_node_paths", "search_nodes", + "settle_database_storage_charges", "source_evidence", "purchase_database_credits", "write_node", From 8672db6a0e61d92abb596bea797c9b3b9205a1d9 Mon Sep 17 00:00:00 2001 From: hude Date: Sat, 30 May 2026 10:00:19 +0900 Subject: [PATCH 02/19] Rename credit billing to cycles and migrate schemas --- crates/vfs_canister/src/lib.rs | 252 +-- crates/vfs_canister/src/tests.rs | 526 +++--- .../vfs_canister/src/tests_sync_contract.rs | 15 +- crates/vfs_canister/vfs.did | 78 +- crates/vfs_cli_core/src/commands.rs | 4 +- crates/vfs_client/src/lib.rs | 116 +- .../migrations/index_db/011_to_latest.sql | 65 +- .../index_db/021_pending_operation_status.sql | 2 + .../migrations/index_db/022_credit_units.sql | 41 + .../index_db/023_storage_billing.sql | 2 + .../migrations/index_db/024_direct_cycles.sql | 83 + .../index_db/fresh_index_schema.sql | 26 +- crates/vfs_runtime/src/lib.rs | 1446 +++++++++-------- crates/vfs_runtime/tests/database_service.rs | 604 ++++--- .../vfs_runtime/tests/database_service_pbt.rs | 81 +- .../tests/database_service_pbt_ext.rs | 96 +- crates/vfs_types/src/fs.rs | 51 +- docs/DB_LIFECYCLE.md | 61 +- extensions/wiki-clipper/popup/popup.js | 6 +- .../scripts/check-candid-drift.mjs | 16 +- extensions/wiki-clipper/src/offscreen.js | 14 +- extensions/wiki-clipper/src/service-worker.js | 2 +- extensions/wiki-clipper/src/vfs-actor.js | 70 +- .../wiki-clipper/tests/offscreen.test.mjs | 50 +- .../tests/service-worker.test.mjs | 10 +- .../wiki-clipper/tests/settings.test.mjs | 28 +- wikibrowser/README.md | 2 +- .../cycles-client.tsx} | 58 +- wikibrowser/app/{credits => cycles}/page.tsx | 16 +- .../app/dashboard/dashboard-client.tsx | 24 +- wikibrowser/app/dashboard/dashboard-ui.tsx | 22 +- .../app/dashboard/database-danger-zone.tsx | 10 +- wikibrowser/app/home-ui.tsx | 32 +- wikibrowser/app/page.tsx | 18 +- wikibrowser/components/document-pane.tsx | 28 +- wikibrowser/components/ingest-panel.tsx | 12 +- wikibrowser/components/query-panel.tsx | 8 +- wikibrowser/components/wiki-browser.tsx | 98 +- wikibrowser/lib/credits-state.ts | 113 -- wikibrowser/lib/credits-url.ts | 31 - wikibrowser/lib/cycles-state.ts | 111 ++ wikibrowser/lib/cycles-url.ts | 31 + .../{credits-wallet.ts => cycles-wallet.ts} | 126 +- .../lib/{credit-amount.ts => kinic-amount.ts} | 2 +- wikibrowser/lib/types.ts | 23 +- wikibrowser/lib/vfs-client.ts | 80 +- wikibrowser/lib/vfs-idl.ts | 70 +- wikibrowser/package.json | 2 +- wikibrowser/scripts/candid-shapes.mjs | 81 +- wikibrowser/scripts/check-candid-drift.mjs | 10 +- .../{check-credits.mjs => check-cycles.mjs} | 107 +- wikibrowser/scripts/check-dashboard.mjs | 36 +- wikibrowser/scripts/generate-vfs-idl.mjs | 52 +- 53 files changed, 2635 insertions(+), 2313 deletions(-) create mode 100644 crates/vfs_runtime/migrations/index_db/021_pending_operation_status.sql create mode 100644 crates/vfs_runtime/migrations/index_db/022_credit_units.sql create mode 100644 crates/vfs_runtime/migrations/index_db/023_storage_billing.sql create mode 100644 crates/vfs_runtime/migrations/index_db/024_direct_cycles.sql rename wikibrowser/app/{credits/credits-client.tsx => cycles/cycles-client.tsx} (80%) rename wikibrowser/app/{credits => cycles}/page.tsx (57%) delete mode 100644 wikibrowser/lib/credits-state.ts delete mode 100644 wikibrowser/lib/credits-url.ts create mode 100644 wikibrowser/lib/cycles-state.ts create mode 100644 wikibrowser/lib/cycles-url.ts rename wikibrowser/lib/{credits-wallet.ts => cycles-wallet.ts} (78%) rename wikibrowser/lib/{credit-amount.ts => kinic-amount.ts} (92%) rename wikibrowser/scripts/{check-credits.mjs => check-cycles.mjs} (51%) diff --git a/crates/vfs_canister/src/lib.rs b/crates/vfs_canister/src/lib.rs index bab2d6e3..37d831d0 100644 --- a/crates/vfs_canister/src/lib.rs +++ b/crates/vfs_canister/src/lib.rs @@ -33,15 +33,15 @@ use ic_stable_structures::memory_manager::{MemoryId, MemoryManager}; #[cfg(target_arch = "wasm32")] use vfs_runtime::STORAGE_BILLING_INTERVAL_MS; use vfs_runtime::{ - CreditsPendingLedgerDetailsInput, DatabaseCreditPurchaseWithLedgerDetails, DatabaseMeta, + CyclesPendingLedgerDetailsInput, DatabaseCyclesPurchaseWithLedgerDetails, DatabaseMeta, RequiredRole, VfsService, }; use vfs_types::{ AppendNodeRequest, CanisterHealth, CanonicalRole, ChildNode, CreateDatabaseRequest, - CreateDatabaseResult, CreditsConfig, CreditsConfigUpdate, CreditsPurchaseResult, - DatabaseArchiveChunk, DatabaseArchiveInfo, DatabaseCreditEntryPage, - DatabaseCreditPendingOperation, DatabaseCreditPendingOperationPage, - DatabaseCreditPurchasePreview, DatabaseCreditPurchaseRequest, DatabaseMember, + CreateDatabaseResult, CyclesBillingConfig, CyclesBillingConfigUpdate, CyclesPurchaseResult, + DatabaseArchiveChunk, DatabaseArchiveInfo, DatabaseCycleEntryPage, + DatabaseCyclePendingOperation, DatabaseCyclePendingOperationPage, + DatabaseCyclesPurchasePreview, DatabaseCyclesPurchaseRequest, DatabaseMember, DatabaseRestoreChunkRequest, DatabaseRole, DatabaseSummary, DeleteDatabaseRequest, DeleteNodeRequest, DeleteNodeResult, EditNodeRequest, EditNodeResult, ExportSnapshotRequest, ExportSnapshotResponse, FetchUpdatesRequest, FetchUpdatesResponse, GlobNodeHit, @@ -281,7 +281,7 @@ enum TransferFromError { } #[init] -fn init_hook(config: CreditsConfig) { +fn init_hook(config: CyclesBillingConfig) { initialize_or_trap(Some(config)); certify_http_responses(); schedule_storage_billing_timer(); @@ -289,7 +289,8 @@ fn init_hook(config: CreditsConfig) { #[post_upgrade] fn post_upgrade_hook() { - let config = post_upgrade_credits_config_arg().unwrap_or_else(|error| ic_cdk::trap(&error)); + let config = + post_upgrade_cycles_billing_config_arg().unwrap_or_else(|error| ic_cdk::trap(&error)); initialize_upgrade_or_trap(config); certify_http_responses(); schedule_storage_billing_timer(); @@ -446,11 +447,13 @@ fn list_databases() -> Result, String> { } #[query] -fn preview_database_credit_purchase( +fn preview_database_cycles_purchase( database_id: String, - credit_units: u64, -) -> Result { - with_service(|service| service.preview_database_credit_purchase(&database_id, credit_units)) + payment_amount_e8s: u64, +) -> Result { + with_service(|service| { + service.preview_database_cycles_purchase(&database_id, payment_amount_e8s) + }) } #[query] @@ -465,24 +468,24 @@ fn icrc10_supported_standards() -> Vec { fn icrc21_canister_call_consent_message( request: Icrc21ConsentMessageRequest, ) -> Icrc21ConsentMessageResponse { - if request.method != "purchase_database_credits" { + if request.method != "purchase_database_cycles" { return icrc21_unsupported(format!("unsupported canister call: {}", request.method)); } - let purchase = match Decode!(&request.arg, DatabaseCreditPurchaseRequest) { + let purchase = match Decode!(&request.arg, DatabaseCyclesPurchaseRequest) { Ok(decoded) => decoded, Err(error) => { return icrc21_unavailable(format!( - "purchase_database_credits argument decode failed: {error}" + "purchase_database_cycles argument decode failed: {error}" )); } }; let preview = match with_service(|service| { - service.preview_database_credit_purchase(&purchase.database_id, purchase.credit_units) + service.preview_database_cycles_purchase(&purchase.database_id, purchase.payment_amount_e8s) }) { Ok(preview) => preview, Err(error) => return icrc21_unsupported(error), }; - if let Err(error) = validate_credit_purchase_expectations(&purchase, &preview) { + if let Err(error) = validate_cycles_purchase_expectations(&purchase, &preview) { return icrc21_unsupported(error); } let language = if request.user_preferences.metadata.language.trim().is_empty() { @@ -496,9 +499,9 @@ fn icrc21_canister_call_consent_message( utc_offset_minutes: request.user_preferences.metadata.utc_offset_minutes, }, consent_message: Icrc21ConsentMessage::GenericDisplayMessage(format!( - "# Purchase Kinic database credits\n\nDatabase: `{database_id}`\n\nCredits: `{credits}`\n\nPayment: `{payment}` KINIC\n\nLedger transfer fee in allowance: `{fee}` KINIC\n\nSpender canister: `{spender}`", + "# Purchase Kinic database cycles\n\nDatabase: `{database_id}`\n\nCycles: `{cycles}`\n\nPayment: `{payment}` KINIC\n\nLedger transfer fee in allowance: `{fee}` KINIC\n\nSpender canister: `{spender}`", database_id = purchase.database_id, - credits = format_credit_units(purchase.credit_units), + cycles = format_cycles(preview.cycles), payment = format_e8s(preview.payment_amount_e8s), fee = format_e8s(preview.ledger_fee_e8s), spender = canister_principal().to_text() @@ -507,32 +510,32 @@ fn icrc21_canister_call_consent_message( } #[update] -async fn purchase_database_credits( - request: DatabaseCreditPurchaseRequest, -) -> Result { +async fn purchase_database_cycles( + request: DatabaseCyclesPurchaseRequest, +) -> Result { require_authenticated_caller()?; let caller = caller_text(); let now = now_millis(); - let config = with_service(|service| service.credits_config())?; + let config = with_service(|service| service.cycles_billing_config())?; let ledger = Principal::from_text(&config.kinic_ledger_canister_id) .map_err(|error| format!("invalid KINIC ledger canister id: {error}"))?; let preview = with_service(|service| { - service.preview_database_credit_purchase(&request.database_id, request.credit_units) + service.preview_database_cycles_purchase(&request.database_id, request.payment_amount_e8s) })?; - validate_credit_purchase_expectations(&request, &preview)?; + validate_cycles_purchase_expectations(&request, &preview)?; let ledger_fee_e8s = preview.ledger_fee_e8s; let payment_amount_e8s = preview.payment_amount_e8s; let ledger_created_at_time_ns = now_nanos(); let canister_owner = canister_principal().to_text(); let operation_id = match with_service(|service| { - service.begin_database_credit_purchase_with_ledger_details( - DatabaseCreditPurchaseWithLedgerDetails { + service.begin_database_cycles_purchase_with_ledger_details( + DatabaseCyclesPurchaseWithLedgerDetails { database_id: &request.database_id, caller: &caller, - credit_units: request.credit_units, - expected_payment_amount_e8s: request.expected_payment_amount_e8s, + payment_amount_e8s: request.payment_amount_e8s, + expected_cycles: request.expected_cycles, expected_config_version: request.expected_config_version, - ledger: CreditsPendingLedgerDetailsInput { + ledger: CyclesPendingLedgerDetailsInput { from_owner: &caller, from_subaccount: None, to_owner: &canister_owner, @@ -566,113 +569,113 @@ async fn purchase_database_credits( { LedgerTransferFromOutcome::Completed(block_index) => { with_service(|service| { - service.mark_database_credit_purchase_completed( + service.mark_database_cycles_purchase_completed( operation_id, &request.database_id, &caller, - request.credit_units, + request.expected_cycles, ) }) - .map_err(|error| credit_purchase_local_apply_error(operation_id, block_index, error))?; - activate_pending_database_after_credit_purchase_ledger_success( + .map_err(|error| cycles_purchase_local_apply_error(operation_id, block_index, error))?; + activate_pending_database_after_cycles_purchase_ledger_success( &request.database_id, now, ) - .map_err(|error| credit_purchase_local_apply_error(operation_id, block_index, error))?; + .map_err(|error| cycles_purchase_local_apply_error(operation_id, block_index, error))?; #[cfg(test)] - if TEST_CREDIT_DATABASE_PURCHASE_APPLY_FAIL_ONCE.with(|flag| flag.replace(false)) { - return Err(credit_purchase_local_apply_error( + if TEST_DATABASE_CYCLES_PURCHASE_APPLY_FAIL_ONCE.with(|flag| flag.replace(false)) { + return Err(cycles_purchase_local_apply_error( operation_id, block_index, - "test credit purchase apply failure".to_string(), + "test cycle purchase apply failure".to_string(), )); } let balance = with_service(|service| { - service.credit_database_purchase( + service.apply_database_cycles_purchase( operation_id, &request.database_id, &caller, - request.credit_units, + request.expected_cycles, block_index, now, ) }) - .map_err(|error| credit_purchase_local_apply_error(operation_id, block_index, error))?; - Ok(CreditsPurchaseResult { + .map_err(|error| cycles_purchase_local_apply_error(operation_id, block_index, error))?; + Ok(CyclesPurchaseResult { block_index, - balance_credit_units: balance, + balance_cycles: balance, }) } LedgerTransferFromOutcome::LedgerErr(error) => { let _ = with_service(|service| { - service.cancel_database_credit_purchase( + service.cancel_database_cycles_purchase( operation_id, &request.database_id, &caller, - request.credit_units, + request.expected_cycles, ) }); Err(error) } LedgerTransferFromOutcome::Ambiguous(error) => { match with_service(|service| { - service.mark_database_credit_purchase_ambiguous( + service.mark_database_cycles_purchase_ambiguous( operation_id, &request.database_id, &caller, - request.credit_units, + request.expected_cycles, now, ) }) { Ok(_) => Err(format!( - "credit purchase pending operation {operation_id}; manual repair required: {error}" + "cycles purchase pending operation {operation_id}; manual repair required: {error}" )), Err(mark_error) => Err(format!( - "credit purchase pending operation {operation_id}; ledger result ambiguous; mark ambiguous failed: {mark_error}; original ledger error: {error}" + "cycles purchase pending operation {operation_id}; ledger result ambiguous; mark ambiguous failed: {mark_error}; original ledger error: {error}" )), } } } } -fn validate_credit_purchase_expectations( - request: &DatabaseCreditPurchaseRequest, - preview: &DatabaseCreditPurchasePreview, +fn validate_cycles_purchase_expectations( + request: &DatabaseCyclesPurchaseRequest, + preview: &DatabaseCyclesPurchasePreview, ) -> Result<(), String> { if request.expected_config_version != preview.config_version { return Err(format!( - "credits config changed: expected version {}, current version {}", + "cycles billing config changed: expected version {}, current version {}", request.expected_config_version, preview.config_version )); } - if request.expected_payment_amount_e8s != preview.payment_amount_e8s { + if request.expected_cycles != preview.cycles { return Err(format!( - "credit purchase payment amount changed: expected {}, current {}", - request.expected_payment_amount_e8s, preview.payment_amount_e8s + "cycles purchase amount changed: expected {}, current {}", + request.expected_cycles, preview.cycles )); } Ok(()) } #[query] -fn list_database_credit_entries( +fn list_database_cycle_entries( database_id: String, cursor: Option, limit: u32, -) -> Result { +) -> Result { with_service(|service| { - service.list_database_credit_entries(&database_id, &caller_text(), cursor, limit) + service.list_database_cycle_entries(&database_id, &caller_text(), cursor, limit) }) } #[query] -fn list_database_credit_pending_operations( +fn list_database_cycle_pending_operations( database_id: String, cursor: Option, limit: u32, -) -> Result { +) -> Result { with_service(|service| { - service.list_database_credit_pending_operations(&database_id, &caller_text(), cursor, limit) + service.list_database_cycle_pending_operations(&database_id, &caller_text(), cursor, limit) }) } @@ -691,45 +694,45 @@ fn settle_database_storage_charges() -> Result<(), String> { } #[update] -async fn repair_database_credit_purchase_complete( +async fn repair_database_cycles_purchase_complete( database_id: String, operation_id: u64, ledger_block_index: u64, -) -> Result { +) -> Result { require_authenticated_caller()?; - let config = with_service(|service| service.credits_config())?; + let config = with_service(|service| service.cycles_billing_config())?; let ledger = Principal::from_text(&config.kinic_ledger_canister_id) .map_err(|error| format!("invalid KINIC ledger canister id: {error}"))?; let operation = with_service(|service| { - service.get_database_credit_pending_operation_for_complete(&database_id, operation_id) + service.get_database_cycle_pending_operation_for_complete(&database_id, operation_id) })?; - let expected = expected_credit_purchase_transfer(&operation)?; + let expected = expected_cycles_purchase_transfer(&operation)?; validate_ledger_transfer_block(ledger, ledger_block_index, expected).await?; let now = now_millis(); - activate_pending_database_after_credit_purchase_ledger_success(&database_id, now).map_err( - |error| credit_purchase_local_apply_error(operation_id, ledger_block_index, error), + activate_pending_database_after_cycles_purchase_ledger_success(&database_id, now).map_err( + |error| cycles_purchase_local_apply_error(operation_id, ledger_block_index, error), )?; let balance = with_service(|service| { - service.repair_database_credit_purchase_complete( + service.repair_database_cycles_purchase_complete( &database_id, operation_id, ledger_block_index, now, ) }) - .map_err(|error| credit_purchase_local_apply_error(operation_id, ledger_block_index, error))?; - Ok(CreditsPurchaseResult { + .map_err(|error| cycles_purchase_local_apply_error(operation_id, ledger_block_index, error))?; + Ok(CyclesPurchaseResult { block_index: ledger_block_index, - balance_credit_units: balance, + balance_cycles: balance, }) } -fn activate_pending_database_after_credit_purchase_ledger_success( +fn activate_pending_database_after_cycles_purchase_ledger_success( database_id: &str, now: i64, ) -> Result<(), String> { let activation = with_service(|service| { - service.activate_pending_database_for_credit_purchase(database_id, now) + service.activate_pending_database_for_cycles_purchase(database_id, now) })?; if let Some(meta) = &activation { if let Err(error) = mount_database_file(meta) { @@ -745,21 +748,21 @@ fn activate_pending_database_after_credit_purchase_ledger_success( Ok(()) } -fn credit_purchase_local_apply_error(operation_id: u64, block_index: u64, cause: String) -> String { +fn cycles_purchase_local_apply_error(operation_id: u64, block_index: u64, cause: String) -> String { format!( - "credit purchase payment completed but local credit application failed; pending operation {operation_id} can be completed with verified ledger block {block_index}: {cause}" + "cycles purchase payment completed but local cycles application failed; pending operation {operation_id} can be completed with verified ledger block {block_index}: {cause}" ) } #[update] -fn repair_database_credit_purchase_cancel( +fn repair_database_cycles_purchase_cancel( database_id: String, operation_id: u64, ) -> Result<(), String> { require_authenticated_caller()?; let caller = caller_text(); with_service(|service| { - service.repair_database_credit_purchase_cancel( + service.repair_database_cycles_purchase_cancel( &database_id, operation_id, &caller, @@ -769,18 +772,24 @@ fn repair_database_credit_purchase_cancel( } #[query] -fn get_credits_config() -> Result { - with_service(|service| service.credits_config()) +fn get_cycles_billing_config() -> Result { + with_service(|service| service.cycles_billing_config()) } #[update] -fn update_credits_config(payload: Vec) -> Result<(), String> { +fn update_cycles_billing_config(payload: Vec) -> Result<(), String> { require_authenticated_caller()?; - let update = Decode!(&payload, CreditsConfigUpdate) - .map_err(|error| format!("invalid credits config payload: {error}"))?; - with_unmetered_update("update_credits_config", None, |service, caller, _now| { - service.update_credits_config(update, caller).map(|_| ()) - }) + let update = Decode!(&payload, CyclesBillingConfigUpdate) + .map_err(|error| format!("invalid cycles config payload: {error}"))?; + with_unmetered_update( + "update_cycles_billing_config", + None, + |service, caller, _now| { + service + .update_cycles_billing_config(update, caller) + .map(|_| ()) + }, + ) } #[update] @@ -987,8 +996,8 @@ fn check_url_ingest_trigger_session( } #[query] -fn check_database_write_credits(database_id: String) -> Result<(), String> { - with_service(|service| service.check_database_write_credits(&database_id, &caller_text())) +fn check_database_write_cycles(database_id: String) -> Result<(), String> { + with_service(|service| service.check_database_write_cycles(&database_id, &caller_text())) } #[update] @@ -1145,11 +1154,11 @@ fn fetch_updates(request: FetchUpdatesRequest) -> Result) { +fn initialize_or_trap(config: Option) { initialize_service_with_config(config).unwrap_or_else(|error| ic_cdk::trap(&error)); } -fn initialize_upgrade_or_trap(config: Option) { +fn initialize_upgrade_or_trap(config: Option) { initialize_service_for_upgrade(config).unwrap_or_else(|error| ic_cdk::trap(&error)); } @@ -1168,7 +1177,7 @@ fn schedule_storage_billing_timer() { } } -fn initialize_service_with_config(config: Option) -> Result<(), String> { +fn initialize_service_with_config(config: Option) -> Result<(), String> { initialize_sqlite_storage()?; #[cfg(not(target_arch = "wasm32"))] let service = VfsService::new(PathBuf::from(INDEX_DB_PATH), PathBuf::from(DATABASES_DIR)); @@ -1186,7 +1195,7 @@ fn initialize_service_with_config(config: Option) -> Result<(), S Ok(()) } -fn initialize_service_for_upgrade(config: Option) -> Result<(), String> { +fn initialize_service_for_upgrade(config: Option) -> Result<(), String> { initialize_sqlite_storage()?; #[cfg(not(target_arch = "wasm32"))] let service = VfsService::new(PathBuf::from(INDEX_DB_PATH), PathBuf::from(DATABASES_DIR)); @@ -1201,26 +1210,28 @@ fn initialize_service_for_upgrade(config: Option) -> Result<(), S } #[cfg(any(target_arch = "wasm32", test))] -fn parse_upgrade_credits_config_arg(bytes: &[u8]) -> Result, String> { +fn parse_upgrade_cycles_billing_config_arg( + bytes: &[u8], +) -> Result, String> { if bytes.is_empty() || bytes == b"DIDL\0\0" { return Ok(None); } - if let Ok((config,)) = decode_args::<(CreditsConfig,)>(bytes) { + if let Ok((config,)) = decode_args::<(CyclesBillingConfig,)>(bytes) { return Ok(Some(config)); } - if let Ok((config,)) = decode_args::<(Option,)>(bytes) { + if let Ok((config,)) = decode_args::<(Option,)>(bytes) { return Ok(config); } Err( - "post_upgrade credits config arg must be empty, CreditsConfig, or opt CreditsConfig" + "post_upgrade cycles config arg must be empty, CyclesBillingConfig, or opt CyclesBillingConfig" .to_string(), ) } -fn post_upgrade_credits_config_arg() -> Result, String> { +fn post_upgrade_cycles_billing_config_arg() -> Result, String> { #[cfg(target_arch = "wasm32")] { - parse_upgrade_credits_config_arg(&ic_cdk::api::msg_arg_data()) + parse_upgrade_cycles_billing_config_arg(&ic_cdk::api::msg_arg_data()) } #[cfg(not(target_arch = "wasm32"))] { @@ -1281,7 +1292,7 @@ thread_local! { static TEST_LAST_LEDGER_MEMO: RefCell>> = const { RefCell::new(None) }; static TEST_LAST_LEDGER_FROM: RefCell> = const { RefCell::new(None) }; static TEST_CALLER_PRINCIPAL: RefCell> = const { RefCell::new(None) }; - static TEST_CREDIT_DATABASE_PURCHASE_APPLY_FAIL_ONCE: RefCell = const { RefCell::new(false) }; + static TEST_DATABASE_CYCLES_PURCHASE_APPLY_FAIL_ONCE: RefCell = const { RefCell::new(false) }; } #[cfg(test)] @@ -1290,8 +1301,8 @@ fn fail_next_mount_database_file_for_test() { } #[cfg(test)] -fn fail_next_credit_database_purchase_apply_for_test() { - TEST_CREDIT_DATABASE_PURCHASE_APPLY_FAIL_ONCE.with(|flag| flag.replace(true)); +fn fail_next_apply_database_cycles_purchase_apply_for_test() { + TEST_DATABASE_CYCLES_PURCHASE_APPLY_FAIL_ONCE.with(|flag| flag.replace(true)); } #[cfg(test)] @@ -1388,17 +1399,8 @@ fn format_e8s(amount_e8s: u64) -> String { format!("{whole}.{fraction}") } -fn format_credit_units(units: u64) -> String { - let whole = units / 1000; - let fractional = units % 1000; - if fractional == 0 { - return whole.to_string(); - } - let mut fraction = format!("{fractional:03}"); - while fraction.ends_with('0') { - fraction.pop(); - } - format!("{whole}.{fraction}") +fn format_cycles(cycles: u64) -> String { + cycles.to_string() } fn caller_text() -> String { @@ -1502,17 +1504,17 @@ fn now_nanos() -> u64 { } } -fn expected_credit_purchase_transfer( - operation: &DatabaseCreditPendingOperation, +fn expected_cycles_purchase_transfer( + operation: &DatabaseCyclePendingOperation, ) -> Result { - if operation.kind != "credit_purchase" { - return Err("pending credit operation kind mismatch".to_string()); + if operation.kind != "cycles_purchase" { + return Err("pending cycle operation kind mismatch".to_string()); } - expected_ledger_transfer(operation, "credit_purchase") + expected_ledger_transfer(operation, "cycles_purchase") } fn expected_ledger_transfer( - operation: &DatabaseCreditPendingOperation, + operation: &DatabaseCyclePendingOperation, memo_kind: &str, ) -> Result { let from_owner = pending_principal(operation.from_owner.as_deref(), "from_owner")?; @@ -1541,7 +1543,7 @@ fn expected_ledger_transfer( }, amount_e8s, ledger_fee_e8s, - memo: credit_operation_memo(memo_kind, operation.operation_id), + memo: cycle_operation_memo(memo_kind, operation.operation_id), created_at_time_ns, }) } @@ -1639,7 +1641,7 @@ async fn ledger_transfer_from( operation_id: u64, created_at_time_ns: u64, ) -> LedgerTransferFromOutcome { - let memo = credit_operation_memo("credit_purchase", operation_id); + let memo = cycle_operation_memo("cycles_purchase", operation_id); #[cfg(test)] { record_test_ledger_from(&from); @@ -1713,7 +1715,7 @@ fn nat_to_u64(value: &Nat) -> Result { .map_err(|_| "nat exceeds u64".to_string()) } -fn credit_operation_memo(kind: &str, operation_id: u64) -> Vec { +fn cycle_operation_memo(kind: &str, operation_id: u64) -> Vec { format!("kinic:vfs:{kind}:{operation_id}").into_bytes() } @@ -1782,7 +1784,7 @@ fn with_authorized_metered_update( f: F, ) -> Result where - A: FnOnce(&VfsService, &str) -> Result, + A: FnOnce(&VfsService, &str) -> Result, F: FnOnce(&VfsService, &str, i64) -> Result, { let caller = caller_text(); @@ -1793,14 +1795,14 @@ where let service = borrowed .as_ref() .ok_or_else(|| "wiki service is not initialized".to_string())?; - let credits_config = authorize(service, &caller)?; + let cycles_billing_config = authorize(service, &caller)?; let result = f(service, &caller, now); let after_cycles = cycle_balance(); let cycles_delta = before_cycles.saturating_sub(after_cycles); if result.is_ok() && let Some(database_id) = database_id.as_deref() && let Err(error) = service.charge_database_update( - &credits_config, + &cycles_billing_config, database_id, &caller, method, @@ -1808,7 +1810,7 @@ where now, ) { - ic_cdk::trap(format!("credits charge failed after update: {error}")); + ic_cdk::trap(format!("cycles charge failed after update: {error}")); } result }) diff --git a/crates/vfs_canister/src/tests.rs b/crates/vfs_canister/src/tests.rs index 4a02d14e..23683504 100644 --- a/crates/vfs_canister/src/tests.rs +++ b/crates/vfs_canister/src/tests.rs @@ -9,8 +9,8 @@ use sha2::{Digest, Sha256}; use tempfile::tempdir; use vfs_runtime::VfsService; use vfs_types::{ - AppendNodeRequest, CreateDatabaseRequest, CreditsConfig, CreditsConfigUpdate, - DatabaseCreditPurchaseRequest, DatabaseRestoreChunkRequest, DatabaseRole, DatabaseStatus, + AppendNodeRequest, CreateDatabaseRequest, CyclesBillingConfig, CyclesBillingConfigUpdate, + DatabaseCyclesPurchaseRequest, DatabaseRestoreChunkRequest, DatabaseRole, DatabaseStatus, DeleteDatabaseRequest, DeleteNodeRequest, EditNodeRequest, ExportSnapshotRequest, FetchUpdatesRequest, GlobNodeType, GlobNodesRequest, GraphLinksRequest, GraphNeighborhoodRequest, IncomingLinksRequest, KINIC_LEDGER_FEE_E8S, ListChildrenRequest, @@ -25,18 +25,19 @@ use super::{ Icrc21ConsentMessageResponse, Icrc21ConsentMessageSpec, IcrcAccount, LedgerTransaction, LedgerTransfer, LedgerTransferFromOutcome, SERVICE, TransferFromError, append_node, begin_database_archive, begin_database_restore, cancel_database_archive, - check_database_write_credits, clear_last_ledger_memo_for_test, + check_database_write_cycles, clear_last_ledger_memo_for_test, clear_ledger_transactions_for_test, create_database, delete_node, edit_node, export_snapshot, - fail_next_credit_database_purchase_apply_for_test, fail_next_mount_database_file_for_test, - fetch_updates, finalize_database_archive, finalize_database_restore, glob_nodes, - grant_database_access, graph_links, graph_neighborhood, icrc21_canister_call_consent_message, - incoming_links, last_ledger_from_for_test, last_ledger_memo_for_test, list_children, - list_database_credit_entries, list_database_credit_pending_operations, list_database_members, - list_databases, list_nodes, memory_manifest, mkdir_node, move_node, multi_edit_node, - outgoing_links, parse_upgrade_credits_config_arg, preview_database_credit_purchase, - purchase_database_credits, query_context, query_index_sql_json, read_database_archive_chunk, - read_node, read_node_context, rename_database, repair_database_credit_purchase_cancel, - repair_database_credit_purchase_complete, revoke_database_access, search_node_paths, + fail_next_apply_database_cycles_purchase_apply_for_test, + fail_next_mount_database_file_for_test, fetch_updates, finalize_database_archive, + finalize_database_restore, glob_nodes, grant_database_access, graph_links, graph_neighborhood, + icrc21_canister_call_consent_message, incoming_links, last_ledger_from_for_test, + last_ledger_memo_for_test, list_children, list_database_cycle_entries, + list_database_cycle_pending_operations, list_database_members, list_databases, list_nodes, + memory_manifest, mkdir_node, move_node, multi_edit_node, outgoing_links, + parse_upgrade_cycles_billing_config_arg, preview_database_cycles_purchase, + purchase_database_cycles, query_context, query_index_sql_json, read_database_archive_chunk, + read_node, read_node_context, rename_database, repair_database_cycles_purchase_cancel, + repair_database_cycles_purchase_complete, revoke_database_access, search_node_paths, search_nodes, set_ledger_transaction_for_test, set_next_ledger_transfer_from_outcome_for_test, set_test_caller_principal_for_test, settle_database_storage_charges, source_evidence, status, transfer_from_error_outcome, write_database_restore_chunk, write_node, write_nodes, @@ -53,25 +54,28 @@ fn install_test_service() { service .create_database("default", "2vxsx-fae", 1_700_000_000_000) .expect("default database should create"); + let preview = service + .preview_database_cycles_purchase("default", 1_000_000) + .expect("default database cycle purchase preview should load"); service - .begin_database_credit_purchase("default", "2vxsx-fae", 1_000_000, 1_700_000_000_001) + .begin_database_cycles_purchase("default", "2vxsx-fae", 1_000_000, 1_700_000_000_001) .and_then(|operation_id| { - service.mark_database_credit_purchase_completed( + service.mark_database_cycles_purchase_completed( operation_id, "default", "2vxsx-fae", - 1_000_000, + preview.cycles, )?; - service.credit_database_purchase( + service.apply_database_cycles_purchase( operation_id, "default", "2vxsx-fae", - 1_000_000, + preview.cycles, 1, 1_700_000_000_001, ) }) - .expect("default database should have write credits available"); + .expect("default database should have write cycles available"); SERVICE.with(|slot| *slot.borrow_mut() = Some(service)); } @@ -116,13 +120,16 @@ fn block_on_ready(future: impl Future) -> T { } } -fn credit_purchase_request(database_id: &str, credit_units: u64) -> DatabaseCreditPurchaseRequest { - let preview = preview_database_credit_purchase(database_id.to_string(), credit_units) - .expect("credit purchase preview should load"); - DatabaseCreditPurchaseRequest { +fn cycles_purchase_request( + database_id: &str, + payment_amount_e8s: u64, +) -> DatabaseCyclesPurchaseRequest { + let preview = preview_database_cycles_purchase(database_id.to_string(), payment_amount_e8s) + .expect("cycle purchase preview should load"); + DatabaseCyclesPurchaseRequest { database_id: database_id.to_string(), - credit_units, - expected_payment_amount_e8s: preview.payment_amount_e8s, + payment_amount_e8s, + expected_cycles: preview.cycles, expected_config_version: preview.config_version, } } @@ -163,8 +170,8 @@ fn ledger_transfer_transaction( } } -fn pending_credit_purchase_transaction( - operation: &vfs_types::DatabaseCreditPendingOperation, +fn pending_cycles_purchase_transaction( + operation: &vfs_types::DatabaseCyclePendingOperation, ) -> LedgerTransaction { ledger_transfer_transaction( IcrcAccount { @@ -186,7 +193,7 @@ fn pending_credit_purchase_transaction( .expect("ledger fee") .try_into() .expect("fee should fit"), - format!("kinic:vfs:credit_purchase:{}", operation.operation_id).into_bytes(), + format!("kinic:vfs:cycles_purchase:{}", operation.operation_id).into_bytes(), operation .ledger_created_at_time_ns .expect("ledger created_at") @@ -195,21 +202,21 @@ fn pending_credit_purchase_transaction( ) } -fn explicit_credits_config() -> CreditsConfig { - CreditsConfig { +fn explicit_cycles_billing_config() -> CyclesBillingConfig { + CyclesBillingConfig { kinic_ledger_canister_id: "aaaaa-aa".to_string(), sns_governance_id: "rrkah-fqaaa-aaaaa-aaaaq-cai".to_string(), - credit_units_per_kinic: 1_000, - min_update_credit_units: 1, + cycles_per_kinic: 1_000, + min_update_cycles: 1, } } #[test] -fn credits_config_rejects_anonymous_principals() { +fn cycles_billing_config_rejects_anonymous_principals() { let dir = tempdir().expect("tempdir should create"); let root = dir.keep(); let service = VfsService::new(root.join("index.sqlite3"), root.join("databases")); - let mut config = explicit_credits_config(); + let mut config = explicit_cycles_billing_config(); config.sns_governance_id = Principal::anonymous().to_text(); let error = service @@ -225,7 +232,7 @@ fn controller_can_query_index_sql_json() { set_test_caller_principal_for_test(Principal::management_canister()); let result = query_index_sql_json( - "SELECT json_object('credit_purchase_credits', COALESCE(SUM(amount_credit_units), 0)) FROM database_credit_ledger WHERE kind = 'credit_purchase' LIMIT 1".to_string(), + "SELECT json_object('cycles_purchase_cycles', COALESCE(SUM(amount_cycles), 0)) FROM database_cycle_ledger WHERE kind = 'cycles_purchase' LIMIT 1".to_string(), 10, ) .expect("controller should query index SQL"); @@ -234,7 +241,7 @@ fn controller_can_query_index_sql_json() { assert_eq!(result.row_count, 1); assert_eq!( result.rows, - vec![r#"{"credit_purchase_credits":1000000}"#.to_string()] + vec![r#"{"cycles_purchase_cycles":10000000000}"#.to_string()] ); } @@ -280,12 +287,12 @@ fn index_sql_json_rejects_mutating_and_multi_statement_sql() { set_test_caller_principal_for_test(Principal::management_canister()); for sql in [ - "UPDATE database_credit_accounts SET balance_credit_units = 0", - "DELETE FROM database_credit_ledger", - "INSERT INTO database_credit_ledger (database_id) VALUES ('x')", + "UPDATE database_cycle_accounts SET balance_cycles = 0", + "DELETE FROM database_cycle_ledger", + "INSERT INTO database_cycle_ledger (database_id) VALUES ('x')", "CREATE TABLE x (id INTEGER)", - "DROP TABLE database_credit_ledger", - "PRAGMA table_info(database_credit_ledger)", + "DROP TABLE database_cycle_ledger", + "PRAGMA table_info(database_cycle_ledger)", "ATTACH DATABASE 'x' AS x", "SELECT json_object('ok', 1); SELECT json_object('ok', 2)", ] { @@ -308,29 +315,32 @@ fn index_sql_json_requires_text_json_first_column() { assert!(error.contains("one non-null TEXT JSON column")); } -fn fund_database(database_id: &str, amount_credit_units: u64, ledger_block_index: u64) { +fn fund_database(database_id: &str, payment_amount_e8s: u64, ledger_block_index: u64) { let principal = Principal::management_canister().to_text(); SERVICE.with(|slot| { let service = slot.borrow(); let service = service.as_ref().expect("service should be installed"); + let preview = service + .preview_database_cycles_purchase(database_id, payment_amount_e8s) + .expect("database cycle purchase preview should load"); let operation_id = service - .begin_database_credit_purchase( + .begin_database_cycles_purchase( database_id, &principal, - amount_credit_units, + payment_amount_e8s, 1_700_000_000_000, ) - .expect("database credit purchase should begin"); + .expect("database cycle purchase should begin"); service - .mark_database_credit_purchase_completed( + .mark_database_cycles_purchase_completed( operation_id, database_id, &principal, - amount_credit_units, + preview.cycles, ) - .expect("database credit purchase should be marked completed"); + .expect("database cycle purchase should be marked completed"); if service - .activate_pending_database_for_credit_purchase(database_id, 1_700_000_000_000) + .activate_pending_database_for_cycles_purchase(database_id, 1_700_000_000_000) .expect("pending database activation should prepare") .is_some() { @@ -339,11 +349,11 @@ fn fund_database(database_id: &str, amount_credit_units: u64, ledger_block_index .expect("pending database migrations should run"); } service - .credit_database_purchase( + .apply_database_cycles_purchase( operation_id, database_id, &principal, - amount_credit_units, + preview.cycles, ledger_block_index, 1_700_000_000_000, ) @@ -376,32 +386,32 @@ impl Drop for AuthenticatedCallerGuard { } #[test] -fn post_upgrade_credits_config_arg_accepts_no_arg() { +fn post_upgrade_cycles_billing_config_arg_accepts_no_arg() { let bytes = Encode!().expect("empty candid args should encode"); - let parsed = - parse_upgrade_credits_config_arg(&bytes).expect("empty post-upgrade arg should parse"); + let parsed = parse_upgrade_cycles_billing_config_arg(&bytes) + .expect("empty post-upgrade arg should parse"); assert_eq!(parsed, None); } #[test] -fn post_upgrade_credits_config_arg_accepts_bare_config() { - let config = explicit_credits_config(); - let bytes = Encode!(&config).expect("credits config should encode"); +fn post_upgrade_cycles_billing_config_arg_accepts_bare_config() { + let config = explicit_cycles_billing_config(); + let bytes = Encode!(&config).expect("cycles config should encode"); - let parsed = - parse_upgrade_credits_config_arg(&bytes).expect("bare post-upgrade config should parse"); + let parsed = parse_upgrade_cycles_billing_config_arg(&bytes) + .expect("bare post-upgrade config should parse"); assert_eq!(parsed, Some(config)); } #[test] -fn post_upgrade_credits_config_arg_accepts_optional_config() { - let config = explicit_credits_config(); - let bytes = Encode!(&Some(config.clone())).expect("optional credits config should encode"); +fn post_upgrade_cycles_billing_config_arg_accepts_optional_config() { + let config = explicit_cycles_billing_config(); + let bytes = Encode!(&Some(config.clone())).expect("optional cycles config should encode"); - let parsed = parse_upgrade_credits_config_arg(&bytes) + let parsed = parse_upgrade_cycles_billing_config_arg(&bytes) .expect("optional post-upgrade config should parse"); assert_eq!(parsed, Some(config)); @@ -420,7 +430,7 @@ fn transfer_from_duplicate_outcome_is_completed() { } #[test] -fn purchase_database_credits_credits_completed_transfer_from() { +fn purchase_database_cycles_cycles_completed_transfer_from() { install_empty_test_service(); let _caller = AuthenticatedCallerGuard::install(); let database = create_database(CreateDatabaseRequest { @@ -429,28 +439,28 @@ fn purchase_database_credits_credits_completed_transfer_from() { .expect("database should create"); set_next_ledger_transfer_from_outcome_for_test(LedgerTransferFromOutcome::Completed(42)); - let result = block_on_ready(purchase_database_credits(credit_purchase_request( + let result = block_on_ready(purchase_database_cycles(cycles_purchase_request( &database.database_id, 500, ))) - .expect("completed transfer-from should credit database"); + .expect("completed transfer-from should cycle database"); assert_eq!(result.block_index, 42); - assert_eq!(result.balance_credit_units, 500); + assert_eq!(result.balance_cycles, 5_000_000); assert_eq!( database_status_and_mount(&database.database_id).0, DatabaseStatus::Active ); - let entries = list_database_credit_entries(database.database_id.clone(), None, 10) + let entries = list_database_cycle_entries(database.database_id.clone(), None, 10) .expect("database ledger should load") .entries; assert_eq!(entries.len(), 1); - assert_eq!(entries[0].kind, "credit_purchase"); + assert_eq!(entries[0].kind, "cycles_purchase"); assert_eq!(entries[0].ledger_block_index, Some(42)); } #[test] -fn preview_database_credit_purchase_rejects_invalid_target_before_approve() { +fn preview_database_cycles_purchase_rejects_invalid_target_before_approve() { install_empty_test_service(); let _caller = AuthenticatedCallerGuard::install(); let database = create_database(CreateDatabaseRequest { @@ -458,25 +468,26 @@ fn preview_database_credit_purchase_rejects_invalid_target_before_approve() { }) .expect("database should create"); - let preview = preview_database_credit_purchase(database.database_id.clone(), 500) + let preview = preview_database_cycles_purchase(database.database_id.clone(), 50_000) .expect("preview should accept"); assert_eq!(preview.payment_amount_e8s, 50_000); + assert_eq!(preview.cycles, 500_000_000); assert_eq!(preview.ledger_fee_e8s, KINIC_LEDGER_FEE_E8S); - assert_eq!(preview.credit_units_per_kinic, 1_000_000); + assert_eq!(preview.cycles_per_kinic, 1_000_000_000_000); assert_eq!(preview.config_version, 1); - let zero = preview_database_credit_purchase(database.database_id.clone(), 0) + let zero = preview_database_cycles_purchase(database.database_id.clone(), 0) .expect_err("zero amount should reject"); - assert!(zero.contains("credit purchase credit units must be positive")); - let overflow = preview_database_credit_purchase(database.database_id.clone(), i64::MAX as u64) + assert!(zero.contains("cycles purchase payment amount must be positive")); + let overflow = preview_database_cycles_purchase(database.database_id.clone(), i64::MAX as u64) .expect_err("payment amount overflow should reject before approve"); - assert!(overflow.contains("credit purchase payment amount overflow")); - let missing = preview_database_credit_purchase("missing".to_string(), 500) + assert!(overflow.contains("cycles purchase amount exceeds u64")); + let missing = preview_database_cycles_purchase("missing".to_string(), 500) .expect_err("missing database should reject"); assert!(missing.contains("database not found")); } #[test] -fn purchase_database_credits_rejects_balance_overflow_before_ledger_call() { +fn purchase_database_cycles_rejects_balance_overflow_before_ledger_call() { install_empty_test_service(); let _caller = AuthenticatedCallerGuard::install(); let database = create_database(CreateDatabaseRequest { @@ -487,23 +498,23 @@ fn purchase_database_credits_rejects_balance_overflow_before_ledger_call() { slot.borrow() .as_ref() .expect("service should be installed") - .update_credits_config( - CreditsConfigUpdate { - credit_units_per_kinic: 100_000_000, - min_update_credit_units: 1, + .update_cycles_billing_config( + CyclesBillingConfigUpdate { + cycles_per_kinic: 100_000_000, + min_update_cycles: 1, }, &test_governance_principal().to_text(), ) - .expect("credits config should update"); + .expect("cycles config should update"); }); fund_database(&database.database_id, i64::MAX as u64, 41); clear_last_ledger_memo_for_test(); set_next_ledger_transfer_from_outcome_for_test(LedgerTransferFromOutcome::Completed(42)); - let error = block_on_ready(purchase_database_credits(DatabaseCreditPurchaseRequest { + let error = block_on_ready(purchase_database_cycles(DatabaseCyclesPurchaseRequest { database_id: database.database_id, - credit_units: 1, - expected_payment_amount_e8s: 1, + payment_amount_e8s: 1, + expected_cycles: 1, expected_config_version: 1, })) .expect_err("overflow should reject before ledger"); @@ -513,39 +524,39 @@ fn purchase_database_credits_rejects_balance_overflow_before_ledger_call() { } #[test] -fn purchase_database_credits_rejects_stale_preview_before_ledger_call() { +fn purchase_database_cycles_rejects_stale_preview_before_ledger_call() { install_empty_test_service(); let _caller = AuthenticatedCallerGuard::install(); let database = create_database(CreateDatabaseRequest { name: "Stale preview".to_string(), }) .expect("database should create"); - let request = credit_purchase_request(&database.database_id, 500); + let request = cycles_purchase_request(&database.database_id, 500); SERVICE.with(|slot| { slot.borrow() .as_ref() .expect("service should be installed") - .update_credits_config( - CreditsConfigUpdate { - credit_units_per_kinic: 2_000, - min_update_credit_units: 1, + .update_cycles_billing_config( + CyclesBillingConfigUpdate { + cycles_per_kinic: 1_000_000_000_000, + min_update_cycles: 2, }, &test_governance_principal().to_text(), ) - .expect("credits config should update"); + .expect("cycles config should update"); }); clear_last_ledger_memo_for_test(); set_next_ledger_transfer_from_outcome_for_test(LedgerTransferFromOutcome::Completed(42)); - let error = block_on_ready(purchase_database_credits(request)) + let error = block_on_ready(purchase_database_cycles(request)) .expect_err("stale preview should reject before ledger"); - assert!(error.contains("credits config changed")); + assert!(error.contains("cycles billing config changed")); assert_eq!(last_ledger_memo_for_test(), None); } #[test] -fn purchase_database_credits_leaves_balance_on_ledger_reject() { +fn purchase_database_cycles_leaves_balance_on_ledger_reject() { install_empty_test_service(); let _caller = AuthenticatedCallerGuard::install(); let database = create_database(CreateDatabaseRequest { @@ -556,25 +567,25 @@ fn purchase_database_credits_leaves_balance_on_ledger_reject() { "icrc2_transfer_from failed: InsufficientAllowance".to_string(), )); - let error = block_on_ready(purchase_database_credits(credit_purchase_request( + let error = block_on_ready(purchase_database_cycles(cycles_purchase_request( &database.database_id, 500, ))) - .expect_err("ledger reject should not credit database"); + .expect_err("ledger reject should not cycle database"); assert!(error.contains("InsufficientAllowance")); assert_eq!( database_status_and_mount(&database.database_id), (DatabaseStatus::Pending, None) ); - let entries = list_database_credit_entries(database.database_id.clone(), None, 10) + let entries = list_database_cycle_entries(database.database_id.clone(), None, 10) .expect("database ledger should load") .entries; assert!(entries.is_empty()); } #[test] -fn purchase_database_credits_records_ambiguous_transfer_from() { +fn purchase_database_cycles_records_ambiguous_transfer_from() { install_empty_test_service(); let _caller = AuthenticatedCallerGuard::install(); let database = create_database(CreateDatabaseRequest { @@ -585,21 +596,21 @@ fn purchase_database_credits_records_ambiguous_transfer_from() { "icrc2_transfer_from decode failed".to_string(), )); - let error = block_on_ready(purchase_database_credits(credit_purchase_request( + let error = block_on_ready(purchase_database_cycles(cycles_purchase_request( &database.database_id, 500, ))) .expect_err("ambiguous transfer-from should return pending error"); - assert!(error.contains("credit purchase pending operation")); + assert!(error.contains("cycles purchase pending operation")); assert!(error.contains("manual repair required")); - let entries = list_database_credit_entries(database.database_id.clone(), None, 10) + let entries = list_database_cycle_entries(database.database_id.clone(), None, 10) .expect("database ledger should load") .entries; assert_eq!(entries.len(), 1); - assert_eq!(entries[0].kind, "credit_purchase_ambiguous"); - assert_eq!(entries[0].amount_credit_units, 500); - assert_eq!(entries[0].balance_after_credit_units, 0); + assert_eq!(entries[0].kind, "cycles_purchase_ambiguous"); + assert_eq!(entries[0].amount_cycles, 5_000_000); + assert_eq!(entries[0].balance_after_cycles, 0); assert_eq!(entries[0].ledger_block_index, None); assert_eq!( database_status_and_mount(&database.database_id), @@ -608,7 +619,7 @@ fn purchase_database_credits_records_ambiguous_transfer_from() { } #[test] -fn purchase_database_credits_mount_failure_keeps_pending_operation_for_repair() { +fn purchase_database_cycles_mount_failure_keeps_pending_operation_for_repair() { install_empty_test_service(); let _owner = AuthenticatedCallerGuard::install(); let database = create_database(CreateDatabaseRequest { @@ -618,42 +629,42 @@ fn purchase_database_credits_mount_failure_keeps_pending_operation_for_repair() set_next_ledger_transfer_from_outcome_for_test(LedgerTransferFromOutcome::Completed(42)); fail_next_mount_database_file_for_test(); - let error = block_on_ready(purchase_database_credits(credit_purchase_request( + let error = block_on_ready(purchase_database_cycles(cycles_purchase_request( &database.database_id, 500, ))) .expect_err("mount failure after ledger success should keep repair path"); assert!(error.contains("test mount failure")); - assert!(error.contains("credit purchase payment completed")); + assert!(error.contains("cycles purchase payment completed")); assert!(error.contains("verified ledger block")); assert!(error.contains("block 42")); assert_eq!( database_status_and_mount(&database.database_id), (DatabaseStatus::Pending, Some(11)) ); - let pending = list_database_credit_pending_operations(database.database_id.clone(), None, 10) + let pending = list_database_cycle_pending_operations(database.database_id.clone(), None, 10) .expect("pending should load") .entries; assert_eq!(pending.len(), 1); assert!(error.contains(&format!("pending operation {}", pending[0].operation_id))); assert_eq!(pending[0].ledger_fee_e8s, Some(KINIC_LEDGER_FEE_E8S as i64)); assert!( - list_database_credit_entries(database.database_id.clone(), None, 10) + list_database_cycle_entries(database.database_id.clone(), None, 10) .expect("ledger should load") .entries .is_empty() ); - set_ledger_transaction_for_test(42, pending_credit_purchase_transaction(&pending[0])); - let result = block_on_ready(repair_database_credit_purchase_complete( + set_ledger_transaction_for_test(42, pending_cycles_purchase_transaction(&pending[0])); + let result = block_on_ready(repair_database_cycles_purchase_complete( database.database_id.clone(), pending[0].operation_id, 42, )) - .expect("verified complete should retry mount and credit"); + .expect("verified complete should retry mount and cycle"); - assert_eq!(result.balance_credit_units, 500); + assert_eq!(result.balance_cycles, 5_000_000); assert_eq!( database_status_and_mount(&database.database_id).0, DatabaseStatus::Active @@ -661,7 +672,7 @@ fn purchase_database_credits_mount_failure_keeps_pending_operation_for_repair() } #[test] -fn repair_complete_succeeds_after_activation_started_and_credit_apply_failed() { +fn repair_complete_succeeds_after_activation_started_and_cycle_apply_failed() { install_empty_test_service(); let _owner = AuthenticatedCallerGuard::install(); let database = create_database(CreateDatabaseRequest { @@ -669,45 +680,45 @@ fn repair_complete_succeeds_after_activation_started_and_credit_apply_failed() { }) .expect("database should create"); set_next_ledger_transfer_from_outcome_for_test(LedgerTransferFromOutcome::Completed(44)); - fail_next_credit_database_purchase_apply_for_test(); + fail_next_apply_database_cycles_purchase_apply_for_test(); - let error = block_on_ready(purchase_database_credits(credit_purchase_request( + let error = block_on_ready(purchase_database_cycles(cycles_purchase_request( &database.database_id, 600, ))) - .expect_err("credit apply failure after activation should keep repair path"); + .expect_err("cycle apply failure after activation should keep repair path"); - assert!(error.contains("test credit purchase apply failure")); + assert!(error.contains("test cycle purchase apply failure")); assert_eq!( database_status_and_mount(&database.database_id), (DatabaseStatus::Pending, Some(11)) ); - let pending = list_database_credit_pending_operations(database.database_id.clone(), None, 10) + let pending = list_database_cycle_pending_operations(database.database_id.clone(), None, 10) .expect("pending should load") .entries; assert_eq!(pending.len(), 1); assert!( - list_database_credit_entries(database.database_id.clone(), None, 10) + list_database_cycle_entries(database.database_id.clone(), None, 10) .expect("ledger should load") .entries .is_empty() ); - let cancel_error = repair_database_credit_purchase_cancel( + let cancel_error = repair_database_cycles_purchase_cancel( database.database_id.clone(), pending[0].operation_id, ) .expect_err("completed payment should not be cancellable"); - assert!(cancel_error.contains("credit purchase operation is completed")); + assert!(cancel_error.contains("cycle purchase operation is completed")); - set_ledger_transaction_for_test(44, pending_credit_purchase_transaction(&pending[0])); - let result = block_on_ready(repair_database_credit_purchase_complete( + set_ledger_transaction_for_test(44, pending_cycles_purchase_transaction(&pending[0])); + let result = block_on_ready(repair_database_cycles_purchase_complete( database.database_id.clone(), pending[0].operation_id, 44, )) - .expect("repair complete should finish activation and credit"); + .expect("repair complete should finish activation and cycle"); - assert_eq!(result.balance_credit_units, 600); + assert_eq!(result.balance_cycles, 6_000_000); assert_eq!( database_status_and_mount(&database.database_id).0, DatabaseStatus::Active @@ -715,7 +726,7 @@ fn repair_complete_succeeds_after_activation_started_and_credit_apply_failed() { } #[test] -fn repair_cancel_rejects_in_flight_credit_purchase() { +fn repair_cancel_rejects_in_flight_cycles_purchase() { install_empty_test_service(); let _owner = AuthenticatedCallerGuard::install(); let database = create_database(CreateDatabaseRequest { @@ -727,18 +738,18 @@ fn repair_cancel_rejects_in_flight_credit_purchase() { slot.borrow() .as_ref() .expect("service should be installed") - .begin_database_credit_purchase(&database.database_id, &caller, 500, 1_700_000_000_000) - .expect("credit purchase should begin") + .begin_database_cycles_purchase(&database.database_id, &caller, 500, 1_700_000_000_000) + .expect("cycle purchase should begin") }); - let error = repair_database_credit_purchase_cancel(database.database_id, operation_id) + let error = repair_database_cycles_purchase_cancel(database.database_id, operation_id) .expect_err("in-flight purchase cancel should reject"); - assert!(error.contains("credit purchase operation is in_flight")); + assert!(error.contains("cycle purchase operation is in_flight")); } #[test] -fn repair_complete_rejects_in_flight_credit_purchase() { +fn repair_complete_rejects_in_flight_cycles_purchase() { install_empty_test_service(); let _owner = AuthenticatedCallerGuard::install(); let database = create_database(CreateDatabaseRequest { @@ -750,22 +761,22 @@ fn repair_complete_rejects_in_flight_credit_purchase() { slot.borrow() .as_ref() .expect("service should be installed") - .begin_database_credit_purchase(&database.database_id, &caller, 500, 1_700_000_000_000) - .expect("credit purchase should begin") + .begin_database_cycles_purchase(&database.database_id, &caller, 500, 1_700_000_000_000) + .expect("cycle purchase should begin") }); - let error = block_on_ready(repair_database_credit_purchase_complete( + let error = block_on_ready(repair_database_cycles_purchase_complete( database.database_id, operation_id, 42, )) .expect_err("in-flight purchase complete should reject"); - assert!(error.contains("credit purchase operation is in_flight")); + assert!(error.contains("cycle purchase operation is in_flight")); } #[test] -fn authenticated_caller_can_complete_verified_ambiguous_credit_purchase() { +fn authenticated_caller_can_complete_verified_ambiguous_cycles_purchase() { install_empty_test_service(); let database_id; let operation_id; @@ -773,7 +784,7 @@ fn authenticated_caller_can_complete_verified_ambiguous_credit_purchase() { { let _owner = AuthenticatedCallerGuard::install(); let database = create_database(CreateDatabaseRequest { - name: "Repair credit purchase".to_string(), + name: "Repair cycle purchase".to_string(), }) .expect("database should create"); database_id = database.database_id; @@ -781,33 +792,33 @@ fn authenticated_caller_can_complete_verified_ambiguous_credit_purchase() { set_next_ledger_transfer_from_outcome_for_test(LedgerTransferFromOutcome::Ambiguous( "icrc2_transfer_from decode failed".to_string(), )); - let error = block_on_ready(purchase_database_credits(credit_purchase_request( + let error = block_on_ready(purchase_database_cycles(cycles_purchase_request( &database_id, 500, ))) .expect_err("ambiguous transfer-from should return pending error"); - assert!(error.contains("credit purchase pending")); + assert!(error.contains("cycles purchase pending")); - let pending = list_database_credit_pending_operations(database_id.clone(), None, 10) + let pending = list_database_cycle_pending_operations(database_id.clone(), None, 10) .expect("owner should list pending operations") .entries; assert_eq!(pending.len(), 1); operation_id = pending[0].operation_id; - set_ledger_transaction_for_test(77, pending_credit_purchase_transaction(&pending[0])); + set_ledger_transaction_for_test(77, pending_cycles_purchase_transaction(&pending[0])); } let repair_caller = Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai") .expect("non-governance principal should parse"); { let _authenticated = AuthenticatedCallerGuard::install_principal(repair_caller); - let result = block_on_ready(repair_database_credit_purchase_complete( + let result = block_on_ready(repair_database_cycles_purchase_complete( database_id.clone(), operation_id, 77, )) - .expect("authenticated caller should complete verified credit purchase"); + .expect("authenticated caller should complete verified cycle purchase"); assert_eq!(result.block_index, 77); - assert_eq!(result.balance_credit_units, 500); + assert_eq!(result.balance_cycles, 5_000_000); assert_eq!( database_status_and_mount(&database_id).0, DatabaseStatus::Active @@ -815,34 +826,34 @@ fn authenticated_caller_can_complete_verified_ambiguous_credit_purchase() { } let _owner = AuthenticatedCallerGuard::install(); - let pending = list_database_credit_pending_operations(database_id.clone(), None, 10) + let pending = list_database_cycle_pending_operations(database_id.clone(), None, 10) .expect("owner should list pending operations") .entries; assert!(pending.is_empty()); - let entries = list_database_credit_entries(database_id.clone(), None, 10) + let entries = list_database_cycle_entries(database_id.clone(), None, 10) .expect("database ledger should load") .entries; - assert_eq!(entries[0].kind, "credit_purchase_ambiguous"); - assert_eq!(entries[1].kind, "credit_purchase_repair_complete"); + assert_eq!(entries[0].kind, "cycles_purchase_ambiguous"); + assert_eq!(entries[1].kind, "cycles_purchase_repair_complete"); assert_eq!(entries[1].caller, payer); assert_ne!(entries[1].caller, repair_caller.to_text()); assert_eq!(entries[1].ledger_block_index, Some(77)); - let error = block_on_ready(repair_database_credit_purchase_complete( + let error = block_on_ready(repair_database_cycles_purchase_complete( database_id.clone(), operation_id, 77, )) .expect_err("second complete should reject missing pending operation"); - assert!(error.contains("pending credit operation not found")); - let entries = list_database_credit_entries(database_id, None, 10) + assert!(error.contains("pending cycle operation not found")); + let entries = list_database_cycle_entries(database_id, None, 10) .expect("database ledger should load") .entries; assert_eq!(entries.len(), 2); } #[test] -fn repair_credit_purchase_complete_rejects_mismatched_ledger_block() { +fn repair_cycles_purchase_complete_rejects_mismatched_ledger_block() { install_empty_test_service(); let operation_id; let database_id; @@ -856,59 +867,59 @@ fn repair_credit_purchase_complete_rejects_mismatched_ledger_block() { set_next_ledger_transfer_from_outcome_for_test(LedgerTransferFromOutcome::Ambiguous( "icrc2_transfer_from decode failed".to_string(), )); - let _ = block_on_ready(purchase_database_credits(credit_purchase_request( + let _ = block_on_ready(purchase_database_cycles(cycles_purchase_request( &database_id, 500, ))) - .expect_err("ambiguous credit purchase should stay pending"); - operation_id = list_database_credit_pending_operations(database_id.clone(), None, 10) + .expect_err("ambiguous cycle purchase should stay pending"); + operation_id = list_database_cycle_pending_operations(database_id.clone(), None, 10) .expect("pending should load") .entries[0] .operation_id; } let _owner = AuthenticatedCallerGuard::install(); - let pending = list_database_credit_pending_operations(database_id.clone(), None, 10) + let pending = list_database_cycle_pending_operations(database_id.clone(), None, 10) .expect("pending should load") .entries; - let mut transaction = pending_credit_purchase_transaction(&pending[0]); + let mut transaction = pending_cycles_purchase_transaction(&pending[0]); transaction.transfer.as_mut().expect("transfer").amount = Nat::from(499_u64); set_ledger_transaction_for_test(78, transaction); - let error = block_on_ready(repair_database_credit_purchase_complete( + let error = block_on_ready(repair_database_cycles_purchase_complete( database_id.clone(), operation_id, 78, )) .expect_err("mismatched block should reject"); assert!(error.contains("amount mismatch")); - let pending = list_database_credit_pending_operations(database_id, None, 10) + let pending = list_database_cycle_pending_operations(database_id, None, 10) .expect("pending should remain") .entries; assert_eq!(pending.len(), 1); } #[test] -fn repair_credit_purchase_cancel_removes_ambiguous_operation() { +fn repair_cycles_purchase_cancel_removes_ambiguous_operation() { install_empty_test_service(); let operation_id; let database_id; { let _owner = AuthenticatedCallerGuard::install(); let database = create_database(CreateDatabaseRequest { - name: "Cancel credit purchase".to_string(), + name: "Cancel cycle purchase".to_string(), }) .expect("database should create"); database_id = database.database_id; set_next_ledger_transfer_from_outcome_for_test(LedgerTransferFromOutcome::Ambiguous( "icrc2_transfer_from call failed".to_string(), )); - let _ = block_on_ready(purchase_database_credits(credit_purchase_request( + let _ = block_on_ready(purchase_database_cycles(cycles_purchase_request( &database_id, 500, ))) - .expect_err("ambiguous credit purchase should stay pending"); - operation_id = list_database_credit_pending_operations(database_id.clone(), None, 10) + .expect_err("ambiguous cycle purchase should stay pending"); + operation_id = list_database_cycle_pending_operations(database_id.clone(), None, 10) .expect("pending should load") .entries[0] .operation_id; @@ -916,23 +927,23 @@ fn repair_credit_purchase_cancel_removes_ambiguous_operation() { let third_party = Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai") .expect("third party principal should parse"); let _third_party = AuthenticatedCallerGuard::install_principal(third_party); - let error = repair_database_credit_purchase_cancel(database_id.clone(), operation_id) + let error = repair_database_cycles_purchase_cancel(database_id.clone(), operation_id) .expect_err("third party cancel should reject"); - assert!(error.contains("not credit purchase payer or database owner")); + assert!(error.contains("not cycle purchase payer or database owner")); } let _owner = AuthenticatedCallerGuard::install(); - repair_database_credit_purchase_cancel(database_id.clone(), operation_id) - .expect("owner should cancel ambiguous credit purchase after verification"); + repair_database_cycles_purchase_cancel(database_id.clone(), operation_id) + .expect("owner should cancel ambiguous cycle purchase after verification"); assert_eq!( database_status_and_mount(&database_id), (DatabaseStatus::Pending, None) ); - let pending = list_database_credit_pending_operations(database_id.clone(), None, 10) + let pending = list_database_cycle_pending_operations(database_id.clone(), None, 10) .expect("pending should load") .entries; assert!(pending.is_empty()); - let entries = list_database_credit_entries(database_id, None, 10) + let entries = list_database_cycle_entries(database_id, None, 10) .expect("ledger should load") .entries; assert_eq!( @@ -941,14 +952,14 @@ fn repair_credit_purchase_cancel_removes_ambiguous_operation() { .map(|entry| entry.kind.as_str()) .collect::>(), vec![ - "credit_purchase_ambiguous", - "credit_purchase_repair_cancelled" + "cycles_purchase_ambiguous", + "cycles_purchase_repair_cancelled" ] ); } #[test] -fn purchase_database_credits_allows_non_owner_payer() { +fn purchase_database_cycles_allows_non_owner_payer() { install_empty_test_service(); let database_id = { let _owner = AuthenticatedCallerGuard::install(); @@ -964,14 +975,14 @@ fn purchase_database_credits_allows_non_owner_payer() { clear_last_ledger_memo_for_test(); set_next_ledger_transfer_from_outcome_for_test(LedgerTransferFromOutcome::Completed(43)); - let result = block_on_ready(purchase_database_credits(credit_purchase_request( + let result = block_on_ready(purchase_database_cycles(cycles_purchase_request( &database_id, 700, ))) .expect("non-owner payer should fund DB"); assert_eq!(result.block_index, 43); - assert_eq!(result.balance_credit_units, 700); + assert_eq!(result.balance_cycles, 7_000_000); assert_eq!( last_ledger_from_for_test().expect("ledger from should be recorded"), IcrcAccount { @@ -982,18 +993,18 @@ fn purchase_database_credits_allows_non_owner_payer() { } #[test] -fn icrc21_purchase_database_credits_returns_consent_message() { +fn icrc21_purchase_database_cycles_returns_consent_message() { install_empty_test_service(); let _caller = AuthenticatedCallerGuard::install(); let database = create_database(CreateDatabaseRequest { name: "Consent".to_string(), }) .expect("database should create"); - let request = credit_purchase_request(&database.database_id, 500); + let request = cycles_purchase_request(&database.database_id, 50_000); let arg = Encode!(&request).expect("arg should encode"); let response = - icrc21_canister_call_consent_message(consent_request("purchase_database_credits", arg)); + icrc21_canister_call_consent_message(consent_request("purchase_database_cycles", arg)); let message = match response { Icrc21ConsentMessageResponse::Ok(info) => match info.consent_message { @@ -1004,37 +1015,37 @@ fn icrc21_purchase_database_credits_returns_consent_message() { } }; assert!(message.contains(&database.database_id)); - assert!(message.contains("Credits: `0.5`")); + assert!(message.contains("Cycles: `500000000`")); assert!(message.contains("Payment: `0.0005` KINIC")); assert!(message.contains("Ledger transfer fee in allowance: `0.0001` KINIC")); assert!(message.contains("Spender canister:")); } #[test] -fn icrc21_purchase_database_credits_rejects_stale_expected_amount() { +fn icrc21_purchase_database_cycles_rejects_stale_expected_amount() { install_empty_test_service(); let _caller = AuthenticatedCallerGuard::install(); let database = create_database(CreateDatabaseRequest { name: "Consent mismatch".to_string(), }) .expect("database should create"); - let mut request = credit_purchase_request(&database.database_id, 500); - request.expected_payment_amount_e8s += 1; + let mut request = cycles_purchase_request(&database.database_id, 500); + request.expected_cycles += 1; let arg = Encode!(&request).expect("arg should encode"); let response = - icrc21_canister_call_consent_message(consent_request("purchase_database_credits", arg)); + icrc21_canister_call_consent_message(consent_request("purchase_database_cycles", arg)); match response { Icrc21ConsentMessageResponse::Err(super::Icrc21Error::UnsupportedCanisterCall(info)) => { - assert!(info.description.contains("payment amount changed")); + assert!(info.description.contains("cycles purchase amount changed")); } other => panic!("stale consent should reject: {other:?}"), } } #[test] -fn icrc21_rejects_unsupported_credit_consent_method() { +fn icrc21_rejects_unsupported_cycle_consent_method() { install_empty_test_service(); let response = icrc21_canister_call_consent_message(consent_request("write_node", Vec::new())); @@ -1045,10 +1056,10 @@ fn icrc21_rejects_unsupported_credit_consent_method() { } #[test] -fn icrc21_rejects_malformed_credit_consent_arg() { +fn icrc21_rejects_malformed_cycle_consent_arg() { install_empty_test_service(); let response = icrc21_canister_call_consent_message(consent_request( - "purchase_database_credits", + "purchase_database_cycles", Vec::new(), )); @@ -1059,7 +1070,7 @@ fn icrc21_rejects_malformed_credit_consent_arg() { } #[test] -fn purchase_database_credits_sends_operation_memo_to_ledger() { +fn purchase_database_cycles_sends_operation_memo_to_ledger() { install_empty_test_service(); let _caller = AuthenticatedCallerGuard::install(); let database = create_database(CreateDatabaseRequest { @@ -1069,26 +1080,26 @@ fn purchase_database_credits_sends_operation_memo_to_ledger() { clear_last_ledger_memo_for_test(); set_next_ledger_transfer_from_outcome_for_test(LedgerTransferFromOutcome::Completed(43)); - block_on_ready(purchase_database_credits(credit_purchase_request( + block_on_ready(purchase_database_cycles(cycles_purchase_request( &database.database_id, 700, ))) - .expect("credit purchase should succeed"); + .expect("cycle purchase should succeed"); let memo = String::from_utf8(last_ledger_memo_for_test().expect("memo should be recorded")) .expect("memo should be utf8"); - assert!(memo.starts_with("kinic:vfs:credit_purchase:")); + assert!(memo.starts_with("kinic:vfs:cycles_purchase:")); } #[test] -fn purchase_database_credits_rejects_unknown_and_deleted_database() { +fn purchase_database_cycles_rejects_unknown_and_deleted_database() { install_empty_test_service(); let _caller = AuthenticatedCallerGuard::install(); - let missing = block_on_ready(purchase_database_credits(DatabaseCreditPurchaseRequest { + let missing = block_on_ready(purchase_database_cycles(DatabaseCyclesPurchaseRequest { database_id: "missing".to_string(), - credit_units: 500, - expected_payment_amount_e8s: 50_000_000, + payment_amount_e8s: 50_000_000, + expected_cycles: 500_000_000_000, expected_config_version: 1, })) .expect_err("unknown database should reject"); @@ -1102,10 +1113,10 @@ fn purchase_database_credits_rejects_unknown_and_deleted_database() { super::delete_database(delete_database_request(&database.database_id)) .expect("owner should delete"); - let deleted = block_on_ready(purchase_database_credits(DatabaseCreditPurchaseRequest { + let deleted = block_on_ready(purchase_database_cycles(DatabaseCyclesPurchaseRequest { database_id: database.database_id, - credit_units: 500, - expected_payment_amount_e8s: 50_000_000, + payment_amount_e8s: 50_000_000, + expected_cycles: 500_000_000_000, expected_config_version: 1, })) .expect_err("deleted database should reject"); @@ -1113,8 +1124,8 @@ fn purchase_database_credits_rejects_unknown_and_deleted_database() { } fn database_charge_methods(database_id: &str) -> Vec { - list_database_credit_entries(database_id.to_string(), None, 20) - .expect("database credits ledger should load") + list_database_cycle_entries(database_id.to_string(), None, 20) + .expect("database cycles ledger should load") .entries .into_iter() .filter(|entry| entry.kind == "charge") @@ -1145,28 +1156,31 @@ fn install_suspended_default_service() { service .create_database("default", "2vxsx-fae", 1_700_000_000_000) .expect("default database should create"); + let preview = service + .preview_database_cycles_purchase("default", 1) + .expect("default database cycle purchase preview should load"); service - .begin_database_credit_purchase("default", "2vxsx-fae", 1, 1_700_000_000_001) + .begin_database_cycles_purchase("default", "2vxsx-fae", 1, 1_700_000_000_001) .and_then(|operation_id| { - service.mark_database_credit_purchase_completed( + service.mark_database_cycles_purchase_completed( operation_id, "default", "2vxsx-fae", - 1, + preview.cycles, )?; - service.credit_database_purchase( + service.apply_database_cycles_purchase( operation_id, "default", "2vxsx-fae", - 1, + preview.cycles, 1, 1_700_000_000_001, ) }) .expect("default database should become suspended"); let config = service - .credits_config() - .expect("credits config should load"); + .cycles_billing_config() + .expect("cycles config should load"); service .charge_database_update( &config, @@ -1190,20 +1204,23 @@ fn install_low_balance_default_service() { service .create_database("default", "2vxsx-fae", 1_700_000_000_000) .expect("default database should create"); + let preview = service + .preview_database_cycles_purchase("default", 1_000_000) + .expect("default database cycle purchase preview should load"); service - .begin_database_credit_purchase("default", "2vxsx-fae", 1_000_000, 1_700_000_000_001) + .begin_database_cycles_purchase("default", "2vxsx-fae", 1_000_000, 1_700_000_000_001) .and_then(|operation_id| { - service.mark_database_credit_purchase_completed( + service.mark_database_cycles_purchase_completed( operation_id, "default", "2vxsx-fae", - 1_000_000, + preview.cycles, )?; - service.credit_database_purchase( + service.apply_database_cycles_purchase( operation_id, "default", "2vxsx-fae", - 1_000_000, + preview.cycles, 1, 1_700_000_000_001, ) @@ -1219,10 +1236,10 @@ fn install_low_balance_default_service() { ) .expect("writer should be granted before low-balance config"); service - .update_credits_config( - CreditsConfigUpdate { - credit_units_per_kinic: 1_000, - min_update_credit_units: 2_000_000, + .update_cycles_billing_config( + CyclesBillingConfigUpdate { + cycles_per_kinic: 1_000, + min_update_cycles: 2_000_000, }, &test_governance_principal().to_text(), ) @@ -1371,7 +1388,7 @@ fn write_node_and_write_nodes_skip_zero_charge_ledger() { } #[test] -fn write_nodes_rejects_low_database_credits_balance() { +fn write_nodes_rejects_low_database_cycles_balance() { install_unfunded_default_service(); let error = write_nodes(WriteNodesRequest { @@ -1386,7 +1403,7 @@ fn write_nodes_rejects_low_database_credits_balance() { }) .expect_err("low balance database should reject batch write"); - assert!(error.contains("database credits are suspended")); + assert!(error.contains("database cycles are suspended")); } #[test] @@ -1412,9 +1429,9 @@ fn suspended_database_rejects_metered_mutations() { let cancel = super::cancel_database_restore("default".to_string()) .expect_err("suspended database should reject restore cancel before runtime mutation"); - assert!(batch.contains("database credits are suspended")); - assert!(mkdir.contains("database credits are suspended")); - assert!(cancel.contains("database credits are suspended")); + assert!(batch.contains("database cycles are suspended")); + assert!(mkdir.contains("database cycles are suspended")); + assert!(cancel.contains("database cycles are suspended")); } #[test] @@ -1441,7 +1458,7 @@ fn low_balance_database_allows_owner_revoke_and_delete() { } #[test] -fn metered_update_checks_access_before_credits_state() { +fn metered_update_checks_access_before_cycles_state() { install_suspended_default_service(); let _caller = AuthenticatedCallerGuard::install(); @@ -1455,14 +1472,14 @@ fn metered_update_checks_access_before_credits_state() { expected_etag: None, }], }) - .expect_err("non-member should fail before credits state"); + .expect_err("non-member should fail before cycles state"); assert!(error.contains("principal has no access")); - assert!(!error.contains("database credits are suspended")); + assert!(!error.contains("database cycles are suspended")); } #[test] -fn check_database_write_credits_requires_authenticated_writer() { +fn check_database_write_cycles_requires_authenticated_writer() { install_empty_test_service(); let owner = Principal::management_canister(); let reader = @@ -1470,22 +1487,22 @@ fn check_database_write_credits_requires_authenticated_writer() { let database_id = { let _caller = AuthenticatedCallerGuard::install_principal(owner); create_database(CreateDatabaseRequest { - name: "Write credits check".to_string(), + name: "Write cycles check".to_string(), }) .expect("database should create") .database_id }; - let anonymous = check_database_write_credits(database_id.clone()) - .expect_err("anonymous caller should fail"); + let anonymous = + check_database_write_cycles(database_id.clone()).expect_err("anonymous caller should fail"); assert!(anonymous.contains("anonymous caller not allowed")); let suspended = { let _caller = AuthenticatedCallerGuard::install_principal(owner); - check_database_write_credits(database_id.clone()) + check_database_write_cycles(database_id.clone()) .expect_err("suspended database should fail") }; - assert!(suspended.contains("database credits are suspended")); + assert!(suspended.contains("database cycles are suspended")); fund_database(&database_id, 1_000_000, 91); SERVICE.with(|slot| { @@ -1504,12 +1521,12 @@ fn check_database_write_credits_requires_authenticated_writer() { let reader_error = { let _caller = AuthenticatedCallerGuard::install_principal(reader); - check_database_write_credits(database_id.clone()).expect_err("reader should fail") + check_database_write_cycles(database_id.clone()).expect_err("reader should fail") }; assert!(reader_error.contains("principal lacks required database role")); let _caller = AuthenticatedCallerGuard::install_principal(owner); - check_database_write_credits(database_id).expect("owner should pass write credits check"); + check_database_write_cycles(database_id).expect("owner should pass write cycles check"); } #[test] @@ -1523,18 +1540,28 @@ fn write_nodes_rejects_reader_role() { service .create_database("public", "owner", 1) .expect("database should create"); + let preview = service + .preview_database_cycles_purchase("public", 1_000_000) + .expect("database cycle purchase preview should load"); service - .begin_database_credit_purchase("public", "owner", 1_000_000, 2) + .begin_database_cycles_purchase("public", "owner", 1_000_000, 2) .and_then(|operation_id| { - service.mark_database_credit_purchase_completed( + service.mark_database_cycles_purchase_completed( operation_id, "public", "owner", - 1_000_000, + preview.cycles, )?; - service.credit_database_purchase(operation_id, "public", "owner", 1_000_000, 1, 2) + service.apply_database_cycles_purchase( + operation_id, + "public", + "owner", + preview.cycles, + 1, + 2, + ) }) - .expect("database should have write credits available"); + .expect("database should have write cycles available"); service .grant_database_access("public", "owner", "2vxsx-fae", DatabaseRole::Reader, 3) .expect("anonymous reader should grant"); @@ -1601,11 +1628,11 @@ fn create_database_returns_result() { assert!(pending_read.contains("database is pending")); set_next_ledger_transfer_from_outcome_for_test(LedgerTransferFromOutcome::Completed(42)); - block_on_ready(purchase_database_credits(credit_purchase_request( + block_on_ready(purchase_database_cycles(cycles_purchase_request( &result.database_id, 1_000_000, ))) - .expect("credit purchase should activate database"); + .expect("cycle purchase should activate database"); let status = status(result.database_id.clone()); assert_eq!(status.file_count, 0); assert_eq!(status.source_count, 0); @@ -2560,25 +2587,28 @@ fn cancel_database_archive_entrypoint_rejects_non_owner() { service .create_database("default", "owner", 1_700_000_000_000) .expect("default database should create"); + let preview = service + .preview_database_cycles_purchase("default", 1_000_000) + .expect("default database cycle purchase preview should load"); service - .begin_database_credit_purchase("default", "owner", 1_000_000, 1_700_000_000_001) + .begin_database_cycles_purchase("default", "owner", 1_000_000, 1_700_000_000_001) .and_then(|operation_id| { - service.mark_database_credit_purchase_completed( + service.mark_database_cycles_purchase_completed( operation_id, "default", "owner", - 1_000_000, + preview.cycles, )?; - service.credit_database_purchase( + service.apply_database_cycles_purchase( operation_id, "default", "owner", - 1_000_000, + preview.cycles, 1, 1_700_000_000_001, ) }) - .expect("database should have write credits available"); + .expect("database should have write cycles available"); service .begin_database_archive("default", "owner", 1_700_000_000_002) .expect("archive should begin"); diff --git a/crates/vfs_canister/src/tests_sync_contract.rs b/crates/vfs_canister/src/tests_sync_contract.rs index 02b8a7db..2481ef8e 100644 --- a/crates/vfs_canister/src/tests_sync_contract.rs +++ b/crates/vfs_canister/src/tests_sync_contract.rs @@ -25,25 +25,28 @@ fn install_test_service() { service .create_database("default", "2vxsx-fae", 1_700_000_000_000) .expect("default database should create"); + let preview = service + .preview_database_cycles_purchase("default", 1_000_000) + .expect("default database cycle purchase preview should load"); service - .begin_database_credit_purchase("default", "2vxsx-fae", 1_000_000, 1_700_000_000_001) + .begin_database_cycles_purchase("default", "2vxsx-fae", 1_000_000, 1_700_000_000_001) .and_then(|operation_id| { - service.mark_database_credit_purchase_completed( + service.mark_database_cycles_purchase_completed( operation_id, "default", "2vxsx-fae", - 1_000_000, + preview.cycles, )?; - service.credit_database_purchase( + service.apply_database_cycles_purchase( operation_id, "default", "2vxsx-fae", - 1_000_000, + preview.cycles, 1, 1_700_000_000_001, ) }) - .expect("default database should have write credits available"); + .expect("default database should have write cycles available"); SERVICE.with(|slot| *slot.borrow_mut() = Some(service)); } diff --git a/crates/vfs_canister/vfs.did b/crates/vfs_canister/vfs.did index 55c22ed2..c991940e 100644 --- a/crates/vfs_canister/vfs.did +++ b/crates/vfs_canister/vfs.did @@ -25,39 +25,38 @@ type ChildNode = record { }; type CreateDatabaseRequest = record { name : text }; type CreateDatabaseResult = record { name : text; database_id : text }; -type CreditsConfig = record { - min_update_credit_units : nat64; - credit_units_per_kinic : nat64; +type CyclesBillingConfig = record { kinic_ledger_canister_id : text; + cycles_per_kinic : nat64; + min_update_cycles : nat64; sns_governance_id : text; }; -type CreditsPurchaseResult = record { +type CyclesPurchaseResult = record { block_index : nat64; - balance_credit_units : nat64; + balance_cycles : nat64; }; type RenameDatabaseRequest = record { name : text; database_id : text }; type DatabaseArchiveChunk = record { bytes : blob }; type DatabaseArchiveInfo = record { size_bytes : nat64; database_id : text }; -type DatabaseCreditEntry = record { +type DatabaseCycleEntry = record { method : opt text; payment_amount_e8s : opt nat64; kind : text; - credit_units_per_kinic : opt nat64; + balance_after_cycles : nat64; created_at_ms : int64; + cycles_per_kinic : opt nat64; ledger_block_index : opt nat64; database_id : text; - amount_credit_units : int64; + amount_cycles : int64; caller : text; cycles_delta : opt nat64; entry_id : nat64; - balance_after_credit_units : nat64; }; -type DatabaseCreditEntryPage = record { - entries : vec DatabaseCreditEntry; +type DatabaseCycleEntryPage = record { + entries : vec DatabaseCycleEntry; next_cursor : opt nat64; }; -type DatabaseCreditPendingOperation = record { - credit_units : int64; +type DatabaseCyclePendingOperation = record { payment_amount_e8s : int64; to_owner : opt text; to_subaccount : opt blob; @@ -66,26 +65,28 @@ type DatabaseCreditPendingOperation = record { operation_id : nat64; from_subaccount : opt blob; created_at_ms : int64; + cycles : int64; ledger_fee_e8s : opt int64; ledger_created_at_time_ns : opt int64; database_id : text; caller : text; }; -type DatabaseCreditPendingOperationPage = record { - entries : vec DatabaseCreditPendingOperation; +type DatabaseCyclePendingOperationPage = record { + entries : vec DatabaseCyclePendingOperation; next_cursor : opt nat64; }; -type DatabaseCreditPurchasePreview = record { +type DatabaseCyclesPurchasePreview = record { payment_amount_e8s : nat64; - credit_units_per_kinic : nat64; + cycles : nat64; ledger_fee_e8s : nat64; config_version : nat64; + cycles_per_kinic : nat64; }; -type DatabaseCreditPurchaseRequest = record { - credit_units : nat64; +type DatabaseCyclesPurchaseRequest = record { + payment_amount_e8s : nat64; expected_config_version : nat64; database_id : text; - expected_payment_amount_e8s : nat64; + expected_cycles : nat64; }; type DatabaseMember = record { "principal" : text; @@ -108,13 +109,13 @@ type DatabaseStatus = variant { }; type DatabaseSummary = record { status : DatabaseStatus; + cycles_balance : opt nat64; name : text; role : DatabaseRole; logical_size_bytes : nat64; - credits_suspended_at_ms : opt int64; + cycles_suspended_at_ms : opt int64; database_id : text; archived_at_ms : opt int64; - credit_units_balance : opt nat64; }; type DeleteDatabaseRequest = record { database_id : text }; type DeleteNodeRequest = record { @@ -365,19 +366,16 @@ type Result_1 = variant { Ok; Err : text }; type Result_10 = variant { Ok : vec GlobNodeHit; Err : text }; type Result_11 = variant { Ok : vec LinkEdge; Err : text }; type Result_12 = variant { Ok : vec ChildNode; Err : text }; -type Result_13 = variant { Ok : DatabaseCreditEntryPage; Err : text }; -type Result_14 = variant { - Ok : DatabaseCreditPendingOperationPage; - Err : text; -}; +type Result_13 = variant { Ok : DatabaseCycleEntryPage; Err : text }; +type Result_14 = variant { Ok : DatabaseCyclePendingOperationPage; Err : text }; type Result_15 = variant { Ok : vec DatabaseMember; Err : text }; type Result_16 = variant { Ok : vec DatabaseSummary; Err : text }; type Result_17 = variant { Ok : vec NodeEntry; Err : text }; type Result_18 = variant { Ok : MkdirNodeResult; Err : text }; type Result_19 = variant { Ok : MoveNodeResult; Err : text }; type Result_2 = variant { Ok : DatabaseArchiveInfo; Err : text }; -type Result_20 = variant { Ok : DatabaseCreditPurchasePreview; Err : text }; -type Result_21 = variant { Ok : CreditsPurchaseResult; Err : text }; +type Result_20 = variant { Ok : DatabaseCyclesPurchasePreview; Err : text }; +type Result_21 = variant { Ok : CyclesPurchaseResult; Err : text }; type Result_22 = variant { Ok : QueryContext; Err : text }; type Result_23 = variant { Ok : IndexSqlJsonQueryResult; Err : text }; type Result_24 = variant { Ok : DatabaseArchiveChunk; Err : text }; @@ -394,7 +392,7 @@ type Result_5 = variant { Ok : DeleteNodeResult; Err : text }; type Result_6 = variant { Ok : EditNodeResult; Err : text }; type Result_7 = variant { Ok : ExportSnapshotResponse; Err : text }; type Result_8 = variant { Ok : FetchUpdatesResponse; Err : text }; -type Result_9 = variant { Ok : CreditsConfig; Err : text }; +type Result_9 = variant { Ok : CyclesBillingConfig; Err : text }; type SearchNodeHit = record { preview : opt SearchPreview; kind : NodeKind; @@ -481,7 +479,7 @@ type WriteSourceForGenerationResult = record { session_nonce : text; write : WriteNodeResult; }; -service : (CreditsConfig) -> { +service : (CyclesBillingConfig) -> { append_node : (AppendNodeRequest) -> (Result); authorize_ops_answer_session : (OpsAnswerSessionRequest) -> (Result_1); authorize_url_ingest_trigger_session : (UrlIngestTriggerSessionRequest) -> ( @@ -492,7 +490,7 @@ service : (CreditsConfig) -> { cancel_database_archive : (text) -> (Result_1); cancel_database_restore : (text) -> (Result_1); canister_health : () -> (CanisterHealth) query; - check_database_write_credits : (text) -> (Result_1) query; + check_database_write_cycles : (text) -> (Result_1) query; check_ops_answer_session : (OpsAnswerSessionRequest) -> (Result_3) query; check_source_run_session : (SourceRunSessionCheckRequest) -> (Result_1) query; check_url_ingest_trigger_session : (UrlIngestTriggerSessionCheckRequest) -> ( @@ -506,7 +504,7 @@ service : (CreditsConfig) -> { fetch_updates : (FetchUpdatesRequest) -> (Result_8) query; finalize_database_archive : (text, blob) -> (Result_1); finalize_database_restore : (text) -> (Result_1); - get_credits_config : () -> (Result_9) query; + get_cycles_billing_config : () -> (Result_9) query; glob_nodes : (GlobNodesRequest) -> (Result_10) query; grant_database_access : (text, text, DatabaseRole) -> (Result_1); graph_links : (GraphLinksRequest) -> (Result_11) query; @@ -518,8 +516,8 @@ service : (CreditsConfig) -> { ); incoming_links : (IncomingLinksRequest) -> (Result_11) query; list_children : (ListChildrenRequest) -> (Result_12) query; - list_database_credit_entries : (text, opt nat64, nat32) -> (Result_13) query; - list_database_credit_pending_operations : (text, opt nat64, nat32) -> ( + list_database_cycle_entries : (text, opt nat64, nat32) -> (Result_13) query; + list_database_cycle_pending_operations : (text, opt nat64, nat32) -> ( Result_14, ) query; list_database_members : (text) -> (Result_15) query; @@ -530,8 +528,8 @@ service : (CreditsConfig) -> { move_node : (MoveNodeRequest) -> (Result_19); multi_edit_node : (MultiEditNodeRequest) -> (Result_6); outgoing_links : (OutgoingLinksRequest) -> (Result_11) query; - preview_database_credit_purchase : (text, nat64) -> (Result_20) query; - purchase_database_credits : (DatabaseCreditPurchaseRequest) -> (Result_21); + preview_database_cycles_purchase : (text, nat64) -> (Result_20) query; + purchase_database_cycles : (DatabaseCyclesPurchaseRequest) -> (Result_21); query_context : (QueryContextRequest) -> (Result_22) query; query_index_sql_json : (text, nat32) -> (Result_23) query; read_database_archive_chunk : (text, nat64, nat32) -> (Result_24) query; @@ -539,8 +537,8 @@ service : (CreditsConfig) -> { read_node_context : (NodeContextRequest) -> (Result_26) query; recent_nodes : (RecentNodesRequest) -> (Result_27) query; rename_database : (RenameDatabaseRequest) -> (Result_1); - repair_database_credit_purchase_cancel : (text, nat64) -> (Result_1); - repair_database_credit_purchase_complete : (text, nat64, nat64) -> ( + repair_database_cycles_purchase_cancel : (text, nat64) -> (Result_1); + repair_database_cycles_purchase_complete : (text, nat64, nat64) -> ( Result_21, ); revoke_database_access : (text, text) -> (Result_1); @@ -549,7 +547,7 @@ service : (CreditsConfig) -> { settle_database_storage_charges : () -> (Result_1); source_evidence : (SourceEvidenceRequest) -> (Result_29) query; status : (text) -> (Status) query; - update_credits_config : (blob) -> (Result_1); + update_cycles_billing_config : (blob) -> (Result_1); write_database_restore_chunk : (DatabaseRestoreChunkRequest) -> (Result_1); write_node : (WriteNodeRequest) -> (Result); write_nodes : (WriteNodesRequest) -> (Result_30); diff --git a/crates/vfs_cli_core/src/commands.rs b/crates/vfs_cli_core/src/commands.rs index 15a5c833..17fd7ec5 100644 --- a/crates/vfs_cli_core/src/commands.rs +++ b/crates/vfs_cli_core/src/commands.rs @@ -1196,8 +1196,8 @@ mod tests { status: DatabaseStatus::Active, role: DatabaseRole::Owner, logical_size_bytes: 42, - credit_units_balance: Some(1_000_000), - credits_suspended_at_ms: None, + cycles_balance: Some(1_000_000), + cycles_suspended_at_ms: None, archived_at_ms: None, }]) } diff --git a/crates/vfs_client/src/lib.rs b/crates/vfs_client/src/lib.rs index d34a939a..fa0ff382 100644 --- a/crates/vfs_client/src/lib.rs +++ b/crates/vfs_client/src/lib.rs @@ -12,9 +12,9 @@ use ic_agent::{ use k256::{SecretKey, pkcs8::DecodePrivateKey}; use vfs_types::{ AppendNodeRequest, CanisterHealth, ChildNode, CreateDatabaseRequest, CreateDatabaseResult, - CreditsConfig, CreditsPurchaseResult, DatabaseArchiveChunk, DatabaseArchiveInfo, - DatabaseCreditEntryPage, DatabaseCreditPendingOperationPage, DatabaseCreditPurchasePreview, - DatabaseCreditPurchaseRequest, DatabaseMember, DatabaseRestoreChunkRequest, DatabaseRole, + CyclesBillingConfig, CyclesPurchaseResult, DatabaseArchiveChunk, DatabaseArchiveInfo, + DatabaseCycleEntryPage, DatabaseCyclePendingOperationPage, DatabaseCyclesPurchasePreview, + DatabaseCyclesPurchaseRequest, DatabaseMember, DatabaseRestoreChunkRequest, DatabaseRole, DatabaseSummary, DeleteNodeRequest, DeleteNodeResult, EditNodeRequest, EditNodeResult, ExportSnapshotRequest, ExportSnapshotResponse, FetchUpdatesRequest, FetchUpdatesResponse, GlobNodeHit, GlobNodesRequest, GraphLinksRequest, GraphNeighborhoodRequest, @@ -22,9 +22,8 @@ use vfs_types::{ MkdirNodeRequest, MkdirNodeResult, MoveNodeRequest, MoveNodeResult, MultiEditNodeRequest, MultiEditNodeResult, Node, NodeContext, NodeContextRequest, NodeEntry, OutgoingLinksRequest, QueryContext, QueryContextRequest, RecentNodeHit, RecentNodesRequest, RenameDatabaseRequest, - SearchNodeHit, - SearchNodePathsRequest, SearchNodesRequest, SourceEvidence, SourceEvidenceRequest, Status, - WriteNodeRequest, WriteNodeResult, WriteNodesRequest, + SearchNodeHit, SearchNodePathsRequest, SearchNodesRequest, SourceEvidence, + SourceEvidenceRequest, Status, WriteNodeRequest, WriteNodeResult, WriteNodesRequest, }; #[async_trait] @@ -46,70 +45,70 @@ pub trait VfsApi: Sync { async fn rename_database(&self, _database_id: &str, _name: &str) -> Result<()> { Err(anyhow!("rename_database is not implemented by this client")) } - async fn purchase_database_credits( + async fn purchase_database_cycles( &self, - _request: DatabaseCreditPurchaseRequest, - ) -> Result { + _request: DatabaseCyclesPurchaseRequest, + ) -> Result { Err(anyhow!( - "purchase_database_credits is not implemented by this client" + "purchase_database_cycles is not implemented by this client" )) } - async fn preview_database_credit_purchase( + async fn preview_database_cycles_purchase( &self, _database_id: &str, - _amount_credit_units: u64, - ) -> Result { + _amount_cycles: u64, + ) -> Result { Err(anyhow!( - "preview_database_credit_purchase is not implemented by this client" + "preview_database_cycles_purchase is not implemented by this client" )) } - async fn check_database_write_credits(&self, _database_id: &str) -> Result<()> { + async fn check_database_write_cycles(&self, _database_id: &str) -> Result<()> { Err(anyhow!( - "check_database_write_credits is not implemented by this client" + "check_database_write_cycles is not implemented by this client" )) } - async fn list_database_credit_entries( + async fn list_database_cycle_entries( &self, _database_id: &str, _cursor: Option, _limit: u32, - ) -> Result { + ) -> Result { Err(anyhow!( - "list_database_credit_entries is not implemented by this client" + "list_database_cycle_entries is not implemented by this client" )) } - async fn list_database_credit_pending_operations( + async fn list_database_cycle_pending_operations( &self, _database_id: &str, _cursor: Option, _limit: u32, - ) -> Result { + ) -> Result { Err(anyhow!( - "list_database_credit_pending_operations is not implemented by this client" + "list_database_cycle_pending_operations is not implemented by this client" )) } - async fn repair_database_credit_purchase_complete( + async fn repair_database_cycles_purchase_complete( &self, _database_id: &str, _operation_id: u64, _ledger_block_index: u64, - ) -> Result { + ) -> Result { Err(anyhow!( - "repair_database_credit_purchase_complete is not implemented by this client" + "repair_database_cycles_purchase_complete is not implemented by this client" )) } - async fn repair_database_credit_purchase_cancel( + async fn repair_database_cycles_purchase_cancel( &self, _database_id: &str, _operation_id: u64, ) -> Result<()> { Err(anyhow!( - "repair_database_credit_purchase_cancel is not implemented by this client" + "repair_database_cycles_purchase_cancel is not implemented by this client" )) } - async fn get_credits_config(&self) -> Result { + async fn get_cycles_billing_config(&self) -> Result { Err(anyhow!( - "get_credits_config is not implemented by this client" + "get_cycles_billing_config is not implemented by this client" )) } async fn grant_database_access( @@ -473,46 +472,46 @@ impl VfsApi for CanisterVfsClient { result.map_err(|error| anyhow!(error)) } - async fn purchase_database_credits( + async fn purchase_database_cycles( &self, - request: DatabaseCreditPurchaseRequest, - ) -> Result { - let result: Result = - self.update("purchase_database_credits", &request).await?; + request: DatabaseCyclesPurchaseRequest, + ) -> Result { + let result: Result = + self.update("purchase_database_cycles", &request).await?; result.map_err(|error| anyhow!(error)) } - async fn preview_database_credit_purchase( + async fn preview_database_cycles_purchase( &self, database_id: &str, - amount_credit_units: u64, - ) -> Result { - let result: Result = self + amount_cycles: u64, + ) -> Result { + let result: Result = self .query2( - "preview_database_credit_purchase", + "preview_database_cycles_purchase", &database_id.to_string(), - &amount_credit_units, + &amount_cycles, ) .await?; result.map_err(|error| anyhow!(error)) } - async fn check_database_write_credits(&self, database_id: &str) -> Result<()> { + async fn check_database_write_cycles(&self, database_id: &str) -> Result<()> { let result: Result<(), String> = self - .query("check_database_write_credits", &database_id.to_string()) + .query("check_database_write_cycles", &database_id.to_string()) .await?; result.map_err(|error| anyhow!(error)) } - async fn list_database_credit_entries( + async fn list_database_cycle_entries( &self, database_id: &str, cursor: Option, limit: u32, - ) -> Result { - let result: Result = self + ) -> Result { + let result: Result = self .query3( - "list_database_credit_entries", + "list_database_cycle_entries", &database_id.to_string(), &cursor, &limit, @@ -521,15 +520,15 @@ impl VfsApi for CanisterVfsClient { result.map_err(|error| anyhow!(error)) } - async fn list_database_credit_pending_operations( + async fn list_database_cycle_pending_operations( &self, database_id: &str, cursor: Option, limit: u32, - ) -> Result { - let result: Result = self + ) -> Result { + let result: Result = self .query3( - "list_database_credit_pending_operations", + "list_database_cycle_pending_operations", &database_id.to_string(), &cursor, &limit, @@ -538,15 +537,15 @@ impl VfsApi for CanisterVfsClient { result.map_err(|error| anyhow!(error)) } - async fn repair_database_credit_purchase_complete( + async fn repair_database_cycles_purchase_complete( &self, database_id: &str, operation_id: u64, ledger_block_index: u64, - ) -> Result { - let result: Result = self + ) -> Result { + let result: Result = self .update3( - "repair_database_credit_purchase_complete", + "repair_database_cycles_purchase_complete", &database_id.to_string(), &operation_id, &ledger_block_index, @@ -555,14 +554,14 @@ impl VfsApi for CanisterVfsClient { result.map_err(|error| anyhow!(error)) } - async fn repair_database_credit_purchase_cancel( + async fn repair_database_cycles_purchase_cancel( &self, database_id: &str, operation_id: u64, ) -> Result<()> { let result: Result<(), String> = self .update2( - "repair_database_credit_purchase_cancel", + "repair_database_cycles_purchase_cancel", &database_id.to_string(), &operation_id, ) @@ -570,8 +569,9 @@ impl VfsApi for CanisterVfsClient { result.map_err(|error| anyhow!(error)) } - async fn get_credits_config(&self) -> Result { - let result: Result = self.query("get_credits_config", &()).await?; + async fn get_cycles_billing_config(&self) -> Result { + let result: Result = + self.query("get_cycles_billing_config", &()).await?; result.map_err(|error| anyhow!(error)) } diff --git a/crates/vfs_runtime/migrations/index_db/011_to_latest.sql b/crates/vfs_runtime/migrations/index_db/011_to_latest.sql index 235f8dad..cc0e3d8f 100644 --- a/crates/vfs_runtime/migrations/index_db/011_to_latest.sql +++ b/crates/vfs_runtime/migrations/index_db/011_to_latest.sql @@ -1,6 +1,6 @@ -CREATE TABLE database_credit_accounts ( +CREATE TABLE database_cycle_accounts ( database_id TEXT PRIMARY KEY, - balance_credit_units INTEGER NOT NULL, + balance_cycles INTEGER NOT NULL, suspended_at_ms INTEGER, storage_charged_at_ms INTEGER, created_at_ms INTEGER NOT NULL, @@ -8,30 +8,30 @@ CREATE TABLE database_credit_accounts ( FOREIGN KEY (database_id) REFERENCES databases(database_id) ); -CREATE TABLE database_credit_ledger ( +CREATE TABLE database_cycle_ledger ( entry_id INTEGER PRIMARY KEY AUTOINCREMENT, database_id TEXT NOT NULL, kind TEXT NOT NULL, - amount_credit_units INTEGER NOT NULL, - balance_after_credit_units INTEGER NOT NULL, + amount_cycles INTEGER NOT NULL, + balance_after_cycles INTEGER NOT NULL, payment_amount_e8s INTEGER, caller TEXT NOT NULL, method TEXT, cycles_delta INTEGER, - credit_units_per_kinic INTEGER, + cycles_per_kinic INTEGER, ledger_block_index INTEGER, created_at_ms INTEGER NOT NULL ); -CREATE INDEX database_credit_ledger_database_idx - ON database_credit_ledger(database_id, entry_id); +CREATE INDEX database_cycle_ledger_database_idx + ON database_cycle_ledger(database_id, entry_id); -CREATE TABLE database_credit_pending_operations ( +CREATE TABLE database_cycle_pending_operations ( operation_id INTEGER PRIMARY KEY AUTOINCREMENT, database_id TEXT NOT NULL, kind TEXT NOT NULL, caller TEXT NOT NULL, - credit_units INTEGER NOT NULL, + cycles INTEGER NOT NULL, payment_amount_e8s INTEGER NOT NULL, from_owner TEXT, from_subaccount BLOB, @@ -44,10 +44,49 @@ CREATE TABLE database_credit_pending_operations ( FOREIGN KEY (database_id) REFERENCES databases(database_id) ); -CREATE INDEX database_credit_pending_operations_database_idx - ON database_credit_pending_operations(database_id); +CREATE INDEX database_cycle_pending_operations_database_idx + ON database_cycle_pending_operations(database_id); -CREATE TABLE credits_config ( +CREATE TABLE cycles_billing_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); + +INSERT INTO database_cycle_accounts + (database_id, balance_cycles, suspended_at_ms, storage_charged_at_ms, + created_at_ms, updated_at_ms) + SELECT database_id, 0, 0, NULL, 0, 0 FROM databases; + +UPDATE databases + SET status = 'active' + WHERE status = 'hot'; + +DELETE FROM database_cycle_pending_operations + WHERE database_id IN (SELECT database_id FROM databases WHERE status = 'deleted'); + +DELETE FROM database_cycle_ledger + WHERE database_id IN (SELECT database_id FROM databases WHERE status = 'deleted'); + +DELETE FROM database_cycle_accounts + WHERE database_id IN (SELECT database_id FROM databases WHERE status = 'deleted'); + +DELETE FROM database_members + WHERE database_id IN (SELECT database_id FROM databases WHERE status = 'deleted'); + +DELETE FROM database_restore_chunks + WHERE database_id IN (SELECT database_id FROM databases WHERE status = 'deleted'); + +DELETE FROM database_restore_sessions + WHERE database_id IN (SELECT database_id FROM databases WHERE status = 'deleted'); + +DELETE FROM url_ingest_trigger_sessions + WHERE database_id IN (SELECT database_id FROM databases WHERE status = 'deleted'); + +DELETE FROM ops_answer_sessions + WHERE database_id IN (SELECT database_id FROM databases WHERE status = 'deleted'); + +DELETE FROM source_run_sessions + WHERE database_id IN (SELECT database_id FROM databases WHERE status = 'deleted'); + +DELETE FROM databases + WHERE status = 'deleted'; diff --git a/crates/vfs_runtime/migrations/index_db/021_pending_operation_status.sql b/crates/vfs_runtime/migrations/index_db/021_pending_operation_status.sql new file mode 100644 index 00000000..0f3f82ef --- /dev/null +++ b/crates/vfs_runtime/migrations/index_db/021_pending_operation_status.sql @@ -0,0 +1,2 @@ +ALTER TABLE database_credit_pending_operations + ADD COLUMN operation_status TEXT NOT NULL DEFAULT 'ambiguous'; diff --git a/crates/vfs_runtime/migrations/index_db/022_credit_units.sql b/crates/vfs_runtime/migrations/index_db/022_credit_units.sql new file mode 100644 index 00000000..c009f617 --- /dev/null +++ b/crates/vfs_runtime/migrations/index_db/022_credit_units.sql @@ -0,0 +1,41 @@ +ALTER TABLE database_credit_accounts + RENAME COLUMN balance_credits TO balance_credit_units; + +UPDATE database_credit_accounts + SET balance_credit_units = balance_credit_units * 1000; + +ALTER TABLE database_credit_ledger + RENAME COLUMN amount_credits TO amount_credit_units; + +ALTER TABLE database_credit_ledger + RENAME COLUMN balance_after_credits TO balance_after_credit_units; + +ALTER TABLE database_credit_ledger + RENAME COLUMN credits_per_kinic TO credit_units_per_kinic; + +UPDATE database_credit_ledger + SET amount_credit_units = amount_credit_units * 1000, + balance_after_credit_units = balance_after_credit_units * 1000, + credit_units_per_kinic = credit_units_per_kinic * 1000 + WHERE credit_units_per_kinic IS NOT NULL; + +UPDATE database_credit_ledger + SET amount_credit_units = amount_credit_units * 1000, + balance_after_credit_units = balance_after_credit_units * 1000 + WHERE credit_units_per_kinic IS NULL; + +ALTER TABLE database_credit_pending_operations + RENAME COLUMN credits TO credit_units; + +UPDATE database_credit_pending_operations + SET credit_units = credit_units * 1000; + +UPDATE credits_config + SET key = 'credit_units_per_kinic', + value = CAST(CAST(value AS INTEGER) * 1000 AS TEXT) + WHERE key = 'credits_per_kinic'; + +UPDATE credits_config + SET key = 'min_update_credit_units', + value = CAST(CAST(value AS INTEGER) * 1000 AS TEXT) + WHERE key = 'min_update_credits'; diff --git a/crates/vfs_runtime/migrations/index_db/023_storage_billing.sql b/crates/vfs_runtime/migrations/index_db/023_storage_billing.sql new file mode 100644 index 00000000..8c73ecd4 --- /dev/null +++ b/crates/vfs_runtime/migrations/index_db/023_storage_billing.sql @@ -0,0 +1,2 @@ +ALTER TABLE database_credit_accounts + ADD COLUMN storage_charged_at_ms INTEGER; diff --git a/crates/vfs_runtime/migrations/index_db/024_direct_cycles.sql b/crates/vfs_runtime/migrations/index_db/024_direct_cycles.sql new file mode 100644 index 00000000..ce1f200b --- /dev/null +++ b/crates/vfs_runtime/migrations/index_db/024_direct_cycles.sql @@ -0,0 +1,83 @@ +ALTER TABLE database_credit_accounts + RENAME TO database_cycle_accounts; + +ALTER TABLE database_cycle_accounts + RENAME COLUMN balance_credit_units TO balance_cycles; + +UPDATE database_cycle_accounts + SET balance_cycles = balance_cycles * 1000000; + +ALTER TABLE database_credit_ledger + RENAME TO database_cycle_ledger; + +ALTER TABLE database_cycle_ledger + RENAME COLUMN amount_credit_units TO amount_cycles; + +ALTER TABLE database_cycle_ledger + RENAME COLUMN balance_after_credit_units TO balance_after_cycles; + +ALTER TABLE database_cycle_ledger + RENAME COLUMN credit_units_per_kinic TO cycles_per_kinic; + +UPDATE database_cycle_ledger + SET amount_cycles = amount_cycles * 1000000, + balance_after_cycles = balance_after_cycles * 1000000, + cycles_per_kinic = cycles_per_kinic * 1000000 + WHERE cycles_per_kinic IS NOT NULL; + +UPDATE database_cycle_ledger + SET amount_cycles = amount_cycles * 1000000, + balance_after_cycles = balance_after_cycles * 1000000 + WHERE cycles_per_kinic IS NULL; + +UPDATE database_cycle_ledger + SET kind = 'cycles_purchase' + WHERE kind = 'credit_purchase'; + +UPDATE database_cycle_ledger + SET kind = 'cycles_purchase_ambiguous' + WHERE kind = 'credit_purchase_ambiguous'; + +UPDATE database_cycle_ledger + SET kind = 'cycles_purchase_repair_complete' + WHERE kind = 'credit_purchase_repair_complete'; + +UPDATE database_cycle_ledger + SET kind = 'cycles_purchase_repair_cancelled' + WHERE kind = 'credit_purchase_repair_cancelled'; + +DROP INDEX database_credit_ledger_database_idx; + +CREATE INDEX database_cycle_ledger_database_idx + ON database_cycle_ledger(database_id, entry_id); + +ALTER TABLE database_credit_pending_operations + RENAME TO database_cycle_pending_operations; + +ALTER TABLE database_cycle_pending_operations + RENAME COLUMN credit_units TO cycles; + +UPDATE database_cycle_pending_operations + SET cycles = cycles * 1000000; + +UPDATE database_cycle_pending_operations + SET kind = 'cycles_purchase' + WHERE kind = 'credit_purchase'; + +DROP INDEX database_credit_pending_operations_database_idx; + +CREATE INDEX database_cycle_pending_operations_database_idx + ON database_cycle_pending_operations(database_id); + +ALTER TABLE credits_config + RENAME TO cycles_billing_config; + +UPDATE cycles_billing_config + SET key = 'cycles_per_kinic', + value = CAST(CAST(value AS INTEGER) * 1000000 AS TEXT) + WHERE key = 'credit_units_per_kinic'; + +UPDATE cycles_billing_config + SET key = 'min_update_cycles', + value = CAST(CAST(value AS INTEGER) * 1000000 AS TEXT) + WHERE key = 'min_update_credit_units'; diff --git a/crates/vfs_runtime/migrations/index_db/fresh_index_schema.sql b/crates/vfs_runtime/migrations/index_db/fresh_index_schema.sql index 6fe215c7..77649b87 100644 --- a/crates/vfs_runtime/migrations/index_db/fresh_index_schema.sql +++ b/crates/vfs_runtime/migrations/index_db/fresh_index_schema.sql @@ -104,9 +104,9 @@ CREATE TABLE database_restore_sessions ( FOREIGN KEY (database_id) REFERENCES databases(database_id) ); -CREATE TABLE database_credit_accounts ( +CREATE TABLE database_cycle_accounts ( database_id TEXT PRIMARY KEY, - balance_credit_units INTEGER NOT NULL, + balance_cycles INTEGER NOT NULL, suspended_at_ms INTEGER, storage_charged_at_ms INTEGER, created_at_ms INTEGER NOT NULL, @@ -114,30 +114,30 @@ CREATE TABLE database_credit_accounts ( FOREIGN KEY (database_id) REFERENCES databases(database_id) ); -CREATE TABLE database_credit_ledger ( +CREATE TABLE database_cycle_ledger ( entry_id INTEGER PRIMARY KEY AUTOINCREMENT, database_id TEXT NOT NULL, kind TEXT NOT NULL, - amount_credit_units INTEGER NOT NULL, - balance_after_credit_units INTEGER NOT NULL, + amount_cycles INTEGER NOT NULL, + balance_after_cycles INTEGER NOT NULL, payment_amount_e8s INTEGER, caller TEXT NOT NULL, method TEXT, cycles_delta INTEGER, - credit_units_per_kinic INTEGER, + cycles_per_kinic INTEGER, ledger_block_index INTEGER, created_at_ms INTEGER NOT NULL ); -CREATE INDEX database_credit_ledger_database_idx - ON database_credit_ledger(database_id, entry_id); +CREATE INDEX database_cycle_ledger_database_idx + ON database_cycle_ledger(database_id, entry_id); -CREATE TABLE database_credit_pending_operations ( +CREATE TABLE database_cycle_pending_operations ( operation_id INTEGER PRIMARY KEY AUTOINCREMENT, database_id TEXT NOT NULL, kind TEXT NOT NULL, caller TEXT NOT NULL, - credit_units INTEGER NOT NULL, + cycles INTEGER NOT NULL, payment_amount_e8s INTEGER NOT NULL, from_owner TEXT, from_subaccount BLOB, @@ -150,10 +150,10 @@ CREATE TABLE database_credit_pending_operations ( FOREIGN KEY (database_id) REFERENCES databases(database_id) ); -CREATE INDEX database_credit_pending_operations_database_idx - ON database_credit_pending_operations(database_id); +CREATE INDEX database_cycle_pending_operations_database_idx + ON database_cycle_pending_operations(database_id); -CREATE TABLE credits_config ( +CREATE TABLE cycles_billing_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); diff --git a/crates/vfs_runtime/src/lib.rs b/crates/vfs_runtime/src/lib.rs index 84533a01..1abb31e1 100644 --- a/crates/vfs_runtime/src/lib.rs +++ b/crates/vfs_runtime/src/lib.rs @@ -18,11 +18,11 @@ use ic_sqlite_vfs::{Db, DbError, DbHandle}; use sha2::{Digest, Sha256}; use vfs_store::FsStore; use vfs_types::{ - AppendNodeRequest, ChildNode, CreditsConfig, CreditsConfigUpdate, DatabaseArchiveInfo, - DatabaseCreditEntry, DatabaseCreditEntryPage, DatabaseCreditPendingOperation, - DatabaseCreditPendingOperationPage, DatabaseCreditPurchasePreview, DatabaseInfo, - DatabaseMember, DatabaseRole, DatabaseStatus, DatabaseSummary, DeleteDatabaseRequest, - DeleteNodeRequest, DeleteNodeResult, EditNodeRequest, EditNodeResult, ExportSnapshotRequest, + AppendNodeRequest, ChildNode, CyclesBillingConfig, CyclesBillingConfigUpdate, + DatabaseArchiveInfo, DatabaseCycleEntry, DatabaseCycleEntryPage, DatabaseCyclePendingOperation, + DatabaseCyclePendingOperationPage, DatabaseCyclesPurchasePreview, DatabaseInfo, DatabaseMember, + DatabaseRole, DatabaseStatus, DatabaseSummary, DeleteDatabaseRequest, DeleteNodeRequest, + DeleteNodeResult, EditNodeRequest, EditNodeResult, ExportSnapshotRequest, ExportSnapshotResponse, FetchUpdatesRequest, FetchUpdatesResponse, GlobNodeHit, GlobNodesRequest, GraphLinksRequest, GraphNeighborhoodRequest, IncomingLinksRequest, IndexSqlJsonQueryResult, KINIC_LEDGER_FEE_E8S, LinkEdge, ListChildrenRequest, ListNodesRequest, @@ -51,23 +51,29 @@ const INDEX_SCHEMA_VERSION_RESTORE_CHUNK_BYTES: &str = "database_index:009_resto const INDEX_SCHEMA_VERSION_DATABASE_NAME_BREAKING: &str = "database_index:010_database_name_breaking"; const INDEX_SCHEMA_VERSION_SOURCE_RUN_SESSIONS: &str = "database_index:011_source_run_sessions"; -const INDEX_SCHEMA_VERSION_BILLING_INITIAL: &str = "database_index:012_credits_initial"; -const INDEX_SCHEMA_VERSION_BILLING_PENDING: &str = "database_index:013_credits_pending"; +const INDEX_SCHEMA_VERSION_BILLING_INITIAL: &str = "database_index:012_cycles_initial"; +const INDEX_SCHEMA_VERSION_BILLING_PENDING: &str = "database_index:013_cycles_pending"; const INDEX_SCHEMA_VERSION_BILLING_LEDGER_BLOCK_INDEX: &str = - "database_index:014_credits_ledger_block_index"; + "database_index:014_cycles_ledger_block_index"; const INDEX_SCHEMA_VERSION_BILLING_PENDING_LEDGER_DETAILS: &str = - "database_index:015_credits_pending_ledger_details"; + "database_index:015_cycles_pending_ledger_details"; const INDEX_SCHEMA_VERSION_ACTIVE_STATUS: &str = "database_index:016_active_status"; const INDEX_SCHEMA_VERSION_HARD_DELETE_DATABASES: &str = "database_index:017_hard_delete_databases"; -const INDEX_SCHEMA_VERSION_CREDIT_LEDGER_ONLY: &str = "database_index:018_credit_ledger_only"; -const INDEX_SCHEMA_VERSION_FIXED_CYCLES_PER_CREDIT_UNIT: &str = - "database_index:019_fixed_cycles_per_credit"; -const INDEX_SCHEMA_VERSION_CREDITS_CONFIG_VERSION: &str = +const INDEX_SCHEMA_VERSION_CYCLES_LEDGER_ONLY: &str = "database_index:018_cycles_ledger_only"; +const INDEX_SCHEMA_VERSION_FIXED_CYCLES_ACCOUNTING: &str = + "database_index:019_fixed_cycles_accounting"; +const INDEX_SCHEMA_VERSION_CYCLES_BILLING_CONFIG_VERSION: &str = + "database_index:020_cycles_billing_config_version"; +const INDEX_SCHEMA_VERSION_CYCLES_PENDING_OPERATION_STATUS: &str = + "database_index:021_cycles_pending_operation_status"; +const INDEX_SCHEMA_VERSION_CYCLES: &str = "database_index:022_cycles"; +const INDEX_SCHEMA_VERSION_STORAGE_BILLING: &str = "database_index:023_storage_billing"; +const INDEX_SCHEMA_VERSION_DIRECT_CYCLES: &str = "database_index:024_direct_cycles"; +const LEGACY_INDEX_SCHEMA_VERSION_CREDITS_CONFIG_VERSION: &str = "database_index:020_credits_config_version"; -const INDEX_SCHEMA_VERSION_CREDIT_PENDING_OPERATION_STATUS: &str = +const LEGACY_INDEX_SCHEMA_VERSION_CREDIT_PENDING_OPERATION_STATUS: &str = "database_index:021_credit_pending_operation_status"; -const INDEX_SCHEMA_VERSION_CREDIT_UNITS: &str = "database_index:022_credit_units"; -const INDEX_SCHEMA_VERSION_STORAGE_BILLING: &str = "database_index:023_storage_billing"; +const LEGACY_INDEX_SCHEMA_VERSION_CREDIT_UNITS: &str = "database_index:022_credit_units"; const PENDING_DATABASE_MOUNT_ID: u16 = 0; const DATABASE_SCHEMA_VERSION: &str = "vfs_store:current"; const MIN_DATABASE_MOUNT_ID: u16 = 11; @@ -83,23 +89,30 @@ const GENERATED_DATABASE_ID_PREFIX: &str = "db_"; const GENERATED_DATABASE_ID_HASH_CHARS: usize = 12; const FRESH_INDEX_SCHEMA_SQL: &str = include_str!("../migrations/index_db/fresh_index_schema.sql"); const INDEX_011_TO_LATEST_SQL: &str = include_str!("../migrations/index_db/011_to_latest.sql"); +const INDEX_021_PENDING_OPERATION_STATUS_SQL: &str = + include_str!("../migrations/index_db/021_pending_operation_status.sql"); +const INDEX_022_CREDIT_UNITS_SQL: &str = + include_str!("../migrations/index_db/022_credit_units.sql"); +const INDEX_023_STORAGE_BILLING_SQL: &str = + include_str!("../migrations/index_db/023_storage_billing.sql"); +const INDEX_024_DIRECT_CYCLES_SQL: &str = + include_str!("../migrations/index_db/024_direct_cycles.sql"); pub const KINIC_E8S_PER_TOKEN: u64 = 100_000_000; -pub const DEFAULT_CREDIT_UNITS_PER_KINIC: u64 = 1_000_000; -pub const CYCLES_PER_CREDIT_UNIT: u128 = 1_000_000; -pub const DEFAULT_MIN_UPDATE_CREDIT_UNITS: u64 = 1; +pub const DEFAULT_CYCLES_PER_KINIC: u64 = 1_000_000_000_000; +pub const DEFAULT_MIN_UPDATE_CYCLES: u64 = 1_000_000; pub const STORAGE_BILLING_INTERVAL_MS: i64 = 24 * 60 * 60 * 1000; pub const STORAGE_CYCLES_PER_GIB_SECOND: u128 = 127_000; const GIB_BYTES: u128 = 1024 * 1024 * 1024; -const DEFAULT_CREDITS_CONFIG_VERSION: u64 = 1; +const DEFAULT_CYCLES_BILLING_CONFIG_VERSION: u64 = 1; const MAX_DATABASE_NAME_CHARS: usize = 80; const FNV1A64_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; const FNV1A64_PRIME: u64 = 0x0000_0100_0000_01b3; pub const DEFAULT_LLM_WRITER_PRINCIPAL: &str = "ckurn-x74ln-nemlm-42vfv-gej7r-4cc3e-v22e5-otcod-jndlh-pbst4-3qe"; const ANONYMOUS_PRINCIPAL: &str = "2vxsx-fae"; -const CREDIT_OPERATION_STATUS_IN_FLIGHT: &str = "in_flight"; -const CREDIT_OPERATION_STATUS_AMBIGUOUS: &str = "ambiguous"; -const CREDIT_OPERATION_STATUS_COMPLETED: &str = "completed"; +const CYCLES_OPERATION_STATUS_IN_FLIGHT: &str = "in_flight"; +const CYCLES_OPERATION_STATUS_AMBIGUOUS: &str = "ambiguous"; +const CYCLES_OPERATION_STATUS_COMPLETED: &str = "completed"; #[derive(Clone, Debug, PartialEq, Eq)] pub struct DatabaseMeta { @@ -134,7 +147,7 @@ pub enum RequiredRole { Owner, } -pub struct CreditsPendingLedgerDetailsInput<'a> { +pub struct CyclesPendingLedgerDetailsInput<'a> { pub from_owner: &'a str, pub from_subaccount: Option<&'a [u8]>, pub to_owner: &'a str, @@ -143,13 +156,13 @@ pub struct CreditsPendingLedgerDetailsInput<'a> { pub ledger_created_at_time_ns: u64, } -pub struct DatabaseCreditPurchaseWithLedgerDetails<'a> { +pub struct DatabaseCyclesPurchaseWithLedgerDetails<'a> { pub database_id: &'a str, pub caller: &'a str, - pub credit_units: u64, - pub expected_payment_amount_e8s: u64, + pub payment_amount_e8s: u64, + pub expected_cycles: u64, pub expected_config_version: u64, - pub ledger: CreditsPendingLedgerDetailsInput<'a>, + pub ledger: CyclesPendingLedgerDetailsInput<'a>, pub now: i64, } @@ -184,10 +197,13 @@ impl VfsService { } pub fn run_index_migrations(&self) -> Result<(), String> { - self.run_index_migrations_with_config(default_credits_config()) + self.run_index_migrations_with_config(default_cycles_billing_config()) } - pub fn run_index_migrations_with_config(&self, config: CreditsConfig) -> Result<(), String> { + pub fn run_index_migrations_with_config( + &self, + config: CyclesBillingConfig, + ) -> Result<(), String> { #[cfg(not(target_arch = "wasm32"))] { let mut conn = self.open_index()?; @@ -201,7 +217,7 @@ impl VfsService { pub fn run_index_migrations_for_upgrade( &self, - config: Option, + config: Option, ) -> Result<(), String> { #[cfg(not(target_arch = "wasm32"))] { @@ -258,7 +274,7 @@ impl VfsService { }) .collect::, String>>()?; self.write_index(|tx| { - let config = load_credits_config(tx)?; + let config = load_cycles_billing_config(tx)?; for (database_id, size_bytes) in measured { settle_database_storage_charge_in_tx( tx, @@ -282,62 +298,63 @@ impl VfsService { self.read_index(|conn| load_database_summaries_for_caller(conn, caller)) } - pub fn credits_config(&self) -> Result { - self.read_index(load_credits_config) + pub fn cycles_billing_config(&self) -> Result { + self.read_index(load_cycles_billing_config) } - pub fn preview_database_credit_purchase( + pub fn preview_database_cycles_purchase( &self, database_id: &str, - credit_units: u64, - ) -> Result { - let credit_units_i64 = credit_units_to_i64(credit_units)?; + payment_amount_e8s: u64, + ) -> Result { + amount_to_i64(payment_amount_e8s)?; self.read_index(|conn| { - let config = load_credits_config(conn)?; - let config_version = load_credits_config_version(conn)?; - let payment_amount_e8s = payment_amount_e8s_for_credit_units(credit_units, &config)?; - amount_to_i64(payment_amount_e8s)?; - validate_database_credit_purchase_for_conn(conn, database_id, credit_units_i64)?; - Ok(DatabaseCreditPurchasePreview { + let config = load_cycles_billing_config(conn)?; + let config_version = load_cycles_billing_config_version(conn)?; + let cycles = cycles_for_payment_amount_e8s(payment_amount_e8s, &config)?; + let cycles_i64 = cycles_to_i64(cycles)?; + validate_database_cycles_purchase_for_conn(conn, database_id, cycles_i64)?; + Ok(DatabaseCyclesPurchasePreview { payment_amount_e8s, + cycles, ledger_fee_e8s: KINIC_LEDGER_FEE_E8S, - credit_units_per_kinic: config.credit_units_per_kinic, + cycles_per_kinic: config.cycles_per_kinic, config_version, }) }) } - pub fn update_credits_config( + pub fn update_cycles_billing_config( &self, - update: CreditsConfigUpdate, + update: CyclesBillingConfigUpdate, caller: &str, - ) -> Result { - let current = self.credits_config()?; - let current_version = self.read_index(load_credits_config_version)?; + ) -> Result { + let current = self.cycles_billing_config()?; + let current_version = self.read_index(load_cycles_billing_config_version)?; if caller != current.sns_governance_id { return Err("caller is not SNS governance".to_string()); } - let config_changed = current.credit_units_per_kinic != update.credit_units_per_kinic - || current.min_update_credit_units != update.min_update_credit_units; - let next = CreditsConfig { + let config_changed = current.cycles_per_kinic != update.cycles_per_kinic + || current.min_update_cycles != update.min_update_cycles; + let next = CyclesBillingConfig { kinic_ledger_canister_id: current.kinic_ledger_canister_id, sns_governance_id: current.sns_governance_id, - credit_units_per_kinic: update.credit_units_per_kinic, - min_update_credit_units: update.min_update_credit_units, + cycles_per_kinic: update.cycles_per_kinic, + min_update_cycles: update.min_update_cycles, }; - validate_credits_config(&next)?; + validate_cycles_billing_config(&next)?; let next_version = if config_changed { current_version .checked_add(1) - .ok_or_else(|| "credits config version overflow".to_string())? + .ok_or_else(|| "cycles billing config version overflow".to_string())? } else { current_version }; self.write_index(|tx| { - set_credits_config_value(tx, "credit_units_per_kinic", next.credit_units_per_kinic)?; - set_credits_config_value(tx, "min_update_credit_units", next.min_update_credit_units)?; + set_cycles_billing_config_value(tx, "cycles_per_kinic", next.cycles_per_kinic)?; + set_cycles_billing_config_value(tx, "min_update_cycles", next.min_update_cycles)?; if config_changed { - set_credits_config_value(tx, "config_version", next_version)?; + set_cycles_billing_config_value(tx, "config_version", next_version)?; } Ok(()) })?; @@ -454,7 +471,7 @@ impl VfsService { caller: &str, now: i64, mount_id: u16, - initial_credits_balance: i64, + initial_cycles_balance: i64, ) -> Result { let db_file_name = self.database_file_name(database_id, mount_id)?; tx.execute( @@ -474,19 +491,19 @@ impl VfsService { .map_err(|error| error.to_string())?; record_mount_history(tx, database_id, mount_id, "create", now)?; insert_initial_database_members(tx, database_id, caller, now)?; - let suspended_at_ms = if initial_credits_balance == 0 { + let suspended_at_ms = if initial_cycles_balance == 0 { Some(now) } else { None }; tx.execute( - "INSERT INTO database_credit_accounts - (database_id, balance_credit_units, suspended_at_ms, storage_charged_at_ms, + "INSERT INTO database_cycle_accounts + (database_id, balance_cycles, suspended_at_ms, storage_charged_at_ms, created_at_ms, updated_at_ms) VALUES (?1, ?2, ?3, ?4, ?4, ?4)", params![ database_id, - initial_credits_balance, + initial_cycles_balance, crate::sqlite::nullable_integer_value(suspended_at_ms), now ], @@ -526,8 +543,8 @@ impl VfsService { .map_err(|error| error.to_string())?; insert_initial_database_members(tx, database_id, caller, now)?; tx.execute( - "INSERT INTO database_credit_accounts - (database_id, balance_credit_units, suspended_at_ms, storage_charged_at_ms, + "INSERT INTO database_cycle_accounts + (database_id, balance_cycles, suspended_at_ms, storage_charged_at_ms, created_at_ms, updated_at_ms) VALUES (?1, 0, ?2, NULL, ?2, ?2)", params![database_id, now], @@ -556,17 +573,17 @@ impl VfsService { .optional() .map_err(|error| error.to_string())?; tx.execute( - "DELETE FROM database_credit_ledger WHERE database_id = ?1", + "DELETE FROM database_cycle_ledger WHERE database_id = ?1", params![database_id], ) .map_err(|error| error.to_string())?; tx.execute( - "DELETE FROM database_credit_pending_operations WHERE database_id = ?1", + "DELETE FROM database_cycle_pending_operations WHERE database_id = ?1", params![database_id], ) .map_err(|error| error.to_string())?; tx.execute( - "DELETE FROM database_credit_accounts WHERE database_id = ?1", + "DELETE FROM database_cycle_accounts WHERE database_id = ?1", params![database_id], ) .map_err(|error| error.to_string())?; @@ -604,7 +621,7 @@ impl VfsService { Ok(()) } - pub fn activate_pending_database_for_credit_purchase( + pub fn activate_pending_database_for_cycles_purchase( &self, database_id: &str, now: i64, @@ -641,31 +658,31 @@ impl VfsService { }) } - pub fn validate_database_credit_purchase( + pub fn validate_database_cycles_purchase( &self, database_id: &str, - credit_units: u64, + payment_amount_e8s: u64, ) -> Result<(), String> { - self.preview_database_credit_purchase(database_id, credit_units) + self.preview_database_cycles_purchase(database_id, payment_amount_e8s) .map(|_| ()) } - pub fn begin_database_credit_purchase( + pub fn begin_database_cycles_purchase( &self, database_id: &str, caller: &str, - credit_units: u64, + payment_amount_e8s: u64, now: i64, ) -> Result { - let preview = self.preview_database_credit_purchase(database_id, credit_units)?; - self.begin_database_credit_purchase_with_ledger_details( - DatabaseCreditPurchaseWithLedgerDetails { + let preview = self.preview_database_cycles_purchase(database_id, payment_amount_e8s)?; + self.begin_database_cycles_purchase_with_ledger_details( + DatabaseCyclesPurchaseWithLedgerDetails { database_id, caller, - credit_units, - expected_payment_amount_e8s: preview.payment_amount_e8s, + payment_amount_e8s, + expected_cycles: preview.cycles, expected_config_version: preview.config_version, - ledger: CreditsPendingLedgerDetailsInput { + ledger: CyclesPendingLedgerDetailsInput { from_owner: caller, from_subaccount: None, to_owner: "canister", @@ -678,42 +695,41 @@ impl VfsService { ) } - pub fn begin_database_credit_purchase_with_ledger_details( + pub fn begin_database_cycles_purchase_with_ledger_details( &self, - request: DatabaseCreditPurchaseWithLedgerDetails<'_>, + request: DatabaseCyclesPurchaseWithLedgerDetails<'_>, ) -> Result { - let credit_units = credit_units_to_i64(request.credit_units)?; + let payment_amount_e8s = amount_to_i64(request.payment_amount_e8s)?; let ledger_fee = amount_to_i64(request.ledger.ledger_fee_e8s)?; let ledger_created_at_time = i64::try_from(request.ledger.ledger_created_at_time_ns) .map_err(|_| "ledger created_at_time exceeds i64".to_string())?; self.write_index(|tx| { - let config = load_credits_config(tx)?; - let config_version = load_credits_config_version(tx)?; - let payment_amount_e8s_u64 = - payment_amount_e8s_for_credit_units(request.credit_units, &config)?; + let config = load_cycles_billing_config(tx)?; + let config_version = load_cycles_billing_config_version(tx)?; + let cycles_u64 = cycles_for_payment_amount_e8s(request.payment_amount_e8s, &config)?; + let cycles = cycles_to_i64(cycles_u64)?; if request.expected_config_version != config_version { return Err(format!( - "credits config changed: expected version {}, current version {}", + "cycles billing config changed: expected version {}, current version {}", request.expected_config_version, config_version )); } - if request.expected_payment_amount_e8s != payment_amount_e8s_u64 { + if request.expected_cycles != cycles_u64 { return Err(format!( - "credit purchase payment amount changed: expected {}, current {}", - request.expected_payment_amount_e8s, payment_amount_e8s_u64 + "cycles purchase amount changed: expected {}, current {}", + request.expected_cycles, cycles_u64 )); } - let payment_amount_e8s = amount_to_i64(payment_amount_e8s_u64)?; - validate_database_credit_purchase_for_conn(tx, request.database_id, credit_units)?; - insert_pending_credits_operation( + validate_database_cycles_purchase_for_conn(tx, request.database_id, cycles)?; + insert_pending_cycles_operation( tx, - PendingCreditsOperationInsert { + PendingCyclesOperationInsert { database_id: request.database_id, - kind: "credit_purchase", + kind: "cycles_purchase", caller: request.caller, - credit_units, + cycles, payment_amount_e8s, - ledger: PendingCreditsLedgerDetails { + ledger: PendingCyclesLedgerDetails { from_owner: request.ledger.from_owner, from_subaccount: request.ledger.from_subaccount, to_owner: request.ledger.to_owner, @@ -721,89 +737,89 @@ impl VfsService { ledger_fee_e8s: ledger_fee, ledger_created_at_time_ns: ledger_created_at_time, }, - operation_status: CREDIT_OPERATION_STATUS_IN_FLIGHT, + operation_status: CYCLES_OPERATION_STATUS_IN_FLIGHT, now: request.now, }, ) }) } - pub fn credit_database_purchase( + pub fn apply_database_cycles_purchase( &self, operation_id: u64, database_id: &str, caller: &str, - credit_units: u64, + cycles: u64, ledger_block_index: u64, now: i64, ) -> Result { - let credit_units_i64 = credit_units_to_i64(credit_units)?; - let config = self.credits_config()?; + let cycles_i64 = cycles_to_i64(cycles)?; + let config = self.cycles_billing_config()?; self.write_index(|tx| { - let operation = load_required_pending_credits_operation( + let operation = load_required_pending_cycles_operation( tx, - PendingCreditsOperationMatch { + PendingCyclesOperationMatch { operation_id, database_id, - kind: "credit_purchase", + kind: "cycles_purchase", caller, - credit_units: credit_units_i64, + cycles: cycles_i64, }, )?; require_pending_operation_status( &operation, - &[CREDIT_OPERATION_STATUS_COMPLETED], - "apply completed credit purchase", + &[CYCLES_OPERATION_STATUS_COMPLETED], + "apply completed cycle purchase", )?; load_database_status(tx, database_id)?; complete_pending_database_activation(tx, database_id, now)?; let db_balance = database_balance_for_update(tx, database_id)?; - let next_database = checked_balance_add(db_balance, credit_units_i64)?; - update_database_credits_balance(tx, database_id, next_database, &config, now)?; + let next_database = checked_balance_add(db_balance, cycles_i64)?; + update_database_cycles_balance(tx, database_id, next_database, &config, now)?; insert_database_ledger( tx, DatabaseLedgerInsert { database_id, - kind: "credit_purchase", - amount_credit_units: credit_units_i64, - balance_after_credit_units: next_database, + kind: "cycles_purchase", + amount_cycles: cycles_i64, + balance_after_cycles: next_database, payment_amount_e8s: Some(operation.payment_amount_e8s), caller, - method: Some("purchase_database_credits"), + method: Some("purchase_database_cycles"), cycles_delta: None, config: None, ledger_block_index: Some(ledger_block_index), now, }, )?; - delete_pending_credits_operation(tx, operation_id)?; + delete_pending_cycles_operation(tx, operation_id)?; Ok(next_database as u64) }) } - pub fn mark_database_credit_purchase_ambiguous( + pub fn mark_database_cycles_purchase_ambiguous( &self, operation_id: u64, database_id: &str, caller: &str, - credit_units: u64, + cycles: u64, now: i64, ) -> Result { - let credit_units_i64 = credit_units_to_i64(credit_units)?; + let cycles_i64 = cycles_to_i64(cycles)?; self.write_index(|tx| { - let operation = load_required_pending_credits_operation( + let operation = load_required_pending_cycles_operation( tx, - PendingCreditsOperationMatch { + PendingCyclesOperationMatch { operation_id, database_id, - kind: "credit_purchase", + kind: "cycles_purchase", caller, - credit_units: credit_units_i64, + cycles: cycles_i64, }, )?; require_pending_operation_status( &operation, - &[CREDIT_OPERATION_STATUS_IN_FLIGHT], - "mark credit purchase ambiguous", + &[CYCLES_OPERATION_STATUS_IN_FLIGHT], + "mark cycle purchase ambiguous", )?; load_database_status(tx, database_id)?; let balance = database_balance_for_update(tx, database_id)?; @@ -811,95 +827,95 @@ impl VfsService { tx, DatabaseLedgerInsert { database_id, - kind: "credit_purchase_ambiguous", - amount_credit_units: credit_units_i64, - balance_after_credit_units: balance, + kind: "cycles_purchase_ambiguous", + amount_cycles: cycles_i64, + balance_after_cycles: balance, payment_amount_e8s: Some(operation.payment_amount_e8s), caller, - method: Some("purchase_database_credits"), + method: Some("purchase_database_cycles"), cycles_delta: None, config: None, ledger_block_index: None, now, }, )?; - update_pending_credits_operation_status( + update_pending_cycles_operation_status( tx, operation_id, - CREDIT_OPERATION_STATUS_AMBIGUOUS, + CYCLES_OPERATION_STATUS_AMBIGUOUS, )?; Ok(balance as u64) }) } - pub fn mark_database_credit_purchase_completed( + pub fn mark_database_cycles_purchase_completed( &self, operation_id: u64, database_id: &str, caller: &str, - credit_units: u64, + cycles: u64, ) -> Result<(), String> { - let credit_units_i64 = credit_units_to_i64(credit_units)?; + let cycles_i64 = cycles_to_i64(cycles)?; self.write_index(|tx| { - let operation = load_required_pending_credits_operation( + let operation = load_required_pending_cycles_operation( tx, - PendingCreditsOperationMatch { + PendingCyclesOperationMatch { operation_id, database_id, - kind: "credit_purchase", + kind: "cycles_purchase", caller, - credit_units: credit_units_i64, + cycles: cycles_i64, }, )?; require_pending_operation_status( &operation, - &[CREDIT_OPERATION_STATUS_IN_FLIGHT], - "mark credit purchase completed", + &[CYCLES_OPERATION_STATUS_IN_FLIGHT], + "mark cycle purchase completed", )?; - update_pending_credits_operation_status( + update_pending_cycles_operation_status( tx, operation_id, - CREDIT_OPERATION_STATUS_COMPLETED, + CYCLES_OPERATION_STATUS_COMPLETED, ) }) } - pub fn cancel_database_credit_purchase( + pub fn cancel_database_cycles_purchase( &self, operation_id: u64, database_id: &str, caller: &str, - credit_units: u64, + cycles: u64, ) -> Result<(), String> { - let credit_units_i64 = credit_units_to_i64(credit_units)?; + let cycles_i64 = cycles_to_i64(cycles)?; self.write_index(|tx| { - let operation = load_required_pending_credits_operation( + let operation = load_required_pending_cycles_operation( tx, - PendingCreditsOperationMatch { + PendingCyclesOperationMatch { operation_id, database_id, - kind: "credit_purchase", + kind: "cycles_purchase", caller, - credit_units: credit_units_i64, + cycles: cycles_i64, }, )?; require_pending_operation_status( &operation, - &[CREDIT_OPERATION_STATUS_IN_FLIGHT], - "cancel credit purchase", + &[CYCLES_OPERATION_STATUS_IN_FLIGHT], + "cancel cycle purchase", )?; - delete_pending_credits_operation(tx, operation_id) + delete_pending_cycles_operation(tx, operation_id) }) } - pub fn list_database_credit_entries( + pub fn list_database_cycle_entries( &self, database_id: &str, caller: &str, cursor: Option, limit: u32, - ) -> Result { - let config = self.credits_config()?; + ) -> Result { + let config = self.cycles_billing_config()?; let limit = page_limit(limit); let after = i64::try_from(cursor.unwrap_or(0)).map_err(|error| error.to_string())?; self.read_index(|conn| { @@ -918,10 +934,10 @@ impl VfsService { }; let mut stmt = conn .prepare( - "SELECT entry_id, database_id, kind, amount_credit_units, balance_after_credit_units, - payment_amount_e8s, caller, method, cycles_delta, credit_units_per_kinic, + "SELECT entry_id, database_id, kind, amount_cycles, balance_after_cycles, + payment_amount_e8s, caller, method, cycles_delta, cycles_per_kinic, ledger_block_index, created_at_ms - FROM database_credit_ledger + FROM database_cycle_ledger WHERE database_id = ?1 AND entry_id > ?2 ORDER BY entry_id ASC LIMIT ?3", @@ -930,7 +946,7 @@ impl VfsService { let mut entries = crate::sqlite::query_map( &mut stmt, params![database_id, after, i64::from(limit) + 1], - map_database_credits_entry, + map_database_cycles_entry, ) .map_err(|error| error.to_string())?; if !show_principal { @@ -944,21 +960,21 @@ impl VfsService { } else { None }; - Ok(DatabaseCreditEntryPage { + Ok(DatabaseCycleEntryPage { entries, next_cursor, }) }) } - pub fn list_database_credit_pending_operations( + pub fn list_database_cycle_pending_operations( &self, database_id: &str, caller: &str, cursor: Option, limit: u32, - ) -> Result { - let config = self.credits_config()?; + ) -> Result { + let config = self.cycles_billing_config()?; let limit = page_limit(limit); let after = i64::try_from(cursor.unwrap_or(0)).map_err(|error| error.to_string())?; self.read_index(|conn| { @@ -974,10 +990,10 @@ impl VfsService { } let mut stmt = conn .prepare( - "SELECT operation_id, database_id, kind, caller, credit_units, payment_amount_e8s, + "SELECT operation_id, database_id, kind, caller, cycles, payment_amount_e8s, from_owner, from_subaccount, to_owner, to_subaccount, ledger_fee_e8s, ledger_created_at_time_ns, operation_status, created_at_ms - FROM database_credit_pending_operations + FROM database_cycle_pending_operations WHERE database_id = ?1 AND operation_id > ?2 ORDER BY operation_id ASC LIMIT ?3", @@ -986,7 +1002,7 @@ impl VfsService { let mut entries = crate::sqlite::query_map( &mut stmt, params![database_id, after, i64::from(limit) + 1], - map_database_credits_pending_operation, + map_database_cycles_pending_operation, ) .map_err(|error| error.to_string())?; let next_cursor = if entries.len() > limit as usize { @@ -995,81 +1011,81 @@ impl VfsService { } else { None }; - Ok(DatabaseCreditPendingOperationPage { + Ok(DatabaseCyclePendingOperationPage { entries, next_cursor, }) }) } - pub fn get_database_credit_pending_operation_for_complete( + pub fn get_database_cycle_pending_operation_for_complete( &self, database_id: &str, operation_id: u64, - ) -> Result { + ) -> Result { self.write_index(|tx| { - let operation = load_pending_credits_operation(tx, operation_id)?; + let operation = load_pending_cycles_operation(tx, operation_id)?; if operation.database_id != database_id { - return Err("pending credit operation mismatch".to_string()); + return Err("pending cycle operation mismatch".to_string()); } require_pending_operation_status( &operation, &[ - CREDIT_OPERATION_STATUS_AMBIGUOUS, - CREDIT_OPERATION_STATUS_COMPLETED, + CYCLES_OPERATION_STATUS_AMBIGUOUS, + CYCLES_OPERATION_STATUS_COMPLETED, ], - "complete credit purchase repair", + "complete cycle purchase repair", )?; - Ok(pending_credits_operation_to_public(operation)) + Ok(pending_cycles_operation_to_public(operation)) }) } - pub fn repair_database_credit_purchase_complete( + pub fn repair_database_cycles_purchase_complete( &self, database_id: &str, operation_id: u64, ledger_block_index: u64, now: i64, ) -> Result { - let config = self.credits_config()?; + let config = self.cycles_billing_config()?; self.write_index(|tx| { - let operation = load_pending_credits_operation(tx, operation_id)?; - require_pending_database_kind(&operation, database_id, "credit_purchase")?; + let operation = load_pending_cycles_operation(tx, operation_id)?; + require_pending_database_kind(&operation, database_id, "cycles_purchase")?; require_pending_operation_status( &operation, &[ - CREDIT_OPERATION_STATUS_AMBIGUOUS, - CREDIT_OPERATION_STATUS_COMPLETED, + CYCLES_OPERATION_STATUS_AMBIGUOUS, + CYCLES_OPERATION_STATUS_COMPLETED, ], - "complete credit purchase repair", + "complete cycle purchase repair", )?; load_database_status(tx, database_id)?; complete_pending_database_activation(tx, database_id, now)?; let balance = database_balance_for_update(tx, database_id)?; - let next = checked_balance_add(balance, operation.credit_units)?; - update_database_credits_balance(tx, database_id, next, &config, now)?; + let next = checked_balance_add(balance, operation.cycles)?; + update_database_cycles_balance(tx, database_id, next, &config, now)?; insert_database_ledger( tx, DatabaseLedgerInsert { database_id, - kind: "credit_purchase_repair_complete", - amount_credit_units: operation.credit_units, - balance_after_credit_units: next, + kind: "cycles_purchase_repair_complete", + amount_cycles: operation.cycles, + balance_after_cycles: next, payment_amount_e8s: Some(operation.payment_amount_e8s), caller: &operation.caller, - method: Some("repair_database_credit_purchase_complete"), + method: Some("repair_database_cycles_purchase_complete"), cycles_delta: None, config: None, ledger_block_index: Some(ledger_block_index), now, }, )?; - delete_pending_credits_operation(tx, operation_id)?; + delete_pending_cycles_operation(tx, operation_id)?; Ok(next as u64) }) } - pub fn repair_database_credit_purchase_cancel( + pub fn repair_database_cycles_purchase_cancel( &self, database_id: &str, operation_id: u64, @@ -1077,12 +1093,12 @@ impl VfsService { now: i64, ) -> Result<(), String> { self.write_index(|tx| { - let operation = load_pending_credits_operation(tx, operation_id)?; - require_pending_database_kind(&operation, database_id, "credit_purchase")?; + let operation = load_pending_cycles_operation(tx, operation_id)?; + require_pending_database_kind(&operation, database_id, "cycles_purchase")?; require_pending_operation_status( &operation, - &[CREDIT_OPERATION_STATUS_AMBIGUOUS], - "cancel credit purchase repair", + &[CYCLES_OPERATION_STATUS_AMBIGUOUS], + "cancel cycle purchase repair", )?; let status = load_database_status(tx, database_id)?; let is_payer = operation.caller == caller; @@ -1091,7 +1107,7 @@ impl VfsService { .unwrap_or(false); if !is_payer && !is_owner { return Err(format!( - "caller is not credit purchase payer or database owner: {database_id}" + "caller is not cycle purchase payer or database owner: {database_id}" )); } let active_mount_id: Option = tx @@ -1103,7 +1119,7 @@ impl VfsService { .map_err(|error| error.to_string())?; if status == DatabaseStatus::Pending && active_mount_id.is_some() { return Err( - "pending database activation already started; complete credit purchase repair" + "pending database activation already started; complete cycle purchase repair" .to_string(), ); } @@ -1112,30 +1128,27 @@ impl VfsService { tx, DatabaseLedgerInsert { database_id, - kind: "credit_purchase_repair_cancelled", - amount_credit_units: operation.credit_units, - balance_after_credit_units: balance, + kind: "cycles_purchase_repair_cancelled", + amount_cycles: operation.cycles, + balance_after_cycles: balance, payment_amount_e8s: Some(operation.payment_amount_e8s), caller, - method: Some("repair_database_credit_purchase_cancel"), + method: Some("repair_database_cycles_purchase_cancel"), cycles_delta: None, config: None, ledger_block_index: None, now, }, )?; - delete_pending_credits_operation(tx, operation_id)?; + delete_pending_cycles_operation(tx, operation_id)?; Ok(()) }) } - pub fn require_database_write_credits_available( - &self, - database_id: &str, - ) -> Result<(), String> { + pub fn require_database_write_cycles_available(&self, database_id: &str) -> Result<(), String> { self.read_index(|conn| { - let config = load_credits_config(conn)?; - require_database_write_credits_available_for_conn(conn, database_id, &config) + let config = load_cycles_billing_config(conn)?; + require_database_write_cycles_available_for_conn(conn, database_id, &config) }) } @@ -1144,7 +1157,7 @@ impl VfsService { database_id: &str, caller: &str, required_role: RequiredRole, - ) -> Result { + ) -> Result { self.read_index(|conn| { let role = load_database_status(conn, database_id).and_then(|_| { load_member_role(conn, database_id, caller)? @@ -1155,13 +1168,13 @@ impl VfsService { "principal lacks required database role: {database_id}" )); } - let config = load_credits_config(conn)?; - require_database_write_credits_available_for_conn(conn, database_id, &config)?; + let config = load_cycles_billing_config(conn)?; + require_database_write_cycles_available_for_conn(conn, database_id, &config)?; Ok(config) }) } - pub fn check_database_write_credits( + pub fn check_database_write_cycles( &self, database_id: &str, caller: &str, @@ -1170,12 +1183,12 @@ impl VfsService { return Err("anonymous caller not allowed".to_string()); } self.require_role(database_id, caller, RequiredRole::Writer)?; - self.require_database_write_credits_available(database_id) + self.require_database_write_cycles_available(database_id) } pub fn charge_database_update( &self, - config: &CreditsConfig, + config: &CyclesBillingConfig, database_id: &str, caller: &str, method: &str, @@ -1236,7 +1249,7 @@ impl VfsService { ) -> Result<(), String> { let database_id = request.database_id.as_str(); self.require_role(database_id, caller, RequiredRole::Owner)?; - self.require_no_pending_credits_operations(database_id)?; + self.require_no_pending_cycles_operations(database_id)?; let status = self.read_index(|conn| load_database_status(conn, database_id))?; if !matches!(status, DatabaseStatus::Pending | DatabaseStatus::Active) { return Err(format!( @@ -1260,11 +1273,11 @@ impl VfsService { }) } - fn require_no_pending_credits_operations(&self, database_id: &str) -> Result<(), String> { + fn require_no_pending_cycles_operations(&self, database_id: &str) -> Result<(), String> { self.read_index(|conn| { let count: i64 = conn .query_row( - "SELECT COUNT(*) FROM database_credit_pending_operations + "SELECT COUNT(*) FROM database_cycle_pending_operations WHERE database_id = ?1", params![database_id], |row| crate::sqlite::row_get(row, 0), @@ -1272,7 +1285,7 @@ impl VfsService { .map_err(|error| error.to_string())?; if count > 0 { return Err(format!( - "database has pending credit operation: {database_id}" + "database has pending cycle operation: {database_id}" )); } Ok(()) @@ -1798,7 +1811,7 @@ impl VfsService { .read_node(&request.database_id, &principal, &request.request_path)? .ok_or_else(|| format!("url ingest request not found: {}", request.request_path))?; validate_url_ingest_request_node(&node, &principal)?; - self.require_database_write_credits_available(&request.database_id) + self.require_database_write_cycles_available(&request.database_id) } pub fn authorize_ops_answer_session( @@ -1856,7 +1869,7 @@ impl VfsService { .ok_or_else(|| "ops answer session is missing or expired".to_string()) })?; self.require_role(&request.database_id, &principal, RequiredRole::Reader)?; - self.require_database_write_credits_available(&request.database_id)?; + self.require_database_write_cycles_available(&request.database_id)?; Ok(OpsAnswerSessionCheckResult { principal }) } @@ -1903,7 +1916,7 @@ impl VfsService { if source.etag != request.source_etag { return Err("source run session source etag is stale".to_string()); } - self.require_database_write_credits_available(&request.database_id)?; + self.require_database_write_cycles_available(&request.database_id)?; Ok(()) } @@ -2640,7 +2653,7 @@ impl VfsService { } #[cfg(not(target_arch = "wasm32"))] -fn run_index_migrations(conn: &mut Connection, config: &CreditsConfig) -> Result<(), String> { +fn run_index_migrations(conn: &mut Connection, config: &CyclesBillingConfig) -> Result<(), String> { if sqlite_master_entry_exists(conn, "table", "schema_migrations")? { let tx = conn.transaction().map_err(|error| error.to_string())?; ensure_existing_index_schema_is_latest(&tx, Some(config))?; @@ -2654,12 +2667,12 @@ fn run_index_migrations(conn: &mut Connection, config: &CreditsConfig) -> Result )); } } - validate_credits_config(config)?; + validate_cycles_billing_config(config)?; let tx = conn.transaction().map_err(|error| error.to_string())?; create_schema_migrations(&tx)?; create_fresh_index_schema(&tx)?; - insert_credits_config(&tx, config)?; - insert_credits_config_version(&tx)?; + insert_cycles_billing_config(&tx, config)?; + insert_cycles_billing_config_version(&tx)?; for &version in INDEX_SCHEMA_VERSIONS { insert_schema_migration_now(&tx, version)?; } @@ -2669,7 +2682,7 @@ fn run_index_migrations(conn: &mut Connection, config: &CreditsConfig) -> Result #[cfg(not(target_arch = "wasm32"))] fn run_index_migrations_for_upgrade( conn: &mut Connection, - config: Option<&CreditsConfig>, + config: Option<&CyclesBillingConfig>, ) -> Result<(), String> { if sqlite_master_entry_exists(conn, "table", "schema_migrations")? { let tx = conn.transaction().map_err(|error| error.to_string())?; @@ -2678,14 +2691,14 @@ fn run_index_migrations_for_upgrade( return Ok(()); } let config = - config.ok_or_else(|| "credits config required for fresh index upgrade".to_string())?; + config.ok_or_else(|| "cycles config required for fresh index upgrade".to_string())?; run_index_migrations(conn, config) } #[cfg(target_arch = "wasm32")] fn run_index_migrations_in_tx( conn: &Transaction<'_>, - config: &CreditsConfig, + config: &CyclesBillingConfig, ) -> Result<(), String> { if wasm_index_table_exists(conn, "schema_migrations")? { ensure_existing_index_schema_is_latest(conn, Some(config))?; @@ -2698,11 +2711,11 @@ fn run_index_migrations_in_tx( )); } } - validate_credits_config(config)?; + validate_cycles_billing_config(config)?; create_schema_migrations(conn)?; create_fresh_index_schema(conn)?; - insert_credits_config(conn, config)?; - insert_credits_config_version(conn)?; + insert_cycles_billing_config(conn, config)?; + insert_cycles_billing_config_version(conn)?; for &version in INDEX_SCHEMA_VERSIONS { insert_schema_migration_zero(conn, version)?; } @@ -2712,28 +2725,29 @@ fn run_index_migrations_in_tx( #[cfg(target_arch = "wasm32")] fn run_index_migrations_in_tx_for_upgrade( conn: &Transaction<'_>, - config: Option<&CreditsConfig>, + config: Option<&CyclesBillingConfig>, ) -> Result<(), String> { if wasm_index_table_exists(conn, "schema_migrations")? { ensure_existing_index_schema_is_latest(conn, config)?; return Ok(()); } let config = - config.ok_or_else(|| "credits config required for fresh index upgrade".to_string())?; + config.ok_or_else(|| "cycles config required for fresh index upgrade".to_string())?; run_index_migrations_in_tx(conn, config) } enum IndexSchemaState { Latest, NeedsPendingOperationStatus, - NeedsCreditUnits, + NeedsCycleUnits, NeedsStorageBilling, + NeedsDirectCycles, Mainnet011, } fn ensure_existing_index_schema_is_latest( conn: &Transaction<'_>, - config: Option<&CreditsConfig>, + config: Option<&CyclesBillingConfig>, ) -> Result<(), String> { match classify_existing_index_schema_state(conn)? { IndexSchemaState::Latest => validate_index_schema(conn), @@ -2741,18 +2755,22 @@ fn ensure_existing_index_schema_is_latest( apply_pending_operation_status_index_migration(conn)?; ensure_existing_index_schema_is_latest(conn, config) } - IndexSchemaState::NeedsCreditUnits => { - apply_credit_units_index_migration(conn)?; + IndexSchemaState::NeedsCycleUnits => { + apply_cycles_index_migration(conn)?; ensure_existing_index_schema_is_latest(conn, config) } IndexSchemaState::NeedsStorageBilling => { apply_storage_billing_index_migration(conn)?; + ensure_existing_index_schema_is_latest(conn, config) + } + IndexSchemaState::NeedsDirectCycles => { + apply_direct_cycles_index_migration(conn)?; validate_index_schema(conn) } IndexSchemaState::Mainnet011 => { let config = config - .ok_or_else(|| "credits config required for first credits upgrade".to_string())?; - validate_credits_config(config)?; + .ok_or_else(|| "cycles config required for first cycles upgrade".to_string())?; + validate_cycles_billing_config(config)?; validate_pre_billing_index_schema(conn)?; apply_mainnet_011_to_latest_index_migration(conn, config)?; validate_index_schema(conn) @@ -2763,17 +2781,31 @@ fn ensure_existing_index_schema_is_latest( fn classify_existing_index_schema_state( conn: &Transaction<'_>, ) -> Result { - if migration_applied_tx(conn, INDEX_SCHEMA_VERSION_CREDITS_CONFIG_VERSION)? { - if !migration_applied_tx(conn, INDEX_SCHEMA_VERSION_CREDIT_PENDING_OPERATION_STATUS)? { + if migration_applied_tx(conn, INDEX_SCHEMA_VERSION_DIRECT_CYCLES)? { + return Ok(IndexSchemaState::Latest); + } + let has_cycles_billing_config_version = + migration_applied_tx(conn, INDEX_SCHEMA_VERSION_CYCLES_BILLING_CONFIG_VERSION)? + || migration_applied_tx(conn, LEGACY_INDEX_SCHEMA_VERSION_CREDITS_CONFIG_VERSION)?; + if has_cycles_billing_config_version { + let has_pending_status = + migration_applied_tx(conn, INDEX_SCHEMA_VERSION_CYCLES_PENDING_OPERATION_STATUS)? + || migration_applied_tx( + conn, + LEGACY_INDEX_SCHEMA_VERSION_CREDIT_PENDING_OPERATION_STATUS, + )?; + if !has_pending_status { return Ok(IndexSchemaState::NeedsPendingOperationStatus); } - if !migration_applied_tx(conn, INDEX_SCHEMA_VERSION_CREDIT_UNITS)? { - return Ok(IndexSchemaState::NeedsCreditUnits); + let has_credit_units = migration_applied_tx(conn, INDEX_SCHEMA_VERSION_CYCLES)? + || migration_applied_tx(conn, LEGACY_INDEX_SCHEMA_VERSION_CREDIT_UNITS)?; + if !has_credit_units { + return Ok(IndexSchemaState::NeedsCycleUnits); } if !migration_applied_tx(conn, INDEX_SCHEMA_VERSION_STORAGE_BILLING)? { return Ok(IndexSchemaState::NeedsStorageBilling); } - return Ok(IndexSchemaState::Latest); + return Ok(IndexSchemaState::NeedsDirectCycles); } if !migration_applied_tx(conn, INDEX_SCHEMA_VERSION_SOURCE_RUN_SESSIONS)? { return Err(format!( @@ -2799,26 +2831,12 @@ fn classify_existing_index_schema_state( fn apply_mainnet_011_to_latest_index_migration( conn: &Transaction<'_>, - config: &CreditsConfig, + config: &CyclesBillingConfig, ) -> Result<(), String> { conn.execute_batch(INDEX_011_TO_LATEST_SQL) .map_err(|error| error.to_string())?; - conn.execute( - "INSERT INTO database_credit_accounts - (database_id, balance_credit_units, suspended_at_ms, storage_charged_at_ms, - created_at_ms, updated_at_ms) - SELECT database_id, 0, 0, NULL, 0, 0 FROM databases", - params![], - ) - .map_err(|error| error.to_string())?; - insert_credits_config(conn, config)?; - insert_credits_config_version(conn)?; - conn.execute( - "UPDATE databases SET status = 'active' WHERE status = 'hot'", - params![], - ) - .map_err(|error| error.to_string())?; - purge_hard_deleted_database_rows(conn)?; + insert_cycles_billing_config(conn, config)?; + insert_cycles_billing_config_version(conn)?; for &version in POST_011_INDEX_SCHEMA_VERSIONS { insert_schema_migration_now(conn, version)?; } @@ -2826,110 +2844,29 @@ fn apply_mainnet_011_to_latest_index_migration( } fn apply_pending_operation_status_index_migration(conn: &Transaction<'_>) -> Result<(), String> { - if !index_column_exists( - conn, - "database_credit_pending_operations", - "operation_status", - )? { - conn.execute( - "ALTER TABLE database_credit_pending_operations - ADD COLUMN operation_status TEXT NOT NULL DEFAULT 'ambiguous'", - params![], - ) + conn.execute_batch(INDEX_021_PENDING_OPERATION_STATUS_SQL) .map_err(|error| error.to_string())?; - } - insert_schema_migration_now(conn, INDEX_SCHEMA_VERSION_CREDIT_PENDING_OPERATION_STATUS) + insert_schema_migration_now(conn, INDEX_SCHEMA_VERSION_CYCLES_PENDING_OPERATION_STATUS) } -fn apply_credit_units_index_migration(conn: &Transaction<'_>) -> Result<(), String> { - conn.execute( - "ALTER TABLE database_credit_accounts - RENAME COLUMN balance_credits TO balance_credit_units", - params![], - ) - .map_err(|error| error.to_string())?; - conn.execute( - "UPDATE database_credit_accounts - SET balance_credit_units = balance_credit_units * 1000", - params![], - ) - .map_err(|error| error.to_string())?; - conn.execute( - "ALTER TABLE database_credit_ledger - RENAME COLUMN amount_credits TO amount_credit_units", - params![], - ) - .map_err(|error| error.to_string())?; - conn.execute( - "ALTER TABLE database_credit_ledger - RENAME COLUMN balance_after_credits TO balance_after_credit_units", - params![], - ) - .map_err(|error| error.to_string())?; - conn.execute( - "ALTER TABLE database_credit_ledger - RENAME COLUMN credits_per_kinic TO credit_units_per_kinic", - params![], - ) - .map_err(|error| error.to_string())?; - conn.execute( - "UPDATE database_credit_ledger - SET amount_credit_units = amount_credit_units * 1000, - balance_after_credit_units = balance_after_credit_units * 1000, - credit_units_per_kinic = credit_units_per_kinic * 1000 - WHERE credit_units_per_kinic IS NOT NULL", - params![], - ) - .map_err(|error| error.to_string())?; - conn.execute( - "UPDATE database_credit_ledger - SET amount_credit_units = amount_credit_units * 1000, - balance_after_credit_units = balance_after_credit_units * 1000 - WHERE credit_units_per_kinic IS NULL", - params![], - ) - .map_err(|error| error.to_string())?; - conn.execute( - "ALTER TABLE database_credit_pending_operations - RENAME COLUMN credits TO credit_units", - params![], - ) - .map_err(|error| error.to_string())?; - conn.execute( - "UPDATE database_credit_pending_operations - SET credit_units = credit_units * 1000", - params![], - ) - .map_err(|error| error.to_string())?; - conn.execute( - "UPDATE credits_config - SET key = 'credit_units_per_kinic', - value = CAST(CAST(value AS INTEGER) * 1000 AS TEXT) - WHERE key = 'credits_per_kinic'", - params![], - ) - .map_err(|error| error.to_string())?; - conn.execute( - "UPDATE credits_config - SET key = 'min_update_credit_units', - value = CAST(CAST(value AS INTEGER) * 1000 AS TEXT) - WHERE key = 'min_update_credits'", - params![], - ) - .map_err(|error| error.to_string())?; - insert_schema_migration_now(conn, INDEX_SCHEMA_VERSION_CREDIT_UNITS) +fn apply_cycles_index_migration(conn: &Transaction<'_>) -> Result<(), String> { + conn.execute_batch(INDEX_022_CREDIT_UNITS_SQL) + .map_err(|error| error.to_string())?; + insert_schema_migration_now(conn, INDEX_SCHEMA_VERSION_CYCLES) } fn apply_storage_billing_index_migration(conn: &Transaction<'_>) -> Result<(), String> { - conn.execute( - "ALTER TABLE database_credit_accounts - ADD COLUMN storage_charged_at_ms INTEGER", - params![], - ) - .map_err(|error| error.to_string())?; + conn.execute_batch(INDEX_023_STORAGE_BILLING_SQL) + .map_err(|error| error.to_string())?; insert_schema_migration_now(conn, INDEX_SCHEMA_VERSION_STORAGE_BILLING) } +fn apply_direct_cycles_index_migration(conn: &Transaction<'_>) -> Result<(), String> { + conn.execute_batch(INDEX_024_DIRECT_CYCLES_SQL) + .map_err(|error| error.to_string())?; + insert_schema_migration_now(conn, INDEX_SCHEMA_VERSION_DIRECT_CYCLES) +} + fn create_schema_migrations(conn: &Transaction<'_>) -> Result<(), String> { conn.execute( "CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)", @@ -2963,29 +2900,26 @@ fn create_fresh_index_schema(conn: &Transaction<'_>) -> Result<(), String> { .map_err(|error| error.to_string()) } -fn default_credits_config() -> CreditsConfig { - CreditsConfig { +fn default_cycles_billing_config() -> CyclesBillingConfig { + CyclesBillingConfig { kinic_ledger_canister_id: "aaaaa-aa".to_string(), sns_governance_id: "rrkah-fqaaa-aaaaa-aaaaq-cai".to_string(), - credit_units_per_kinic: DEFAULT_CREDIT_UNITS_PER_KINIC, - min_update_credit_units: DEFAULT_MIN_UPDATE_CREDIT_UNITS, + cycles_per_kinic: DEFAULT_CYCLES_PER_KINIC, + min_update_cycles: DEFAULT_MIN_UPDATE_CYCLES, } } -fn validate_credits_config(config: &CreditsConfig) -> Result<(), String> { +fn validate_cycles_billing_config(config: &CyclesBillingConfig) -> Result<(), String> { validate_principal_text(&config.kinic_ledger_canister_id)?; validate_principal_text(&config.sns_governance_id)?; - if config.credit_units_per_kinic == 0 { - return Err("credit_units_per_kinic must be positive".to_string()); - } - if config.min_update_credit_units == 0 { - return Err("min_update_credit_units must be positive".to_string()); + if config.cycles_per_kinic == 0 { + return Err("cycles_per_kinic must be positive".to_string()); } - if !KINIC_E8S_PER_TOKEN.is_multiple_of(config.credit_units_per_kinic) { - return Err("credit_units_per_kinic must divide 100000000".to_string()); + if config.min_update_cycles == 0 { + return Err("min_update_cycles must be positive".to_string()); } - amount_to_i64(config.credit_units_per_kinic)?; - amount_to_i64(config.min_update_credit_units)?; + amount_to_i64(config.cycles_per_kinic)?; + amount_to_i64(config.min_update_cycles)?; Ok(()) } @@ -2998,42 +2932,44 @@ fn validate_principal_text(value: &str) -> Result<(), String> { Ok(()) } -fn insert_credits_config(conn: &Transaction<'_>, config: &CreditsConfig) -> Result<(), String> { +fn insert_cycles_billing_config( + conn: &Transaction<'_>, + config: &CyclesBillingConfig, +) -> Result<(), String> { conn.execute( - "INSERT INTO credits_config (key, value) VALUES (?1, ?2)", + "INSERT INTO cycles_billing_config (key, value) VALUES (?1, ?2)", params!["kinic_ledger_canister_id", config.kinic_ledger_canister_id], ) .map_err(|error| error.to_string())?; conn.execute( - "INSERT INTO credits_config (key, value) VALUES (?1, ?2)", + "INSERT INTO cycles_billing_config (key, value) VALUES (?1, ?2)", params!["sns_governance_id", config.sns_governance_id], ) .map_err(|error| error.to_string())?; - set_credits_config_value( - conn, - "credit_units_per_kinic", - config.credit_units_per_kinic, - )?; - set_credits_config_value( - conn, - "min_update_credit_units", - config.min_update_credit_units, - )?; + set_cycles_billing_config_value(conn, "cycles_per_kinic", config.cycles_per_kinic)?; + set_cycles_billing_config_value(conn, "min_update_cycles", config.min_update_cycles)?; Ok(()) } -fn insert_credits_config_version(conn: &Transaction<'_>) -> Result<(), String> { +fn insert_cycles_billing_config_version(conn: &Transaction<'_>) -> Result<(), String> { conn.execute( - "INSERT INTO credits_config (key, value) VALUES (?1, ?2)", - params!["config_version", DEFAULT_CREDITS_CONFIG_VERSION.to_string()], + "INSERT INTO cycles_billing_config (key, value) VALUES (?1, ?2)", + params![ + "config_version", + DEFAULT_CYCLES_BILLING_CONFIG_VERSION.to_string() + ], ) .map_err(|error| error.to_string())?; Ok(()) } -fn set_credits_config_value(conn: &Transaction<'_>, key: &str, value: u64) -> Result<(), String> { +fn set_cycles_billing_config_value( + conn: &Transaction<'_>, + key: &str, + value: u64, +) -> Result<(), String> { conn.execute( - "INSERT INTO credits_config (key, value) + "INSERT INTO cycles_billing_config (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value", params![key, value.to_string()], @@ -3060,12 +2996,13 @@ const INDEX_SCHEMA_VERSIONS: &[&str] = &[ INDEX_SCHEMA_VERSION_BILLING_PENDING_LEDGER_DETAILS, INDEX_SCHEMA_VERSION_ACTIVE_STATUS, INDEX_SCHEMA_VERSION_HARD_DELETE_DATABASES, - INDEX_SCHEMA_VERSION_CREDIT_LEDGER_ONLY, - INDEX_SCHEMA_VERSION_FIXED_CYCLES_PER_CREDIT_UNIT, - INDEX_SCHEMA_VERSION_CREDITS_CONFIG_VERSION, - INDEX_SCHEMA_VERSION_CREDIT_PENDING_OPERATION_STATUS, - INDEX_SCHEMA_VERSION_CREDIT_UNITS, + INDEX_SCHEMA_VERSION_CYCLES_LEDGER_ONLY, + INDEX_SCHEMA_VERSION_FIXED_CYCLES_ACCOUNTING, + INDEX_SCHEMA_VERSION_CYCLES_BILLING_CONFIG_VERSION, + INDEX_SCHEMA_VERSION_CYCLES_PENDING_OPERATION_STATUS, + INDEX_SCHEMA_VERSION_CYCLES, INDEX_SCHEMA_VERSION_STORAGE_BILLING, + INDEX_SCHEMA_VERSION_DIRECT_CYCLES, ]; const INDEX_SCHEMA_TABLES_WITHOUT_MIGRATIONS: &[&str] = &[ @@ -3081,6 +3018,10 @@ const INDEX_SCHEMA_TABLES_WITHOUT_MIGRATIONS: &[&str] = &[ "database_credit_ledger", "database_credit_pending_operations", "credits_config", + "database_cycle_accounts", + "database_cycle_ledger", + "database_cycle_pending_operations", + "cycles_billing_config", ]; const POST_011_INDEX_SCHEMA_VERSIONS: &[&str] = &[ @@ -3090,12 +3031,13 @@ const POST_011_INDEX_SCHEMA_VERSIONS: &[&str] = &[ INDEX_SCHEMA_VERSION_BILLING_PENDING_LEDGER_DETAILS, INDEX_SCHEMA_VERSION_ACTIVE_STATUS, INDEX_SCHEMA_VERSION_HARD_DELETE_DATABASES, - INDEX_SCHEMA_VERSION_CREDIT_LEDGER_ONLY, - INDEX_SCHEMA_VERSION_FIXED_CYCLES_PER_CREDIT_UNIT, - INDEX_SCHEMA_VERSION_CREDITS_CONFIG_VERSION, - INDEX_SCHEMA_VERSION_CREDIT_PENDING_OPERATION_STATUS, - INDEX_SCHEMA_VERSION_CREDIT_UNITS, + INDEX_SCHEMA_VERSION_CYCLES_LEDGER_ONLY, + INDEX_SCHEMA_VERSION_FIXED_CYCLES_ACCOUNTING, + INDEX_SCHEMA_VERSION_CYCLES_BILLING_CONFIG_VERSION, + INDEX_SCHEMA_VERSION_CYCLES_PENDING_OPERATION_STATUS, + INDEX_SCHEMA_VERSION_CYCLES, INDEX_SCHEMA_VERSION_STORAGE_BILLING, + INDEX_SCHEMA_VERSION_DIRECT_CYCLES, ]; const POST_011_INDEX_SCHEMA_TABLES: &[&str] = &[ @@ -3103,6 +3045,10 @@ const POST_011_INDEX_SCHEMA_TABLES: &[&str] = &[ "database_credit_ledger", "database_credit_pending_operations", "credits_config", + "database_cycle_accounts", + "database_cycle_ledger", + "database_cycle_pending_operations", + "cycles_billing_config", ]; fn validate_pre_billing_index_schema(conn: &Transaction<'_>) -> Result<(), String> { @@ -3231,10 +3177,10 @@ fn validate_index_schema(conn: &Transaction<'_>) -> Result<(), String> { "databases", "database_restore_chunks", "database_restore_sessions", - "database_credit_accounts", - "database_credit_ledger", - "database_credit_pending_operations", - "credits_config", + "database_cycle_accounts", + "database_cycle_ledger", + "database_cycle_pending_operations", + "cycles_billing_config", ] { if !tx_sqlite_master_entry_exists(conn, "table", table)? { return Err(format!("unsupported index schema: missing table {table}")); @@ -3266,39 +3212,39 @@ fn validate_index_schema(conn: &Transaction<'_>) -> Result<(), String> { &["database_id", "offset_bytes", "end_bytes", "bytes"][..], ), ( - "database_credit_accounts", + "database_cycle_accounts", &[ "database_id", - "balance_credit_units", + "balance_cycles", "suspended_at_ms", "storage_charged_at_ms", ][..], ), ( - "database_credit_ledger", + "database_cycle_ledger", &[ "entry_id", "database_id", "kind", - "amount_credit_units", - "balance_after_credit_units", + "amount_cycles", + "balance_after_cycles", "payment_amount_e8s", "caller", "method", "cycles_delta", - "credit_units_per_kinic", + "cycles_per_kinic", "ledger_block_index", "created_at_ms", ][..], ), ( - "database_credit_pending_operations", + "database_cycle_pending_operations", &[ "operation_id", "database_id", "kind", "caller", - "credit_units", + "cycles", "payment_amount_e8s", "from_owner", "from_subaccount", @@ -3322,8 +3268,8 @@ fn validate_index_schema(conn: &Transaction<'_>) -> Result<(), String> { for index in [ "databases_active_mount_id_idx", "database_restore_chunks_database_id_idx", - "database_credit_ledger_database_idx", - "database_credit_pending_operations_database_idx", + "database_cycle_ledger_database_idx", + "database_cycle_pending_operations_database_idx", ] { if !tx_sqlite_master_entry_exists(conn, "index", index)? { return Err(format!("unsupported index schema: missing index {index}")); @@ -3389,30 +3335,33 @@ fn sqlite_master_entry_exists( .map_err(|error| error.to_string()) } -fn load_credits_config(conn: &Connection) -> Result { - Ok(CreditsConfig { - kinic_ledger_canister_id: load_credits_config_text(conn, "kinic_ledger_canister_id")?, - sns_governance_id: load_credits_config_text(conn, "sns_governance_id")?, - credit_units_per_kinic: load_credits_config_u64(conn, "credit_units_per_kinic")?, - min_update_credit_units: load_credits_config_u64(conn, "min_update_credit_units")?, +fn load_cycles_billing_config(conn: &Connection) -> Result { + Ok(CyclesBillingConfig { + kinic_ledger_canister_id: load_cycles_billing_config_text( + conn, + "kinic_ledger_canister_id", + )?, + sns_governance_id: load_cycles_billing_config_text(conn, "sns_governance_id")?, + cycles_per_kinic: load_cycles_billing_config_u64(conn, "cycles_per_kinic")?, + min_update_cycles: load_cycles_billing_config_u64(conn, "min_update_cycles")?, }) } -fn load_credits_config_version(conn: &Connection) -> Result { - load_credits_config_u64(conn, "config_version") +fn load_cycles_billing_config_version(conn: &Connection) -> Result { + load_cycles_billing_config_u64(conn, "config_version") } -fn load_credits_config_text(conn: &Connection, key: &str) -> Result { +fn load_cycles_billing_config_text(conn: &Connection, key: &str) -> Result { conn.query_row( - "SELECT value FROM credits_config WHERE key = ?1", + "SELECT value FROM cycles_billing_config WHERE key = ?1", params![key], |row| crate::sqlite::row_get(row, 0), ) .map_err(|error| error.to_string()) } -fn load_credits_config_u64(conn: &Connection, key: &str) -> Result { - let value = load_credits_config_text(conn, key)?; +fn load_cycles_billing_config_u64(conn: &Connection, key: &str) -> Result { + let value = load_cycles_billing_config_text(conn, key)?; value.parse::().map_err(|error| error.to_string()) } @@ -3459,25 +3408,34 @@ fn amount_to_i64(amount: u64) -> Result { i64::try_from(amount).map_err(|_| "amount exceeds i64 limit".to_string()) } -fn credit_units_to_i64(credit_units: u64) -> Result { - let credit_units = - i64::try_from(credit_units).map_err(|_| "credit units exceeds i64 limit".to_string())?; - if credit_units <= 0 { - return Err("credit purchase credit units must be positive".to_string()); +fn cycles_to_i64(cycles: u64) -> Result { + let cycles = i64::try_from(cycles).map_err(|_| "cycles exceeds i64 limit".to_string())?; + if cycles <= 0 { + return Err("cycles purchase cycles must be positive".to_string()); } - Ok(credit_units) + Ok(cycles) } -pub fn payment_amount_e8s_for_credit_units( - credit_units: u64, - config: &CreditsConfig, +pub fn cycles_for_payment_amount_e8s( + payment_amount_e8s: u64, + config: &CyclesBillingConfig, ) -> Result { - let e8s_per_credit_unit = KINIC_E8S_PER_TOKEN - .checked_div(config.credit_units_per_kinic) - .ok_or_else(|| "credit_units_per_kinic must be positive".to_string())?; - credit_units - .checked_mul(e8s_per_credit_unit) - .ok_or_else(|| "credit purchase payment amount overflow".to_string()) + if payment_amount_e8s == 0 { + return Err("cycles purchase payment amount must be positive".to_string()); + } + if config.cycles_per_kinic == 0 { + return Err("cycles_per_kinic must be positive".to_string()); + } + let cycles = u128::from(payment_amount_e8s) + .checked_mul(u128::from(config.cycles_per_kinic)) + .ok_or_else(|| "cycles purchase amount overflow".to_string())? + / u128::from(KINIC_E8S_PER_TOKEN); + let cycles = + u64::try_from(cycles).map_err(|_| "cycles purchase amount exceeds u64".to_string())?; + if cycles == 0 { + return Err("cycles purchase amount is too small".to_string()); + } + Ok(cycles) } fn millis_to_nanos(value: i64) -> Result { @@ -3497,10 +3455,10 @@ fn checked_balance_add(balance: i64, amount: i64) -> Result { Ok(next) } -fn validate_database_credit_purchase_for_conn( +fn validate_database_cycles_purchase_for_conn( conn: &Connection, database_id: &str, - credit_units: i64, + cycles: i64, ) -> Result<(), String> { let status = load_database_status(conn, database_id)?; if !database_has_owner(conn, database_id)? { @@ -3508,39 +3466,39 @@ fn validate_database_credit_purchase_for_conn( } let balance: i64 = conn .query_row( - "SELECT balance_credit_units FROM database_credit_accounts WHERE database_id = ?1", + "SELECT balance_cycles FROM database_cycle_accounts WHERE database_id = ?1", params![database_id], |row| crate::sqlite::row_get(row, 0), ) .optional() .map_err(|error| error.to_string())? - .ok_or_else(|| format!("database credits account not found: {database_id}"))?; - let pending_credit_purchase: i64 = conn + .ok_or_else(|| format!("database cycles account not found: {database_id}"))?; + let pending_cycles_purchase: i64 = conn .query_row( - "SELECT COALESCE(SUM(credit_units), 0) - FROM database_credit_pending_operations - WHERE database_id = ?1 AND kind = 'credit_purchase'", + "SELECT COALESCE(SUM(cycles), 0) + FROM database_cycle_pending_operations + WHERE database_id = ?1 AND kind = 'cycles_purchase'", params![database_id], |row| crate::sqlite::row_get(row, 0), ) .map_err(|error| error.to_string())?; - if status == DatabaseStatus::Pending && pending_credit_purchase > 0 { + if status == DatabaseStatus::Pending && pending_cycles_purchase > 0 { return Err(format!("database activation is pending: {database_id}")); } - let reserved = checked_balance_add(balance, pending_credit_purchase)?; - checked_balance_add(reserved, credit_units)?; + let reserved = checked_balance_add(balance, pending_cycles_purchase)?; + checked_balance_add(reserved, cycles)?; Ok(()) } -fn require_database_write_credits_available_for_conn( +fn require_database_write_cycles_available_for_conn( conn: &Connection, database_id: &str, - config: &CreditsConfig, + config: &CyclesBillingConfig, ) -> Result<(), String> { let (balance, suspended_at_ms): (i64, Option) = conn .query_row( - "SELECT balance_credit_units, suspended_at_ms - FROM database_credit_accounts + "SELECT balance_cycles, suspended_at_ms + FROM database_cycle_accounts WHERE database_id = ?1", params![database_id], |row| { @@ -3552,37 +3510,21 @@ fn require_database_write_credits_available_for_conn( ) .optional() .map_err(|error| error.to_string())? - .ok_or_else(|| format!("database credits account not found: {database_id}"))?; + .ok_or_else(|| format!("database cycles account not found: {database_id}"))?; if suspended_at_ms.is_some() { - return Err(format!("database credits are suspended: {database_id}")); + return Err(format!("database cycles are suspended: {database_id}")); } - if balance < credit_units_to_i64(config.min_update_credit_units)? { - return Err(format!( - "database credits balance is too low: {database_id}" - )); - } - Ok(()) -} - -fn purge_hard_deleted_database_rows(conn: &Connection) -> Result<(), String> { - let mut stmt = conn - .prepare("SELECT database_id FROM databases WHERE status = 'deleted'") - .map_err(|error| error.to_string())?; - let database_ids = crate::sqlite::query_map(&mut stmt, params![], |row| { - crate::sqlite::row_get::(row, 0) - }) - .map_err(|error| error.to_string())?; - for database_id in database_ids { - delete_database_index_rows(conn, &database_id)?; + if balance < cycles_to_i64(config.min_update_cycles)? { + return Err(format!("database cycles balance is too low: {database_id}")); } Ok(()) } fn delete_database_index_rows(conn: &Connection, database_id: &str) -> Result<(), String> { for table in [ - "database_credit_pending_operations", - "database_credit_ledger", - "database_credit_accounts", + "database_cycle_pending_operations", + "database_cycle_ledger", + "database_cycle_accounts", "database_members", "database_restore_chunks", "database_restore_sessions", @@ -3628,7 +3570,7 @@ fn complete_pending_database_activation( ) .map_err(|error| error.to_string())?; conn.execute( - "UPDATE database_credit_accounts + "UPDATE database_cycle_accounts SET storage_charged_at_ms = COALESCE(storage_charged_at_ms, ?2), updated_at_ms = ?2 WHERE database_id = ?1", @@ -3640,23 +3582,23 @@ fn complete_pending_database_activation( fn database_balance_for_update(conn: &Transaction<'_>, database_id: &str) -> Result { conn.query_row( - "SELECT balance_credit_units FROM database_credit_accounts WHERE database_id = ?1", + "SELECT balance_cycles FROM database_cycle_accounts WHERE database_id = ?1", params![database_id], |row| crate::sqlite::row_get(row, 0), ) .optional() .map_err(|error| error.to_string())? - .ok_or_else(|| format!("database credits account not found: {database_id}")) + .ok_or_else(|| format!("database cycles account not found: {database_id}")) } -fn update_database_credits_balance( +fn update_database_cycles_balance( conn: &Transaction<'_>, database_id: &str, balance: i64, - config: &CreditsConfig, + config: &CyclesBillingConfig, now: i64, ) -> Result<(), String> { - let min = credit_units_to_i64(config.min_update_credit_units)?; + let min = cycles_to_i64(config.min_update_cycles)?; let suspended_at_ms = if balance >= min { None } else { Some(now) }; let values = vec![ crate::sqlite::text_value(database_id), @@ -3666,8 +3608,8 @@ fn update_database_credits_balance( ]; crate::sqlite::execute_values( conn, - "UPDATE database_credit_accounts - SET balance_credit_units = ?2, suspended_at_ms = ?3, updated_at_ms = ?4 + "UPDATE database_cycle_accounts + SET balance_cycles = ?2, suspended_at_ms = ?3, updated_at_ms = ?4 WHERE database_id = ?1", &values, ) @@ -3675,18 +3617,18 @@ fn update_database_credits_balance( Ok(()) } -fn load_storage_credit_account( +fn load_storage_cycle_account( conn: &Connection, database_id: &str, -) -> Result { +) -> Result { conn.query_row( - "SELECT balance_credit_units, suspended_at_ms, storage_charged_at_ms - FROM database_credit_accounts + "SELECT balance_cycles, suspended_at_ms, storage_charged_at_ms + FROM database_cycle_accounts WHERE database_id = ?1", params![database_id], |row| { - Ok(StorageCreditAccount { - balance_credit_units: crate::sqlite::row_get(row, 0)?, + Ok(StorageCycleAccount { + balance_cycles: crate::sqlite::row_get(row, 0)?, suspended_at_ms: crate::sqlite::row_get(row, 1)?, storage_charged_at_ms: crate::sqlite::row_get(row, 2)?, }) @@ -3694,7 +3636,7 @@ fn load_storage_credit_account( ) .optional() .map_err(|error| error.to_string())? - .ok_or_else(|| format!("database credits account not found: {database_id}")) + .ok_or_else(|| format!("database cycles account not found: {database_id}")) } fn update_database_logical_size( @@ -3715,22 +3657,22 @@ fn update_database_logical_size( fn update_database_storage_account( conn: &Transaction<'_>, database_id: &str, - balance_credit_units: i64, + balance_cycles: i64, suspended_at_ms: Option, storage_charged_at_ms: i64, now: i64, ) -> Result<(), String> { let values = vec![ crate::sqlite::text_value(database_id), - crate::sqlite::integer_value(balance_credit_units), + crate::sqlite::integer_value(balance_cycles), crate::sqlite::nullable_integer_value(suspended_at_ms), crate::sqlite::integer_value(storage_charged_at_ms), crate::sqlite::integer_value(now), ]; crate::sqlite::execute_values( conn, - "UPDATE database_credit_accounts - SET balance_credit_units = ?2, + "UPDATE database_cycle_accounts + SET balance_cycles = ?2, suspended_at_ms = ?3, storage_charged_at_ms = ?4, updated_at_ms = ?5 @@ -3741,12 +3683,12 @@ fn update_database_storage_account( Ok(()) } -struct PendingCreditsOperation { +struct PendingCyclesOperation { operation_id: u64, database_id: String, kind: String, caller: String, - credit_units: i64, + cycles: i64, payment_amount_e8s: i64, from_owner: Option, from_subaccount: Option>, @@ -3758,7 +3700,7 @@ struct PendingCreditsOperation { created_at_ms: i64, } -struct PendingCreditsLedgerDetails<'a> { +struct PendingCyclesLedgerDetails<'a> { from_owner: &'a str, from_subaccount: Option<&'a [u8]>, to_owner: &'a str, @@ -3767,34 +3709,34 @@ struct PendingCreditsLedgerDetails<'a> { ledger_created_at_time_ns: i64, } -struct PendingCreditsOperationInsert<'a> { +struct PendingCyclesOperationInsert<'a> { database_id: &'a str, kind: &'a str, caller: &'a str, - credit_units: i64, + cycles: i64, payment_amount_e8s: i64, - ledger: PendingCreditsLedgerDetails<'a>, + ledger: PendingCyclesLedgerDetails<'a>, operation_status: &'a str, now: i64, } -struct PendingCreditsOperationMatch<'a> { +struct PendingCyclesOperationMatch<'a> { operation_id: u64, database_id: &'a str, kind: &'a str, caller: &'a str, - credit_units: i64, + cycles: i64, } -fn insert_pending_credits_operation( +fn insert_pending_cycles_operation( conn: &Transaction<'_>, - operation: PendingCreditsOperationInsert<'_>, + operation: PendingCyclesOperationInsert<'_>, ) -> Result { let values = vec![ crate::sqlite::text_value(operation.database_id), crate::sqlite::text_value(operation.kind), crate::sqlite::text_value(operation.caller), - crate::sqlite::integer_value(operation.credit_units), + crate::sqlite::integer_value(operation.cycles), crate::sqlite::integer_value(operation.payment_amount_e8s), crate::sqlite::text_value(operation.ledger.from_owner), crate::sqlite::nullable_blob_value(operation.ledger.from_subaccount.map(Vec::from)), @@ -3807,8 +3749,8 @@ fn insert_pending_credits_operation( ]; crate::sqlite::execute_values( conn, - "INSERT INTO database_credit_pending_operations - (database_id, kind, caller, credit_units, payment_amount_e8s, from_owner, from_subaccount, + "INSERT INTO database_cycle_pending_operations + (database_id, kind, caller, cycles, payment_amount_e8s, from_owner, from_subaccount, to_owner, to_subaccount, ledger_fee_e8s, ledger_created_at_time_ns, operation_status, created_at_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", @@ -3819,27 +3761,27 @@ fn insert_pending_credits_operation( u64::try_from(operation_id).map_err(|error| error.to_string()) } -fn load_pending_credits_operation( +fn load_pending_cycles_operation( conn: &Transaction<'_>, operation_id: u64, -) -> Result { +) -> Result { let operation_id = i64::try_from(operation_id).map_err(|error| error.to_string())?; conn.query_row( - "SELECT operation_id, database_id, kind, caller, credit_units, payment_amount_e8s, + "SELECT operation_id, database_id, kind, caller, cycles, payment_amount_e8s, from_owner, from_subaccount, to_owner, to_subaccount, ledger_fee_e8s, ledger_created_at_time_ns, operation_status, created_at_ms - FROM database_credit_pending_operations + FROM database_cycle_pending_operations WHERE operation_id = ?1", params![operation_id], - map_pending_credits_operation, + map_pending_cycles_operation, ) .optional() .map_err(|error| error.to_string())? - .ok_or_else(|| "pending credit operation not found".to_string()) + .ok_or_else(|| "pending cycle operation not found".to_string()) } fn require_pending_operation_status( - operation: &PendingCreditsOperation, + operation: &PendingCyclesOperation, allowed: &[&str], action: &str, ) -> Result<(), String> { @@ -3850,58 +3792,58 @@ fn require_pending_operation_status( return Ok(()); } Err(format!( - "cannot {action}; credit purchase operation is {}", + "cannot {action}; cycle purchase operation is {}", operation.operation_status )) } fn require_pending_database_kind( - operation: &PendingCreditsOperation, + operation: &PendingCyclesOperation, database_id: &str, kind: &str, ) -> Result<(), String> { if operation.database_id != database_id || operation.kind != kind { - return Err("pending credit operation mismatch".to_string()); + return Err("pending cycle operation mismatch".to_string()); } Ok(()) } -fn load_required_pending_credits_operation( +fn load_required_pending_cycles_operation( conn: &Transaction<'_>, - expected: PendingCreditsOperationMatch<'_>, -) -> Result { - let operation = load_pending_credits_operation(conn, expected.operation_id)?; + expected: PendingCyclesOperationMatch<'_>, +) -> Result { + let operation = load_pending_cycles_operation(conn, expected.operation_id)?; if operation.database_id != expected.database_id || operation.kind != expected.kind || operation.caller != expected.caller - || operation.credit_units != expected.credit_units + || operation.cycles != expected.cycles { - return Err("pending credit operation mismatch".to_string()); + return Err("pending cycle operation mismatch".to_string()); } Ok(operation) } -fn delete_pending_credits_operation( +fn delete_pending_cycles_operation( conn: &Transaction<'_>, operation_id: u64, ) -> Result<(), String> { let operation_id = i64::try_from(operation_id).map_err(|error| error.to_string())?; conn.execute( - "DELETE FROM database_credit_pending_operations WHERE operation_id = ?1", + "DELETE FROM database_cycle_pending_operations WHERE operation_id = ?1", params![operation_id], ) .map_err(|error| error.to_string())?; Ok(()) } -fn update_pending_credits_operation_status( +fn update_pending_cycles_operation_status( conn: &Transaction<'_>, operation_id: u64, status: &str, ) -> Result<(), String> { let operation_id = i64::try_from(operation_id).map_err(|error| error.to_string())?; conn.execute( - "UPDATE database_credit_pending_operations + "UPDATE database_cycle_pending_operations SET operation_status = ?2 WHERE operation_id = ?1", params![operation_id, status], @@ -3910,16 +3852,16 @@ fn update_pending_credits_operation_status( Ok(()) } -fn map_pending_credits_operation( +fn map_pending_cycles_operation( row: &crate::sqlite::Row<'_>, -) -> crate::sqlite::Result { +) -> crate::sqlite::Result { let operation_id: i64 = crate::sqlite::row_get(row, 0)?; - Ok(PendingCreditsOperation { + Ok(PendingCyclesOperation { operation_id: operation_id.max(0) as u64, database_id: crate::sqlite::row_get(row, 1)?, kind: crate::sqlite::row_get(row, 2)?, caller: crate::sqlite::row_get(row, 3)?, - credit_units: crate::sqlite::row_get(row, 4)?, + cycles: crate::sqlite::row_get(row, 4)?, payment_amount_e8s: crate::sqlite::row_get(row, 5)?, from_owner: crate::sqlite::row_get(row, 6)?, from_subaccount: crate::sqlite::row_get(row, 7)?, @@ -3932,22 +3874,22 @@ fn map_pending_credits_operation( }) } -fn map_database_credits_pending_operation( +fn map_database_cycles_pending_operation( row: &crate::sqlite::Row<'_>, -) -> crate::sqlite::Result { - let operation = map_pending_credits_operation(row)?; - Ok(pending_credits_operation_to_public(operation)) +) -> crate::sqlite::Result { + let operation = map_pending_cycles_operation(row)?; + Ok(pending_cycles_operation_to_public(operation)) } -fn pending_credits_operation_to_public( - operation: PendingCreditsOperation, -) -> DatabaseCreditPendingOperation { - DatabaseCreditPendingOperation { +fn pending_cycles_operation_to_public( + operation: PendingCyclesOperation, +) -> DatabaseCyclePendingOperation { + DatabaseCyclePendingOperation { operation_id: operation.operation_id, database_id: operation.database_id, kind: operation.kind, caller: operation.caller, - credit_units: operation.credit_units, + cycles: operation.cycles, payment_amount_e8s: operation.payment_amount_e8s, from_owner: operation.from_owner, from_subaccount: operation.from_subaccount, @@ -3962,13 +3904,13 @@ fn pending_credits_operation_to_public( struct DatabaseLedgerInsert<'a> { database_id: &'a str, kind: &'a str, - amount_credit_units: i64, - balance_after_credit_units: i64, + amount_cycles: i64, + balance_after_cycles: i64, payment_amount_e8s: Option, caller: &'a str, method: Option<&'a str>, cycles_delta: Option, - config: Option<&'a CreditsConfig>, + config: Option<&'a CyclesBillingConfig>, ledger_block_index: Option, now: i64, } @@ -3979,7 +3921,7 @@ struct DatabaseCharge<'a> { method: &'a str, cycles_delta: u128, now: i64, - config: &'a CreditsConfig, + config: &'a CyclesBillingConfig, computed_charge: i64, } @@ -3988,11 +3930,11 @@ struct StorageChargeInput<'a> { caller: &'a str, size_bytes: u64, now: i64, - config: &'a CreditsConfig, + config: &'a CyclesBillingConfig, } -struct StorageCreditAccount { - balance_credit_units: i64, +struct StorageCycleAccount { + balance_cycles: i64, suspended_at_ms: Option, storage_charged_at_ms: Option, } @@ -4004,8 +3946,8 @@ fn insert_database_ledger( let values = vec![ crate::sqlite::text_value(entry.database_id), crate::sqlite::text_value(entry.kind), - crate::sqlite::integer_value(entry.amount_credit_units), - crate::sqlite::integer_value(entry.balance_after_credit_units), + crate::sqlite::integer_value(entry.amount_cycles), + crate::sqlite::integer_value(entry.balance_after_cycles), crate::sqlite::nullable_integer_value(entry.payment_amount_e8s), crate::sqlite::text_value(entry.caller), entry @@ -4020,7 +3962,7 @@ fn insert_database_ledger( crate::sqlite::nullable_integer_value( entry .config - .map(|config| i64::try_from(config.credit_units_per_kinic).unwrap_or(i64::MAX)), + .map(|config| i64::try_from(config.cycles_per_kinic).unwrap_or(i64::MAX)), ), crate::sqlite::nullable_integer_value( entry @@ -4031,9 +3973,9 @@ fn insert_database_ledger( ]; crate::sqlite::execute_values( conn, - "INSERT INTO database_credit_ledger - (database_id, kind, amount_credit_units, balance_after_credit_units, payment_amount_e8s, - caller, method, cycles_delta, credit_units_per_kinic, ledger_block_index, created_at_ms) + "INSERT INTO database_cycle_ledger + (database_id, kind, amount_cycles, balance_after_cycles, payment_amount_e8s, + caller, method, cycles_delta, cycles_per_kinic, ledger_block_index, created_at_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", &values, ) @@ -4045,13 +3987,13 @@ fn settle_database_storage_charge_in_tx( tx: &Transaction<'_>, input: StorageChargeInput<'_>, ) -> Result<(), String> { - let account = load_storage_credit_account(tx, input.database_id)?; + let account = load_storage_cycle_account(tx, input.database_id)?; update_database_logical_size(tx, input.database_id, input.size_bytes)?; let Some(charged_at_ms) = account.storage_charged_at_ms else { update_database_storage_account( tx, input.database_id, - account.balance_credit_units, + account.balance_cycles, account.suspended_at_ms, input.now, input.now, @@ -4067,21 +4009,20 @@ fn settle_database_storage_charge_in_tx( update_database_storage_account( tx, input.database_id, - account.balance_credit_units, + account.balance_cycles, account.suspended_at_ms, input.now, input.now, )?; return Ok(()); } - let charge_units_u128 = storage_cycles.div_ceil(CYCLES_PER_CREDIT_UNIT); - let charge_units = i64::try_from(charge_units_u128) + let charge_cycles = i64::try_from(storage_cycles) .map_err(|_| "storage charge exceeds i64 limit".to_string())?; - let paid_units = account.balance_credit_units.min(charge_units).max(0); - let next_balance = account.balance_credit_units.saturating_sub(paid_units); - let min_balance = credit_units_to_i64(input.config.min_update_credit_units)?; - let should_suspend = paid_units < charge_units || next_balance < min_balance; + let paid_cycles = account.balance_cycles.min(charge_cycles).max(0); + let next_balance = account.balance_cycles.saturating_sub(paid_cycles); + let min_balance = cycles_to_i64(input.config.min_update_cycles)?; + let should_suspend = paid_cycles < charge_cycles || next_balance < min_balance; let suspended_at_ms = if should_suspend { account.suspended_at_ms.or(Some(input.now)) } else { @@ -4096,14 +4037,14 @@ fn settle_database_storage_charge_in_tx( input.now, input.now, )?; - if paid_units > 0 { + if paid_cycles > 0 { insert_database_ledger( tx, DatabaseLedgerInsert { database_id: input.database_id, kind: "storage_charge", - amount_credit_units: -paid_units, - balance_after_credit_units: next_balance, + amount_cycles: -paid_cycles, + balance_after_cycles: next_balance, payment_amount_e8s: None, caller: input.caller, method: Some("storage_billing"), @@ -4120,8 +4061,8 @@ fn settle_database_storage_charge_in_tx( DatabaseLedgerInsert { database_id: input.database_id, kind: "suspend", - amount_credit_units: 0, - balance_after_credit_units: next_balance, + amount_cycles: 0, + balance_after_cycles: next_balance, payment_amount_e8s: None, caller: input.caller, method: Some("storage_billing"), @@ -4142,14 +4083,14 @@ fn charge_database_update_in_tx( let balance = database_balance_for_update(tx, charge.database_id)?; let amount = balance.min(charge.computed_charge); let next = balance - amount; - update_database_credits_balance(tx, charge.database_id, next, charge.config, charge.now)?; + update_database_cycles_balance(tx, charge.database_id, next, charge.config, charge.now)?; insert_database_ledger( tx, DatabaseLedgerInsert { database_id: charge.database_id, kind: "charge", - amount_credit_units: -amount, - balance_after_credit_units: next, + amount_cycles: -amount, + balance_after_cycles: next, payment_amount_e8s: None, caller: charge.caller, method: Some(charge.method), @@ -4165,8 +4106,8 @@ fn charge_database_update_in_tx( DatabaseLedgerInsert { database_id: charge.database_id, kind: "suspend", - amount_credit_units: 0, - balance_after_credit_units: next, + amount_cycles: 0, + balance_after_cycles: next, payment_amount_e8s: None, caller: charge.caller, method: Some(charge.method), @@ -4181,8 +4122,7 @@ fn charge_database_update_in_tx( } fn compute_update_charge(cycles_delta: u128) -> Result { - let charge = cycles_delta.div_ceil(CYCLES_PER_CREDIT_UNIT); - i64::try_from(charge).map_err(|_| "cycle charge exceeds i64 limit".to_string()) + i64::try_from(cycles_delta).map_err(|_| "cycle charge exceeds i64 limit".to_string()) } fn compute_storage_charge_cycles(size_bytes: u64, elapsed_ms: i64) -> Result { @@ -4204,26 +4144,26 @@ fn page_limit(limit: u32) -> u32 { limit.clamp(1, 100) } -fn map_database_credits_entry( +fn map_database_cycles_entry( row: &crate::sqlite::Row<'_>, -) -> crate::sqlite::Result { +) -> crate::sqlite::Result { let entry_id: i64 = crate::sqlite::row_get(row, 0)?; let balance_after: i64 = crate::sqlite::row_get(row, 4)?; let payment_amount_e8s: Option = crate::sqlite::row_get(row, 5)?; let cycles_delta: Option = crate::sqlite::row_get(row, 8)?; - let credit_units_per_kinic: Option = crate::sqlite::row_get(row, 9)?; + let cycles_per_kinic: Option = crate::sqlite::row_get(row, 9)?; let ledger_block_index: Option = crate::sqlite::row_get(row, 10)?; - Ok(DatabaseCreditEntry { + Ok(DatabaseCycleEntry { entry_id: entry_id.max(0) as u64, database_id: crate::sqlite::row_get(row, 1)?, kind: crate::sqlite::row_get(row, 2)?, - amount_credit_units: crate::sqlite::row_get(row, 3)?, - balance_after_credit_units: balance_after.max(0) as u64, + amount_cycles: crate::sqlite::row_get(row, 3)?, + balance_after_cycles: balance_after.max(0) as u64, payment_amount_e8s: payment_amount_e8s.map(|value| value.max(0) as u64), caller: crate::sqlite::row_get(row, 6)?, method: crate::sqlite::row_get(row, 7)?, cycles_delta: cycles_delta.map(|value| value.max(0) as u64), - credit_units_per_kinic: credit_units_per_kinic.map(|value| value.max(0) as u64), + cycles_per_kinic: cycles_per_kinic.map(|value| value.max(0) as u64), ledger_block_index: ledger_block_index.map(|value| value.max(0) as u64), created_at_ms: crate::sqlite::row_get(row, 11)?, }) @@ -4858,26 +4798,26 @@ fn load_database_summaries_for_caller( let mut stmt = conn .prepare( "SELECT d.database_id, d.name, d.status, m.role, d.logical_size_bytes, - COALESCE(b.balance_credit_units, 0), b.suspended_at_ms, + COALESCE(b.balance_cycles, 0), b.suspended_at_ms, d.archived_at_ms FROM databases d INNER JOIN database_members m ON m.database_id = d.database_id - LEFT JOIN database_credit_accounts b ON b.database_id = d.database_id + LEFT JOIN database_cycle_accounts b ON b.database_id = d.database_id WHERE m.principal = ?1 ORDER BY d.database_id ASC", ) .map_err(|error| error.to_string())?; crate::sqlite::query_map(&mut stmt, params![caller], |row| { let logical_size_bytes: i64 = crate::sqlite::row_get(row, 4)?; - let credit_units_balance: i64 = crate::sqlite::row_get(row, 5)?; + let cycles_balance: i64 = crate::sqlite::row_get(row, 5)?; Ok(DatabaseSummary { database_id: crate::sqlite::row_get(row, 0)?, name: crate::sqlite::row_get(row, 1)?, status: status_from_db(&crate::sqlite::row_get::(row, 2)?)?, role: role_from_db(&crate::sqlite::row_get::(row, 3)?)?, logical_size_bytes: logical_size_bytes.max(0) as u64, - credit_units_balance: Some(credit_units_balance.max(0) as u64), - credits_suspended_at_ms: crate::sqlite::row_get(row, 6)?, + cycles_balance: Some(cycles_balance.max(0) as u64), + cycles_suspended_at_ms: crate::sqlite::row_get(row, 6)?, archived_at_ms: crate::sqlite::row_get(row, 7)?, }) }) @@ -4992,16 +4932,16 @@ mod tests { use super::*; - fn test_credits_config() -> CreditsConfig { - CreditsConfig { + fn test_cycles_billing_config() -> CyclesBillingConfig { + CyclesBillingConfig { kinic_ledger_canister_id: "aaaaa-aa".to_string(), sns_governance_id: "rrkah-fqaaa-aaaaa-aaaaq-cai".to_string(), - credit_units_per_kinic: DEFAULT_CREDIT_UNITS_PER_KINIC, - min_update_credit_units: DEFAULT_MIN_UPDATE_CREDIT_UNITS, + cycles_per_kinic: DEFAULT_CYCLES_PER_KINIC, + min_update_cycles: DEFAULT_MIN_UPDATE_CYCLES, } } - fn write_pre_credits_schema(index_path: &Path) { + fn write_pre_cycles_schema(index_path: &Path) { let conn = Connection::open(index_path).expect("index DB should open"); conn.execute_batch( "CREATE TABLE schema_migrations ( @@ -5114,48 +5054,48 @@ mod tests { FOREIGN KEY (database_id) REFERENCES databases(database_id) );", ) - .expect("pre-credits schema should write"); + .expect("pre-cycles schema should write"); } #[test] fn old_upgrade_migrations_require_config() { let dir = tempdir().expect("tempdir should create"); let index_path = dir.path().join("index.sqlite3"); - write_pre_credits_schema(&index_path); + write_pre_cycles_schema(&index_path); let service = VfsService::new(index_path, dir.path().join("databases")); let error = service .run_index_migrations_for_upgrade(None) .expect_err("old index should require config"); - assert!(error.contains("credits config required for first credits upgrade")); + assert!(error.contains("cycles config required for first cycles upgrade")); } #[test] fn old_upgrade_migrations_apply_with_config() { let dir = tempdir().expect("tempdir should create"); let index_path = dir.path().join("index.sqlite3"); - write_pre_credits_schema(&index_path); + write_pre_cycles_schema(&index_path); let service = VfsService::new(index_path.clone(), dir.path().join("databases")); - let config = test_credits_config(); + let config = test_cycles_billing_config(); service .run_index_migrations_for_upgrade(Some(config.clone())) .expect("old index should upgrade"); assert_eq!( - service.credits_config().expect("config should load"), + service.cycles_billing_config().expect("config should load"), config ); let conn = Connection::open(&index_path).expect("index DB should reopen"); let marker: String = conn .query_row( "SELECT version FROM schema_migrations - WHERE version = 'database_index:018_credit_ledger_only'", + WHERE version = 'database_index:018_cycles_ledger_only'", params![], |row| row.get(0), ) - .expect("credit ledger only marker should exist"); + .expect("cycle ledger only marker should exist"); let usage_table_count: i64 = conn .query_row( "SELECT COUNT(*) FROM sqlite_master @@ -5164,85 +5104,172 @@ mod tests { |row| row.get(0), ) .expect("usage table count should load"); - assert_eq!(marker, "database_index:018_credit_ledger_only"); + assert_eq!(marker, "database_index:018_cycles_ledger_only"); assert_eq!(usage_table_count, 0); } #[test] - fn upgrade_migrations_accept_no_config_after_credits_initial() { + fn upgrade_migrations_accept_no_config_after_cycles_initial() { let dir = tempdir().expect("tempdir should create"); let service = VfsService::new( dir.path().join("index.sqlite3"), dir.path().join("databases"), ); - let config = test_credits_config(); + let config = test_cycles_billing_config(); service .run_index_migrations_with_config(config.clone()) .expect("initial migrations should run"); service .run_index_migrations_for_upgrade(None) - .expect("post-credits upgrade should not need config"); + .expect("post-cycles upgrade should not need config"); assert_eq!( - service.credits_config().expect("config should load"), + service.cycles_billing_config().expect("config should load"), config ); } #[test] - fn upgrade_migration_marks_existing_pending_credit_operations_ambiguous() { + fn upgrade_migration_converts_existing_credit_operations_to_cycles() { let dir = tempdir().expect("tempdir should create"); let index_path = dir.path().join("index.sqlite3"); let conn = Connection::open(&index_path).expect("index DB should open"); - conn.execute( - "CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)", - params![], - ) - .expect("schema_migrations should create"); conn.execute_batch( - &FRESH_INDEX_SCHEMA_SQL.replace(" operation_status TEXT NOT NULL,\n", ""), + "CREATE TABLE schema_migrations ( + version TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL + ); + INSERT INTO schema_migrations (version, applied_at) VALUES + ('database_index:000_initial', 0), + ('database_index:001_lifecycle', 0), + ('database_index:002_restore_size', 0), + ('database_index:003_restore_chunks', 0), + ('database_index:005_mount_history', 0), + ('database_index:006_url_ingest_trigger_sessions', 0), + ('database_index:007_ops_answer_sessions', 0), + ('database_index:008_restore_sessions', 0), + ('database_index:009_restore_chunk_bytes', 0), + ('database_index:010_database_name_breaking', 0), + ('database_index:011_source_run_sessions', 0), + ('database_index:012_credits_initial', 0), + ('database_index:013_credits_pending', 0), + ('database_index:014_credits_ledger_block_index', 0), + ('database_index:015_credits_pending_ledger_details', 0), + ('database_index:016_active_status', 0), + ('database_index:017_hard_delete_databases', 0), + ('database_index:018_credit_ledger_only', 0), + ('database_index:019_fixed_cycles_per_credit', 0), + ('database_index:020_credits_config_version', 0); + CREATE TABLE databases ( + database_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + db_file_name TEXT NOT NULL, + mount_id INTEGER NOT NULL, + active_mount_id INTEGER, + status TEXT NOT NULL DEFAULT 'active', + schema_version TEXT NOT NULL, + logical_size_bytes INTEGER NOT NULL DEFAULT 0, + snapshot_hash BLOB, + archived_at_ms INTEGER, + deleted_at_ms INTEGER, + restore_size_bytes INTEGER, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + ); + CREATE UNIQUE INDEX databases_active_mount_id_idx + ON databases(active_mount_id) + WHERE active_mount_id IS NOT NULL; + CREATE TABLE database_members ( + database_id TEXT NOT NULL, + principal TEXT NOT NULL, + role TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + PRIMARY KEY (database_id, principal) + ); + CREATE TABLE database_restore_chunks ( + database_id TEXT NOT NULL, + offset_bytes INTEGER NOT NULL, + end_bytes INTEGER NOT NULL, + bytes BLOB, + PRIMARY KEY (database_id, offset_bytes, end_bytes) + ); + CREATE INDEX database_restore_chunks_database_id_idx + ON database_restore_chunks(database_id, offset_bytes); + CREATE TABLE database_restore_sessions ( + database_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + active_mount_id INTEGER, + snapshot_hash BLOB, + archived_at_ms INTEGER, + deleted_at_ms INTEGER, + restore_size_bytes INTEGER, + created_at_ms INTEGER NOT NULL + ); + CREATE TABLE database_credit_accounts ( + database_id TEXT PRIMARY KEY, + balance_credits INTEGER NOT NULL, + suspended_at_ms INTEGER, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + ); + CREATE TABLE database_credit_ledger ( + entry_id INTEGER PRIMARY KEY AUTOINCREMENT, + database_id TEXT NOT NULL, + kind TEXT NOT NULL, + amount_credits INTEGER NOT NULL, + balance_after_credits INTEGER NOT NULL, + payment_amount_e8s INTEGER, + caller TEXT NOT NULL, + method TEXT, + cycles_delta INTEGER, + credits_per_kinic INTEGER, + ledger_block_index INTEGER, + created_at_ms INTEGER NOT NULL + ); + CREATE INDEX database_credit_ledger_database_idx + ON database_credit_ledger(database_id, entry_id); + CREATE TABLE database_credit_pending_operations ( + operation_id INTEGER PRIMARY KEY AUTOINCREMENT, + database_id TEXT NOT NULL, + kind TEXT NOT NULL, + caller TEXT NOT NULL, + credits INTEGER NOT NULL, + payment_amount_e8s INTEGER NOT NULL, + from_owner TEXT, + from_subaccount BLOB, + to_owner TEXT, + to_subaccount BLOB, + ledger_fee_e8s INTEGER, + ledger_created_at_time_ns INTEGER, + created_at_ms INTEGER NOT NULL + ); + CREATE INDEX database_credit_pending_operations_database_idx + ON database_credit_pending_operations(database_id); + CREATE TABLE credits_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + );", ) - .expect("pre-status schema should create"); - for version in INDEX_SCHEMA_VERSIONS - .iter() - .copied() - .filter(|version| *version != INDEX_SCHEMA_VERSION_CREDIT_PENDING_OPERATION_STATUS) - { - conn.execute( - "INSERT INTO schema_migrations (version, applied_at) VALUES (?1, 0)", - params![version], - ) - .expect("migration marker should insert"); - } - let config = test_credits_config(); + .expect("legacy credit schema should create"); conn.execute( "INSERT INTO credits_config (key, value) VALUES (?1, ?2)", - params![ - "kinic_ledger_canister_id", - config.kinic_ledger_canister_id.as_str() - ], + params!["kinic_ledger_canister_id", "aaaaa-aa"], ) .expect("ledger config should insert"); conn.execute( "INSERT INTO credits_config (key, value) VALUES (?1, ?2)", - params!["sns_governance_id", config.sns_governance_id.as_str()], + params!["sns_governance_id", "rrkah-fqaaa-aaaaa-aaaaq-cai"], ) .expect("governance config should insert"); conn.execute( "INSERT INTO credits_config (key, value) VALUES (?1, ?2)", - params![ - "credit_units_per_kinic", - config.credit_units_per_kinic.to_string() - ], + params!["credits_per_kinic", "1000"], ) .expect("rate config should insert"); conn.execute( "INSERT INTO credits_config (key, value) VALUES (?1, ?2)", - params![ - "min_update_credit_units", - config.min_update_credit_units.to_string() - ], + params!["min_update_credits", "1"], ) .expect("minimum config should insert"); conn.execute( @@ -5258,16 +5285,31 @@ mod tests { params![DATABASE_SCHEMA_VERSION], ) .expect("database should insert"); + conn.execute( + "INSERT INTO database_members (database_id, principal, role, created_at_ms) + VALUES ('db_old', '2vxsx-fae', 'owner', 1)", + params![], + ) + .expect("database member should insert"); conn.execute( "INSERT INTO database_credit_accounts - (database_id, balance_credit_units, suspended_at_ms, created_at_ms, updated_at_ms) - VALUES ('db_old', 0, 1, 1, 1)", + (database_id, balance_credits, suspended_at_ms, created_at_ms, updated_at_ms) + VALUES ('db_old', 5, 1, 1, 1)", params![], ) .expect("account should insert"); + conn.execute( + "INSERT INTO database_credit_ledger + (database_id, kind, amount_credits, balance_after_credits, payment_amount_e8s, + caller, method, cycles_delta, credits_per_kinic, ledger_block_index, created_at_ms) + VALUES ('db_old', 'credit_purchase', 7, 9, 700000, '2vxsx-fae', + 'purchase_database_credits', NULL, 1000, 3, 1)", + params![], + ) + .expect("ledger entry should insert"); conn.execute( "INSERT INTO database_credit_pending_operations - (database_id, kind, caller, credit_units, payment_amount_e8s, from_owner, from_subaccount, + (database_id, kind, caller, credits, payment_amount_e8s, from_owner, from_subaccount, to_owner, to_subaccount, ledger_fee_e8s, ledger_created_at_time_ns, created_at_ms) VALUES ('db_old', 'credit_purchase', '2vxsx-fae', 10, 1000, '2vxsx-fae', NULL, 'aaaaa-aa', NULL, 10000, 1700000000000000000, 1)", @@ -5279,35 +5321,42 @@ mod tests { service .run_index_migrations_for_upgrade(None) - .expect("status migration should apply"); + .expect("legacy credit migrations should apply"); let conn = Connection::open(&index_path).expect("index DB should reopen"); - let status: String = conn + let (balance, amount, rate, status): (i64, i64, i64, String) = conn .query_row( - "SELECT operation_status FROM database_credit_pending_operations WHERE database_id = 'db_old'", + "SELECT a.balance_cycles, l.amount_cycles, l.cycles_per_kinic, p.operation_status + FROM database_cycle_accounts a + JOIN database_cycle_ledger l ON l.database_id = a.database_id + JOIN database_cycle_pending_operations p ON p.database_id = a.database_id + WHERE a.database_id = 'db_old'", params![], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), ) - .expect("operation status should load"); + .expect("migrated cycle values should load"); let marker: String = conn .query_row( "SELECT version FROM schema_migrations WHERE version = ?1", - params![INDEX_SCHEMA_VERSION_CREDIT_PENDING_OPERATION_STATUS], + params![INDEX_SCHEMA_VERSION_DIRECT_CYCLES], |row| row.get(0), ) - .expect("status migration marker should exist"); - assert_eq!(status, CREDIT_OPERATION_STATUS_AMBIGUOUS); - assert_eq!(marker, INDEX_SCHEMA_VERSION_CREDIT_PENDING_OPERATION_STATUS); + .expect("direct cycles marker should exist"); + assert_eq!(balance, 5_000_000_000); + assert_eq!(amount, 7_000_000_000); + assert_eq!(rate, 1_000_000_000_000); + assert_eq!(status, CYCLES_OPERATION_STATUS_AMBIGUOUS); + assert_eq!(marker, INDEX_SCHEMA_VERSION_DIRECT_CYCLES); drop(conn); service - .repair_database_credit_purchase_cancel("db_old", 1, "2vxsx-fae", 2) + .repair_database_cycles_purchase_cancel("db_old", 1, "2vxsx-fae", 2) .expect("migrated ambiguous operation should remain cancellable"); let conn = Connection::open(&index_path).expect("index DB should reopen after cancel"); let pending_count: i64 = conn .query_row( - "SELECT COUNT(*) FROM database_credit_pending_operations WHERE database_id = 'db_old'", + "SELECT COUNT(*) FROM database_cycle_pending_operations WHERE database_id = 'db_old'", params![], |row| row.get(0), ) @@ -5316,7 +5365,7 @@ mod tests { } #[test] - fn index_sql_json_returns_credits_json_rows() { + fn index_sql_json_returns_cycles_json_rows() { let dir = tempdir().expect("tempdir should create"); let service = VfsService::new( dir.path().join("index.sqlite3"), @@ -5329,30 +5378,33 @@ mod tests { .create_database("default", "2vxsx-fae", 1_700_000_000_000) .expect("database should create"); let operation_id = service - .begin_database_credit_purchase("default", "2vxsx-fae", 1_000_000, 1_700_000_000_001) - .expect("credit purchase should begin"); + .begin_database_cycles_purchase("default", "2vxsx-fae", 1_000_000, 1_700_000_000_001) + .expect("cycle purchase should begin"); + let preview = service + .preview_database_cycles_purchase("default", 1_000_000) + .expect("cycle purchase preview should load"); service - .mark_database_credit_purchase_completed( + .mark_database_cycles_purchase_completed( operation_id, "default", "2vxsx-fae", - 1_000_000, + preview.cycles, ) - .expect("credit purchase should be marked completed"); + .expect("cycle purchase should be marked completed"); service - .credit_database_purchase( + .apply_database_cycles_purchase( operation_id, "default", "2vxsx-fae", - 1_000_000, + preview.cycles, 1, 1_700_000_000_001, ) - .expect("credit purchase should credit"); + .expect("cycle purchase should cycle"); let result = service .query_index_sql_json( - "SELECT json_object('credit_purchase_credits', COALESCE(SUM(amount_credit_units), 0)) FROM database_credit_ledger WHERE kind = 'credit_purchase' LIMIT 1", + "SELECT json_object('cycles_purchase_cycles', COALESCE(SUM(amount_cycles), 0)) FROM database_cycle_ledger WHERE kind = 'cycles_purchase' LIMIT 1", 10, ) .expect("index SQL should query"); @@ -5361,7 +5413,7 @@ mod tests { assert_eq!(result.row_count, 1); assert_eq!( result.rows, - vec![r#"{"credit_purchase_credits":1000000}"#.to_string()] + vec![r#"{"cycles_purchase_cycles":10000000000}"#.to_string()] ); } @@ -5411,18 +5463,18 @@ mod tests { #[test] fn index_sql_json_rejects_mutating_sql() { for sql in [ - "UPDATE database_credit_accounts SET balance_credit_units = 0", - "DELETE FROM database_credit_ledger", - "INSERT INTO database_credit_ledger (database_id) VALUES ('x')", + "UPDATE database_cycle_accounts SET balance_cycles = 0", + "DELETE FROM database_cycle_ledger", + "INSERT INTO database_cycle_ledger (database_id) VALUES ('x')", "CREATE TABLE x (id INTEGER)", - "DROP TABLE database_credit_ledger", - "ALTER TABLE database_credit_ledger ADD COLUMN x INTEGER", - "REPLACE INTO credits_config (key, value) VALUES ('x', 'y')", + "DROP TABLE database_cycle_ledger", + "ALTER TABLE database_cycle_ledger ADD COLUMN x INTEGER", + "REPLACE INTO cycles_billing_config (key, value) VALUES ('x', 'y')", "VACUUM", - "PRAGMA table_info(database_credit_ledger)", + "PRAGMA table_info(database_cycle_ledger)", "ATTACH DATABASE 'x' AS x", "DETACH DATABASE x", - "REINDEX database_credit_ledger_database_idx", + "REINDEX database_cycle_ledger_database_idx", "ANALYZE", "SELECT json_object('ok', 1); SELECT json_object('ok', 2)", ] { @@ -5452,20 +5504,20 @@ mod tests { } #[test] - fn storage_billing_daily_units_match_subnet_rate() { + fn storage_billing_daily_cycles_match_subnet_rate() { let one_gib_cycles = compute_storage_charge_cycles(GIB_BYTES as u64, STORAGE_BILLING_INTERVAL_MS) .expect("1GiB storage cycles should compute"); - assert_eq!(one_gib_cycles.div_ceil(CYCLES_PER_CREDIT_UNIT), 10_973); + assert_eq!(one_gib_cycles, 10_972_800_000); let ten_mib = 10 * 1024 * 1024; let ten_mib_cycles = compute_storage_charge_cycles(ten_mib, STORAGE_BILLING_INTERVAL_MS) .expect("10MiB storage cycles should compute"); - assert_eq!(ten_mib_cycles.div_ceil(CYCLES_PER_CREDIT_UNIT), 108); + assert_eq!(ten_mib_cycles, 107_156_250); } #[test] - fn storage_billing_rounds_sub_unit_cycles_up() { + fn storage_billing_charges_raw_storage_cycles() { let dir = tempdir().expect("tempdir should create"); let service = VfsService::new( dir.path().join("index.sqlite3"), @@ -5478,7 +5530,7 @@ mod tests { .create_database("alpha", "owner", 0) .expect("database should create"); set_test_database_balance(&service, "alpha", 1_000); - let config = service.credits_config().expect("config should load"); + let config = service.cycles_billing_config().expect("config should load"); service .write_index(|tx| { @@ -5497,25 +5549,25 @@ mod tests { let (balance, charged_at, amount) = service .read_index(|conn| { - let account = load_storage_credit_account(conn, "alpha")?; + let account = load_storage_cycle_account(conn, "alpha")?; let amount: i64 = conn .query_row( - "SELECT amount_credit_units FROM database_credit_ledger + "SELECT amount_cycles FROM database_cycle_ledger WHERE database_id = 'alpha' AND kind = 'storage_charge'", params![], |row| crate::sqlite::row_get(row, 0), ) .map_err(|error| error.to_string())?; Ok(( - account.balance_credit_units, + account.balance_cycles, account.storage_charged_at_ms, amount, )) }) .expect("account should load"); - assert_eq!(balance, 999); + assert_eq!(balance, 990); assert_eq!(charged_at, Some(STORAGE_BILLING_INTERVAL_MS)); - assert_eq!(amount, -1); + assert_eq!(amount, -10); } #[test] @@ -5532,7 +5584,7 @@ mod tests { .create_database("alpha", "owner", 0) .expect("database should create"); set_test_database_balance(&service, "alpha", 1_000); - let config = service.credits_config().expect("config should load"); + let config = service.cycles_billing_config().expect("config should load"); service .write_index(|tx| { @@ -5551,16 +5603,16 @@ mod tests { let (balance, charged_at, ledger_count) = service .read_index(|conn| { - let account = load_storage_credit_account(conn, "alpha")?; + let account = load_storage_cycle_account(conn, "alpha")?; let ledger_count: i64 = conn .query_row( - "SELECT COUNT(*) FROM database_credit_ledger WHERE database_id = 'alpha'", + "SELECT COUNT(*) FROM database_cycle_ledger WHERE database_id = 'alpha'", params![], |row| crate::sqlite::row_get(row, 0), ) .map_err(|error| error.to_string())?; Ok(( - account.balance_credit_units, + account.balance_cycles, account.storage_charged_at_ms, ledger_count, )) @@ -5585,7 +5637,7 @@ mod tests { .create_database("alpha", "owner", 0) .expect("database should create"); set_test_database_balance(&service, "alpha", 1_000); - let config = service.credits_config().expect("config should load"); + let config = service.cycles_billing_config().expect("config should load"); service .write_index(|tx| { @@ -5604,8 +5656,8 @@ mod tests { let (balance, charged_at) = service .read_index(|conn| { - let account = load_storage_credit_account(conn, "alpha")?; - Ok((account.balance_credit_units, account.storage_charged_at_ms)) + let account = load_storage_cycle_account(conn, "alpha")?; + Ok((account.balance_cycles, account.storage_charged_at_ms)) }) .expect("account should load"); assert_eq!(balance, 1_000); @@ -5626,7 +5678,7 @@ mod tests { .create_database("alpha", "owner", 0) .expect("database should create"); set_test_database_balance(&service, "alpha", 100); - let config = service.credits_config().expect("config should load"); + let config = service.cycles_billing_config().expect("config should load"); service .write_index(|tx| { @@ -5660,10 +5712,10 @@ mod tests { let (balance, suspended_at, charged_at, kinds, amount) = service .read_index(|conn| { - let account = load_storage_credit_account(conn, "alpha")?; + let account = load_storage_cycle_account(conn, "alpha")?; let mut stmt = conn .prepare( - "SELECT kind FROM database_credit_ledger + "SELECT kind FROM database_cycle_ledger WHERE database_id = 'alpha' ORDER BY entry_id ASC", ) @@ -5674,14 +5726,14 @@ mod tests { .map_err(|error| error.to_string())?; let amount: i64 = conn .query_row( - "SELECT amount_credit_units FROM database_credit_ledger + "SELECT amount_cycles FROM database_cycle_ledger WHERE database_id = 'alpha' AND kind = 'storage_charge'", params![], |row| crate::sqlite::row_get(row, 0), ) .map_err(|error| error.to_string())?; Ok(( - account.balance_credit_units, + account.balance_cycles, account.suspended_at_ms, account.storage_charged_at_ms, kinds, @@ -5741,8 +5793,8 @@ mod tests { service .write_index(|tx| { tx.execute( - "UPDATE database_credit_accounts - SET balance_credit_units = ?2, suspended_at_ms = NULL + "UPDATE database_cycle_accounts + SET balance_cycles = ?2, suspended_at_ms = NULL WHERE database_id = ?1", params![database_id, balance], ) diff --git a/crates/vfs_runtime/tests/database_service.rs b/crates/vfs_runtime/tests/database_service.rs index ca777647..dff04cd5 100644 --- a/crates/vfs_runtime/tests/database_service.rs +++ b/crates/vfs_runtime/tests/database_service.rs @@ -7,13 +7,13 @@ use rusqlite::{Connection, OptionalExtension, params}; use sha2::{Digest, Sha256}; use tempfile::tempdir; use vfs_runtime::{ - CreditsPendingLedgerDetailsInput, DEFAULT_LLM_WRITER_PRINCIPAL, - DatabaseCreditPurchaseWithLedgerDetails, MAX_ARCHIVE_CHUNK_BYTES, MAX_DATABASE_SIZE_BYTES, + CyclesPendingLedgerDetailsInput, DEFAULT_LLM_WRITER_PRINCIPAL, + DatabaseCyclesPurchaseWithLedgerDetails, MAX_ARCHIVE_CHUNK_BYTES, MAX_DATABASE_SIZE_BYTES, MAX_RESTORE_CHUNK_BYTES, VfsService, }; use vfs_types::{ - AppendNodeRequest, CreditsConfigUpdate, DatabaseRole, DatabaseStatus, DeleteDatabaseRequest, - DeleteNodeRequest, KINIC_LEDGER_FEE_E8S, MkdirNodeRequest, NodeKind, + AppendNodeRequest, CyclesBillingConfigUpdate, DatabaseRole, DatabaseStatus, + DeleteDatabaseRequest, DeleteNodeRequest, KINIC_LEDGER_FEE_E8S, MkdirNodeRequest, NodeKind, OpsAnswerSessionCheckRequest, OpsAnswerSessionRequest, SearchNodesRequest, SearchPreviewMode, SourceRunSessionCheckRequest, UrlIngestTriggerSessionCheckRequest, UrlIngestTriggerSessionRequest, WriteNodeRequest, WriteSourceForGenerationRequest, @@ -61,21 +61,21 @@ fn mainnet_011_index_upgrades_to_latest() { .expect("database status should load"); let balance: i64 = conn .query_row( - "SELECT balance_credit_units FROM database_credit_accounts WHERE database_id = 'db_existing'", + "SELECT balance_cycles FROM database_cycle_accounts WHERE database_id = 'db_existing'", params![], |row| row.get(0), ) - .expect("database credits account should exist"); + .expect("database cycles account should exist"); let suspended_at_ms: Option = conn .query_row( - "SELECT suspended_at_ms FROM database_credit_accounts WHERE database_id = 'db_existing'", + "SELECT suspended_at_ms FROM database_cycle_accounts WHERE database_id = 'db_existing'", params![], |row| row.get(0), ) - .expect("database credits suspension should exist"); + .expect("database cycles suspension should exist"); let storage_columns: i64 = conn .query_row( - "SELECT COUNT(*) FROM pragma_table_info('database_credit_accounts') + "SELECT COUNT(*) FROM pragma_table_info('database_cycle_accounts') WHERE name = 'storage_charged_at_ms'", params![], |row| row.get(0), @@ -83,7 +83,7 @@ fn mainnet_011_index_upgrades_to_latest() { .expect("storage charged cursor column should load"); let removed_storage_columns: i64 = conn .query_row( - "SELECT COUNT(*) FROM pragma_table_info('database_credit_accounts') + "SELECT COUNT(*) FROM pragma_table_info('database_cycle_accounts') WHERE name = 'storage_unbilled_cycles'", params![], |row| row.get(0), @@ -91,7 +91,7 @@ fn mainnet_011_index_upgrades_to_latest() { .expect("removed storage column count should load"); let pending_details_columns: i64 = conn .query_row( - "SELECT COUNT(*) FROM pragma_table_info('database_credit_pending_operations') + "SELECT COUNT(*) FROM pragma_table_info('database_cycle_pending_operations') WHERE name IN ('from_owner', 'from_subaccount', 'to_owner', 'to_subaccount', 'ledger_fee_e8s', 'ledger_created_at_time_ns')", params![], @@ -100,8 +100,8 @@ fn mainnet_011_index_upgrades_to_latest() { .expect("pending details columns should load"); let ledger_cycles_column_count: i64 = conn .query_row( - "SELECT COUNT(*) FROM pragma_table_info('database_credit_ledger') - WHERE name = 'cycles_per_credit'", + "SELECT COUNT(*) FROM pragma_table_info('database_cycle_ledger') + WHERE name = 'cycles_per_cycle'", params![], |row| row.get(0), ) @@ -124,10 +124,10 @@ fn mainnet_011_index_upgrades_to_latest() { assert_eq!(ledger_cycles_column_count, 0); assert_eq!(usage_table_count, 0); assert_eq!( - schema_migration_count(&root, "database_index:020_credits_config_version"), + schema_migration_count(&root, "database_index:020_cycles_billing_config_version"), 1 ); - assert_eq!(credits_config_version(&root), 1); + assert_eq!(cycles_billing_config_version(&root), 1); } fn write_mainnet_011_index_schema(index_path: &std::path::Path, status: &str) { @@ -272,7 +272,7 @@ fn partial_billing_index_schema_is_rejected() { let conn = Connection::open(&index_path).expect("index should reopen"); conn.execute( "INSERT INTO schema_migrations (version, applied_at) VALUES (?1, 0)", - params!["database_index:012_credits_initial"], + params!["database_index:012_cycles_initial"], ) .expect("partial billing marker should insert"); drop(conn); @@ -320,7 +320,7 @@ fn pre_011_index_schema_is_rejected() { } #[test] -fn cycles_per_credit_ledger_schema_is_rejected() { +fn cycles_per_cycle_ledger_schema_is_rejected() { let dir = tempdir().expect("tempdir should create"); let root = dir.keep(); let index_path = root.join("index.sqlite3"); @@ -330,27 +330,27 @@ fn cycles_per_credit_ledger_schema_is_rejected() { version TEXT PRIMARY KEY, applied_at INTEGER NOT NULL ); - CREATE TABLE database_credit_ledger ( + CREATE TABLE database_cycle_ledger ( entry_id INTEGER PRIMARY KEY AUTOINCREMENT, database_id TEXT NOT NULL, kind TEXT NOT NULL, - amount_credit_units INTEGER NOT NULL, - balance_after_credit_units INTEGER NOT NULL, + amount_cycles INTEGER NOT NULL, + balance_after_cycles INTEGER NOT NULL, payment_amount_e8s INTEGER, caller TEXT NOT NULL, method TEXT, cycles_delta INTEGER, - credit_units_per_kinic INTEGER, - cycles_per_credit INTEGER, + cycles_per_kinic INTEGER, + cycles_per_cycle INTEGER, ledger_block_index INTEGER, created_at_ms INTEGER NOT NULL ); - CREATE TABLE credits_config ( + CREATE TABLE cycles_billing_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL );", ) - .expect("legacy credits schema should create"); + .expect("legacy cycles schema should create"); for version in [ "database_index:000_initial", "database_index:001_lifecycle", @@ -363,7 +363,7 @@ fn cycles_per_credit_ledger_schema_is_rejected() { "database_index:009_restore_chunk_bytes", "database_index:010_database_name_breaking", "database_index:011_source_run_sessions", - "database_index:012_credits_initial", + "database_index:012_cycles_initial", ] { conn.execute( "INSERT INTO schema_migrations (version, applied_at) VALUES (?1, 0)", @@ -376,13 +376,13 @@ fn cycles_per_credit_ledger_schema_is_rejected() { let service = VfsService::new(index_path.clone(), root.join("databases")); let error = service .run_index_migrations() - .expect_err("cycles_per_credit schema should reject"); + .expect_err("cycles_per_cycle schema should reject"); let conn = Connection::open(index_path).expect("index should reopen"); let ledger_cycles_column_count: i64 = conn .query_row( - "SELECT COUNT(*) FROM pragma_table_info('database_credit_ledger') - WHERE name = 'cycles_per_credit'", + "SELECT COUNT(*) FROM pragma_table_info('database_cycle_ledger') + WHERE name = 'cycles_per_cycle'", params![], |row| row.get(0), ) @@ -476,84 +476,93 @@ fn database_member_count(root: &std::path::Path, database_id: &str) -> i64 { .expect("member count should load") } -fn database_credits_balance(root: &std::path::Path, database_id: &str) -> i64 { +fn database_cycles_balance(root: &std::path::Path, database_id: &str) -> i64 { let conn = Connection::open(root.join("index.sqlite3")).expect("index should open"); conn.query_row( - "SELECT balance_credit_units FROM database_credit_accounts WHERE database_id = ?1", + "SELECT balance_cycles FROM database_cycle_accounts WHERE database_id = ?1", params![database_id], |row| row.get(0), ) - .expect("database credits balance should load") + .expect("database cycles balance should load") } -fn database_credits_suspended_at(root: &std::path::Path, database_id: &str) -> Option { +fn database_cycles_suspended_at(root: &std::path::Path, database_id: &str) -> Option { let conn = Connection::open(root.join("index.sqlite3")).expect("index should open"); conn.query_row( - "SELECT suspended_at_ms FROM database_credit_accounts WHERE database_id = ?1", + "SELECT suspended_at_ms FROM database_cycle_accounts WHERE database_id = ?1", params![database_id], |row| row.get(0), ) - .expect("database credits suspension should load") + .expect("database cycles suspension should load") } -fn credits_config_version(root: &std::path::Path) -> i64 { +fn cycles_billing_config_version(root: &std::path::Path) -> i64 { let conn = Connection::open(root.join("index.sqlite3")).expect("index should open"); conn.query_row( - "SELECT value FROM credits_config WHERE key = 'config_version'", + "SELECT value FROM cycles_billing_config WHERE key = 'config_version'", params![], |row| row.get::<_, String>(0), ) - .expect("credits config version should load") + .expect("cycles config version should load") .parse() - .expect("credits config version should be numeric") + .expect("cycles config version should be numeric") } fn database_pending_operation_count(root: &std::path::Path, database_id: &str) -> i64 { let conn = Connection::open(root.join("index.sqlite3")).expect("index should open"); conn.query_row( - "SELECT COUNT(*) FROM database_credit_pending_operations WHERE database_id = ?1", + "SELECT COUNT(*) FROM database_cycle_pending_operations WHERE database_id = ?1", params![database_id], |row| row.get(0), ) - .expect("pending credit operation count should load") + .expect("pending cycle operation count should load") } -fn credit_database( +fn cycle_database( service: &VfsService, database_id: &str, caller: &str, - amount_credit_units: u64, + payment_amount_e8s: u64, block_index: u64, now: i64, ) -> u64 { + let preview = service + .preview_database_cycles_purchase(database_id, payment_amount_e8s) + .expect("database cycle purchase preview should load"); let operation_id = service - .begin_database_credit_purchase(database_id, caller, amount_credit_units, now) - .expect("database credit purchase should begin"); + .begin_database_cycles_purchase(database_id, caller, payment_amount_e8s, now) + .expect("database cycle purchase should begin"); service - .mark_database_credit_purchase_completed( - operation_id, - database_id, - caller, - amount_credit_units, - ) - .expect("database credit purchase should be marked completed"); + .mark_database_cycles_purchase_completed(operation_id, database_id, caller, preview.cycles) + .expect("database cycle purchase should be marked completed"); service - .credit_database_purchase( + .apply_database_cycles_purchase( operation_id, database_id, caller, - amount_credit_units, + preview.cycles, block_index, now, ) - .expect("database credit purchase should credit") + .expect("database cycle purchase should cycle") +} + +fn cycles_for_payment(service: &VfsService, database_id: &str, payment_amount_e8s: u64) -> u64 { + service + .preview_database_cycles_purchase(database_id, payment_amount_e8s) + .expect("database cycle purchase preview should load") + .cycles +} + +fn default_cycles_for_payment(payment_amount_e8s: u64) -> u64 { + payment_amount_e8s * 10_000 } fn database_ledger_kinds(root: &std::path::Path, database_id: &str) -> Vec { let conn = Connection::open(root.join("index.sqlite3")).expect("index should open"); let mut stmt = conn .prepare( - "SELECT kind FROM database_credit_ledger + "SELECT kind FROM database_cycle_ledger WHERE database_id = ?1 ORDER BY entry_id ASC", ) @@ -804,11 +813,11 @@ fn archive_bytes_for_chunk_size( } #[test] -fn index_migrations_create_credit_ledger_only_schema_once() { +fn index_migrations_create_cycle_ledger_only_schema_once() { let (service, root) = service_with_root(); let conn = Connection::open(root.join("index.sqlite3")).expect("index should open"); - for table_name in ["database_mount_history", "database_credit_ledger"] { + for table_name in ["database_mount_history", "database_cycle_ledger"] { let table_exists: i64 = conn .query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", @@ -828,15 +837,15 @@ fn index_migrations_create_credit_ledger_only_schema_once() { assert_eq!(usage_table_exists, 0); let usage_column_exists: i64 = conn .query_row( - "SELECT COUNT(*) FROM pragma_table_info('database_credit_ledger') + "SELECT COUNT(*) FROM pragma_table_info('database_cycle_ledger') WHERE name = 'usage_event_id'", params![], |row| row.get(0), ) - .expect("credit ledger column lookup should work"); + .expect("cycle ledger column lookup should work"); assert_eq!(usage_column_exists, 0); assert_eq!( - schema_migration_count(&root, "database_index:018_credit_ledger_only"), + schema_migration_count(&root, "database_index:018_cycles_ledger_only"), 1 ); assert_eq!( @@ -868,7 +877,7 @@ fn index_migrations_create_credit_ledger_only_schema_once() { .run_index_migrations() .expect("index migrations should be idempotent"); assert_eq!( - schema_migration_count(&root, "database_index:018_credit_ledger_only"), + schema_migration_count(&root, "database_index:018_cycles_ledger_only"), 1 ); assert_eq!( @@ -903,7 +912,7 @@ fn url_ingest_trigger_session_requires_writer_and_allows_replay() { service .create_database("alpha", "owner", 1) .expect("database should create"); - credit_database(&service, "alpha", "owner", 1_000_000, 1, 2); + cycle_database(&service, "alpha", "owner", 1_000_000, 1, 2); let request_path = "/Sources/ingest-requests/1.md"; write_url_ingest_request(&service, "owner", "alpha", request_path, "queued", "owner"); @@ -934,7 +943,7 @@ fn url_ingest_trigger_session_requires_default_llm_writer() { service .create_database("alpha", "owner", 1) .expect("database should create"); - credit_database(&service, "alpha", "owner", 1_000_000, 1, 2); + cycle_database(&service, "alpha", "owner", 1_000_000, 1, 2); let request_path = "/Sources/ingest-requests/1.md"; write_url_ingest_request(&service, "owner", "alpha", request_path, "queued", "owner"); service @@ -972,7 +981,7 @@ fn url_ingest_trigger_session_rejects_invalid_request_nodes() { service .create_database("alpha", "owner", 1) .expect("database should create"); - credit_database(&service, "alpha", "owner", 1_000_000, 1, 2); + cycle_database(&service, "alpha", "owner", 1_000_000, 1, 2); service .grant_database_access("alpha", "owner", "other", DatabaseRole::Reader, 2) .expect("reader grant should succeed"); @@ -1082,7 +1091,7 @@ fn url_ingest_trigger_session_rejects_expired_and_unknown_nonce() { service .create_database("alpha", "owner", 1) .expect("database should create"); - credit_database(&service, "alpha", "owner", 1_000_000, 1, 2); + cycle_database(&service, "alpha", "owner", 1_000_000, 1, 2); let request_path = "/Sources/ingest-requests/1.md"; write_url_ingest_request(&service, "owner", "alpha", request_path, "queued", "owner"); @@ -1118,7 +1127,7 @@ fn url_ingest_trigger_session_rejects_expired_and_unknown_nonce() { } #[test] -fn url_ingest_trigger_session_check_requires_write_credits_database() { +fn url_ingest_trigger_session_check_requires_write_cycles_database() { let service = service(); service .create_database("alpha", "owner", 1) @@ -1131,7 +1140,7 @@ fn url_ingest_trigger_session_check_requires_write_credits_database() { url_ingest_session_request("alpha", "session-1"), 100, ) - .expect("session should authorize before credits changes"); + .expect("session should authorize before cycles changes"); let error = service .check_url_ingest_trigger_session( @@ -1140,7 +1149,7 @@ fn url_ingest_trigger_session_check_requires_write_credits_database() { ) .expect_err("suspended database should reject session check"); - assert!(error.contains("database credits are suspended")); + assert!(error.contains("database cycles are suspended")); } #[test] @@ -1149,7 +1158,7 @@ fn url_ingest_trigger_session_check_allows_generating_status() { service .create_database("alpha", "owner", 1) .expect("database should create"); - credit_database(&service, "alpha", "owner", 1_000_000, 1, 2); + cycle_database(&service, "alpha", "owner", 1_000_000, 1, 2); let request_path = "/Sources/ingest-requests/1.md"; write_url_ingest_request( &service, @@ -1181,7 +1190,7 @@ fn source_for_generation_writes_source_and_authorizes_bound_session() { service .create_database("alpha", "owner", 1) .expect("database should create"); - credit_database(&service, "alpha", "owner", 1_000_000, 1, 2); + cycle_database(&service, "alpha", "owner", 1_000_000, 1, 2); service .grant_database_access("alpha", "owner", "writer", DatabaseRole::Writer, 2) .expect("writer grant should succeed"); @@ -1291,7 +1300,7 @@ fn source_run_session_requires_funded_database() { 101, ) .expect_err("suspended database should reject source run session"); - assert!(error.contains("database credits are suspended")); + assert!(error.contains("database cycles are suspended")); } #[test] @@ -1337,7 +1346,7 @@ fn ops_answer_session_allows_database_members_and_replay() { service .create_database("alpha", "owner", 1) .expect("database should create"); - credit_database(&service, "alpha", "owner", 1_000_000, 1, 2); + cycle_database(&service, "alpha", "owner", 1_000_000, 1, 2); service .grant_database_access("alpha", "owner", "writer", DatabaseRole::Writer, 2) .expect("writer grant should succeed"); @@ -1370,7 +1379,7 @@ fn ops_answer_session_rejects_anonymous_and_non_members() { service .create_database("alpha", "owner", 1) .expect("database should create"); - credit_database(&service, "alpha", "owner", 1_000_000, 1, 2); + cycle_database(&service, "alpha", "owner", 1_000_000, 1, 2); service .grant_database_access("alpha", "owner", "2vxsx-fae", DatabaseRole::Reader, 2) .expect("anonymous public grant should succeed"); @@ -1395,24 +1404,24 @@ fn ops_answer_session_rejects_anonymous_and_non_members() { } #[test] -fn ops_answer_session_check_requires_write_credits_database() { +fn ops_answer_session_check_requires_write_cycles_database() { let service = service(); service .create_database("alpha", "owner", 1) .expect("database should create"); service .authorize_ops_answer_session("owner", ops_answer_session_request("alpha", "session-1"), 0) - .expect("session should authorize before credits changes"); + .expect("session should authorize before cycles changes"); let error = service .check_ops_answer_session(ops_answer_session_check_request("alpha", "session-1"), 1) .expect_err("suspended database should reject ops answer session check"); - assert!(error.contains("database credits are suspended")); + assert!(error.contains("database cycles are suspended")); } #[test] -fn check_database_write_credits_requires_writer_and_funded_database() { +fn check_database_write_cycles_requires_writer_and_funded_database() { let service = service(); service .create_database("alpha", "owner", 1) @@ -1425,29 +1434,29 @@ fn check_database_write_credits_requires_writer_and_funded_database() { .expect("reader grant should succeed"); let suspended = service - .check_database_write_credits("alpha", "writer") + .check_database_write_cycles("alpha", "writer") .expect_err("suspended database should reject writer"); - assert!(suspended.contains("database credits are suspended")); + assert!(suspended.contains("database cycles are suspended")); - credit_database(&service, "alpha", "owner", 1_000_000, 1, 4); + cycle_database(&service, "alpha", "owner", 1_000_000, 1, 4); service - .check_database_write_credits("alpha", "owner") - .expect("owner should pass write credits check"); + .check_database_write_cycles("alpha", "owner") + .expect("owner should pass write cycles check"); service - .check_database_write_credits("alpha", "writer") - .expect("writer should pass write credits check"); + .check_database_write_cycles("alpha", "writer") + .expect("writer should pass write cycles check"); let reader = service - .check_database_write_credits("alpha", "reader") - .expect_err("reader should fail write credits check"); + .check_database_write_cycles("alpha", "reader") + .expect_err("reader should fail write cycles check"); assert!(reader.contains("principal lacks required database role")); let anonymous = service - .check_database_write_credits("alpha", "2vxsx-fae") - .expect_err("anonymous should fail write credits check"); + .check_database_write_cycles("alpha", "2vxsx-fae") + .expect_err("anonymous should fail write cycles check"); assert!(anonymous.contains("anonymous caller not allowed")); let missing = service - .check_database_write_credits("alpha", "missing") - .expect_err("non-member should fail write credits check"); + .check_database_write_cycles("alpha", "missing") + .expect_err("non-member should fail write cycles check"); assert!(missing.contains("principal has no access")); } @@ -1457,7 +1466,7 @@ fn ops_answer_session_rechecks_current_role_after_revoke() { service .create_database("alpha", "owner", 1) .expect("database should create"); - credit_database(&service, "alpha", "owner", 1_000_000, 1, 2); + cycle_database(&service, "alpha", "owner", 1_000_000, 1, 2); service .grant_database_access("alpha", "owner", "reader", DatabaseRole::Reader, 2) .expect("reader grant should succeed"); @@ -1493,7 +1502,7 @@ fn ops_answer_session_rejects_invalid_and_expired_nonce() { service .create_database("alpha", "owner", 1) .expect("database should create"); - credit_database(&service, "alpha", "owner", 1_000_000, 1, 2); + cycle_database(&service, "alpha", "owner", 1_000_000, 1, 2); service .authorize_ops_answer_session("owner", ops_answer_session_request("alpha", "session-1"), 0) @@ -1536,9 +1545,9 @@ fn database_create_returns_generated_id_and_name() { assert_eq!(result.database_id.len(), 15); assert_eq!(result.name, "Team skills"); assert_eq!(database_member_count(&root, &result.database_id), 2); - assert_eq!(database_credits_balance(&root, &result.database_id), 0); + assert_eq!(database_cycles_balance(&root, &result.database_id), 0); assert_eq!( - database_credits_suspended_at(&root, &result.database_id), + database_cycles_suspended_at(&root, &result.database_id), Some(1) ); assert!(database_ledger_kinds(&root, &result.database_id).is_empty()); @@ -1550,7 +1559,7 @@ fn database_create_returns_generated_id_and_name() { } #[test] -fn pending_database_creation_defers_mount_slot_until_credit_purchase_activation() { +fn pending_database_creation_defers_mount_slot_until_cycles_purchase_activation() { let (service, root) = service_with_root(); let pending = service @@ -1560,9 +1569,9 @@ fn pending_database_creation_defers_mount_slot_until_credit_purchase_activation( assert!(pending.database_id.starts_with("db_")); assert_eq!(pending.name, "Pending"); assert_eq!(database_member_count(&root, &pending.database_id), 2); - assert_eq!(database_credits_balance(&root, &pending.database_id), 0); + assert_eq!(database_cycles_balance(&root, &pending.database_id), 0); assert_eq!( - database_credits_suspended_at(&root, &pending.database_id), + database_cycles_suspended_at(&root, &pending.database_id), Some(1) ); assert_eq!(mount_history_count(&root), 0); @@ -1577,45 +1586,46 @@ fn pending_database_creation_defers_mount_slot_until_credit_purchase_activation( .contains("database is pending") ); service - .validate_database_credit_purchase(&pending.database_id, 500) - .expect("anonymous preview should accept pending DB credit purchase"); + .validate_database_cycles_purchase(&pending.database_id, 500) + .expect("anonymous preview should accept pending DB cycle purchase"); let operation_id = service - .begin_database_credit_purchase(&pending.database_id, "payer", 1_000_000, 2) - .expect("credit purchase should begin"); + .begin_database_cycles_purchase(&pending.database_id, "payer", 1_000_000, 2) + .expect("cycle purchase should begin"); assert_eq!(mount_history_count(&root), 0); assert_eq!( database_index_row(&root, &pending.database_id), ("pending".to_string(), None, 0, None) ); let meta = service - .activate_pending_database_for_credit_purchase(&pending.database_id, 2) + .activate_pending_database_for_cycles_purchase(&pending.database_id, 2) .expect("pending activation should prepare") .expect("pending activation should allocate mount"); assert_eq!(meta.mount_id, 11); service .run_pending_database_migrations(&pending.database_id) .expect("pending migrations should run"); + let purchased_cycles = default_cycles_for_payment(1_000_000); service - .mark_database_credit_purchase_completed( + .mark_database_cycles_purchase_completed( operation_id, &pending.database_id, "payer", - 1_000_000, + purchased_cycles, ) - .expect("credit purchase should be marked completed"); + .expect("cycle purchase should be marked completed"); let balance = service - .credit_database_purchase( + .apply_database_cycles_purchase( operation_id, &pending.database_id, "payer", - 1_000_000, + purchased_cycles, 42, 4, ) - .expect("credit purchase should activate and credit"); + .expect("cycle purchase should activate and cycle"); - assert_eq!(balance, 1_000_000); + assert_eq!(balance, purchased_cycles); let row = database_index_row(&root, &pending.database_id); assert_eq!(row.0, "active"); assert_eq!(row.1, Some(11)); @@ -1627,17 +1637,23 @@ fn pending_database_creation_defers_mount_slot_until_credit_purchase_activation( } #[test] -fn pending_database_credit_purchase_cancel_does_not_allocate_mount_slot() { +fn pending_database_cycles_purchase_cancel_does_not_allocate_mount_slot() { let (service, root) = service_with_root(); let pending = service .reserve_pending_generated_database("Cancel", "owner", 1) .expect("pending database should create"); let operation_id = service - .begin_database_credit_purchase(&pending.database_id, "payer", 500, 3) - .expect("credit purchase should begin"); + .begin_database_cycles_purchase(&pending.database_id, "payer", 500, 3) + .expect("cycle purchase should begin"); + let purchased_cycles = default_cycles_for_payment(500); service - .cancel_database_credit_purchase(operation_id, &pending.database_id, "payer", 500) + .cancel_database_cycles_purchase( + operation_id, + &pending.database_id, + "payer", + purchased_cycles, + ) .expect("ledger reject cancel should delete operation"); assert_eq!(mount_history_count(&root), 0); @@ -1652,7 +1668,7 @@ fn pending_database_credit_purchase_cancel_does_not_allocate_mount_slot() { } #[test] -fn direct_credit_purchase_cancel_rejects_non_in_flight_operations() { +fn direct_cycles_purchase_cancel_rejects_non_in_flight_operations() { let (service, root) = service_with_root(); for database_id in ["completed-cancel", "ambiguous-cancel"] { service @@ -1661,36 +1677,49 @@ fn direct_credit_purchase_cancel_rejects_non_in_flight_operations() { } let completed = service - .begin_database_credit_purchase("completed-cancel", "payer", 500, 2) + .begin_database_cycles_purchase("completed-cancel", "payer", 500, 2) .expect("completed operation should begin"); + let completed_cycles = cycles_for_payment(&service, "completed-cancel", 500); service - .mark_database_credit_purchase_completed(completed, "completed-cancel", "payer", 500) + .mark_database_cycles_purchase_completed( + completed, + "completed-cancel", + "payer", + completed_cycles, + ) .expect("completed operation should be marked completed"); let completed_error = service - .cancel_database_credit_purchase(completed, "completed-cancel", "payer", 500) + .cancel_database_cycles_purchase(completed, "completed-cancel", "payer", completed_cycles) .expect_err("completed operation should not be directly cancellable"); - assert!(completed_error.contains("credit purchase operation is completed")); + assert!(completed_error.contains("cycle purchase operation is completed")); assert_eq!( database_pending_operation_count(&root, "completed-cancel"), 1 ); let ambiguous = service - .begin_database_credit_purchase("ambiguous-cancel", "payer", 700, 3) + .begin_database_cycles_purchase("ambiguous-cancel", "payer", 700, 3) .expect("ambiguous operation should begin"); + let ambiguous_cycles = cycles_for_payment(&service, "ambiguous-cancel", 700); service - .mark_database_credit_purchase_ambiguous(ambiguous, "ambiguous-cancel", "payer", 700, 4) + .mark_database_cycles_purchase_ambiguous( + ambiguous, + "ambiguous-cancel", + "payer", + ambiguous_cycles, + 4, + ) .expect("ambiguous operation should be marked ambiguous"); let ambiguous_error = service - .cancel_database_credit_purchase(ambiguous, "ambiguous-cancel", "payer", 700) + .cancel_database_cycles_purchase(ambiguous, "ambiguous-cancel", "payer", ambiguous_cycles) .expect_err("ambiguous operation should not be directly cancellable"); - assert!(ambiguous_error.contains("credit purchase operation is ambiguous")); + assert!(ambiguous_error.contains("cycle purchase operation is ambiguous")); assert_eq!( database_pending_operation_count(&root, "ambiguous-cancel"), 1 ); service - .repair_database_credit_purchase_cancel("ambiguous-cancel", ambiguous, "payer", 5) + .repair_database_cycles_purchase_cancel("ambiguous-cancel", ambiguous, "payer", 5) .expect("ambiguous operation should remain repair cancellable"); assert_eq!( database_pending_operation_count(&root, "ambiguous-cancel"), @@ -1699,34 +1728,35 @@ fn direct_credit_purchase_cancel_rejects_non_in_flight_operations() { } #[test] -fn pending_database_credit_purchase_cancel_rejects_after_activation_started() { +fn pending_database_cycles_purchase_cancel_rejects_after_activation_started() { let (service, root) = service_with_root(); let pending = service .reserve_pending_generated_database("Started", "owner", 1) .expect("pending database should create"); let operation_id = service - .begin_database_credit_purchase(&pending.database_id, "payer", 500, 2) - .expect("credit purchase should begin"); + .begin_database_cycles_purchase(&pending.database_id, "payer", 500, 2) + .expect("cycle purchase should begin"); + let purchased_cycles = default_cycles_for_payment(500); service - .mark_database_credit_purchase_ambiguous( + .mark_database_cycles_purchase_ambiguous( operation_id, &pending.database_id, "payer", - 500, + purchased_cycles, 3, ) - .expect("credit purchase ambiguity should record"); + .expect("cycle purchase ambiguity should record"); let meta = service - .activate_pending_database_for_credit_purchase(&pending.database_id, 4) + .activate_pending_database_for_cycles_purchase(&pending.database_id, 4) .expect("pending activation should prepare") .expect("activation should allocate mount"); assert_eq!(meta.mount_id, 11); let error = service - .repair_database_credit_purchase_cancel(&pending.database_id, operation_id, "payer", 5) + .repair_database_cycles_purchase_cancel(&pending.database_id, operation_id, "payer", 5) .expect_err("started activation should require complete repair"); - assert!(error.contains("complete credit purchase repair")); + assert!(error.contains("complete cycle purchase repair")); assert_eq!( database_pending_operation_count(&root, &pending.database_id), 1 @@ -1778,105 +1808,106 @@ fn database_create_rejects_duplicate_requested_id_for_internal_setup() { } #[test] -fn requested_database_create_starts_with_zero_credits_balance() { +fn requested_database_create_starts_with_zero_cycles_balance() { let (service, root) = service_with_root(); service .create_database("alpha", "owner", 1) .expect("database should create"); - assert_eq!(database_credits_balance(&root, "alpha"), 0); - assert_eq!(database_credits_suspended_at(&root, "alpha"), Some(1)); + assert_eq!(database_cycles_balance(&root, "alpha"), 0); + assert_eq!(database_cycles_suspended_at(&root, "alpha"), Some(1)); assert!(database_ledger_kinds(&root, "alpha").is_empty()); } #[test] -fn reservation_starts_with_zero_credits_balance() { +fn reservation_starts_with_zero_cycles_balance() { let (service, root) = service_with_root(); service .reserve_database("reserved", "Reserved", "owner", 1) .expect("reservation should create"); - assert_eq!(database_credits_balance(&root, "reserved"), 0); - assert_eq!(database_credits_suspended_at(&root, "reserved"), Some(1)); + assert_eq!(database_cycles_balance(&root, "reserved"), 0); + assert_eq!(database_cycles_suspended_at(&root, "reserved"), Some(1)); assert!(database_ledger_kinds(&root, "reserved").is_empty()); } #[test] -fn database_credits_purchase_allows_authenticated_non_owner() { +fn database_cycles_purchase_allows_authenticated_non_owner() { let (service, _root) = service_with_root(); service .create_database("alpha", "owner", 1) .expect("database should create"); - credit_database(&service, "alpha", "owner", 1_000, 1, 3); + cycle_database(&service, "alpha", "owner", 1_000, 1, 3); service - .begin_database_credit_purchase("alpha", "stranger", 100, 4) - .expect("authenticated non-owner should be allowed to purchase credits"); + .begin_database_cycles_purchase("alpha", "stranger", 100, 4) + .expect("authenticated non-owner should be allowed to purchase cycles"); } #[test] -fn credits_config_version_changes_only_for_effective_rate_updates() { +fn cycles_billing_config_version_changes_only_for_effective_rate_updates() { let (service, root) = service_with_root(); - assert_eq!(credits_config_version(&root), 1); + assert_eq!(cycles_billing_config_version(&root), 1); service - .update_credits_config( - CreditsConfigUpdate { - credit_units_per_kinic: 1_000_000, - min_update_credit_units: 1, + .update_cycles_billing_config( + CyclesBillingConfigUpdate { + cycles_per_kinic: 1_000_000_000_000, + min_update_cycles: 1_000_000, }, "rrkah-fqaaa-aaaaa-aaaaq-cai", ) .expect("same config should update without version bump"); - assert_eq!(credits_config_version(&root), 1); + assert_eq!(cycles_billing_config_version(&root), 1); service - .update_credits_config( - CreditsConfigUpdate { - credit_units_per_kinic: 2_000_000, - min_update_credit_units: 1, + .update_cycles_billing_config( + CyclesBillingConfigUpdate { + cycles_per_kinic: 2_000_000_000_000, + min_update_cycles: 1_000_000, }, "rrkah-fqaaa-aaaaa-aaaaq-cai", ) .expect("changed config should update"); - assert_eq!(credits_config_version(&root), 2); + assert_eq!(cycles_billing_config_version(&root), 2); } #[test] -fn credit_purchase_preview_returns_fixed_payment_inputs() { +fn cycles_purchase_preview_returns_fixed_payment_inputs() { let (service, _root) = service_with_root(); service .create_database("alpha", "owner", 1) .expect("database should create"); let preview = service - .preview_database_credit_purchase("alpha", 500) + .preview_database_cycles_purchase("alpha", 50_000) .expect("preview should succeed"); assert_eq!(preview.payment_amount_e8s, 50_000); + assert_eq!(preview.cycles, 500_000_000); assert_eq!(preview.ledger_fee_e8s, KINIC_LEDGER_FEE_E8S); - assert_eq!(preview.credit_units_per_kinic, 1_000_000); + assert_eq!(preview.cycles_per_kinic, 1_000_000_000_000); assert_eq!(preview.config_version, 1); } #[test] -fn credit_purchase_begin_rejects_stale_expected_values_before_pending_create() { +fn cycles_purchase_begin_rejects_stale_expected_values_before_pending_create() { let (service, root) = service_with_root(); service .create_database("alpha", "owner", 1) .expect("database should create"); let stale_amount = service - .begin_database_credit_purchase_with_ledger_details( - DatabaseCreditPurchaseWithLedgerDetails { + .begin_database_cycles_purchase_with_ledger_details( + DatabaseCyclesPurchaseWithLedgerDetails { database_id: "alpha", caller: "payer", - credit_units: 500, - expected_payment_amount_e8s: 50_000_001, + payment_amount_e8s: 50_000, + expected_cycles: 500_000_001, expected_config_version: 1, - ledger: CreditsPendingLedgerDetailsInput { + ledger: CyclesPendingLedgerDetailsInput { from_owner: "payer", from_subaccount: None, to_owner: "canister", @@ -1888,18 +1919,18 @@ fn credit_purchase_begin_rejects_stale_expected_values_before_pending_create() { }, ) .expect_err("stale amount should reject"); - assert!(stale_amount.contains("payment amount changed")); + assert!(stale_amount.contains("cycles purchase amount changed")); assert_eq!(database_pending_operation_count(&root, "alpha"), 0); let stale_version = service - .begin_database_credit_purchase_with_ledger_details( - DatabaseCreditPurchaseWithLedgerDetails { + .begin_database_cycles_purchase_with_ledger_details( + DatabaseCyclesPurchaseWithLedgerDetails { database_id: "alpha", caller: "payer", - credit_units: 500, - expected_payment_amount_e8s: 50_000_000, + payment_amount_e8s: 50_000, + expected_cycles: 500_000_000, expected_config_version: 2, - ledger: CreditsPendingLedgerDetailsInput { + ledger: CyclesPendingLedgerDetailsInput { from_owner: "payer", from_subaccount: None, to_owner: "canister", @@ -1911,19 +1942,20 @@ fn credit_purchase_begin_rejects_stale_expected_values_before_pending_create() { }, ) .expect_err("stale version should reject"); - assert!(stale_version.contains("credits config changed")); + assert!(stale_version.contains("cycles billing config changed")); assert_eq!(database_pending_operation_count(&root, "alpha"), 0); } #[test] -fn database_credit_purchase_settlement_survives_owner_role_change() { +fn database_cycles_purchase_settlement_survives_owner_role_change() { let (service, root) = service_with_root(); service .create_database("alpha", "owner", 1) .expect("database should create"); let operation_id = service - .begin_database_credit_purchase("alpha", "owner", 500, 2) - .expect("owner should start credit purchase"); + .begin_database_cycles_purchase("alpha", "owner", 500, 2) + .expect("owner should start cycle purchase"); + let purchased_cycles = cycles_for_payment(&service, "alpha", 500); service .grant_database_access("alpha", "owner", "replacement", DatabaseRole::Owner, 2) .expect("replacement owner should grant"); @@ -1931,23 +1963,26 @@ fn database_credit_purchase_settlement_survives_owner_role_change() { .revoke_database_access("alpha", "replacement", "owner") .expect("replacement should revoke original owner"); service - .mark_database_credit_purchase_completed(operation_id, "alpha", "owner", 500) - .expect("credit purchase should be marked completed"); + .mark_database_cycles_purchase_completed(operation_id, "alpha", "owner", purchased_cycles) + .expect("cycle purchase should be marked completed"); let balance = service - .credit_database_purchase(operation_id, "alpha", "owner", 500, 7, 3) - .expect("started credit purchase should settle"); + .apply_database_cycles_purchase(operation_id, "alpha", "owner", purchased_cycles, 7, 3) + .expect("started cycle purchase should settle"); - assert_eq!(balance, 500); - assert_eq!(database_credits_balance(&root, "alpha"), 500); + assert_eq!(balance, purchased_cycles); + assert_eq!( + database_cycles_balance(&root, "alpha"), + purchased_cycles as i64 + ); assert_eq!( database_ledger_kinds(&root, "alpha"), - vec!["credit_purchase"] + vec!["cycles_purchase"] ); } #[test] -fn pending_database_credit_purchase_blocks_delete_until_resolved() { +fn pending_database_cycles_purchase_blocks_delete_until_resolved() { let (service, root) = service_with_root(); for database_id in ["complete", "cancel", "ambiguous"] { service @@ -1955,60 +1990,67 @@ fn pending_database_credit_purchase_blocks_delete_until_resolved() { .expect("database should create"); } let complete = service - .begin_database_credit_purchase("complete", "owner", 500, 2) - .expect("credit purchase should begin"); + .begin_database_cycles_purchase("complete", "owner", 500, 2) + .expect("cycle purchase should begin"); let cancel = service - .begin_database_credit_purchase("cancel", "owner", 500, 2) - .expect("credit purchase should begin"); + .begin_database_cycles_purchase("cancel", "owner", 500, 2) + .expect("cycle purchase should begin"); let ambiguous = service - .begin_database_credit_purchase("ambiguous", "owner", 500, 2) - .expect("credit purchase should begin"); + .begin_database_cycles_purchase("ambiguous", "owner", 500, 2) + .expect("cycle purchase should begin"); + let purchased_cycles = cycles_for_payment(&service, "complete", 500); for database_id in ["complete", "cancel", "ambiguous"] { let error = service .delete_database(delete_request(database_id), "owner", 3) - .expect_err("pending credit purchase should block delete"); - assert!(error.contains("pending credit operation")); + .expect_err("pending cycle purchase should block delete"); + assert!(error.contains("pending cycle operation")); assert_eq!(database_pending_operation_count(&root, database_id), 1); } service - .mark_database_credit_purchase_completed(complete, "complete", "owner", 500) - .expect("credit purchase should be marked completed"); + .mark_database_cycles_purchase_completed(complete, "complete", "owner", purchased_cycles) + .expect("cycle purchase should be marked completed"); service - .credit_database_purchase(complete, "complete", "owner", 500, 10, 4) - .expect("credit purchase should complete"); + .apply_database_cycles_purchase(complete, "complete", "owner", purchased_cycles, 10, 4) + .expect("cycle purchase should complete"); service - .cancel_database_credit_purchase(cancel, "cancel", "owner", 500) - .expect("credit purchase should cancel"); + .cancel_database_cycles_purchase(cancel, "cancel", "owner", purchased_cycles) + .expect("cycle purchase should cancel"); service - .mark_database_credit_purchase_ambiguous(ambiguous, "ambiguous", "owner", 500, 4) - .expect("ambiguous credit purchase should record"); + .mark_database_cycles_purchase_ambiguous( + ambiguous, + "ambiguous", + "owner", + purchased_cycles, + 4, + ) + .expect("ambiguous cycle purchase should record"); for database_id in ["complete", "cancel"] { assert_eq!(database_pending_operation_count(&root, database_id), 0); service .delete_database(delete_request(database_id), "owner", 5) - .expect("resolved credit purchase should allow delete"); + .expect("resolved cycle purchase should allow delete"); } assert_eq!(database_pending_operation_count(&root, "ambiguous"), 1); let error = service .delete_database(delete_request("ambiguous"), "owner", 5) - .expect_err("ambiguous credit purchase should keep delete blocked"); - assert!(error.contains("pending credit operation")); + .expect_err("ambiguous cycle purchase should keep delete blocked"); + assert!(error.contains("pending cycle operation")); } #[test] -fn delete_database_removes_index_rows_and_discards_remaining_credits() { +fn delete_database_removes_index_rows_and_discards_remaining_cycles() { let (service, root) = service_with_root(); service .create_database("funded", "owner", 1) .expect("database should create"); - credit_database(&service, "funded", "owner", 100_000_000, 1, 2); + cycle_database(&service, "funded", "owner", 100_000_000, 1, 2); service .delete_database(delete_request("funded"), "owner", 3) - .expect("remaining credits should be discarded on delete"); + .expect("remaining cycles should be discarded on delete"); assert!(!database_index_row_exists(&root, "funded")); assert_eq!(database_member_count(&root, "funded"), 0); assert_eq!(database_pending_operation_count(&root, "funded"), 0); @@ -2016,7 +2058,7 @@ fn delete_database_removes_index_rows_and_discards_remaining_credits() { } #[test] -fn pending_credits_operations_are_visible_to_owner_and_governance_only() { +fn pending_cycles_operations_are_visible_to_owner_and_governance_only() { let (service, _root) = service_with_root(); service .create_database("alpha", "owner", 1) @@ -2028,24 +2070,24 @@ fn pending_credits_operations_are_visible_to_owner_and_governance_only() { .grant_database_access("alpha", "owner", "reader", DatabaseRole::Reader, 2) .expect("reader should be granted"); service - .begin_database_credit_purchase("alpha", "payer", 500, 3) - .expect("credit purchase should begin"); + .begin_database_cycles_purchase("alpha", "payer", 500, 3) + .expect("cycle purchase should begin"); let owner_page = service - .list_database_credit_pending_operations("alpha", "owner", None, 10) + .list_database_cycle_pending_operations("alpha", "owner", None, 10) .expect("owner should list pending operations"); assert_eq!(owner_page.entries.len(), 1); - assert_eq!(owner_page.entries[0].kind, "credit_purchase"); + assert_eq!(owner_page.entries[0].kind, "cycles_purchase"); assert_eq!(owner_page.entries[0].caller, "payer"); let governance_page = service - .list_database_credit_pending_operations("alpha", "rrkah-fqaaa-aaaaa-aaaaq-cai", None, 10) + .list_database_cycle_pending_operations("alpha", "rrkah-fqaaa-aaaaa-aaaaq-cai", None, 10) .expect("governance should list pending operations"); assert_eq!(governance_page.entries.len(), 1); for caller in ["writer", "reader", "2vxsx-fae"] { let error = service - .list_database_credit_pending_operations("alpha", caller, None, 10) + .list_database_cycle_pending_operations("alpha", caller, None, 10) .expect_err("non-owner should not list pending operations"); assert!( error.contains("principal lacks required database role") @@ -2055,7 +2097,7 @@ fn pending_credits_operations_are_visible_to_owner_and_governance_only() { } #[test] -fn repair_credit_purchase_cancel_allows_payer_or_owner_only() { +fn repair_cycles_purchase_cancel_allows_payer_or_owner_only() { let (service, root) = service_with_root(); for database_id in ["payer-cancel", "owner-cancel", "reject-cancel"] { service @@ -2070,50 +2112,71 @@ fn repair_credit_purchase_cancel_allows_payer_or_owner_only() { } let payer_cancel = service - .begin_database_credit_purchase("payer-cancel", "payer", 500, 4) + .begin_database_cycles_purchase("payer-cancel", "payer", 500, 4) .expect("payer cancel operation should begin"); + let payer_cancel_cycles = cycles_for_payment(&service, "payer-cancel", 500); service - .mark_database_credit_purchase_ambiguous(payer_cancel, "payer-cancel", "payer", 500, 5) + .mark_database_cycles_purchase_ambiguous( + payer_cancel, + "payer-cancel", + "payer", + payer_cancel_cycles, + 5, + ) .expect("payer cancel should mark ambiguous"); service - .repair_database_credit_purchase_cancel("payer-cancel", payer_cancel, "payer", 6) + .repair_database_cycles_purchase_cancel("payer-cancel", payer_cancel, "payer", 6) .expect("payer should cancel own pending purchase"); assert_eq!(database_pending_operation_count(&root, "payer-cancel"), 0); let owner_cancel = service - .begin_database_credit_purchase("owner-cancel", "payer", 700, 7) + .begin_database_cycles_purchase("owner-cancel", "payer", 700, 7) .expect("owner cancel operation should begin"); + let owner_cancel_cycles = cycles_for_payment(&service, "owner-cancel", 700); service - .mark_database_credit_purchase_ambiguous(owner_cancel, "owner-cancel", "payer", 700, 8) + .mark_database_cycles_purchase_ambiguous( + owner_cancel, + "owner-cancel", + "payer", + owner_cancel_cycles, + 8, + ) .expect("owner cancel should mark ambiguous"); service - .repair_database_credit_purchase_cancel("owner-cancel", owner_cancel, "owner", 9) + .repair_database_cycles_purchase_cancel("owner-cancel", owner_cancel, "owner", 9) .expect("owner should cancel pending purchase"); assert_eq!( database_ledger_kinds(&root, "owner-cancel"), vec![ - "credit_purchase_ambiguous", - "credit_purchase_repair_cancelled" + "cycles_purchase_ambiguous", + "cycles_purchase_repair_cancelled" ] ); let reject_cancel = service - .begin_database_credit_purchase("reject-cancel", "payer", 900, 10) + .begin_database_cycles_purchase("reject-cancel", "payer", 900, 10) .expect("reject cancel operation should begin"); + let reject_cancel_cycles = cycles_for_payment(&service, "reject-cancel", 900); service - .mark_database_credit_purchase_ambiguous(reject_cancel, "reject-cancel", "payer", 900, 11) + .mark_database_cycles_purchase_ambiguous( + reject_cancel, + "reject-cancel", + "payer", + reject_cancel_cycles, + 11, + ) .expect("reject cancel should mark ambiguous"); for caller in ["writer", "reader", "outsider"] { let error = service - .repair_database_credit_purchase_cancel("reject-cancel", reject_cancel, caller, 12) + .repair_database_cycles_purchase_cancel("reject-cancel", reject_cancel, caller, 12) .expect_err("non-payer non-owner should reject"); - assert!(error.contains("not credit purchase payer or database owner")); + assert!(error.contains("not cycle purchase payer or database owner")); assert_eq!(database_pending_operation_count(&root, "reject-cancel"), 1); } } #[test] -fn credits_history_redacts_principals_for_non_owner_readers() { +fn cycles_history_redacts_principals_for_non_owner_readers() { let (service, _root) = service_with_root(); service .create_database("alpha", "owner", 1) @@ -2124,10 +2187,10 @@ fn credits_history_redacts_principals_for_non_owner_readers() { service .grant_database_access("alpha", "owner", "reader", DatabaseRole::Reader, 2) .expect("reader should be granted"); - credit_database(&service, "alpha", "payer-principal", 500, 42, 3); + cycle_database(&service, "alpha", "payer-principal", 500, 42, 3); let reader_entry = service - .list_database_credit_entries("alpha", "reader", None, 10) + .list_database_cycle_entries("alpha", "reader", None, 10) .expect("reader should list history") .entries .remove(0); @@ -2135,28 +2198,28 @@ fn credits_history_redacts_principals_for_non_owner_readers() { assert_eq!(reader_entry.ledger_block_index, Some(42)); let writer_entry = service - .list_database_credit_entries("alpha", "writer", None, 10) + .list_database_cycle_entries("alpha", "writer", None, 10) .expect("writer should list history") .entries .remove(0); assert_eq!(writer_entry.caller, "redacted"); let owner_entry = service - .list_database_credit_entries("alpha", "owner", None, 10) + .list_database_cycle_entries("alpha", "owner", None, 10) .expect("owner should list history") .entries .remove(0); assert_eq!(owner_entry.caller, "payer-principal"); let governance_entry = service - .list_database_credit_entries("alpha", "rrkah-fqaaa-aaaaa-aaaaq-cai", None, 10) + .list_database_cycle_entries("alpha", "rrkah-fqaaa-aaaaa-aaaaq-cai", None, 10) .expect("governance should list history without membership") .entries .remove(0); assert_eq!(governance_entry.caller, "payer-principal"); let error = service - .list_database_credit_entries("alpha", "outsider", None, 10) + .list_database_cycle_entries("alpha", "outsider", None, 10) .expect_err("outsider should not list history"); assert!(error.contains("principal has no access")); } @@ -2171,59 +2234,64 @@ fn verified_complete_allows_authenticated_caller_and_owner_cancel() { .create_database("cancel", "owner", 1) .expect("database should create"); let complete = service - .begin_database_credit_purchase("complete", "payer", 500, 2) - .expect("credit purchase should begin"); + .begin_database_cycles_purchase("complete", "payer", 500, 2) + .expect("cycle purchase should begin"); let cancel = service - .begin_database_credit_purchase("cancel", "payer", 700, 2) - .expect("credit purchase should begin"); + .begin_database_cycles_purchase("cancel", "payer", 700, 2) + .expect("cycle purchase should begin"); + let complete_cycles = cycles_for_payment(&service, "complete", 500); + let cancel_cycles = cycles_for_payment(&service, "cancel", 700); service - .mark_database_credit_purchase_ambiguous(complete, "complete", "payer", 500, 3) - .expect("credit purchase ambiguity should record"); + .mark_database_cycles_purchase_ambiguous(complete, "complete", "payer", complete_cycles, 3) + .expect("cycle purchase ambiguity should record"); service - .mark_database_credit_purchase_ambiguous(cancel, "cancel", "payer", 700, 3) - .expect("credit purchase ambiguity should record"); + .mark_database_cycles_purchase_ambiguous(cancel, "cancel", "payer", cancel_cycles, 3) + .expect("cycle purchase ambiguity should record"); let balance = service - .repair_database_credit_purchase_complete("complete", complete, 77, 4) - .expect("authenticated caller should complete verified credit purchase"); - assert_eq!(balance, 500); + .repair_database_cycles_purchase_complete("complete", complete, 77, 4) + .expect("authenticated caller should complete verified cycle purchase"); + assert_eq!(balance, complete_cycles); service - .repair_database_credit_purchase_cancel("cancel", cancel, "owner", 4) - .expect("owner should cancel ambiguous credit purchase after verification"); + .repair_database_cycles_purchase_cancel("cancel", cancel, "owner", 4) + .expect("owner should cancel ambiguous cycle purchase after verification"); - assert_eq!(database_credits_balance(&root, "complete"), 500); - assert_eq!(database_credits_balance(&root, "cancel"), 0); + assert_eq!( + database_cycles_balance(&root, "complete"), + complete_cycles as i64 + ); + assert_eq!(database_cycles_balance(&root, "cancel"), 0); assert_eq!(database_pending_operation_count(&root, "complete"), 0); assert_eq!(database_pending_operation_count(&root, "cancel"), 0); assert_eq!( database_ledger_kinds(&root, "complete"), vec![ - "credit_purchase_ambiguous", - "credit_purchase_repair_complete" + "cycles_purchase_ambiguous", + "cycles_purchase_repair_complete" ] ); assert_eq!( database_ledger_kinds(&root, "cancel"), vec![ - "credit_purchase_ambiguous", - "credit_purchase_repair_cancelled" + "cycles_purchase_ambiguous", + "cycles_purchase_repair_cancelled" ] ); let entries = service - .list_database_credit_entries("complete", "owner", None, 10) - .expect("credits entries should load") + .list_database_cycle_entries("complete", "owner", None, 10) + .expect("cycles entries should load") .entries; assert_eq!(entries[0].caller, "payer"); assert_eq!(entries[1].caller, "payer"); assert_eq!(entries[1].ledger_block_index, Some(77)); let cancel_entries = service - .list_database_credit_entries("cancel", "owner", None, 10) + .list_database_cycle_entries("cancel", "owner", None, 10) .expect("cancel entries should load") .entries; assert_eq!(cancel_entries[1].caller, "owner"); - assert_eq!(cancel_entries[1].payment_amount_e8s, Some(70_000)); - assert_eq!(cancel_entries[1].balance_after_credit_units, 0); + assert_eq!(cancel_entries[1].payment_amount_e8s, Some(700)); + assert_eq!(cancel_entries[1].balance_after_cycles, 0); assert_eq!(cancel_entries[1].ledger_block_index, None); } @@ -2254,45 +2322,45 @@ fn database_rename_requires_owner() { } #[test] -fn zero_cycle_charge_skips_credit_ledger() { +fn zero_cycle_charge_skips_cycle_ledger() { let (service, root) = service_with_root(); service .create_database("alpha", "owner", 1) .expect("database should create"); - credit_database(&service, "alpha", "owner", 500, 7, 2); + cycle_database(&service, "alpha", "owner", 500, 7, 2); let config = service - .credits_config() - .expect("credits config should load"); + .cycles_billing_config() + .expect("cycles config should load"); service .charge_database_update(&config, "alpha", "owner", "write_node", 0, 3) .expect("zero-cycle update should skip charge"); - assert_eq!(database_credits_balance(&root, "alpha"), 500); + assert_eq!(database_cycles_balance(&root, "alpha"), 5_000_000); assert_eq!( database_ledger_kinds(&root, "alpha"), - vec!["credit_purchase"] + vec!["cycles_purchase"] ); service .charge_database_update(&config, "alpha", "owner", "write_node", 1_000_000, 4) - .expect("charged update should record credit ledger"); + .expect("charged update should record cycle ledger"); - assert_eq!(database_credits_balance(&root, "alpha"), 499); + assert_eq!(database_cycles_balance(&root, "alpha"), 4_000_000); service .charge_database_update(&config, "alpha", "owner", "write_node", 1_000_001, 5) - .expect("rounded-up update should record credit ledger"); + .expect("raw update cycle charge should record cycle ledger"); - assert_eq!(database_credits_balance(&root, "alpha"), 497); + assert_eq!(database_cycles_balance(&root, "alpha"), 2_999_999); let entries = service - .list_database_credit_entries("alpha", "owner", None, 10) - .expect("credit entries should load") + .list_database_cycle_entries("alpha", "owner", None, 10) + .expect("cycle entries should load") .entries; assert_eq!(entries.len(), 3); assert_eq!(entries[1].kind, "charge"); - assert_eq!(entries[1].amount_credit_units, -1); + assert_eq!(entries[1].amount_cycles, -1_000_000); assert_eq!(entries[2].kind, "charge"); - assert_eq!(entries[2].amount_credit_units, -2); + assert_eq!(entries[2].amount_cycles, -1_000_001); } #[test] diff --git a/crates/vfs_runtime/tests/database_service_pbt.rs b/crates/vfs_runtime/tests/database_service_pbt.rs index 4bbffbab..14d5cb28 100644 --- a/crates/vfs_runtime/tests/database_service_pbt.rs +++ b/crates/vfs_runtime/tests/database_service_pbt.rs @@ -1,5 +1,5 @@ // Where: crates/vfs_runtime/tests/database_service_pbt.rs -// What: Property tests for database credits and lifecycle operation sequences. +// What: Property tests for database cycles and lifecycle operation sequences. // Why: Randomized state-machine checks catch partial updates across balances, status, and mounts. use std::path::{Path, PathBuf}; @@ -7,15 +7,15 @@ use proptest::prelude::*; use proptest::test_runner::{Config as ProptestConfig, FileFailurePersistence}; use sha2::{Digest, Sha256}; use tempfile::{TempDir, tempdir}; -use vfs_runtime::{CYCLES_PER_CREDIT_UNIT, VfsService}; +use vfs_runtime::VfsService; use vfs_types::DatabaseStatus; const OWNER: &str = "owner"; -const INITIAL_DATABASE_CREDITS: u64 = 1_000; +const INITIAL_DATABASE_PAYMENT_E8S: u64 = 1_000; #[derive(Clone, Debug)] enum RuntimeOp { - PurchaseDatabaseCredits { amount: u64 }, + PurchaseDatabaseCycles { amount: u64 }, Charge { cycles_delta: u128 }, ArchiveFinalize, RestoreArchived, @@ -23,7 +23,7 @@ enum RuntimeOp { #[derive(Debug)] struct Model { - database_credits: u64, + database_cycles: u64, status: DatabaseStatus, archive_bytes: Option>, archive_hash: Option>, @@ -49,7 +49,7 @@ fn property_config() -> ProptestConfig { fn operation_strategy() -> impl Strategy { prop_oneof![ - 4 => (1_u64..=250_000).prop_map(|amount| RuntimeOp::PurchaseDatabaseCredits { amount }), + 4 => (1_u64..=250_000).prop_map(|amount| RuntimeOp::PurchaseDatabaseCycles { amount }), 4 => (0_u128..=20_000_u128).prop_map(|cycles_delta| RuntimeOp::Charge { cycles_delta }), 2 => Just(RuntimeOp::ArchiveFinalize), 2 => Just(RuntimeOp::RestoreArchived), @@ -70,46 +70,48 @@ fn service_with_root() -> TestService { } } -fn create_seeded_database(service: &VfsService) -> String { +fn create_seeded_database(service: &VfsService) -> (String, u64) { let meta = service .create_generated_database("PBT database", OWNER, 2) .expect("database should create"); - credit_database( + let initial_cycles = purchase_database_cycles( service, &meta.database_id, OWNER, - INITIAL_DATABASE_CREDITS, + INITIAL_DATABASE_PAYMENT_E8S, 1, 3, ) - .expect("database seed should credit"); - meta.database_id + .expect("database seed should cycle"); + (meta.database_id, initial_cycles) } -fn credit_database( +fn purchase_database_cycles( service: &VfsService, database_id: &str, caller: &str, - credit_units: u64, + payment_amount_e8s: u64, block_index: u64, now: i64, ) -> Result { + let preview = service.preview_database_cycles_purchase(database_id, payment_amount_e8s)?; let operation_id = - service.begin_database_credit_purchase(database_id, caller, credit_units, now)?; - service.mark_database_credit_purchase_completed( + service.begin_database_cycles_purchase(database_id, caller, payment_amount_e8s, now)?; + service.mark_database_cycles_purchase_completed( operation_id, database_id, caller, - credit_units, + preview.cycles, )?; - service.credit_database_purchase( + service.apply_database_cycles_purchase( operation_id, database_id, caller, - credit_units, + preview.cycles, block_index, now, - ) + )?; + Ok(preview.cycles) } fn status_and_mount(service: &VfsService, database_id: &str) -> (DatabaseStatus, Option) { @@ -157,22 +159,19 @@ fn database_bytes(root: &Path, service: &VfsService, database_id: &str) -> (Vec< } fn charge_amount(cycles_delta: u128) -> u64 { - cycles_delta - .div_ceil(CYCLES_PER_CREDIT_UNIT) - .try_into() - .expect("generated charge fits u64") + u64::try_from(cycles_delta).expect("generated charge fits u64") } fn assert_invariants(service: &VfsService, database_id: &str, model: &Model) { let database_entries = service - .list_database_credit_entries(database_id, OWNER, None, 100) + .list_database_cycle_entries(database_id, OWNER, None, 100) .expect("database ledger should load") .entries; let database_after = database_entries .last() .expect("database ledger should not be empty") - .balance_after_credit_units; - assert_eq!(database_after, model.database_credits); + .balance_after_cycles; + assert_eq!(database_after, model.database_cycles); let (status, mount_id) = status_and_mount(service, database_id); assert_eq!(status, model.status); @@ -204,16 +203,22 @@ fn apply_operation( step: i64, ) { match operation { - RuntimeOp::PurchaseDatabaseCredits { amount } => { - let result = - credit_database(service, database_id, OWNER, amount, step as u64 + 10, step); - result.expect("database credit purchase should succeed"); - model.database_credits += amount; + RuntimeOp::PurchaseDatabaseCycles { amount } => { + let purchased_cycles = purchase_database_cycles( + service, + database_id, + OWNER, + amount, + step as u64 + 10, + step, + ) + .expect("database cycle purchase should succeed"); + model.database_cycles += purchased_cycles; } RuntimeOp::Charge { cycles_delta } => { let config = service - .credits_config() - .expect("credits config should load"); + .cycles_billing_config() + .expect("cycles config should load"); let result = service.charge_database_update( &config, database_id, @@ -222,9 +227,9 @@ fn apply_operation( cycles_delta, step, ); - result.expect("database charge should record against credit account"); - let charge = model.database_credits.min(charge_amount(cycles_delta)); - model.database_credits -= charge; + result.expect("database charge should record against cycle account"); + let charge = model.database_cycles.min(charge_amount(cycles_delta)); + model.database_cycles -= charge; } RuntimeOp::ArchiveFinalize => { if model.status == DatabaseStatus::Active { @@ -299,9 +304,9 @@ proptest! { fn database_service_pbt(operations in prop::collection::vec(operation_strategy(), 1..40)) { let env = service_with_root(); let service = &env.service; - let database_id = create_seeded_database(service); + let (database_id, initial_cycles) = create_seeded_database(service); let mut model = Model { - database_credits: INITIAL_DATABASE_CREDITS, + database_cycles: initial_cycles, status: DatabaseStatus::Active, archive_bytes: None, archive_hash: None, diff --git a/crates/vfs_runtime/tests/database_service_pbt_ext.rs b/crates/vfs_runtime/tests/database_service_pbt_ext.rs index 253b1697..8bd44d23 100644 --- a/crates/vfs_runtime/tests/database_service_pbt_ext.rs +++ b/crates/vfs_runtime/tests/database_service_pbt_ext.rs @@ -1,5 +1,5 @@ // Where: crates/vfs_runtime/tests/database_service_pbt_ext.rs -// What: Supplemental property tests for credits suspension, mount history, and restore chunks. +// What: Supplemental property tests for cycles suspension, mount history, and restore chunks. // Why: The main PBT covers common flows; these tests target branch-risky edge state. use std::collections::BTreeSet; use std::path::{Path, PathBuf}; @@ -9,15 +9,15 @@ use proptest::test_runner::{Config as ProptestConfig, FileFailurePersistence}; use rusqlite::{Connection, params}; use sha2::{Digest, Sha256}; use tempfile::{TempDir, tempdir}; -use vfs_runtime::{CYCLES_PER_CREDIT_UNIT, DEFAULT_MIN_UPDATE_CREDIT_UNITS, VfsService}; +use vfs_runtime::{DEFAULT_MIN_UPDATE_CYCLES, VfsService}; use vfs_types::{DatabaseStatus, DeleteDatabaseRequest, NodeKind, WriteNodeRequest}; const OWNER: &str = "owner"; -const DEPOSIT_CREDITS: u64 = 1_000; +const DEPOSIT_PAYMENT_E8S: u64 = 1_000; #[derive(Clone, Debug)] -enum CreditsOp { - PurchaseDatabaseCredits { amount: u64 }, +enum CyclesOp { + PurchaseDatabaseCycles { amount: u64 }, Charge { cycles_delta: u128 }, } @@ -59,21 +59,21 @@ fn service_with_root() -> TestService { } } -fn create_billed_database(service: &VfsService, name: &str, now: i64) -> String { +fn create_billed_database(service: &VfsService, name: &str, now: i64) -> (String, u64) { let database_id = service .create_generated_database(name, OWNER, now + 1) .expect("database should create") .database_id; - credit_database( + let deposit_cycles = purchase_database_cycles( service, &database_id, OWNER, - DEPOSIT_CREDITS, + DEPOSIT_PAYMENT_E8S, now as u64, now + 2, ) - .expect("database seed should credit"); - database_id + .expect("database seed should cycle"); + (database_id, deposit_cycles) } fn delete_request(database_id: &str) -> DeleteDatabaseRequest { @@ -82,37 +82,39 @@ fn delete_request(database_id: &str) -> DeleteDatabaseRequest { } } -fn credit_database( +fn purchase_database_cycles( service: &VfsService, database_id: &str, caller: &str, - credit_units: u64, + payment_amount_e8s: u64, block_index: u64, now: i64, ) -> Result { + let preview = service.preview_database_cycles_purchase(database_id, payment_amount_e8s)?; let operation_id = - service.begin_database_credit_purchase(database_id, caller, credit_units, now)?; - service.mark_database_credit_purchase_completed( + service.begin_database_cycles_purchase(database_id, caller, payment_amount_e8s, now)?; + service.mark_database_cycles_purchase_completed( operation_id, database_id, caller, - credit_units, + preview.cycles, )?; - service.credit_database_purchase( + service.apply_database_cycles_purchase( operation_id, database_id, caller, - credit_units, + preview.cycles, block_index, now, - ) + )?; + Ok(preview.cycles) } fn db_account(root: &Path, database_id: &str) -> (u64, Option) { let conn = Connection::open(root.join("index.sqlite3")).expect("index should open"); conn.query_row( - "SELECT balance_credit_units, suspended_at_ms - FROM database_credit_accounts + "SELECT balance_cycles, suspended_at_ms + FROM database_cycle_accounts WHERE database_id = ?1", params![database_id], |row| { @@ -121,7 +123,7 @@ fn db_account(root: &Path, database_id: &str) -> (u64, Option) { Ok((balance.max(0) as u64, suspended_at_ms)) }, ) - .expect("credit account should exist") + .expect("cycle account should exist") } fn status_and_mount(service: &VfsService, database_id: &str) -> (DatabaseStatus, Option) { @@ -135,16 +137,15 @@ fn status_and_mount(service: &VfsService, database_id: &str) -> (DatabaseStatus, } fn charge_amount(cycles_delta: u128) -> u64 { - let variable = cycles_delta.div_ceil(CYCLES_PER_CREDIT_UNIT); - u64::try_from(variable).expect("generated charge fits u64") + u64::try_from(cycles_delta).expect("generated charge fits u64") } fn assert_database_ledger_chain(root: &Path, database_id: &str, expected_balance: u64) { let conn = Connection::open(root.join("index.sqlite3")).expect("index should open"); let mut stmt = conn .prepare( - "SELECT kind, amount_credit_units, balance_after_credit_units, method, cycles_delta - FROM database_credit_ledger + "SELECT kind, amount_cycles, balance_after_cycles, method, cycles_delta + FROM database_cycle_ledger WHERE database_id = ?1 ORDER BY entry_id ASC", ) @@ -167,9 +168,9 @@ fn assert_database_ledger_chain(root: &Path, database_id: &str, expected_balance balance += amount; assert_eq!(balance_after, balance, "database ledger chain broke"); match kind.as_str() { - "credit_purchase" => assert!(amount > 0), + "cycles_purchase" => assert!(amount > 0), "charge" => assert!(amount <= 0), - "delete_credit_discard" => assert!(amount <= 0), + "delete_cycle_discard" => assert!(amount <= 0), "suspend" => { assert_eq!(amount, 0); assert!(method.is_some()); @@ -270,10 +271,10 @@ fn finalize_restore_from_bytes( .expect("complete restore should finalize"); } -fn credits_operation_strategy() -> impl Strategy { +fn cycles_operation_strategy() -> impl Strategy { prop_oneof![ - 4 => (1_u64..=2_000_000).prop_map(|amount| CreditsOp::PurchaseDatabaseCredits { amount }), - 5 => (0_u128..=8_000_000_000_u128).prop_map(|cycles_delta| CreditsOp::Charge { cycles_delta }), + 4 => (1_u64..=2_000_000).prop_map(|amount| CyclesOp::PurchaseDatabaseCycles { amount }), + 5 => (0_u128..=8_000_000_000_u128).prop_map(|cycles_delta| CyclesOp::Charge { cycles_delta }), ] } @@ -289,27 +290,28 @@ proptest! { #![proptest_config(property_config())] #[test] - fn database_service_pbt_credits_suspension_and_ledger_chain( - operations in prop::collection::vec(credits_operation_strategy(), 1..50), + fn database_service_pbt_cycles_suspension_and_ledger_chain( + operations in prop::collection::vec(cycles_operation_strategy(), 1..50), ) { let env = service_with_root(); let service = &env.service; - let database_id = create_billed_database(service, "credits-pbt", 1); - let mut database_balance = DEPOSIT_CREDITS; + let (database_id, deposit_cycles) = create_billed_database(service, "cycles-pbt", 1); + let mut database_balance = deposit_cycles; for (index, operation) in operations.into_iter().enumerate() { let now = index as i64 + 100; match operation { - CreditsOp::PurchaseDatabaseCredits { amount } => { - credit_database(service, &database_id, OWNER, amount, now as u64, now) - .expect("credit purchase should succeed"); - database_balance += amount; + CyclesOp::PurchaseDatabaseCycles { amount } => { + let purchased_cycles = + purchase_database_cycles(service, &database_id, OWNER, amount, now as u64, now) + .expect("cycle purchase should succeed"); + database_balance += purchased_cycles; } - CreditsOp::Charge { cycles_delta } => { + CyclesOp::Charge { cycles_delta } => { let before = database_balance; let config = service - .credits_config() - .expect("credits config should load"); + .cycles_billing_config() + .expect("cycles config should load"); service .charge_database_update( &config, @@ -323,8 +325,8 @@ proptest! { let computed = charge_amount(cycles_delta); database_balance = database_balance.saturating_sub(computed); let entries = service - .list_database_credit_entries(&database_id, OWNER, None, 100) - .expect("database credits entries should load") + .list_database_cycle_entries(&database_id, OWNER, None, 100) + .expect("database cycles entries should load") .entries; if computed > before { assert_eq!(entries[entries.len() - 2].kind, "charge"); @@ -335,10 +337,10 @@ proptest! { let (stored_balance, suspended_at_ms) = db_account(&env.root, &database_id); assert_eq!(stored_balance, database_balance); - assert_eq!(suspended_at_ms.is_some(), database_balance < DEFAULT_MIN_UPDATE_CREDIT_UNITS); + assert_eq!(suspended_at_ms.is_some(), database_balance < DEFAULT_MIN_UPDATE_CYCLES); assert_eq!( - service.require_database_write_credits_available(&database_id).is_ok(), - database_balance >= DEFAULT_MIN_UPDATE_CREDIT_UNITS + service.require_database_write_cycles_available(&database_id).is_ok(), + database_balance >= DEFAULT_MIN_UPDATE_CYCLES ); assert_database_ledger_chain(&env.root, &database_id, database_balance); } @@ -352,7 +354,7 @@ proptest! { ) { let env = service_with_root(); let service = &env.service; - let database_id = create_billed_database(service, "restore-pbt", 1); + let (database_id, _deposit_cycles) = create_billed_database(service, "restore-pbt", 1); let content = format!("restore body split={split_bias} deleted={restore_deleted} cancel={cancel_first}"); service .write_node( diff --git a/crates/vfs_types/src/fs.rs b/crates/vfs_types/src/fs.rs index 21454066..ddc6edeb 100644 --- a/crates/vfs_types/src/fs.rs +++ b/crates/vfs_types/src/fs.rs @@ -57,76 +57,77 @@ pub struct DatabaseSummary { pub status: DatabaseStatus, pub role: DatabaseRole, pub logical_size_bytes: u64, - pub credit_units_balance: Option, - pub credits_suspended_at_ms: Option, + pub cycles_balance: Option, + pub cycles_suspended_at_ms: Option, pub archived_at_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] -pub struct CreditsConfig { +pub struct CyclesBillingConfig { pub kinic_ledger_canister_id: String, pub sns_governance_id: String, - pub credit_units_per_kinic: u64, - pub min_update_credit_units: u64, + pub cycles_per_kinic: u64, + pub min_update_cycles: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] -pub struct CreditsConfigUpdate { - pub credit_units_per_kinic: u64, - pub min_update_credit_units: u64, +pub struct CyclesBillingConfigUpdate { + pub cycles_per_kinic: u64, + pub min_update_cycles: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] -pub struct DatabaseCreditPurchasePreview { +pub struct DatabaseCyclesPurchasePreview { pub payment_amount_e8s: u64, + pub cycles: u64, pub ledger_fee_e8s: u64, - pub credit_units_per_kinic: u64, + pub cycles_per_kinic: u64, pub config_version: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] -pub struct DatabaseCreditPurchaseRequest { +pub struct DatabaseCyclesPurchaseRequest { pub database_id: String, - pub credit_units: u64, - pub expected_payment_amount_e8s: u64, + pub payment_amount_e8s: u64, + pub expected_cycles: u64, pub expected_config_version: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] -pub struct CreditsPurchaseResult { +pub struct CyclesPurchaseResult { pub block_index: u64, - pub balance_credit_units: u64, + pub balance_cycles: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] -pub struct DatabaseCreditEntry { +pub struct DatabaseCycleEntry { pub entry_id: u64, pub database_id: String, pub kind: String, - pub amount_credit_units: i64, - pub balance_after_credit_units: u64, + pub amount_cycles: i64, + pub balance_after_cycles: u64, pub payment_amount_e8s: Option, pub caller: String, pub method: Option, pub cycles_delta: Option, - pub credit_units_per_kinic: Option, + pub cycles_per_kinic: Option, pub ledger_block_index: Option, pub created_at_ms: i64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] -pub struct DatabaseCreditEntryPage { - pub entries: Vec, +pub struct DatabaseCycleEntryPage { + pub entries: Vec, pub next_cursor: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] -pub struct DatabaseCreditPendingOperation { +pub struct DatabaseCyclePendingOperation { pub operation_id: u64, pub database_id: String, pub kind: String, pub caller: String, - pub credit_units: i64, + pub cycles: i64, pub payment_amount_e8s: i64, pub from_owner: Option, pub from_subaccount: Option>, @@ -138,8 +139,8 @@ pub struct DatabaseCreditPendingOperation { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] -pub struct DatabaseCreditPendingOperationPage { - pub entries: Vec, +pub struct DatabaseCyclePendingOperationPage { + pub entries: Vec, pub next_cursor: Option, } diff --git a/docs/DB_LIFECYCLE.md b/docs/DB_LIFECYCLE.md index fb0434fd..db5916a4 100644 --- a/docs/DB_LIFECYCLE.md +++ b/docs/DB_LIFECYCLE.md @@ -24,17 +24,17 @@ Stable-memory mount IDs are partitioned by purpose: - `11..=32767`: user DB slots - `32768..=65534`: reserved -The index DB tracks database metadata, membership, and credits history. User DBs hold VFS node data, search data, and link data. +The index DB tracks database metadata, membership, and cycles history. User DBs hold VFS node data, search data, and link data. The index DB startup path ensures the latest schema. Fresh index DBs are created directly at the latest schema, and already-latest DBs are validated only. The only supported automatic migration is the production mainnet `database_index:011_source_run_sessions` to latest upgrade. Partial billing schemas, index DBs without `schema_migrations`, and pre-011 schemas are rejected instead of repaired. -Pending DBs have index metadata and credit accounts but no stable-memory mount ID. Active, archiving, or restoring DBs consume one active user DB slot. Archived DBs release their active mount, but v1 does not recycle stable-memory mount IDs for another database. A pending DB consumes a mount ID only after the first successful credit purchase activates it. +Pending DBs have index metadata and cycle accounts but no stable-memory mount ID. Active, archiving, or restoring DBs consume one active user DB slot. Archived DBs release their active mount, but v1 does not recycle stable-memory mount IDs for another database. A pending DB consumes a mount ID only after the first successful cycle purchase activates it. ## Status Databases move through five statuses: -- `pending`: metadata reserved, no mounted SQLite DB yet, only credit purchase and owner management are available +- `pending`: metadata reserved, no mounted SQLite DB yet, only cycle purchase and owner management are available - `active`: mounted and usable for VFS read/write/search/list - `archiving`: mounted for chunk export, VFS operations rejected until finalize succeeds - `archived`: not mounted, active mount released, snapshot metadata retained @@ -50,68 +50,67 @@ It is updated after VFS mutations, restore finalization, and storage billing set Deleting or archiving a DB releases the active mount. It does not imply that canister stable memory shrinks or that the stable-memory mount ID is reused. -## Credits +## Cycles -KINIC credits uses one internal DB-scoped balance: +KINIC cycles uses one internal DB-scoped balance: -- DB credits balance: KINIC pulled from the external ledger directly into a reserved DB +- DB cycles balance: KINIC pulled from the external ledger directly into a reserved DB -DB creation uses `create_database(display_name)`. It creates a generated `database_id`, owner membership, and a zero DB credits balance without allocating a stable-memory mount ID. The DB remains `pending` and credits-suspended until its first successful credit purchase activates the mounted SQLite DB. +DB creation uses `create_database(display_name)`. It creates a generated `database_id`, owner membership, and a zero DB cycles balance without allocating a stable-memory mount ID. The DB remains `pending` and cycles-suspended until its first successful cycle purchase activates the mounted SQLite DB. -External ledger calls are limited to DB credit purchase: +External ledger calls are limited to DB cycles purchase: -- `preview_database_credit_purchase(database_id, credit_units)` returns `payment_amount_e8s`, `ledger_fee_e8s`, `credit_units_per_kinic`, and `config_version`. -- `purchase_database_credits(DatabaseCreditPurchaseRequest)` pulls the KINIC payment from the caller through ICRC-2 `approve` + `icrc2_transfer_from` and mints credits into that DB credits balance. The request must include the previewed `expected_payment_amount_e8s` and `expected_config_version`; mismatch rejects before pending operation creation and before ledger transfer. The approved allowance must cover `payment_amount_e8s + ledger_fee_e8s`. +- `preview_database_cycles_purchase(database_id, payment_amount_e8s)` returns `payment_amount_e8s`, `cycles`, `ledger_fee_e8s`, `cycles_per_kinic`, and `config_version`. +- `purchase_database_cycles(DatabaseCyclesPurchaseRequest)` pulls the KINIC payment from the caller through ICRC-2 `approve` + `icrc2_transfer_from` and mints cycles into that DB cycles balance. The request must include `payment_amount_e8s`, previewed `expected_cycles`, and `expected_config_version`; mismatch rejects before pending operation creation and before ledger transfer. The approved allowance must cover `payment_amount_e8s + ledger_fee_e8s`. -Any authenticated caller can credit purchase an existing DB that still has an owner, including callers with no DB role. `preview_database_credit_purchase` is intentionally callable by anonymous callers so wallet UIs can validate a database target before requesting approval. The payer is recorded in the DB ledger entry. Reader and writer credits history redacts payer/caller principals, while DB owner and SNS governance can read full payer/caller details. Once the ledger call starts, completion, cancellation, or ambiguous recording resolves the started operation even if membership changes during the await. +Any authenticated caller can cycle purchase an existing DB that still has an owner, including callers with no DB role. `preview_database_cycles_purchase` is intentionally callable by anonymous callers so wallet UIs can validate a database target before requesting approval. The payer is recorded in the DB ledger entry. Reader and writer cycles history redacts payer/caller principals, while DB owner and SNS governance can read full payer/caller details. Once the ledger call starts, completion, cancellation, or ambiguous recording resolves the started operation even if membership changes during the await. -Successful DB update calls are charged after execution. The charge is: +Successful DB update calls are charged after execution. The charge is raw cycle usage: ```text -ceil(cycles_delta / 1_000_000) +cycles_delta ``` -Credits are stored as integer credit units. `1 credit_unit = 0.001 credit = 1_000_000 cycles`; UI and CLI output divide units by `1000`. The default purchase rate is `1 KINIC = 1_000_000 credit_units`, controlled by `credit_units_per_kinic`. Before a metered update, the caller role is checked first, then the DB credits balance must be at least `min_update_credit_units` and the DB must not be suspended. Non-members receive access errors without learning credits state. If the post-update charge exceeds the DB credits balance, the remaining balance is fully consumed, the DB is suspended, and the update result remains successful. +Cycles are stored as raw integer cycles. The default purchase rate is `1 KINIC = 1_000_000_000_000 cycles`, controlled by `cycles_per_kinic`. Before a metered update, the caller role is checked first, then the DB cycles balance must be at least `min_update_cycles` and the DB must not be suspended. Non-members receive access errors without learning cycles state. If the post-update charge exceeds the DB cycles balance, the remaining balance is fully consumed, the DB is suspended, and the update result remains successful. Storage billing settles every 24h from a canister timer, with controller-only `settle_database_storage_charges()` as recovery path. Only active DBs are charged. The 13-node subnet rate is fixed at `127_000 cycles / GiB / sec`: ```text storage_cycles = logical_size_bytes * elapsed_seconds * 127_000 / 2^30 -charge_units = ceil(storage_cycles / 1_000_000) ``` -Storage charges write `kind = "storage_charge"` ledger entries for actually collected credit units. Cycles below 1 credit_unit and insufficient-balance unpaid cycles are not carried forward; each DB settle rounds up by less than one credit_unit. Insufficient balance consumes the remaining balance and suspends the DB. +Storage charges write `kind = "storage_charge"` ledger entries for actually collected cycles. Insufficient-balance unpaid cycles are not carried forward. Insufficient balance consumes the remaining balance and suspends the DB. -`database_credit_ledger` is the credits source of truth. Successful charged update calls are recorded there directly. Ledger-backed credit purchase and repair entries store ledger block indexes in `ledger_block_index`. +`database_cycle_ledger` is the cycles source of truth. Successful charged update calls are recorded there directly. Ledger-backed cycle purchase and repair entries store ledger block indexes in `ledger_block_index`. -Credits history redacts payer/caller principals for reader and writer callers. DB owner and SNS governance can read full credits history. Pending credit operations remain visible only to DB owner and SNS governance. New credits history fields must not carry payer/caller principals unless the same redaction policy is applied. +Cycles history redacts payer/caller principals for reader and writer callers. DB owner and SNS governance can read full cycles history. Pending cycle operations remain visible only to DB owner and SNS governance. New cycles history fields must not carry payer/caller principals unless the same redaction policy is applied. -`kinic_ledger_canister_id` and `sns_governance_id` are fixed at init. SNS governance may update only rate and minimum-balance fields by calling `update_credits_config` with a Candid-encoded `CreditsConfigUpdate` blob. `config_version` starts at `1` and increments only when `credit_units_per_kinic` or `min_update_credit_units` actually changes. +`kinic_ledger_canister_id` and `sns_governance_id` are fixed at init. SNS governance may update only rate and minimum-balance fields by calling `update_cycles_billing_config` with a Candid-encoded `CyclesBillingConfigUpdate` blob. `config_version` starts at `1` and increments only when `cycles_per_kinic` or `min_update_cycles` actually changes. -`scripts/local/deploy_wiki.sh` carries local development init args. If `SNS_GOVERNANCE_ID` is unset, local deploy uses `icp identity principal`. The deploy script does not create a ledger canister by itself. Local credit purchase smoke should use `scripts/local/setup_kinic_ledger.sh` or `scripts/smoke/local_canister_archive_restore.sh`, which creates or validates a project-local ICRC ledger and deploys the wiki with that ledger ID. +`scripts/local/deploy_wiki.sh` carries local development init args. If `SNS_GOVERNANCE_ID` is unset, local deploy uses `icp identity principal`. The deploy script does not create a ledger canister by itself. Local cycle purchase smoke should use `scripts/local/setup_kinic_ledger.sh` or `scripts/smoke/local_canister_archive_restore.sh`, which creates or validates a project-local ICRC ledger and deploys the wiki with that ledger ID. Unit tests do not deploy a ledger. They mock ledger transfer outcomes inside the canister test harness. Production deploy must use `scripts/mainnet/deploy_wiki.sh` with `KINIC_LEDGER_CANISTER_ID` and `SNS_GOVERNANCE_ID`; the script rejects unset, empty, or anonymous values before install. These principal values cannot be changed after init. Normal operator flow: 1. Owner creates a pending DB with `create_database(display_name)`. -2. Payer previews the DB credit purchase, then approves the VFS canister on the KINIC ICRC-2 ledger for the DB credit amount plus ledger transfer fee. Browser approve uses the current allowance as `expected_allowance` and expires after 30 minutes. The approve transaction fee is paid separately by the wallet. -3. Payer calls `purchase_database_credits` with the previewed expected amount and config version. If the DB is pending, the canister starts the ledger transfer first, then allocates and migrates the DB mount only after the ledger transfer succeeds. The DB becomes active when mount migration and balance credit both complete. -4. Successful DB updates consume DB credits balance. -5. DB delete discards any remaining credits. +2. Payer previews the DB cycle purchase, then approves the VFS canister on the KINIC ICRC-2 ledger for the DB cycle amount plus ledger transfer fee. Browser approve uses the current allowance as `expected_allowance` and expires after 30 minutes. The approve transaction fee is paid separately by the wallet. +3. Payer calls `purchase_database_cycles` with the previewed expected amount and config version. If the DB is pending, the canister starts the ledger transfer first, then allocates and migrates the DB mount only after the ledger transfer succeeds. The DB becomes active when mount migration and balance cycle both complete. +4. Successful DB updates consume DB cycles balance. +5. DB delete discards any remaining cycles. -URL ingest and query-answer sessions can expire after issuance if the DB becomes suspended or drops below the minimum update balance. Browser write UI also treats suspended, low-balance, or credits-config-unavailable DBs as not writable. Browser and worker paths re-check credits before forwarding to external Worker or DeepSeek calls. URL ingest source generation carries the original `sessionNonce` through the queue and re-checks the session immediately before DeepSeek. +URL ingest and query-answer sessions can expire after issuance if the DB becomes suspended or drops below the minimum update balance. Browser write UI also treats suspended, low-balance, or cycles-config-unavailable DBs as not writable. Browser and worker paths re-check cycles before forwarding to external Worker or DeepSeek calls. URL ingest source generation carries the original `sessionNonce` through the queue and re-checks the session immediately before DeepSeek. Treasury sweep, DB-specific ledger subaccounts, and repair browser UI are not implemented. -If DB credit purchase receives an explicit ledger error, the credit purchase is cancelled. If the inter-canister call or response decoding is ambiguous, the operation remains pending and the DB ledger records `credit_purchase_ambiguous`. If the ledger transfer succeeds but local DB activation or credit application fails, the operation remains pending and the error returns the pending `operation_id` and `ledger_block_index` for verified completion. Pending operations store the expected ledger from/to accounts, fee, memo inputs, and `created_at_time` so completion can validate the exact transfer. +If DB cycle purchase receives an explicit ledger error, the cycle purchase is cancelled. If the inter-canister call or response decoding is ambiguous, the operation remains pending and the DB ledger records `cycles_purchase_ambiguous`. If the ledger transfer succeeds but local DB activation or cycle application fails, the operation remains pending and the error returns the pending `operation_id` and `ledger_block_index` for verified completion. Pending operations store the expected ledger from/to accounts, fee, memo inputs, and `created_at_time` so completion can validate the exact transfer. Pending operations block DB delete until they are resolved: -- `repair_database_credit_purchase_complete(database_id, operation_id, ledger_block_index)` -- `repair_database_credit_purchase_cancel(database_id, operation_id)` +- `repair_database_cycles_purchase_complete(database_id, operation_id, ledger_block_index)` +- `repair_database_cycles_purchase_cancel(database_id, operation_id)` -Complete checks the ledger transaction at `ledger_block_index` against the pending operation before changing DB credits balance. The canister entrypoint accepts any non-anonymous caller when the ledger block proves the payment; the official CLI defaults to Internet Identity and requires explicit `--allow-non-ii-identity` opt-in for non-II operator identities. The completed ledger entry records the original payer from the pending operation as `caller`, not the repair executor. If local activation or credit application fails during complete, the pending operation remains and the returned error includes the operation and block identifiers. Cancel repair is allowed only for the original payer or DB owner, and is rejected once pending DB activation has started. Cancel writes `credit_purchase_repair_cancelled` with the cancel caller, pending payment amount, current balance, and no ledger block index. DB owner and SNS governance can inspect pending operations. +Complete checks the ledger transaction at `ledger_block_index` against the pending operation before changing DB cycles balance. The canister entrypoint accepts any non-anonymous caller when the ledger block proves the payment; the official CLI defaults to Internet Identity and requires explicit `--allow-non-ii-identity` opt-in for non-II operator identities. The completed ledger entry records the original payer from the pending operation as `caller`, not the repair executor. If local activation or cycle application fails during complete, the pending operation remains and the returned error includes the operation and block identifiers. Cancel repair is allowed only for the original payer or DB owner, and is rejected once pending DB activation has started. Cancel writes `cycles_purchase_repair_cancelled` with the cancel caller, pending payment amount, current balance, and no ledger block index. DB owner and SNS governance can inspect pending operations. ## Delete @@ -120,11 +119,11 @@ Complete checks the ledger transaction at `ledger_block_index` against the pendi Delete is a hard delete: - the SQLite DB file is removed where file deletion is available -- DB membership, credits, pending operations, restore chunks, restore sessions, and transient sessions are removed from the index +- DB membership, cycles, pending operations, restore chunks, restore sessions, and transient sessions are removed from the index - `database_mount_history` is retained so the stable-memory mount ID is not reused by another DB in v1 - the stable-memory mount ID is not reused by another DB in v1 -Delete requires no pending credit purchase operations. The request carries only `database_id`. Remaining DB credits are discarded with the deleted index rows. +Delete requires no pending cycle purchase operations. The request carries only `database_id`. Remaining DB cycles are discarded with the deleted index rows. Delete is treated as irreversible. If recovery is required, archive first and store the exported bytes outside the canister. Deleted DBs are absent from `list_databases` and subsequent DB operations return `database not found`. diff --git a/extensions/wiki-clipper/popup/popup.js b/extensions/wiki-clipper/popup/popup.js index d5e38f2e..a2306771 100644 --- a/extensions/wiki-clipper/popup/popup.js +++ b/extensions/wiki-clipper/popup/popup.js @@ -155,12 +155,12 @@ function renderDatabaseOptions(databases, selectedDatabaseId, placeholder = "No const option = document.createElement("option"); option.value = database.databaseId; const label = databaseOptionLabel(database, nameCounts.get(databaseNameKey(database.name)) || 1); - option.disabled = !database.writeCreditsAvailable; - option.textContent = database.writeCreditsAvailable ? label : `${label} - ${database.creditsReason}`; + option.disabled = !database.writeCyclesAvailable; + option.textContent = database.writeCyclesAvailable ? label : `${label} - ${database.cyclesReason}`; option.title = database.databaseId; databaseSelect.append(option); } - const selectable = databases.filter((database) => database.writeCreditsAvailable); + const selectable = databases.filter((database) => database.writeCyclesAvailable); if (selectable.length === 0) { databaseSelect.value = ""; databaseSelect.disabled = true; diff --git a/extensions/wiki-clipper/scripts/check-candid-drift.mjs b/extensions/wiki-clipper/scripts/check-candid-drift.mjs index 73bc349a..535235e3 100644 --- a/extensions/wiki-clipper/scripts/check-candid-drift.mjs +++ b/extensions/wiki-clipper/scripts/check-candid-drift.mjs @@ -18,18 +18,18 @@ const expectedTypes = { role: "DatabaseRole", logical_size_bytes: "nat64", database_id: "text", - credit_units_balance: "opt nat64", - credits_suspended_at_ms: "opt int64", + cycles_balance: "opt nat64", + cycles_suspended_at_ms: "opt int64", archived_at_ms: "opt int64" } }, - CreditsConfig: { + CyclesBillingConfig: { kind: "record", fields: { kinic_ledger_canister_id: "text", sns_governance_id: "text", - credit_units_per_kinic: "nat64", - min_update_credit_units: "nat64" + cycles_per_kinic: "nat64", + min_update_cycles: "nat64" } }, CreateDatabaseRequest: { kind: "record", fields: { name: "text" } }, @@ -84,7 +84,7 @@ const actorExpectedTypes = { const expectedMethods = { authorize_url_ingest_trigger_session: { input: ["UrlIngestTriggerSessionRequest"], output: "ResultUnit", mode: "update" }, - get_credits_config: { input: [], output: "ResultCreditsConfig", mode: "query" }, + get_cycles_billing_config: { input: [], output: "ResultCyclesBillingConfig", mode: "query" }, create_database: { input: ["CreateDatabaseRequest"], output: "ResultCreateDatabase", mode: "update" }, list_databases: { input: [], output: "ResultDatabases", mode: "query" }, mkdir_node: { input: ["MkdirNodeRequest"], output: "ResultMkdirNode", mode: "update" }, @@ -193,7 +193,7 @@ function normalizeDidShape(value) { function normalizeDidResult(value) { const normalized = normalizeDidShape(value).replace(/,$/, ""); if (normalized === "Result_1") return "ResultUnit"; - if (normalized === "Result_9") return "ResultCreditsConfig"; + if (normalized === "Result_9") return "ResultCyclesBillingConfig"; if (normalized === "Result_4") return "ResultCreateDatabase"; if (normalized === "Result_16") return "ResultDatabases"; if (normalized === "Result_18") return "ResultMkdirNode"; @@ -230,7 +230,7 @@ function splitActorInputs(value) { function actorResultName(okShape) { const normalized = normalizeActorShape(okShape); if (normalized === "null") return "ResultUnit"; - if (normalized === "CreditsConfig") return "ResultCreditsConfig"; + if (normalized === "CyclesBillingConfig") return "ResultCyclesBillingConfig"; if (normalized === "CreateDatabaseResult") return "ResultCreateDatabase"; if (normalized === "Vec(DatabaseSummary)") return "ResultDatabases"; if (normalized === "MkdirNodeResult") return "ResultMkdirNode"; diff --git a/extensions/wiki-clipper/src/offscreen.js b/extensions/wiki-clipper/src/offscreen.js index 210c7829..5e4bd3eb 100644 --- a/extensions/wiki-clipper/src/offscreen.js +++ b/extensions/wiki-clipper/src/offscreen.js @@ -5,9 +5,9 @@ import { authSnapshot as defaultAuthSnapshot } from "./auth-client.js"; import { buildUrlIngestRequest } from "./url-ingest-request.js"; import { createVfsActor as defaultCreateVfsActor, - getCreditsConfigOrNull, + getCyclesBillingConfigOrNull, normalizeWritableDatabases, - requireDatabaseWriteCreditsAvailable + requireDatabaseWriteCyclesAvailable } from "./vfs-actor.js"; const URL_INGEST_TRIGGER_URL = "https://wiki.kinic.xyz/api/url-ingest/trigger"; @@ -50,7 +50,7 @@ export async function queueUrlIngest(tab, config) { if (!config?.databaseId) throw new Error("database id is required"); const snapshot = await authenticatedSnapshot(); const actor = await vfsActorFactory({ ...config, identity: snapshot.identity }); - await requireDatabaseWriteCreditsAvailable(actor, config.databaseId); + await requireDatabaseWriteCyclesAvailable(actor, config.databaseId); const session = await ensureTriggerSession(actor, config.databaseId, snapshot.principal); const request = buildUrlIngestRequest({ url: tab.url, @@ -86,7 +86,7 @@ export async function saveRawSource(rawSource, config) { if (!config?.databaseId) throw new Error("database id is required"); const snapshot = await authenticatedSnapshot(); const actor = await vfsActorFactory({ ...config, identity: snapshot.identity }); - await requireDatabaseWriteCreditsAvailable(actor, config.databaseId); + await requireDatabaseWriteCyclesAvailable(actor, config.databaseId); const existing = await actor.read_node(config.databaseId, rawSource.path); if ("Err" in existing) throw new Error(existing.Err); const expected = existing.Ok[0]?.etag ? [existing.Ok[0].etag] : []; @@ -148,12 +148,12 @@ export async function listWritableDatabases(config) { if (!config?.canisterId) throw new Error("canister id is required"); const snapshot = await authenticatedSnapshot(); const actor = await vfsActorFactory({ ...config, identity: snapshot.identity }); - const [result, creditsConfig] = await Promise.all([ + const [result, cyclesConfig] = await Promise.all([ actor.list_databases(), - getCreditsConfigOrNull(actor) + getCyclesBillingConfigOrNull(actor) ]); if ("Err" in result) throw new Error(result.Err); - return normalizeWritableDatabases(result.Ok, creditsConfig); + return normalizeWritableDatabases(result.Ok, cyclesConfig); } export function setOffscreenDepsForTest(deps = {}) { diff --git a/extensions/wiki-clipper/src/service-worker.js b/extensions/wiki-clipper/src/service-worker.js index f000e7a3..2347d2bb 100644 --- a/extensions/wiki-clipper/src/service-worker.js +++ b/extensions/wiki-clipper/src/service-worker.js @@ -352,7 +352,7 @@ function errorStatus(message, url = "") { } function shouldOpenSettingsForError(message) { - return message === "UNAUTHENTICATED" || /credits|balance/i.test(String(message || "")); + return message === "UNAUTHENTICATED" || /cycles|balance/i.test(String(message || "")); } function setupRequiredStatus(url = "") { diff --git a/extensions/wiki-clipper/src/vfs-actor.js b/extensions/wiki-clipper/src/vfs-actor.js index 5917421a..fd7d2725 100644 --- a/extensions/wiki-clipper/src/vfs-actor.js +++ b/extensions/wiki-clipper/src/vfs-actor.js @@ -30,15 +30,15 @@ function idlFactory({ IDL: idl }) { role: DatabaseRole, logical_size_bytes: idl.Nat64, database_id: idl.Text, - credit_units_balance: idl.Opt(idl.Nat64), - credits_suspended_at_ms: idl.Opt(idl.Int64), + cycles_balance: idl.Opt(idl.Nat64), + cycles_suspended_at_ms: idl.Opt(idl.Int64), archived_at_ms: idl.Opt(idl.Int64) }); - const CreditsConfig = idl.Record({ + const CyclesBillingConfig = idl.Record({ kinic_ledger_canister_id: idl.Text, sns_governance_id: idl.Text, - credit_units_per_kinic: idl.Nat64, - min_update_credit_units: idl.Nat64 + cycles_per_kinic: idl.Nat64, + min_update_cycles: idl.Nat64 }); const CreateDatabaseRequest = idl.Record({ name: idl.Text }); const CreateDatabaseResult = idl.Record({ database_id: idl.Text, name: idl.Text }); @@ -93,7 +93,7 @@ function idlFactory({ IDL: idl }) { }); return idl.Service({ authorize_url_ingest_trigger_session: idl.Func([UrlIngestTriggerSessionRequest], [idl.Variant({ Ok: idl.Null, Err: idl.Text })], []), - get_credits_config: idl.Func([], [idl.Variant({ Ok: CreditsConfig, Err: idl.Text })], ["query"]), + get_cycles_billing_config: idl.Func([], [idl.Variant({ Ok: CyclesBillingConfig, Err: idl.Text })], ["query"]), create_database: idl.Func([CreateDatabaseRequest], [idl.Variant({ Ok: CreateDatabaseResult, Err: idl.Text })], []), list_databases: idl.Func([], [idl.Variant({ Ok: idl.Vec(DatabaseSummary), Err: idl.Text })], ["query"]), mkdir_node: idl.Func([MkdirNodeRequest], [idl.Variant({ Ok: MkdirNodeResult, Err: idl.Text })], []), @@ -118,40 +118,40 @@ export async function createDatabaseWithActor(actor, name) { export async function listWritableDatabases(config) { const actor = await createVfsActor(config); - const [databaseResult, creditsConfig] = await Promise.all([ + const [databaseResult, cyclesConfig] = await Promise.all([ actor.list_databases(), - getCreditsConfigOrNull(actor) + getCyclesBillingConfigOrNull(actor) ]); if ("Err" in databaseResult) { throw new Error(databaseResult.Err); } - return normalizeWritableDatabases(databaseResult.Ok, creditsConfig); + return normalizeWritableDatabases(databaseResult.Ok, cyclesConfig); } -export async function requireDatabaseWriteCreditsAvailable(actor, databaseId) { - const [databaseResult, creditsConfigResult] = await Promise.all([ +export async function requireDatabaseWriteCyclesAvailable(actor, databaseId) { + const [databaseResult, cyclesConfigResult] = await Promise.all([ actor.list_databases(), - actor.get_credits_config() + actor.get_cycles_billing_config() ]); if ("Err" in databaseResult) throw new Error(databaseResult.Err); - if ("Err" in creditsConfigResult) throw new Error(`Credits config unavailable: ${creditsConfigResult.Err}`); - const config = normalizeCreditsConfig(creditsConfigResult.Ok); + if ("Err" in cyclesConfigResult) throw new Error(`Cycles config unavailable: ${cyclesConfigResult.Err}`); + const config = normalizeCyclesBillingConfig(cyclesConfigResult.Ok); const databases = databaseResult.Ok.map(normalizeDatabaseSummary); const database = databases.find((entry) => entry.databaseId === databaseId); - if (!database) throw new Error(`Database credits state unavailable: ${databaseId}`); - const reason = databaseCreditsDisabledReason(database, config); + if (!database) throw new Error(`Database cycles state unavailable: ${databaseId}`); + const reason = databaseCyclesDisabledReason(database, config); if (reason) throw new Error(reason); } -export function normalizeWritableDatabases(rawDatabases, creditsConfig = null) { +export function normalizeWritableDatabases(rawDatabases, cyclesConfig = null) { return rawDatabases.map(normalizeDatabaseSummary).filter((database) => { return database.status === "Active" && (database.role === "Owner" || database.role === "Writer"); }).map((database) => { - const reason = databaseCreditsDisabledReason(database, creditsConfig); + const reason = databaseCyclesDisabledReason(database, cyclesConfig); return { ...database, - writeCreditsAvailable: !reason, - creditsReason: reason + writeCyclesAvailable: !reason, + cyclesReason: reason }; }); } @@ -170,8 +170,8 @@ function normalizeDatabaseSummary(raw) { role: variantKey(raw.role), status: normalizeDatabaseStatus(raw.status), logicalSizeBytes: raw.logical_size_bytes?.toString?.() ?? String(raw.logical_size_bytes ?? "0"), - creditsBalance: raw.credit_units_balance?.[0]?.toString?.() ?? "0", - creditsSuspendedAtMs: raw.credits_suspended_at_ms?.[0]?.toString?.() ?? null + cyclesBalance: raw.cycles_balance?.[0]?.toString?.() ?? "0", + cyclesSuspendedAtMs: raw.cycles_suspended_at_ms?.[0]?.toString?.() ?? null }; } @@ -180,29 +180,29 @@ function normalizeDatabaseStatus(status) { return key === "Hot" ? "Active" : key; } -export async function getCreditsConfigOrNull(actor) { - const result = await actor.get_credits_config(); +export async function getCyclesBillingConfigOrNull(actor) { + const result = await actor.get_cycles_billing_config(); if ("Err" in result) return null; - return normalizeCreditsConfig(result.Ok); + return normalizeCyclesBillingConfig(result.Ok); } -function normalizeCreditsConfig(raw) { +function normalizeCyclesBillingConfig(raw) { return { - minUpdateCredits: raw.min_update_credit_units?.toString?.() ?? String(raw.min_update_credit_units ?? "0") + minUpdateCycles: raw.min_update_cycles?.toString?.() ?? String(raw.min_update_cycles ?? "0") }; } -function databaseCreditsDisabledReason(database, config) { - const balance = parseCredits(database.creditsBalance); - const minimum = parseCredits(config?.minUpdateCredits); - if (!config) return "Credits config unavailable."; - if (database.status === "Pending") return "Database activation is pending until its first credit purchase completes."; - if (database.creditsSuspendedAtMs) return "Database credits are suspended."; - if (balance < minimum) return "Database credits balance is below the minimum update balance."; +function databaseCyclesDisabledReason(database, config) { + const balance = parseCycles(database.cyclesBalance); + const minimum = parseCycles(config?.minUpdateCycles); + if (!config) return "Cycles config unavailable."; + if (database.status === "Pending") return "Database activation is pending until its first cycle purchase completes."; + if (database.cyclesSuspendedAtMs) return "Database cycles are suspended."; + if (balance < minimum) return "Database cycles balance is below the minimum update balance."; return null; } -function parseCredits(value) { +function parseCycles(value) { return typeof value === "string" && /^[0-9]+$/.test(value) ? BigInt(value) : 0n; } diff --git a/extensions/wiki-clipper/tests/offscreen.test.mjs b/extensions/wiki-clipper/tests/offscreen.test.mjs index a8dddbd2..4a974bee 100644 --- a/extensions/wiki-clipper/tests/offscreen.test.mjs +++ b/extensions/wiki-clipper/tests/offscreen.test.mjs @@ -17,7 +17,7 @@ test("queueUrlIngest writes request and triggers via wiki route", async () => { createVfsActor: async (config) => { calls.push(["create", config.identity, config.databaseId]); return { - ...writeCreditsActorMethods(), + ...writeCyclesActorMethods(), async authorize_url_ingest_trigger_session(request) { calls.push(["session", request.database_id, request.session_nonce]); return { Ok: null }; @@ -68,7 +68,7 @@ test("queueUrlIngest keeps request result when trigger fails", async () => { authSnapshot: async () => ({ isAuthenticated: true, identity: { tag: "identity" }, principal: "principal-1" }), fetch: async () => new Response("nope", { status: 502 }), createVfsActor: async () => ({ - ...writeCreditsActorMethods(), + ...writeCyclesActorMethods(), async mkdir_node(request) { return { Ok: { created: true, path: request.path } }; }, @@ -101,7 +101,7 @@ test("queueUrlIngest rejects before writing when session authorize fails", async return Response.json({ accepted: true }); }, createVfsActor: async () => ({ - ...writeCreditsActorMethods(), + ...writeCyclesActorMethods(), async write_node() { calls.push(["write"]); return { Ok: { created: true, node: { etag: "etag-request" } } }; @@ -135,7 +135,7 @@ test("queueUrlIngest reuses session nonce inside ttl", async () => { return Response.json({ accepted: true }); }, createVfsActor: async () => ({ - ...writeCreditsActorMethods(), + ...writeCyclesActorMethods(), async authorize_url_ingest_trigger_session(request) { calls.push(["session", request.session_nonce]); return { Ok: null }; @@ -175,7 +175,7 @@ test("saveRawSource writes with authenticated identity", async () => { createVfsActor: async (config) => { calls.push(["create", config.identity, config.databaseId]); return { - ...writeCreditsActorMethods(), + ...writeCyclesActorMethods(), async read_node(databaseId, path) { calls.push(["read", databaseId, path]); return { Ok: [{ etag: "etag-1" }] }; @@ -230,12 +230,12 @@ test("saveRawSource rejects unauthenticated sessions", async () => { } }); -test("queueUrlIngest rejects low credit balance before writing", async () => { +test("queueUrlIngest rejects low cycle balance before writing", async () => { const calls = []; setOffscreenDepsForTest({ authSnapshot: async () => ({ isAuthenticated: true, identity: { tag: "identity" }, principal: "principal-1" }), createVfsActor: async () => ({ - ...writeCreditsActorMethods({ balanceCredits: 9_999n }), + ...writeCyclesActorMethods({ balanceCycles: 9_999n }), async authorize_url_ingest_trigger_session() { calls.push(["session"]); return { Ok: null }; @@ -262,12 +262,12 @@ test("queueUrlIngest rejects low credit balance before writing", async () => { } }); -test("saveRawSource rejects suspended credits before writing", async () => { +test("saveRawSource rejects suspended cycles before writing", async () => { const calls = []; setOffscreenDepsForTest({ authSnapshot: async () => ({ isAuthenticated: true, identity: { tag: "identity" }, principal: "principal-1" }), createVfsActor: async () => ({ - ...writeCreditsActorMethods({ suspendedAtMs: 1n }), + ...writeCyclesActorMethods({ suspendedAtMs: 1n }), async read_node() { calls.push(["read"]); return { Ok: [] }; @@ -283,7 +283,7 @@ test("saveRawSource rejects suspended credits before writing", async () => { }) }); try { - await assert.rejects(() => saveRawSource(rawSource(), config()), /credits are suspended/); + await assert.rejects(() => saveRawSource(rawSource(), config()), /cycles are suspended/); assert.deepEqual(calls, []); } finally { @@ -345,13 +345,13 @@ test("listWritableDatabases returns active writable database summaries", async ( ] }; }, - async get_credits_config() { + async get_cycles_billing_config() { return { Ok: { kinic_ledger_canister_id: "ryjl3-tyaaa-aaaaa-aaaba-cai", sns_governance_id: "rrkah-fqaaa-aaaaa-aaaaq-cai", - credit_units_per_kinic: 1n, - min_update_credit_units: 10_000n + cycles_per_kinic: 1n, + min_update_cycles: 10_000n } }; } @@ -365,10 +365,10 @@ test("listWritableDatabases returns active writable database summaries", async ( role: "Writer", status: "Active", logicalSizeBytes: "0", - creditsBalance: "20000", - creditsSuspendedAtMs: null, - writeCreditsAvailable: true, - creditsReason: null + cyclesBalance: "20000", + cyclesSuspendedAtMs: null, + writeCyclesAvailable: true, + cyclesReason: null } ]); } finally { @@ -393,7 +393,7 @@ function config() { }; } -function writeCreditsActorMethods({ databaseId = "team-db", balanceCredits = 20_000n, suspendedAtMs = null } = {}) { +function writeCyclesActorMethods({ databaseId = "team-db", balanceCycles = 20_000n, suspendedAtMs = null } = {}) { return { async list_databases() { return { @@ -404,20 +404,20 @@ function writeCreditsActorMethods({ databaseId = "team-db", balanceCredits = 20_ role: { Writer: null }, status: { Active: null }, logical_size_bytes: 0n, - credit_units_balance: [balanceCredits], - credits_suspended_at_ms: suspendedAtMs === null ? [] : [suspendedAtMs], + cycles_balance: [balanceCycles], + cycles_suspended_at_ms: suspendedAtMs === null ? [] : [suspendedAtMs], archived_at_ms: [] } ] }; }, - async get_credits_config() { + async get_cycles_billing_config() { return { Ok: { kinic_ledger_canister_id: "ryjl3-tyaaa-aaaaa-aaaba-cai", sns_governance_id: "rrkah-fqaaa-aaaaa-aaaaq-cai", - credit_units_per_kinic: 1n, - min_update_credit_units: 10_000n + cycles_per_kinic: 1n, + min_update_cycles: 10_000n } }; } @@ -431,8 +431,8 @@ function rawDatabase(databaseId, name, role, status) { role: { [role]: null }, status: { [status]: null }, logical_size_bytes: 0n, - credit_units_balance: [20_000n], - credits_suspended_at_ms: [], + cycles_balance: [20_000n], + cycles_suspended_at_ms: [], archived_at_ms: [] }; } diff --git a/extensions/wiki-clipper/tests/service-worker.test.mjs b/extensions/wiki-clipper/tests/service-worker.test.mjs index 91795340..c68cb74e 100644 --- a/extensions/wiki-clipper/tests/service-worker.test.mjs +++ b/extensions/wiki-clipper/tests/service-worker.test.mjs @@ -222,12 +222,12 @@ test("unauthenticated save-source opens settings once", async () => { } }); -test("credits-disabled save-source opens settings once", async () => { +test("cycles-disabled save-source opens settings once", async () => { const syncStorage = memoryStorage(); const settingsTabs = []; resetSettingsOpenThrottleForTest(); const restore = installChromeForSettings(syncStorage, settingsTabs); - setOffscreenBridgeForTest(async () => ({ ok: false, error: "Database credits balance is below the minimum update balance." })); + setOffscreenBridgeForTest(async () => ({ ok: false, error: "Database cycles balance is below the minimum update balance." })); try { const message = { type: "save-source", @@ -285,12 +285,12 @@ test("action click opens settings when database config is incomplete", async () ]); }); -test("action click opens settings and stores status when credits is disabled", async () => { +test("action click opens settings and stores status when cycles is disabled", async () => { const calls = []; const response = await handleActionClick( { url: "https://example.com/", title: "Example" }, actionDeps({ - sendOffscreen: async () => ({ ok: false, error: "Database credits are suspended." }), + sendOffscreen: async () => ({ ok: false, error: "Database cycles are suspended." }), openSettings: async () => calls.push(["settings"]), writeStatus: async (status) => calls.push(["status", status.status, status.message]), setBadge: async (text) => calls.push(["badge", text]) @@ -299,7 +299,7 @@ test("action click opens settings and stores status when credits is disabled", a assert.equal(response.ok, false); assert.deepEqual(calls, [ ["badge", "..."], - ["status", "error", "Database credits are suspended."], + ["status", "error", "Database cycles are suspended."], ["badge", "ERR"], ["settings"] ]); diff --git a/extensions/wiki-clipper/tests/settings.test.mjs b/extensions/wiki-clipper/tests/settings.test.mjs index 60fd31d7..49c684e0 100644 --- a/extensions/wiki-clipper/tests/settings.test.mjs +++ b/extensions/wiki-clipper/tests/settings.test.mjs @@ -139,9 +139,9 @@ test("database dropdown options include only active owner and writer databases", rawDatabase("writer-db", "Writer", "Active", 20_000n), rawDatabase("reader-db", "Reader", "Active", 20_000n), rawDatabase("archived-db", "Owner", "Archived", 20_000n) - ], { minUpdateCredits: "10000" }); + ], { minUpdateCycles: "10000" }); assert.deepEqual( - databases.map((database) => [database.databaseId, database.name, database.role, database.status, database.writeCreditsAvailable]), + databases.map((database) => [database.databaseId, database.name, database.role, database.status, database.writeCyclesAvailable]), [ ["owner-db", "owner-db name", "Owner", "Active", true], ["legacy-owner-db", "legacy-owner-db name", "Owner", "Active", true], @@ -150,29 +150,29 @@ test("database dropdown options include only active owner and writer databases", ); }); -test("database dropdown keeps credits-disabled writer databases with reasons", () => { +test("database dropdown keeps cycles-disabled writer databases with reasons", () => { const databases = normalizeWritableDatabases([ rawDatabase("active-db", "Owner", "Active", 20_000n), rawDatabase("low-db", "Writer", "Active", 9_999n), rawDatabase("suspended-db", "Writer", "Active", 20_000n, 1n) - ], { minUpdateCredits: "10000" }); + ], { minUpdateCycles: "10000" }); assert.deepEqual( - databases.map((database) => [database.databaseId, database.writeCreditsAvailable, database.creditsReason]), + databases.map((database) => [database.databaseId, database.writeCyclesAvailable, database.cyclesReason]), [ ["active-db", true, null], - ["low-db", false, "Database credits balance is below the minimum update balance."], - ["suspended-db", false, "Database credits are suspended."] + ["low-db", false, "Database cycles balance is below the minimum update balance."], + ["suspended-db", false, "Database cycles are suspended."] ] ); }); -test("database dropdown disables writer databases when credits config is unavailable", () => { +test("database dropdown disables writer databases when cycles config is unavailable", () => { const databases = normalizeWritableDatabases([ rawDatabase("owner-db", "Owner", "Active", 20_000n) ]); assert.deepEqual( - databases.map((database) => [database.databaseId, database.writeCreditsAvailable, database.creditsReason]), - [["owner-db", false, "Credits config unavailable."]] + databases.map((database) => [database.databaseId, database.writeCyclesAvailable, database.cyclesReason]), + [["owner-db", false, "Cycles config unavailable."]] ); }); @@ -250,17 +250,17 @@ test("settings docs describe automatic database save", () => { assert.doesNotMatch(storeAssets, /Refresh/); }); -function rawDatabase(databaseId, role, status, nameOrBalance = 20_000n, creditsSuspendedAtMs = null) { +function rawDatabase(databaseId, role, status, nameOrBalance = 20_000n, cyclesSuspendedAtMs = null) { const name = typeof nameOrBalance === "string" ? nameOrBalance : `${databaseId} name`; - const creditsBalance = typeof nameOrBalance === "bigint" ? nameOrBalance : 20_000n; + const cyclesBalance = typeof nameOrBalance === "bigint" ? nameOrBalance : 20_000n; return { database_id: databaseId, name, role: { [role]: null }, status: { [status]: null }, logical_size_bytes: 0n, - credit_units_balance: [creditsBalance], - credits_suspended_at_ms: creditsSuspendedAtMs === null ? [] : [creditsSuspendedAtMs], + cycles_balance: [cyclesBalance], + cycles_suspended_at_ms: cyclesSuspendedAtMs === null ? [] : [cyclesSuspendedAtMs], archived_at_ms: [] }; } diff --git a/wikibrowser/README.md b/wikibrowser/README.md index 9890ffa5..0aef1260 100644 --- a/wikibrowser/README.md +++ b/wikibrowser/README.md @@ -83,7 +83,7 @@ Submitting a URL writes one request node to the same database: Ingest request nodes are regular `file` nodes. Only fetched raw web evidence under `/Sources/raw//.md` is stored as `source`. -When `KINIC_WIKI_GENERATOR_URL` and the `KINIC_WIKI_WORKER_TOKEN` secret are set, the browser asks the VFS canister to authorize a 30 minute session trigger ticket for the II caller, writes the request, then calls `/api/url-ingest/trigger`. That server route checks the canister session ticket and configured canister id before forwarding `canisterId`, `databaseId`, `requestPath`, and `sessionNonce` to the generator Worker with bearer auth. The ticket is replayable within its TTL; duplicate jobs are handled by Worker/job idempotency and rate limits. Writer access and DB credits state are re-checked after issuance, so role revocation, suspension, or low balance can invalidate an existing ticket before its TTL. `Origin` is only a CORS allowlist, not the authorization boundary. +When `KINIC_WIKI_GENERATOR_URL` and the `KINIC_WIKI_WORKER_TOKEN` secret are set, the browser asks the VFS canister to authorize a 30 minute session trigger ticket for the II caller, writes the request, then calls `/api/url-ingest/trigger`. That server route checks the canister session ticket and configured canister id before forwarding `canisterId`, `databaseId`, `requestPath`, and `sessionNonce` to the generator Worker with bearer auth. The ticket is replayable within its TTL; duplicate jobs are handled by Worker/job idempotency and rate limits. Writer access and DB cycles state are re-checked after issuance, so role revocation, suspension, or low balance can invalidate an existing ticket before its TTL. `Origin` is only a CORS allowlist, not the authorization boundary. The worker fetches supported `http` / `https` HTML or text URLs, writes the normalized source to `/Sources/raw//.md`, then generates one review-ready page under `/Wiki/conversations`. Source run tickets are replayable within their TTL so `/api/source/run` can be retried after temporary Worker failures; duplicate source runs are handled by Worker/job idempotency. The generator Worker principal must have writer access to the target database. New databases include the default LLM writer service principal as a `writer` member so URL ingest and page generation can run immediately. Owners can revoke that member, but URL ingest sessions will fail while the service principal lacks writer access. diff --git a/wikibrowser/app/credits/credits-client.tsx b/wikibrowser/app/cycles/cycles-client.tsx similarity index 80% rename from wikibrowser/app/credits/credits-client.tsx rename to wikibrowser/app/cycles/cycles-client.tsx index 24d74047..b488f449 100644 --- a/wikibrowser/app/credits/credits-client.tsx +++ b/wikibrowser/app/cycles/cycles-client.tsx @@ -1,38 +1,38 @@ -// Where: /credits client UI. -// What: collects a KINIC amount locally, then submits wallet approval and credits purchase. -// Why: CLI/query can seed credits, and the final purchase amount remains user-editable. +// Where: /cycles client UI. +// What: collects a KINIC amount locally, then submits wallet approval and cycles purchase. +// Why: CLI/query can seed KINIC, and the final purchase amount remains user-editable. "use client"; import Link from "next/link"; import { CheckCircle2, CircleAlert, Info, PlugZap, Wallet } from "lucide-react"; import { useEffect, useMemo, useState, type ReactNode } from "react"; -import { parseCreditsAmountInput, parseCreditsTarget } from "@/lib/credits-url"; -import { connectOisyWallet, connectPlugWallet, purchaseCreditsWithOisy, purchaseCreditsWithPlug, type ConnectedOisyWallet, type ConnectedPlugWallet } from "@/lib/credits-wallet"; -import { formatTokenAmountFromE8s } from "@/lib/credit-amount"; +import { parseKinicAmountE8sInput, parseCyclesTarget } from "@/lib/cycles-url"; +import { connectOisyWallet, connectPlugWallet, purchaseCyclesWithOisy, purchaseCyclesWithPlug, type ConnectedOisyWallet, type ConnectedPlugWallet } from "@/lib/cycles-wallet"; +import { formatTokenAmountFromE8s } from "@/lib/kinic-amount"; -type CreditsStatus = "idle" | "connecting" | "running" | "success" | "error"; -type CreditsProvider = "oisy" | "plug"; +type CyclesStatus = "idle" | "connecting" | "running" | "success" | "error"; +type CyclesProvider = "oisy" | "plug"; -type CreditsClientProps = { +type CyclesClientProps = { canisterId: string; databaseId: string; - initialCredits?: string; + initialKinic?: string; }; -export function CreditsClient({ canisterId, databaseId, initialCredits }: CreditsClientProps) { - const [status, setStatus] = useState("idle"); +export function CyclesClient({ canisterId, databaseId, initialKinic }: CyclesClientProps) { + const [status, setStatus] = useState("idle"); const [message, setMessage] = useState(null); - const [provider, setProvider] = useState(null); - const [amount, setAmount] = useState(() => (initialCredits?.trim() ? initialCredits : "1")); + const [provider, setProvider] = useState(null); + const [amount, setAmount] = useState(() => (initialKinic?.trim() ? initialKinic : "1")); const [oisyWallet, setOisyWallet] = useState(null); const [plugWallet, setPlugWallet] = useState(null); const configuredCanisterId = process.env.NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID ?? ""; const parsedTarget = useMemo(() => { const params = new URLSearchParams(); params.set("database_id", databaseId); - return parseCreditsTarget(params); + return parseCyclesTarget(params); }, [databaseId]); - const parsedAmount = useMemo(() => parseCreditsAmountInput(amount), [amount]); + const parsedAmount = useMemo(() => parseKinicAmountE8sInput(amount), [amount]); const error = typeof parsedTarget === "string" ? parsedTarget @@ -59,7 +59,7 @@ export function CreditsClient({ canisterId, databaseId, initialCredits }: Credit }; }, [oisyWallet]); - async function connect(nextProvider: CreditsProvider) { + async function connect(nextProvider: CyclesProvider) { setStatus("connecting"); setProvider(nextProvider); setMessage(null); @@ -91,17 +91,17 @@ export function CreditsClient({ canisterId, databaseId, initialCredits }: Credit setProvider(selectedProvider); setMessage(null); try { - const request = { canisterId, databaseId: parsedTarget.databaseId, creditUnits: parsedAmount }; + const request = { canisterId, databaseId: parsedTarget.databaseId, paymentAmountE8s: parsedAmount }; const result = selectedProvider === "oisy" && activeOisyWallet - ? await purchaseCreditsWithOisy(request, activeOisyWallet) + ? await purchaseCyclesWithOisy(request, activeOisyWallet) : activePlugWallet - ? await purchaseCreditsWithPlug(request, activePlugWallet) + ? await purchaseCyclesWithPlug(request, activePlugWallet) : null; if (!result) return; - const balance = result.balanceCredits ? `credits balance ${result.balanceCredits}` : "credits purchase accepted"; + const balance = result.balanceCycles ? `cycles balance ${result.balanceCycles}` : "cycles purchase accepted"; setMessage( - `${result.provider} approve block ${result.approveBlockIndex}; purchased credits ${result.creditedCredits}; paid ${formatTokenAmountFromE8s(result.paymentAmountE8s)} KINIC; approved allowance ${formatTokenAmountFromE8s(result.approvedAllowanceE8s)}; ledger transfer fee in allowance ${formatTokenAmountFromE8s(result.transferFeeE8s)}; ${balance}` + `${result.provider} approve block ${result.approveBlockIndex}; purchased cycles ${result.purchasedCycles}; paid ${formatTokenAmountFromE8s(result.paymentAmountE8s)} KINIC; approved allowance ${formatTokenAmountFromE8s(result.approvedAllowanceE8s)}; ledger transfer fee in allowance ${formatTokenAmountFromE8s(result.transferFeeE8s)}; ${balance}` ); if (selectedProvider === "oisy") setOisyWallet(null); setStatus("success"); @@ -118,14 +118,14 @@ export function CreditsClient({ canisterId, databaseId, initialCredits }: Credit Database dashboard -

Database purchase

+

Database cycles purchase

- +
@@ -225,14 +225,14 @@ function WalletConnect({ ); } -function purchaseButtonLabel(selectedProvider: CreditsProvider | null, status: CreditsStatus, activeProvider: CreditsProvider | null): string { +function purchaseButtonLabel(selectedProvider: CyclesProvider | null, status: CyclesStatus, activeProvider: CyclesProvider | null): string { if (status === "running" && activeProvider === selectedProvider) { if (selectedProvider === "oisy") return "Processing OISY"; if (selectedProvider === "plug") return "Processing Plug"; } - if (selectedProvider === "oisy") return "Purchase credits with OISY"; - if (selectedProvider === "plug") return "Purchase credits with Plug"; - return "Purchase credits"; + if (selectedProvider === "oisy") return "Purchase cycles with OISY"; + if (selectedProvider === "plug") return "Purchase cycles with Plug"; + return "Purchase cycles"; } function Field({ label, value }: { label: string; value: string }) { diff --git a/wikibrowser/app/credits/page.tsx b/wikibrowser/app/cycles/page.tsx similarity index 57% rename from wikibrowser/app/credits/page.tsx rename to wikibrowser/app/cycles/page.tsx index 276b42c3..ef703b13 100644 --- a/wikibrowser/app/credits/page.tsx +++ b/wikibrowser/app/cycles/page.tsx @@ -1,23 +1,23 @@ -// Where: /credits route. +// Where: /cycles route. // What: passes the configured canister and target database into the client. -// Why: CLI/query can seed credits, but canister selection must not come from URL input. +// Why: CLI/query can seed cycles, but canister selection must not come from URL input. import type { Metadata } from "next"; -import { CreditsClient } from "./credits-client"; +import { CyclesClient } from "./cycles-client"; export const metadata: Metadata = { - title: "Kinic Wiki Credits", - description: "Fund a Kinic Wiki database credits balance with a wallet." + title: "Kinic Wiki Cycles", + description: "Fund a Kinic Wiki database cycles balance with a wallet." }; type PageSearchParams = Promise>; -export default async function CreditsPage({ searchParams }: { searchParams: PageSearchParams }) { +export default async function CyclesPage({ searchParams }: { searchParams: PageSearchParams }) { const params = await searchParams; return ( - ); } diff --git a/wikibrowser/app/dashboard/dashboard-client.tsx b/wikibrowser/app/dashboard/dashboard-client.tsx index 2d1348be..710940f4 100644 --- a/wikibrowser/app/dashboard/dashboard-client.tsx +++ b/wikibrowser/app/dashboard/dashboard-client.tsx @@ -8,12 +8,12 @@ import type { BusyAction } from "./access-control"; import { AuthControls, OwnerPanel, ReadonlyMembersPanel, StatusPanel, SummaryPanel } from "./dashboard-ui"; import { CycleBattery } from "@/components/cycle-battery"; import { AUTH_CLIENT_CREATE_OPTIONS, authLoginOptions } from "@/lib/auth"; -import type { CreditsConfig, DatabaseMember, DatabaseRole, DatabaseSummary } from "@/lib/types"; +import type { CyclesBillingConfig, DatabaseMember, DatabaseRole, DatabaseSummary } from "@/lib/types"; import { deleteDatabaseAuthenticated, - getCreditsConfig, + getCyclesBillingConfig, grantDatabaseAccessAuthenticated, - listDatabaseCreditPendingOperationsAuthenticated, + listDatabaseCyclePendingOperationsAuthenticated, listDatabaseMembersAuthenticated, listDatabaseMembersPublic, listDatabasesAuthenticated, @@ -32,7 +32,7 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) const [authClient, setAuthClient] = useState(null); const [principal, setPrincipal] = useState(null); const [databases, setDatabases] = useState([]); - const [creditsConfig, setCreditsConfig] = useState(null); + const [cyclesConfig, setCyclesBillingConfig] = useState(null); const [members, setMembers] = useState([]); const [pendingOperationCount, setPendingOperationCount] = useState(0); const [loadState, setLoadState] = useState("idle"); @@ -59,7 +59,7 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) if (!nextDatabaseId) { setPrincipal(client?.getIdentity().getPrincipal().toText() ?? null); setDatabases([]); - setCreditsConfig(null); + setCyclesBillingConfig(null); setMembers([]); setPendingOperationCount(0); setError(null); @@ -74,8 +74,8 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) setMemberError(null); try { const identity = client?.getIdentity() ?? null; - const [creditsResult, publicResult, memberResult] = await Promise.allSettled([ - getCreditsConfig(canisterId), + const [cyclesResult, publicResult, memberResult] = await Promise.allSettled([ + getCyclesBillingConfig(canisterId), listDatabasesPublic(canisterId), identity ? listDatabasesAuthenticated(canisterId, identity) : Promise.resolve([]) ]); @@ -92,7 +92,7 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) const nextDatabase = nextDatabases.find((item) => item.databaseId === nextDatabaseId) ?? null; setPrincipal(identity?.getPrincipal().toText() ?? null); setDatabases(nextDatabases); - setCreditsConfig(creditsResult.status === "fulfilled" ? creditsResult.value : null); + setCyclesBillingConfig(cyclesResult.status === "fulfilled" ? cyclesResult.value : null); setMembers([]); setPendingOperationCount(0); if (publicResult.status === "rejected") { @@ -111,7 +111,7 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) setMemberError(errorMessage(cause)); } try { - const pendingOperations = await listDatabaseCreditPendingOperationsAuthenticated(canisterId, identity, nextDatabaseId); + const pendingOperations = await listDatabaseCyclePendingOperationsAuthenticated(canisterId, identity, nextDatabaseId); if (!isCurrentRefresh()) return; setPendingOperationCount(pendingOperations.length); } catch { @@ -182,7 +182,7 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) await authClient.logout(); setPrincipal(null); setDatabases([]); - setCreditsConfig(null); + setCyclesBillingConfig(null); setMembers([]); setPendingOperationCount(0); setError(null); @@ -294,12 +294,12 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) {warning ? : null} {actionMessage ? : null} - {database ? : null} + {database ? : null} {database ? ( canManage ? ( @@ -61,9 +61,9 @@ export function SummaryPanel({ - +
- {purchaseHref ? } label="Credits" /> : null} + {purchaseHref ? } label="Cycles" /> : null} {openHref ? } label="Open" /> : null} {publicReadable && routable ? ( {pendingAction ? setPendingAction(null)} onConfirm={confirmPendingAction} /> : null} {props.databaseName} / {props.databaseId}

- +
@@ -113,13 +113,13 @@ function ConfirmDeleteDatabaseDialog(props: { ); } -function DeleteCreditsNotice({ pendingOperationCount }: { pendingOperationCount: number }) { +function DeleteCyclesNotice({ pendingOperationCount }: { pendingOperationCount: number }) { if (pendingOperationCount > 0) { return (

- Resolve pending credit operations before deleting. Pending operations: {pendingOperationCount} + Resolve pending cycle operations before deleting. Pending operations: {pendingOperationCount}

); } - return

Remaining credits will be discarded.

; + return

Remaining cycles will be discarded.

; } diff --git a/wikibrowser/app/home-ui.tsx b/wikibrowser/app/home-ui.tsx index ea52ca7b..8edf4b00 100644 --- a/wikibrowser/app/home-ui.tsx +++ b/wikibrowser/app/home-ui.tsx @@ -3,8 +3,8 @@ import Link from "next/link"; import { BookOpen, Settings, Share2, Wallet } from "lucide-react"; import type { ReactNode } from "react"; -import { databaseCreditsView, databaseCreditsHref } from "@/lib/credits-state"; -import type { CreditsConfig, DatabaseSummary } from "@/lib/types"; +import { databaseCyclesView, databaseCyclesHref } from "@/lib/cycles-state"; +import type { CyclesBillingConfig, DatabaseSummary } from "@/lib/types"; import { isRoutableDatabaseId, publicDatabasePath, xShareDatabaseHref } from "@/lib/share-links"; const OFFICIAL_KINIC_WIKI_DATABASE_ID = "db_kva4v2twg6jv"; @@ -57,14 +57,14 @@ export function AuthControls({ } export function DatabaseBody({ - creditsConfig, + cyclesConfig, loading, myDatabases, principal, publicError, publicDatabases }: { - creditsConfig: CreditsConfig | null; + cyclesConfig: CyclesBillingConfig | null; loading: boolean; myDatabases: DatabaseRow[]; principal: string | null; @@ -73,12 +73,12 @@ export function DatabaseBody({ }) { if (loading) return
Loading databases...
; if (!principal) { - return ; + return ; } return (
- - + +
); } @@ -110,7 +110,7 @@ export function OfficialKinicWikiPanel() { } function DatabaseSection({ - creditsConfig, + cyclesConfig, description, emptyMessage, mode, @@ -119,7 +119,7 @@ function DatabaseSection({ showTitle = true, title }: { - creditsConfig: CreditsConfig | null; + cyclesConfig: CyclesBillingConfig | null; description: string; emptyMessage: string; mode: "member" | "public"; @@ -150,7 +150,7 @@ function DatabaseSection({ {!showTitle && publicError && mode === "public" ?

{publicError}

: null}
{rows.map((database) => ( - + ))}
@@ -161,7 +161,7 @@ function DatabaseSection({ Role Status Logical size - Credits + Cycles Open Share {mode === "member" ? Skills : null} @@ -181,7 +181,7 @@ function DatabaseSection({ {database.role} {database.status} {formatBytes(database.logicalSizeBytes)} - {databaseCreditsView(database, creditsConfig).summary} + {databaseCyclesView(database, cyclesConfig).summary}
{isRoutableDatabaseId(database.databaseId) ? } label="Open" /> : -} @@ -197,7 +197,7 @@ function DatabaseSection({ Registry - } label="Credits" /> + } label="Cycles" />
) : null} @@ -213,7 +213,7 @@ function DatabaseSection({ ); } -function DatabaseMobileCard({ creditsConfig, database, mode }: { creditsConfig: CreditsConfig | null; database: DatabaseRow; mode: "member" | "public" }) { +function DatabaseMobileCard({ cyclesConfig, database, mode }: { cyclesConfig: CyclesBillingConfig | null; database: DatabaseRow; mode: "member" | "public" }) { return (
@@ -225,7 +225,7 @@ function DatabaseMobileCard({ creditsConfig, database, mode }: { creditsConfig: - +
{isRoutableDatabaseId(database.databaseId) ? ( @@ -240,7 +240,7 @@ function DatabaseMobileCard({ creditsConfig, database, mode }: { creditsConfig: ) : null} {mode === "member" ? ( - } label="Credits" /> + } label="Cycles" /> ) : null} {database.publicReadable && isRoutableDatabaseId(database.databaseId) ? : null} } label="Access" /> diff --git a/wikibrowser/app/page.tsx b/wikibrowser/app/page.tsx index 08a2f9bb..c68255dd 100644 --- a/wikibrowser/app/page.tsx +++ b/wikibrowser/app/page.tsx @@ -8,8 +8,8 @@ import { Plus, TerminalSquare } from "lucide-react"; import { CreateDatabaseDialog } from "./create-database-dialog"; import { AuthControls, CreatedDatabasePanel, DatabaseBody, OfficialKinicWikiPanel, StatusPanel } from "./home-ui"; import { AUTH_CLIENT_CREATE_OPTIONS, authLoginOptions } from "@/lib/auth"; -import type { CreditsConfig, DatabaseSummary } from "@/lib/types"; -import { createDatabaseAuthenticated, getCreditsConfig, listDatabasesAuthenticated, listDatabasesPublic } from "@/lib/vfs-client"; +import type { CyclesBillingConfig, DatabaseSummary } from "@/lib/types"; +import { createDatabaseAuthenticated, getCyclesBillingConfig, listDatabasesAuthenticated, listDatabasesPublic } from "@/lib/vfs-client"; import type { DatabaseRow } from "./home-ui"; type LoadState = "idle" | "loading" | "ready" | "error"; @@ -20,7 +20,7 @@ export default function HomePage() { const [authClient, setAuthClient] = useState(null); const [principal, setPrincipal] = useState(null); const [databases, setDatabases] = useState([]); - const [creditsConfig, setCreditsConfig] = useState(null); + const [cyclesConfig, setCyclesBillingConfig] = useState(null); const [loadState, setLoadState] = useState("idle"); const [error, setError] = useState(null); const [publicError, setPublicError] = useState(null); @@ -45,8 +45,8 @@ export default function HomePage() { setWarning(null); try { const identity = client?.getIdentity() ?? null; - const [creditsResult, publicResult, memberResult] = await Promise.allSettled([ - getCreditsConfig(canisterId), + const [cyclesResult, publicResult, memberResult] = await Promise.allSettled([ + getCyclesBillingConfig(canisterId), listDatabasesPublic(canisterId), identity ? listDatabasesAuthenticated(canisterId, identity) : Promise.resolve([]) ]); @@ -58,7 +58,7 @@ export default function HomePage() { const nextDatabases = mergeDatabaseRows(memberDatabases, publicDatabases); if (!isCurrentRefresh()) return; setDatabases(nextDatabases); - setCreditsConfig(creditsResult.status === "fulfilled" ? creditsResult.value : null); + setCyclesBillingConfig(cyclesResult.status === "fulfilled" ? cyclesResult.value : null); setPrincipal(identity?.getPrincipal().toText() ?? null); setPublicError(publicResult.status === "rejected" ? `Public database list unavailable: ${errorMessage(publicResult.reason)}` : null); setWarning(listWarning(memberResult)); @@ -115,7 +115,7 @@ export default function HomePage() { if (!authClient) return; await authClient.logout(); setPrincipal(null); - setCreditsConfig(null); + setCyclesBillingConfig(null); setCreatedDatabase(null); setCreateDialogOpen(false); setNewDatabaseName(""); @@ -223,7 +223,7 @@ export default function HomePage() {
- + ) : (
@@ -233,7 +233,7 @@ export default function HomePage() {

Public databases open without login. Login with Internet Identity to show My databases linked to your principal.

- + )} diff --git a/wikibrowser/components/document-pane.tsx b/wikibrowser/components/document-pane.tsx index da4fe322..afe7b616 100644 --- a/wikibrowser/components/document-pane.tsx +++ b/wikibrowser/components/document-pane.tsx @@ -113,7 +113,7 @@ export function DocumentPane({ writeIdentity, currentDatabaseRole, databaseRoleError, - databaseCreditsError, + databaseCyclesError, onNodeSaved, onFolderIndexSaved, onEditStateChange, @@ -132,7 +132,7 @@ export function DocumentPane({ writeIdentity?: Identity | null; currentDatabaseRole?: DatabaseRole | null; databaseRoleError?: string | null; - databaseCreditsError?: string | null; + databaseCyclesError?: string | null; onNodeSaved?: () => Promise; onFolderIndexSaved?: () => Promise; onEditStateChange?: (state: DocumentEditState) => void; @@ -160,7 +160,7 @@ export function DocumentPane({ writeIdentity={writeIdentity ?? null} currentDatabaseRole={currentDatabaseRole ?? null} databaseRoleError={databaseRoleError ?? null} - databaseCreditsError={databaseCreditsError ?? null} + databaseCyclesError={databaseCyclesError ?? null} onFolderIndexSaved={onFolderIndexSaved} onEditStateChange={onEditStateChange} /> @@ -181,7 +181,7 @@ export function DocumentPane({ writeIdentity={writeIdentity ?? null} currentDatabaseRole={currentDatabaseRole ?? null} databaseRoleError={databaseRoleError ?? null} - databaseCreditsError={databaseCreditsError ?? null} + databaseCyclesError={databaseCyclesError ?? null} onNodeSaved={onNodeSaved} onEditStateChange={onEditStateChange} tab={tab} @@ -285,7 +285,7 @@ function NodeDocument({ writeIdentity, currentDatabaseRole, databaseRoleError, - databaseCreditsError, + databaseCyclesError, onNodeSaved, onEditStateChange }: { @@ -300,7 +300,7 @@ function NodeDocument({ writeIdentity: Identity | null; currentDatabaseRole: DatabaseRole | null; databaseRoleError: string | null; - databaseCreditsError: string | null; + databaseCyclesError: string | null; onNodeSaved?: () => Promise; onEditStateChange?: (state: DocumentEditState) => void; }) { @@ -321,7 +321,7 @@ function NodeDocument({ writeIdentity={writeIdentity} currentDatabaseRole={currentDatabaseRole} databaseRoleError={databaseRoleError} - databaseCreditsError={databaseCreditsError} + databaseCyclesError={databaseCyclesError} onNodeSaved={onNodeSaved} onEditStateChange={onEditStateChange} /> @@ -355,7 +355,7 @@ function EditDocument({ writeIdentity, currentDatabaseRole, databaseRoleError, - databaseCreditsError, + databaseCyclesError, onNodeSaved, onEditStateChange }: { @@ -371,7 +371,7 @@ function EditDocument({ writeIdentity: Identity | null; currentDatabaseRole: DatabaseRole | null; databaseRoleError: string | null; - databaseCreditsError: string | null; + databaseCyclesError: string | null; onNodeSaved?: () => Promise; onEditStateChange?: (state: DocumentEditState) => void; }) { @@ -419,8 +419,8 @@ function EditDocument({ if (currentDatabaseRole !== "writer" && currentDatabaseRole !== "owner") { return ; } - if (databaseCreditsError) { - return ; + if (databaseCyclesError) { + return ; } if (!onNodeSaved) { return ; @@ -572,7 +572,7 @@ function FolderDocument({ writeIdentity, currentDatabaseRole, databaseRoleError, - databaseCreditsError, + databaseCyclesError, onFolderIndexSaved, onEditStateChange }: { @@ -589,7 +589,7 @@ function FolderDocument({ writeIdentity: Identity | null; currentDatabaseRole: DatabaseRole | null; databaseRoleError: string | null; - databaseCreditsError: string | null; + databaseCyclesError: string | null; onFolderIndexSaved?: () => Promise; onEditStateChange?: (state: DocumentEditState) => void; }) { @@ -614,7 +614,7 @@ function FolderDocument({ writeIdentity={writeIdentity} currentDatabaseRole={currentDatabaseRole} databaseRoleError={databaseRoleError} - databaseCreditsError={databaseCreditsError} + databaseCyclesError={databaseCyclesError} onNodeSaved={onFolderIndexSaved} onEditStateChange={onEditStateChange} /> diff --git a/wikibrowser/components/ingest-panel.tsx b/wikibrowser/components/ingest-panel.tsx index c77aa0c6..af58f116 100644 --- a/wikibrowser/components/ingest-panel.tsx +++ b/wikibrowser/components/ingest-panel.tsx @@ -9,12 +9,12 @@ export function IngestPanel({ canisterId, databaseId, readIdentity, - databaseCreditsError + databaseCyclesError }: { canisterId: string; databaseId: string; readIdentity: Identity | null; - databaseCreditsError: string | null; + databaseCyclesError: string | null; }) { const [url, setUrl] = useState(""); const [busy, setBusy] = useState(false); @@ -24,9 +24,9 @@ export function IngestPanel({ async function submit(event: FormEvent) { event.preventDefault(); if (!readIdentity || !url.trim()) return; - if (databaseCreditsError) { + if (databaseCyclesError) { setTone("error"); - setMessage(databaseCreditsError); + setMessage(databaseCyclesError); return; } setBusy(true); @@ -54,7 +54,7 @@ export function IngestPanel({ ); } - const submitDisabled = busy || !url.trim() || Boolean(databaseCreditsError); + const submitDisabled = busy || !url.trim() || Boolean(databaseCyclesError); return (
@@ -81,7 +81,7 @@ export function IngestPanel({
{databaseId}
- {databaseCreditsError ?
{databaseCreditsError}
: null} + {databaseCyclesError ?
{databaseCyclesError}
: null} {message ?
{message}
: null}
); diff --git a/wikibrowser/components/query-panel.tsx b/wikibrowser/components/query-panel.tsx index be35c08a..3c8382ee 100644 --- a/wikibrowser/components/query-panel.tsx +++ b/wikibrowser/components/query-panel.tsx @@ -30,7 +30,7 @@ export function QueryPanel({ writeIdentity, readMode, readIdentityMode, - databaseCreditsError + databaseCyclesError }: { canisterId: string; databaseId: string; @@ -40,7 +40,7 @@ export function QueryPanel({ writeIdentity: Identity | null; readMode: "anonymous" | null; readIdentityMode: ReadIdentityMode; - databaseCreditsError: string | null; + databaseCyclesError: string | null; }) { const [input, setInput] = useState(""); const [activeAction, setActiveAction] = useState(null); @@ -146,8 +146,8 @@ export function QueryPanel({ setResult({ kind: "message", tone: "error", text: "Login with Internet Identity to queue URL ingest." }); return; } - if (databaseCreditsError) { - setResult({ kind: "message", tone: "error", text: databaseCreditsError }); + if (databaseCyclesError) { + setResult({ kind: "message", tone: "error", text: databaseCyclesError }); return; } setBusy(true); diff --git a/wikibrowser/components/wiki-browser.tsx b/wikibrowser/components/wiki-browser.tsx index 8bc46fd8..01138b7c 100644 --- a/wikibrowser/components/wiki-browser.tsx +++ b/wikibrowser/components/wiki-browser.tsx @@ -17,13 +17,13 @@ import { IngestPanel } from "@/components/ingest-panel"; import { QueryPanel } from "@/components/query-panel"; import { PanelHeader } from "@/components/panel"; import { AUTH_CLIENT_CREATE_OPTIONS, authLoginOptions } from "@/lib/auth"; -import { databaseCreditsDisabledReason, databaseCreditsHref, databaseCreditsView, formatCredits } from "@/lib/credits-state"; +import { databaseCyclesDisabledReason, databaseCyclesHref, databaseCyclesView, formatCycles } from "@/lib/cycles-state"; import { readBrowserNodeCache } from "@/lib/browser-node-cache"; import { hrefForDatabaseSwitch, hrefForGraph, hrefForHelp, hrefForPath, hrefForSearch, parentPath } from "@/lib/paths"; import { nodeRequestKey } from "@/lib/request-keys"; import { xShareDatabaseHref } from "@/lib/share-links"; -import type { CreditsConfig, ChildNode, DatabaseRole, DatabaseSummary, NodeContext, WikiNode } from "@/lib/types"; -import { getCreditsConfig, listDatabasesAuthenticated, listDatabasesPublic } from "@/lib/vfs-client"; +import type { CyclesBillingConfig, ChildNode, DatabaseRole, DatabaseSummary, NodeContext, WikiNode } from "@/lib/types"; +import { getCyclesBillingConfig, listDatabasesAuthenticated, listDatabasesPublic } from "@/lib/vfs-client"; import { folderIndexPath, isReservedFolderIndexName, visibleChildren } from "@/lib/folder-index"; import { errorHint, @@ -82,7 +82,7 @@ export function WikiBrowser() { const [authError, setAuthError] = useState(null); const [databases, setDatabases] = useState([]); const [memberDatabases, setMemberDatabases] = useState([]); - const [creditsConfig, setCreditsConfig] = useState(null); + const [cyclesConfig, setCyclesBillingConfig] = useState(null); const [publicDatabaseIds, setPublicDatabaseIds] = useState>(() => new Set()); const [memberDatabasesLoaded, setMemberDatabasesLoaded] = useState(false); const [databaseListError, setDatabaseListError] = useState(null); @@ -145,16 +145,16 @@ export function WikiBrowser() { return Promise.allSettled([ listDatabasesPublic(canisterId), readIdentity ? listDatabasesAuthenticated(canisterId, readIdentity) : Promise.resolve([]), - getCreditsConfig(canisterId) + getCyclesBillingConfig(canisterId) ]); }) .then((results) => { if (cancelled || !results) return; - const [publicResult, memberResult, creditsConfigResult] = results; + const [publicResult, memberResult, cyclesConfigResult] = results; if (publicResult.status === "rejected" && memberResult.status === "rejected") { setDatabases([]); setMemberDatabases([]); - setCreditsConfig(null); + setCyclesBillingConfig(null); setPublicDatabaseIds(new Set()); setMemberDatabasesLoaded(false); setDatabaseListError(`${errorMessage(publicResult.reason)}; ${errorMessage(memberResult.reason)}`); @@ -164,16 +164,16 @@ export function WikiBrowser() { const authenticatedDatabases = memberResult.status === "fulfilled" ? memberResult.value : []; setDatabases(mergeDatabaseSummaries(authenticatedDatabases, publicDatabases)); setMemberDatabases(authenticatedDatabases); - setCreditsConfig(creditsConfigResult.status === "fulfilled" ? creditsConfigResult.value : null); + setCyclesBillingConfig(cyclesConfigResult.status === "fulfilled" ? cyclesConfigResult.value : null); setPublicDatabaseIds(new Set(publicDatabases.map((database) => database.databaseId))); setMemberDatabasesLoaded(memberResult.status === "fulfilled"); - setDatabaseListError(databaseListWarning(publicResult, memberResult, creditsConfigResult)); + setDatabaseListError(databaseListWarning(publicResult, memberResult, cyclesConfigResult)); }) .catch((cause) => { if (!cancelled) { setDatabases([]); setMemberDatabases([]); - setCreditsConfig(null); + setCyclesBillingConfig(null); setPublicDatabaseIds(new Set()); setMemberDatabasesLoaded(false); setDatabaseListError(errorMessage(cause)); @@ -360,9 +360,9 @@ export function WikiBrowser() { }, [canLeaveDirtyEdit, logout]); const databaseOptions = useMemo(() => withCurrentDatabase(databases, databaseId), [databaseId, databases]); const currentDatabase = useMemo(() => databaseOptions.find((database) => database.databaseId === databaseId) ?? null, [databaseId, databaseOptions]); - const currentDatabaseCreditReason = useMemo( - () => readIdentity && currentDatabaseRole ? databaseCreditsDisabledReason(currentDatabase, creditsConfig) : null, - [creditsConfig, currentDatabase, currentDatabaseRole, readIdentity] + const currentDatabaseCycleReason = useMemo( + () => readIdentity && currentDatabaseRole ? databaseCyclesDisabledReason(currentDatabase, cyclesConfig) : null, + [cyclesConfig, currentDatabase, currentDatabaseRole, readIdentity] ); const explorerSelectionKey = nodeRequestKey(canisterId, databaseId, selectedPath, readPrincipal); const selectedExplorerNode = selectedExplorerState?.key === explorerSelectionKey @@ -373,7 +373,7 @@ export function WikiBrowser() { readIdentity, currentDatabaseRole, readIdentity && !currentDatabaseRole ? databaseListError : null, - currentDatabaseCreditReason + currentDatabaseCycleReason ); const explorerCreateDirectory = createDirectoryForExplorerNode(selectedExplorerNode); const explorerMutationTarget = selectedExplorerNode && isMutableWikiExplorerNode(selectedExplorerNode) ? selectedExplorerNode : null; @@ -406,7 +406,7 @@ export function WikiBrowser() { if (readMode === "anonymous") throw new Error("Authenticated mode is required."); if (!readIdentity) throw new Error("Login with Internet Identity to create Markdown files."); if (currentDatabaseRole !== "writer" && currentDatabaseRole !== "owner") throw new Error("Writer or owner access required."); - if (currentDatabaseCreditReason) throw new Error(currentDatabaseCreditReason); + if (currentDatabaseCycleReason) throw new Error(currentDatabaseCycleReason); const nextPath = wikiMarkdownChildPath(directoryPath, fileName); const { writeNodeAuthenticated } = await import("@/lib/vfs-client"); await writeNodeAuthenticated(canisterId, readIdentity, { @@ -421,13 +421,13 @@ export function WikiBrowser() { setEditState(EMPTY_EDIT_STATE); router.replace(hrefForPath(canisterId, databaseId, nextPath, "edit", tab, undefined, undefined, readMode)); return true; - }, [canLeaveDirtyEdit, canisterId, currentDatabaseCreditReason, currentDatabaseRole, databaseId, invalidateBrowserCaches, readIdentity, readMode, router, setEditState, tab]); + }, [canLeaveDirtyEdit, canisterId, currentDatabaseCycleReason, currentDatabaseRole, databaseId, invalidateBrowserCaches, readIdentity, readMode, router, setEditState, tab]); const createFolderNode = useCallback(async (directoryPath: string, folderName: string) => { if (!canLeaveDirtyEdit()) return false; if (readMode === "anonymous") throw new Error("Authenticated mode is required."); if (!readIdentity) throw new Error("Login with Internet Identity to create folders."); if (currentDatabaseRole !== "writer" && currentDatabaseRole !== "owner") throw new Error("Writer or owner access required."); - if (currentDatabaseCreditReason) throw new Error(currentDatabaseCreditReason); + if (currentDatabaseCycleReason) throw new Error(currentDatabaseCycleReason); const nextPath = wikiChildPath(directoryPath, folderName, "folder"); const { mkdirNodeAuthenticated } = await import("@/lib/vfs-client"); await mkdirNodeAuthenticated(canisterId, readIdentity, { @@ -438,13 +438,13 @@ export function WikiBrowser() { setEditState(EMPTY_EDIT_STATE); router.replace(hrefForPath(canisterId, databaseId, nextPath, undefined, tab, undefined, undefined, readMode)); return true; - }, [canLeaveDirtyEdit, canisterId, currentDatabaseCreditReason, currentDatabaseRole, databaseId, invalidateBrowserCaches, readIdentity, readMode, router, setEditState, tab]); + }, [canLeaveDirtyEdit, canisterId, currentDatabaseCycleReason, currentDatabaseRole, databaseId, invalidateBrowserCaches, readIdentity, readMode, router, setEditState, tab]); const renameExplorerNode = useCallback(async (target: ChildNode, nextName: string) => { if (!canLeaveDirtyEdit()) return false; if (readMode === "anonymous") throw new Error("Authenticated mode is required."); if (!readIdentity) throw new Error("Login with Internet Identity to rename nodes."); if (currentDatabaseRole !== "writer" && currentDatabaseRole !== "owner") throw new Error("Writer or owner access required."); - if (currentDatabaseCreditReason) throw new Error(currentDatabaseCreditReason); + if (currentDatabaseCycleReason) throw new Error(currentDatabaseCycleReason); if (!isMutableWikiExplorerNode(target)) throw new Error("Only /Wiki Markdown files and folders can be renamed."); if (!target.etag) throw new Error("Cannot rename a node without an etag."); const normalizedName = target.kind === "file" ? normalizeMarkdownFileName(nextName) : normalizePathSegment(nextName); @@ -463,13 +463,13 @@ export function WikiBrowser() { setEditState(EMPTY_EDIT_STATE); router.replace(hrefForPath(canisterId, databaseId, nextPath, target.kind === "file" ? view : undefined, tab, undefined, undefined, readMode)); return true; - }, [canLeaveDirtyEdit, canisterId, currentDatabaseCreditReason, currentDatabaseRole, databaseId, invalidateBrowserCaches, readIdentity, readMode, router, setEditState, tab, view]); + }, [canLeaveDirtyEdit, canisterId, currentDatabaseCycleReason, currentDatabaseRole, databaseId, invalidateBrowserCaches, readIdentity, readMode, router, setEditState, tab, view]); const moveExplorerNode = useCallback(async (target: ChildNode, targetDirectory: string) => { if (!canLeaveDirtyEdit()) return false; if (readMode === "anonymous") throw new Error("Authenticated mode is required."); if (!readIdentity) throw new Error("Login with Internet Identity to move nodes."); if (currentDatabaseRole !== "writer" && currentDatabaseRole !== "owner") throw new Error("Writer or owner access required."); - if (currentDatabaseCreditReason) throw new Error(currentDatabaseCreditReason); + if (currentDatabaseCycleReason) throw new Error(currentDatabaseCycleReason); if (!isMutableWikiExplorerNode(target)) throw new Error("Only /Wiki Markdown files and folders can be moved."); if (!target.etag) throw new Error("Cannot move a node without an etag."); if (!isWikiPath(targetDirectory)) throw new Error("Move destination must be under /Wiki."); @@ -487,13 +487,13 @@ export function WikiBrowser() { setEditState(EMPTY_EDIT_STATE); router.replace(hrefForPath(canisterId, databaseId, nextPath, target.kind === "file" ? view : undefined, tab, undefined, undefined, readMode)); return true; - }, [canLeaveDirtyEdit, canisterId, currentDatabaseCreditReason, currentDatabaseRole, databaseId, invalidateBrowserCaches, readIdentity, readMode, router, setEditState, tab, view]); + }, [canLeaveDirtyEdit, canisterId, currentDatabaseCycleReason, currentDatabaseRole, databaseId, invalidateBrowserCaches, readIdentity, readMode, router, setEditState, tab, view]); const deleteExplorerNode = useCallback(async (target: ChildNode) => { if (!canLeaveDirtyEdit()) return false; if (readMode === "anonymous") throw new Error("Authenticated mode is required."); if (!readIdentity) throw new Error("Login with Internet Identity to delete nodes."); if (currentDatabaseRole !== "writer" && currentDatabaseRole !== "owner") throw new Error("Writer or owner access required."); - if (currentDatabaseCreditReason) throw new Error(currentDatabaseCreditReason); + if (currentDatabaseCycleReason) throw new Error(currentDatabaseCycleReason); const targetChildren = target.kind === "folder" ? childNodesCache.current.get(nodeRequestKey(canisterId, databaseId, target.path, readPrincipal)) : undefined; @@ -516,7 +516,7 @@ export function WikiBrowser() { router.replace(hrefForPath(canisterId, databaseId, parentPath(target.path) ?? "/Wiki", undefined, tab, undefined, undefined, readMode)); } return true; - }, [canLeaveDirtyEdit, canisterId, currentDatabaseCreditReason, currentDatabaseRole, databaseId, invalidateBrowserCaches, readIdentity, readMode, readPrincipal, router, selectedPath, setEditState, tab]); + }, [canLeaveDirtyEdit, canisterId, currentDatabaseCycleReason, currentDatabaseRole, databaseId, invalidateBrowserCaches, readIdentity, readMode, readPrincipal, router, selectedPath, setEditState, tab]); async function submitExplorerCreate(event: FormEvent) { event.preventDefault(); @@ -617,7 +617,7 @@ export function WikiBrowser() { databaseOptions={databaseOptions} currentDatabase={currentDatabase} currentDatabaseName={currentDatabase?.name ?? databaseId} - creditsConfig={creditsConfig} + cyclesConfig={cyclesConfig} publicReadable={publicDatabaseIds.has(databaseId)} databaseListError={databaseListError} selectedPath={selectedPath} @@ -724,7 +724,7 @@ export function WikiBrowser() { currentNode={currentNode.data} readIdentityMode={currentReadIdentityMode} readMode={readMode} - databaseCreditsError={currentDatabaseCreditReason} + databaseCyclesError={currentDatabaseCycleReason} explorerRevision={explorerRevision} onSelectedExplorerNode={rememberSelectedExplorerNode} /> @@ -768,7 +768,7 @@ export function WikiBrowser() { writeIdentity={readIdentity} currentDatabaseRole={currentDatabaseRole} databaseRoleError={readIdentity && !currentDatabaseRole ? databaseListError : null} - databaseCreditsError={currentDatabaseCreditReason} + databaseCyclesError={currentDatabaseCycleReason} onNodeSaved={refreshSelectedNodeContext} onFolderIndexSaved={refreshSelectedFolderIndex} onEditStateChange={setEditState} @@ -814,7 +814,7 @@ function LeftPane({ currentNode, readIdentityMode, readMode, - databaseCreditsError, + databaseCyclesError, explorerRevision, onSelectedExplorerNode }: { @@ -829,7 +829,7 @@ function LeftPane({ currentNode: WikiNode | null; readIdentityMode: "anonymous" | "user"; readMode: "anonymous" | null; - databaseCreditsError: string | null; + databaseCyclesError: string | null; explorerRevision: number; onSelectedExplorerNode: (node: ChildNode) => void; }) { @@ -844,7 +844,7 @@ function LeftPane({ writeIdentity={readIdentity} readMode={readMode} readIdentityMode={readIdentityMode} - databaseCreditsError={databaseCreditsError} + databaseCyclesError={databaseCyclesError} /> ); } @@ -854,7 +854,7 @@ function LeftPane({ canisterId={canisterId} databaseId={databaseId} readIdentity={readIdentity} - databaseCreditsError={databaseCreditsError} + databaseCyclesError={databaseCyclesError} /> ); } @@ -1181,14 +1181,14 @@ function writeDisabledReason( writeIdentity: Identity | null, currentDatabaseRole: DatabaseRole | null, databaseRoleError: string | null, - databaseCreditsError: string | null + databaseCyclesError: string | null ): string | null { if (readMode === "anonymous") return "Switch to authenticated mode to change files."; if (!writeIdentity) return "Login with Internet Identity to change files."; if (databaseRoleError) return databaseRoleError; if (!currentDatabaseRole) return "Database role unavailable."; if (currentDatabaseRole !== "writer" && currentDatabaseRole !== "owner") return "Writer or owner access required."; - if (databaseCreditsError) return databaseCreditsError; + if (databaseCyclesError) return databaseCyclesError; return null; } @@ -1244,7 +1244,7 @@ function TopBar({ databaseOptions, currentDatabase, currentDatabaseName, - creditsConfig, + cyclesConfig, publicReadable, databaseListError, selectedPath, @@ -1270,7 +1270,7 @@ function TopBar({ databaseOptions: DatabaseSummary[]; currentDatabase: DatabaseSummary | null; currentDatabaseName: string; - creditsConfig: CreditsConfig | null; + cyclesConfig: CyclesBillingConfig | null; publicReadable: boolean; databaseListError: string | null; selectedPath: string; @@ -1287,7 +1287,7 @@ function TopBar({ ? hrefForPath(canisterId, databaseId, graphLinkCenter ?? "/Wiki", undefined, undefined, undefined, undefined, readMode) : hrefForGraph(canisterId, databaseId, graphLinkCenter, undefined, readMode); const visibleError = authError ?? databaseListError; - const credits = databaseCreditsView(currentDatabase, creditsConfig); + const cycles = databaseCyclesView(currentDatabase, cyclesConfig); function switchDatabase(event: ChangeEvent) { const nextDatabaseId = event.target.value; @@ -1383,7 +1383,7 @@ function TopBar({ Graph - + {principal ? ( - - + +
{preview?.proposalPath === proposal.proposalRoot ?

Preview: {preview.targetPath} +{preview.additions} -{preview.removals}

: null}
@@ -203,6 +203,10 @@ export function ProposalList({ ); } +function proposalCanApply(proposal: SkillProposal): boolean { + return proposal.status === "proposed" || proposal.status === "reviewed"; +} + function EventList({ skill }: { skill: CatalogSkill }) { if (skill.events.length === 0) return

No skill events.

; return ( diff --git a/skill-registry-web/lib/markdown-frontmatter.ts b/skill-registry-web/lib/markdown-frontmatter.ts index 5f8332d8..23226baf 100644 --- a/skill-registry-web/lib/markdown-frontmatter.ts +++ b/skill-registry-web/lib/markdown-frontmatter.ts @@ -11,10 +11,11 @@ export type MarkdownFrontmatter = { export function splitMarkdownFrontmatter(content: string): MarkdownFrontmatter | null { if (!content.startsWith("---\n")) return null; const rest = content.slice(4); - const end = rest.indexOf("\n---"); + const match = rest.match(/\n---(?:\n|$)/); + const end = match?.index ?? -1; if (end < 0) return null; const frontmatter = rest.slice(0, end); - const bodyStart = end + "\n---".length; + const bodyStart = end + match![0].length; const body = rest.slice(bodyStart).replace(/^\n+/, ""); return { fields: flattenFrontmatter(frontmatter), @@ -50,11 +51,14 @@ function flattenFrontmatter(frontmatter: string): FrontmatterField[] { function cleanValue(value: string): string { const trimmed = value.trim(); - if ( - (trimmed.startsWith("\"") && trimmed.endsWith("\"")) || - (trimmed.startsWith("'") && trimmed.endsWith("'")) - ) { - return trimmed.slice(1, -1); + if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + try { + const parsed: unknown = JSON.parse(trimmed); + return typeof parsed === "string" ? parsed : ""; + } catch { + return ""; + } } + if (trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1).replace(/''/g, "'"); return trimmed; } diff --git a/skill-registry-web/lib/paths.ts b/skill-registry-web/lib/paths.ts index 204711fd..51b8fdde 100644 --- a/skill-registry-web/lib/paths.ts +++ b/skill-registry-web/lib/paths.ts @@ -104,13 +104,14 @@ export function hrefForMarkdownLink(canisterId: string, databaseId: string, curr return null; } const target = splitMarkdownHref(trimmed); - if (trimmed.startsWith("/Wiki") || trimmed.startsWith("/Sources")) { - return appendMarkdownSuffix(hrefForPath(canisterId, databaseId, target.path, undefined, undefined, undefined, undefined, readMode), target, readMode); + const targetPath = decodeMarkdownHrefPath(target.path); + if (isInternalWikiPath(targetPath)) { + return appendMarkdownSuffix(hrefForPath(canisterId, databaseId, targetPath, undefined, undefined, undefined, undefined, readMode), target, readMode); } - if (trimmed.startsWith("/")) { + if (targetPath.startsWith("/")) { return null; } - return appendMarkdownSuffix(hrefForPath(canisterId, databaseId, resolveRelativeWikiPath(currentPath, target.path), undefined, undefined, undefined, undefined, readMode), target, readMode); + return appendMarkdownSuffix(hrefForPath(canisterId, databaseId, resolveRelativeWikiPath(currentPath, targetPath), undefined, undefined, undefined, undefined, readMode), target, readMode); } export function parentPath(path: string): string | null { @@ -145,6 +146,10 @@ function isExternalHref(href: string): boolean { return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//"); } +function isInternalWikiPath(path: string): boolean { + return path === "/Wiki" || path.startsWith("/Wiki/") || path === "/Sources" || path.startsWith("/Sources/"); +} + function appendMarkdownSuffix(baseHref: string, target: MarkdownHrefTarget, readMode?: string | null): string { const params = new URLSearchParams(target.query); if (readMode === "anonymous") { @@ -154,6 +159,14 @@ function appendMarkdownSuffix(baseHref: string, target: MarkdownHrefTarget, read return `${baseHref.split("?")[0]}${queryString}${target.hash}`; } +function decodeMarkdownHrefPath(path: string): string { + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + function splitMarkdownHref(href: string): MarkdownHrefTarget { const hashIndex = href.indexOf("#"); const pathAndQuery = hashIndex === -1 ? href : href.slice(0, hashIndex); diff --git a/skill-registry-web/lib/skill-manifest.ts b/skill-registry-web/lib/skill-manifest.ts index 2f8a1f85..ba764b26 100644 --- a/skill-registry-web/lib/skill-manifest.ts +++ b/skill-registry-web/lib/skill-manifest.ts @@ -34,6 +34,8 @@ export function parseSkillManifest(content: string): SkillManifest | null { const version = scalar(values, "version"); const entry = scalar(values, "entry"); if (!id || !version || !entry) return null; + if (!isSafeSkillId(id)) return null; + if (entry !== "SKILL.md") return null; return { kind: "kinic.skill", schemaVersion: "1", @@ -53,6 +55,10 @@ export function parseSkillManifest(content: string): SkillManifest | null { }; } +function isSafeSkillId(value: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value) && !value.includes(".."); +} + export function isSkillRegistryPath(path: string): boolean { return path === "/Wiki/skills" || path.startsWith("/Wiki/skills/"); } @@ -99,8 +105,8 @@ export function skillAccessHint(mode: string | null, roles: string[], authentica function extractFrontmatter(content: string): string | null { if (!content.startsWith("---\n")) return null; const rest = content.slice(4); - const end = rest.indexOf("\n---"); - return end >= 0 ? rest.slice(0, end) : null; + const match = rest.match(/\n---(?:\n|$)/); + return match?.index === undefined ? null : rest.slice(0, match.index); } function formatBoolean(value: boolean): string { @@ -150,5 +156,14 @@ function nested(values: Record, parent: string): Record { - const id = proposalRoot.split("/").pop() ?? proposalRoot; + const parsedRoot = parseProposalRoot(proposalRoot); + if (!parsedRoot) return null; + const { skillId, proposalId: id } = parsedRoot; const candidatePath = `${proposalRoot}/candidate/SKILL.md`; const metricsPath = `${proposalRoot}/metrics.json`; + const statusPath = `${proposalRoot}/status.md`; const [candidate, metrics, status] = await Promise.all([ readRegistryNode(canisterId, databaseId, candidatePath, identity), readRegistryNode(canisterId, databaseId, metricsPath, identity), - readRegistryNode(canisterId, databaseId, `${proposalRoot}/status.md`, identity) + readRegistryNode(canisterId, databaseId, statusPath, identity) ]); - if (!candidate || !metrics) return null; + if (!candidate || !metrics || !status) return null; const metricsJson = parseJsonObject(metrics.content); - const statusFields = status ? frontmatterFields(status.content) : {}; + const baseEtag = stringField(metricsJson, "base_etag"); + const statusFields = frontmatterFields(status.content); + const proposalStatus = parseProposalStatus(statusFields.status); + if (!baseEtag) return null; + if (statusFields.kind !== "kinic.skill_evolution_proposal_status" || statusFields.schema_version !== "1") return null; + if (statusFields.skill_id !== skillId || statusFields.proposal_id !== id) return null; + if (!proposalStatus || !statusFields.recorded_at || Number.isNaN(Date.parse(statusFields.recorded_at))) return null; return { proposalRoot, candidatePath, metricsPath, - statusPath: status?.path ?? null, + statusPath, id, title: id, - status: statusFields.status ?? "proposed", - createdAt: stringField(metricsJson, "created_at") ?? String(metricsJson.created_at_ms ?? ""), + status: proposalStatus, + createdAt: stringField(metricsJson, "created_at") ?? statusFields.recorded_at, sourceRuns: arrayStringField(metricsJson, "source_runs"), candidatePreview: candidate.content.slice(0, 1200), - baseEtag: stringField(metricsJson, "base_etag"), - appliedAt: statusFields.recorded_at ?? null, + baseEtag, + appliedAt: statusFields.recorded_at, metricsPreview: metrics.content.slice(0, 2000) }; } +function parseProposalStatus(value: string | undefined): ProposalStatus | null { + if (value === "proposed" || value === "reviewed" || value === "auto_applied" || value === "gate_failed" || value === "conflict") return value; + return null; +} + async function loadVersions(canisterId: string, databaseId: string, basePath: string, identity?: Identity): Promise { const entries = await listRegistryChildren(canisterId, databaseId, `${basePath}/versions`, identity); return entries @@ -157,6 +171,18 @@ function isFileEntry(entry: ChildNode): boolean { return entry.kind !== "directory" && entry.kind !== "folder"; } +function parseProposalRoot(proposalRoot: string): { skillId: string; proposalId: string } | null { + const match = proposalRoot.match(/^\/Wiki\/skills\/([^/]+)\/proposals\/([^/]+)$/); + if (!match) return null; + const [, skillId, proposalId] = match; + if (!isSafePathSegment(skillId) || !isSafePathSegment(proposalId)) return null; + return { skillId, proposalId }; +} + +function isSafePathSegment(value: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value) && !value.includes(".."); +} + function summarizeRuns(runs: SkillRunEvidence[]): SkillRunSummary { const summary: SkillRunSummary = { runs: 0, success: 0, partial: 0, fail: 0, lastUsedAt: null, lastOutcome: null }; for (const run of runs) { diff --git a/skill-registry-web/lib/skill-registry-diff.ts b/skill-registry-web/lib/skill-registry-diff.ts index dc118118..c0a0ebc9 100644 --- a/skill-registry-web/lib/skill-registry-diff.ts +++ b/skill-registry-web/lib/skill-registry-diff.ts @@ -1,5 +1,6 @@ import type { Identity } from "@icp-sdk/core/agent"; import type { CatalogSkill, SkillProposal } from "@/lib/skill-registry-catalog"; +import { assertProposalStatus } from "@/lib/skill-registry-operations"; import type { WikiNode } from "@/lib/types"; import { readNode, writeNodeAuthenticated } from "@/lib/vfs-client"; import { ensureParentFoldersAuthenticated } from "@/lib/vfs-folders"; @@ -7,6 +8,7 @@ import { ensureParentFoldersAuthenticated } from "@/lib/vfs-folders"; export type ProposalDiffPreview = { proposalPath: string; targetPath: string; + baseEtag: string; nextContent: string; currentEtag: string; metadataJson: string; @@ -21,13 +23,16 @@ export async function previewApplyProposalDiff(canisterId: string, databaseId: s requireNode(canisterId, databaseId, proposal.metricsPath, identity) ]); assertProposalGates(metrics.content); - if (proposal.baseEtag && proposal.baseEtag !== current.etag) { + assertSafeProposalId(proposal.id); + if (!proposal.baseEtag) throw new Error("Proposal metrics base_etag is required."); + if (proposal.baseEtag !== current.etag) { throw new Error("Current SKILL.md etag no longer matches proposal base_etag."); } const counts = lineDelta(current.content, candidate.content); return { proposalPath: proposal.proposalRoot, targetPath: `${skill.basePath}/SKILL.md`, + baseEtag: proposal.baseEtag, nextContent: candidate.content, currentEtag: current.etag, metadataJson: current.metadataJson, @@ -37,14 +42,25 @@ export async function previewApplyProposalDiff(canisterId: string, databaseId: s } export async function applyProposalDiff(canisterId: string, databaseId: string, identity: Identity, proposal: SkillProposal, preview: ProposalDiffPreview): Promise { + assertSafeProposalId(proposal.id); + if (proposal.proposalRoot !== preview.proposalPath) throw new Error("Proposal changed since preview."); + if (!proposal.baseEtag) throw new Error("Proposal metrics base_etag is required."); + if (proposal.baseEtag !== preview.baseEtag) throw new Error("Proposal base_etag changed since preview."); + if (!preview.targetPath.endsWith("/SKILL.md")) throw new Error("Proposal target must be SKILL.md."); const basePath = preview.targetPath.replace(/\/SKILL\.md$/, ""); const current = await requireNode(canisterId, databaseId, preview.targetPath, identity); if (current.etag !== preview.currentEtag) throw new Error("Current SKILL.md changed since preview."); - const [manifest, metrics] = await Promise.all([ + if (current.etag !== proposal.baseEtag) throw new Error("Current SKILL.md etag no longer matches proposal base_etag."); + const skillId = preview.targetPath.split("/").at(-2); + if (!skillId) throw new Error("Skill id is missing."); + const statusPath = `${proposal.proposalRoot}/status.md`; + const [manifest, metrics, status] = await Promise.all([ readNode(canisterId, databaseId, `${basePath}/manifest.md`, identity), - requireNode(canisterId, databaseId, proposal.metricsPath, identity) + requireNode(canisterId, databaseId, proposal.metricsPath, identity), + requireNode(canisterId, databaseId, statusPath, identity) ]); assertProposalGates(metrics.content); + assertProposalStatus(status.content, skillId, proposal.id, ["proposed", "reviewed"]); const versionId = `${Date.now()}-${(await sha256Hex(current.content)).slice(0, 12)}`; const versionBase = `${basePath}/versions/${versionId}`; const versionSkillPath = `${versionBase}/SKILL.md`; @@ -75,15 +91,14 @@ export async function applyProposalDiff(canisterId: string, databaseId: string, metadataJson: preview.metadataJson, expectedEtag: preview.currentEtag }); - const statusPath = `${proposal.proposalRoot}/status.md`; await ensureParentFoldersAuthenticated(canisterId, databaseId, identity, statusPath); await writeNodeAuthenticated(canisterId, identity, { databaseId, path: statusPath, kind: "file", - content: ["---", "kind: kinic.skill_evolution_proposal_status", "schema_version: 1", `proposal_id: ${JSON.stringify(proposal.id)}`, "status: auto_applied", `recorded_at: ${new Date().toISOString()}`, "---", "# Proposal Status"].join("\n"), - metadataJson: "{}", - expectedEtag: null + content: ["---", "kind: kinic.skill_evolution_proposal_status", "schema_version: 1", `skill_id: ${JSON.stringify(skillId)}`, `proposal_id: ${JSON.stringify(proposal.id)}`, "status: auto_applied", `recorded_at: ${new Date().toISOString()}`, "---", "# Proposal Status"].join("\n"), + metadataJson: status.metadataJson, + expectedEtag: status.etag }); } @@ -134,6 +149,12 @@ function parseJsonObject(content: string): Record { } } +function assertSafeProposalId(value: string): void { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value) || value.includes("..")) { + throw new Error("Proposal id must be a single safe path segment."); + } +} + async function sha256Hex(content: string): Promise { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(content)); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); diff --git a/skill-registry-web/lib/skill-registry-operations.ts b/skill-registry-web/lib/skill-registry-operations.ts index 1556846b..45014345 100644 --- a/skill-registry-web/lib/skill-registry-operations.ts +++ b/skill-registry-web/lib/skill-registry-operations.ts @@ -16,12 +16,17 @@ export async function updateSkillStatus( reason: string ): Promise { const node = await requireNode(canisterId, databaseId, skill.manifestPath, identity); - const updates: Record = { status }; + const updates: Record = { status }; if (status === "promoted") updates.promoted_at = new Date().toISOString(); if (status === "deprecated") { updates.deprecated_at = new Date().toISOString(); if (reason.trim()) updates.deprecated_reason = reason.trim(); } + if (status !== "promoted") updates.promoted_at = null; + if (status !== "deprecated") { + updates.deprecated_at = null; + updates.deprecated_reason = null; + } await writeNodeAuthenticated(canisterId, identity, { databaseId, path: skill.manifestPath, @@ -85,16 +90,16 @@ export async function recordSkillRun( } export async function approveSkillProposal(canisterId: string, databaseId: string, identity: Identity, skill: CatalogSkill, proposalPath: string): Promise { - if (!proposalPath.startsWith(`${skill.basePath}/proposals/`)) throw new Error("Proposal path is outside this skill package."); - const statusPath = `${proposalPath}/status.md`; - await ensureParentFoldersAuthenticated(canisterId, databaseId, identity, statusPath); + const { proposalId, statusPath } = proposalStatusPathForSkill(skill, proposalPath); + const current = await requireNode(canisterId, databaseId, statusPath, identity); + assertProposalStatus(current.content, skill.manifest.id, proposalId, ["proposed"]); await writeNodeAuthenticated(canisterId, identity, { databaseId, path: statusPath, kind: "file", - content: ["---", "kind: kinic.skill_evolution_proposal_status", "schema_version: 1", `skill_id: ${JSON.stringify(skill.manifest.id)}`, "status: reviewed", `recorded_at: ${new Date().toISOString()}`, "---", "# Proposal Status"].join("\n"), - metadataJson: "{}", - expectedEtag: null + content: ["---", "kind: kinic.skill_evolution_proposal_status", "schema_version: 1", `skill_id: ${JSON.stringify(skill.manifest.id)}`, `proposal_id: ${JSON.stringify(proposalId)}`, "status: reviewed", `recorded_at: ${new Date().toISOString()}`, "---", "# Proposal Status"].join("\n"), + metadataJson: current.metadataJson, + expectedEtag: current.etag }); await recordSkillEvent(canisterId, databaseId, identity, skill.manifest.id, { action: "proposal.review", @@ -143,10 +148,10 @@ async function requireNode(canisterId: string, databaseId: string, path: string, return node; } -function replaceRootFrontmatter(content: string, updates: Record): string { +function replaceRootFrontmatter(content: string, updates: Record): string { if (!content.startsWith("---\n")) throw new Error("Markdown frontmatter is missing."); const rest = content.slice(4); - const end = rest.indexOf("\n---"); + const end = frontmatterEnd(rest); if (end < 0) throw new Error("Markdown frontmatter terminator is missing."); const lines = rest.slice(0, end).split("\n"); const pending = new Set(Object.keys(updates)); @@ -156,18 +161,73 @@ function replaceRootFrontmatter(content: string, updates: Record const key = match[1].trim(); if (!(key in updates)) return line; pending.delete(key); - return `${key}: ${quoteYaml(updates[key])}`; - }); + const update = updates[key]; + if (update === null) return null; + return `${key}: ${quoteYaml(update)}`; + }).filter((line): line is string => line !== null); for (const key of pending) { - replaced.push(`${key}: ${quoteYaml(updates[key])}`); + const update = updates[key]; + if (update === null) continue; + replaced.push(`${key}: ${quoteYaml(update)}`); } return `---\n${replaced.join("\n")}${rest.slice(end)}`; } +function frontmatterEnd(rest: string): number { + const match = rest.match(/\n---(?:\n|$)/); + return match?.index ?? -1; +} + function quoteYaml(value: string): string { return JSON.stringify(value); } +function proposalStatusPathForSkill(skill: CatalogSkill, proposalPath: string): { proposalId: string; statusPath: string } { + const prefix = `${skill.basePath}/proposals/`; + if (!proposalPath.startsWith(prefix)) throw new Error("Proposal path is outside this skill package."); + const proposalId = proposalPath.slice(prefix.length); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(proposalId) || proposalId.includes("..")) { + throw new Error("Proposal id must be a single safe path segment."); + } + return { proposalId, statusPath: `${proposalPath}/status.md` }; +} + +export function assertProposalStatus(content: string, skillId: string, proposalId: string, allowedStatuses: readonly string[]): void { + const fields = frontmatterFields(content); + if (fields.kind !== "kinic.skill_evolution_proposal_status") throw new Error("Proposal status kind is invalid."); + if (fields.schema_version !== "1") throw new Error("Proposal status schema_version is invalid."); + if (fields.skill_id !== skillId) throw new Error("Proposal status skill_id does not match."); + if (fields.proposal_id !== proposalId) throw new Error("Proposal status proposal_id does not match."); + if (!fields.recorded_at || Number.isNaN(Date.parse(fields.recorded_at))) throw new Error("Proposal status recorded_at is invalid."); + if (!fields.status || !allowedStatuses.includes(fields.status)) throw new Error("Proposal status is not in an updateable state."); +} + +function frontmatterFields(content: string): Record { + if (!content.startsWith("---\n")) return {}; + const rest = content.slice(4); + const end = frontmatterEnd(rest); + if (end < 0) return {}; + const fields: Record = {}; + for (const line of rest.slice(0, end).split("\n")) { + const match = line.match(/^([^:\s][^:]*):(.*)$/); + if (!match) continue; + fields[match[1].trim()] = cleanYamlScalar(match[2].trim()); + } + return fields; +} + +function cleanYamlScalar(value: string): string { + if (value.startsWith('"') && value.endsWith('"')) { + try { + const parsed: unknown = JSON.parse(value); + return typeof parsed === "string" ? parsed : value; + } catch { + return value; + } + } + return value; +} + async function sha256Hex(value: string): Promise { const bytes = new TextEncoder().encode(value); const digest = await crypto.subtle.digest("SHA-256", bytes); diff --git a/skill-registry-web/lib/skill-registry-package.ts b/skill-registry-web/lib/skill-registry-package.ts index 14286555..af0fff10 100644 --- a/skill-registry-web/lib/skill-registry-package.ts +++ b/skill-registry-web/lib/skill-registry-package.ts @@ -72,7 +72,7 @@ function normalizeFiles(files: SkillPackageFile[], skillId: string): SkillPackag } const skill = cleaned.get("SKILL.md"); if (!skill) throw new Error("SKILL.md is required."); - cleaned.set("manifest.md", normalizeManifestForSkill(skillId, cleaned.get("manifest.md") ?? manifestForSkill(skillId, skill))); + cleaned.set("manifest.md", normalizeManifestForSkill(skillId, cleaned.get("manifest.md") ?? manifestForSkill(skillId, skill), skill)); return [...cleaned.entries()].map(([name, content]) => ({ name, content })).sort((left, right) => left.name.localeCompare(right.name)); } @@ -87,6 +87,8 @@ function normalizeGitHubManifest(files: SkillPackageFile[], skillId: string, sou function manifestForSkill(skillId: string, skill: string): string { const title = frontmatterValue(skill, "metadata.title") ?? skillId; const summary = frontmatterValue(skill, "description") ?? ""; + const category = frontmatterValue(skill, "metadata.category"); + const license = frontmatterValue(skill, "license"); return [ "---", "kind: kinic.skill", @@ -96,18 +98,24 @@ function manifestForSkill(skillId: string, skill: string): string { "entry: SKILL.md", `title: ${JSON.stringify(title)}`, `summary: ${JSON.stringify(summary)}`, + ...(category ? ["tags:", ` - ${JSON.stringify(category)}`] : []), "status: draft", + ...(license ? ["provenance:", ` license: ${JSON.stringify(license)}`] : []), "---", `# ${title}` ].join("\n"); } -function normalizeManifestForSkill(skillId: string, content: string): string { +function normalizeManifestForSkill(skillId: string, content: string, skill: string): string { let next = content.startsWith("---\n") ? content : manifestForSkill(skillId, ""); next = setRootFrontmatterField(next, "kind", "kinic.skill"); next = setRootFrontmatterField(next, "schema_version", "1"); next = setRootFrontmatterField(next, "id", skillId); next = setRootFrontmatterField(next, "entry", "SKILL.md"); + next = fillRootFrontmatterField(next, "title", frontmatterValue(skill, "metadata.title")); + next = fillRootFrontmatterField(next, "summary", frontmatterValue(skill, "description")); + next = fillListFrontmatterField(next, "tags", frontmatterValue(skill, "metadata.category")); + next = fillNestedFrontmatterField(next, "provenance", "license", frontmatterValue(skill, "license")); return next; } @@ -118,13 +126,13 @@ function setManifestProvenance(content: string, source: GitHubSource, sha: strin ` revision: ${JSON.stringify(sha)}` ]; if (content.includes("\nprovenance:\n")) return content.replace(/\nprovenance:\n(?: .+\n?)*/m, `\nprovenance:\n${fields.join("\n")}\n`); - return content.replace(/\n---/, `\nprovenance:\n${fields.join("\n")}\n---`); + return insertBeforeFrontmatterTerminator(content, ["provenance:", ...fields]); } function setRootFrontmatterField(content: string, key: string, value: string): string { if (!content.startsWith("---\n")) throw new Error("manifest.md frontmatter is required."); const rest = content.slice(4); - const end = rest.indexOf("\n---"); + const end = frontmatterEnd(rest); if (end < 0) throw new Error("manifest.md frontmatter terminator is missing."); const lines = rest.slice(0, end).split("\n"); let replaced = false; @@ -138,6 +146,21 @@ function setRootFrontmatterField(content: string, key: string, value: string): s return `---\n${next.join("\n")}${rest.slice(end)}`; } +function fillRootFrontmatterField(content: string, key: string, value: string | null): string { + if (!value || frontmatterValue(content, key)) return content; + return setRootFrontmatterField(content, key, value); +} + +function fillListFrontmatterField(content: string, key: string, value: string | null): string { + if (!value || frontmatterHasListItems(content, key)) return content; + return setListFrontmatterField(content, key, [value]); +} + +function fillNestedFrontmatterField(content: string, parent: string, child: string, value: string | null): string { + if (!value || frontmatterValue(content, `${parent}.${child}`)) return content; + return setNestedFrontmatterField(content, parent, child, value); +} + type GitHubSource = { owner: string; repo: string; path: string | null }; function parseGitHubSource(value: string): GitHubSource { @@ -170,8 +193,8 @@ async function fetchOptionalText(url: string): Promise { function markdownPackageLinks(content: string): string[] { const names = new Set(); - for (const match of content.matchAll(/\]\(([^)]+)\)/g)) { - const name = cleanPackageFileName(match[1].split(/[?#\s]/)[0] ?? ""); + for (const target of markdownLinkTargets(content)) { + const name = cleanPackageFileName(cleanMarkdownDestination(target)); if (name) names.add(name); } return [...names]; @@ -179,29 +202,108 @@ function markdownPackageLinks(content: string): string[] { function cleanSkillId(value: string): string { const id = value.trim(); - if (!/^[a-z0-9][a-z0-9_-]*$/.test(id)) throw new Error("Skill id must use lowercase letters, numbers, _ or -."); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id) || id.includes("..")) { + throw new Error("Skill id must be a single path-safe segment."); + } return id; } function cleanPackageFileName(value: string): string | null { const name = value.trim().replace(/^\.\//, ""); if (!name.endsWith(".md") || name.startsWith("/") || name.includes("..") || name.includes("://")) return null; + const segments = name.split("/"); + if (segments.some((segment) => !segment || segment === ".")) return null; return name; } function frontmatterValue(content: string, key: string): string | null { - const start = content.startsWith("---\n") ? content.indexOf("\n---", 4) : -1; + return frontmatterField(content, key)?.value ?? null; +} + +function frontmatterField(content: string, key: string): { value: string } | null { + if (!content.startsWith("---\n")) return null; + const rest = content.slice(4); + const start = frontmatterEnd(rest); if (start < 0) return null; const parent = key.split(".")[0]; + const child = key.split(".")[1]; let inParent = false; - for (const line of content.slice(4, start).split("\n")) { - if (line.startsWith(`${parent}:`)) inParent = true; - const match = key.includes(".") && inParent ? line.trim().match(new RegExp(`^${key.split(".")[1]}:\\s*(.*)$`)) : line.match(new RegExp(`^${key}:\\s*(.*)$`)); - if (match) return cleanYaml(match[1]); + for (const line of rest.slice(0, start).split("\n")) { + if (!line.startsWith(" ") && !line.startsWith("\t")) { + inParent = line.startsWith(`${parent}:`); + } + const match = child && inParent ? line.trim().match(new RegExp(`^${child}:\\s*(.*)$`)) : line.match(new RegExp(`^${key}:\\s*(.*)$`)); + if (match) return { value: cleanYaml(match[1]) }; } return null; } +function frontmatterHasListItems(content: string, key: string): boolean { + if (!content.startsWith("---\n")) return false; + const rest = content.slice(4); + const end = frontmatterEnd(rest); + if (end < 0) return false; + let inList = false; + for (const line of rest.slice(0, end).split("\n")) { + if (!line.startsWith(" ") && !line.startsWith("\t")) { + const match = line.match(/^([^:\s][^:]*):/); + inList = Boolean(match && match[1].trim() === key); + continue; + } + if (inList && line.trim().startsWith("- ")) return true; + } + return false; +} + +function setListFrontmatterField(content: string, key: string, values: string[]): string { + if (!content.startsWith("---\n")) throw new Error("manifest.md frontmatter is required."); + const rest = content.slice(4); + const end = frontmatterEnd(rest); + if (end < 0) throw new Error("manifest.md frontmatter terminator is missing."); + const lines = rest.slice(0, end).split("\n"); + const replacement = [key + ":", ...values.map((value) => ` - ${JSON.stringify(value)}`)]; + const index = lines.findIndex((line) => line.match(/^([^:\s][^:]*):/)?.[1].trim() === key); + if (index < 0) return `---\n${[...lines, ...replacement].join("\n")}${rest.slice(end)}`; + let after = index + 1; + while (after < lines.length && (lines[after].startsWith(" ") || lines[after].startsWith("\t"))) after += 1; + return `---\n${[...lines.slice(0, index), ...replacement, ...lines.slice(after)].join("\n")}${rest.slice(end)}`; +} + +function setNestedFrontmatterField(content: string, parent: string, child: string, value: string): string { + if (!content.startsWith("---\n")) throw new Error("manifest.md frontmatter is required."); + const rest = content.slice(4); + const end = frontmatterEnd(rest); + if (end < 0) throw new Error("manifest.md frontmatter terminator is missing."); + const lines = rest.slice(0, end).split("\n"); + let parentIndex = -1; + let childIndex = -1; + let inParent = false; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (!line.startsWith(" ") && !line.startsWith("\t")) { + const match = line.match(/^([^:\s][^:]*):/); + inParent = Boolean(match && match[1].trim() === parent); + if (inParent) parentIndex = index; + continue; + } + if (inParent && line.trim().match(new RegExp(`^${child}:`))) { + childIndex = index; + break; + } + } + const replacement = ` ${child}: ${JSON.stringify(value)}`; + if (childIndex >= 0) { + const next = [...lines]; + next[childIndex] = replacement; + return `---\n${next.join("\n")}${rest.slice(end)}`; + } + if (parentIndex >= 0) { + const next = [...lines.slice(0, parentIndex + 1), replacement, ...lines.slice(parentIndex + 1)]; + return `---\n${next.join("\n")}${rest.slice(end)}`; + } + return `---\n${[...lines, parent + ":", replacement].join("\n")}${rest.slice(end)}`; +} + function cleanGitHubPath(value: string): string | null { const path = value.trim().replace(/^\/+|\/+$/g, ""); if (!path) return null; @@ -211,7 +313,111 @@ function cleanGitHubPath(value: string): string | null { function cleanYaml(value: string): string { const trimmed = value.trim(); - return trimmed.startsWith("\"") && trimmed.endsWith("\"") ? trimmed.slice(1, -1) : trimmed; + if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed === "string") return parsed; + throw new Error("Invalid quoted YAML scalar."); + } catch { + throw new Error("Invalid quoted YAML scalar."); + } + } + if (trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1).replace(/''/g, "'"); + return trimmed; +} + +function frontmatterEnd(rest: string): number { + const match = rest.match(/\n---(?:\n|$)/); + return match?.index ?? -1; +} + +function insertBeforeFrontmatterTerminator(content: string, lines: string[]): string { + if (!content.startsWith("---\n")) throw new Error("manifest.md frontmatter is required."); + const rest = content.slice(4); + const end = frontmatterEnd(rest); + if (end < 0) throw new Error("manifest.md frontmatter terminator is missing."); + const absoluteEnd = 4 + end; + return `${content.slice(0, absoluteEnd)}\n${lines.join("\n")}${content.slice(absoluteEnd)}`; +} + +function markdownLinkTargets(content: string): string[] { + const targets: string[] = []; + let index = 0; + while (index < content.length) { + const open = content.indexOf("](", index); + if (open < 0) break; + let cursor = open + 2; + if (content[cursor] === "<") { + const close = content.indexOf(">", cursor + 1); + if (close >= 0 && content[close + 1] === ")") { + targets.push(content.slice(cursor, close + 1)); + index = close + 2; + continue; + } + } + let depth = 0; + while (cursor < content.length) { + const char = content[cursor]; + if (char === "(") depth += 1; + if (char === ")") { + if (depth === 0) break; + depth -= 1; + } + cursor += 1; + } + if (cursor < content.length) targets.push(content.slice(open + 2, cursor)); + index = cursor + 1; + } + return targets; +} + +function cleanMarkdownDestination(value: string): string { + const trimmed = value.trim(); + const withoutTitle = markdownDestinationWithoutTitle(trimmed); + const destination = withoutTitle.startsWith("<") && withoutTitle.endsWith(">") ? withoutTitle.slice(1, -1) : withoutTitle; + return destination.split(/[?#]/)[0]?.trim() ?? ""; +} + +function markdownDestinationWithoutTitle(value: string): string { + const trimmed = value.trim(); + if (trimmed.startsWith("<")) { + const close = trimmed.indexOf(">"); + if (close > 0) { + const destination = trimmed.slice(1, close); + const suffix = trimmed.slice(close + 1).trim(); + if (!suffix || isMarkdownTitleSuffix(suffix)) return destination; + } + } + return stripQuotedMarkdownTitle(trimmed, "\"") ?? stripQuotedMarkdownTitle(trimmed, "'") ?? stripParenthesizedMarkdownTitle(trimmed) ?? trimmed; +} + +function stripQuotedMarkdownTitle(value: string, quote: string): string | null { + if (!value.endsWith(quote)) return null; + for (let index = value.length - 2; index > 0; index -= 1) { + if (value[index] === quote && /\s/.test(value[index - 1] ?? "")) { + const destination = value.slice(0, index - 1).trimEnd(); + if (isMarkdownDestinationCandidate(destination)) return destination; + } + } + return null; +} + +function stripParenthesizedMarkdownTitle(value: string): string | null { + if (!value.endsWith(")")) return null; + const titleStart = value.lastIndexOf(" ("); + if (titleStart < 0) return null; + const destination = value.slice(0, titleStart).trimEnd(); + return isMarkdownDestinationCandidate(destination) ? destination : null; +} + +function isMarkdownTitleSuffix(value: string): boolean { + return (value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'")) || (value.startsWith("(") && value.endsWith(")")); +} + +function isMarkdownDestinationCandidate(value: string): boolean { + const unwrapped = value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value; + const destination = unwrapped.split(/[?#]/)[0]?.trim() ?? ""; + return Boolean(destination && !destination.startsWith("#") && !destination.startsWith("/") && !destination.includes("://") && destination.endsWith(".md")); } function isCommitPayload(value: unknown): value is { sha: string } { diff --git a/skill-registry-web/lib/wiki-helpers.ts b/skill-registry-web/lib/wiki-helpers.ts index 5f2d495f..6f9610b5 100644 --- a/skill-registry-web/lib/wiki-helpers.ts +++ b/skill-registry-web/lib/wiki-helpers.ts @@ -78,7 +78,7 @@ export function inferNoteRole(path: string): string { if (name === "plans.md") return "plans"; if (name === "summary.md") return "summary"; if (name === "open_questions.md") return "open_questions"; - if (path.startsWith("/Sources/raw")) return "raw_source"; + if (path === "/Sources/raw" || path.startsWith("/Sources/raw/")) return "raw_source"; if (path.endsWith(".md")) return "markdown_note"; return "directory"; } diff --git a/skill-registry-web/package.json b/skill-registry-web/package.json index 48035322..ddb0717e 100644 --- a/skill-registry-web/package.json +++ b/skill-registry-web/package.json @@ -8,6 +8,7 @@ "lint": "eslint .", "test": "node scripts/check-skill-registry-web.mjs", "typecheck": "next typegen && node scripts/normalize-typegen-tsconfig.mjs && tsc --noEmit", + "audit:moderate": "pnpm audit --audit-level moderate", "normalize:typegen": "node scripts/normalize-typegen-tsconfig.mjs" }, "dependencies": { @@ -25,13 +26,14 @@ "autoprefixer": "10.4.22", "eslint": "9.39.1", "eslint-config-next": "16.2.4", - "postcss": "8.5.11", + "postcss": "8.5.12", "tailwindcss": "3.4.18", "typescript": "5.9.3" }, "pnpm": { "overrides": { - "next>postcss": "8.5.10" + "brace-expansion": "5.0.6", + "postcss": "8.5.12" } }, "packageManager": "pnpm@11.0.8" diff --git a/skill-registry-web/pnpm-lock.yaml b/skill-registry-web/pnpm-lock.yaml index 7d57a1dc..f26756b5 100644 --- a/skill-registry-web/pnpm-lock.yaml +++ b/skill-registry-web/pnpm-lock.yaml @@ -38,7 +38,7 @@ importers: version: 19.2.3(@types/react@19.2.7) autoprefixer: specifier: 10.4.22 - version: 10.4.22(postcss@8.5.11) + version: 10.4.22(postcss@8.5.12) eslint: specifier: 9.39.1 version: 9.39.1(jiti@1.21.7) @@ -46,8 +46,8 @@ importers: specifier: 16.2.4 version: 16.2.4(@typescript-eslint/parser@8.59.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) postcss: - specifier: 8.5.11 - version: 8.5.11 + specifier: 8.5.12 + version: 8.5.12 tailwindcss: specifier: 3.4.18 version: 3.4.18 @@ -780,8 +780,8 @@ packages: brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -1668,8 +1668,8 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.11: - resolution: {integrity: sha512-5dDj8+lmvA8XB78SmzGI8NlQoksv7IfutGWeVZxiixHbO+p4LDPT3wuG/D9sM/wrjZZ9I+Siy/e117vbFPxSZg==} + postcss@8.5.12: + resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -2676,14 +2676,14 @@ snapshots: async-function@1.0.0: {} - autoprefixer@10.4.22(postcss@8.5.11): + autoprefixer@10.4.22(postcss@8.5.12): dependencies: browserslist: 4.28.2 caniuse-lite: 1.0.30001791 fraction.js: 5.3.4 normalize-range: 0.1.2 picocolors: 1.1.1 - postcss: 8.5.11 + postcss: 8.5.12 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: @@ -2709,7 +2709,7 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -3540,7 +3540,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 minimatch@3.1.5: dependencies: @@ -3568,7 +3568,7 @@ snapshots: '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.29 caniuse-lite: 1.0.30001792 - postcss: 8.4.31 + postcss: 8.5.12 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.5) @@ -3688,28 +3688,28 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-import@15.1.0(postcss@8.5.11): + postcss-import@15.1.0(postcss@8.5.12): dependencies: - postcss: 8.5.11 + postcss: 8.5.12 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.12 - postcss-js@4.1.0(postcss@8.5.11): + postcss-js@4.1.0(postcss@8.5.12): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.11 + postcss: 8.5.12 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.11): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.12): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 - postcss: 8.5.11 + postcss: 8.5.12 - postcss-nested@6.2.0(postcss@8.5.11): + postcss-nested@6.2.0(postcss@8.5.12): dependencies: - postcss: 8.5.11 + postcss: 8.5.12 postcss-selector-parser: 6.1.2 postcss-selector-parser@6.1.2: @@ -3725,7 +3725,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.11: + postcss@8.5.12: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 @@ -4027,11 +4027,11 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.11 - postcss-import: 15.1.0(postcss@8.5.11) - postcss-js: 4.1.0(postcss@8.5.11) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.11) - postcss-nested: 6.2.0(postcss@8.5.11) + postcss: 8.5.12 + postcss-import: 15.1.0(postcss@8.5.12) + postcss-js: 4.1.0(postcss@8.5.12) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.12) + postcss-nested: 6.2.0(postcss@8.5.12) postcss-selector-parser: 6.1.2 resolve: 1.22.12 sucrase: 3.35.1 diff --git a/skill-registry-web/scripts/check-skill-registry-web.mjs b/skill-registry-web/scripts/check-skill-registry-web.mjs index e328f9cc..f1dbee86 100644 --- a/skill-registry-web/scripts/check-skill-registry-web.mjs +++ b/skill-registry-web/scripts/check-skill-registry-web.mjs @@ -1,12 +1,16 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import ts from "typescript"; const route = readFileSync(new URL("../app/skills/[databaseId]/page.tsx", import.meta.url), "utf8"); const client = readFileSync(new URL("../app/skills/skill-registry-client.tsx", import.meta.url), "utf8"); const panels = readFileSync(new URL("../app/skills/skill-registry-panels.tsx", import.meta.url), "utf8"); +const catalog = readFileSync(new URL("../lib/skill-registry-catalog.ts", import.meta.url), "utf8"); const details = readFileSync(new URL("../lib/skill-registry-details.ts", import.meta.url), "utf8"); const diff = readFileSync(new URL("../lib/skill-registry-diff.ts", import.meta.url), "utf8"); const operations = readFileSync(new URL("../lib/skill-registry-operations.ts", import.meta.url), "utf8"); +const packages = readFileSync(new URL("../lib/skill-registry-package.ts", import.meta.url), "utf8"); +const wikiHelpers = readFileSync(new URL("../lib/wiki-helpers.ts", import.meta.url), "utf8"); const types = readFileSync(new URL("../lib/types.ts", import.meta.url), "utf8"); const vfsClient = readFileSync(new URL("../lib/vfs-client.ts", import.meta.url), "utf8"); const vfsIdl = readFileSync(new URL("../lib/vfs-idl.ts", import.meta.url), "utf8"); @@ -20,19 +24,43 @@ assert.match(panels, /Skill Registry Dashboard/); assert.match(panels, /EvolutionJobsPanel/); assert.match(panels, /Current SKILL\.md/); assert.doesNotMatch(panels, /authenticated=\{false\}/); +assert.match(readFileSync(new URL("../app/skills/skill-registry-ui.tsx", import.meta.url), "utf8"), /proposal\.status === "proposed" \|\| proposal\.status === "reviewed"/); assert.match(client, /handlersFor\(selectedSkill\)/); assert.match(details, /\/Wiki\/skill-evolution-jobs/); assert.match(details, /candidate\/SKILL\.md/); assert.match(details, /metrics\.json/); +assert.match(details, /parseProposalRoot/); +assert.match(details, /statusFields\.skill_id !== skillId/); +assert.match(details, /statusFields\.proposal_id !== id/); +assert.match(catalog, /statusPath: string;/); +assert.match(catalog, /baseEtag: string;/); +assert.match(catalog, /export type ProposalStatus = "proposed" \| "reviewed" \| "auto_applied" \| "gate_failed" \| "conflict"/); +assert.doesNotMatch(catalog, /statusPath: string \| null/); +assert.doesNotMatch(catalog, /baseEtag: string \| null/); +assert.match(details, /parseProposalStatus\(statusFields\.status\)/); +assert.match(details, /statusFields\.kind !== "kinic\.skill_evolution_proposal_status"/); +assert.match(details, /statusFields\.schema_version !== "1"/); assert.match(diff, /candidatePath/); assert.match(diff, /base_etag/); +assert.match(diff, /baseEtag: string/); +assert.match(diff, /Proposal metrics base_etag is required/); +assert.match(diff, /Proposal changed since preview/); assert.match(diff, /assertProposalGates/); +assert.match(diff, /assertProposalStatus\(status\.content, skillId, proposal\.id, \["proposed", "reviewed"\]\)/); assert.match(diff, /candidate_score_gate/); assert.match(diff, /heading_consistency_gate/); assert.match(diff, /permission_gate/); assert.match(diff, /proposal\.metricsPath/); assert.equal((diff.match(/proposal\.metricsPath/g) ?? []).length, 2); assert.match(operations, /kinic.skill_evolution_proposal_status/); +assert.match(operations, /assertProposalStatus\(current\.content, skill\.manifest\.id, proposalId, \["proposed"\]\)/); +assert.match(operations, /proposalStatusPathForSkill/); +assert.match(operations, /Proposal id must be a single safe path segment/); +assert.doesNotMatch(operations, /proposalPath\.split\("\/"\)\.pop/); +assert.match(operations, /frontmatterEnd\(rest\)/); +assert.doesNotMatch(operations, /indexOf\("\\n---"\)/); +assert.match(wikiHelpers, /path === "\/Sources\/raw" \|\| path\.startsWith\("\/Sources\/raw\/"\)/); +assert.doesNotMatch(wikiHelpers, /path\.startsWith\("\/Sources\/raw"\)/); assert.match(types, /DatabaseStatus = "pending" \| "active" \| "restoring" \| "archiving" \| "archived"/); assert.doesNotMatch(vfsIdl, /Hot: idl\.Null/); assert.match(vfsIdl, /Pending: idl\.Null/); @@ -43,5 +71,84 @@ assert.match(vfsClient, /"Pending" in status/); assert.doesNotMatch(vfsClient, /: "hot"/); assert.doesNotMatch(client, /from ["']..\/..\/..\/wikibrowser/); assert.doesNotMatch(panels, /from ["']..\/..\/..\/wikibrowser/); +assert.match(packages, /markdownLinkTargets/); +const packageParser = await importSkillRegistryPackageForTest("../lib/skill-registry-package.ts"); +const manifestParser = await importSkillManifestForTest("../lib/skill-manifest.ts"); +const normalizedManifest = packageParser.normalizeManifestForSkill( + "Skill.v1", + "---\nkind: kinic.skill\nschema_version: 1\nid: Skill.v1\nversion: 0.1.0\nentry: SKILL.md\ntitle: Existing\ntags:\n - Existing\nprovenance:\n license: Existing-License\n---\n# Manifest\n", + "---\nmetadata:\n title: Generated\n category: Generated\ndescription: Generated summary\nlicense: Generated-License\n---\n# Skill\n" +); +assert.equal((normalizedManifest.match(/\ntags:/g) ?? []).length, 1); +assert.match(normalizedManifest, / - Existing/); +assert.match(normalizedManifest, /summary: "Generated summary"/); +assert.doesNotMatch(normalizedManifest, /title: "Generated"/); +assert.doesNotMatch(normalizedManifest, / - Generated/); +assert.doesNotMatch(normalizedManifest, /Generated-License/); +const normalizedEmptyManifest = packageParser.normalizeManifestForSkill( + "Skill.v1", + "---\nkind: kinic.skill\nschema_version: 1\nid: Skill.v1\nversion: 0.1.0\nentry: SKILL.md\ntitle: \"\"\nsummary: \"\"\ntags:\nprovenance:\n license: \"\"\n---\n# Manifest\n", + "---\nmetadata:\n title: Generated\n category: Generated\ndescription: Generated summary\nlicense: Generated-License\n---\n# Skill\n" +); +assert.equal((normalizedEmptyManifest.match(/\ntags:/g) ?? []).length, 1); +assert.equal((normalizedEmptyManifest.match(/\n license:/g) ?? []).length, 1); +assert.match(normalizedEmptyManifest, /title: "Generated"/); +assert.match(normalizedEmptyManifest, /summary: "Generated summary"/); +assert.match(normalizedEmptyManifest, / - "Generated"/); +assert.match(normalizedEmptyManifest, / license: "Generated-License"/); +assert.deepEqual( + packageParser.markdownPackageLinks([ + "[Plan](docs/Project Plan.md)", + "[Angle]()", + "[Alpha](docs/Project (Alpha).md)", + "[Titled](docs/usage.md \"Usage\")", + "[Angle titled]( 'Project plan')", + "[Parenthesized title](docs/reference.md (Reference))", + "[Ignored](https://example.com/docs/External.md)", + "[Escape](../escape.md)", + "[Empty](docs//Broken.md)", + "[Dot](docs/./Hidden.md)" + ].join("\n")), + ["docs/Project Plan.md", "docs/Project (Alpha).md", "docs/usage.md", "docs/reference.md"] +); +assert.equal(packageParser.cleanSkillId("Skill.v1"), "Skill.v1"); +assert.throws(() => packageParser.cleanSkillId("skill..v1"), /single path-safe segment/); +assert.throws(() => packageParser.cleanSkillId("a".repeat(129)), /single path-safe segment/); +assert.equal( + manifestParser.parseSkillManifest("---\nkind: kinic.skill\nschema_version: 1\nid: Skill.v1\nversion: 0.1.0\nentry: SKILL.md\n---\n# Skill")?.id, + "Skill.v1" +); +assert.equal( + manifestParser.parseSkillManifest("---\nkind: kinic.skill\nschema_version: 1\nid: skill..v1\nversion: 0.1.0\nentry: SKILL.md\n---\n# Skill"), + null +); console.log("Skill Registry web checks OK"); + +async function importSkillRegistryPackageForTest(relativePath) { + const sourcePath = new URL(relativePath, import.meta.url); + const source = readFileSync(sourcePath, "utf8") + .replace(/^import .+;\n/gm, "") + .concat("\nexport { markdownPackageLinks, cleanSkillId, normalizeManifestForSkill };\n"); + const compiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ES2022, + target: ts.ScriptTarget.ES2022 + } + }).outputText; + const moduleUrl = `data:text/javascript;base64,${Buffer.from(compiled).toString("base64")}`; + return import(moduleUrl); +} + +async function importSkillManifestForTest(relativePath) { + const sourcePath = new URL(relativePath, import.meta.url); + const source = readFileSync(sourcePath, "utf8"); + const compiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ES2022, + target: ts.ScriptTarget.ES2022 + } + }).outputText; + const moduleUrl = `data:text/javascript;base64,${Buffer.from(compiled).toString("base64")}`; + return import(moduleUrl); +} diff --git a/wikibrowser/app/api/source/run/route.ts b/wikibrowser/app/api/source/run/route.ts index fe910cdc..9f195ddf 100644 --- a/wikibrowser/app/api/source/run/route.ts +++ b/wikibrowser/app/api/source/run/route.ts @@ -113,7 +113,8 @@ function parseSourceRunRequest(value: unknown): SourceRunRequest | string { } function isCanonicalSourcePath(path: string): boolean { - return /^\/Sources\/raw\/[a-z][a-z0-9]{0,31}\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(path); + const match = path.match(/^\/Sources\/raw\/[a-z][a-z0-9]{0,31}\/([A-Za-z0-9][A-Za-z0-9._-]{0,127})\.md$/); + return !!match && !match[1].includes(".."); } function allowedOrigin(request: Request): string | null { diff --git a/wikibrowser/app/api/url-ingest/trigger/route.ts b/wikibrowser/app/api/url-ingest/trigger/route.ts index b7677278..03e8a8ef 100644 --- a/wikibrowser/app/api/url-ingest/trigger/route.ts +++ b/wikibrowser/app/api/url-ingest/trigger/route.ts @@ -104,12 +104,18 @@ function parseTriggerRequest(value: unknown): TriggerRequest | string { if (typeof requestPath !== "string" || !requestPath) return "requestPath is required"; if (typeof sessionNonce !== "string" || !sessionNonce) return "sessionNonce is required"; if (sessionNonce.length > 128) return "sessionNonce is too long"; - if (!requestPath.startsWith("/Sources/ingest-requests/") || !requestPath.endsWith(".md")) { + if (!isIngestRequestPath(requestPath)) { return "requestPath must be a URL ingest request path"; } return { canisterId, databaseId, requestPath, sessionNonce }; } +function isIngestRequestPath(path: string): boolean { + if (!path.startsWith("/Sources/ingest-requests/")) return false; + const name = path.slice("/Sources/ingest-requests/".length); + return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(name) && !name.includes(".."); +} + function allowedOrigin(request: Request): string | null { const origin = request.headers.get("origin"); if (!origin || !ALLOWED_ORIGINS.has(origin)) return null; diff --git a/wikibrowser/app/dashboard/dashboard-client.tsx b/wikibrowser/app/dashboard/dashboard-client.tsx index cd4e39d1..0b6f6311 100644 --- a/wikibrowser/app/dashboard/dashboard-client.tsx +++ b/wikibrowser/app/dashboard/dashboard-client.tsx @@ -13,7 +13,6 @@ import { deleteDatabaseAuthenticated, getCyclesBillingConfig, grantDatabaseAccessAuthenticated, - listDatabaseCyclePendingOperationsAuthenticated, listDatabaseMembersAuthenticated, listDatabaseMembersPublic, listDatabasesAuthenticated, @@ -34,7 +33,6 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) const [databases, setDatabases] = useState([]); const [cyclesConfig, setCyclesBillingConfig] = useState(null); const [members, setMembers] = useState([]); - const [pendingOperationCount, setPendingOperationCount] = useState(0); const [loadState, setLoadState] = useState("idle"); const [error, setError] = useState(null); const [warning, setWarning] = useState(null); @@ -63,7 +61,6 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) setDatabases([]); setCyclesBillingConfig(null); setMembers([]); - setPendingOperationCount(0); setError(null); setWarning(null); setMemberError(null); @@ -96,7 +93,6 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) setDatabases(nextDatabases); setCyclesBillingConfig(cyclesResult.status === "fulfilled" ? cyclesResult.value : null); setMembers([]); - setPendingOperationCount(0); if (publicResult.status === "rejected") { setWarning(`Public database list unavailable: ${errorMessage(publicResult.reason)}`); } @@ -114,14 +110,6 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) setMemberError(errorMessage(cause)); } } - try { - const pendingOperations = await listDatabaseCyclePendingOperationsAuthenticated(canisterId, identity, nextDatabaseId); - if (!isCurrentRefresh()) return; - setPendingOperationCount(pendingOperations.length); - } catch { - if (!isCurrentRefresh()) return; - setPendingOperationCount(0); - } } else if (nextDatabase?.publicReadable && nextDatabase.status === "active") { try { const nextMembers = await listDatabaseMembersPublic(canisterId, nextDatabaseId); @@ -302,7 +290,7 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) {database ? ( canDeletePendingDatabase ? ( - + ) : canManage ? ( Promise; }) { return ( @@ -94,7 +93,7 @@ export function PendingDatabasePanel(props: {

Reserved database

This database is reserved until the first cycle purchase completes. VFS, skills, and member management are available after activation.

- + ); } @@ -106,7 +105,6 @@ export function OwnerPanel(props: { databaseId: string; databaseName: string; members: DatabaseMember[]; - pendingOperationCount: number; principal: string; onDelete: () => Promise; onGrant: (principalText: string, role: DatabaseRole) => void; @@ -267,7 +265,6 @@ export function OwnerPanel(props: { busyAction={props.busyAction} databaseId={props.databaseId} databaseName={props.databaseName} - pendingOperationCount={props.pendingOperationCount} onDelete={props.onDelete} /> diff --git a/wikibrowser/app/dashboard/database-danger-zone.tsx b/wikibrowser/app/dashboard/database-danger-zone.tsx index 0bdef8b3..93923114 100644 --- a/wikibrowser/app/dashboard/database-danger-zone.tsx +++ b/wikibrowser/app/dashboard/database-danger-zone.tsx @@ -14,13 +14,11 @@ export function DatabaseDangerZone(props: { busyAction: BusyAction | null; databaseId: string; databaseName: string; - pendingOperationCount: number; onDelete: () => Promise; }) { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteError, setDeleteError] = useState(null); - const hasPendingOperations = props.pendingOperationCount > 0; - const deleteDisabled = props.busy || hasPendingOperations; + const deleteDisabled = props.busy; function openDeleteDialog() { setDeleteError(null); setDeleteDialogOpen(true); @@ -45,7 +43,7 @@ export function DatabaseDangerZone(props: {

{props.databaseName} / {props.databaseId}

- +
@@ -113,13 +111,6 @@ function ConfirmDeleteDatabaseDialog(props: { ); } -function DeleteCyclesNotice({ pendingOperationCount }: { pendingOperationCount: number }) { - if (pendingOperationCount > 0) { - return ( -

- Resolve pending cycle operations before deleting. Pending operations: {pendingOperationCount} -

- ); - } +function DeleteCyclesNotice() { return

Remaining cycles will be discarded.

; } diff --git a/wikibrowser/app/skills/skill-registry-client.tsx b/wikibrowser/app/skills/skill-registry-client.tsx index 4d193a05..e46bcebe 100644 --- a/wikibrowser/app/skills/skill-registry-client.tsx +++ b/wikibrowser/app/skills/skill-registry-client.tsx @@ -303,7 +303,7 @@ export function SkillRegistryClient({ databaseId }: { databaseId: string }) { }), true ), - approveProposal: (proposal) => void runSkillAction(skill, (activeIdentity) => approveSkillProposal(canisterId, databaseId, activeIdentity, skill, proposal.path)), + approveProposal: (proposal) => void runSkillAction(skill, (activeIdentity) => approveSkillProposal(canisterId, databaseId, activeIdentity, skill, proposal.proposalRoot)), previewProposal: (proposal) => void runSkillAction( skill, @@ -316,7 +316,7 @@ export function SkillRegistryClient({ databaseId }: { databaseId: string }) { ), applyProposal: (proposal) => void runSkillAction(skill, async (activeIdentity, draft) => { - if (!draft.preview || draft.preview.proposalPath !== proposal.path) throw new Error("Preview this proposal before applying."); + if (!draft.preview || draft.preview.proposalPath !== proposal.proposalRoot) throw new Error("Preview this proposal before applying."); await applyProposalDiff(canisterId, databaseId, activeIdentity, proposal, draft.preview); await recordSkillEvent(canisterId, databaseId, activeIdentity, skill.manifest.id, { action: "proposal.apply", targetPath: draft.preview.targetPath, result: "applied" }); }) diff --git a/wikibrowser/app/skills/skill-registry-management-ui.tsx b/wikibrowser/app/skills/skill-registry-management-ui.tsx index 7d59095b..583542ed 100644 --- a/wikibrowser/app/skills/skill-registry-management-ui.tsx +++ b/wikibrowser/app/skills/skill-registry-management-ui.tsx @@ -3,13 +3,11 @@ import type { ReactNode } from "react"; import { ChevronDown, Github, ShieldCheck, Upload } from "lucide-react"; import type { DatabaseRole } from "@/lib/types"; -import type { SkillCatalog } from "@/lib/skill-registry-package"; export type PackageDraft = { source: string; reference: string; id: string; - catalog: SkillCatalog; skill: string; manifest: string; provenance: string; @@ -77,7 +75,7 @@ export function PackageManager({ onChange={(event) => handlers.setDraft({ source: event.target.value })} /> -
+
handlers.setDraft({ id: event.target.value })} /> - - handlers.setDraft({ catalog })} /> -

Public GitHub repositories only. Browser API cannot prune deleted files.