Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 22 additions & 17 deletions crates/vfs_canister/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ use std::time::Duration;
use candid::utils::decode_args;
use candid::{CandidType, Decode, Deserialize, Nat, Principal, export_service};
#[cfg(not(test))]
use ic_cdk::api::PerformanceCounterType;
#[cfg(not(test))]
use ic_cdk::call::Call;
use ic_cdk::{init, post_upgrade, query, update};
#[cfg(target_arch = "wasm32")]
Expand Down Expand Up @@ -66,6 +68,7 @@ const II_ALTERNATIVE_ORIGINS_BODY: &str = r#"{"alternativeOrigins":["https://wik
const ICP_CLI_LOGIN_DISCOVERY_PATH: &str = "/.well-known/ic-cli-login";
const ICP_CLI_LOGIN_PATH: &str = "/login";
const ICP_CLI_LOGIN_HTML: &str = include_str!("icp_cli_login.html");
const UPDATE_EXECUTION_BASE_CYCLES: u128 = 5_000_000;
#[cfg(target_arch = "wasm32")]
const INDEX_DB_MEMORY_ID: u16 = 10;

Expand Down Expand Up @@ -1195,7 +1198,7 @@ thread_local! {
static TEST_LAST_LEDGER_FROM: RefCell<Option<IcrcAccount>> = const { RefCell::new(None) };
static TEST_CALLER_PRINCIPAL: RefCell<Option<Principal>> = const { RefCell::new(None) };
static TEST_DATABASE_CYCLES_PURCHASE_APPLY_FAIL_ONCE: RefCell<bool> = const { RefCell::new(false) };
static TEST_CYCLE_BALANCES: RefCell<Vec<u128>> = const { RefCell::new(Vec::new()) };
static TEST_UPDATE_CHARGE_UNITS: RefCell<Vec<u128>> = const { RefCell::new(Vec::new()) };
}

#[cfg(test)]
Expand All @@ -1216,9 +1219,9 @@ fn set_next_ledger_transfer_from_outcome_for_test(outcome: LedgerTransferFromOut
}

