From 044f48758bae2b686419f3c36de6eb96634be7ba Mon Sep 17 00:00:00 2001 From: hude Date: Fri, 5 Jun 2026 11:31:09 +0900 Subject: [PATCH 01/10] Refactor wiki indexing and search migration flow --- MARKETPLACE_PLAN.md | 486 ++++ crates/vfs_canister/src/lib.rs | 249 +- crates/vfs_canister/vfs.did | 187 +- crates/vfs_client/src/lib.rs | 221 +- .../migrations/index_db/011_to_latest.sql | 102 + .../index_db/fresh_index_schema.sql | 102 + crates/vfs_runtime/src/lib.rs | 2162 +++++++++++++++-- crates/vfs_runtime/tests/database_service.rs | 321 ++- crates/vfs_types/src/fs.rs | 145 ++ wikibrowser/app/app-header.tsx | 35 +- wikibrowser/app/app-session-provider.tsx | 2 +- wikibrowser/app/cycles/cycles-client.tsx | 2 +- .../app/dashboard/dashboard-client.tsx | 120 +- wikibrowser/app/dashboard/dashboard-ui.tsx | 196 +- .../app/dashboard/database-danger-zone.tsx | 10 + wikibrowser/app/home-page-client.tsx | 2 +- .../[listingId]/listing-detail-client.tsx | 180 ++ .../app/marketplace/[listingId]/page.tsx | 11 + .../app/marketplace/marketplace-client.tsx | 114 + wikibrowser/app/marketplace/page.tsx | 11 + wikibrowser/app/marketplace/wallet/page.tsx | 10 + .../app/marketplace/wallet/wallet-client.tsx | 116 + .../lib/{cycles-wallet.ts => kinic-wallet.ts} | 476 +++- wikibrowser/lib/types.ts | 103 + wikibrowser/lib/vfs-client.ts | 472 ++++ wikibrowser/lib/vfs-idl.ts | 118 + wikibrowser/scripts/candid-shapes.mjs | 184 +- wikibrowser/scripts/check-candid-drift.mjs | 31 +- wikibrowser/scripts/check-cycles-wallet.mjs | 87 +- wikibrowser/scripts/check-cycles.mjs | 41 +- wikibrowser/scripts/check-dashboard.mjs | 5 +- wikibrowser/scripts/generate-vfs-idl.mjs | 72 +- 32 files changed, 5967 insertions(+), 406 deletions(-) create mode 100644 MARKETPLACE_PLAN.md create mode 100644 wikibrowser/app/marketplace/[listingId]/listing-detail-client.tsx create mode 100644 wikibrowser/app/marketplace/[listingId]/page.tsx create mode 100644 wikibrowser/app/marketplace/marketplace-client.tsx create mode 100644 wikibrowser/app/marketplace/page.tsx create mode 100644 wikibrowser/app/marketplace/wallet/page.tsx create mode 100644 wikibrowser/app/marketplace/wallet/wallet-client.tsx rename wikibrowser/lib/{cycles-wallet.ts => kinic-wallet.ts} (61%) diff --git a/MARKETPLACE_PLAN.md b/MARKETPLACE_PLAN.md new file mode 100644 index 0000000..f3bc5b0 --- /dev/null +++ b/MARKETPLACE_PLAN.md @@ -0,0 +1,486 @@ +# Marketplace Plan + +Kinic Wiki に DB 閲覧権マーケットプレイスを追加するための計画。 + +目的は、複雑な金融商品ではなく「価値ある private DB を、購入者だけが読める」状態を最小実装で作ること。 + +## 結論 + +最初は「DB 全体の永続 Reader 相当閲覧権」を販売する。 + +- 売るもの: DB の閲覧権 +- 売らないもの: Writer 権、Owner 権、DB 所有権、二次流通権 +- 購入前に見せるもの: title、description、LLM summary、sample excerpts、node count、最終更新日、tags、price +- 購入後に許可するもの: read、list、search、graph +- 権限正本: `database_members` ではなく `market_entitlements` +- API 名: `market_` prefix +- 購入権: 永続。active 判定は `status = 'active'` のみ。 +- DB delete/archive: owner 権限を維持し、禁止しない。UI/CLI で購入者影響を警告する。 +- 決済: ユーザー別 KINIC balance を正本にし、購入時は外部 ledger を呼ばない。 +- cashout: MVP では実装しない。seller 売上は内部 balance までに留める。 + +## なぜこの形にするか + +`database_members` に reader を直接追加すると、owner が後から revoke できる。 +購入者から見ると「買った権利が消える」ため、販売契約として弱い。 + +購入権は `market_entitlements` に分離する。 +通常 ACL と購入権を分けることで、手動 grant、owner 管理、paid access、期限管理を混ぜずに扱える。 + +決済は buyer から seller への direct transfer ではなく、deposit 済み残高で処理する。 +閲覧権購入時に ledger call を行うと、ledger await と entitlement 付与が同じ update に混在し、曖昧結果、重複実行、local apply failure の復旧分岐が増える。 +購入は canister 内の SQLite transaction だけで buyer debit、seller credit、entitlement grant を確定する。 + +## 決済前提 + +- deposit は `approve + icrc2_transfer_from` で buyer account から canister account へ KINIC を移す。 +- 閲覧権購入時は外部 ledger を呼ばず、内部 balance transaction だけで確定する。 +- deposit の ledger 曖昧結果は `kinic_pending_operations` で扱う。 + +## MVP Scope + +MVP で作るもの: + +- DB owner が listing を作成できる。 +- listing は DB 全体に紐付く。 +- listing は公開 metadata を持つ。 +- buyer は KINIC を deposit して内部 balance を持つ。 +- buyer は内部 balance で閲覧権を購入する。 +- purchase 成功後、buyer に active entitlement を付与する。 +- seller 売上は seller の内部 balance に credit する。 +- read 系認可は `database_members` または active `market_entitlements` を許可する。 +- active entitlement が残る DB の delete/archive は許可し、UI/CLI で警告する。 +- listing 検索は本文検索と分離する。 +- delete/archive 前に active entitlement count を返す query を提供する。 + +MVP で作らないもの: + +- Writer / Owner 権限販売 +- prefix 単位販売 +- snapshot 固定販売 +- platform fee split +- refund +- resale +- ZKP +- 購入者ごとの LLM 要約生成 +- 複数 token +- seller withdraw +- kinic pending operation repair/cancel + +## Buyer Experience + +購入者は Marketplace 一覧から listing を探す。 +詳細画面では本文全体ではなく、購入判断材料だけを見る。 + +表示するもの: + +- title +- description +- LLM summary +- sample excerpts +- sample questions +- topics / tags +- node count +- logical size +- last updated +- seller +- price + +購入前に balance が不足する場合、deposit 画面へ誘導する。 +購入後は My purchases に DB が表示され、通常の WikiBrowser と同じ read/search/graph UI で読む。 + +## Seller Experience + +seller は既存 DB 管理画面から「Sell this DB」を押す。 +公開情報を入力し、必要なら LLM summary を生成する。 + +seller が入力するもの: + +- title +- description +- tags +- price +- sample excerpts + +LLM summary は任意。 +生成する場合も listing publish 時だけ実行し、閲覧ごとには生成しない。 +summary には `summary_snapshot_revision` を持たせる。 +DB 更新で listing snapshot と current snapshot がズレた場合、購入は止めずに stale 警告を表示する。 + +## Value Signals + +DB 本文は購入前に全面公開しない。 +価値証明は暗号ではなく、購入判断材料として提示する。 + +使う signal: + +- LLM summary +- seller description +- sample excerpts +- sample questions +- node count +- prefix stats +- last updated +- provenance +- purchase count +- report count + +ZKP は使わない。 +「内容が有益」という性質は暗号で証明しにくい。 +最初は preview、sample、統計、評判で判断させる。 + +## Entitlement Model + +`market_entitlements` を追加する。 + +```sql +CREATE TABLE market_entitlements ( + database_id TEXT NOT NULL, + buyer_principal TEXT NOT NULL, + listing_id TEXT NOT NULL, + order_id TEXT NOT NULL, + purchased_at_ms INTEGER NOT NULL, + status TEXT NOT NULL, + PRIMARY KEY (database_id, buyer_principal, listing_id) +); +``` + +DB 全体閲覧権販売なので、同じ DB を relist しても二重購入させない。 + +```sql +CREATE UNIQUE INDEX market_entitlements_database_buyer_idx + ON market_entitlements(database_id, buyer_principal) + WHERE status = 'active'; +``` + +認可判定: + +```text +can_read = database_members has reader/writer/owner + OR market_entitlements has active entitlement +``` + +Writer 系 update は entitlement では許可しない。 +owner revoke は entitlement に影響しない。 +`list_database_members`、cycles history、Owner/Writer update は entitlement を見ない。 + +実装では `load_member_role` と `role_allows` を変更しない。 +`require_database_read_access` などの新規 helper を作り、member role が通る場合は許可し、明示した read surface だけ active entitlement を許可する。 + +active entitlement 判定: + +```sql +status = 'active' +``` + +anonymous principal は entitlement 対象外にする。 + +購入済み entitlement で許可する read surface は以下に限定する。 + +- `read_node` +- `list_children` +- `list_nodes` +- `search_nodes` +- `search_node_paths` +- `incoming_links` +- `outgoing_links` +- `graph_links` +- `graph_neighborhood` +- `read_node_context` + +`export_snapshot`、`fetch_updates`、source generation、URL ingest、write/update 系、member/cycles 管理系は entitlement では許可しない。 + +## Payment Model + +KINIC は marketplace 内部 balance に deposit してから使う。 + +購入時に外部 ledger は呼ばない。 +購入 transaction は index DB だけを更新する。 + +MVP invariant: + +- deposit は既存 cycles purchase と同じく `approve + icrc2_transfer_from` で canister account に入れる。 +- deposit の ledger await は購入処理と分離する。 +- seller 売上の withdraw は実装しない。 + +deposit は buyer から canister default account への `transfer_from` として扱う。 +既に `database_cycle_pending_operations` が ledger await と曖昧結果を扱う設計を持つため、KINIC deposit も同じ pattern の pending table を持つ。 + +### Deposit Flow + +1. wallet が KINIC ledger で canister を spender に `icrc2_approve` する。 +2. buyer が `market_deposit_balance(amount_e8s, expected_fee_e8s)` を呼ぶ。 +3. canister が `kinic_pending_operations` に `deposit` を作成する。 +4. canister が `icrc2_transfer_from` で buyer account から canister account へ KINIC を移す。 +5. ledger success または `Duplicate` success なら buyer internal balance に credit する。 +6. `kinic_ledger` に `deposit` を記録し、pending operation を削除する。 + +同一 caller の deposit は guard で直列化する。 +ledger 明示 error では pending operation を削除し、内部 balance は更新しない。 +ledger 結果曖昧、または ledger success 後 local apply 前に失敗した場合は pending operation を残す。 + +### Internal Balance + +`kinic_accounts` を追加する。 +これは marketplace 専用ではなく、将来ほかの KINIC 利用でも参照できる canister 内部 account である。 + +```sql +CREATE TABLE kinic_accounts ( + principal TEXT PRIMARY KEY, + balance_e8s INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +); +``` + +`kinic_ledger` を内部 balance の正本履歴にする。 +履歴の `source` で marketplace 由来か別用途由来かを区別する。 + +```sql +CREATE TABLE kinic_ledger ( + entry_id INTEGER PRIMARY KEY AUTOINCREMENT, + principal TEXT NOT NULL, + source TEXT NOT NULL, + kind TEXT NOT NULL, + amount_e8s INTEGER NOT NULL, + balance_after_e8s INTEGER NOT NULL, + counterparty TEXT, + listing_id TEXT, + order_id TEXT, + external_block_index INTEGER, + created_at_ms INTEGER NOT NULL +); +``` + +`kind` は MVP では `deposit`, `purchase`, `sale` だけにする。 + +### Purchase Flow + +`market_purchase_access(listing_id, price_e8s, expected_revision)` は ledger を呼ばない。 + +1. caller 認証。anonymous は拒否。 +2. listing が active で、price と revision が一致することを確認。 +3. buyer balance が price 以上であることを確認。 +4. buyer から price を debit。 +5. seller に price を credit。 +6. `market_orders` と buyer/seller の `kinic_ledger` を記録。 +7. `market_entitlements` を付与。 + +全手順を 1 SQLite transaction で行う。 +同じ buyer が同じ listing を再購入した場合は拒否する。 + +### Future Withdraw + +MVP では withdraw を実装しない。 +理由は、canister から外部 ledger へ outgoing transfer する場合、fee、ambiguous transfer repair、同一 TransferArg retry が同時に必要になるためである。 + +withdraw は別 PR とし、fee、created_at_time、`Duplicate` 成功扱い、同一 TransferArg retry、repair/cancel API を同時に設計する。 + +## Listing Model + +`market_listings` を追加する。 + +```sql +CREATE TABLE market_listings ( + listing_id TEXT PRIMARY KEY, + seller_principal TEXT NOT NULL, + database_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + llm_summary TEXT, + summary_snapshot_revision TEXT, + sample_excerpts_json TEXT NOT NULL, + sample_questions_json TEXT NOT NULL, + tags_json TEXT NOT NULL, + price_e8s INTEGER NOT NULL, + status TEXT NOT NULL, + revision INTEGER NOT NULL, + purchase_count INTEGER NOT NULL, + report_count INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +); +``` + +制約: + +- seller は対象 DB の owner だけ。 +- `price_e8s > 0`。 +- `status` は `draft`, `active`, `paused`。 +- active listing の公開 metadata / price 変更は revision を進める。 + +## Order Model + +`market_orders` を追加する。 + +```sql +CREATE TABLE market_orders ( + order_id TEXT PRIMARY KEY, + listing_id TEXT NOT NULL, + database_id TEXT NOT NULL, + buyer_principal TEXT NOT NULL, + seller_principal TEXT NOT NULL, + price_e8s INTEGER NOT NULL, + listing_revision INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL +); +``` + +`order_id` は canister 側生成にする。 +購入 API が ledger を呼ばないため、重複購入は entitlement existence で判定する。 + +## Pending Operations + +MVP では `kinic_pending_operations` を deposit の曖昧結果に使う。 +purchase は外部 ledger を呼ばないため pending operation を持たない。 + +構造は既存 `database_cycle_pending_operations` と同じ考え方にする。 +既存 table は DB cycles 専用であり、KINIC 内部 balance には流用しない。 + +```sql +CREATE TABLE kinic_pending_operations ( + operation_id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + caller TEXT NOT NULL, + amount_e8s INTEGER NOT NULL, + from_owner TEXT, + from_subaccount BLOB, + to_owner TEXT, + to_subaccount BLOB, + ledger_fee_e8s INTEGER, + operation_status TEXT NOT NULL, + external_block_index INTEGER, + ledger_created_at_time_ns INTEGER, + created_at_ms INTEGER NOT NULL +); +``` + +`operation_status` は `in_flight`, `ambiguous`, `completed`。 +MVP では repair/cancel method は作らず、caller と billing authority が pending 状態を確認できる query を用意する。 +withdraw PR では repair/cancel method も同時に実装する。 + +## API + +すべて `market_` prefix を付ける。 + +MVP: + +- `market_get_balance` +- `market_deposit_balance` +- `market_create_listing` +- `market_update_listing` +- `market_publish_listing` +- `market_pause_listing` +- `market_list_listings` +- `market_list_database_listings` +- `market_get_listing` +- `market_preview_purchase` +- `market_purchase_access` +- `market_list_entitlements` +- `market_list_orders` +- `market_count_active_entitlements` +- `market_list_pending_operations` + +`market_list_pending_operations` は `kinic_pending_operations` のうち marketplace deposit に関係する pending だけを返す。 + +MVP では作らない: + +- `market_withdraw_balance` +- `market_repair_withdraw_complete` +- `market_repair_withdraw_cancel` +- `market_refund_purchase` + +後続候補: + +- `market_list_seller_listings` +- `market_report_listing` +- `market_update_summary` +- platform fee 設定 + +## UI + +最初の画面は 4 つにする。 + +- Marketplace: listing 一覧 +- Listing detail: preview と purchase +- My purchases: 購入済み DB 一覧 +- Market wallet: deposit、balance、履歴 + +seller 操作は既存 DB 管理画面に追加する。 + +- Sell this DB +- Edit listing +- Publish listing +- Pause listing +- Delete/archive warning + +## Delete And Archive Rule + +active entitlement がある DB でも owner は delete/archive できる。 + +理由: + +- 著作権と DB 管理権は作者・owner 側に残す。 +- marketplace entitlement は Reader 相当の閲覧権であり、owner 権限を奪わない。 +- refund や escrow は MVP に入れない。 + +UI/CLI は delete/archive 前に active entitlement 数を表示し、購入者が影響を受けることを警告する。 +canister API は既存 owner 権限を維持する。 + +DB delete 後は listing/order/entitlement rows を削除する。 + +## Abuse Controls + +MVP は DB owner が listing を作成できる。 +seller 制限 table は作らない。 + +必要な後続管理操作: + +- listing pause +- abusive listing delist +- report count display + +## Open Questions + +- price は seller 入力か、初期は固定価格か。 +- purchase count と report count を公開するか。 +- LLM summary generation は Worker 経由か、seller 手書きだけで始めるか。 +- active entitlement が残る DB の rename は許可するか。 +- seller cashout を後続で入れる場合の outgoing transfer repair 方式。 +- seller cashout を入れる前に seller 内部 balance を何に使えるようにするか。 + +## Implementation Order + +1. `market_` public types を `vfs_types` に追加する。 +2. index schema に `kinic_accounts`、`kinic_ledger`、`kinic_pending_operations`、listing、order、entitlement を追加する。 +3. canister runtime に deposit transfer_from、balance credit、listing CRUD を追加する。 +4. purchase なしで entitlement seed helper を作り、read surface の認可テストを追加する。 +5. read/list/search/graph 認可に entitlement 判定を追加する。 +6. market purchase transaction を追加する。 +7. delete/archive の UI/CLI warning を追加する。canister guard は追加しない。 +8. CLI に最小 market commands を追加する。 +9. WikiBrowser に Marketplace、Listing detail、My purchases、Market wallet を追加する。 +10. IDL generator、hand-written TS IDL、Rust client、candid shape tests を同期する。 +11. unit tests と e2e smoke を追加する。 + +## Verification + +最低限の検証: + +- DB owner は listing 作成可能。 +- DB owner 以外は listing 作成不可。 +- listing publish 後、anonymous は preview だけ読める。 +- 未購入 buyer は DB 本文を読めない。 +- `market_deposit_balance` は pending operation を作り、ledger success 後だけ buyer balance を credit する。 +- ledger `Duplicate` は同一 pending operation の success として扱う。 +- purchase は外部 ledger を呼ばず、buyer balance を debit し seller balance を credit する。 +- purchase 成功後、buyer は read/list/search/graph できる。 +- purchase entitlement では write できない。 +- owner revoke は entitlement に影響しない。 +- 永続 entitlement は期限切れしない。 +- DB 更新後、listing は stale 警告を表示するが購入可能。 +- sample excerpt は listing metadata から返り、DB 本文 API を匿名に開かない。 +- `export_snapshot` と `fetch_updates` は entitlement では拒否される。 +- seller が対象 DB owner であることは create/update/publish/purchase 時に再確認される。 +- active entitlement があっても owner は delete/archive できる。 +- UI/CLI は delete/archive 前に購入者影響を警告する。 diff --git a/crates/vfs_canister/src/lib.rs b/crates/vfs_canister/src/lib.rs index 2916e43..4e2786a 100644 --- a/crates/vfs_canister/src/lib.rs +++ b/crates/vfs_canister/src/lib.rs @@ -36,7 +36,7 @@ use ic_stable_structures::memory_manager::{MemoryId, MemoryManager}; use vfs_runtime::STORAGE_BILLING_INTERVAL_MS; use vfs_runtime::{ CyclesPendingLedgerDetailsInput, DatabaseCyclesPurchaseWithLedgerDetails, DatabaseMeta, - RequiredRole, VfsService, cycles_for_payment_amount_e8s, + KinicDepositWithLedgerDetails, RequiredRole, VfsService, cycles_for_payment_amount_e8s, }; use vfs_types::{ AppendNodeRequest, CanisterHealth, CanonicalRole, ChildNode, CreateDatabaseRequest, @@ -47,14 +47,17 @@ use vfs_types::{ DeleteNodeRequest, DeleteNodeResult, EditNodeRequest, EditNodeResult, ExportSnapshotRequest, ExportSnapshotResponse, FetchUpdatesRequest, FetchUpdatesResponse, GlobNodeHit, GlobNodesRequest, GraphLinksRequest, GraphNeighborhoodRequest, IncomingLinksRequest, - IndexSqlJsonQueryResult, KINIC_DECIMALS, KINIC_LEDGER_FEE_E8S, LinkEdge, ListChildrenRequest, - ListNodesRequest, MemoryCapability, MemoryManifest, MemoryRoot, MkdirNodeRequest, - MkdirNodeResult, MoveNodeRequest, MoveNodeResult, MultiEditNodeRequest, MultiEditNodeResult, - Node, NodeContext, NodeContextRequest, NodeEntry, OpsAnswerSessionCheckRequest, - OpsAnswerSessionCheckResult, OpsAnswerSessionRequest, OutgoingLinksRequest, QueryContext, - QueryContextRequest, RenameDatabaseRequest, SearchNodeHit, SearchNodePathsRequest, - SearchNodesRequest, SourceEvidence, SourceEvidenceRequest, SourceRunSessionCheckRequest, - Status, StorageBillingBatchRequest, StorageBillingBatchResult, + IndexSqlJsonQueryResult, KINIC_DECIMALS, KINIC_LEDGER_FEE_E8S, KinicBalance, + KinicPendingOperation, LinkEdge, ListChildrenRequest, ListNodesRequest, + MarketCreateListingRequest, MarketDepositRequest, MarketDepositResult, MarketEntitlementPage, + MarketListing, MarketListingPage, MarketOrder, MarketOrderPage, MarketPurchasePreview, + MarketPurchaseRequest, MarketUpdateListingRequest, MemoryCapability, MemoryManifest, + MemoryRoot, MkdirNodeRequest, MkdirNodeResult, MoveNodeRequest, MoveNodeResult, + MultiEditNodeRequest, MultiEditNodeResult, Node, NodeContext, NodeContextRequest, NodeEntry, + OpsAnswerSessionCheckRequest, OpsAnswerSessionCheckResult, OpsAnswerSessionRequest, + OutgoingLinksRequest, QueryContext, QueryContextRequest, RenameDatabaseRequest, SearchNodeHit, + SearchNodePathsRequest, SearchNodesRequest, SourceEvidence, SourceEvidenceRequest, + SourceRunSessionCheckRequest, Status, StorageBillingBatchRequest, StorageBillingBatchResult, UrlIngestTriggerSessionCheckRequest, UrlIngestTriggerSessionRequest, WriteNodeRequest, WriteNodeResult, WriteNodesRequest, WriteSourceForGenerationRequest, WriteSourceForGenerationResult, kinic_base_units_per_token, @@ -709,6 +712,209 @@ fn list_database_cycles_pending_purchases( }) } +#[query] +fn market_get_balance() -> Result { + with_service(|service| service.market_get_balance(&caller_text())) +} + +#[update] +async fn market_deposit_balance( + request: MarketDepositRequest, +) -> Result { + require_authenticated_caller()?; + let caller = caller_text(); + let now = now_millis(); + 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 ledger_created_at_time_ns = now_nanos(); + let canister_owner = canister_principal().to_text(); + let deposit_start = with_service(|service| { + service.begin_kinic_deposit_with_ledger_details(KinicDepositWithLedgerDetails { + caller: &caller, + amount_e8s: request.amount_e8s, + ledger: CyclesPendingLedgerDetailsInput { + from_owner: &caller, + from_subaccount: None, + to_owner: &canister_owner, + to_subaccount: None, + ledger_fee_e8s: request.expected_fee_e8s, + ledger_created_at_time_ns, + }, + now, + }) + })?; + let operation_id = deposit_start.operation_id; + match ledger_transfer_from_with_memo( + ledger, + IcrcAccount { + owner: caller_principal(), + subaccount: None, + }, + IcrcAccount { + owner: canister_principal(), + subaccount: None, + }, + request.amount_e8s, + request.expected_fee_e8s, + market_deposit_memo(operation_id), + ledger_created_at_time_ns, + ) + .await + { + LedgerTransferFromOutcome::Completed(block_index) => { + if let Err(error) = with_service(|service| { + service.complete_kinic_deposit_ledger_transfer( + operation_id, + &caller, + request.amount_e8s, + block_index, + ) + }) { + return Err(market_deposit_local_apply_error( + operation_id, + block_index, + error, + )); + } + let balance = with_service(|service| { + service.apply_kinic_deposit(operation_id, &caller, request.amount_e8s, now) + }) + .map_err(|error| market_deposit_local_apply_error(operation_id, block_index, error))?; + Ok(MarketDepositResult { + block_index, + amount_e8s: deposit_start.amount_e8s, + balance_e8s: balance.balance_e8s, + }) + } + LedgerTransferFromOutcome::BadFee { expected_fee_e8s } => { + let _ = with_service(|service| { + service.cancel_kinic_deposit_after_ledger_error( + operation_id, + &caller, + request.amount_e8s, + ) + }); + Err(format!( + "icrc2_transfer_from failed: BadFee expected fee {expected_fee_e8s}; re-approve with the current ledger fee and retry" + )) + } + LedgerTransferFromOutcome::LedgerErr(error) => { + let _ = with_service(|service| { + service.cancel_kinic_deposit_after_ledger_error( + operation_id, + &caller, + request.amount_e8s, + ) + }); + Err(error) + } + LedgerTransferFromOutcome::Ambiguous(error) => { + if let Err(mark_error) = with_service(|service| { + service.mark_kinic_deposit_ambiguous(operation_id, &caller, request.amount_e8s) + }) { + return Err(format!( + "icrc2_transfer_from result ambiguous for market deposit operation_id {operation_id}; failed to mark operation ambiguous; billing authority review required: {mark_error}; original ledger ambiguity: {error}" + )); + } + Err(format!( + "icrc2_transfer_from result ambiguous for market deposit operation_id {operation_id}; billing authority review required: {error}" + )) + } + } +} + +fn market_deposit_local_apply_error(operation_id: u64, block_index: u64, cause: String) -> String { + format!( + "market deposit completed at ledger block {block_index} but local balance application failed; pending operation {operation_id} remains completed for billing authority review: {cause}" + ) +} + +#[query] +fn market_list_pending_operations() -> Result, String> { + with_service(|service| service.market_list_pending_operations(&caller_text())) +} + +#[update] +fn market_create_listing(request: MarketCreateListingRequest) -> Result { + require_authenticated_caller()?; + with_unmetered_update( + "market_create_listing", + Some(request.database_id.clone()), + |service, caller, now| service.market_create_listing(caller, request, now), + ) +} + +#[update] +fn market_update_listing(request: MarketUpdateListingRequest) -> Result { + require_authenticated_caller()?; + with_unmetered_update("market_update_listing", None, |service, caller, now| { + service.market_update_listing(caller, request, now) + }) +} + +#[update] +fn market_publish_listing(listing_id: String) -> Result { + require_authenticated_caller()?; + with_unmetered_update("market_publish_listing", None, |service, caller, now| { + service.market_publish_listing(caller, &listing_id, now) + }) +} + +#[update] +fn market_pause_listing(listing_id: String) -> Result { + require_authenticated_caller()?; + with_unmetered_update("market_pause_listing", None, |service, caller, now| { + service.market_pause_listing(caller, &listing_id, now) + }) +} + +#[query] +fn market_list_listings(cursor: Option, limit: u32) -> Result { + with_service(|service| service.market_list_listings(cursor, limit)) +} + +#[query] +fn market_list_database_listings(database_id: String) -> Result, String> { + with_service(|service| service.market_list_database_listings(&caller_text(), &database_id)) +} + +#[query] +fn market_get_listing(listing_id: String) -> Result { + with_service(|service| service.market_get_listing(&caller_text(), &listing_id)) +} + +#[query] +fn market_preview_purchase(listing_id: String) -> Result { + with_service(|service| service.market_preview_purchase(&caller_text(), &listing_id)) +} + +#[update] +fn market_purchase_access(request: MarketPurchaseRequest) -> Result { + require_authenticated_caller()?; + with_unmetered_update("market_purchase_access", None, |service, caller, now| { + service.market_purchase_access(caller, request, now) + }) +} + +#[query] +fn market_list_entitlements( + cursor: Option, + limit: u32, +) -> Result { + with_service(|service| service.market_list_entitlements(&caller_text(), cursor, limit)) +} + +#[query] +fn market_list_orders(cursor: Option, limit: u32) -> Result { + with_service(|service| service.market_list_orders(&caller_text(), cursor, limit)) +} + +#[query] +fn market_count_active_entitlements(database_id: String) -> Result { + with_service(|service| service.market_count_active_entitlements(&caller_text(), &database_id)) +} + #[update] fn update_cycles_billing_config(update: CyclesBillingConfigUpdate) -> Result<(), String> { require_authenticated_caller()?; @@ -1495,6 +1701,27 @@ async fn ledger_transfer_from( created_at_time_ns: u64, ) -> LedgerTransferFromOutcome { let memo = cycles_purchase_memo(operation_id); + ledger_transfer_from_with_memo( + ledger, + from, + to, + amount_e8s, + ledger_fee_e8s, + memo, + created_at_time_ns, + ) + .await +} + +async fn ledger_transfer_from_with_memo( + ledger: Principal, + from: IcrcAccount, + to: IcrcAccount, + amount_e8s: u64, + ledger_fee_e8s: u64, + memo: Vec, + created_at_time_ns: u64, +) -> LedgerTransferFromOutcome { #[cfg(test)] { record_test_ledger_from(&from); @@ -1581,6 +1808,10 @@ fn cycles_purchase_memo(operation_id: u64) -> Vec { format!("kvfs:cp:{operation_id}").into_bytes() } +fn market_deposit_memo(operation_id: u64) -> Vec { + format!("kvfs:md:{operation_id}").into_bytes() +} + fn with_unmetered_update(method: &str, database_id: Option, f: F) -> Result where F: FnOnce(&VfsService, &str, i64) -> Result, diff --git a/crates/vfs_canister/vfs.did b/crates/vfs_canister/vfs.did index 75b4b3e..d2b564d 100644 --- a/crates/vfs_canister/vfs.did +++ b/crates/vfs_canister/vfs.did @@ -231,6 +231,17 @@ type IndexSqlJsonQueryResult = record { row_count : nat32; limit : nat32; }; +type KinicBalance = record { balance_e8s : nat64 }; +type KinicPendingOperation = record { + status : text; + kind : text; + operation_id : nat64; + created_at_ms : int64; + amount_e8s : nat64; + required_action : text; + ledger_block_index : opt nat64; + caller : text; +}; type OutgoingLinksRequest = record { path : text; limit : nat32; database_id : text }; type LinkEdge = record { updated_at : int64; @@ -246,6 +257,100 @@ type ListNodesRequest = record { database_id : text; prefix : text; }; +type MarketCreateListingRequest = record { + llm_summary : opt text; + title : text; + summary_snapshot_revision : opt text; + sample_questions_json : text; + description : text; + database_id : text; + price_e8s : nat64; + sample_excerpts_json : text; + tags_json : text; +}; +type MarketDepositRequest = record { + amount_e8s : nat64; + expected_fee_e8s : nat64; +}; +type MarketDepositResult = record { + block_index : nat64; + amount_e8s : nat64; + balance_e8s : nat64; +}; +type MarketEntitlement = record { + status : text; + purchased_at_ms : int64; + database_id : text; + buyer_principal : text; + order_id : text; + listing_id : text; +}; +type MarketEntitlementPage = record { + next_cursor : opt text; + entitlements : vec MarketEntitlement; +}; +type MarketListing = record { + status : MarketListingStatus; + llm_summary : opt text; + title : text; + summary_snapshot_revision : opt text; + report_count : nat64; + sample_questions_json : text; + description : text; + updated_at_ms : int64; + created_at_ms : int64; + seller_principal : text; + purchase_count : nat64; + database_id : text; + listing_id : text; + price_e8s : nat64; + revision : nat64; + sample_excerpts_json : text; + tags_json : text; +}; +type MarketListingPage = record { + listings : vec MarketListing; + next_cursor : opt text; +}; +type MarketListingStatus = variant { Paused; Active; Draft }; +type MarketOrder = record { + created_at_ms : int64; + seller_principal : text; + database_id : text; + buyer_principal : text; + order_id : text; + listing_id : text; + price_e8s : nat64; + listing_revision : nat64; +}; +type MarketOrderPage = record { + orders : vec MarketOrder; + next_cursor : opt text; +}; +type MarketPurchasePreview = record { + already_entitled : bool; + buyer_balance_e8s : nat64; + database_id : text; + listing_id : text; + price_e8s : nat64; +}; +type MarketPurchaseRequest = record { + listing_id : text; + price_e8s : nat64; + expected_revision : nat64; +}; +type MarketUpdateListingRequest = record { + llm_summary : opt text; + title : text; + summary_snapshot_revision : opt text; + sample_questions_json : text; + description : text; + listing_id : text; + price_e8s : nat64; + expected_revision : nat64; + sample_excerpts_json : text; + tags_json : text; +}; type MemoryCapability = record { name : text; description : text }; type MemoryManifest = record { recommended_entrypoint : text; @@ -348,22 +453,33 @@ type Result_14 = variant { Ok : vec DatabaseCyclesPendingPurchase; 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_18 = variant { Ok : nat64; Err : text }; +type Result_19 = variant { Ok : MarketListing; Err : text }; type Result_2 = variant { Ok : DatabaseArchiveInfo; Err : text }; -type Result_20 = variant { Ok : CyclesPurchaseResult; Err : text }; -type Result_21 = variant { Ok : QueryContext; Err : text }; -type Result_22 = variant { Ok : IndexSqlJsonQueryResult; Err : text }; -type Result_23 = variant { Ok : DatabaseArchiveChunk; Err : text }; -type Result_24 = variant { Ok : opt Node; Err : text }; -type Result_25 = variant { Ok : opt NodeContext; Err : text }; -type Result_26 = variant { Ok : vec SearchNodeHit; Err : text }; -type Result_27 = variant { Ok : StorageBillingBatchResult; Err : text }; -type Result_28 = variant { Ok : SourceEvidence; Err : text }; -type Result_29 = variant { Ok : vec WriteNodeResult; Err : text }; +type Result_20 = variant { Ok : MarketDepositResult; Err : text }; +type Result_21 = variant { Ok : KinicBalance; Err : text }; +type Result_22 = variant { Ok : vec MarketListing; Err : text }; +type Result_23 = variant { Ok : MarketEntitlementPage; Err : text }; +type Result_24 = variant { Ok : MarketListingPage; Err : text }; +type Result_25 = variant { Ok : MarketOrderPage; Err : text }; +type Result_26 = variant { Ok : vec KinicPendingOperation; Err : text }; +type Result_27 = variant { Ok : MarketPurchasePreview; Err : text }; +type Result_28 = variant { Ok : MarketOrder; Err : text }; +type Result_29 = variant { Ok : MkdirNodeResult; Err : text }; type Result_3 = variant { Ok : OpsAnswerSessionCheckResult; Err : text }; -type Result_30 = variant { Ok : WriteSourceForGenerationResult; Err : text }; +type Result_30 = variant { Ok : MoveNodeResult; Err : text }; +type Result_31 = variant { Ok : CyclesPurchaseResult; Err : text }; +type Result_32 = variant { Ok : QueryContext; Err : text }; +type Result_33 = variant { Ok : IndexSqlJsonQueryResult; Err : text }; +type Result_34 = variant { Ok : DatabaseArchiveChunk; Err : text }; +type Result_35 = variant { Ok : opt Node; Err : text }; +type Result_36 = variant { Ok : opt NodeContext; Err : text }; +type Result_37 = variant { Ok : vec SearchNodeHit; Err : text }; +type Result_38 = variant { Ok : StorageBillingBatchResult; Err : text }; +type Result_39 = variant { Ok : SourceEvidence; Err : text }; type Result_4 = variant { Ok : CreateDatabaseResult; Err : text }; +type Result_40 = variant { Ok : vec WriteNodeResult; Err : text }; +type Result_41 = variant { Ok : WriteSourceForGenerationResult; Err : text }; type Result_5 = variant { Ok : DeleteNodeResult; Err : text }; type Result_6 = variant { Ok : EditNodeResult; Err : text }; type Result_7 = variant { Ok : ExportSnapshotResponse; Err : text }; @@ -508,31 +624,46 @@ service : (CyclesBillingConfig) -> { list_database_members : (text) -> (Result_15) query; list_databases : () -> (Result_16) query; list_nodes : (ListNodesRequest) -> (Result_17) query; + market_count_active_entitlements : (text) -> (Result_18) query; + market_create_listing : (MarketCreateListingRequest) -> (Result_19); + market_deposit_balance : (MarketDepositRequest) -> (Result_20); + market_get_balance : () -> (Result_21) query; + market_get_listing : (text) -> (Result_19) query; + market_list_database_listings : (text) -> (Result_22) query; + market_list_entitlements : (opt text, nat32) -> (Result_23) query; + market_list_listings : (opt text, nat32) -> (Result_24) query; + market_list_orders : (opt text, nat32) -> (Result_25) query; + market_list_pending_operations : () -> (Result_26) query; + market_pause_listing : (text) -> (Result_19); + market_preview_purchase : (text) -> (Result_27) query; + market_publish_listing : (text) -> (Result_19); + market_purchase_access : (MarketPurchaseRequest) -> (Result_28); + market_update_listing : (MarketUpdateListingRequest) -> (Result_19); memory_manifest : () -> (MemoryManifest) query; - mkdir_node : (MkdirNodeRequest) -> (Result_18); - move_node : (MoveNodeRequest) -> (Result_19); + mkdir_node : (MkdirNodeRequest) -> (Result_29); + move_node : (MoveNodeRequest) -> (Result_30); multi_edit_node : (MultiEditNodeRequest) -> (Result_6); outgoing_links : (OutgoingLinksRequest) -> (Result_11) query; - purchase_database_cycles : (DatabaseCyclesPurchaseRequest) -> (Result_20); - query_context : (QueryContextRequest) -> (Result_21) query; - query_index_sql_json : (text, nat32) -> (Result_22) query; - read_database_archive_chunk : (text, nat64, nat32) -> (Result_23) query; - read_node : (text, text) -> (Result_24) query; - read_node_context : (NodeContextRequest) -> (Result_25) query; + purchase_database_cycles : (DatabaseCyclesPurchaseRequest) -> (Result_31); + query_context : (QueryContextRequest) -> (Result_32) query; + query_index_sql_json : (text, nat32) -> (Result_33) query; + read_database_archive_chunk : (text, nat64, nat32) -> (Result_34) query; + read_node : (text, text) -> (Result_35) query; + read_node_context : (NodeContextRequest) -> (Result_36) query; rename_database : (RenameDatabaseRequest) -> (Result_1); revoke_database_access : (text, text) -> (Result_1); - search_node_paths : (SearchNodePathsRequest) -> (Result_26) query; - search_nodes : (SearchNodesRequest) -> (Result_26) query; + search_node_paths : (SearchNodePathsRequest) -> (Result_37) query; + search_nodes : (SearchNodesRequest) -> (Result_37) query; settle_database_storage_charges_batch : (StorageBillingBatchRequest) -> ( - Result_27, + Result_38, ); - source_evidence : (SourceEvidenceRequest) -> (Result_28) query; + source_evidence : (SourceEvidenceRequest) -> (Result_39) query; status : (text) -> (Status) query; update_cycles_billing_config : (CyclesBillingConfigUpdate) -> (Result_1); write_database_restore_chunk : (DatabaseRestoreChunkRequest) -> (Result_1); write_node : (WriteNodeRequest) -> (Result); - write_nodes : (WriteNodesRequest) -> (Result_29); + write_nodes : (WriteNodesRequest) -> (Result_40); write_source_for_generation : (WriteSourceForGenerationRequest) -> ( - Result_30, + Result_41, ); -} +} \ No newline at end of file diff --git a/crates/vfs_client/src/lib.rs b/crates/vfs_client/src/lib.rs index db26029..e296826 100644 --- a/crates/vfs_client/src/lib.rs +++ b/crates/vfs_client/src/lib.rs @@ -18,7 +18,10 @@ use vfs_types::{ DeleteDatabaseRequest, DeleteNodeRequest, DeleteNodeResult, EditNodeRequest, EditNodeResult, ExportSnapshotRequest, ExportSnapshotResponse, FetchUpdatesRequest, FetchUpdatesResponse, GlobNodeHit, GlobNodesRequest, GraphLinksRequest, GraphNeighborhoodRequest, - IncomingLinksRequest, LinkEdge, ListChildrenRequest, ListNodesRequest, MemoryManifest, + IncomingLinksRequest, KinicBalance, KinicPendingOperation, LinkEdge, ListChildrenRequest, + ListNodesRequest, MarketCreateListingRequest, MarketDepositRequest, MarketDepositResult, + MarketEntitlementPage, MarketListing, MarketListingPage, MarketOrder, MarketOrderPage, + MarketPurchasePreview, MarketPurchaseRequest, MarketUpdateListingRequest, MemoryManifest, MkdirNodeRequest, MkdirNodeResult, MoveNodeRequest, MoveNodeResult, MultiEditNodeRequest, MultiEditNodeResult, Node, NodeContext, NodeContextRequest, NodeEntry, OutgoingLinksRequest, QueryContext, QueryContextRequest, RenameDatabaseRequest, SearchNodeHit, @@ -76,6 +79,105 @@ pub trait VfsApi: Sync { "list_database_cycles_pending_purchases is not implemented by this client" )) } + async fn market_get_balance(&self) -> Result { + Err(anyhow!( + "market_get_balance is not implemented by this client" + )) + } + async fn market_deposit_balance( + &self, + _request: MarketDepositRequest, + ) -> Result { + Err(anyhow!( + "market_deposit_balance is not implemented by this client" + )) + } + async fn market_list_pending_operations(&self) -> Result> { + Err(anyhow!( + "market_list_pending_operations is not implemented by this client" + )) + } + async fn market_create_listing( + &self, + _request: MarketCreateListingRequest, + ) -> Result { + Err(anyhow!( + "market_create_listing is not implemented by this client" + )) + } + async fn market_update_listing( + &self, + _request: MarketUpdateListingRequest, + ) -> Result { + Err(anyhow!( + "market_update_listing is not implemented by this client" + )) + } + async fn market_publish_listing(&self, _listing_id: &str) -> Result { + Err(anyhow!( + "market_publish_listing is not implemented by this client" + )) + } + async fn market_pause_listing(&self, _listing_id: &str) -> Result { + Err(anyhow!( + "market_pause_listing is not implemented by this client" + )) + } + async fn market_list_listings( + &self, + _cursor: Option, + _limit: u32, + ) -> Result { + Err(anyhow!( + "market_list_listings is not implemented by this client" + )) + } + async fn market_list_database_listings( + &self, + _database_id: &str, + ) -> Result> { + Err(anyhow!( + "market_list_database_listings is not implemented by this client" + )) + } + async fn market_get_listing(&self, _listing_id: &str) -> Result { + Err(anyhow!( + "market_get_listing is not implemented by this client" + )) + } + async fn market_preview_purchase(&self, _listing_id: &str) -> Result { + Err(anyhow!( + "market_preview_purchase is not implemented by this client" + )) + } + async fn market_purchase_access(&self, _request: MarketPurchaseRequest) -> Result { + Err(anyhow!( + "market_purchase_access is not implemented by this client" + )) + } + async fn market_list_entitlements( + &self, + _cursor: Option, + _limit: u32, + ) -> Result { + Err(anyhow!( + "market_list_entitlements is not implemented by this client" + )) + } + async fn market_list_orders( + &self, + _cursor: Option, + _limit: u32, + ) -> Result { + Err(anyhow!( + "market_list_orders is not implemented by this client" + )) + } + async fn market_count_active_entitlements(&self, _database_id: &str) -> Result { + Err(anyhow!( + "market_count_active_entitlements is not implemented by this client" + )) + } async fn get_cycles_billing_config(&self) -> Result { Err(anyhow!( "get_cycles_billing_config is not implemented by this client" @@ -493,6 +595,123 @@ impl VfsApi for CanisterVfsClient { result.map_err(|error| anyhow!(error)) } + async fn market_get_balance(&self) -> Result { + let result: Result = self.query("market_get_balance", &()).await?; + result.map_err(|error| anyhow!(error)) + } + + async fn market_deposit_balance( + &self, + request: MarketDepositRequest, + ) -> Result { + let result: Result = + self.update("market_deposit_balance", &request).await?; + result.map_err(|error| anyhow!(error)) + } + + async fn market_list_pending_operations(&self) -> Result> { + let result: Result, String> = + self.query("market_list_pending_operations", &()).await?; + result.map_err(|error| anyhow!(error)) + } + + async fn market_create_listing( + &self, + request: MarketCreateListingRequest, + ) -> Result { + let result: Result = + self.update("market_create_listing", &request).await?; + result.map_err(|error| anyhow!(error)) + } + + async fn market_update_listing( + &self, + request: MarketUpdateListingRequest, + ) -> Result { + let result: Result = + self.update("market_update_listing", &request).await?; + result.map_err(|error| anyhow!(error)) + } + + async fn market_publish_listing(&self, listing_id: &str) -> Result { + let result: Result = self + .update("market_publish_listing", &listing_id.to_string()) + .await?; + result.map_err(|error| anyhow!(error)) + } + + async fn market_pause_listing(&self, listing_id: &str) -> Result { + let result: Result = self + .update("market_pause_listing", &listing_id.to_string()) + .await?; + result.map_err(|error| anyhow!(error)) + } + + async fn market_list_listings( + &self, + cursor: Option, + limit: u32, + ) -> Result { + let result: Result = + self.query2("market_list_listings", &cursor, &limit).await?; + result.map_err(|error| anyhow!(error)) + } + + async fn market_list_database_listings(&self, database_id: &str) -> Result> { + let result: Result, String> = self + .query("market_list_database_listings", &database_id.to_string()) + .await?; + result.map_err(|error| anyhow!(error)) + } + + async fn market_get_listing(&self, listing_id: &str) -> Result { + let result: Result = self + .query("market_get_listing", &listing_id.to_string()) + .await?; + result.map_err(|error| anyhow!(error)) + } + + async fn market_preview_purchase(&self, listing_id: &str) -> Result { + let result: Result = self + .query("market_preview_purchase", &listing_id.to_string()) + .await?; + result.map_err(|error| anyhow!(error)) + } + + async fn market_purchase_access(&self, request: MarketPurchaseRequest) -> Result { + let result: Result = + self.update("market_purchase_access", &request).await?; + result.map_err(|error| anyhow!(error)) + } + + async fn market_list_entitlements( + &self, + cursor: Option, + limit: u32, + ) -> Result { + let result: Result = self + .query2("market_list_entitlements", &cursor, &limit) + .await?; + result.map_err(|error| anyhow!(error)) + } + + async fn market_list_orders( + &self, + cursor: Option, + limit: u32, + ) -> Result { + let result: Result = + self.query2("market_list_orders", &cursor, &limit).await?; + result.map_err(|error| anyhow!(error)) + } + + async fn market_count_active_entitlements(&self, database_id: &str) -> Result { + let result: Result = self + .query("market_count_active_entitlements", &database_id.to_string()) + .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 40810db..7eb4485 100644 --- a/crates/vfs_runtime/migrations/index_db/011_to_latest.sql +++ b/crates/vfs_runtime/migrations/index_db/011_to_latest.sql @@ -61,6 +61,108 @@ CREATE TABLE storage_billing_state ( CHECK (key = 'timer') ); +CREATE TABLE kinic_accounts ( + principal TEXT PRIMARY KEY, + balance_e8s INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +); + +CREATE TABLE kinic_ledger ( + entry_id INTEGER PRIMARY KEY AUTOINCREMENT, + principal TEXT NOT NULL, + source TEXT NOT NULL, + kind TEXT NOT NULL, + amount_e8s INTEGER NOT NULL, + balance_after_e8s INTEGER NOT NULL, + counterparty TEXT, + listing_id TEXT, + order_id TEXT, + external_block_index INTEGER, + created_at_ms INTEGER NOT NULL +); + +CREATE INDEX kinic_ledger_principal_idx + ON kinic_ledger(principal, entry_id); + +CREATE TABLE kinic_pending_operations ( + operation_id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + caller TEXT NOT NULL, + amount_e8s INTEGER NOT NULL, + from_owner TEXT, + from_subaccount BLOB, + to_owner TEXT, + to_subaccount BLOB, + ledger_fee_e8s INTEGER, + operation_status TEXT NOT NULL, + external_block_index INTEGER, + ledger_created_at_time_ns INTEGER, + created_at_ms INTEGER NOT NULL +); + +CREATE INDEX kinic_pending_operations_caller_idx + ON kinic_pending_operations(caller, operation_id); + +CREATE TABLE market_listings ( + listing_id TEXT PRIMARY KEY, + seller_principal TEXT NOT NULL, + database_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + llm_summary TEXT, + summary_snapshot_revision TEXT, + sample_excerpts_json TEXT NOT NULL, + sample_questions_json TEXT NOT NULL, + tags_json TEXT NOT NULL, + price_e8s INTEGER NOT NULL, + status TEXT NOT NULL, + revision INTEGER NOT NULL, + purchase_count INTEGER NOT NULL, + report_count INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + FOREIGN KEY (database_id) REFERENCES databases(database_id) +); + +CREATE INDEX market_listings_status_idx + ON market_listings(status, listing_id); + +CREATE INDEX market_listings_database_idx + ON market_listings(database_id); + +CREATE TABLE market_orders ( + order_id TEXT PRIMARY KEY, + listing_id TEXT NOT NULL, + database_id TEXT NOT NULL, + buyer_principal TEXT NOT NULL, + seller_principal TEXT NOT NULL, + price_e8s INTEGER NOT NULL, + listing_revision INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL +); + +CREATE INDEX market_orders_buyer_idx + ON market_orders(buyer_principal, order_id); + +CREATE TABLE market_entitlements ( + database_id TEXT NOT NULL, + buyer_principal TEXT NOT NULL, + listing_id TEXT NOT NULL, + order_id TEXT NOT NULL, + purchased_at_ms INTEGER NOT NULL, + status TEXT NOT NULL, + PRIMARY KEY (database_id, buyer_principal, listing_id), + FOREIGN KEY (database_id) REFERENCES databases(database_id) +); + +CREATE UNIQUE INDEX market_entitlements_database_buyer_active_idx + ON market_entitlements(database_id, buyer_principal) + WHERE status = 'active'; + +CREATE INDEX market_entitlements_buyer_idx + ON market_entitlements(buyer_principal, database_id); + INSERT INTO database_cycle_accounts (database_id, balance_cycles, suspended_at_ms, storage_charged_at_ms, created_at_ms, updated_at_ms) 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 fc65cc6..c3612c9 100644 --- a/crates/vfs_runtime/migrations/index_db/fresh_index_schema.sql +++ b/crates/vfs_runtime/migrations/index_db/fresh_index_schema.sql @@ -166,3 +166,105 @@ CREATE TABLE storage_billing_state ( updated_at_ms INTEGER NOT NULL, CHECK (key = 'timer') ); + +CREATE TABLE kinic_accounts ( + principal TEXT PRIMARY KEY, + balance_e8s INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +); + +CREATE TABLE kinic_ledger ( + entry_id INTEGER PRIMARY KEY AUTOINCREMENT, + principal TEXT NOT NULL, + source TEXT NOT NULL, + kind TEXT NOT NULL, + amount_e8s INTEGER NOT NULL, + balance_after_e8s INTEGER NOT NULL, + counterparty TEXT, + listing_id TEXT, + order_id TEXT, + external_block_index INTEGER, + created_at_ms INTEGER NOT NULL +); + +CREATE INDEX kinic_ledger_principal_idx + ON kinic_ledger(principal, entry_id); + +CREATE TABLE kinic_pending_operations ( + operation_id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + caller TEXT NOT NULL, + amount_e8s INTEGER NOT NULL, + from_owner TEXT, + from_subaccount BLOB, + to_owner TEXT, + to_subaccount BLOB, + ledger_fee_e8s INTEGER, + operation_status TEXT NOT NULL, + external_block_index INTEGER, + ledger_created_at_time_ns INTEGER, + created_at_ms INTEGER NOT NULL +); + +CREATE INDEX kinic_pending_operations_caller_idx + ON kinic_pending_operations(caller, operation_id); + +CREATE TABLE market_listings ( + listing_id TEXT PRIMARY KEY, + seller_principal TEXT NOT NULL, + database_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + llm_summary TEXT, + summary_snapshot_revision TEXT, + sample_excerpts_json TEXT NOT NULL, + sample_questions_json TEXT NOT NULL, + tags_json TEXT NOT NULL, + price_e8s INTEGER NOT NULL, + status TEXT NOT NULL, + revision INTEGER NOT NULL, + purchase_count INTEGER NOT NULL, + report_count INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + FOREIGN KEY (database_id) REFERENCES databases(database_id) +); + +CREATE INDEX market_listings_status_idx + ON market_listings(status, listing_id); + +CREATE INDEX market_listings_database_idx + ON market_listings(database_id); + +CREATE TABLE market_orders ( + order_id TEXT PRIMARY KEY, + listing_id TEXT NOT NULL, + database_id TEXT NOT NULL, + buyer_principal TEXT NOT NULL, + seller_principal TEXT NOT NULL, + price_e8s INTEGER NOT NULL, + listing_revision INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL +); + +CREATE INDEX market_orders_buyer_idx + ON market_orders(buyer_principal, order_id); + +CREATE TABLE market_entitlements ( + database_id TEXT NOT NULL, + buyer_principal TEXT NOT NULL, + listing_id TEXT NOT NULL, + order_id TEXT NOT NULL, + purchased_at_ms INTEGER NOT NULL, + status TEXT NOT NULL, + PRIMARY KEY (database_id, buyer_principal, listing_id), + FOREIGN KEY (database_id) REFERENCES databases(database_id) +); + +CREATE UNIQUE INDEX market_entitlements_database_buyer_active_idx + ON market_entitlements(database_id, buyer_principal) + WHERE status = 'active'; + +CREATE INDEX market_entitlements_buyer_idx + ON market_entitlements(buyer_principal, database_id); diff --git a/crates/vfs_runtime/src/lib.rs b/crates/vfs_runtime/src/lib.rs index b7cb787..e72e652 100644 --- a/crates/vfs_runtime/src/lib.rs +++ b/crates/vfs_runtime/src/lib.rs @@ -24,7 +24,10 @@ use vfs_types::{ DeleteDatabaseRequest, DeleteNodeRequest, DeleteNodeResult, EditNodeRequest, EditNodeResult, ExportSnapshotRequest, ExportSnapshotResponse, FetchUpdatesRequest, FetchUpdatesResponse, GlobNodeHit, GlobNodesRequest, GraphLinksRequest, GraphNeighborhoodRequest, - IncomingLinksRequest, IndexSqlJsonQueryResult, LinkEdge, ListChildrenRequest, ListNodesRequest, + IncomingLinksRequest, IndexSqlJsonQueryResult, KinicBalance, KinicPendingOperation, LinkEdge, + ListChildrenRequest, ListNodesRequest, MarketCreateListingRequest, MarketEntitlement, + MarketEntitlementPage, MarketListing, MarketListingPage, MarketListingStatus, MarketOrder, + MarketOrderPage, MarketPurchasePreview, MarketPurchaseRequest, MarketUpdateListingRequest, MkdirNodeRequest, MkdirNodeResult, MoveNodeRequest, MoveNodeResult, MultiEditNodeRequest, MultiEditNodeResult, Node, NodeContext, NodeContextRequest, NodeEntry, NodeKind, OpsAnswerSessionCheckRequest, OpsAnswerSessionCheckResult, OpsAnswerSessionRequest, @@ -71,6 +74,7 @@ const INDEX_SCHEMA_VERSION_DIRECT_CYCLES: &str = concat!("database_index:024_", const INDEX_SCHEMA_VERSION_CYCLES_PENDING_LEDGER_BLOCK_INDEX: &str = "database_index:025_cycles_pending_ledger_block_index"; const INDEX_SCHEMA_VERSION_STORAGE_BILLING_BATCH: &str = "database_index:026_storage_billing_batch"; +const INDEX_SCHEMA_VERSION_MARKETPLACE_CORE: &str = "database_index:027_marketplace_core"; const PENDING_DATABASE_MOUNT_ID: u16 = 0; const DATABASE_SCHEMA_VERSION: &str = "vfs_store:current"; const MIN_DATABASE_MOUNT_ID: u16 = 11; @@ -107,6 +111,21 @@ const ANONYMOUS_PRINCIPAL: &str = "2vxsx-fae"; const CYCLES_OPERATION_STATUS_IN_FLIGHT: &str = "in_flight"; const CYCLES_OPERATION_STATUS_COMPLETED: &str = "completed"; const CYCLES_OPERATION_STATUS_AMBIGUOUS: &str = "ambiguous"; +const KINIC_OPERATION_STATUS_IN_FLIGHT: &str = "in_flight"; +const KINIC_OPERATION_STATUS_COMPLETED: &str = "completed"; +const KINIC_OPERATION_STATUS_AMBIGUOUS: &str = "ambiguous"; +const KINIC_LEDGER_SOURCE_MARKETPLACE: &str = "marketplace"; +const KINIC_PENDING_KIND_DEPOSIT: &str = "deposit"; +const MARKET_LISTING_STATUS_DRAFT: &str = "draft"; +const MARKET_LISTING_STATUS_ACTIVE: &str = "active"; +const MARKET_LISTING_STATUS_PAUSED: &str = "paused"; +const MARKET_ENTITLEMENT_STATUS_ACTIVE: &str = "active"; +const GENERATED_LISTING_ID_PREFIX: &str = "listing_"; +const GENERATED_ORDER_ID_PREFIX: &str = "order_"; +const GENERATED_MARKET_ID_HASH_CHARS: usize = 16; +const MAX_MARKET_TITLE_CHARS: usize = 160; +const MAX_MARKET_DESCRIPTION_CHARS: usize = 4_000; +const MAX_MARKET_JSON_CHARS: usize = 20_000; #[derive(Clone, Debug, PartialEq, Eq)] pub struct DatabaseMeta { @@ -165,6 +184,19 @@ pub struct DatabaseCyclesPurchaseStart { pub amount_cycles: u64, } +pub struct KinicDepositWithLedgerDetails<'a> { + pub caller: &'a str, + pub amount_e8s: u64, + pub ledger: CyclesPendingLedgerDetailsInput<'a>, + pub now: i64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct KinicDepositStart { + pub operation_id: u64, + pub amount_e8s: u64, +} + #[derive(Clone, Debug, PartialEq, Eq)] struct RestoreChunk { offset: u64, @@ -601,6 +633,21 @@ impl VfsService { params![database_id], ) .map_err(|error| error.to_string())?; + tx.execute( + "DELETE FROM market_entitlements WHERE database_id = ?1", + params![database_id], + ) + .map_err(|error| error.to_string())?; + tx.execute( + "DELETE FROM market_orders WHERE database_id = ?1", + params![database_id], + ) + .map_err(|error| error.to_string())?; + tx.execute( + "DELETE FROM market_listings WHERE database_id = ?1", + params![database_id], + ) + .map_err(|error| error.to_string())?; tx.execute( "DELETE FROM database_members WHERE database_id = ?1", params![database_id], @@ -1050,196 +1097,912 @@ impl VfsService { }) } - pub fn require_database_write_cycles_available(&self, database_id: &str) -> Result<(), String> { + pub fn market_get_balance(&self, caller: &str) -> Result { + require_authenticated_principal(caller)?; self.read_index(|conn| { - let config = load_cycles_billing_config(conn)?; - require_database_write_cycles_available_for_conn(conn, database_id, &config) + Ok(KinicBalance { + balance_e8s: u64::try_from(load_kinic_balance(conn, caller)?) + .map_err(|error| error.to_string())?, + }) }) } - pub fn prepare_metered_update( + pub fn begin_kinic_deposit_with_ledger_details( &self, - database_id: &str, - caller: &str, - required_role: RequiredRole, - ) -> Result { - self.read_index(|conn| { - let role = load_database_status(conn, database_id).and_then(|_| { - load_member_role(conn, database_id, caller)? - .ok_or_else(|| format!("principal has no access to database: {database_id}")) - })?; - if !role_allows(role, required_role) { - return Err(format!( - "principal lacks required database role: {database_id}" - )); - } - let config = load_cycles_billing_config(conn)?; - require_database_write_cycles_available_for_conn(conn, database_id, &config)?; - Ok(config) + request: KinicDepositWithLedgerDetails<'_>, + ) -> Result { + require_authenticated_principal(request.caller)?; + if request.amount_e8s == 0 { + return Err("market deposit amount must be positive".to_string()); + } + let amount_e8s = amount_to_i64(request.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| { + ensure_no_pending_kinic_deposit_for_caller(tx, request.caller)?; + let operation_id = insert_pending_kinic_operation( + tx, + PendingKinicOperationInsert { + kind: KINIC_PENDING_KIND_DEPOSIT, + caller: request.caller, + amount_e8s, + ledger: PendingCyclesLedgerDetails { + from_owner: request.ledger.from_owner, + from_subaccount: request.ledger.from_subaccount, + to_owner: request.ledger.to_owner, + to_subaccount: request.ledger.to_subaccount, + ledger_fee_e8s: ledger_fee, + ledger_created_at_time_ns: ledger_created_at_time, + }, + operation_status: KINIC_OPERATION_STATUS_IN_FLIGHT, + now: request.now, + }, + )?; + Ok(KinicDepositStart { + operation_id, + amount_e8s: request.amount_e8s, + }) }) } - pub fn check_database_write_cycles( + pub fn complete_kinic_deposit_ledger_transfer( &self, - database_id: &str, + operation_id: u64, caller: &str, + amount_e8s: u64, + ledger_block_index: u64, ) -> Result<(), String> { - if caller == ANONYMOUS_PRINCIPAL { - return Err("anonymous caller not allowed".to_string()); - } - self.require_role(database_id, caller, RequiredRole::Writer)?; - self.require_database_write_cycles_available(database_id) + let amount_e8s = amount_to_i64(amount_e8s)?; + let ledger_block_index = i64::try_from(ledger_block_index) + .map_err(|_| "ledger block index exceeds i64".to_string())?; + self.write_index(|tx| { + let operation = load_required_pending_kinic_operation( + tx, + PendingKinicOperationMatch { + operation_id, + kind: KINIC_PENDING_KIND_DEPOSIT, + caller, + amount_e8s, + }, + )?; + require_pending_kinic_operation_status( + &operation, + &[KINIC_OPERATION_STATUS_IN_FLIGHT], + "complete market deposit ledger transfer", + )?; + let operation_id = i64::try_from(operation_id).map_err(|error| error.to_string())?; + tx.execute( + "UPDATE kinic_pending_operations + SET operation_status = ?2, + external_block_index = ?3 + WHERE operation_id = ?1", + params![ + operation_id, + KINIC_OPERATION_STATUS_COMPLETED, + ledger_block_index + ], + ) + .map_err(|error| error.to_string())?; + Ok(()) + }) } - pub fn charge_database_update( + pub fn apply_kinic_deposit( &self, - config: &CyclesBillingConfig, - database_id: &str, + operation_id: u64, caller: &str, - method: &str, - cycles_delta: u128, + amount_e8s: u64, now: i64, - ) -> Result<(), String> { - let computed_charge = compute_update_charge(cycles_delta)?; - if computed_charge == 0 { - return Ok(()); - } + ) -> Result { + let amount_i64 = amount_to_i64(amount_e8s)?; self.write_index(|tx| { - charge_database_update_in_tx( + let operation = load_required_pending_kinic_operation( tx, - DatabaseCharge { - database_id, + PendingKinicOperationMatch { + operation_id, + kind: KINIC_PENDING_KIND_DEPOSIT, caller, - method, - cycles_delta, + amount_e8s: amount_i64, + }, + )?; + require_pending_kinic_operation_status( + &operation, + &[KINIC_OPERATION_STATUS_COMPLETED], + "apply market deposit", + )?; + let block_index = operation + .external_block_index + .ok_or_else(|| "completed market deposit missing ledger block index".to_string())?; + let balance = load_kinic_balance(tx, caller)?; + let next_balance = checked_balance_add(balance, amount_i64)?; + upsert_kinic_balance(tx, caller, next_balance, now)?; + insert_kinic_ledger( + tx, + KinicLedgerInsert { + principal: caller, + source: KINIC_LEDGER_SOURCE_MARKETPLACE, + kind: "deposit", + amount_e8s: amount_i64, + balance_after_e8s: next_balance, + counterparty: None, + listing_id: None, + order_id: None, + external_block_index: Some(block_index), now, - config, - computed_charge, }, - ) + )?; + delete_pending_kinic_operation(tx, operation_id)?; + Ok(KinicBalance { + balance_e8s: u64::try_from(next_balance).map_err(|error| error.to_string())?, + }) }) } - pub fn run_database_migrations(&self, database_id: &str) -> Result<(), String> { - let meta = self.database_meta(database_id)?; - self.run_database_migrations_for_meta(database_id, &meta) - } - - pub fn run_pending_database_migrations(&self, database_id: &str) -> Result<(), String> { - let meta = self.database_meta_with_statuses(database_id, &[DatabaseStatus::Pending])?; - self.run_database_migrations_for_meta(database_id, &meta) - } - - fn run_database_migrations_for_meta( + pub fn mark_kinic_deposit_ambiguous( &self, - database_id: &str, - meta: &DatabaseMeta, + operation_id: u64, + caller: &str, + amount_e8s: u64, ) -> Result<(), String> { - #[cfg(not(target_arch = "wasm32"))] - if let Some(parent) = Path::new(&meta.db_file_name).parent() { - create_dir_all(parent).map_err(|error| error.to_string())?; - } - let result = self.database_store(meta)?.run_fs_migrations(); - if result.is_ok() { - let _ = self.refresh_logical_size(database_id); - } - result + let amount_i64 = amount_to_i64(amount_e8s)?; + self.write_index(|tx| { + let operation = load_required_pending_kinic_operation( + tx, + PendingKinicOperationMatch { + operation_id, + kind: KINIC_PENDING_KIND_DEPOSIT, + caller, + amount_e8s: amount_i64, + }, + )?; + require_pending_kinic_operation_status( + &operation, + &[KINIC_OPERATION_STATUS_IN_FLIGHT], + "mark market deposit ambiguous", + )?; + let operation_id = i64::try_from(operation_id).map_err(|error| error.to_string())?; + tx.execute( + "UPDATE kinic_pending_operations + SET operation_status = ?2 + WHERE operation_id = ?1", + params![operation_id, KINIC_OPERATION_STATUS_AMBIGUOUS], + ) + .map_err(|error| error.to_string())?; + Ok(()) + }) } - pub fn delete_database( + pub fn cancel_kinic_deposit_after_ledger_error( &self, - request: DeleteDatabaseRequest, + operation_id: u64, caller: &str, - _now: i64, + amount_e8s: u64, ) -> Result<(), String> { - let database_id = request.database_id.as_str(); - self.require_role(database_id, caller, RequiredRole::Owner)?; - 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!( - "database is {}: {database_id}", - status_to_db(status) - )); - } - let meta = self.database_meta(database_id).ok(); - #[cfg(target_arch = "wasm32")] - let _ = &meta; - #[cfg(not(target_arch = "wasm32"))] - if let Some(meta) = &meta - && let Err(error) = remove_file(&meta.db_file_name) - && error.kind() != std::io::ErrorKind::NotFound - { - return Err(error.to_string()); - } - self.write_index(|conn| { - delete_database_index_rows(conn, database_id)?; - Ok(()) + let amount_i64 = amount_to_i64(amount_e8s)?; + self.write_index(|tx| { + let operation = load_required_pending_kinic_operation( + tx, + PendingKinicOperationMatch { + operation_id, + kind: KINIC_PENDING_KIND_DEPOSIT, + caller, + amount_e8s: amount_i64, + }, + )?; + require_pending_kinic_operation_status( + &operation, + &[KINIC_OPERATION_STATUS_IN_FLIGHT], + "cancel market deposit", + )?; + delete_pending_kinic_operation(tx, operation_id) }) } - fn require_no_pending_cycles_operations(&self, database_id: &str) -> Result<(), String> { + pub fn market_list_pending_operations( + &self, + caller: &str, + ) -> Result, String> { + require_authenticated_principal(caller)?; + let config = self.cycles_billing_config()?; self.read_index(|conn| { - let pending = first_database_cycles_pending_purchase_status(conn, database_id)?; - if let Some(pending) = pending { - return Err(format!( - "database has pending cycle operation: {database_id}; operation_id={}; status={}; required_action={}", - pending.operation_id, - pending.status, - pending.required_action - )); - } - Ok(()) + let show_all = caller == config.billing_authority_id; + load_kinic_pending_operations(conn, caller, show_all)? + .into_iter() + .map(PendingKinicOperationRaw::into_public) + .collect::, _>>() }) } - pub fn begin_database_archive( + pub fn market_create_listing( &self, - database_id: &str, caller: &str, + request: MarketCreateListingRequest, now: i64, - ) -> Result { - self.require_role(database_id, caller, RequiredRole::Owner)?; - self.require_no_pending_cycles_operations(database_id)?; - let meta = self.database_meta(database_id)?; - let size_bytes = self.database_size(&meta)?; - self.write_index(|conn| { - conn.execute( - "UPDATE databases - SET status = 'archiving', - updated_at_ms = ?2, - logical_size_bytes = ?3 - WHERE database_id = ?1", + ) -> Result { + require_authenticated_principal(caller)?; + validate_market_create_listing_request(&request)?; + self.write_index(|tx| { + require_market_seller_can_list(tx, caller, &request.database_id)?; + let listing_id = unique_market_id( + tx, + "market_listings", + "listing_id", + GENERATED_LISTING_ID_PREFIX, + caller, + &request.database_id, + now, + )?; + tx.execute( + "INSERT INTO market_listings + (listing_id, seller_principal, database_id, title, description, + llm_summary, summary_snapshot_revision, sample_excerpts_json, + sample_questions_json, tags_json, price_e8s, status, revision, + purchase_count, report_count, created_at_ms, updated_at_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1, 0, 0, ?13, ?13)", params![ - database_id, - now, - i64::try_from(size_bytes).map_err(|error| error.to_string())? + listing_id, + caller, + request.database_id, + request.title, + request.description, + request.llm_summary, + request.summary_snapshot_revision, + request.sample_excerpts_json, + request.sample_questions_json, + request.tags_json, + i64::try_from(request.price_e8s).map_err(|error| error.to_string())?, + MARKET_LISTING_STATUS_DRAFT, + now ], ) .map_err(|error| error.to_string())?; - Ok(()) - })?; - Ok(DatabaseArchiveInfo { - database_id: database_id.to_string(), - size_bytes, + load_market_listing_by_id(tx, &listing_id)? + .ok_or_else(|| "market listing insert failed".to_string()) }) } - pub fn read_database_archive_chunk( + pub fn market_update_listing( &self, - database_id: &str, caller: &str, - offset: u64, - max_bytes: u32, - ) -> Result, String> { - self.require_role(database_id, caller, RequiredRole::Owner)?; - let meta = self.database_meta_with_statuses(database_id, &[DatabaseStatus::Archiving])?; - if max_bytes == 0 { - return Ok(Vec::new()); - } - if max_bytes > MAX_ARCHIVE_CHUNK_BYTES { - return Err(format!( - "archive chunk size exceeds limit: {max_bytes} > {MAX_ARCHIVE_CHUNK_BYTES}" + request: MarketUpdateListingRequest, + now: i64, + ) -> Result { + require_authenticated_principal(caller)?; + validate_market_update_listing_request(&request)?; + self.write_index(|tx| { + let listing = load_market_listing_by_id(tx, &request.listing_id)? + .ok_or_else(|| "market listing not found".to_string())?; + require_market_listing_seller_or_admin(tx, caller, &listing)?; + if listing.revision != request.expected_revision { + return Err("market listing revision mismatch".to_string()); + } + if listing.status == MarketListingStatus::Active { + require_market_seller_can_list( + tx, + &listing.seller_principal, + &listing.database_id, + )?; + } + tx.execute( + "UPDATE market_listings + SET title = ?2, + description = ?3, + llm_summary = ?4, + summary_snapshot_revision = ?5, + sample_excerpts_json = ?6, + sample_questions_json = ?7, + tags_json = ?8, + price_e8s = ?9, + revision = revision + 1, + updated_at_ms = ?10 + WHERE listing_id = ?1", + params![ + request.listing_id, + request.title, + request.description, + request.llm_summary, + request.summary_snapshot_revision, + request.sample_excerpts_json, + request.sample_questions_json, + request.tags_json, + i64::try_from(request.price_e8s).map_err(|error| error.to_string())?, + now + ], + ) + .map_err(|error| error.to_string())?; + load_market_listing_by_id(tx, &listing.listing_id)? + .ok_or_else(|| "market listing update failed".to_string()) + }) + } + + pub fn market_publish_listing( + &self, + caller: &str, + listing_id: &str, + now: i64, + ) -> Result { + self.market_set_listing_status(caller, listing_id, MARKET_LISTING_STATUS_ACTIVE, now) + } + + pub fn market_pause_listing( + &self, + caller: &str, + listing_id: &str, + now: i64, + ) -> Result { + self.market_set_listing_status(caller, listing_id, MARKET_LISTING_STATUS_PAUSED, now) + } + + fn market_set_listing_status( + &self, + caller: &str, + listing_id: &str, + status: &str, + now: i64, + ) -> Result { + require_authenticated_principal(caller)?; + self.write_index(|tx| { + let listing = load_market_listing_by_id(tx, listing_id)? + .ok_or_else(|| "market listing not found".to_string())?; + require_market_listing_seller_or_admin(tx, caller, &listing)?; + if status == MARKET_LISTING_STATUS_ACTIVE { + require_market_seller_can_list( + tx, + &listing.seller_principal, + &listing.database_id, + )?; + } + tx.execute( + "UPDATE market_listings + SET status = ?2, + revision = revision + 1, + updated_at_ms = ?3 + WHERE listing_id = ?1", + params![listing_id, status, now], + ) + .map_err(|error| error.to_string())?; + load_market_listing_by_id(tx, listing_id)? + .ok_or_else(|| "market listing status update failed".to_string()) + }) + } + + pub fn market_list_listings( + &self, + cursor: Option, + limit: u32, + ) -> Result { + let limit = page_limit(limit); + let after = cursor.unwrap_or_default(); + self.read_index(|conn| { + let mut stmt = conn + .prepare( + "SELECT listing_id, seller_principal, database_id, title, description, + llm_summary, summary_snapshot_revision, sample_excerpts_json, + sample_questions_json, tags_json, price_e8s, status, revision, + purchase_count, report_count, created_at_ms, updated_at_ms + FROM market_listings + WHERE status = ?1 AND listing_id > ?2 + ORDER BY listing_id ASC + LIMIT ?3", + ) + .map_err(|error| error.to_string())?; + let mut listings = crate::sqlite::query_map( + &mut stmt, + params![MARKET_LISTING_STATUS_ACTIVE, after, i64::from(limit) + 1], + map_market_listing, + ) + .map_err(|error| error.to_string())?; + let next_cursor = if listings.len() > limit as usize { + listings.pop(); + listings.last().map(|listing| listing.listing_id.clone()) + } else { + None + }; + Ok(MarketListingPage { + listings, + next_cursor, + }) + }) + } + + pub fn market_list_database_listings( + &self, + caller: &str, + database_id: &str, + ) -> Result, String> { + require_authenticated_principal(caller)?; + validate_database_id(database_id)?; + self.read_index(|conn| { + let role = load_member_role(conn, database_id, caller)? + .ok_or_else(|| format!("principal has no access to database: {database_id}"))?; + if role != DatabaseRole::Owner { + return Err(format!( + "principal lacks required database role: {database_id}" + )); + } + let mut stmt = conn + .prepare( + "SELECT listing_id, seller_principal, database_id, title, description, + llm_summary, summary_snapshot_revision, sample_excerpts_json, + sample_questions_json, tags_json, price_e8s, status, revision, + purchase_count, report_count, created_at_ms, updated_at_ms + FROM market_listings + WHERE database_id = ?1 + ORDER BY updated_at_ms DESC, listing_id ASC", + ) + .map_err(|error| error.to_string())?; + crate::sqlite::query_map(&mut stmt, params![database_id], map_market_listing) + .map_err(|error| error.to_string()) + }) + } + + pub fn market_get_listing( + &self, + caller: &str, + listing_id: &str, + ) -> Result { + self.read_index(|conn| { + let listing = load_market_listing_by_id(conn, listing_id)? + .ok_or_else(|| "market listing not found".to_string())?; + if listing.status == MarketListingStatus::Active { + return Ok(listing); + } + require_market_listing_seller_or_admin(conn, caller, &listing)?; + Ok(listing) + }) + } + + pub fn market_preview_purchase( + &self, + caller: &str, + listing_id: &str, + ) -> Result { + require_authenticated_principal(caller)?; + self.read_index(|conn| { + let listing = load_market_listing_by_id(conn, listing_id)? + .ok_or_else(|| "market listing not found".to_string())?; + require_market_listing_purchasable(conn, &listing)?; + Ok(MarketPurchasePreview { + listing_id: listing.listing_id.clone(), + database_id: listing.database_id.clone(), + price_e8s: listing.price_e8s, + buyer_balance_e8s: u64::try_from(load_kinic_balance(conn, caller)?) + .map_err(|error| error.to_string())?, + already_entitled: has_active_market_entitlement( + conn, + &listing.database_id, + caller, + )?, + }) + }) + } + + pub fn market_purchase_access( + &self, + caller: &str, + request: MarketPurchaseRequest, + now: i64, + ) -> Result { + require_authenticated_principal(caller)?; + self.write_index(|tx| { + let listing = load_market_listing_by_id(tx, &request.listing_id)? + .ok_or_else(|| "market listing not found".to_string())?; + require_market_listing_purchasable(tx, &listing)?; + if listing.price_e8s != request.price_e8s { + return Err("market listing price mismatch".to_string()); + } + if listing.revision != request.expected_revision { + return Err("market listing revision mismatch".to_string()); + } + if caller == listing.seller_principal { + return Err("market seller cannot purchase own listing".to_string()); + } + if has_active_market_entitlement(tx, &listing.database_id, caller)? { + return Err("active entitlement already exists".to_string()); + } + let price_i64 = amount_to_i64(request.price_e8s)?; + let buyer_balance = load_kinic_balance(tx, caller)?; + if buyer_balance < price_i64 { + return Err("insufficient KINIC balance".to_string()); + } + let seller_balance = load_kinic_balance(tx, &listing.seller_principal)?; + let buyer_next = checked_balance_add(buyer_balance, -price_i64)?; + let seller_next = checked_balance_add(seller_balance, price_i64)?; + let order_id = unique_market_id( + tx, + "market_orders", + "order_id", + GENERATED_ORDER_ID_PREFIX, + caller, + &listing.listing_id, + now, + )?; + upsert_kinic_balance(tx, caller, buyer_next, now)?; + upsert_kinic_balance(tx, &listing.seller_principal, seller_next, now)?; + insert_kinic_ledger( + tx, + KinicLedgerInsert { + principal: caller, + source: KINIC_LEDGER_SOURCE_MARKETPLACE, + kind: "purchase", + amount_e8s: -price_i64, + balance_after_e8s: buyer_next, + counterparty: Some(&listing.seller_principal), + listing_id: Some(&listing.listing_id), + order_id: Some(&order_id), + external_block_index: None, + now, + }, + )?; + insert_kinic_ledger( + tx, + KinicLedgerInsert { + principal: &listing.seller_principal, + source: KINIC_LEDGER_SOURCE_MARKETPLACE, + kind: "sale", + amount_e8s: price_i64, + balance_after_e8s: seller_next, + counterparty: Some(caller), + listing_id: Some(&listing.listing_id), + order_id: Some(&order_id), + external_block_index: None, + now, + }, + )?; + tx.execute( + "INSERT INTO market_orders + (order_id, listing_id, database_id, buyer_principal, seller_principal, + price_e8s, listing_revision, created_at_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + order_id, + listing.listing_id, + listing.database_id, + caller, + listing.seller_principal, + price_i64, + i64::try_from(listing.revision).map_err(|error| error.to_string())?, + now + ], + ) + .map_err(|error| error.to_string())?; + tx.execute( + "INSERT INTO market_entitlements + (database_id, buyer_principal, listing_id, order_id, purchased_at_ms, status) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + listing.database_id, + caller, + listing.listing_id, + order_id, + now, + MARKET_ENTITLEMENT_STATUS_ACTIVE + ], + ) + .map_err(|error| error.to_string())?; + tx.execute( + "UPDATE market_listings + SET purchase_count = purchase_count + 1, + updated_at_ms = ?2 + WHERE listing_id = ?1", + params![listing.listing_id, now], + ) + .map_err(|error| error.to_string())?; + load_market_order_by_id(tx, &order_id)? + .ok_or_else(|| "market order insert failed".to_string()) + }) + } + + pub fn market_list_entitlements( + &self, + caller: &str, + cursor: Option, + limit: u32, + ) -> Result { + require_authenticated_principal(caller)?; + let limit = page_limit(limit); + let after = cursor.unwrap_or_default(); + self.read_index(|conn| { + let mut stmt = conn + .prepare( + "SELECT database_id, buyer_principal, listing_id, order_id, + purchased_at_ms, status + FROM market_entitlements + WHERE buyer_principal = ?1 + AND database_id > ?2 + AND status = ?3 + ORDER BY database_id ASC + LIMIT ?4", + ) + .map_err(|error| error.to_string())?; + let mut entitlements = crate::sqlite::query_map( + &mut stmt, + params![ + caller, + after, + MARKET_ENTITLEMENT_STATUS_ACTIVE, + i64::from(limit) + 1 + ], + map_market_entitlement, + ) + .map_err(|error| error.to_string())?; + let next_cursor = if entitlements.len() > limit as usize { + entitlements.pop(); + entitlements + .last() + .map(|entitlement| entitlement.database_id.clone()) + } else { + None + }; + Ok(MarketEntitlementPage { + entitlements, + next_cursor, + }) + }) + } + + pub fn market_list_orders( + &self, + caller: &str, + cursor: Option, + limit: u32, + ) -> Result { + require_authenticated_principal(caller)?; + let limit = page_limit(limit); + let after = cursor.unwrap_or_default(); + self.read_index(|conn| { + let mut stmt = conn + .prepare( + "SELECT order_id, listing_id, database_id, buyer_principal, seller_principal, + price_e8s, listing_revision, created_at_ms + FROM market_orders + WHERE buyer_principal = ?1 AND order_id > ?2 + ORDER BY order_id ASC + LIMIT ?3", + ) + .map_err(|error| error.to_string())?; + let mut orders = crate::sqlite::query_map( + &mut stmt, + params![caller, after, i64::from(limit) + 1], + map_market_order, + ) + .map_err(|error| error.to_string())?; + let next_cursor = if orders.len() > limit as usize { + orders.pop(); + orders.last().map(|order| order.order_id.clone()) + } else { + None + }; + Ok(MarketOrderPage { + orders, + next_cursor, + }) + }) + } + + pub fn market_count_active_entitlements( + &self, + caller: &str, + database_id: &str, + ) -> Result { + require_authenticated_principal(caller)?; + self.read_index(|conn| { + load_database_status(conn, database_id)?; + let config = load_cycles_billing_config(conn)?; + if caller != config.billing_authority_id { + let role = load_member_role(conn, database_id, caller)? + .ok_or_else(|| format!("principal has no access to database: {database_id}"))?; + if role != DatabaseRole::Owner { + return Err(format!( + "principal lacks required database role: {database_id}" + )); + } + } + let count: i64 = conn + .query_row( + "SELECT COUNT(*) + FROM market_entitlements + WHERE database_id = ?1 + AND status = ?2", + params![database_id, MARKET_ENTITLEMENT_STATUS_ACTIVE], + |row| crate::sqlite::row_get(row, 0), + ) + .map_err(|error| error.to_string())?; + u64::try_from(count).map_err(|error| error.to_string()) + }) + } + + pub fn require_database_write_cycles_available(&self, database_id: &str) -> Result<(), String> { + self.read_index(|conn| { + let config = load_cycles_billing_config(conn)?; + require_database_write_cycles_available_for_conn(conn, database_id, &config) + }) + } + + pub fn prepare_metered_update( + &self, + database_id: &str, + caller: &str, + required_role: RequiredRole, + ) -> Result { + self.read_index(|conn| { + let role = load_database_status(conn, database_id).and_then(|_| { + load_member_role(conn, database_id, caller)? + .ok_or_else(|| format!("principal has no access to database: {database_id}")) + })?; + if !role_allows(role, required_role) { + return Err(format!( + "principal lacks required database role: {database_id}" + )); + } + 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_cycles( + &self, + database_id: &str, + caller: &str, + ) -> Result<(), String> { + if caller == ANONYMOUS_PRINCIPAL { + return Err("anonymous caller not allowed".to_string()); + } + self.require_role(database_id, caller, RequiredRole::Writer)?; + self.require_database_write_cycles_available(database_id) + } + + pub fn charge_database_update( + &self, + config: &CyclesBillingConfig, + database_id: &str, + caller: &str, + method: &str, + cycles_delta: u128, + now: i64, + ) -> Result<(), String> { + let computed_charge = compute_update_charge(cycles_delta)?; + if computed_charge == 0 { + return Ok(()); + } + self.write_index(|tx| { + charge_database_update_in_tx( + tx, + DatabaseCharge { + database_id, + caller, + method, + cycles_delta, + now, + config, + computed_charge, + }, + ) + }) + } + + pub fn run_database_migrations(&self, database_id: &str) -> Result<(), String> { + let meta = self.database_meta(database_id)?; + self.run_database_migrations_for_meta(database_id, &meta) + } + + pub fn run_pending_database_migrations(&self, database_id: &str) -> Result<(), String> { + let meta = self.database_meta_with_statuses(database_id, &[DatabaseStatus::Pending])?; + self.run_database_migrations_for_meta(database_id, &meta) + } + + fn run_database_migrations_for_meta( + &self, + database_id: &str, + meta: &DatabaseMeta, + ) -> Result<(), String> { + #[cfg(not(target_arch = "wasm32"))] + if let Some(parent) = Path::new(&meta.db_file_name).parent() { + create_dir_all(parent).map_err(|error| error.to_string())?; + } + let result = self.database_store(meta)?.run_fs_migrations(); + if result.is_ok() { + let _ = self.refresh_logical_size(database_id); + } + result + } + + pub fn delete_database( + &self, + request: DeleteDatabaseRequest, + caller: &str, + _now: i64, + ) -> Result<(), String> { + let database_id = request.database_id.as_str(); + self.require_role(database_id, caller, RequiredRole::Owner)?; + 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!( + "database is {}: {database_id}", + status_to_db(status) + )); + } + let meta = self.database_meta(database_id).ok(); + #[cfg(target_arch = "wasm32")] + let _ = &meta; + #[cfg(not(target_arch = "wasm32"))] + if let Some(meta) = &meta + && let Err(error) = remove_file(&meta.db_file_name) + && error.kind() != std::io::ErrorKind::NotFound + { + return Err(error.to_string()); + } + self.write_index(|conn| { + delete_database_index_rows(conn, database_id)?; + Ok(()) + }) + } + + fn require_no_pending_cycles_operations(&self, database_id: &str) -> Result<(), String> { + self.read_index(|conn| { + let pending = first_database_cycles_pending_purchase_status(conn, database_id)?; + if let Some(pending) = pending { + return Err(format!( + "database has pending cycle operation: {database_id}; operation_id={}; status={}; required_action={}", + pending.operation_id, + pending.status, + pending.required_action + )); + } + Ok(()) + }) + } + + pub fn begin_database_archive( + &self, + database_id: &str, + caller: &str, + now: i64, + ) -> Result { + self.require_role(database_id, caller, RequiredRole::Owner)?; + self.require_no_pending_cycles_operations(database_id)?; + let meta = self.database_meta(database_id)?; + let size_bytes = self.database_size(&meta)?; + self.write_index(|conn| { + conn.execute( + "UPDATE databases + SET status = 'archiving', + updated_at_ms = ?2, + logical_size_bytes = ?3 + WHERE database_id = ?1", + params![ + database_id, + now, + i64::try_from(size_bytes).map_err(|error| error.to_string())? + ], + ) + .map_err(|error| error.to_string())?; + Ok(()) + })?; + Ok(DatabaseArchiveInfo { + database_id: database_id.to_string(), + size_bytes, + }) + } + + pub fn read_database_archive_chunk( + &self, + database_id: &str, + caller: &str, + offset: u64, + max_bytes: u32, + ) -> Result, String> { + self.require_role(database_id, caller, RequiredRole::Owner)?; + let meta = self.database_meta_with_statuses(database_id, &[DatabaseStatus::Archiving])?; + if max_bytes == 0 { + return Ok(Vec::new()); + } + if max_bytes > MAX_ARCHIVE_CHUNK_BYTES { + return Err(format!( + "archive chunk size exceeds limit: {max_bytes} > {MAX_ARCHIVE_CHUNK_BYTES}" )); } let size = meta.logical_size_bytes; @@ -1645,9 +2408,7 @@ impl VfsService { caller: &str, path: &str, ) -> Result, String> { - self.with_database_store(database_id, caller, RequiredRole::Reader, |store| { - store.read_node(path) - }) + self.with_market_read_database_store(database_id, caller, |store| store.read_node(path)) } pub fn authorize_url_ingest_trigger_session( @@ -1835,7 +2596,7 @@ impl VfsService { request: ListNodesRequest, ) -> Result, String> { let database_id = request.database_id.clone(); - self.with_database_store(&database_id, caller, RequiredRole::Reader, |store| { + self.with_market_read_database_store(&database_id, caller, |store| { store.list_nodes(request) }) } @@ -1846,7 +2607,7 @@ impl VfsService { request: ListChildrenRequest, ) -> Result, String> { let database_id = request.database_id.clone(); - self.with_database_store(&database_id, caller, RequiredRole::Reader, |store| { + self.with_market_read_database_store(&database_id, caller, |store| { store.list_children(request) }) } @@ -2048,7 +2809,7 @@ impl VfsService { request: IncomingLinksRequest, ) -> Result, String> { let database_id = request.database_id.clone(); - self.with_database_store(&database_id, caller, RequiredRole::Reader, |store| { + self.with_market_read_database_store(&database_id, caller, |store| { store.incoming_links(request) }) } @@ -2059,7 +2820,7 @@ impl VfsService { request: OutgoingLinksRequest, ) -> Result, String> { let database_id = request.database_id.clone(); - self.with_database_store(&database_id, caller, RequiredRole::Reader, |store| { + self.with_market_read_database_store(&database_id, caller, |store| { store.outgoing_links(request) }) } @@ -2070,7 +2831,7 @@ impl VfsService { request: GraphLinksRequest, ) -> Result, String> { let database_id = request.database_id.clone(); - self.with_database_store(&database_id, caller, RequiredRole::Reader, |store| { + self.with_market_read_database_store(&database_id, caller, |store| { store.graph_links(request) }) } @@ -2081,7 +2842,7 @@ impl VfsService { request: GraphNeighborhoodRequest, ) -> Result, String> { let database_id = request.database_id.clone(); - self.with_database_store(&database_id, caller, RequiredRole::Reader, |store| { + self.with_market_read_database_store(&database_id, caller, |store| { store.graph_neighborhood(request) }) } @@ -2092,7 +2853,7 @@ impl VfsService { request: NodeContextRequest, ) -> Result, String> { let database_id = request.database_id.clone(); - self.with_database_store(&database_id, caller, RequiredRole::Reader, |store| { + self.with_market_read_database_store(&database_id, caller, |store| { store.read_node_context(request) }) } @@ -2142,7 +2903,7 @@ impl VfsService { request: SearchNodesRequest, ) -> Result, String> { let database_id = request.database_id.clone(); - self.with_database_store(&database_id, caller, RequiredRole::Reader, |store| { + self.with_market_read_database_store(&database_id, caller, |store| { store.search_nodes(request) }) } @@ -2153,7 +2914,7 @@ impl VfsService { request: SearchNodePathsRequest, ) -> Result, String> { let database_id = request.database_id.clone(); - self.with_database_store(&database_id, caller, RequiredRole::Reader, |store| { + self.with_market_read_database_store(&database_id, caller, |store| { store.search_node_paths(request) }) } @@ -2193,6 +2954,18 @@ impl VfsService { f(&store) } + fn with_market_read_database_store( + &self, + database_id: &str, + caller: &str, + f: impl FnOnce(&FsStore) -> Result, + ) -> Result { + self.require_market_read_access(database_id, caller)?; + let meta = self.database_meta(database_id)?; + let store = self.database_store(&meta)?; + f(&store) + } + pub fn require_database_role( &self, database_id: &str, @@ -2222,6 +2995,23 @@ impl VfsService { } } + fn require_market_read_access(&self, database_id: &str, caller: &str) -> Result<(), String> { + self.read_index(|conn| { + load_database_status(conn, database_id)?; + if let Some(role) = load_member_role(conn, database_id, caller)? + && role_allows(role, RequiredRole::Reader) + { + return Ok(()); + } + if has_active_market_entitlement(conn, database_id, caller)? { + return Ok(()); + } + Err(format!( + "principal has no access to database: {database_id}" + )) + }) + } + fn database_meta(&self, database_id: &str) -> Result { self.read_index(|conn| { load_database(conn, database_id)?.ok_or_else(|| database_meta_error(conn, database_id)) @@ -2634,6 +3424,7 @@ fn run_index_migrations_in_tx_for_upgrade( enum IndexSchemaState { Latest, + MarketplaceCoreUpgrade, StorageBillingBatchUpgrade, Mainnet011, } @@ -2644,8 +3435,13 @@ fn ensure_existing_index_schema_is_latest( ) -> Result<(), String> { match classify_existing_index_schema_state(conn)? { IndexSchemaState::Latest => validate_index_schema(conn), + IndexSchemaState::MarketplaceCoreUpgrade => { + apply_marketplace_core_index_migration(conn)?; + validate_index_schema(conn) + } IndexSchemaState::StorageBillingBatchUpgrade => { apply_storage_billing_batch_index_migration(conn)?; + apply_marketplace_core_index_migration(conn)?; validate_index_schema(conn) } IndexSchemaState::Mainnet011 => { @@ -2684,9 +3480,12 @@ fn classify_existing_index_schema_state( "unsupported partial billing index schema: table {table} already exists" )); } - if migration_applied_tx(conn, INDEX_SCHEMA_VERSION_STORAGE_BILLING_BATCH)? { + if migration_applied_tx(conn, INDEX_SCHEMA_VERSION_MARKETPLACE_CORE)? { return Ok(IndexSchemaState::Latest); } + if migration_applied_tx(conn, INDEX_SCHEMA_VERSION_STORAGE_BILLING_BATCH)? { + return Ok(IndexSchemaState::MarketplaceCoreUpgrade); + } if migration_applied_tx(conn, INDEX_SCHEMA_VERSION_CYCLES_PENDING_LEDGER_BLOCK_INDEX)? { return Ok(IndexSchemaState::StorageBillingBatchUpgrade); } @@ -2741,6 +3540,117 @@ fn apply_storage_billing_batch_index_migration(conn: &Transaction<'_>) -> Result Ok(()) } +fn apply_marketplace_core_index_migration(conn: &Transaction<'_>) -> Result<(), String> { + conn.execute_batch( + " + CREATE TABLE kinic_accounts ( + principal TEXT PRIMARY KEY, + balance_e8s INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + ); + + CREATE TABLE kinic_ledger ( + entry_id INTEGER PRIMARY KEY AUTOINCREMENT, + principal TEXT NOT NULL, + source TEXT NOT NULL, + kind TEXT NOT NULL, + amount_e8s INTEGER NOT NULL, + balance_after_e8s INTEGER NOT NULL, + counterparty TEXT, + listing_id TEXT, + order_id TEXT, + external_block_index INTEGER, + created_at_ms INTEGER NOT NULL + ); + + CREATE INDEX kinic_ledger_principal_idx + ON kinic_ledger(principal, entry_id); + + CREATE TABLE kinic_pending_operations ( + operation_id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + caller TEXT NOT NULL, + amount_e8s INTEGER NOT NULL, + from_owner TEXT, + from_subaccount BLOB, + to_owner TEXT, + to_subaccount BLOB, + ledger_fee_e8s INTEGER, + operation_status TEXT NOT NULL, + external_block_index INTEGER, + ledger_created_at_time_ns INTEGER, + created_at_ms INTEGER NOT NULL + ); + + CREATE INDEX kinic_pending_operations_caller_idx + ON kinic_pending_operations(caller, operation_id); + + CREATE TABLE market_listings ( + listing_id TEXT PRIMARY KEY, + seller_principal TEXT NOT NULL, + database_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + llm_summary TEXT, + summary_snapshot_revision TEXT, + sample_excerpts_json TEXT NOT NULL, + sample_questions_json TEXT NOT NULL, + tags_json TEXT NOT NULL, + price_e8s INTEGER NOT NULL, + status TEXT NOT NULL, + revision INTEGER NOT NULL, + purchase_count INTEGER NOT NULL, + report_count INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + FOREIGN KEY (database_id) REFERENCES databases(database_id) + ); + + CREATE INDEX market_listings_status_idx + ON market_listings(status, listing_id); + + CREATE INDEX market_listings_database_idx + ON market_listings(database_id); + + CREATE TABLE market_orders ( + order_id TEXT PRIMARY KEY, + listing_id TEXT NOT NULL, + database_id TEXT NOT NULL, + buyer_principal TEXT NOT NULL, + seller_principal TEXT NOT NULL, + price_e8s INTEGER NOT NULL, + listing_revision INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL + ); + + CREATE INDEX market_orders_buyer_idx + ON market_orders(buyer_principal, order_id); + + CREATE TABLE market_entitlements ( + database_id TEXT NOT NULL, + buyer_principal TEXT NOT NULL, + listing_id TEXT NOT NULL, + order_id TEXT NOT NULL, + purchased_at_ms INTEGER NOT NULL, + status TEXT NOT NULL, + PRIMARY KEY (database_id, buyer_principal, listing_id), + FOREIGN KEY (database_id) REFERENCES databases(database_id) + ); + + CREATE UNIQUE INDEX market_entitlements_database_buyer_active_idx + ON market_entitlements(database_id, buyer_principal) + WHERE status = 'active'; + + CREATE INDEX market_entitlements_buyer_idx + ON market_entitlements(buyer_principal, database_id); + ", + ) + .map_err(|error| error.to_string())?; + insert_schema_migration_now(conn, INDEX_SCHEMA_VERSION_MARKETPLACE_CORE)?; + Ok(()) +} + fn create_schema_migrations(conn: &Transaction<'_>) -> Result<(), String> { conn.execute( "CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)", @@ -2867,6 +3777,7 @@ const INDEX_SCHEMA_VERSIONS: &[&str] = &[ INDEX_SCHEMA_VERSION_DIRECT_CYCLES, INDEX_SCHEMA_VERSION_CYCLES_PENDING_LEDGER_BLOCK_INDEX, INDEX_SCHEMA_VERSION_STORAGE_BILLING_BATCH, + INDEX_SCHEMA_VERSION_MARKETPLACE_CORE, ]; const INDEX_SCHEMA_TABLES_WITHOUT_MIGRATIONS: &[&str] = &[ @@ -2883,6 +3794,12 @@ const INDEX_SCHEMA_TABLES_WITHOUT_MIGRATIONS: &[&str] = &[ "database_cycle_pending_operations", "cycles_billing_config", "storage_billing_state", + "kinic_accounts", + "kinic_ledger", + "kinic_pending_operations", + "market_listings", + "market_orders", + "market_entitlements", ]; const POST_011_INDEX_SCHEMA_VERSIONS: &[&str] = &[ @@ -2901,6 +3818,7 @@ const POST_011_INDEX_SCHEMA_VERSIONS: &[&str] = &[ INDEX_SCHEMA_VERSION_DIRECT_CYCLES, INDEX_SCHEMA_VERSION_CYCLES_PENDING_LEDGER_BLOCK_INDEX, INDEX_SCHEMA_VERSION_STORAGE_BILLING_BATCH, + INDEX_SCHEMA_VERSION_MARKETPLACE_CORE, ]; const POST_011_INDEX_SCHEMA_TABLES: &[&str] = &[ @@ -2909,6 +3827,12 @@ const POST_011_INDEX_SCHEMA_TABLES: &[&str] = &[ "database_cycle_pending_operations", "cycles_billing_config", "storage_billing_state", + "kinic_accounts", + "kinic_ledger", + "kinic_pending_operations", + "market_listings", + "market_orders", + "market_entitlements", ]; fn validate_pre_billing_index_schema(conn: &Transaction<'_>) -> Result<(), String> { @@ -3028,6 +3952,14 @@ fn validate_pre_billing_index_schema(conn: &Transaction<'_>) -> Result<(), Strin return Err(format!("unsupported index schema: missing index {index}")); } } + if tx_sqlite_master_entry_exists(conn, "table", "market_seller_allowlist")? { + return Err("unsupported index schema: stale table market_seller_allowlist".to_string()); + } + if index_column_exists(conn, "market_entitlements", "expires_at_ms")? { + return Err( + "unsupported index schema: stale column market_entitlements.expires_at_ms".to_string(), + ); + } Ok(()) } @@ -3042,6 +3974,12 @@ fn validate_index_schema(conn: &Transaction<'_>) -> Result<(), String> { "database_cycle_pending_operations", "cycles_billing_config", "storage_billing_state", + "kinic_accounts", + "kinic_ledger", + "kinic_pending_operations", + "market_listings", + "market_orders", + "market_entitlements", ] { if !tx_sqlite_master_entry_exists(conn, "table", table)? { return Err(format!("unsupported index schema: missing table {table}")); @@ -3112,15 +4050,99 @@ fn validate_index_schema(conn: &Transaction<'_>) -> Result<(), String> { "to_owner", "to_subaccount", "ledger_fee_e8s", - "ledger_created_at_time_ns", + "ledger_created_at_time_ns", + "operation_status", + "ledger_block_index", + "created_at_ms", + ][..], + ), + ( + "storage_billing_state", + &["key", "cursor_mount_id", "billing_now_ms", "updated_at_ms"][..], + ), + ( + "kinic_accounts", + &["principal", "balance_e8s", "created_at_ms", "updated_at_ms"][..], + ), + ( + "kinic_ledger", + &[ + "entry_id", + "principal", + "source", + "kind", + "amount_e8s", + "balance_after_e8s", + "counterparty", + "listing_id", + "order_id", + "external_block_index", + "created_at_ms", + ][..], + ), + ( + "kinic_pending_operations", + &[ + "operation_id", + "kind", + "caller", + "amount_e8s", + "from_owner", + "from_subaccount", + "to_owner", + "to_subaccount", + "ledger_fee_e8s", "operation_status", - "ledger_block_index", + "external_block_index", + "ledger_created_at_time_ns", "created_at_ms", ][..], ), ( - "storage_billing_state", - &["key", "cursor_mount_id", "billing_now_ms", "updated_at_ms"][..], + "market_listings", + &[ + "listing_id", + "seller_principal", + "database_id", + "title", + "description", + "llm_summary", + "summary_snapshot_revision", + "sample_excerpts_json", + "sample_questions_json", + "tags_json", + "price_e8s", + "status", + "revision", + "purchase_count", + "report_count", + "created_at_ms", + "updated_at_ms", + ][..], + ), + ( + "market_orders", + &[ + "order_id", + "listing_id", + "database_id", + "buyer_principal", + "seller_principal", + "price_e8s", + "listing_revision", + "created_at_ms", + ][..], + ), + ( + "market_entitlements", + &[ + "database_id", + "buyer_principal", + "listing_id", + "order_id", + "purchased_at_ms", + "status", + ][..], ), ] { for column in columns { @@ -3136,6 +4158,13 @@ fn validate_index_schema(conn: &Transaction<'_>) -> Result<(), String> { "database_restore_chunks_database_id_idx", "database_cycle_ledger_database_idx", "database_cycle_pending_operations_database_idx", + "kinic_ledger_principal_idx", + "kinic_pending_operations_caller_idx", + "market_listings_status_idx", + "market_listings_database_idx", + "market_orders_buyer_idx", + "market_entitlements_database_buyer_active_idx", + "market_entitlements_buyer_idx", ] { if !tx_sqlite_master_entry_exists(conn, "index", index)? { return Err(format!("unsupported index schema: missing index {index}")); @@ -3438,6 +4467,9 @@ fn delete_database_index_rows(conn: &Connection, database_id: &str) -> Result<() "database_cycle_pending_operations", "database_cycle_ledger", "database_cycle_accounts", + "market_entitlements", + "market_orders", + "market_listings", "database_members", "database_restore_chunks", "database_restore_sessions", @@ -3738,27 +4770,320 @@ fn insert_pending_cycles_operation( u64::try_from(operation_id).map_err(|error| error.to_string()) } -fn load_pending_cycles_operation( +fn load_pending_cycles_operation( + conn: &Connection, + operation_id: u64, +) -> Result { + let operation_id = i64::try_from(operation_id).map_err(|error| error.to_string())?; + conn.query_row( + "SELECT 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, ledger_block_index + FROM database_cycle_pending_operations + WHERE operation_id = ?1", + params![operation_id], + map_pending_cycles_operation, + ) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| "pending cycle operation not found".to_string()) +} + +fn require_pending_operation_status( + operation: &PendingCyclesOperation, + allowed: &[&str], + action: &str, +) -> Result<(), String> { + if allowed + .iter() + .any(|status| operation.operation_status == *status) + { + return Ok(()); + } + Err(format!( + "cannot {action}; cycle purchase operation is {}", + operation.operation_status + )) +} + +fn load_required_pending_cycles_operation( + conn: &Transaction<'_>, + 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.cycles != expected.cycles + { + return Err("pending cycle operation mismatch".to_string()); + } + Ok(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_cycle_pending_operations WHERE operation_id = ?1", + params![operation_id], + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +fn ensure_no_pending_cycles_purchase_for_caller( + conn: &Connection, + database_id: &str, + caller: &str, +) -> Result<(), String> { + let count: i64 = conn + .query_row( + "SELECT COUNT(*) + FROM database_cycle_pending_operations + WHERE database_id = ?1 + AND caller = ?2 + AND kind = 'cycles_purchase'", + params![database_id, caller], + |row| crate::sqlite::row_get(row, 0), + ) + .map_err(|error| error.to_string())?; + if count > 0 { + return Err("cycles purchase already pending for caller".to_string()); + } + Ok(()) +} + +fn load_database_cycles_pending_purchase_statuses( + conn: &Connection, + database_id: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT operation_id, database_id, caller, operation_status, cycles, + payment_amount_e8s, ledger_block_index, created_at_ms + FROM database_cycle_pending_operations + WHERE database_id = ?1 AND kind = 'cycles_purchase' + ORDER BY operation_id ASC", + ) + .map_err(|error| error.to_string())?; + crate::sqlite::query_map( + &mut stmt, + params![database_id], + map_database_cycles_pending_purchase_raw, + ) + .map_err(|error| error.to_string()) +} + +fn first_database_cycles_pending_purchase_status( + conn: &Connection, + database_id: &str, +) -> Result, String> { + conn.query_row( + "SELECT operation_id, database_id, caller, operation_status, cycles, + payment_amount_e8s, ledger_block_index, created_at_ms + FROM database_cycle_pending_operations + WHERE database_id = ?1 AND kind = 'cycles_purchase' + ORDER BY operation_id ASC + LIMIT 1", + params![database_id], + map_database_cycles_pending_purchase_raw, + ) + .optional() + .map_err(|error| error.to_string())? + .map(DatabaseCyclesPendingPurchaseRaw::into_public) + .transpose() +} + +fn map_database_cycles_pending_purchase_raw( + row: &crate::sqlite::Row<'_>, +) -> crate::sqlite::Result { + Ok(DatabaseCyclesPendingPurchaseRaw { + operation_id: crate::sqlite::row_get(row, 0)?, + database_id: crate::sqlite::row_get(row, 1)?, + caller: crate::sqlite::row_get(row, 2)?, + status: crate::sqlite::row_get(row, 3)?, + amount_cycles: crate::sqlite::row_get(row, 4)?, + payment_amount_e8s: crate::sqlite::row_get(row, 5)?, + ledger_block_index: crate::sqlite::row_get(row, 6)?, + created_at_ms: crate::sqlite::row_get(row, 7)?, + }) +} + +fn pending_cycles_required_action(status: &str) -> &'static str { + match status { + CYCLES_OPERATION_STATUS_IN_FLIGHT => "wait_for_ledger_result", + CYCLES_OPERATION_STATUS_AMBIGUOUS | CYCLES_OPERATION_STATUS_COMPLETED => { + "billing_authority_review" + } + _ => "billing_authority_review", + } +} + +fn map_pending_cycles_operation( + row: &crate::sqlite::Row<'_>, +) -> crate::sqlite::Result { + Ok(PendingCyclesOperation { + database_id: crate::sqlite::row_get(row, 0)?, + kind: crate::sqlite::row_get(row, 1)?, + caller: crate::sqlite::row_get(row, 2)?, + cycles: crate::sqlite::row_get(row, 3)?, + payment_amount_e8s: crate::sqlite::row_get(row, 4)?, + operation_status: crate::sqlite::row_get(row, 11)?, + ledger_block_index: crate::sqlite::row_get(row, 12)?, + }) +} + +struct PendingKinicOperation { + kind: String, + caller: String, + amount_e8s: i64, + operation_status: String, + external_block_index: Option, +} + +struct PendingKinicOperationInsert<'a> { + kind: &'a str, + caller: &'a str, + amount_e8s: i64, + ledger: PendingCyclesLedgerDetails<'a>, + operation_status: &'a str, + now: i64, +} + +struct PendingKinicOperationMatch<'a> { + operation_id: u64, + kind: &'a str, + caller: &'a str, + amount_e8s: i64, +} + +struct PendingKinicOperationRaw { + operation_id: i64, + kind: String, + caller: String, + status: String, + amount_e8s: i64, + external_block_index: Option, + created_at_ms: i64, +} + +impl PendingKinicOperationRaw { + fn into_public(self) -> Result { + let operation_id = u64::try_from(self.operation_id).map_err(|error| error.to_string())?; + let amount_e8s = u64::try_from(self.amount_e8s).map_err(|error| error.to_string())?; + let ledger_block_index = self + .external_block_index + .map(u64::try_from) + .transpose() + .map_err(|error| error.to_string())?; + Ok(KinicPendingOperation { + operation_id, + kind: self.kind, + caller: self.caller, + status: self.status.clone(), + amount_e8s, + ledger_block_index, + created_at_ms: self.created_at_ms, + required_action: pending_kinic_required_action(&self.status).to_string(), + }) + } +} + +struct KinicLedgerInsert<'a> { + principal: &'a str, + source: &'a str, + kind: &'a str, + amount_e8s: i64, + balance_after_e8s: i64, + counterparty: Option<&'a str>, + listing_id: Option<&'a str>, + order_id: Option<&'a str>, + external_block_index: Option, + now: i64, +} + +fn require_authenticated_principal(caller: &str) -> Result<(), String> { + if caller == ANONYMOUS_PRINCIPAL { + return Err("anonymous caller not allowed".to_string()); + } + Ok(()) +} + +fn insert_pending_kinic_operation( + conn: &Transaction<'_>, + operation: PendingKinicOperationInsert<'_>, +) -> Result { + let values = vec![ + crate::sqlite::text_value(operation.kind), + crate::sqlite::text_value(operation.caller), + crate::sqlite::integer_value(operation.amount_e8s), + crate::sqlite::text_value(operation.ledger.from_owner), + crate::sqlite::nullable_blob_value(operation.ledger.from_subaccount.map(Vec::from)), + crate::sqlite::text_value(operation.ledger.to_owner), + crate::sqlite::nullable_blob_value(operation.ledger.to_subaccount.map(Vec::from)), + crate::sqlite::integer_value(operation.ledger.ledger_fee_e8s), + crate::sqlite::integer_value(operation.ledger.ledger_created_at_time_ns), + crate::sqlite::text_value(operation.operation_status), + crate::sqlite::integer_value(operation.now), + ]; + crate::sqlite::execute_values( + conn, + "INSERT INTO kinic_pending_operations + (kind, caller, 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)", + &values, + ) + .map_err(|error| error.to_string())?; + let operation_id = crate::sqlite::last_insert_rowid(conn).map_err(|error| error.to_string())?; + u64::try_from(operation_id).map_err(|error| error.to_string()) +} + +fn load_pending_kinic_operation( conn: &Connection, operation_id: u64, -) -> Result { +) -> Result { let operation_id = i64::try_from(operation_id).map_err(|error| error.to_string())?; conn.query_row( - "SELECT 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, ledger_block_index - FROM database_cycle_pending_operations + "SELECT kind, caller, amount_e8s, operation_status, external_block_index + FROM kinic_pending_operations WHERE operation_id = ?1", params![operation_id], - map_pending_cycles_operation, + |row| { + Ok(PendingKinicOperation { + kind: crate::sqlite::row_get(row, 0)?, + caller: crate::sqlite::row_get(row, 1)?, + amount_e8s: crate::sqlite::row_get(row, 2)?, + operation_status: crate::sqlite::row_get(row, 3)?, + external_block_index: crate::sqlite::row_get(row, 4)?, + }) + }, ) .optional() .map_err(|error| error.to_string())? - .ok_or_else(|| "pending cycle operation not found".to_string()) + .ok_or_else(|| "pending KINIC operation not found".to_string()) } -fn require_pending_operation_status( - operation: &PendingCyclesOperation, +fn load_required_pending_kinic_operation( + conn: &Transaction<'_>, + expected: PendingKinicOperationMatch<'_>, +) -> Result { + let operation = load_pending_kinic_operation(conn, expected.operation_id)?; + if operation.kind != expected.kind + || operation.caller != expected.caller + || operation.amount_e8s != expected.amount_e8s + { + return Err("pending KINIC operation mismatch".to_string()); + } + Ok(operation) +} + +fn require_pending_kinic_operation_status( + operation: &PendingKinicOperation, allowed: &[&str], action: &str, ) -> Result<(), String> { @@ -3769,139 +5094,470 @@ fn require_pending_operation_status( return Ok(()); } Err(format!( - "cannot {action}; cycle purchase operation is {}", + "cannot {action}; KINIC operation is {}", operation.operation_status )) } -fn load_required_pending_cycles_operation( - conn: &Transaction<'_>, - 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.cycles != expected.cycles - { - return Err("pending cycle operation mismatch".to_string()); - } - Ok(operation) -} - -fn delete_pending_cycles_operation( - conn: &Transaction<'_>, - operation_id: u64, -) -> Result<(), String> { +fn delete_pending_kinic_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_cycle_pending_operations WHERE operation_id = ?1", + "DELETE FROM kinic_pending_operations WHERE operation_id = ?1", params![operation_id], ) .map_err(|error| error.to_string())?; Ok(()) } -fn ensure_no_pending_cycles_purchase_for_caller( +fn ensure_no_pending_kinic_deposit_for_caller( conn: &Connection, - database_id: &str, caller: &str, ) -> Result<(), String> { let count: i64 = conn .query_row( "SELECT COUNT(*) - FROM database_cycle_pending_operations - WHERE database_id = ?1 - AND caller = ?2 - AND kind = 'cycles_purchase'", - params![database_id, caller], + FROM kinic_pending_operations + WHERE caller = ?1 + AND kind = ?2 + AND operation_status IN (?3, ?4, ?5)", + params![ + caller, + KINIC_PENDING_KIND_DEPOSIT, + KINIC_OPERATION_STATUS_IN_FLIGHT, + KINIC_OPERATION_STATUS_COMPLETED, + KINIC_OPERATION_STATUS_AMBIGUOUS + ], |row| crate::sqlite::row_get(row, 0), ) .map_err(|error| error.to_string())?; if count > 0 { - return Err("cycles purchase already pending for caller".to_string()); + return Err("market deposit already pending for caller".to_string()); } Ok(()) } -fn load_database_cycles_pending_purchase_statuses( +fn load_kinic_pending_operations( + conn: &Connection, + caller: &str, + show_all: bool, +) -> Result, String> { + if show_all { + let mut stmt = conn + .prepare( + "SELECT operation_id, kind, caller, operation_status, amount_e8s, + external_block_index, created_at_ms + FROM kinic_pending_operations + WHERE kind = ?1 + ORDER BY operation_id ASC", + ) + .map_err(|error| error.to_string())?; + crate::sqlite::query_map( + &mut stmt, + params![KINIC_PENDING_KIND_DEPOSIT], + map_pending_kinic_operation_raw, + ) + .map_err(|error| error.to_string()) + } else { + let mut stmt = conn + .prepare( + "SELECT operation_id, kind, caller, operation_status, amount_e8s, + external_block_index, created_at_ms + FROM kinic_pending_operations + WHERE kind = ?1 AND caller = ?2 + ORDER BY operation_id ASC", + ) + .map_err(|error| error.to_string())?; + crate::sqlite::query_map( + &mut stmt, + params![KINIC_PENDING_KIND_DEPOSIT, caller], + map_pending_kinic_operation_raw, + ) + .map_err(|error| error.to_string()) + } +} + +fn map_pending_kinic_operation_raw( + row: &crate::sqlite::Row<'_>, +) -> crate::sqlite::Result { + Ok(PendingKinicOperationRaw { + operation_id: crate::sqlite::row_get(row, 0)?, + kind: crate::sqlite::row_get(row, 1)?, + caller: crate::sqlite::row_get(row, 2)?, + status: crate::sqlite::row_get(row, 3)?, + amount_e8s: crate::sqlite::row_get(row, 4)?, + external_block_index: crate::sqlite::row_get(row, 5)?, + created_at_ms: crate::sqlite::row_get(row, 6)?, + }) +} + +fn pending_kinic_required_action(status: &str) -> &'static str { + match status { + KINIC_OPERATION_STATUS_IN_FLIGHT => "wait_for_ledger_result", + KINIC_OPERATION_STATUS_AMBIGUOUS | KINIC_OPERATION_STATUS_COMPLETED => { + "billing_authority_review" + } + _ => "billing_authority_review", + } +} + +fn load_kinic_balance(conn: &Connection, principal: &str) -> Result { + conn.query_row( + "SELECT balance_e8s FROM kinic_accounts WHERE principal = ?1", + params![principal], + |row| crate::sqlite::row_get(row, 0), + ) + .optional() + .map_err(|error| error.to_string()) + .map(|balance| balance.unwrap_or(0)) +} + +fn upsert_kinic_balance( + conn: &Transaction<'_>, + principal: &str, + balance_e8s: i64, + now: i64, +) -> Result<(), String> { + conn.execute( + "INSERT INTO kinic_accounts (principal, balance_e8s, created_at_ms, updated_at_ms) + VALUES (?1, ?2, ?3, ?3) + ON CONFLICT(principal) DO UPDATE SET + balance_e8s = excluded.balance_e8s, + updated_at_ms = excluded.updated_at_ms", + params![principal, balance_e8s, now], + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +fn insert_kinic_ledger(conn: &Transaction<'_>, entry: KinicLedgerInsert<'_>) -> Result<(), String> { + conn.execute( + "INSERT INTO kinic_ledger + (principal, source, kind, amount_e8s, balance_after_e8s, counterparty, + listing_id, order_id, external_block_index, created_at_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + entry.principal, + entry.source, + entry.kind, + entry.amount_e8s, + entry.balance_after_e8s, + entry.counterparty, + entry.listing_id, + entry.order_id, + entry.external_block_index, + entry.now + ], + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +fn require_market_seller_can_list( conn: &Connection, + seller: &str, database_id: &str, -) -> Result, String> { - let mut stmt = conn - .prepare( - "SELECT operation_id, database_id, caller, operation_status, cycles, - payment_amount_e8s, ledger_block_index, created_at_ms - FROM database_cycle_pending_operations - WHERE database_id = ?1 AND kind = 'cycles_purchase' - ORDER BY operation_id ASC", +) -> Result<(), String> { + load_database_status(conn, database_id)?; + let role = load_member_role(conn, database_id, seller)? + .ok_or_else(|| format!("principal has no access to database: {database_id}"))?; + if role != DatabaseRole::Owner { + return Err("market seller must be database owner".to_string()); + } + Ok(()) +} + +fn require_market_listing_seller_or_admin( + conn: &Connection, + caller: &str, + listing: &MarketListing, +) -> Result<(), String> { + let config = load_cycles_billing_config(conn)?; + if caller == listing.seller_principal || caller == config.billing_authority_id { + return Ok(()); + } + Err("market listing seller or admin required".to_string()) +} + +fn require_market_listing_purchasable( + conn: &Connection, + listing: &MarketListing, +) -> Result<(), String> { + if listing.status != MarketListingStatus::Active { + return Err("market listing is not active".to_string()); + } + require_market_seller_can_list(conn, &listing.seller_principal, &listing.database_id) +} + +fn has_active_market_entitlement( + conn: &Connection, + database_id: &str, + caller: &str, +) -> Result { + if caller == ANONYMOUS_PRINCIPAL { + return Ok(false); + } + let count: i64 = conn + .query_row( + "SELECT COUNT(*) + FROM market_entitlements + WHERE database_id = ?1 + AND buyer_principal = ?2 + AND status = ?3", + params![database_id, caller, MARKET_ENTITLEMENT_STATUS_ACTIVE], + |row| crate::sqlite::row_get(row, 0), ) .map_err(|error| error.to_string())?; - crate::sqlite::query_map( - &mut stmt, - params![database_id], - map_database_cycles_pending_purchase_raw, + Ok(count > 0) +} + +fn load_market_listing_by_id( + conn: &Connection, + listing_id: &str, +) -> Result, String> { + conn.query_row( + "SELECT listing_id, seller_principal, database_id, title, description, + llm_summary, summary_snapshot_revision, sample_excerpts_json, + sample_questions_json, tags_json, price_e8s, status, revision, + purchase_count, report_count, created_at_ms, updated_at_ms + FROM market_listings + WHERE listing_id = ?1", + params![listing_id], + map_market_listing, ) + .optional() .map_err(|error| error.to_string()) } -fn first_database_cycles_pending_purchase_status( +fn map_market_listing(row: &crate::sqlite::Row<'_>) -> crate::sqlite::Result { + let price_e8s: i64 = crate::sqlite::row_get(row, 10)?; + let revision: i64 = crate::sqlite::row_get(row, 12)?; + let purchase_count: i64 = crate::sqlite::row_get(row, 13)?; + let report_count: i64 = crate::sqlite::row_get(row, 14)?; + Ok(MarketListing { + listing_id: crate::sqlite::row_get(row, 0)?, + seller_principal: crate::sqlite::row_get(row, 1)?, + database_id: crate::sqlite::row_get(row, 2)?, + title: crate::sqlite::row_get(row, 3)?, + description: crate::sqlite::row_get(row, 4)?, + llm_summary: crate::sqlite::row_get(row, 5)?, + summary_snapshot_revision: crate::sqlite::row_get(row, 6)?, + sample_excerpts_json: crate::sqlite::row_get(row, 7)?, + sample_questions_json: crate::sqlite::row_get(row, 8)?, + tags_json: crate::sqlite::row_get(row, 9)?, + price_e8s: u64::try_from(price_e8s) + .map_err(|_| crate::sqlite::integral_value_out_of_range(10, price_e8s))?, + status: market_listing_status_from_db(&crate::sqlite::row_get::(row, 11)?)?, + revision: u64::try_from(revision) + .map_err(|_| crate::sqlite::integral_value_out_of_range(12, revision))?, + purchase_count: u64::try_from(purchase_count) + .map_err(|_| crate::sqlite::integral_value_out_of_range(13, purchase_count))?, + report_count: u64::try_from(report_count) + .map_err(|_| crate::sqlite::integral_value_out_of_range(14, report_count))?, + created_at_ms: crate::sqlite::row_get(row, 15)?, + updated_at_ms: crate::sqlite::row_get(row, 16)?, + }) +} + +fn load_market_order_by_id( conn: &Connection, - database_id: &str, -) -> Result, String> { + order_id: &str, +) -> Result, String> { conn.query_row( - "SELECT operation_id, database_id, caller, operation_status, cycles, - payment_amount_e8s, ledger_block_index, created_at_ms - FROM database_cycle_pending_operations - WHERE database_id = ?1 AND kind = 'cycles_purchase' - ORDER BY operation_id ASC - LIMIT 1", - params![database_id], - map_database_cycles_pending_purchase_raw, + "SELECT order_id, listing_id, database_id, buyer_principal, seller_principal, + price_e8s, listing_revision, created_at_ms + FROM market_orders + WHERE order_id = ?1", + params![order_id], + map_market_order, ) .optional() - .map_err(|error| error.to_string())? - .map(DatabaseCyclesPendingPurchaseRaw::into_public) - .transpose() + .map_err(|error| error.to_string()) } -fn map_database_cycles_pending_purchase_raw( - row: &crate::sqlite::Row<'_>, -) -> crate::sqlite::Result { - Ok(DatabaseCyclesPendingPurchaseRaw { - operation_id: crate::sqlite::row_get(row, 0)?, - database_id: crate::sqlite::row_get(row, 1)?, - caller: crate::sqlite::row_get(row, 2)?, - status: crate::sqlite::row_get(row, 3)?, - amount_cycles: crate::sqlite::row_get(row, 4)?, - payment_amount_e8s: crate::sqlite::row_get(row, 5)?, - ledger_block_index: crate::sqlite::row_get(row, 6)?, +fn map_market_order(row: &crate::sqlite::Row<'_>) -> crate::sqlite::Result { + let price_e8s: i64 = crate::sqlite::row_get(row, 5)?; + let listing_revision: i64 = crate::sqlite::row_get(row, 6)?; + Ok(MarketOrder { + order_id: crate::sqlite::row_get(row, 0)?, + listing_id: crate::sqlite::row_get(row, 1)?, + database_id: crate::sqlite::row_get(row, 2)?, + buyer_principal: crate::sqlite::row_get(row, 3)?, + seller_principal: crate::sqlite::row_get(row, 4)?, + price_e8s: u64::try_from(price_e8s) + .map_err(|_| crate::sqlite::integral_value_out_of_range(5, price_e8s))?, + listing_revision: u64::try_from(listing_revision) + .map_err(|_| crate::sqlite::integral_value_out_of_range(6, listing_revision))?, created_at_ms: crate::sqlite::row_get(row, 7)?, }) } -fn pending_cycles_required_action(status: &str) -> &'static str { - match status { - CYCLES_OPERATION_STATUS_IN_FLIGHT => "wait_for_ledger_result", - CYCLES_OPERATION_STATUS_AMBIGUOUS | CYCLES_OPERATION_STATUS_COMPLETED => { - "billing_authority_review" +fn map_market_entitlement( + row: &crate::sqlite::Row<'_>, +) -> crate::sqlite::Result { + Ok(MarketEntitlement { + database_id: crate::sqlite::row_get(row, 0)?, + buyer_principal: crate::sqlite::row_get(row, 1)?, + listing_id: crate::sqlite::row_get(row, 2)?, + order_id: crate::sqlite::row_get(row, 3)?, + purchased_at_ms: crate::sqlite::row_get(row, 4)?, + status: crate::sqlite::row_get(row, 5)?, + }) +} + +fn market_listing_status_from_db(value: &str) -> crate::sqlite::Result { + match value { + MARKET_LISTING_STATUS_DRAFT => Ok(MarketListingStatus::Draft), + MARKET_LISTING_STATUS_ACTIVE => Ok(MarketListingStatus::Active), + MARKET_LISTING_STATUS_PAUSED => Ok(MarketListingStatus::Paused), + _ => Err(crate::sqlite::invalid_query()), + } +} + +fn validate_market_create_listing_request( + request: &MarketCreateListingRequest, +) -> Result<(), String> { + validate_database_id(&request.database_id)?; + validate_market_listing_metadata( + &request.title, + &request.description, + request.llm_summary.as_deref(), + request.summary_snapshot_revision.as_deref(), + &request.sample_excerpts_json, + &request.sample_questions_json, + &request.tags_json, + request.price_e8s, + ) +} + +fn validate_market_update_listing_request( + request: &MarketUpdateListingRequest, +) -> Result<(), String> { + validate_market_id(&request.listing_id, GENERATED_LISTING_ID_PREFIX)?; + validate_market_listing_metadata( + &request.title, + &request.description, + request.llm_summary.as_deref(), + request.summary_snapshot_revision.as_deref(), + &request.sample_excerpts_json, + &request.sample_questions_json, + &request.tags_json, + request.price_e8s, + ) +} + +fn validate_market_listing_metadata( + title: &str, + description: &str, + llm_summary: Option<&str>, + summary_snapshot_revision: Option<&str>, + sample_excerpts_json: &str, + sample_questions_json: &str, + tags_json: &str, + price_e8s: u64, +) -> Result<(), String> { + if price_e8s == 0 { + return Err("market listing price must be positive".to_string()); + } + amount_to_i64(price_e8s)?; + validate_market_text("market listing title", title, 1, MAX_MARKET_TITLE_CHARS)?; + validate_market_text( + "market listing description", + description, + 1, + MAX_MARKET_DESCRIPTION_CHARS, + )?; + if let Some(summary) = llm_summary { + validate_market_text( + "market listing summary", + summary, + 0, + MAX_MARKET_DESCRIPTION_CHARS, + )?; + } + if let Some(revision) = summary_snapshot_revision { + validate_market_text("market listing summary revision", revision, 0, 256)?; + } + validate_market_text( + "market listing sample excerpts", + sample_excerpts_json, + 0, + MAX_MARKET_JSON_CHARS, + )?; + validate_market_text( + "market listing sample questions", + sample_questions_json, + 0, + MAX_MARKET_JSON_CHARS, + )?; + validate_market_text("market listing tags", tags_json, 0, MAX_MARKET_JSON_CHARS) +} + +fn validate_market_text( + label: &str, + value: &str, + min_chars: usize, + max_chars: usize, +) -> Result<(), String> { + let count = value.chars().count(); + if count < min_chars || count > max_chars { + return Err(format!( + "{label} must be {min_chars}..{max_chars} characters" + )); + } + if value.chars().any(char::is_control) { + return Err(format!("{label} may not contain control characters")); + } + Ok(()) +} + +fn validate_market_id(value: &str, prefix: &str) -> Result<(), String> { + if !value.starts_with(prefix) { + return Err("market id has invalid prefix".to_string()); + } + let suffix = &value[prefix.len()..]; + if suffix.is_empty() + || suffix + .chars() + .any(|character| !character.is_ascii_lowercase() && !character.is_ascii_digit()) + { + return Err("market id has invalid characters".to_string()); + } + Ok(()) +} + +fn unique_market_id( + conn: &Connection, + table: &str, + column: &str, + prefix: &str, + caller: &str, + seed: &str, + now: i64, +) -> Result { + for attempt in 0..16_u32 { + let id = generated_market_id(prefix, caller, seed, now, attempt); + let sql = format!("SELECT COUNT(*) FROM {table} WHERE {column} = ?1"); + let count: i64 = conn + .query_row(&sql, params![id], |row| crate::sqlite::row_get(row, 0)) + .map_err(|error| error.to_string())?; + if count == 0 { + return Ok(id); } - _ => "billing_authority_review", } + Err("failed to allocate market id".to_string()) } -fn map_pending_cycles_operation( - row: &crate::sqlite::Row<'_>, -) -> crate::sqlite::Result { - Ok(PendingCyclesOperation { - database_id: crate::sqlite::row_get(row, 0)?, - kind: crate::sqlite::row_get(row, 1)?, - caller: crate::sqlite::row_get(row, 2)?, - cycles: crate::sqlite::row_get(row, 3)?, - payment_amount_e8s: crate::sqlite::row_get(row, 4)?, - operation_status: crate::sqlite::row_get(row, 11)?, - ledger_block_index: crate::sqlite::row_get(row, 12)?, - }) +fn generated_market_id(prefix: &str, caller: &str, seed: &str, now: i64, attempt: u32) -> String { + let mut hasher = Sha256::new(); + hasher.update(prefix.as_bytes()); + hasher.update(caller.as_bytes()); + hasher.update(seed.as_bytes()); + hasher.update(now.to_be_bytes()); + hasher.update(attempt.to_be_bytes()); + format!( + "{prefix}{}", + &base32_lower(&hasher.finalize())[..GENERATED_MARKET_ID_HASH_CHARS] + ) } struct DatabaseLedgerInsert<'a> { diff --git a/crates/vfs_runtime/tests/database_service.rs b/crates/vfs_runtime/tests/database_service.rs index b9864cd..defd58e 100644 --- a/crates/vfs_runtime/tests/database_service.rs +++ b/crates/vfs_runtime/tests/database_service.rs @@ -8,16 +8,17 @@ use sha2::{Digest, Sha256}; use tempfile::tempdir; use vfs_runtime::{ CyclesPendingLedgerDetailsInput, DEFAULT_LLM_WRITER_PRINCIPAL, - DatabaseCyclesPurchaseWithLedgerDetails, MAX_ARCHIVE_CHUNK_BYTES, MAX_DATABASE_SIZE_BYTES, - MAX_RESTORE_CHUNK_BYTES, VfsService, cycles_for_payment_amount_e8s, + DatabaseCyclesPurchaseWithLedgerDetails, KinicDepositWithLedgerDetails, + MAX_ARCHIVE_CHUNK_BYTES, MAX_DATABASE_SIZE_BYTES, MAX_RESTORE_CHUNK_BYTES, VfsService, + cycles_for_payment_amount_e8s, }; use vfs_types::{ AppendNodeRequest, CyclesBillingConfigUpdate, DatabaseRole, DatabaseStatus, DeleteDatabaseRequest, DeleteNodeRequest, EditNodeRequest, KINIC_LEDGER_FEE_E8S, - MkdirNodeRequest, MoveNodeRequest, NodeKind, OpsAnswerSessionCheckRequest, - OpsAnswerSessionRequest, SearchNodesRequest, SearchPreviewMode, SourceRunSessionCheckRequest, - UrlIngestTriggerSessionCheckRequest, UrlIngestTriggerSessionRequest, WriteNodeRequest, - WriteSourceForGenerationRequest, + MarketCreateListingRequest, MarketPurchaseRequest, MkdirNodeRequest, MoveNodeRequest, NodeKind, + OpsAnswerSessionCheckRequest, OpsAnswerSessionRequest, SearchNodesRequest, SearchPreviewMode, + SourceRunSessionCheckRequest, UrlIngestTriggerSessionCheckRequest, + UrlIngestTriggerSessionRequest, WriteNodeRequest, WriteSourceForGenerationRequest, }; fn service() -> VfsService { @@ -40,6 +41,50 @@ fn delete_request(database_id: &str) -> DeleteDatabaseRequest { } } +fn market_listing_request(database_id: &str, price_e8s: u64) -> MarketCreateListingRequest { + MarketCreateListingRequest { + database_id: database_id.to_string(), + title: "Team database".to_string(), + description: "Reusable team knowledge base".to_string(), + llm_summary: None, + summary_snapshot_revision: None, + sample_excerpts_json: "[]".to_string(), + sample_questions_json: "[]".to_string(), + tags_json: "[]".to_string(), + price_e8s, + } +} + +fn credit_market_balance( + service: &VfsService, + caller: &str, + amount_e8s: u64, + block_index: u64, + now: i64, +) { + let start = service + .begin_kinic_deposit_with_ledger_details(KinicDepositWithLedgerDetails { + caller, + amount_e8s, + ledger: CyclesPendingLedgerDetailsInput { + from_owner: caller, + from_subaccount: None, + to_owner: "canister", + to_subaccount: None, + ledger_fee_e8s: KINIC_LEDGER_FEE_E8S, + ledger_created_at_time_ns: u64::try_from(now).expect("now should fit u64"), + }, + now, + }) + .expect("deposit should begin"); + service + .complete_kinic_deposit_ledger_transfer(start.operation_id, caller, amount_e8s, block_index) + .expect("ledger transfer should complete"); + service + .apply_kinic_deposit(start.operation_id, caller, amount_e8s, now + 1) + .expect("deposit should credit balance"); +} + #[test] fn mainnet_011_index_upgrades_to_latest() { let dir = tempdir().expect("tempdir should create"); @@ -486,6 +531,20 @@ fn database_member_count(root: &std::path::Path, database_id: &str) -> i64 { .expect("member count should load") } +fn market_row_count(root: &std::path::Path, table: &str, database_id: &str) -> i64 { + assert!( + ["market_listings", "market_orders", "market_entitlements"].contains(&table), + "test helper must only read marketplace tables" + ); + let conn = Connection::open(root.join("index.sqlite3")).expect("index should open"); + conn.query_row( + &format!("SELECT COUNT(*) FROM {table} WHERE database_id = ?1"), + params![database_id], + |row| row.get(0), + ) + .expect("market row count should load") +} + 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( @@ -4186,3 +4245,253 @@ fn move_node_validates_source_target_path() { ) .expect("canonical source target should pass"); } + +#[test] +fn market_purchase_moves_internal_balance_and_creates_entitlement() { + let service = service(); + service + .create_database("market-db", "seller", 1) + .expect("database should create"); + let listing = service + .market_create_listing("seller", market_listing_request("market-db", 250), 2) + .expect("listing should create"); + let listing = service + .market_publish_listing("seller", &listing.listing_id, 3) + .expect("listing should publish"); + credit_market_balance(&service, "buyer", 1_000, 10, 5); + + let order = service + .market_purchase_access( + "buyer", + MarketPurchaseRequest { + listing_id: listing.listing_id.clone(), + price_e8s: listing.price_e8s, + expected_revision: listing.revision, + }, + 6, + ) + .expect("purchase should succeed"); + + assert_eq!(order.buyer_principal, "buyer"); + assert_eq!(order.seller_principal, "seller"); + assert_eq!( + service + .market_get_balance("buyer") + .expect("buyer balance should load") + .balance_e8s, + 750 + ); + assert_eq!( + service + .market_get_balance("seller") + .expect("seller balance should load") + .balance_e8s, + 250 + ); + assert_eq!( + service + .market_list_entitlements("buyer", None, 10) + .expect("entitlements should load") + .entitlements + .len(), + 1 + ); + let second_listing = service + .market_create_listing("seller", market_listing_request("market-db", 300), 7) + .expect("second listing should create"); + let second_listing = service + .market_publish_listing("seller", &second_listing.listing_id, 8) + .expect("second listing should publish"); + assert!( + service + .market_purchase_access( + "buyer", + MarketPurchaseRequest { + listing_id: second_listing.listing_id, + price_e8s: second_listing.price_e8s, + expected_revision: second_listing.revision, + }, + 9, + ) + .is_err(), + "same buyer cannot repurchase the same database" + ); +} + +#[test] +fn market_purchase_rejects_seller_self_purchase() { + let service = service(); + service + .create_database("self-market", "seller", 1) + .expect("database should create"); + let listing = service + .market_create_listing("seller", market_listing_request("self-market", 250), 2) + .expect("listing should create"); + let listing = service + .market_publish_listing("seller", &listing.listing_id, 3) + .expect("listing should publish"); + credit_market_balance(&service, "seller", 1_000, 10, 4); + + let error = service + .market_purchase_access( + "seller", + MarketPurchaseRequest { + listing_id: listing.listing_id, + price_e8s: listing.price_e8s, + expected_revision: listing.revision, + }, + 5, + ) + .expect_err("seller must not buy their own listing"); + + assert!(error.contains("seller cannot purchase own listing")); + assert_eq!( + service + .market_get_balance("seller") + .expect("seller balance should load") + .balance_e8s, + 1_000 + ); +} + +#[test] +fn market_listing_owner_policy_and_database_listing_query() { + let service = service(); + service + .create_database("owner-market", "seller", 1) + .expect("database should create"); + service + .grant_database_access("owner-market", "seller", "reader", DatabaseRole::Reader, 2) + .expect("reader should be granted"); + assert!( + service + .market_create_listing("reader", market_listing_request("owner-market", 100), 3) + .is_err(), + "non-owner must not create listing" + ); + + let listing = service + .market_create_listing("seller", market_listing_request("owner-market", 100), 4) + .expect("owner should create listing"); + assert!( + service + .market_list_database_listings("reader", "owner-market") + .is_err(), + "non-owner must not list database listings" + ); + let listings = service + .market_list_database_listings("seller", "owner-market") + .expect("owner should list database listings"); + assert_eq!(listings.len(), 1); + assert_eq!(listings[0].listing_id, listing.listing_id); +} + +#[test] +fn delete_database_removes_marketplace_rows() { + let (service, root) = service_with_root(); + service + .create_database("market-delete", "seller", 1) + .expect("database should create"); + let listing = service + .market_create_listing("seller", market_listing_request("market-delete", 100), 2) + .expect("listing should create"); + let listing = service + .market_publish_listing("seller", &listing.listing_id, 3) + .expect("listing should publish"); + credit_market_balance(&service, "buyer", 100, 10, 4); + service + .market_purchase_access( + "buyer", + MarketPurchaseRequest { + listing_id: listing.listing_id, + price_e8s: listing.price_e8s, + expected_revision: listing.revision, + }, + 5, + ) + .expect("purchase should succeed"); + assert_eq!( + market_row_count(&root, "market_listings", "market-delete"), + 1 + ); + assert_eq!(market_row_count(&root, "market_orders", "market-delete"), 1); + assert_eq!( + market_row_count(&root, "market_entitlements", "market-delete"), + 1 + ); + + service + .delete_database(delete_request("market-delete"), "seller", 6) + .expect("delete should succeed"); + assert_eq!( + market_row_count(&root, "market_listings", "market-delete"), + 0 + ); + assert_eq!(market_row_count(&root, "market_orders", "market-delete"), 0); + assert_eq!( + market_row_count(&root, "market_entitlements", "market-delete"), + 0 + ); +} + +#[test] +fn market_entitlement_allows_read_surface_but_not_export() { + let service = service(); + service + .create_database("read-market", "seller", 1) + .expect("database should create"); + service + .write_node( + "seller", + WriteNodeRequest { + database_id: "read-market".to_string(), + path: "/Wiki/private.md".to_string(), + kind: NodeKind::File, + content: "paid body".to_string(), + metadata_json: "{}".to_string(), + expected_etag: None, + }, + 2, + ) + .expect("owner should write"); + let listing = service + .market_create_listing("seller", market_listing_request("read-market", 100), 3) + .expect("listing should create"); + let listing = service + .market_publish_listing("seller", &listing.listing_id, 4) + .expect("listing should publish"); + credit_market_balance(&service, "buyer", 100, 20, 6); + service + .market_purchase_access( + "buyer", + MarketPurchaseRequest { + listing_id: listing.listing_id, + price_e8s: listing.price_e8s, + expected_revision: listing.revision, + }, + 7, + ) + .expect("purchase should succeed"); + + let node = service + .read_node("read-market", "buyer", "/Wiki/private.md") + .expect("entitled buyer should read") + .expect("node should exist"); + assert_eq!(node.content, "paid body"); + assert!( + service + .export_fs_snapshot( + "buyer", + vfs_types::ExportSnapshotRequest { + database_id: "read-market".to_string(), + prefix: None, + limit: 10, + cursor: None, + snapshot_session_id: None, + snapshot_revision: None, + }, + ) + .is_err(), + "entitlement must not allow export" + ); +} diff --git a/crates/vfs_types/src/fs.rs b/crates/vfs_types/src/fs.rs index 8e00d44..165db78 100644 --- a/crates/vfs_types/src/fs.rs +++ b/crates/vfs_types/src/fs.rs @@ -124,6 +124,151 @@ pub struct DatabaseCyclesPendingPurchase { pub required_action: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct KinicBalance { + pub balance_e8s: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct KinicPendingOperation { + pub operation_id: u64, + pub kind: String, + pub caller: String, + pub status: String, + pub amount_e8s: u64, + pub ledger_block_index: Option, + pub created_at_ms: i64, + pub required_action: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct MarketDepositRequest { + pub amount_e8s: u64, + pub expected_fee_e8s: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct MarketDepositResult { + pub block_index: u64, + pub amount_e8s: u64, + pub balance_e8s: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +#[serde(rename_all = "snake_case")] +pub enum MarketListingStatus { + #[serde(alias = "Draft")] + Draft, + #[serde(alias = "Active")] + Active, + #[serde(alias = "Paused")] + Paused, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct MarketListing { + pub listing_id: String, + pub seller_principal: String, + pub database_id: String, + pub title: String, + pub description: String, + pub llm_summary: Option, + pub summary_snapshot_revision: Option, + pub sample_excerpts_json: String, + pub sample_questions_json: String, + pub tags_json: String, + pub price_e8s: u64, + pub status: MarketListingStatus, + pub revision: u64, + pub purchase_count: u64, + pub report_count: u64, + pub created_at_ms: i64, + pub updated_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct MarketListingPage { + pub listings: Vec, + pub next_cursor: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct MarketCreateListingRequest { + pub database_id: String, + pub title: String, + pub description: String, + pub llm_summary: Option, + pub summary_snapshot_revision: Option, + pub sample_excerpts_json: String, + pub sample_questions_json: String, + pub tags_json: String, + pub price_e8s: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct MarketUpdateListingRequest { + pub listing_id: String, + pub expected_revision: u64, + pub title: String, + pub description: String, + pub llm_summary: Option, + pub summary_snapshot_revision: Option, + pub sample_excerpts_json: String, + pub sample_questions_json: String, + pub tags_json: String, + pub price_e8s: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct MarketPurchaseRequest { + pub listing_id: String, + pub price_e8s: u64, + pub expected_revision: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct MarketPurchasePreview { + pub listing_id: String, + pub database_id: String, + pub price_e8s: u64, + pub buyer_balance_e8s: u64, + pub already_entitled: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct MarketOrder { + pub order_id: String, + pub listing_id: String, + pub database_id: String, + pub buyer_principal: String, + pub seller_principal: String, + pub price_e8s: u64, + pub listing_revision: u64, + pub created_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct MarketOrderPage { + pub orders: Vec, + pub next_cursor: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct MarketEntitlement { + pub database_id: String, + pub buyer_principal: String, + pub listing_id: String, + pub order_id: String, + pub purchased_at_ms: i64, + pub status: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct MarketEntitlementPage { + pub entitlements: Vec, + pub next_cursor: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] pub struct StorageBillingBatchRequest { pub cursor_mount_id: Option, diff --git a/wikibrowser/app/app-header.tsx b/wikibrowser/app/app-header.tsx index 5344f61..1028f06 100644 --- a/wikibrowser/app/app-header.tsx +++ b/wikibrowser/app/app-header.tsx @@ -29,9 +29,10 @@ export function AppHeader() { walletControlsLocked } = useAppSession(); - if (pathname !== "/" && pathname !== "/cycles") return null; + const isMarketplace = pathname === "/marketplace" || pathname.startsWith("/marketplace/"); + if (pathname !== "/" && pathname !== "/cycles" && !isMarketplace) return null; - const title = pathname === "/cycles" ? "Database cycles purchase" : "Database dashboard"; + const title = pathname === "/cycles" ? "Database cycles purchase" : isMarketplace ? "Kinic marketplace" : "Database dashboard"; const connectedWalletLabel = wallet ? `${walletLabel(wallet.provider)} ${shortPrincipal(connectedWalletPrincipal(wallet))}` : null; const connectedWalletBalanceLabel = walletBalance ? formatTokenAmountFromE8s(walletBalance) : null; @@ -40,13 +41,7 @@ export function AppHeader() {
- Database dashboard - - ) : null - } + nav={} actions={ <> + {pathname !== "/" ? ( + + Dashboard + + ) : null} + {pathname !== "/marketplace" ? ( + + Marketplace + + ) : null} + {pathname !== "/marketplace/wallet" ? ( + + Wallet + + ) : null} + + ); +} + function shortPrincipal(value: string): string { if (value.length <= 16) return value; return `${value.slice(0, 8)}...${value.slice(-5)}`; diff --git a/wikibrowser/app/app-session-provider.tsx b/wikibrowser/app/app-session-provider.tsx index b0582a8..8842056 100644 --- a/wikibrowser/app/app-session-provider.tsx +++ b/wikibrowser/app/app-session-provider.tsx @@ -6,7 +6,7 @@ import { AuthClient } from "@icp-sdk/auth/client"; import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"; import { AUTH_CLIENT_CREATE_OPTIONS, authLoginOptions } from "@/lib/auth"; -import { connectOisyWallet, connectPlugWallet, getConnectedWalletKinicBalance, type ConnectedKinicWallet } from "@/lib/cycles-wallet"; +import { connectOisyWallet, connectPlugWallet, getConnectedWalletKinicBalance, type ConnectedKinicWallet } from "@/lib/kinic-wallet"; import type { HeaderWalletProvider } from "./home-ui"; type AppSessionContext = { diff --git a/wikibrowser/app/cycles/cycles-client.tsx b/wikibrowser/app/cycles/cycles-client.tsx index 5346f52..9bb15c5 100644 --- a/wikibrowser/app/cycles/cycles-client.tsx +++ b/wikibrowser/app/cycles/cycles-client.tsx @@ -8,7 +8,7 @@ import { CheckCircle2, CircleAlert, Info, PlugZap, Wallet } from "lucide-react"; import { useMemo, useState } from "react"; import { useAppSession } from "@/app/app-session-provider"; import { parseKinicAmountE8sInput, parseCyclesTarget } from "@/lib/cycles-url"; -import { purchaseCyclesWithOisy, purchaseCyclesWithPlug } from "@/lib/cycles-wallet"; +import { purchaseCyclesWithOisy, purchaseCyclesWithPlug } from "@/lib/kinic-wallet"; import { formatTokenAmountFromE8s } from "@/lib/kinic-amount"; import type { DatabaseStatus } from "@/lib/types"; diff --git a/wikibrowser/app/dashboard/dashboard-client.tsx b/wikibrowser/app/dashboard/dashboard-client.tsx index 79264cb..58432d3 100644 --- a/wikibrowser/app/dashboard/dashboard-client.tsx +++ b/wikibrowser/app/dashboard/dashboard-client.tsx @@ -10,7 +10,7 @@ import { AuthControls, CyclesHistoryPanel, DashboardTabs, OwnerPanel, PendingDat import { AdminHeader } from "@/components/admin-header"; import { CycleBattery } from "@/components/cycle-battery"; import { AUTH_CLIENT_CREATE_OPTIONS, authLoginOptions } from "@/lib/auth"; -import type { CyclesBillingConfig, DatabaseCycleEntry, DatabaseCyclesPendingPurchase, DatabaseMember, DatabaseRole, DatabaseSummary } from "@/lib/types"; +import type { CyclesBillingConfig, DatabaseCycleEntry, DatabaseCyclesPendingPurchase, DatabaseMember, DatabaseRole, DatabaseSummary, MarketCreateListingRequest, MarketListing, MarketUpdateListingRequest } from "@/lib/types"; import { deleteDatabaseAuthenticated, getCyclesBillingConfig, @@ -21,6 +21,12 @@ import { listDatabaseMembersPublic, listDatabasesAuthenticated, listDatabasesPublic, + marketCountActiveEntitlements, + marketCreateListing, + marketListDatabaseListings, + marketPauseListing, + marketPublishListing, + marketUpdateListing, renameDatabaseAuthenticated, revokeDatabaseAccessAuthenticated } from "@/lib/vfs-client"; @@ -57,6 +63,10 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) const [pendingPurchases, setPendingPurchases] = useState([]); const [pendingPurchasesError, setPendingPurchasesError] = useState(null); const [pendingPurchasesLoading, setPendingPurchasesLoading] = useState(false); + const [marketListings, setMarketListings] = useState([]); + const [marketError, setMarketError] = useState(null); + const [activeEntitlementCount, setActiveEntitlementCount] = useState(null); + const [marketBusy, setMarketBusy] = useState(false); const database = useMemo(() => databases.find((item) => item.databaseId === databaseId) ?? null, [databaseId, databases]); const isActiveDatabase = database?.status === "active"; @@ -88,6 +98,9 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) setDatabases([]); setCyclesBillingConfig(null); setMembers([]); + setMarketListings([]); + setMarketError(null); + setActiveEntitlementCount(null); setError(null); setWarning(null); setMemberError(null); @@ -121,6 +134,9 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) setDatabases(nextDatabases); setCyclesBillingConfig(cyclesResult.status === "fulfilled" ? cyclesResult.value : null); setMembers([]); + setMarketListings([]); + setMarketError(null); + setActiveEntitlementCount(null); if (publicResult.status === "rejected") { setWarning(`Public database list unavailable: ${errorMessage(publicResult.reason)}`); } @@ -129,13 +145,26 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) } if (identity && nextDatabase?.role === "owner") { if (nextDatabase.status === "active") { - try { - const nextMembers = await listDatabaseMembersAuthenticated(canisterId, identity, nextDatabaseId); - if (!isCurrentRefresh()) return; - setMembers(nextMembers); - } catch (cause) { - if (!isCurrentRefresh()) return; - setMemberError(errorMessage(cause)); + const [membersResult, listingsResult, entitlementCountResult] = await Promise.allSettled([ + listDatabaseMembersAuthenticated(canisterId, identity, nextDatabaseId), + marketListDatabaseListings(canisterId, identity, nextDatabaseId), + marketCountActiveEntitlements(canisterId, identity, nextDatabaseId) + ]); + if (!isCurrentRefresh()) return; + if (membersResult.status === "fulfilled") { + setMembers(membersResult.value); + } else { + setMemberError(errorMessage(membersResult.reason)); + } + if (listingsResult.status === "fulfilled") { + setMarketListings(listingsResult.value); + } else { + setMarketError(errorMessage(listingsResult.reason)); + } + if (entitlementCountResult.status === "fulfilled") { + setActiveEntitlementCount(entitlementCountResult.value); + } else { + setMarketError((current) => [current, errorMessage(entitlementCountResult.reason)].filter(Boolean).join("; ")); } } } else if (nextDatabase?.publicReadable && nextDatabase.status === "active") { @@ -261,6 +290,9 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) setDatabases([]); setCyclesBillingConfig(null); setMembers([]); + setMarketListings([]); + setMarketError(null); + setActiveEntitlementCount(null); setError(null); setWarning(null); setMemberError(null); @@ -364,6 +396,70 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) } } + async function createMarketListing(request: MarketCreateListingRequest) { + if (!authClient || !databaseId) return; + setMarketBusy(true); + setActionMessage(null); + try { + await marketCreateListing(canisterId, authClient.getIdentity(), request); + setActionTone("info"); + setActionMessage("Listing created."); + await refresh(authClient, databaseId); + } catch (cause) { + setMarketError(errorMessage(cause)); + } finally { + setMarketBusy(false); + } + } + + async function updateMarketListing(request: MarketUpdateListingRequest) { + if (!authClient || !databaseId) return; + setMarketBusy(true); + setActionMessage(null); + try { + await marketUpdateListing(canisterId, authClient.getIdentity(), request); + setActionTone("info"); + setActionMessage("Listing updated."); + await refresh(authClient, databaseId); + } catch (cause) { + setMarketError(errorMessage(cause)); + } finally { + setMarketBusy(false); + } + } + + async function publishMarketListing(listingId: string) { + if (!authClient || !databaseId) return; + setMarketBusy(true); + setActionMessage(null); + try { + await marketPublishListing(canisterId, authClient.getIdentity(), listingId); + setActionTone("info"); + setActionMessage("Listing published."); + await refresh(authClient, databaseId); + } catch (cause) { + setMarketError(errorMessage(cause)); + } finally { + setMarketBusy(false); + } + } + + async function pauseMarketListing(listingId: string) { + if (!authClient || !databaseId) return; + setMarketBusy(true); + setActionMessage(null); + try { + await marketPauseListing(canisterId, authClient.getIdentity(), listingId); + setActionTone("info"); + setActionMessage("Listing paused."); + await refresh(authClient, databaseId); + } catch (cause) { + setMarketError(errorMessage(cause)); + } finally { + setMarketBusy(false); + } + } + return (
@@ -443,11 +539,19 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) busyAction={busyAction} databaseId={databaseId} databaseName={database.name} + activeEntitlementCount={activeEntitlementCount} + marketBusy={marketBusy} + marketError={marketError} + marketListings={marketListings} members={members} principal={principal ?? "anonymous"} + onCreateListing={createMarketListing} onDelete={deleteDatabase} onGrant={grantAccess} + onPauseListing={pauseMarketListing} + onPublishListing={publishMarketListing} onRevoke={revokeAccess} + onUpdateListing={updateMarketListing} /> ) : database.publicReadable ? ( diff --git a/wikibrowser/app/dashboard/dashboard-ui.tsx b/wikibrowser/app/dashboard/dashboard-ui.tsx index e70a33e..1ec1fb9 100644 --- a/wikibrowser/app/dashboard/dashboard-ui.tsx +++ b/wikibrowser/app/dashboard/dashboard-ui.tsx @@ -11,7 +11,7 @@ import { MemberTable } from "./member-table"; import { formatRawCycles } from "@/lib/cycles"; import { databaseCyclesView, databaseCyclesHref } from "@/lib/cycles-state"; import { formatTokenAmountFromE8s } from "@/lib/kinic-amount"; -import type { CyclesBillingConfig, DatabaseCycleEntry, DatabaseCyclesPendingPurchase, DatabaseMember, DatabaseRole, DatabaseSummary } from "@/lib/types"; +import type { CyclesBillingConfig, DatabaseCycleEntry, DatabaseCyclesPendingPurchase, DatabaseMember, DatabaseRole, DatabaseSummary, MarketCreateListingRequest, MarketListing, MarketUpdateListingRequest } from "@/lib/types"; import { isRoutableDatabaseId, publicDatabasePath, xShareDatabaseHref } from "@/lib/share-links"; type PendingAclAction = { @@ -97,7 +97,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.

- +
); } @@ -108,11 +108,19 @@ export function OwnerPanel(props: { busyAction: BusyAction | null; databaseId: string; databaseName: string; + activeEntitlementCount: string | null; + marketBusy: boolean; + marketError: string | null; + marketListings: MarketListing[]; members: DatabaseMember[]; principal: string; + onCreateListing: (request: MarketCreateListingRequest) => void; onDelete: () => Promise; onGrant: (principalText: string, role: DatabaseRole) => void; + onPauseListing: (listingId: string) => void; + onPublishListing: (listingId: string) => void; onRevoke: (principalText: string) => void; + onUpdateListing: (request: MarketUpdateListingRequest) => void; }) { const [pendingAction, setPendingAction] = useState(null); const publicMember = props.members.find((member) => member.principal === ANONYMOUS_PRINCIPAL); @@ -242,7 +250,19 @@ export function OwnerPanel(props: { {pendingAction ? setPendingAction(null)} onConfirm={confirmPendingAction} /> : null} + void; + onPause: (listingId: string) => void; + onPublish: (listingId: string) => void; + onUpdate: (request: MarketUpdateListingRequest) => void; +}) { + const [selectedListingId, setSelectedListingId] = useState(""); + const [title, setTitle] = useState(props.databaseName); + const [description, setDescription] = useState(""); + const [price, setPrice] = useState("1"); + const [tags, setTags] = useState(""); + const selected = props.listings.find((listing) => listing.listingId === selectedListingId) ?? null; + const priceE8s = parseKinicInput(price); + const submitDisabled = props.busy || !title.trim() || !description.trim() || !priceE8s; + + function selectListing(listing: MarketListing) { + setSelectedListingId(listing.listingId); + setTitle(listing.title); + setDescription(listing.description); + setPrice(decimalFromE8s(listing.priceE8s)); + setTags(tagsFromJson(listing.tagsJson)); + } + + function submit(event: FormEvent) { + event.preventDefault(); + if (submitDisabled || !priceE8s) return; + const base = { + databaseId: props.databaseId, + title: title.trim(), + description: description.trim(), + llmSummary: null, + summarySnapshotRevision: null, + sampleExcerptsJson: "[]", + sampleQuestionsJson: "[]", + tagsJson: tagsJsonFromInput(tags), + priceE8s + }; + if (selected) { + props.onUpdate({ + ...base, + listingId: selected.listingId, + expectedRevision: selected.revision + }); + } else { + props.onCreate(base); + } + } + + return ( +
+
+
+

Marketplace

+

DB owner can sell paid reader access.

+
+ + Marketplace + +
+ {props.error ?

{props.error}

: null} + {props.listings.length ? ( +
+ {props.listings.map((listing) => ( +
+
+

{listing.title}

+

+ {listing.status} / {formatTokenAmountFromE8s(listing.priceE8s)} / {listing.purchaseCount} purchases +

+
+
+ selectListing(listing)} variant="secondary"> + Edit + + {listing.status === "Active" ? ( + props.onPause(listing.listingId)} variant="secondary"> + Pause + + ) : ( + props.onPublish(listing.listingId)} variant="primary"> + Publish + + )} +
+
+ ))} +
+ ) : null} +
+
+ + +
+