#[cfg(test)]
fn set_cycle_balances_for_test(balances: Vec<u128>) {
TEST_CYCLE_BALANCES.with(|slot| {
slot.replace(balances);
fn set_update_charge_units_for_test(units: Vec<u128>) {
TEST_UPDATE_CHARGE_UNITS.with(|slot| {
slot.replace(units);
});
}

Expand All @@ -1230,7 +1233,7 @@ fn clear_ledger_transactions_for_test() {
TEST_LEDGER_TRANSFER_FEES.with(|slot| {
slot.borrow_mut().clear();
});
TEST_CYCLE_BALANCES.with(|slot| {
TEST_UPDATE_CHARGE_UNITS.with(|slot| {
slot.borrow_mut().clear();
});
}
Expand Down Expand Up @@ -1397,24 +1400,26 @@ fn now_millis() -> i64 {
}
}

fn cycle_balance() -> u128 {
fn update_charge_units() -> u128 {
#[cfg(test)]
{
TEST_CYCLE_BALANCES.with(|slot| {
let mut balances = slot.borrow_mut();
if balances.is_empty() {
1_000_000_000_000
} else {
balances.remove(0)
}
TEST_UPDATE_CHARGE_UNITS.with(|slot| {
let mut units = slot.borrow_mut();
if units.is_empty() { 0 } else { units.remove(0) }
})
}
#[cfg(not(test))]
{
ic_cdk::api::canister_cycle_balance()
u128::from(ic_cdk::api::performance_counter(
PerformanceCounterType::InstructionCounter,
))
}
}

fn update_charge_cycles(before: u128, after: u128) -> u128 {
UPDATE_EXECUTION_BASE_CYCLES + after.saturating_sub(before)
}

fn now_nanos() -> u64 {
#[cfg(test)]
{
Expand Down Expand Up @@ -1592,16 +1597,16 @@ where
{
let caller = caller_text();
let now = now_millis();
let before_cycles = cycle_balance();
let before_charge_units = update_charge_units();
SERVICE.with(|slot| {
let borrowed = slot.borrow();
let service = borrowed
.as_ref()
.ok_or_else(|| "wiki service is not initialized".to_string())?;
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);
let after_charge_units = update_charge_units();
let cycles_delta = update_charge_cycles(before_charge_units, after_charge_units);
if result.is_ok()
&& let Some(database_id) = database_id.as_deref()
&& let Err(error) = service.charge_database_update(
Expand Down
42 changes: 32 additions & 10 deletions crates/vfs_canister/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ use super::{
multi_edit_node, outgoing_links, parse_upgrade_cycles_billing_config_arg,
purchase_database_cycles, query_context, query_index_sql_json, read_database_archive_chunk,
read_node, read_node_context, rename_database, revoke_database_access, search_node_paths,
search_nodes, set_cycle_balances_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, update_cycles_billing_config, write_database_restore_chunk,
write_node, write_nodes,
search_nodes, set_next_ledger_transfer_from_outcome_for_test,
set_test_caller_principal_for_test, set_update_charge_units_for_test,
settle_database_storage_charges, source_evidence, status, transfer_from_error_outcome,
update_cycles_billing_config, write_database_restore_chunk, write_node, write_nodes,
};

fn install_test_service() {
Expand Down Expand Up @@ -1334,8 +1334,9 @@ fn canister_list_databases_hides_deleted_databases() {
}

#[test]
fn write_nodes_skips_zero_charge_ledger_and_writes_nodes() {
fn write_nodes_records_instruction_charge_and_writes_nodes() {
install_test_service();
set_update_charge_units_for_test(vec![10_000, 10_321]);

let results = write_nodes(WriteNodesRequest {
database_id: "default".to_string(),
Expand All @@ -1359,7 +1360,16 @@ fn write_nodes_skips_zero_charge_ledger_and_writes_nodes() {
.expect("batch write should succeed");

assert_eq!(results.len(), 2);
assert!(database_charge_methods("default").is_empty());
let entries = list_database_cycle_entries("default".to_string(), None, 20)
.expect("database cycles ledger should load")
.entries;
let charge = entries
.iter()
.find(|entry| entry.kind == "charge")
.expect("charge entry should exist");
assert_eq!(charge.amount_cycles, -5_000_321);
assert_eq!(charge.cycles_delta, Some(5_000_321));
assert_eq!(charge.method.as_deref(), Some("write_nodes"));
assert!(
read_node("default".to_string(), "/Wiki/batch-a.md".to_string())
.expect("read should succeed")
Expand All @@ -1368,8 +1378,9 @@ fn write_nodes_skips_zero_charge_ledger_and_writes_nodes() {
}

#[test]
fn write_node_and_write_nodes_skip_zero_charge_ledger() {
fn write_node_and_write_nodes_record_instruction_charges() {
install_test_service();
set_update_charge_units_for_test(vec![7, 11, 13, 19]);

write_node(WriteNodeRequest {
database_id: "default".to_string(),
Expand All @@ -1392,7 +1403,18 @@ fn write_node_and_write_nodes_skip_zero_charge_ledger() {
})
.expect("batch write should succeed");

assert!(database_charge_methods("default").is_empty());
let entries = list_database_cycle_entries("default".to_string(), None, 20)
.expect("database cycles ledger should load")
.entries;
let charges = entries
.iter()
.filter(|entry| entry.kind == "charge")
.collect::<Vec<_>>();
assert_eq!(charges.len(), 2);
assert_eq!(charges[0].method.as_deref(), Some("write_node"));
assert_eq!(charges[0].cycles_delta, Some(5_000_004));
assert_eq!(charges[1].method.as_deref(), Some("write_nodes"));
assert_eq!(charges[1].cycles_delta, Some(5_000_006));
}

#[test]
Expand All @@ -1405,7 +1427,7 @@ fn write_node_overdrawn_charge_consumes_balance_and_suspends_database() {
.and_then(|database| database.cycles_balance)
.expect("default database should have cycles balance");

set_cycle_balances_for_test(vec![1_000_000_000_000, 0]);
set_update_charge_units_for_test(vec![0, 1_000_000_000_000]);
let written = write_node(WriteNodeRequest {
database_id: "default".to_string(),
path: "/Wiki/overdrawn.md".to_string(),
Expand Down Expand Up @@ -1439,7 +1461,7 @@ fn write_node_overdrawn_charge_consumes_balance_and_suspends_database() {
.expect("charge entry should exist");
assert_eq!(charge.amount_cycles, -(before_balance as i64));
assert_eq!(charge.balance_after_cycles, 0);
assert_eq!(charge.cycles_delta, Some(1_000_000_000_000));
assert_eq!(charge.cycles_delta, Some(1_000_005_000_000));
assert_eq!(charge.method.as_deref(), Some("write_node"));
}

Expand Down
2 changes: 1 addition & 1 deletion docs/DB_LIFECYCLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Successful DB update calls are charged after execution. The charge is raw cycle
cycles_delta
```

Cycles are stored as raw integer cycles. The default purchase rate is `1 KINIC = 234_500_000_000 cycles` (`0.2345 Tcycle`), 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 DB cycles are fully charged, the balance becomes `0`, and the DB is suspended.
Cycles are stored as raw integer cycles. The default purchase rate is `1 KINIC = 234_500_000_000 cycles` (`0.2345 Tcycle`), 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. Metered update charge uses the current message instruction counter delta plus the 13-node update execution base fee, not same-message canister balance deltas. If the post-update charge exceeds the DB cycles balance, the remaining DB cycles are fully charged, the balance becomes `0`, and the DB is suspended.

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`:

Expand Down
6 changes: 3 additions & 3 deletions docs/payment.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ approved_allowance_e8s = payment_amount_e8s + ledger_fee_e8s

approve の transaction fee は wallet 残高から別途支払われる。approve は現在 allowance を `expected_allowance` として渡し、30 分後に expire する。approve 後に purchase が失敗した場合、UI は approval が expire まで残る旨を error に含める。

UI は `NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID` と request canister ID が一致しない場合に拒否する。Plug は VFS canister と KINIC ledger canister を whitelist して接続する。OISY は ICRC wallet の call-canister 結果 certificate を検証し、method、canister、arg、reply を照合する。
UI は `NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID` と request canister ID が一致しない場合に拒否する。Plug は VFS canister と KINIC ledger canister を whitelist して接続する。OISY は接続確認後に signer popup を閉じ、購入時に再度 signer を開いて同じ owner であることを確認する。OISY は ICRC wallet の call-canister 結果 certificate を検証し、method、canister、arg、reply を照合する。

## ICRC-21 consent

Expand All @@ -154,11 +154,11 @@ metered update は実行前に `prepare_metered_update` で認可と残高確認

`check_database_write_cycles(database_id)` は anonymous caller を明示的に拒否し、writer 以上の role と cycles 利用可能状態を確認する。

canister の metered update wrapper は、更新前後の canister cycle balance 差分を `cycles_delta` として計算する。更新関数が `Ok` を返した場合だけ、`charge_database_update` を実行する。
canister の metered update wrapper は、更新前後の `performance_counter(InstructionCounter)` 差分に 13-node application subnet の update 実行 base fee `5_000_000 cycles` を加えた値を `cycles_delta` として計算する。`canister_cycle_balance` は同一 message 内の実行課金確定前の残高を返すため、update 本体の課金計測には使わない。更新関数が `Ok` を返した場合だけ、`charge_database_update` を実行する。

更新課金額は `cycles_delta` そのものである。`cycles_delta` が `i64` に収まらない場合は `cycle charge exceeds i64 limit` になる。`cycles_delta == 0` の場合、残高更新も ledger 記録も行わない。

`charge_database_update` は現在残高より請求額が大きい場合、残高を全徴収して `balance_after_cycles = 0` にし、DB を suspended にする。ledger の `amount_cycles` は実際に徴収した cycles、`cycles_delta` は実測請求額を記録する
`charge_database_update` は現在残高より請求額が大きい場合、残高を全徴収して `balance_after_cycles = 0` にし、DB を suspended にする。ledger の `amount_cycles` は実際に徴収した cycles、`cycles_delta` は update 実行 base fee と instruction 差分から計算した請求額を記録する

課金成功時は残高から実徴収額を引く。更新後残高が `min_update_cycles` 未満なら `suspended_at_ms` を課金時刻に設定し、それ以上なら `NULL` にする。ledger には以下を記録する。

Expand Down
2 changes: 1 addition & 1 deletion extensions/wiki-clipper/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Kinic Wiki Clipper",
"version": "0.1.1",
"version": "0.1.2",
"description": "Create Kinic Wiki pages from the current tab and export ChatGPT conversations.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs5287sDaurUdgrnxKcx/A5JjcRpUFrxb5qXEvtkiO2STfEVsprr+uUltmecOlbHzt/8n15LxXPOyl3C9S2ZyArZWgUbOy0o3cCTDwLM80KkMj+x0hHGwC5KGAjZqOWnyQsFe6jonlQuTUye70xFodZdDfgU0V7SMjmrCFArIno+8A+3aVdODpcCMSkwiWaAFmCbvXeVFcAn70tcI0rJ/GXgnksU4gqwL6+0S5ZvLvhc7xRLNRjK+j2YXTRxxrGKQJ1ongCgJSgWzzSnJVOJ3t4K7ZosBlCZV6p/qTrY/4V2LC/XACRPctnKgNmD/9rvedIZdCVBMBYrfPOMSbteZeQIDAQAB",
"action": {
Expand Down
4 changes: 2 additions & 2 deletions extensions/wiki-clipper/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion extensions/wiki-clipper/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kinic-wiki-clipper",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion scripts/smoke/local_canister_post_upgrade.sh
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export ICP_ENVIRONMENT
export KINIC_LEDGER_CANISTER_ID
export SMOKE_CYCLE_PURCHASE_E8S

scripts/local/deploy_wiki.sh
scripts/local/deploy_wiki.sh "$@"
CANISTER_ID="$(resolve_canister_id)"
export CANISTER_ID
export REPLICA_HOST
Expand Down
11 changes: 4 additions & 7 deletions wikibrowser/app/cycles/cycles-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { CheckCircle2, CircleAlert, Info, PlugZap, Wallet } from "lucide-react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useMemo, useState, type ReactNode } from "react";
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";
Expand All @@ -20,6 +21,7 @@ type CyclesClientProps = {
};

export function CyclesClient({ canisterId, databaseId, initialKinic }: CyclesClientProps) {
const router = useRouter();
const [status, setStatus] = useState<CyclesStatus>("idle");
const [message, setMessage] = useState<string | null>(null);
const [provider, setProvider] = useState<CyclesProvider | null>(null);
Expand Down Expand Up @@ -53,12 +55,6 @@ export function CyclesClient({ canisterId, databaseId, initialKinic }: CyclesCli
: null;
const purchaseDisabled = !selectedProvider || Boolean(error) || Boolean(amountError) || busy;

useEffect(() => {
return () => {
if (oisyWallet) void oisyWallet.wallet.disconnect();
};
}, [oisyWallet]);

async function connect(nextProvider: CyclesProvider) {
setStatus("connecting");
setProvider(nextProvider);
Expand Down Expand Up @@ -105,6 +101,7 @@ export function CyclesClient({ canisterId, databaseId, initialKinic }: CyclesCli
);
if (selectedProvider === "oisy") setOisyWallet(null);
setStatus("success");
router.replace("/");
} catch (cause) {
setMessage(cause instanceof Error ? cause.message : String(cause));
setStatus("error");
Expand Down
2 changes: 1 addition & 1 deletion wikibrowser/app/dashboard/dashboard-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string })
) : !databaseId ? (
<section className="rounded-lg border border-line bg-paper p-8 shadow-sm">
<h2 className="text-lg font-semibold text-ink">Select a database to manage</h2>
<p className="mt-2 text-sm leading-6 text-muted">Open the Database dashboard, then choose Access on a database row.</p>
<p className="mt-2 text-sm leading-6 text-muted">Open the Database dashboard, then choose Manage on a database row.</p>
<Link className="mt-5 inline-flex rounded-2xl border border-action bg-action px-4 py-2 text-sm font-bold text-white no-underline hover:-translate-y-[3px] hover:border-accent hover:bg-accent" href="/">
Open Database dashboard
</Link>
Expand Down
Loading