diff --git a/.gitignore b/.gitignore index 66fb7527547..7c3514fb38f 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ coverage/ tmp .qodo /cycles-ledger.wasm.gz +/icrc1-ledger.wasm.gz diff --git a/Cargo.lock b/Cargo.lock index 77971473f2c..da444816e45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,6 +135,7 @@ dependencies = [ "ic-signature-verification", "ic-stable-structures", "ic-vetkeys", + "icrc-ledger-types", "lazy_static", "pocket-ic", "pretty_assertions", @@ -157,6 +158,12 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + [[package]] name = "base58ck" version = "0.1.101" @@ -1655,6 +1662,40 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "icrc-cbor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90569d2894d9536c5416943556ac6339df249f06611b3c41029196b39e0dd119" +dependencies = [ + "candid", + "minicbor", + "num-bigint", + "num-traits", +] + +[[package]] +name = "icrc-ledger-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a11c866a01b93ef6bd0e3dc01374df41bc70a8b79027a2fd120543a094668978" +dependencies = [ + "base32", + "candid", + "crc32fast", + "hex", + "icrc-cbor", + "minicbor", + "num-bigint", + "num-traits", + "serde", + "serde_bytes", + "sha2 0.10.9", + "strum 0.26.3", + "strum_macros 0.26.4", + "time", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1896,6 +1937,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.17" @@ -2002,6 +2049,26 @@ dependencies = [ "unicase", ] +[[package]] +name = "minicbor" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7005aaf257a59ff4de471a9d5538ec868a21586534fff7f85dd97d4043a6139" +dependencies = [ + "minicbor-derive", +] + +[[package]] +name = "minicbor-derive" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1154809406efdb7982841adb6311b3d095b46f78342dd646736122fe6b19e267" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2086,6 +2153,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 482c7e77bf3..4dfc8750810 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ ic-cycles-ledger-client = { path = "src/cycles_ledger/client" } ic-cycles-ledger-pic = { path = "src/cycles_ledger/pic" } ic-cycles-ledger-types = { path = "src/cycles_ledger/types" } ic-ledger-types = "0.16.0" +icrc-ledger-types = "0.2.0" ic-stable-structures = "0.7" # Must track ic-cdk: 0.7.0 targets ic-cdk ^0.20.1 and the same # ic-management-canister-types major, so the two cannot be bumped independently. diff --git a/docs/ai/backend/workflows/state-and-migrations.md b/docs/ai/backend/workflows/state-and-migrations.md index 88085318a56..7f5a0cb2e65 100644 --- a/docs/ai/backend/workflows/state-and-migrations.md +++ b/docs/ai/backend/workflows/state-and-migrations.md @@ -27,6 +27,34 @@ of persisted data is a migration and must be done deliberately. **Never reuse a previously-used `MemoryId`.** Stable memory at that slot may still hold legacy data. + **"The next number" is not safe on its own.** The namespace is shared and + append-only, and nothing in the toolchain protects it: two branches can each + take what looks like the next free id, and both compile, both lint and both + pass their own tests. It only breaks once they meet on `main` and deploy, as + two structures decoding each other's bytes. This has happened — tips took id + 20 while `CONTACT_IMAGE_MEMORY_ID` took id 20 on `main`. `every_memory_id_is_claimed_once` + in `state/memory.rs` now catches it, but it catches it at _merge_ time, so + check what has landed on `main` before picking a number, and re-check after + a long-lived branch merges `main` in. + + Each id also claims a whole **bucket** — 128 pages, 8 MiB — the first time + its structure is initialised, and _when_ that happens depends on how the + store is held: + + - **Eagerly**, for a structure built inside the `STATE` thread_local (`tips`, + `tips_by_sender`). `#[init]` calls `set_config`, which calls + `mutate_state`, which forces the whole of `STATE` to build — so + `StableBTreeMap::init` writes its header and the bucket is claimed **at + install**, before anyone calls anything. + - **Lazily**, for a store held as an `Option` and attached on first use (the + vetKeys `EncryptedMaps`: `personal_notes`, `tip_secrets`). Those claim + their bucket the first time the feature is actually used. + + The distinction matters because a bucket is claimed by growing stable memory, + which needs _reserved cycles_. On a canister with a tight + `reserved_cycles_limit`, the eager case fails the install outright, while the + lazy case fails at first use — long after deployment looked fine. + 2. **Define the type alias** in [`types/maps.rs`](../../../../src/backend/src/types/maps.rs): @@ -118,6 +146,14 @@ adding migration tests. will fail. - Don't run the migration outside `post_upgrade`. `init` is for fresh installs, not upgrades. +- Don't assume `Cell::init` writes the value you hand it. It **loads** the + stored value whenever the region is non-empty and writes only when the region + is fresh — so a `StableCell`'s contents are whatever the very first touch + wrote, permanently, and no redeploy changes them. This is how a store gets + stuck: the tip-secrets `KeyManager` was first initialised in a test + environment under `dfx_test_key`, which exists only on a local replica, and + every vetKD derivation there trapped with `InvalidKeyName` forever after. If + you need a different value, you need a fresh region or a reinstall. - Don't leave migration code in `lib.rs` indefinitely. Add a TODO comment with the release tag after which it can be removed, and clean it up when it's safe. diff --git a/docs/ai/spec-driven-development/specs/2026-08-05-feat-tips-via-link.md b/docs/ai/spec-driven-development/specs/2026-08-05-feat-tips-via-link.md index 9ebe22868d9..6d64eafeb77 100644 --- a/docs/ai/spec-driven-development/specs/2026-08-05-feat-tips-via-link.md +++ b/docs/ai/spec-driven-development/specs/2026-08-05-feat-tips-via-link.md @@ -172,7 +172,22 @@ pending decision in the first draft. | 12 | Create step uses the design's **radio cards** | The compact dropdown variant is dropped | | 13 | Logged-out CTA is **Open or Create** with the Terms-of-Use consent line | Consent is collected before a wallet is created | | 14 | **No cap on the number of active tips** | The sender's balance is the natural limiter; keep a minimum amount and a rate limit | -| 15 | **Self-claim rejected** | Under an allowance it is a self-transfer that only burns a fee, and cancellation covers the intent | +| 15 | **Self-claim rejected** — _not built, see below_ | Under an allowance it is a self-transfer that only burns a fee, and cancellation covers the intent | + +### Decision 15 is not implemented + +Measured, not assumed: against the local ledger, `get_tip_details` and +`claim_tip` both succeed for the sender's own tip, and the payout goes through as +a self-transfer that costs one fee. No guard compares the claimer to the sender. + +Leaving it that way is defensible — it is the sender's money either way, and it +makes a link testable end to end in one browser, which is how the build was +actually exercised. Closing it needs a `SelfClaim` variant (a breaking candid +change, harmless while tips have never shipped) rejected before any state moves, +and a claim screen that says _this is your own tip_ rather than offering a retry +that can never work. Owner's call, and worth making before tips ship: the sender +who tries it today burns a fee and gets a History row reading **Claimed by +**. ## Escrow model — decided: ICRC-2 allowance, no custody @@ -347,9 +362,10 @@ which is the fragile part. 7. Taps **Open or Create** → Internet Identity. That is the entire account-creation step. 8. Back in the app, **Claim tip** shows the amount and fiat value, **To: Your OISY - wallet**, Network, Token, the payout fee, the sender's **message**, a note that - the sender will see who claimed, and **Status: Reserved** → **Claim now**. The - backend re-checks the allowance and the sender's balance before moving anything. + wallet**, Network, Token, the sender's **message**, a note that the sender will + see who claimed, and **Status: Reserved** → **Claim now**. No fee line: the + claimer pays nothing and receives the full amount. The backend re-checks the + allowance before moving anything. 9. Success: **"135.00 USDC Received!"** with **Status: Completed** and a single **Take me to the wallet** CTA. The tokens are in their own wallet. @@ -526,11 +542,13 @@ the privacy promise. 5. **History's info banner** reads _"We've hidden these transactions as they considered suspicious…"_ [sic] — copy from the spam-token surface, a reused component left in the mock. Do not implement. -6. **Two fees, one story — mostly dissolved.** There is no funding leg any more: the - sender pays the `approve` fee, and the payout fee is drawn from the allowance at - claim. Two numbers still exist, but they are now "what you pay to reserve" and - "what the transfer costs", and the recipient must see the net amount before - claiming. +6. **Two fees — resolved, and both land on the sender.** There is no funding leg + any more. The sender pays the `approve` fee to reserve, and the payout fee when + the claim moves the tokens; the ledger takes the second from the sender's balance + while crediting the claimer the full amount. So the two numbers are "what you pay + to reserve" and "what you pay when it is claimed", both quoted to the **sender** + at creation. The recipient has no net amount to be shown — what the link says is + what they get. Verified against a real ledger in the backend build. 7. **Two logged-out CTAs — resolved** in favour of **Open or Create** with the consent line. 8. **"Single-use security" — inherent.** A tip is a fixed amount with one allowance; @@ -680,6 +698,74 @@ no reconciliation sweep, and no refund that can fail. It introduces its own. 33. **Phishing lookalikes** — a fake "you received a tip" page harvesting II sign-ins is the obvious follow-on scam, and this flow trains users to sign in from a link. +## Link recovery and auditability — added after the first build + +Two gaps the built flow surfaced, both closed in the stack rather than deferred. + +### The sender could not get their link back + +The claim code is generated in the browser and only its SHA-256 reaches the +canister (criterion 16). That is what makes a link unforgeable — and it also +meant closing the share screen destroyed the only copy. The sender could still +`cancel_tip` to free the reservation, so no money was ever stuck, but a tip they +meant to re-send was unrecoverable. + +Closed **without weakening criterion 16**: the browser encrypts the claim code +under a vetKey only that principal can derive and stores the ciphertext. The +canister holds opaque bytes and can read a claim code exactly as well as before, +which is to say not at all. + +- A second `ic_vetkeys::EncryptedMaps`, mirroring `personal_notes` — see + [`src/backend/src/tips/secrets.rs`](../../../../src/backend/src/tips/secrets.rs) + and [`tip.vetkeys.ts`](../../../../src/frontend/src/lib/services/tip.vetkeys.ts). + `EncryptedMaps` keys every map by its owner, so "only the sender reads their own + codes" is the library's guarantee rather than ours — and an integration test + pins it, because that is not a property to take on faith. +- Its **own map name and domain separator** (`tip_secrets` / `oisy_tip_secrets`), + so a fault or a key rotation in the notes store cannot reach tips. Its own rate + limiters for the same reason. Neither string may ever change for a deployed + canister: both are bound into the key derivation, so a change orphans every + stored ciphertext. +- The **tip id is the AES-GCM domain separator**, so ciphertext lifted from one + entry cannot decrypt under another. +- Storage is **best-effort and happens after the tip exists**. It is a + convenience; a failure must not read as a failed reservation when the tip is + real and the link is on screen. +- **Cancelling drops the stored code** — the link is worthless then. Claimed and + expired tips keep theirs until pruned: the cleanup runs as the caller, and + neither of those paths has the sender as caller. +- History carries a **Link** action per live row, which decrypts and reopens the + share step. An action rather than a clickable row: the row already carries + Cancel, and nesting interactive elements is both invalid markup and ambiguous + when one outcome is irreversible. +- A tip created before this store existed has no ciphertext. That is a fact about + the tip, not a failure, and reads as one. + +### OISY-side auditability + +Encryption is orthogonal to this, and worth stating plainly because it is easy to +assume otherwise: vetKey-encrypted data is opaque to OISY **by construction**, so +it contributes nothing to auditability. It does not have to — the interesting +data was never hidden. Every tip is a `TipRecord` in stable memory with its +amount, ledger, status and timestamps. + +What was missing was a way to read the aggregate, and the endpoint for that +already existed: + +- `tips_count` on `Stats`, off the map's own `len()`, behind the same + `caller_is_allowed` guard as every other stat. `personal_note_shares_count` is + the precedent. +- **Aggregate only.** Criterion 19 promises no endpoint enumerates another + principal's tips, and this one must not become the way around it: a count, never + a row. Note the precedent cuts both ways — `get_account_creation_timestamps` + returns per-principal data under the same guard. Tips must not follow it. +- A per-status or per-ledger breakdown needs either an `O(n)` scan (fine now, an + instruction-limit trap later) or counters maintained on each transition. Its own + change, deliberately not smuggled into this one. +- The funnel question — how many people opened a link and converted — is + structurally invisible to the canister. That is what the + [Analytics](#analytics-plausible) section is for, and it remains unbuilt. + ## Security model - **Two factors authorise a claim:** the opaque `tip_id` the server knows, and the @@ -722,9 +808,11 @@ behaviour-first voice. It must cover, in the same PR as the behaviour change: - Expiry options and the default, and that a lapsed tip cannot be claimed — enforced by the backend record and by the reservation itself, which carries the same deadline. -- The two fees — the ledger fee the sender pays to reserve the amount, and the - payout fee taken from the tip at claim — and therefore that the recipient - receives slightly less than the amount the sender set aside. +- **Who pays the fees, stated plainly: the sender pays both.** One ledger fee to + reserve the amount, a second when the claimer moves it. The claimer receives the + **full amount shown** and pays nothing. Measured against a real ledger during the + backend build: `icrc2_transfer_from` debits amount + fee from the sender and + credits the amount in full, which is why the reservation is sized at amount + fee. - **Where the money actually is.** The tokens stay in the sender's account; OISY holds a bounded, revocable authorisation and never a balance. The design's "non-custodial" phrasing is accurate here and can be used — but the flip side has @@ -819,23 +907,33 @@ change. and network filter, and shows the drawn empty state when the user holds none. 3. Creating a tip requires an amount and an expiry (**1h / 24h / 7d**, default 24h), accepts an optional message of up to **250 characters**, and states that the - amount is reserved in the user's own account and lapses on its own. + amount is reserved in the user's own account and lapses on its own — quoting + **both** fees the sender will pay: one now to reserve, one when it is claimed. 4. **Generate** issues an `approve` to `{owner: backend, subaccount: H(tip_id)}` for the amount plus the payout fee, records the tip, and produces a share screen with a scannable QR, the `oisy.com/tip/#c=` link, copy **and** share actions, and an absolute expiry. 5. **No tokens leave the sender's account at creation.** Verifiable in a ledger trace: the only transaction is an `approve`. -6. The reserved amount is **excluded from spendable balance** in the token list, the - send flow, the swap flow and both **MAX** controls. +6. The reserved amount **plus its payout fee** — the whole allowance — is + **excluded from spendable balance** in the token list, the send flow, the swap + flow and both **MAX** controls. Excluding only the amount would let a user spend + down to where their own tip can no longer be claimed. 7. Opening the link **signed out** shows the branded modal with amount, token and expiry — **not** the message, the sender, or the claimer — and performs no state-changing call. 8. After **Open or Create** and Internet Identity, the claim resumes with the - fragment intact and shows the review card with the payout fee, the message, - **Status: Reserved**, and a disclosure that the sender will see who claimed. -9. **Claim now** pays out via `icrc2_transfer_from` net of the fee, **including for a - principal that has never used OISY before**, with no manual token setup. + fragment intact and shows the review card with the amount, the message, + **Status: Reserved**, and a disclosure that the sender will see who claimed — + and **no fee line**, since the claimer pays none. +9. **Claim now** pays out via `icrc2_transfer_from` for the **full amount shown**, + **including for a principal that has never used OISY before**, with no manual + token setup. Adjusted during the backend build, and measured against a real + ledger: the ledger charges the transfer fee to the **allowance**, not to the + transferred amount, so an allowance sized at amount + fee (criterion 4) pays the + claimer the whole amount and the sender carries both fees. The earlier "net of + the fee" wording described a model where the fee came out of the tip, which the + allowance design does not do. 10. A tip can be claimed **exactly once**; two simultaneous claims produce exactly one payout; a tip marked claimed is never left unpaid. 11. If the sender no longer covers the tip — spent, revoked, or cancelled — the diff --git a/scripts/test.backend.sh b/scripts/test.backend.sh index 11f7c7c04d1..e979fe92147 100755 --- a/scripts/test.backend.sh +++ b/scripts/test.backend.sh @@ -35,6 +35,14 @@ export CYCLES_LEDGER_CANISTER_WASM_FILE="../../${CYCLES_LEDGER_CANISTER_WASM}" scripts/download-immutable.sh "${II_CANISTER_URL}" "${II_CANISTER_WASM}" export II_CANISTER_WASM_FILE="../../${II_CANISTER_WASM}" +# The real ICRC-1/2 token ledger, for the tips tests. Pinned to the same IC +# commit the local ckBTC/ckETH/ckUSDC ledgers are built from, read from that +# script so the two cannot drift apart. +IC_LEDGER_COMMIT="$(grep -m1 '^IC_COMMIT=' scripts/download.ckbtc.sh | cut -d'"' -f2)" +ICRC1_LEDGER_WASM="icrc1-ledger.wasm.gz" +scripts/download-immutable.sh "https://download.dfinity.systems/ic/${IC_LEDGER_COMMIT}/canisters/ic-icrc1-ledger.wasm.gz" "${ICRC1_LEDGER_WASM}" +export ICRC1_LEDGER_WASM_FILE="../../${ICRC1_LEDGER_WASM}" + # Download PocketIC server POCKET_IC_SERVER_PATH="target/pocket-ic" diff --git a/src/backend/Cargo.toml b/src/backend/Cargo.toml index 20a3d67fa48..6fc7467e6a4 100644 --- a/src/backend/Cargo.toml +++ b/src/backend/Cargo.toml @@ -25,6 +25,7 @@ ic-ledger-types = { workspace = true } ic-signature-verification = { workspace = true } ic-stable-structures = { workspace = true } ic-vetkeys = { workspace = true } +icrc-ledger-types = { workspace = true } lazy_static = { workspace = true } pretty_assertions = { workspace = true } serde = { workspace = true } diff --git a/src/backend/backend.did b/src/backend/backend.did index 9dd2712078d..3a38c733c36 100644 --- a/src/backend/backend.did +++ b/src/backend/backend.did @@ -364,6 +364,7 @@ type BtcGetPendingTransactionsResult = variant { }; // Bitcoin transaction data. type BtcTransactionData = record { fee : opt nat }; +type CancelTipResult = variant { Ok; Err : TipError }; // Copy of the synonymous Rosetta type. type CanisterStatusResultV2 = record { controller : principal; @@ -417,6 +418,7 @@ type ChainFusionDirection = variant { Erc20ToCkErc20; CkEthToEth }; +type ClaimTipResult = variant { Ok : TipClaim; Err : TipError }; type Config = record { // The derivation origin used for II authentication, ensuring users get a // consistent identity across different domains. @@ -495,6 +497,26 @@ type CreatePersonalNoteShareResult = variant { Ok; Err : PersonalNoteShareError }; +// Create-tip request. The canister stores the tip and verifies the sender's +// allowance covers it; it never takes custody. +type CreateTipRequest = record { + // Opaque, client-generated random id; also the map key and the `` in + // the share link. + tip_id : text; + // SHA-256 of the claim code held in the link fragment. + claim_code_hash : blob; + // Optional note shown to the claimer *after* they sign in — never in the + // anonymous preview. + message : opt text; + // Ledger the tip is denominated in. Must be ICRC-2 capable — the sender's + // allowance is what makes the tip claimable. + ledger_canister_id : principal; + // What the claimer receives, in the ledger's base units. The sender's + // allowance must additionally cover one ledger fee, which the ledger + // draws from the allowance at claim rather than from this amount. + amount : nat; + expires_at_ns : nat64 +}; type CreateUserProfileError = variant { // Sign-ups of new users are currently disabled on the backend. Callers that already have a // profile are unaffected; this variant is only returned for principals without an existing @@ -639,6 +661,7 @@ type GetContactsResult = variant { // The contacts were not retrieved due to an error. Err : ContactError }; +type GetMyTipsResult = variant { Ok : vec MyTip; Err : TipError }; type GetPersonalNoteShareResult = variant { Ok : PersonalNoteShareContent; Err : PersonalNoteShareError @@ -659,6 +682,11 @@ type GetPersonalNotesResult = variant { // The notes could not be retrieved due to an error. Err : PersonalNoteError }; +type GetTipDetailsResult = variant { Ok : TipDetails; Err : TipError }; +type GetTipResult = variant { Ok : PublicTip; Err : TipError }; +// The caller's encrypted claim code for one tip. `Ok(None)` means no secret is +// stored — a tip from before the store existed, or one already cleaned up. +type GetTipSecretResult = variant { Ok : opt blob; Err : TipError }; type GetUserProfileError = variant { NotFound }; type GetUserProfileResult = variant { // The user's profile was retrieved successfully. @@ -816,6 +844,23 @@ type LiquidiumData = record { // Amount in the token's base units. amount : nat }; +// One of the caller's own tips, as returned by `get_my_tips`. +type MyTip = record { + status : TipStatus; + // Set once claimed. The sender learns who claimed their tip; the claim + // screen discloses this before the claimer commits. + claimed_by : opt principal; + tip_id : text; + // The most recent claim that did not pay out, if any. Present alongside + // `status = Failed` for a live tip, and kept afterwards so a tip that + // eventually succeeded can still show it was not first time lucky. + last_claim_failure : opt TipClaimFailure; + created_at_ns : nat64; + message : opt text; + ledger_canister_id : principal; + amount : nat; + expires_at_ns : nat64 +}; // NEAR Intents (1Click) cross-chain swap payload. Settlement is tracked // off-chain by polling the 1Click status endpoint keyed by the deposit // address, so that address (and its optional memo, plus learned-mid-flow tx @@ -992,6 +1037,14 @@ type ProviderAgreementType = record { provider : ProviderAgreementProvider; scope : ProviderAgreementScope }; +// What an **anonymous** reader of a tip link sees. Deliberately excludes the +// message, the sender, and the claimer: enough to decide whether to sign in, +// nothing that identifies anyone. +type PublicTip = record { + ledger_canister_id : principal; + amount : nat; + expires_at_ns : nat64 +}; type QualifiedNotificationKind = variant { NoIndexCanister; UnavailableIndexCanister @@ -1025,6 +1078,17 @@ type SetShowTestnetsRequest = record { show_testnets : bool }; type SetTestnetsSettingsError = variant { VersionMismatch; UserNotFound }; +// Stores the sender's own encrypted claim code so they can recover the link. +// +// A single request struct rather than two arguments, matching +// `SetPersonalNoteRequest`: it keeps the candid signature stable if the store +// ever needs another field. +type SetTipSecretRequest = record { + tip_id : text; + // AES-GCM ciphertext of the claim code, opaque to the canister. Bounded by + // [`MAX_TIP_SECRET_CIPHERTEXT_BYTES`]. + encrypted_claim_code : blob +}; type SetUserShowTestnetsResult = variant { // The user's show testnets was set successfully. Ok; @@ -1115,12 +1179,148 @@ type Stats = record { // not yet pruned). personal_note_shares_count : nat64; agreement_history_count : nat64; + // Total number of tips ever created and not yet pruned, across all users + // and every status. Aggregate only, deliberately: the negative guarantee + // that no endpoint enumerates another principal's tips holds for this one + // too, so there is a count here and never a row. + tips_count : nat64; // Total number of stored (encrypted) personal-note entries across all users. personal_notes_count : nat64; user_timestamps_count : nat64; user_token_count : nat64 }; type TestnetsSettings = record { show_testnets : bool }; +// Outcome of a successful claim. +type TipClaim = record { + // Ledger block index of the payout, so the client can link to it. + block_index : nat; + ledger_canister_id : principal; + // What was transferred to the claimer, in base units. + amount : nat +}; +// The most recent failed claim on a tip. Returned only to the tip's own sender. +// +// Deliberately not the ledger's error text: that is written for an operator, it +// can name balances, and it has no business being rendered to a user. +type TipClaimFailure = record { at_ns : nat64; reason : TipClaimFailureReason }; +// Why a claim attempt did not pay out. +// +// Three outcomes, and the split is by what the sender can do about it. +// `Uncovered` is the reservation having been reduced or revoked, so the link is +// dead and only a new tip fixes it. `InsufficientFunds` is the reservation +// standing but the money not being there, so the same link works again once +// they top up. `TransferFailed` is everything else — the ledger refusing or +// failing to answer — where retrying is the whole advice. +type TipClaimFailureReason = variant { + Uncovered; + TransferFailed; + // The sender's account no longer holds the amount. Distinct from + // `Uncovered`: the reservation is still granted, the money is simply not + // there, so topping up makes the same link work again. + InsufficientFunds +}; +// Identifies a tip **and** proves the caller holds its link. +// +// One type for both `get_tip_details` and `claim_tip` on purpose: reading the +// claim review and claiming are the same claim of authority, differing only in +// whether they move money. Two structurally identical records would also +// collapse into one name in the generated candid anyway — better to say so than +// to have the interface say it for us. +type TipClaimRequest = record { + tip_id : text; + // The plaintext code from the link fragment. Only ever compared against the + // stored hash; never persisted, never returned. + claim_code : text +}; +// What an authenticated claimer sees before claiming: the preview plus the +// sender's message. The payout fee is not included — the client reads it from +// the ledger directly (`icrc1_fee`), which is also the value the ledger will +// actually charge. +type TipDetails = record { + message : opt text; + ledger_canister_id : principal; + amount : nat; + expires_at_ns : nat64 +}; +type TipError = variant { + // `expires_at_ns` is not strictly in the future of IC time, or is further + // out than [`MAX_TIP_EXPIRY_NS`]. + InvalidExpiry; + // A claim is already in flight for this tip. Resolves on its own: either + // it completes, or [`TIP_CLAIM_IN_FLIGHT_TIMEOUT_NS`] passes and a retry + // may take it over. + ClaimInProgress; + // The encrypted claim code exceeds [`MAX_TIP_SECRET_CIPHERTEXT_BYTES`]. + SecretCiphertextTooLarge; + // The tip exists and the claim code is right, but the sender's allowance + // no longer covers it — they spent, reduced or revoked it. The one + // deliberately distinguishable failure, reachable only with a valid link, + // because telling the claimer "come back later" is useless. + Uncovered; + // No claimable tip for this id. Also returned for an expired, cancelled or + // already-claimed tip, and for a wrong claim code — every case collapsed + // into one response so a prober can never distinguish them. + NotFound; + // The caller is not the sender of this tip. + NotYourTip; + // The `claim_code_hash` is not exactly [`TIP_CLAIM_CODE_HASH_BYTES`] long. + InvalidClaimCodeHash; + // The `tip_id` is empty or exceeds [`MAX_TIP_ID_BYTES`]. + InvalidTipId; + RateLimited : RateLimitError; + // The `tip_id` already exists; the client should generate a fresh random + // id and retry. + DuplicateTipId; + // Only a `Reserved` tip can be cancelled. + NotCancellable; + // The ledger rejected or failed to answer the payout. The tip stays + // claimable — nothing was transferred. + TransferFailed : record { msg : text }; + InternalError : record { msg : text }; + // `message` exceeds [`MAX_TIP_MESSAGE_CHARS`] characters. + MessageTooLong; + // The caller already holds [`MAX_TIPS_PER_USER`] active tips. + TooManyTips; + // The sender's account no longer holds the amount. The reservation is still + // granted and the claim code is still valid, so the same link works again + // once they top up — which is why this is not folded into `TransferFailed`. + InsufficientFunds; + // The amount is zero, or below one ledger fee — a tip that cannot cover + // its own payout is not a tip. + AmountTooSmall +}; +// Lifecycle as the **sender** sees it in History. +// +// `Uncovered` is deliberately absent: it is not a stored state but the +// outcome of a claim attempt against an allowance the sender has since spent, +// reduced or revoked. Reporting it here would mean querying every tip's +// allowance on every History read; it surfaces on the claim path instead, as +// [`TipError::Uncovered`]. +type TipStatus = variant { + // Somebody tried to claim and the payout did not go through, and the tip is + // still live. The code stays valid, so this is the one status the sender can + // act on — typically by topping up the account the tip draws from. + // + // Distinct from `Reserved` precisely because it is actionable: without it a + // tip nobody has touched and a tip that has already failed a claimer look + // identical in History. + Failed; + // Funds are authorised in the sender's own account, waiting for a claimer. + Reserved; + // A claimer moved the tokens. + Claimed; + // The sender revoked it before anyone claimed. + Cancelled; + // The deadline passed unclaimed. Nothing was ever transferred, so nothing + // is returned — the allowance simply lapsed on the ledger. + Expired +}; +// vetKey material for the tip-secrets store, or why it could not be derived. +type TipVetkeyResult = variant { + // vetKey bytes, opaque to the canister. + Ok : blob; + Err : TipError +}; // A variant describing any token type Token = variant { Erc20 : ErcToken; @@ -1430,6 +1630,26 @@ service : (Arg) -> { btc_get_pending_transactions : (BtcGetPendingTransactionsRequest) -> ( BtcGetPendingTransactionsResult ); + // Stops an unclaimed tip of the caller's from being claimable. The allowance + // itself is the caller's to revoke — the client pairs this with an + // `icrc2_approve` of zero. + // + // # Errors + // Errors are enumerated by `TipError` (`NotFound`, `NotYourTip`, + // `NotCancellable`, `ClaimInProgress`, `RateLimited`). + cancel_tip : (text) -> (CancelTipResult); + // Pays the tip out to the caller, exactly once. + // + // Guarded on a non-anonymous caller rather than a registered one: the claimer + // may be an identity that has never used OISY before — that is the point of the + // feature — so requiring a user profile would defeat it. A principal is still + // required, since the payout needs somewhere to land. + // + // # Errors + // Errors are enumerated by `TipError` (`NotFound` for an unclaimable tip or a + // wrong code, `Uncovered` when the sender's allowance no longer covers it, + // `ClaimInProgress`, `TransferFailed`, `RateLimited`). + claim_tip : (TipClaimRequest) -> (ClaimTipResult); // Gets the canister configuration. config : () -> (Config) query; // Returns a **single-use** share's content exactly once, atomically deleting @@ -1466,6 +1686,17 @@ service : (Arg) -> { create_personal_note_share : (CreatePersonalNoteShareRequest) -> ( CreatePersonalNoteShareResult ); + // Records a tip against an ICRC-2 allowance the caller has already granted to + // this canister under the tip's own spender subaccount. + // + // No tokens move here, and none are held: the amount stays in the caller's + // account until someone claims it, and lapses in place if nobody does. + // + // # Errors + // Errors are enumerated by `TipError` (e.g. `Uncovered` when the allowance does + // not cover the amount plus its fee, `TooManyTips`, `AmountTooSmall`, + // `InvalidExpiry`, `DuplicateTipId`, `RateLimited`). + create_tip : (CreateTipRequest) -> (CancelTipResult); // It creates a new user profile for the caller. // If the user has already a profile, it will return that profile. // @@ -1575,6 +1806,16 @@ service : (Arg) -> { // state (`token_activity`) and may need an update context to schedule the // background fetch. get_exchange_rates : () -> (vec record { TokenId; opt ExchangeRate }); + // Returns the caller's own tips, newest first, for History. Bounded by + // `MAX_TIPS_RETURNED`. + // + // Not rate-limited, for the same reason as [`get_tip`]: a stateful limiter is + // a no-op on the non-certified query path. The row cap is what bounds the work + // here, and a caller can only ever read their own tips. + // + // # Errors + // Errors are enumerated by `TipError`. + get_my_tips : () -> (GetMyTipsResult) query; // Returns the note ciphertext for a **reusable** (non-single-use), unexpired // share. A single-use share's content is only ever returned by // `consume_personal_note_share`. Callable anonymously — a deliberate, @@ -1622,6 +1863,58 @@ service : (Arg) -> { // # Errors // Errors are enumerated by `PersonalNoteError`. get_personal_notes_vetkey_public_key : () -> (PersonalNotesVetkeyResult); + // Returns what an anonymous holder of a tip link may see: amount, token and + // deadline — never the message, the sender, or the claimer. + // + // Callable without an identity, deliberately: the recipient of a tip link has + // no OISY account yet, and the whole feature exists so they don't need one + // first. Same narrowly-scoped exception as `get_personal_note_share`. + // + // Not rate-limited, for the same reason that endpoint isn't: state changes + // during a query are not persisted, so a stateful limiter would be a no-op on + // the non-certified query path. The abuse surface is a cheap O(log n) lookup + // against a 128-bit id space. + // + // # Errors + // `TipError::NotFound` for anything not currently claimable — unknown, + // expired, cancelled and already-claimed are indistinguishable. + get_tip : (text) -> (GetTipResult) query; + // Returns the claim review for a signed-in claimer: the public preview plus + // the sender's message. Requires the claim code, so the message is visible only + // to someone holding the full link. + // + // Not rate-limited, for the same reason as [`get_tip`]: a stateful limiter is + // a no-op on the non-certified query path, because state changes during a + // query are not persisted. The abuse surface is one O(log n) lookup that also + // has to guess a 128-bit claim code. + // + // # Errors + // `TipError::NotFound` for an unclaimable tip or a wrong claim code. + get_tip_details : (TipClaimRequest) -> (GetTipDetailsResult) query; + // Derives the caller's vetKey for the tip-secrets store, secured to a + // browser-supplied transport public key. + // + // # Errors + // Errors are enumerated by `TipError` (`RateLimited`, `InternalError`). + get_tip_encrypted_vetkey : (blob) -> (TipVetkeyResult); + // The caller's encrypted claim code for one of their own tips, if stored. + // + // `EncryptedMaps` keys every map by its owner, so this can only ever return + // the caller's own ciphertext. + // + // Not rate-limited, for the same reason as [`get_tip`]: a stateful limiter is + // a no-op on the non-certified query path. The work is one keyed lookup + // scoped to the caller. + // + // # Errors + // Errors are enumerated by `TipError` (`InvalidTipId`, `InternalError`). + get_tip_secret : (text) -> (GetTipSecretResult) query; + // The vetKey verification key for the tip-secrets store. Identical for every + // caller; the browser needs it to verify its derived vetKey. + // + // # Errors + // Errors are enumerated by `TipError` (`RateLimited`, `InternalError`). + get_tip_vetkey_public_key : () -> (TipVetkeyResult); // Returns the full agreement consent/rejection history for the caller. // // # Returns @@ -1741,6 +2034,17 @@ service : (Arg) -> { // Errors are enumerated by `PersonalNoteError` (e.g. `TooManyNotes`, // `NoteCiphertextTooLarge`, `RateLimited`). set_personal_note : (PersonalNoteEntry) -> (SetPersonalNoteResult); + // Stores the caller's encrypted claim code for one of their own tips, so they + // can recover the link after closing the share screen. + // + // The value is ciphertext the canister cannot read: the browser encrypts it + // under a vetKey only this principal can derive. Storing it changes nothing + // about who can claim the tip — the canister still only holds the code's hash. + // + // # Errors + // Errors are enumerated by `TipError` (`RateLimited`, `InvalidTipId`, + // `SecretCiphertextTooLarge`, `InternalError`). + set_tip_secret : (SetTipSecretRequest) -> (CancelTipResult); // Sets the user's preference to show (or hide) testnets in the interface. // // # Returns diff --git a/src/backend/src/api/mod.rs b/src/backend/src/api/mod.rs index dce323afdd8..ded10352ea1 100644 --- a/src/backend/src/api/mod.rs +++ b/src/backend/src/api/mod.rs @@ -9,5 +9,6 @@ pub mod onramper; pub mod personal_note_shares; pub mod personal_notes; pub mod signer; +pub mod tips; pub mod transactions; pub mod user_profile; diff --git a/src/backend/src/api/tips.rs b/src/backend/src/api/tips.rs new file mode 100644 index 00000000000..225e481a7a7 --- /dev/null +++ b/src/backend/src/api/tips.rs @@ -0,0 +1,196 @@ +use ic_cdk::{query, update}; +use serde_bytes::ByteBuf; +use shared::types::{ + result_types::{ + CancelTipResult, ClaimTipResult, CreateTipResult, GetMyTipsResult, GetTipDetailsResult, + GetTipResult, GetTipSecretResult, SetTipSecretResult, TipVetkeyResult, + }, + tip::{CreateTipRequest, SetTipSecretRequest, TipClaimRequest, TipError}, +}; + +use crate::{ + tips::{secrets, service}, + utils::{ + guards::{caller_is_not_anonymous, caller_is_registered_user}, + rate_limiter::{ + self, TieredRateLimiter, CANCEL_TIP_RATE_LIMITER, CLAIM_TIP_RATE_LIMITER, + CREATE_TIP_RATE_LIMITER, GET_TIP_ENCRYPTED_VETKEY_RATE_LIMITER, + GET_TIP_VETKEY_PUBLIC_KEY_RATE_LIMITER, SET_TIP_SECRET_RATE_LIMITER, + }, + }, +}; + +/// Records a tip against an ICRC-2 allowance the caller has already granted to +/// this canister under the tip's own spender subaccount. +/// +/// No tokens move here, and none are held: the amount stays in the caller's +/// account until someone claims it, and lapses in place if nobody does. +/// +/// # Errors +/// Errors are enumerated by `TipError` (e.g. `Uncovered` when the allowance does +/// not cover the amount plus its fee, `TooManyTips`, `AmountTooSmall`, +/// `InvalidExpiry`, `DuplicateTipId`, `RateLimited`). +#[update(guard = "caller_is_registered_user")] +#[must_use] +pub async fn create_tip(request: CreateTipRequest) -> CreateTipResult { + if let Err(e) = CREATE_TIP_RATE_LIMITER.with(TieredRateLimiter::check_caller) { + return CreateTipResult::Err(TipError::RateLimited(e)); + } + service::create_tip(request).await.into() +} + +/// Returns what an anonymous holder of a tip link may see: amount, token and +/// deadline — never the message, the sender, or the claimer. +/// +/// Callable without an identity, deliberately: the recipient of a tip link has +/// no OISY account yet, and the whole feature exists so they don't need one +/// first. Same narrowly-scoped exception as `get_personal_note_share`. +/// +/// Not rate-limited, for the same reason that endpoint isn't: state changes +/// during a query are not persisted, so a stateful limiter would be a no-op on +/// the non-certified query path. The abuse surface is a cheap O(log n) lookup +/// against a 128-bit id space. +/// +/// # Errors +/// `TipError::NotFound` for anything not currently claimable — unknown, +/// expired, cancelled and already-claimed are indistinguishable. +#[query] +#[must_use] +pub fn get_tip(tip_id: String) -> GetTipResult { + service::get_tip(tip_id).into() +} + +/// Returns the claim review for a signed-in claimer: the public preview plus +/// the sender's message. Requires the claim code, so the message is visible only +/// to someone holding the full link. +/// +/// Not rate-limited, for the same reason as [`get_tip`]: a stateful limiter is +/// a no-op on the non-certified query path, because state changes during a +/// query are not persisted. The abuse surface is one O(log n) lookup that also +/// has to guess a 128-bit claim code. +/// +/// # Errors +/// `TipError::NotFound` for an unclaimable tip or a wrong claim code. +#[query(guard = "caller_is_not_anonymous")] +#[must_use] +pub fn get_tip_details(request: TipClaimRequest) -> GetTipDetailsResult { + service::get_tip_details(request).into() +} + +/// Pays the tip out to the caller, exactly once. +/// +/// Guarded on a non-anonymous caller rather than a registered one: the claimer +/// may be an identity that has never used OISY before — that is the point of the +/// feature — so requiring a user profile would defeat it. A principal is still +/// required, since the payout needs somewhere to land. +/// +/// # Errors +/// Errors are enumerated by `TipError` (`NotFound` for an unclaimable tip or a +/// wrong code, `Uncovered` when the sender's allowance no longer covers it, +/// `ClaimInProgress`, `TransferFailed`, `RateLimited`). +#[update(guard = "caller_is_not_anonymous")] +#[must_use] +pub async fn claim_tip(request: TipClaimRequest) -> ClaimTipResult { + if let Err(e) = CLAIM_TIP_RATE_LIMITER.with(TieredRateLimiter::check_caller) { + return ClaimTipResult::Err(TipError::RateLimited(e)); + } + service::claim_tip(request).await.into() +} + +/// Stops an unclaimed tip of the caller's from being claimable. The allowance +/// itself is the caller's to revoke — the client pairs this with an +/// `icrc2_approve` of zero. +/// +/// # Errors +/// Errors are enumerated by `TipError` (`NotFound`, `NotYourTip`, +/// `NotCancellable`, `ClaimInProgress`, `RateLimited`). +#[update(guard = "caller_is_registered_user")] +#[must_use] +pub fn cancel_tip(tip_id: String) -> CancelTipResult { + if let Err(e) = CANCEL_TIP_RATE_LIMITER.with(TieredRateLimiter::check_caller) { + return CancelTipResult::Err(TipError::RateLimited(e)); + } + service::cancel_tip(tip_id).into() +} + +/// Returns the caller's own tips, newest first, for History. Bounded by +/// `MAX_TIPS_RETURNED`. +/// +/// Not rate-limited, for the same reason as [`get_tip`]: a stateful limiter is +/// a no-op on the non-certified query path. The row cap is what bounds the work +/// here, and a caller can only ever read their own tips. +/// +/// # Errors +/// Errors are enumerated by `TipError`. +#[query(guard = "caller_is_registered_user")] +#[must_use] +pub fn get_my_tips() -> GetMyTipsResult { + service::get_my_tips().into() +} + +/// Stores the caller's encrypted claim code for one of their own tips, so they +/// can recover the link after closing the share screen. +/// +/// The value is ciphertext the canister cannot read: the browser encrypts it +/// under a vetKey only this principal can derive. Storing it changes nothing +/// about who can claim the tip — the canister still only holds the code's hash. +/// +/// # Errors +/// Errors are enumerated by `TipError` (`RateLimited`, `InvalidTipId`, +/// `SecretCiphertextTooLarge`, `InternalError`). +#[update(guard = "caller_is_registered_user")] +#[must_use] +pub fn set_tip_secret(request: SetTipSecretRequest) -> SetTipSecretResult { + if let Err(e) = SET_TIP_SECRET_RATE_LIMITER.with(TieredRateLimiter::check_caller) { + return SetTipSecretResult::Err(TipError::RateLimited(e)); + } + + secrets::set_tip_secret(request).into() +} + +/// The caller's encrypted claim code for one of their own tips, if stored. +/// +/// `EncryptedMaps` keys every map by its owner, so this can only ever return +/// the caller's own ciphertext. +/// +/// Not rate-limited, for the same reason as [`get_tip`]: a stateful limiter is +/// a no-op on the non-certified query path. The work is one keyed lookup +/// scoped to the caller. +/// +/// # Errors +/// Errors are enumerated by `TipError` (`InvalidTipId`, `InternalError`). +#[query(guard = "caller_is_registered_user")] +#[must_use] +pub fn get_tip_secret(tip_id: String) -> GetTipSecretResult { + secrets::get_tip_secret(tip_id).into() +} + +/// Derives the caller's vetKey for the tip-secrets store, secured to a +/// browser-supplied transport public key. +/// +/// # Errors +/// Errors are enumerated by `TipError` (`RateLimited`, `InternalError`). +#[update(guard = "caller_is_registered_user")] +#[must_use] +pub async fn get_tip_encrypted_vetkey(transport_key: ByteBuf) -> TipVetkeyResult { + if let Err(e) = GET_TIP_ENCRYPTED_VETKEY_RATE_LIMITER.with(TieredRateLimiter::check_caller) { + return TipVetkeyResult::Err(TipError::RateLimited(e)); + } + secrets::get_encrypted_vetkey(transport_key).await.into() +} + +/// The vetKey verification key for the tip-secrets store. Identical for every +/// caller; the browser needs it to verify its derived vetKey. +/// +/// # Errors +/// Errors are enumerated by `TipError` (`RateLimited`, `InternalError`). +#[update(guard = "caller_is_registered_user")] +#[must_use] +pub async fn get_tip_vetkey_public_key() -> TipVetkeyResult { + if let Err(e) = + GET_TIP_VETKEY_PUBLIC_KEY_RATE_LIMITER.with(rate_limiter::RateLimiter::check_caller) + { + return TipVetkeyResult::Err(TipError::RateLimited(e)); + } + secrets::get_vetkey_public_key().await.into() +} diff --git a/src/backend/src/lib.rs b/src/backend/src/lib.rs index 3a79f24b1e5..38b2a8ff602 100644 --- a/src/backend/src/lib.rs +++ b/src/backend/src/lib.rs @@ -31,23 +31,26 @@ use shared::{ result_types::{ ActiveUserTransactionResult, AddUserDismissedNotificationResult, AddUserHiddenDappIdResult, AllowSigningResult, BtcAddPendingTransactionResult, - BtcGetFeePercentilesResult, BtcGetPendingTransactionsResult, - ConsumePersonalNoteShareResult, CreateContactResult, CreatePersonalNoteShareResult, - CreateUserProfileResult, DeleteActiveUserTransactionResult, DeleteContactResult, - DeletePersonalNoteResult, GetActiveUserTransactionsResult, GetAgreementHistoryResult, - GetAllowedCyclesResult, GetContactResult, GetContactsResult, - GetPersonalNoteShareResult, GetPersonalNoteSharesCountResult, - GetPersonalNotesCountResult, GetPersonalNotesResult, GetUserProfileResult, + BtcGetFeePercentilesResult, BtcGetPendingTransactionsResult, CancelTipResult, + ClaimTipResult, ConsumePersonalNoteShareResult, CreateContactResult, + CreatePersonalNoteShareResult, CreateTipResult, CreateUserProfileResult, + DeleteActiveUserTransactionResult, DeleteContactResult, DeletePersonalNoteResult, + GetActiveUserTransactionsResult, GetAgreementHistoryResult, GetAllowedCyclesResult, + GetContactResult, GetContactsResult, GetMyTipsResult, GetPersonalNoteShareResult, + GetPersonalNoteSharesCountResult, GetPersonalNotesCountResult, GetPersonalNotesResult, + GetTipDetailsResult, GetTipResult, GetTipSecretResult, GetUserProfileResult, GetUserTransactionsResult, PersonalNotesVetkeyResult, SaveUserTransactionsResult, - SetPersonalNoteResult, SetUserShowTestnetsResult, SignOnramperWidgetUrlResult, - UpdateContactResult, UpdateExperimentalFeaturesSettingsResult, - UpdateProviderAgreementsResult, UpdateTransactionFilterSettingsResult, - UpdateUserAgreementsResult, UpdateUserNetworkSettingsResult, + SetPersonalNoteResult, SetTipSecretResult, SetUserShowTestnetsResult, + SignOnramperWidgetUrlResult, TipVetkeyResult, UpdateContactResult, + UpdateExperimentalFeaturesSettingsResult, UpdateProviderAgreementsResult, + UpdateTransactionFilterSettingsResult, UpdateUserAgreementsResult, + UpdateUserNetworkSettingsResult, }, signer::{ topup::{TopUpCyclesLedgerRequest, TopUpCyclesLedgerResult}, AllowSigningRequest, }, + tip::{CreateTipRequest, SetTipSecretRequest, TipClaimRequest}, token_id::TokenId, transaction_settings::UpdateTransactionFilterSettingsRequest, user_profile::HasUserProfileResponse, @@ -69,6 +72,7 @@ mod personal_notes; mod signer; mod state; mod status; +mod tips; mod token; mod transactions; mod types; diff --git a/src/backend/src/state/memory.rs b/src/backend/src/state/memory.rs index e92742fa0a2..2580ad3ab6b 100644 --- a/src/backend/src/state/memory.rs +++ b/src/backend/src/state/memory.rs @@ -42,8 +42,152 @@ pub(crate) const PERSONAL_NOTE_SHARES_BY_CREATOR_MEMORY_ID: MemoryId = MemoryId: // `(principal, contact_id)`; see `ContactImageKey`. pub(crate) const CONTACT_IMAGE_MEMORY_ID: MemoryId = MemoryId::new(20); +// Tips: one map keyed by the opaque tip id, and a by-sender index used to +// range-scan a sender's active-tip count and their History without walking the +// primary map. Same two-map shape as the note shares above. +// +// These were 20 and 21 while this branch was away from main. Main took 20 for +// `CONTACT_IMAGE_MEMORY_ID` in the meantime, and two structures pointing at one +// region decode into each other's data — so tips moved up rather than main +// moving, since main's is already live and ours is not. +pub(crate) const TIPS_MEMORY_ID: MemoryId = MemoryId::new(21); +pub(crate) const TIPS_BY_SENDER_MEMORY_ID: MemoryId = MemoryId::new(22); + +/// The four memories an `EncryptedMaps` needs for the per-tip claim-code store. +/// Mirrors `PERSONAL_NOTES_*` (14-17). Never renumber these once they hold data: +/// the ids are how the memory manager finds it again across an upgrade. +/// +/// Contiguous with the tips maps above, following this file's convention — ids +/// run in sequence, and a retired one is parked with a `RESERVED_` name rather +/// than skipped, the way id 5 is. +/// +/// These sat at 26-29 for a while, after an earlier move away from 22-25. +/// `KeyManager` keeps its vetKD key id in a `StableCell`, and `Cell::init` +/// *loads* the stored value whenever the region is non-empty — it writes the +/// value passed in only when the region is fresh. So the key name a store uses +/// is whichever was configured the first time it was ever touched, permanently, +/// and no redeployment can change it. A test environment initialised the store +/// under `dfx_test_key`, which exists only on a local replica, and every +/// derivation there trapped with `SignCostError(InvalidKeyName)`. +/// +/// Reusing 22-25 is safe now, and only now: that store never initialised +/// anywhere. The message that would have written the config cell trapped on the +/// key name and rolled back, and the later attempt trapped on the canister's +/// reserved-cycles limit before allocating. No environment holds a byte of it. +/// The one canister that does hold tips data, be1, is pinned to the old ids on +/// the deploy branch so it can keep upgrading; it is reinstalled the day that +/// pin is dropped, because main's contact images want the region its tips are +/// sitting in. +pub(crate) const TIP_SECRETS_KEY_MANAGER_CONFIG_MEMORY_ID: MemoryId = MemoryId::new(23); +pub(crate) const TIP_SECRETS_KEY_MANAGER_ACCESS_MEMORY_ID: MemoryId = MemoryId::new(24); +pub(crate) const TIP_SECRETS_KEY_MANAGER_SHARED_MEMORY_ID: MemoryId = MemoryId::new(25); +pub(crate) const TIP_SECRETS_ENCRYPTED_MAPS_MEMORY_ID: MemoryId = MemoryId::new(26); + thread_local! { pub(crate) static MEMORY_MANAGER: RefCell> = RefCell::new( MemoryManager::init(DefaultMemoryImpl::default()) ); } + +#[cfg(test)] +mod tests { + /// Every `MemoryId` in this file, read back out of the file itself. + /// + /// Parsed from the source rather than listed here on purpose: a hand-kept + /// list is one someone forgets to extend, and the whole point of this test + /// is to catch the constant that was added without checking what else holds + /// that id. + fn declared_ids() -> Vec<(u8, String)> { + declaration_chunks() + .filter_map(|chunk| { + let (name, rest) = chunk.split_once(": MemoryId = MemoryId::new(")?; + let id = rest.split(')').next()?.trim().parse().ok()?; + let name = name.rsplit(' ').next()?.to_string(); + + Some((id, name)) + }) + .collect() + } + + /// The file split into `;`-terminated declarations, comments dropped and + /// whitespace flattened. + /// + /// Not line-by-line, which is what this started as. rustfmt wraps a + /// declaration once the name grows long enough, and a line-based match then + /// skips it in silence — leaving a guard that passes while checking less + /// than it claims, which is worse than no guard at all. Comments go because + /// prose in this file names ids, and prose must not register as a claim on + /// one. + fn declaration_chunks() -> impl Iterator { + let source = include_str!("memory.rs"); + + // Everything above this module. `include_str!` pulls in the whole file, + // test included, and the patterns below appear here as string literals — + // which the counting check duly reported as two declarations nobody + // wrote. The guard is about the constants, so it reads only them. + let declarations = source + .split_once("#[cfg(test)]") + .map_or(source, |(before, _)| before); + + declarations + .split(';') + .map(|chunk| { + chunk + .lines() + .map(str::trim) + .filter(|line| !line.starts_with("//")) + .collect::>() + .join(" ") + .split_whitespace() + .collect::>() + .join(" ") + }) + .collect::>() + .into_iter() + } + + /// Two constants on one id is the failure this exists for, and nothing else + /// catches it. + /// + /// Stable memory is a globally shared, append-only namespace with no + /// compile-time protection. Two branches can each take what looks like the + /// next free id — tips took 20 while `CONTACT_IMAGE_MEMORY_ID` took 20 on + /// main — and both compile, both lint and both pass their own tests. The + /// damage only appears once they meet and deploy, as two structures decoding + /// each other's bytes. That collision was caught by reading the diff, which + /// is not a control. + #[test] + fn every_memory_id_is_claimed_once() { + let mut seen: Vec<(u8, String)> = Vec::new(); + + for (id, name) in declared_ids() { + assert!( + !seen.iter().any(|(other, _)| *other == id), + "MemoryId {id} is claimed by both {} and {name}. Stable memory at \ + an id belongs to whatever wrote it first — two structures there \ + decode each other's data. Take the next free id instead, and park \ + a retired one as RESERVED_* rather than handing it out again.", + seen.iter() + .find(|(other, _)| *other == id) + .map_or("?", |(_, taken)| taken.as_str()) + ); + seen.push((id, name)); + } + + // The parser has to keep up with the file, and "more than twenty" does + // not prove that: one declaration silently skipped still passes it. + // Counting the constructor calls the parser was supposed to find is the + // check that actually fails when it stops matching. + let constructed = declaration_chunks() + .filter(|chunk| chunk.contains("MemoryId::new(")) + .count(); + + assert_eq!( + seen.len(), + constructed, + "parsed {} ids out of {constructed} declarations that construct one, so the \ + parser has stopped matching the file it is meant to guard", + seen.len() + ); + } +} diff --git a/src/backend/src/state/mod.rs b/src/backend/src/state/mod.rs index 171c79a2b9c..81d35804dc9 100644 --- a/src/backend/src/state/mod.rs +++ b/src/backend/src/state/mod.rs @@ -17,15 +17,19 @@ use crate::{ PERSONAL_NOTES_ENCRYPTED_MAPS_MEMORY_ID, PERSONAL_NOTES_KEY_MANAGER_ACCESS_MEMORY_ID, PERSONAL_NOTES_KEY_MANAGER_CONFIG_MEMORY_ID, PERSONAL_NOTES_KEY_MANAGER_SHARED_MEMORY_ID, PERSONAL_NOTE_SHARES_BY_CREATOR_MEMORY_ID, PERSONAL_NOTE_SHARES_MEMORY_ID, - TOKEN_ACTIVITY_MEMORY_ID, USER_CUSTOM_TOKEN_MEMORY_ID, USER_PROFILE_MEMORY_ID, - USER_PROFILE_UPDATED_MEMORY_ID, USER_TOKEN_MEMORY_ID, USER_TRANSACTIONS_MEMORY_ID, + TIPS_BY_SENDER_MEMORY_ID, TIPS_MEMORY_ID, TIP_SECRETS_ENCRYPTED_MAPS_MEMORY_ID, + TIP_SECRETS_KEY_MANAGER_ACCESS_MEMORY_ID, TIP_SECRETS_KEY_MANAGER_CONFIG_MEMORY_ID, + TIP_SECRETS_KEY_MANAGER_SHARED_MEMORY_ID, TOKEN_ACTIVITY_MEMORY_ID, + USER_CUSTOM_TOKEN_MEMORY_ID, USER_PROFILE_MEMORY_ID, USER_PROFILE_UPDATED_MEMORY_ID, + USER_TOKEN_MEMORY_ID, USER_TRANSACTIONS_MEMORY_ID, }, + tips::secrets::TIP_SECRETS_DOMAIN_SEPARATOR, types::{ maps::{ ActiveUserTransactionsMap, AgreementHistoryMap, ApiKeysCell, BtcUserPendingTransactionsMap, ConfigCell, ContactImageMap, ContactMap, CustomTokenMap, - ExchangeRateMap, PersonalNoteShareMap, PersonalNoteSharesByCreatorMap, - TokenActivityMap, UserProfileMap, UserProfileUpdatedMap, UserTokenMap, + ExchangeRateMap, PersonalNoteShareMap, PersonalNoteSharesByCreatorMap, TipMap, + TipsBySenderMap, TokenActivityMap, UserProfileMap, UserProfileUpdatedMap, UserTokenMap, UserTransactionsMap, }, storable::Candid, @@ -72,6 +76,11 @@ pub(crate) struct State { /// (ids 14–17) and survives upgrades regardless of this field; /// [`EncryptedMaps::init`] re-attaches to it on first access. pub(crate) personal_notes: Option>, + + /// Per-user end-to-end-encrypted claim codes, so a sender can recover a tip + /// link after closing the share screen. Same lazy-init reasoning as + /// `personal_notes` above: it needs the vetKD key name from `config`. + pub(crate) tip_secrets: Option>, /// Publicly-readable, token-keyed store of personal-note shares. Unlike /// `personal_notes` above, this is a plain `StableBTreeMap` (the value is /// already client-side ciphertext under a per-share key, so there is no @@ -80,6 +89,12 @@ pub(crate) struct State { /// By-creator index over `personal_note_shares`, used only to enforce the /// per-user active-share cap without scanning the primary map. pub(crate) personal_note_shares_by_creator: PersonalNoteSharesByCreatorMap, + /// Tips: the canister holds no tokens for these, only the record of an + /// allowance the sender granted under a per-tip subaccount. See `tips`. + pub(crate) tips: TipMap, + /// By-sender index over `tips`, for the active-tip cap and the sender's + /// History, without scanning the primary map. + pub(crate) tips_by_sender: TipsBySenderMap, } impl From<&State> for Stats { @@ -99,6 +114,7 @@ impl From<&State> for Stats { .as_ref() .map_or(0, |em| em.mapkey_vals.len()), personal_note_shares_count: state.personal_note_shares.len(), + tips_count: state.tips.len(), } } } @@ -125,10 +141,14 @@ thread_local! { active_user_transactions: ActiveUserTransactionsMap::init(mm.borrow().get(ACTIVE_USER_TRANSACTIONS_MEMORY_ID)), // Initialised lazily on first access (see `ensure_personal_notes`). personal_notes: None, + // Initialised lazily on first access (see `ensure_tip_secrets`). + tip_secrets: None, personal_note_shares: PersonalNoteShareMap::init(mm.borrow().get(PERSONAL_NOTE_SHARES_MEMORY_ID)), personal_note_shares_by_creator: PersonalNoteSharesByCreatorMap::init( mm.borrow().get(PERSONAL_NOTE_SHARES_BY_CREATOR_MEMORY_ID), ), + tips: TipMap::init(mm.borrow().get(TIPS_MEMORY_ID)), + tips_by_sender: TipsBySenderMap::init(mm.borrow().get(TIPS_BY_SENDER_MEMORY_ID)), }) ); } @@ -193,6 +213,73 @@ pub(crate) fn with_personal_notes_mut( }) } +/// Initialises the tip-secrets [`EncryptedMaps`] store. Mirrors +/// [`init_personal_notes`], including reusing `config.ecdsa_key_name` as the +/// vetKD key name. +fn init_tip_secrets() { + let key_id = VetKDKeyId { + curve: VetKDCurve::Bls12_381_G2, + name: read_config(|c| c.ecdsa_key_name.clone()), + }; + + let encrypted_maps = MEMORY_MANAGER.with(|mm| { + let mm = mm.borrow(); + EncryptedMaps::init( + TIP_SECRETS_DOMAIN_SEPARATOR, + key_id, + mm.get(TIP_SECRETS_KEY_MANAGER_CONFIG_MEMORY_ID), + mm.get(TIP_SECRETS_KEY_MANAGER_ACCESS_MEMORY_ID), + mm.get(TIP_SECRETS_KEY_MANAGER_SHARED_MEMORY_ID), + mm.get(TIP_SECRETS_ENCRYPTED_MAPS_MEMORY_ID), + ) + }); + + mutate_state(|s| s.tip_secrets = Some(encrypted_maps)); +} + +/// Ensures the tip-secrets store is initialised, creating it on first use. +pub(crate) fn ensure_tip_secrets() { + if read_state(|s| s.tip_secrets.is_none()) { + init_tip_secrets(); + } +} + +/// Runs `f` against the tip-secrets store, initialising it on first access. +pub(crate) fn with_tip_secrets(f: impl FnOnce(&EncryptedMaps) -> R) -> R { + ensure_tip_secrets(); + read_state(|s| { + f(s.tip_secrets + .as_ref() + .expect("tip secrets store initialised by ensure_tip_secrets")) + }) +} + +/// Runs `f` against the mutable tip-secrets store, initialising it on first +/// access. +pub(crate) fn with_tip_secrets_mut(f: impl FnOnce(&mut EncryptedMaps) -> R) -> R { + ensure_tip_secrets(); + mutate_state(|s| { + f(s.tip_secrets + .as_mut() + .expect("tip secrets store initialised by ensure_tip_secrets")) + }) +} + +/// Runs `f` against the tip-secrets store **only if it already exists**, and +/// returns `None` when it does not. +/// +/// Cleanup paths must use this rather than [`with_tip_secrets_mut`]. That one +/// calls [`ensure_tip_secrets`], which creates the store on first access and +/// allocates its four stable-memory regions — so an hourly sweep over a canister +/// where nobody ever stored a claim code would allocate 32 MiB purely to discover +/// there was nothing to delete, and on a canister short of reserved cycles it +/// would trap instead. +pub(crate) fn with_existing_tip_secrets_mut( + f: impl FnOnce(&mut EncryptedMaps) -> R, +) -> Option { + mutate_state(|s| s.tip_secrets.as_mut().map(f)) +} + pub(crate) fn read_state(f: impl FnOnce(&State) -> R) -> R { STATE.with(|cell| f(&cell.borrow())) } diff --git a/src/backend/src/tips/icrc2.rs b/src/backend/src/tips/icrc2.rs new file mode 100644 index 00000000000..c42fe10c17e --- /dev/null +++ b/src/backend/src/tips/icrc2.rs @@ -0,0 +1,115 @@ +//! ICRC-1/2 calls for arbitrary token ledgers. +//! +//! The **types** come from `icrc-ledger-types`, the canonical ICRC crate. It is +//! types only — no client, no transport — so it carries no opinion about how the +//! calls below are made. Its `Subaccount` is `[u8; 32]` rather than a `ByteBuf`, +//! which is the same `blob` on the wire and makes a wrong-length subaccount +//! unrepresentable. +//! +//! The **calls** stay local, for two reasons that survive that: +//! +//! - `CyclesLedgerService` uses `Call::bounded_wait` throughout. A payout is the one call in this +//! feature that must not come back "maybe": a bounded wait that times out leaves the canister +//! unable to say whether money moved, and the claim state machine has nothing safe to do with +//! that answer. These use `unbounded_wait`. +//! - That client is a struct bound to one canister and bundles cycles-only methods — `deposit`, +//! `withdraw`, `create_canister`. Tips points at whatever ledger the sender chose, and needs +//! three methods. +//! +//! An earlier version of this comment claimed the existing client "speaks the +//! cycles ledger's dialect — `icrc_2_approve`, with underscores". That was +//! wrong: the underscore is in the Rust method name only, and the wire strings +//! there are the standard `icrc2_*`. Recorded because it is the obvious thing to +//! assume, and it is not a reason to write a second client. + +use candid::{Nat, Principal}; +use ic_cdk::call::Call; +// Re-exported so callers keep importing the ledger shapes from the module that +// speaks to the ledger. +pub use icrc_ledger_types::{ + icrc1::account::Account, + icrc2::{ + allowance::{Allowance, AllowanceArgs}, + transfer_from::{TransferFromArgs, TransferFromError}, + }, +}; + +/// Why a payout did not happen. +/// +/// The split matters. `InsufficientAllowance` is the sender having reduced or +/// revoked the reservation; `InsufficientFunds` is the reservation still standing +/// with the money gone, which topping up fixes. Everything else is `Failed`. All +/// three leave the tip claimable. +#[derive(Debug)] +pub enum TransferFromCallError { + InsufficientAllowance, + /// The sender's balance dropped below the amount. Kept apart from `Failed` + /// because it is the one failure the sender can fix, and the claimer can be + /// told to come back rather than just "try again". + InsufficientFunds, + Failed(String), +} + +/// The ledger's current transfer fee, needed to size the minimum tip and to +/// check that an allowance covers a payout plus its fee. +/// +/// # Errors +/// Returns the ledger's own message when the call fails or cannot be decoded. +pub async fn fee(ledger: Principal) -> Result { + Call::unbounded_wait(ledger, "icrc1_fee") + .with_args(&()) + .await + .map_err(|err| format!("icrc1_fee call failed: {err:?}"))? + .candid::() + .map_err(|err| format!("icrc1_fee decode failed: {err:?}")) +} + +/// The allowance an account has granted to a spender account. +/// +/// # Errors +/// Returns the ledger's own message when the call fails or cannot be decoded. +pub async fn allowance(ledger: Principal, args: AllowanceArgs) -> Result { + Call::unbounded_wait(ledger, "icrc2_allowance") + .with_args(&(args,)) + .await + .map_err(|err| format!("icrc2_allowance call failed: {err:?}"))? + .candid::() + .map_err(|err| format!("icrc2_allowance decode failed: {err:?}")) +} + +/// Moves tokens from the sender's account to the claimer's, drawing on the +/// per-tip allowance. The ledger charges its fee to the allowance, not to the +/// transferred amount, so the claimer receives `args.amount` in full. +/// +/// # Errors +/// [`TransferFromCallError::InsufficientAllowance`] when the reservation no +/// longer covers the payout; [`TransferFromCallError::Failed`] for any other +/// ledger rejection or transport failure. +pub async fn transfer_from( + ledger: Principal, + args: TransferFromArgs, +) -> Result { + let response = Call::unbounded_wait(ledger, "icrc2_transfer_from") + .with_args(&(args,)) + .await + .map_err(|err| { + TransferFromCallError::Failed(format!("icrc2_transfer_from call failed: {err:?}")) + })?; + + // `Result` is what candid's `variant { Ok; Err }` decodes into, so the named + // wrapper enum this used to carry was a third copy of the same shape. + match response + .candid::>() + .map_err(|err| { + TransferFromCallError::Failed(format!("icrc2_transfer_from decode failed: {err:?}")) + })? { + Ok(block_index) => Ok(block_index), + Err(TransferFromError::InsufficientAllowance { .. }) => { + Err(TransferFromCallError::InsufficientAllowance) + } + Err(TransferFromError::InsufficientFunds { .. }) => { + Err(TransferFromCallError::InsufficientFunds) + } + Err(err) => Err(TransferFromCallError::Failed(format!("{err:?}"))), + } +} diff --git a/src/backend/src/tips/mod.rs b/src/backend/src/tips/mod.rs new file mode 100644 index 00000000000..a4ec09359d5 --- /dev/null +++ b/src/backend/src/tips/mod.rs @@ -0,0 +1,14 @@ +//! Sending a tip via a link or QR code. +//! +//! The canister never holds the tokens. A tip is an ICRC-2 allowance the sender +//! grants to this canister under a **per-tip spender subaccount**, so the funds +//! stay in the sender's own account and an unclaimed tip lapses on the ledger +//! with nothing to refund. The per-tip subaccount is what keeps tips isolated: +//! an allowance granted for one tip is unusable for any other, even though +//! every tip shares this canister as the spender. See +//! `docs/ai/spec-driven-development/specs/2026-08-05-feat-tips-via-link.md`. + +pub mod icrc2; +pub mod model; +pub mod secrets; +pub mod service; diff --git a/src/backend/src/tips/model.rs b/src/backend/src/tips/model.rs new file mode 100644 index 00000000000..6fbc088eb5f --- /dev/null +++ b/src/backend/src/tips/model.rs @@ -0,0 +1,542 @@ +//! Pure domain type and invariant checks for tips. No IC calls or state access +//! here — see `service.rs` for orchestration. + +use candid::{CandidType, Deserialize, Nat, Principal}; +use serde_bytes::ByteBuf; +use sha2::{Digest, Sha256}; +use shared::types::tip::{ + MyTip, PublicTip, TipClaimFailure, TipDetails, TipError, TipStatus, MAX_TIPS_PER_USER, + MAX_TIP_EXPIRY_NS, MAX_TIP_ID_BYTES, MAX_TIP_MESSAGE_CHARS, TIP_CLAIM_CODE_HASH_BYTES, + TIP_CLAIM_IN_FLIGHT_TIMEOUT_NS, +}; + +/// Where a tip is in its lifecycle, as stored. +/// +/// `Claiming` exists so a claim can reserve the tip *before* awaiting the +/// ledger: two concurrent claims cannot both reach the transfer, and a claim +/// that dies mid-flight (canister upgrade) is recoverable by timeout rather +/// than stranding the tip forever. +#[derive(CandidType, Deserialize, Clone, Debug)] +pub enum TipState { + Reserved, + Claiming { + claimer: Principal, + started_at_ns: u64, + }, + Claimed { + claimer: Principal, + block_index: Nat, + claimed_at_ns: u64, + }, + Cancelled, +} + +/// The stored record for one tip. +/// +/// `claim_code_hash` is the SHA-256 of the code that lives only in the link +/// fragment: the code itself never reaches the canister, and no endpoint +/// returns the hash. `sender` is returned only to the sender's own +/// `get_my_tips` — never to a claimer or an anonymous reader. +#[derive(CandidType, Deserialize, Clone, Debug)] +pub struct TipRecord { + pub sender: Principal, + pub ledger_canister_id: Principal, + pub amount: Nat, + pub expires_at_ns: u64, + pub created_at_ns: u64, + pub message: Option, + pub claim_code_hash: ByteBuf, + pub state: TipState, + /// When the tip reached a terminal state, which is where its retention + /// window starts counting from. + /// + /// Only `Cancelled` needs it — `Claimed` already carries `claimed_at_ns`, + /// and a live tip has no terminal instant. An `Option` rather than a field + /// on the variant so records already in stable memory keep decoding: + /// candid reads a missing `opt` as `None`, whereas turning the unit + /// `Cancelled` into a record would make every stored cancellation + /// undecodable. + pub terminal_at_ns: Option, + /// The most recent claim attempt that did not pay out. + /// + /// Set on every failed payout and never cleared: a tip that failed once and + /// then succeeded is worth being able to see. `status` decides what that + /// means for the reader, and only reports `Failed` while the tip is still + /// live. `Option` so records already in stable memory keep decoding. + pub last_claim_failure: Option, +} + +impl TipRecord { + pub fn is_expired(&self, now_ns: u64) -> bool { + self.expires_at_ns <= now_ns + } + + /// Whether a claim may proceed: the tip is unexpired and either untouched + /// or left in flight by a claim that never came back (see + /// [`TIP_CLAIM_IN_FLIGHT_TIMEOUT_NS`]). + pub fn is_claimable(&self, now_ns: u64) -> bool { + if self.is_expired(now_ns) { + return false; + } + match &self.state { + TipState::Reserved => true, + TipState::Claiming { started_at_ns, .. } => { + now_ns.saturating_sub(*started_at_ns) >= TIP_CLAIM_IN_FLIGHT_TIMEOUT_NS + } + TipState::Claimed { .. } | TipState::Cancelled => false, + } + } + + /// Whether a claim is in flight and still within its window — the one case + /// that earns [`TipError::ClaimInProgress`] rather than `NotFound`, since it + /// resolves on its own and the caller should simply retry. + pub fn has_claim_in_flight(&self, now_ns: u64) -> bool { + matches!(&self.state, TipState::Claiming { started_at_ns, .. } + if now_ns.saturating_sub(*started_at_ns) < TIP_CLAIM_IN_FLIGHT_TIMEOUT_NS) + && !self.is_expired(now_ns) + } + + /// The lifecycle as the sender sees it in History. A claim in flight still + /// reads as `Reserved` — an attempt is not a payout. + pub fn status(&self, now_ns: u64) -> TipStatus { + match &self.state { + TipState::Claimed { .. } => TipStatus::Claimed, + TipState::Cancelled => TipStatus::Cancelled, + TipState::Reserved | TipState::Claiming { .. } => { + if self.is_expired(now_ns) { + // Expired outranks Failed: once the deadline has passed there + // is nothing left for the sender to do about the failure. + TipStatus::Expired + } else if self.last_claim_failure.is_some() { + TipStatus::Failed + } else { + TipStatus::Reserved + } + } + } + } + + pub fn claimed_by(&self) -> Option { + match &self.state { + TipState::Claimed { claimer, .. } => Some(*claimer), + _ => None, + } + } + + /// What an anonymous reader of the link may see: amount, token, deadline. + /// Never the message, the sender, or the claimer. + pub fn to_public(&self) -> PublicTip { + PublicTip { + ledger_canister_id: self.ledger_canister_id, + amount: self.amount.clone(), + expires_at_ns: self.expires_at_ns, + } + } + + /// What an authenticated claimer sees before committing: the preview plus + /// the sender's message. + pub fn to_details(&self) -> TipDetails { + TipDetails { + ledger_canister_id: self.ledger_canister_id, + amount: self.amount.clone(), + expires_at_ns: self.expires_at_ns, + message: self.message.clone(), + } + } + + pub fn to_my_tip(&self, tip_id: String, now_ns: u64) -> MyTip { + MyTip { + tip_id, + ledger_canister_id: self.ledger_canister_id, + amount: self.amount.clone(), + expires_at_ns: self.expires_at_ns, + created_at_ns: self.created_at_ns, + status: self.status(now_ns), + message: self.message.clone(), + claimed_by: self.claimed_by(), + last_claim_failure: self.last_claim_failure.clone(), + } + } +} + +/// SHA-256 of a claim code. Used to check a submitted code against the stored +/// hash, and — over the tip id — to derive the tip's spender subaccount. +pub fn sha256(bytes: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher.finalize().into() +} + +/// The per-tip spender subaccount: `SHA-256(tip_id)`, 32 bytes, exactly the +/// width an ICRC-1 subaccount takes. +/// +/// This is the load-bearing part of the no-custody model. The sender approves +/// this canister *at this subaccount only*, so the resulting allowance can pay +/// out this tip and nothing else — a second tip from the same sender to the +/// same canister sits at a different subaccount and cannot be drawn on. +pub fn spender_subaccount(tip_id: &str) -> [u8; 32] { + sha256(tip_id.as_bytes()) +} + +/// Constant-time comparison of a submitted claim code against the stored hash. +/// +/// Guessing a 128-bit code is the real barrier here, not comparison timing, but +/// a byte-by-byte early return leaks a prefix oracle for free and there is no +/// reason to hand it over. +/// +/// Takes the code **by value**: a claim code is checked once and has no further +/// use, so consuming it keeps the plaintext from lingering in a caller's scope. +pub fn claim_code_matches(stored_hash: &[u8], submitted_code: String) -> bool { + let submitted = sha256(&submitted_code.into_bytes()); + if stored_hash.len() != submitted.len() { + return false; + } + stored_hash + .iter() + .zip(submitted.iter()) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + == 0 +} + +pub fn validate_tip_id(tip_id: &str) -> Result<(), TipError> { + if tip_id.is_empty() || tip_id.len() > MAX_TIP_ID_BYTES as usize { + return Err(TipError::InvalidTipId); + } + Ok(()) +} + +pub fn validate_claim_code_hash(hash: &[u8]) -> Result<(), TipError> { + if hash.len() != TIP_CLAIM_CODE_HASH_BYTES { + return Err(TipError::InvalidClaimCodeHash); + } + Ok(()) +} + +pub fn validate_message(message: Option<&str>) -> Result<(), TipError> { + if let Some(message) = message { + if message.chars().count() > MAX_TIP_MESSAGE_CHARS { + return Err(TipError::MessageTooLong); + } + } + Ok(()) +} + +/// Validates the requested expiry is strictly in the future and no further out +/// than [`MAX_TIP_EXPIRY_NS`] — defense-in-depth against a client bypassing the +/// UI's expiry options to encumber a balance indefinitely. +pub fn validate_expiry(expires_at_ns: u64, now_ns: u64) -> Result<(), TipError> { + if expires_at_ns <= now_ns || expires_at_ns - now_ns > MAX_TIP_EXPIRY_NS { + return Err(TipError::InvalidExpiry); + } + Ok(()) +} + +/// The minimum tip is one ledger fee. +/// +/// This is the answer to "minimum amount per token" without a per-token table +/// to maintain: below the cost of moving it, a tip is spam by construction, and +/// the ledger already tells us that number. +pub fn validate_amount(amount: &Nat, ledger_fee: &Nat) -> Result<(), TipError> { + if amount == &Nat::from(0u8) || amount < ledger_fee { + return Err(TipError::AmountTooSmall); + } + Ok(()) +} + +/// Whether creating another tip would exceed the per-sender active-tip cap. +pub fn new_tip_exceeds_cap(active_count: usize) -> bool { + active_count >= MAX_TIPS_PER_USER +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use shared::types::tip::TipClaimFailureReason; + + use super::*; + + const ONE_SEC: u64 = 1_000_000_000; + + fn principal(id: u8) -> Principal { + Principal::from_slice(&[id]) + } + + fn record(state: TipState, expires_at_ns: u64) -> TipRecord { + TipRecord { + sender: principal(1), + ledger_canister_id: principal(9), + amount: Nat::from(1_000u64), + expires_at_ns, + created_at_ns: 0, + message: Some("thanks!".to_string()), + claim_code_hash: ByteBuf::from(sha256(b"code").to_vec()), + state, + terminal_at_ns: None, + last_claim_failure: None, + } + } + + #[test] + fn tip_id_bounds() { + assert_eq!(validate_tip_id(""), Err(TipError::InvalidTipId)); + assert!(validate_tip_id("a").is_ok()); + assert!(validate_tip_id(&"a".repeat(MAX_TIP_ID_BYTES as usize)).is_ok()); + assert_eq!( + validate_tip_id(&"a".repeat(MAX_TIP_ID_BYTES as usize + 1)), + Err(TipError::InvalidTipId) + ); + } + + #[test] + fn claim_code_hash_must_be_exactly_sha256_wide() { + assert!(validate_claim_code_hash(&[0u8; TIP_CLAIM_CODE_HASH_BYTES]).is_ok()); + assert_eq!( + validate_claim_code_hash(&[0u8; TIP_CLAIM_CODE_HASH_BYTES - 1]), + Err(TipError::InvalidClaimCodeHash) + ); + assert_eq!( + validate_claim_code_hash(&[0u8; TIP_CLAIM_CODE_HASH_BYTES + 1]), + Err(TipError::InvalidClaimCodeHash) + ); + assert_eq!( + validate_claim_code_hash(&[]), + Err(TipError::InvalidClaimCodeHash) + ); + } + + #[test] + fn message_limit_counts_characters_not_bytes() { + assert!(validate_message(None).is_ok()); + assert!(validate_message(Some("")).is_ok()); + assert!(validate_message(Some(&"a".repeat(MAX_TIP_MESSAGE_CHARS))).is_ok()); + assert_eq!( + validate_message(Some(&"a".repeat(MAX_TIP_MESSAGE_CHARS + 1))), + Err(TipError::MessageTooLong) + ); + // A 4-byte emoji is one character: 250 of them fit, 251 do not. Counting + // bytes here would reject a message the UI presents as well within limit. + assert!(validate_message(Some(&"🎉".repeat(MAX_TIP_MESSAGE_CHARS))).is_ok()); + assert_eq!( + validate_message(Some(&"🎉".repeat(MAX_TIP_MESSAGE_CHARS + 1))), + Err(TipError::MessageTooLong) + ); + } + + #[test] + fn expiry_must_be_in_the_future_and_bounded() { + let now = 1_000 * ONE_SEC; + assert_eq!(validate_expiry(now, now), Err(TipError::InvalidExpiry)); + assert_eq!(validate_expiry(now - 1, now), Err(TipError::InvalidExpiry)); + assert!(validate_expiry(now + ONE_SEC, now).is_ok()); + assert!(validate_expiry(now + MAX_TIP_EXPIRY_NS, now).is_ok()); + assert_eq!( + validate_expiry(now + MAX_TIP_EXPIRY_NS + 1, now), + Err(TipError::InvalidExpiry) + ); + } + + #[test] + fn minimum_tip_is_one_ledger_fee() { + let fee = Nat::from(10_000u64); + assert_eq!( + validate_amount(&Nat::from(0u8), &fee), + Err(TipError::AmountTooSmall) + ); + assert_eq!( + validate_amount(&Nat::from(9_999u64), &fee), + Err(TipError::AmountTooSmall) + ); + assert!(validate_amount(&fee, &fee).is_ok()); + assert!(validate_amount(&Nat::from(10_001u64), &fee).is_ok()); + } + + #[test] + fn cap_boundary() { + assert!(!new_tip_exceeds_cap(MAX_TIPS_PER_USER - 1)); + assert!(new_tip_exceeds_cap(MAX_TIPS_PER_USER)); + } + + #[test] + fn claim_code_matches_only_the_right_code() { + let stored = sha256(b"the-real-code"); + assert!(claim_code_matches(&stored, "the-real-code".to_string())); + assert!(!claim_code_matches(&stored, "the-real-cod".to_string())); + assert!(!claim_code_matches(&stored, String::new())); + // A stored hash of the wrong width can never match, and must not panic. + assert!(!claim_code_matches( + &stored[..31], + "the-real-code".to_string() + )); + } + + #[test] + fn spender_subaccount_is_per_tip_and_deterministic() { + let a = spender_subaccount("tip-a"); + assert_eq!(a, spender_subaccount("tip-a")); + assert_ne!( + a, + spender_subaccount("tip-b"), + "two tips must never share a spender subaccount — that isolation is what \ + keeps one tip's allowance unusable for another" + ); + assert_eq!(a.len(), 32, "an ICRC-1 subaccount is 32 bytes wide"); + } + + #[test] + fn reserved_tip_is_claimable_until_it_expires() { + let tip = record(TipState::Reserved, 100); + assert!(tip.is_claimable(99)); + assert!(!tip.is_claimable(100), "expiry is inclusive"); + assert!(!tip.is_claimable(101)); + assert_eq!(tip.status(99), TipStatus::Reserved); + assert_eq!(tip.status(100), TipStatus::Expired); + } + + #[test] + fn a_failed_claim_shows_as_failed_only_while_the_tip_is_still_live() { + let now = 10 * ONE_SEC; + let failure = Some(TipClaimFailure { + at_ns: now - ONE_SEC, + reason: TipClaimFailureReason::TransferFailed, + }); + + // Live and untouched vs live and already failed: the distinction the + // status exists to make, since only the second is actionable. + let live = record(TipState::Reserved, now + ONE_SEC); + assert_eq!(live.status(now), TipStatus::Reserved); + assert_eq!( + TipRecord { + last_claim_failure: failure.clone(), + ..live + } + .status(now), + TipStatus::Failed + ); + + // Expiry outranks it: past the deadline there is nothing to act on. + assert_eq!( + TipRecord { + last_claim_failure: failure.clone(), + ..record(TipState::Reserved, now - ONE_SEC) + } + .status(now), + TipStatus::Expired + ); + + // And a tip that failed once, then paid out, reads as Claimed — while + // still carrying the failure, so History can say it was not first time + // lucky. + let claimed = TipRecord { + last_claim_failure: failure.clone(), + ..record( + TipState::Claimed { + claimer: principal(2), + block_index: Nat::from(1u64), + claimed_at_ns: now, + }, + now + ONE_SEC, + ) + }; + assert_eq!(claimed.status(now), TipStatus::Claimed); + assert_eq!( + claimed.to_my_tip("t".to_string(), now).last_claim_failure, + failure + ); + + assert_eq!( + TipRecord { + last_claim_failure: failure, + ..record(TipState::Cancelled, now + ONE_SEC) + } + .status(now), + TipStatus::Cancelled + ); + } + + #[test] + fn claimed_and_cancelled_are_terminal() { + let claimed = record( + TipState::Claimed { + claimer: principal(2), + block_index: Nat::from(7u64), + claimed_at_ns: 50, + }, + 100, + ); + assert!(!claimed.is_claimable(99)); + assert_eq!(claimed.status(99), TipStatus::Claimed); + assert_eq!( + claimed.status(101), + TipStatus::Claimed, + "a claimed tip does not become Expired once its deadline passes" + ); + assert_eq!(claimed.claimed_by(), Some(principal(2))); + + let cancelled = record(TipState::Cancelled, 100); + assert!(!cancelled.is_claimable(99)); + assert_eq!(cancelled.status(99), TipStatus::Cancelled); + assert_eq!(cancelled.claimed_by(), None); + } + + #[test] + fn an_in_flight_claim_blocks_others_until_it_times_out() { + let started = 1_000 * ONE_SEC; + let tip = record( + TipState::Claiming { + claimer: principal(2), + started_at_ns: started, + }, + started + MAX_TIP_EXPIRY_NS, + ); + + assert!(tip.has_claim_in_flight(started)); + assert!( + !tip.is_claimable(started), + "no second payout while in flight" + ); + assert!(tip.has_claim_in_flight(started + TIP_CLAIM_IN_FLIGHT_TIMEOUT_NS - 1)); + + // Past the window, a fresh claim may take over — otherwise a claim + // interrupted by an upgrade would strand the tip forever. + assert!(!tip.has_claim_in_flight(started + TIP_CLAIM_IN_FLIGHT_TIMEOUT_NS)); + assert!(tip.is_claimable(started + TIP_CLAIM_IN_FLIGHT_TIMEOUT_NS)); + + assert_eq!( + tip.status(started), + TipStatus::Reserved, + "an attempt is not a payout: History still reads Reserved" + ); + } + + #[test] + fn an_expired_tip_has_no_claim_in_flight() { + let tip = record( + TipState::Claiming { + claimer: principal(2), + started_at_ns: 100, + }, + 200, + ); + assert!(tip.has_claim_in_flight(150)); + assert!( + !tip.has_claim_in_flight(200), + "once expired there is nothing left to be in flight for" + ); + } + + #[test] + fn the_anonymous_preview_carries_no_message_and_the_claim_review_does() { + let tip = record(TipState::Reserved, 100); + let public = tip.to_public(); + assert_eq!(public.amount, Nat::from(1_000u64)); + assert_eq!(public.expires_at_ns, 100); + + let details = tip.to_details(); + assert_eq!(details.message, Some("thanks!".to_string())); + + let mine = tip.to_my_tip("tip-1".to_string(), 99); + assert_eq!(mine.tip_id, "tip-1"); + assert_eq!(mine.status, TipStatus::Reserved); + assert_eq!(mine.claimed_by, None); + } +} diff --git a/src/backend/src/tips/secrets.rs b/src/backend/src/tips/secrets.rs new file mode 100644 index 00000000000..10091b2bae8 --- /dev/null +++ b/src/backend/src/tips/secrets.rs @@ -0,0 +1,211 @@ +//! Per-tip claim-code recovery, stored end-to-end encrypted. +//! +//! The claim code is generated in the browser and only its hash reaches this +//! canister, which is what keeps a tip link unforgeable — and also what made a +//! link unrecoverable once the sender closed the share screen. This module gives +//! the sender a way back to it without weakening that: the browser encrypts the +//! code under a vetKey only that principal can derive, and stores the +//! ciphertext here. The canister moves opaque bytes and can no more read a +//! claim code than before. +//! +//! Deliberately a second `EncryptedMaps` rather than a field on `TipRecord`. +//! `EncryptedMaps` is what enforces that a map belongs to one principal, so +//! reusing it means the "only the sender can read their own codes" property is +//! the library's job and not ours. Mirrors `personal_notes`. + +use std::cell::RefCell; + +use candid::Principal; +use ic_cdk::api::msg_caller; +use ic_stable_structures::storable::Blob; +use ic_vetkeys::types::ByteBuf as VetkeysByteBuf; +use serde_bytes::ByteBuf; +use shared::types::tip::{SetTipSecretRequest, TipError, MAX_TIP_SECRET_CIPHERTEXT_BYTES}; + +use crate::{ + state::{with_existing_tip_secrets_mut, with_tip_secrets, with_tip_secrets_mut}, + tips::model::{sha256, validate_tip_id}, +}; + +/// Domain separator bound into the vetKD derivation for the tip-secrets store. +/// Never change this for a deployed canister — it is part of the key derivation, +/// so changing it would orphan every stored ciphertext, and with it every +/// sender's ability to recover their own links. +pub const TIP_SECRETS_DOMAIN_SEPARATOR: &str = "oisy_tip_secrets"; + +/// Raw bytes of the single map name used for every user's tip-secrets map. Each +/// user owns their own map under their own principal, so a constant name is +/// enough. Distinct from the personal-notes name on purpose: the two stores +/// derive different keys, so a fault or a rotation in one cannot reach the other. +const TIP_SECRETS_MAP_NAME: &[u8] = b"tip_secrets"; + +/// The fixed 32-byte map name. `EncryptedMaps` map names are `Blob<32>`; the +/// constant name is right-padded with zero bytes. +pub fn tip_secrets_map_name() -> Blob<32> { + let mut bytes = [0u8; 32]; + bytes[..TIP_SECRETS_MAP_NAME.len()].copy_from_slice(TIP_SECRETS_MAP_NAME); + Blob::try_from(bytes.as_slice()).expect("a 32-byte array always fits a Blob<32>") +} + +/// `EncryptedMaps` identifies each map by `(owner, map_name)`. The owner is the +/// caller, so a sender automatically owns their own tip-secrets map and no +/// caller can reach another principal's. +type KeyId = (Principal, Blob<32>); + +fn caller_key_id() -> KeyId { + (msg_caller(), tip_secrets_map_name()) +} + +/// Wraps an `EncryptedMaps` (`String`) error. The message never carries a claim +/// code — the canister cannot read one. +fn internal(msg: String) -> TipError { + TipError::InternalError { msg } +} + +/// Takes the id by value so the exported endpoints can move it straight +/// through: a candid method must accept an owned `String`, and clippy's +/// `needless_pass_by_value` is satisfied only if something downstream consumes +/// it. `service::cancel_tip` moves its id into `TipId` for the same reason. +fn tip_id_to_map_key(tip_id: String) -> Result, TipError> { + // Through the canonical validator rather than a length check of its own. + // An empty id is a key no tip can ever match, so an entry under it would + // outlive every cleanup path — claim, cancel and prune all remove by tip id. + validate_tip_id(&tip_id)?; + + // Hashed, not copied. `MAX_TIP_ID_BYTES` is 64 and this key is a `Blob<32>`, + // so the raw bytes made ids of 33-64 a silent dead zone: creatable, + // claimable and cancellable, but their recovery secret could never be stored + // or read, and the sender would only find out when the link they wanted back + // was not there. Hashing removes the coupling instead of trading one bound + // for the other, and it is what `spender_subaccount` already does with the + // same id. + Ok(Blob::try_from(sha256(&tip_id.into_bytes()).as_slice()) + .expect("a 32-byte digest always fits a Blob<32>")) +} + +/// Stores the encrypted claim code for one of the caller's tips. +/// +/// Not gated on the tip existing: the browser writes this immediately after a +/// successful `create_tip`, and a stray entry for a tip that never materialised +/// is a few opaque bytes in the caller's own map, cleaned up with the tip. +pub fn set_tip_secret(request: SetTipSecretRequest) -> Result<(), TipError> { + if request.encrypted_claim_code.len() > MAX_TIP_SECRET_CIPHERTEXT_BYTES { + return Err(TipError::SecretCiphertextTooLarge); + } + + let map_key = tip_id_to_map_key(request.tip_id)?; + let key_id = caller_key_id(); + let caller = key_id.0; + + with_tip_secrets_mut(|encrypted_maps| { + encrypted_maps + .insert_encrypted_value( + caller, + key_id, + map_key, + VetkeysByteBuf::from(request.encrypted_claim_code.into_vec()), + ) + .map_err(internal)?; + Ok(()) + }) +} + +/// The encrypted claim code for one of the caller's tips, if one was stored. +/// `None` for a tip created before this store existed, or one whose secret has +/// been dropped — the caller cannot tell those apart, and neither case is an +/// error. +pub fn get_tip_secret(tip_id: String) -> Result, TipError> { + let map_key = tip_id_to_map_key(tip_id)?; + let key_id = caller_key_id(); + let caller = key_id.0; + + with_tip_secrets(|encrypted_maps| { + let value = encrypted_maps + .get_encrypted_value(caller, key_id, map_key) + .map_err(internal)?; + Ok(value.map(|bytes| ByteBuf::from(Vec::::from(bytes)))) + }) +} + +/// Drops the stored code for a tip, addressed by its **owner** rather than by +/// whoever is calling. +/// +/// Called when a tip reaches a state where the link is worthless — cancelled, +/// claimed, or swept after its retention window — so a recoverable secret does +/// not outlive its usefulness. +/// +/// Takes the owner explicitly because two of those three callers are not the +/// sender: a claim runs as the recipient, and the retention sweep runs as the +/// canister on a timer. `EncryptedMaps` checks writes with +/// `ensure_user_can_write(caller, key_id)`, which an owner satisfies implicitly, +/// so passing the owner as both is what lets those paths clean up at all. The +/// earlier caller-scoped version is why nothing but an explicit cancel ever +/// released a secret. +/// +/// Never initialises the store — see [`with_existing_tip_secrets_mut`]. A +/// canister with no stored codes has nothing to clean up, and `Ok(())` is the +/// honest answer rather than a reason to allocate its memory. +pub fn remove_tip_secret_for(owner: Principal, tip_id: String) -> Result<(), TipError> { + let map_key = tip_id_to_map_key(tip_id)?; + let key_id = (owner, tip_secrets_map_name()); + + with_existing_tip_secrets_mut(|encrypted_maps| { + encrypted_maps + .remove_encrypted_value(owner, key_id, map_key) + .map_err(internal) + .map(|_| ()) + }) + .unwrap_or(Ok(())) +} + +/// Derives the caller's encrypted vetKey for the tip-secrets store, secured to +/// the browser-supplied transport public key. The browser decrypts it with the +/// transport secret key and derives the per-user symmetric key; only the caller +/// can obtain their own. +pub async fn get_encrypted_vetkey(transport_key: ByteBuf) -> Result { + let key_id = caller_key_id(); + let caller = key_id.0; + // The future is `'static` (it clones what it needs), so it is awaited after + // the state borrow is released. + let future = with_tip_secrets(|encrypted_maps| { + encrypted_maps + .get_encrypted_vetkey( + caller, + key_id, + VetkeysByteBuf::from(transport_key.into_vec()), + ) + .map_err(internal) + })?; + let vetkey = future.await; + Ok(ByteBuf::from(Vec::::from(vetkey))) +} + +thread_local! { + /// The verification key, once fetched. + /// + /// It is a property of this canister's key name, the domain separator and + /// the map name — none of which depend on the caller and none of which + /// change — so fetching it more than once per canister lifetime is pure + /// waste. Heap rather than stable memory on purpose: it is derivable, so + /// re-fetching once after an upgrade costs one call and needs no migration. + static VETKEY_PUBLIC_KEY: RefCell>> = const { RefCell::new(None) }; +} + +/// The vetKey verification (public) key for the tip-secrets store, which the +/// browser needs to verify the derived vetKey. The same for every user, so it is +/// fetched once and cached. +pub async fn get_vetkey_public_key() -> Result { + if let Some(cached) = VETKEY_PUBLIC_KEY.with_borrow(Clone::clone) { + return Ok(ByteBuf::from(cached)); + } + + let future = + with_tip_secrets(|encrypted_maps| Ok(encrypted_maps.get_vetkey_verification_key()))?; + let verification_key = Vec::::from(future.await); + + // Two callers racing the first fetch both write the same bytes, so last + // write wins is not a race worth guarding. + VETKEY_PUBLIC_KEY.set(Some(verification_key.clone())); + + Ok(ByteBuf::from(verification_key)) +} diff --git a/src/backend/src/tips/service.rs b/src/backend/src/tips/service.rs new file mode 100644 index 00000000000..5cecd151cdf --- /dev/null +++ b/src/backend/src/tips/service.rs @@ -0,0 +1,804 @@ +//! Storage and orchestration for tips. +//! +//! The invariant this file exists to protect: **the canister never holds a +//! user's tokens.** Every payout is an `icrc2_transfer_from` drawing on an +//! allowance the sender granted under this tip's own spender subaccount, and +//! every failure path leaves the money where it already was — in the sender's +//! account. + +use std::cmp::Reverse; + +use candid::Principal; +use ic_cdk::api::{canister_self, msg_caller, time}; +use shared::types::tip::{ + CreateTipRequest, MyTip, PublicTip, TipClaim, TipClaimFailure, TipClaimFailureReason, + TipClaimRequest, TipDetails, TipError, MAX_TIPS_RETURNED, TIP_RETENTION_AFTER_TERMINAL_NS, +}; + +use super::{ + icrc2::{self, Account, AllowanceArgs, TransferFromArgs, TransferFromCallError}, + model::{ + claim_code_matches, new_tip_exceeds_cap, spender_subaccount, validate_amount, + validate_claim_code_hash, validate_expiry, validate_message, validate_tip_id, TipRecord, + TipState, + }, + secrets, +}; +use crate::{ + state::{mutate_state, read_state, State}, + types::{ + maps::TipsBySenderMap, + storable::{Candid, StoredPrincipal, TipId, TipSenderKey}, + }, +}; + +/// The value stored in the by-sender index: the instant a tip stopped, or will +/// stop, occupying a cap slot. +/// +/// For a live tip that is its expiry. For a terminal one it is **the instant it +/// became terminal**, which is already in the past — so the slot is freed +/// immediately (`value > now` is false) while the row stays in the index for +/// History, and the retention arithmetic below gets a real age to measure. +/// One `u64` answers both questions, which is what keeps the cap check a single +/// range scan. +/// +/// This used to store `0` for a terminal tip. That freed the slot correctly and +/// silently broke retention: the test is `now - value > retention`, and with +/// `value = 0` that reads `now > 30 days`, which is true by a factor of ~700 +/// for every nanosecond timestamp there will ever be. Every claimed and +/// cancelled tip was therefore collectable the moment it became terminal, and +/// vanished from History on the next create or the next hourly sweep. +fn active_until(record: &TipRecord) -> u64 { + match record.state { + TipState::Reserved | TipState::Claiming { .. } => record.expires_at_ns, + TipState::Claimed { claimed_at_ns, .. } => claimed_at_ns, + // `created_at_ns` only for a cancellation written before + // `terminal_at_ns` existed: it starts the window early rather than + // never, and a tip lives at most 7 days, so such a row loses at most a + // week of its 30. + TipState::Cancelled => record.terminal_at_ns.unwrap_or(record.created_at_ns), + } +} + +/// Range-scans one sender's slice of the by-sender index in a single pass, +/// returning their active-tip count and the ids of entries that are past +/// retention. +/// +/// Scanning the index rather than the primary map keeps this cheap, and taking +/// the map directly (rather than re-entering `read_state` / `mutate_state`) lets +/// it run inside an existing state borrow without a `RefCell` double-borrow +/// panic. Mirrors `personal_notes::share::service::partition_creator_shares`. +fn partition_sender_tips( + by_sender: &TipsBySenderMap, + sender: Principal, + now_ns: u64, +) -> (usize, Vec) { + let prefix = StoredPrincipal(sender); + let start = TipSenderKey(prefix, TipId(String::new())); + let mut active = 0usize; + let mut collectable = Vec::new(); + for entry in by_sender + .range(start..) + .take_while(|entry| entry.key().0 == prefix) + { + let active_until_ns = entry.value(); + if active_until_ns > now_ns { + active += 1; + } else if now_ns.saturating_sub(active_until_ns) > TIP_RETENTION_AFTER_TERMINAL_NS { + collectable.push(entry.key().1.clone()); + } + } + (active, collectable) +} + +fn remove_tip(state: &mut State, tip_id: &TipId, sender: Principal) { + state.tips.remove(tip_id); + state + .tips_by_sender + .remove(&TipSenderKey(StoredPrincipal(sender), tip_id.clone())); +} + +/// Writes a record back to the primary map and keeps the by-sender index's +/// `active_until` in step with it. Every state transition goes through here, so +/// the index can never disagree with the record about whether a tip is live. +fn store_tip(state: &mut State, tip_id: &TipId, record: TipRecord) { + state.tips_by_sender.insert( + TipSenderKey(StoredPrincipal(record.sender), tip_id.clone()), + active_until(&record), + ); + state.tips.insert(tip_id.clone(), Candid(record)); +} + +fn read_tip(tip_id: &TipId) -> Option { + read_state(|s| s.tips.get(tip_id).map(|Candid(record)| record)) +} + +/// The account this canister spends from on a given tip's behalf: itself, at +/// the tip's own subaccount. +fn tip_spender(tip_id: &str) -> Account { + Account { + owner: canister_self(), + subaccount: Some(spender_subaccount(tip_id)), + } +} + +/// Creates a tip against an allowance the sender has already granted. +/// +/// Verifies the reservation really covers the payout **and** its fee before +/// recording anything, so a tip that exists is a tip that was claimable at +/// creation. It cannot promise it stays that way — the sender can spend or +/// revoke afterwards, which is what `Uncovered` is for. +/// +/// # Errors +/// Errors are enumerated by [`TipError`]. +pub async fn create_tip(request: CreateTipRequest) -> Result<(), TipError> { + validate_tip_id(&request.tip_id)?; + validate_claim_code_hash(&request.claim_code_hash)?; + validate_message(request.message.as_deref())?; + let now = time(); + validate_expiry(request.expires_at_ns, now)?; + + let sender = msg_caller(); + let tip_id = TipId(request.tip_id.clone()); + + // Fail fast before spending cycles on the ledger. Re-checked authoritatively + // in the write below, which is what actually makes it safe. + if read_state(|s| s.tips.contains_key(&tip_id)) { + return Err(TipError::DuplicateTipId); + } + + let ledger = request.ledger_canister_id; + let fee = icrc2::fee(ledger) + .await + .map_err(|msg| TipError::TransferFailed { msg })?; + validate_amount(&request.amount, &fee)?; + + let allowance = icrc2::allowance( + ledger, + AllowanceArgs { + account: Account { + owner: sender, + subaccount: None, + }, + spender: tip_spender(&request.tip_id), + }, + ) + .await + .map_err(|msg| TipError::TransferFailed { msg })?; + + // The ledger draws its fee from the allowance rather than from the amount, + // so the claimer receives `amount` in full and the reservation has to cover + // both. Proven against a real ledger in the spec's PR-0 spike. + if allowance.allowance < request.amount.clone() + fee { + return Err(TipError::Uncovered); + } + + // A reservation that lapses before the tip does would leave a tip that + // *looks* live and cannot pay out. Reject it rather than shipping a + // deadline we cannot honour. + if allowance + .expires_at + .is_some_and(|expires_at| expires_at < request.expires_at_ns) + { + return Err(TipError::InvalidExpiry); + } + + mutate_state(|s| { + // Re-read the clock. `now` above was taken before two awaited ledger + // calls, and an expiry that was in the future when the request arrived + // can be in the past by the time it is written — which would store a tip + // that is already unclaimable and hand the sender a dead link. The same + // stale reading also drove the retention sweep below. + let now = time(); + validate_expiry(request.expires_at_ns, now)?; + + // Collect the caller's own past-retention rows first, so History + // pruning is driven by the sender who is actually using the feature + // rather than waiting on the hourly sweep. Scoped to one sender, so it + // stays cheap. + let (active_count, collectable) = partition_sender_tips(&s.tips_by_sender, sender, now); + for stale in collectable { + remove_tip(s, &stale, sender); + } + if s.tips.contains_key(&tip_id) { + return Err(TipError::DuplicateTipId); + } + if new_tip_exceeds_cap(active_count) { + return Err(TipError::TooManyTips); + } + + store_tip( + s, + &tip_id, + TipRecord { + sender, + ledger_canister_id: ledger, + amount: request.amount, + expires_at_ns: request.expires_at_ns, + created_at_ns: now, + message: request.message, + claim_code_hash: request.claim_code_hash, + state: TipState::Reserved, + terminal_at_ns: None, + last_claim_failure: None, + }, + ); + Ok(()) + }) +} + +/// What an anonymous reader of a tip link sees: amount, token, deadline. +/// +/// Unknown, expired, claimed and cancelled all return `NotFound`, so a prober +/// with a random id learns nothing. Deliberately callable without an identity — +/// the whole point is that the recipient has no OISY account yet. Mirrors +/// `get_personal_note_share`. +/// +/// # Errors +/// [`TipError::NotFound`] for anything not currently claimable. +pub fn get_tip(tip_id: String) -> Result { + validate_tip_id(&tip_id)?; + let now = time(); + read_tip(&TipId(tip_id)) + .filter(|record| record.is_claimable(now) || record.has_claim_in_flight(now)) + .map(|record| record.to_public()) + .ok_or(TipError::NotFound) +} + +/// The claim review: the public preview plus the sender's message. Requires the +/// claim code, so only someone holding the full link sees the message — the +/// anonymous preview never carries it. +/// +/// # Errors +/// [`TipError::NotFound`] for an unclaimable tip or a wrong claim code. +pub fn get_tip_details(request: TipClaimRequest) -> Result { + validate_tip_id(&request.tip_id)?; + let now = time(); + let record = read_tip(&TipId(request.tip_id)).ok_or(TipError::NotFound)?; + if !claim_code_matches(&record.claim_code_hash, request.claim_code) { + return Err(TipError::NotFound); + } + if !record.is_claimable(now) && !record.has_claim_in_flight(now) { + return Err(TipError::NotFound); + } + Ok(record.to_details()) +} + +/// Pays a tip out to the caller. +/// +/// The record flips to `Claiming` **before** the ledger call, so a second caller +/// arriving mid-flight sees `ClaimInProgress` rather than starting a second +/// payout. Every failure reverts to `Reserved` — safe even for an ambiguous +/// transport error, because the allowance is the source of truth: if the +/// transfer did happen, the next claim finds it consumed and fails `Uncovered`. +/// +/// **Once, but not by this state machine alone.** The `Claiming` guard lapses +/// after [`TIP_CLAIM_IN_FLIGHT_TIMEOUT_NS`], because a claim whose reply never +/// comes back must not strand the tip forever. A first `transfer_from` that is +/// still genuinely in flight past that point — a congested subnet, not only the +/// upgrade case — can therefore overlap a second attempt. What stops both from +/// paying is the allowance covering exactly one payout, which the client sizes +/// and `create_tip` only checks a lower bound on. So "exactly once" is a +/// property of the allowance, and this code's job is not to weaken it. +/// +/// # Errors +/// Errors are enumerated by [`TipError`]. +pub async fn claim_tip(request: TipClaimRequest) -> Result { + let TipClaimRequest { tip_id, claim_code } = request; + validate_tip_id(&tip_id)?; + let claimer = msg_caller(); + let now = time(); + let key = TipId(tip_id.clone()); + + // Reserve the claim, then release the state borrow before awaiting. + let record = mutate_state(|s| { + let Some(Candid(record)) = s.tips.get(&key) else { + return Err(TipError::NotFound); + }; + if record.has_claim_in_flight(now) { + return Err(TipError::ClaimInProgress); + } + // Checked before the code so a wrong code and an unclaimable tip are + // indistinguishable to a prober — both are `NotFound`. + if !record.is_claimable(now) { + return Err(TipError::NotFound); + } + if !claim_code_matches(&record.claim_code_hash, claim_code) { + return Err(TipError::NotFound); + } + + let claiming = TipRecord { + state: TipState::Claiming { + claimer, + started_at_ns: now, + }, + ..record + }; + store_tip(s, &key, claiming.clone()); + Ok(claiming) + })?; + + let transfer = icrc2::transfer_from( + record.ledger_canister_id, + TransferFromArgs { + // The subaccount of *this canister* the allowance was granted to. + // Omitting it would address the bare-principal allowance, which for + // tips is always empty — every tip's allowance sits under its own + // subaccount, and that is what keeps one tip's reservation unusable + // for another. + spender_subaccount: Some(spender_subaccount(&tip_id)), + from: Account { + owner: record.sender, + subaccount: None, + }, + to: Account { + owner: claimer, + subaccount: None, + }, + amount: record.amount.clone(), + fee: None, + memo: None, + created_at_time: None, + }, + ) + .await; + + match transfer { + Ok(block_index) => { + let claimed_at_ns = time(); + mutate_state(|s| { + // Only if this claim still owns the slot, the same test + // `release_claim` applies to a failure. Without it a late success + // wrote `Claimed` over whatever it found — including a + // `Claimed{someone_else}` that had already paid out, which would + // erase the record of who actually received the money. + if let Some(Candid(current)) = s + .tips + .get(&key) + .filter(|Candid(current)| claim_is_ours(current, claimer, now)) + { + store_tip( + s, + &key, + TipRecord { + state: TipState::Claimed { + claimer, + block_index: block_index.clone(), + claimed_at_ns, + }, + ..current + }, + ); + } + }); + // The payout landed, so the link is spent and the sender's + // recoverable copy of the claim code has nothing left to recover. + // Dropped here rather than left to the retention sweep because it is + // dead the instant the transfer succeeds. Best-effort: a tip created + // before this store existed has no secret, which is not an error, and + // failing to drop opaque bytes must never fail a claim that already + // moved money. + let _ = secrets::remove_tip_secret_for(record.sender, key.0); + + Ok(TipClaim { + ledger_canister_id: record.ledger_canister_id, + amount: record.amount, + block_index, + }) + } + Err(err) => { + let reason = match &err { + TransferFromCallError::InsufficientAllowance => TipClaimFailureReason::Uncovered, + TransferFromCallError::InsufficientFunds => { + TipClaimFailureReason::InsufficientFunds + } + TransferFromCallError::Failed(_) => TipClaimFailureReason::TransferFailed, + }; + release_claim(&key, claimer, now, reason); + match err { + TransferFromCallError::InsufficientAllowance => Err(TipError::Uncovered), + TransferFromCallError::InsufficientFunds => Err(TipError::InsufficientFunds), + TransferFromCallError::Failed(msg) => Err(TipError::TransferFailed { msg }), + } + } + } +} + +/// Whether the record is still the `Claiming` slot this call created. +/// +/// Both ends of a claim need this and for the same reason. A claim that timed +/// out and was taken over must not have its late answer — success or failure — +/// overwrite whoever holds the tip now. The claimer alone is not enough to +/// identify it: the same principal can retry after a timeout, so the start +/// instant is what distinguishes this attempt from that one. +fn claim_is_ours(record: &TipRecord, claimer: Principal, started_at_ns: u64) -> bool { + matches!( + record.state, + TipState::Claiming { + claimer: in_flight_claimer, + started_at_ns: in_flight_started, + } if in_flight_claimer == claimer && in_flight_started == started_at_ns + ) +} + +/// Returns a failed claim's tip to `Reserved` and records why it failed, but +/// only if this claim still owns it. A claim that timed out and was taken over by +/// someone else must not have its late failure clobber the new claimer's state — +/// nor mark the tip failed when somebody else is mid-payout. +/// +/// The record is what lets History separate "nobody has tried this yet" from +/// "somebody tried and it did not pay out", which is the only one of the two the +/// sender can act on. +fn release_claim( + key: &TipId, + claimer: Principal, + started_at_ns: u64, + reason: TipClaimFailureReason, +) { + mutate_state(|s| { + let Some(Candid(current)) = s.tips.get(key) else { + return; + }; + if claim_is_ours(¤t, claimer, started_at_ns) { + store_tip( + s, + key, + TipRecord { + state: TipState::Reserved, + last_claim_failure: Some(TipClaimFailure { + at_ns: time(), + reason, + }), + ..current + }, + ); + } + }); +} + +/// Cancels an unclaimed tip. The allowance is the sender's to revoke — this +/// only stops the tip being claimable; the client pairs it with an +/// `icrc2_approve` of zero. +/// +/// # Errors +/// [`TipError::NotFound`] if no such tip, [`TipError::NotYourTip`] if it belongs +/// to someone else, [`TipError::NotCancellable`] if it is not `Reserved`. +pub fn cancel_tip(tip_id: String) -> Result<(), TipError> { + validate_tip_id(&tip_id)?; + let caller = msg_caller(); + let now = time(); + let key = TipId(tip_id); + + mutate_state(|s| { + let Some(Candid(record)) = s.tips.get(&key) else { + return Err(TipError::NotFound); + }; + if record.sender != caller { + return Err(TipError::NotYourTip); + } + if record.has_claim_in_flight(now) { + return Err(TipError::ClaimInProgress); + } + // `Reserved`, and not past its deadline — which is what the doc above + // always said and the code did not do. + // + // A timed-out `Claiming` used to qualify. The timeout only means no + // *reply* has come back; the ledger call may still be outstanding and + // may still pay, and the success branch of `claim_tip` writes `Claimed` + // over whatever it finds. So cancelling one returned Ok on a promise the + // canister could not keep. The sender's allowance is theirs to revoke + // either way, which is the lever that actually stops a payout. + // + // An expired tip is refused for a quieter reason: it lapsed, and + // rewriting that history row as `Cancelled` claims the sender did + // something they did not. + if !matches!(record.state, TipState::Reserved) || record.is_expired(now) { + return Err(TipError::NotCancellable); + } + + store_tip( + s, + &key, + TipRecord { + state: TipState::Cancelled, + terminal_at_ns: Some(now), + ..record + }, + ); + Ok(()) + })?; + + // The link is worthless once cancelled, so the recoverable copy of its claim + // code goes with it. Best-effort: a tip with no stored secret (created before + // the store existed) is not an error, and failing to drop opaque bytes the + // canister cannot read must not fail a cancellation that already succeeded. + let _ = secrets::remove_tip_secret_for(caller, key.0); + + Ok(()) +} + +/// The caller's own tips, newest first, capped at [`MAX_TIPS_RETURNED`]. +/// +/// # Errors +/// Errors are enumerated by [`TipError`]. +pub fn get_my_tips() -> Result, TipError> { + let caller = msg_caller(); + let now = time(); + let prefix = StoredPrincipal(caller); + + read_state(|s| { + let mut tips: Vec = s + .tips_by_sender + .range(TipSenderKey(prefix, TipId(String::new()))..) + .take_while(|entry| entry.key().0 == prefix) + .filter_map(|entry| { + let tip_id = entry.key().1.clone(); + s.tips + .get(&tip_id) + .map(|Candid(record)| record.to_my_tip(tip_id.0, now)) + }) + .collect(); + tips.sort_by_key(|tip| Reverse(tip.created_at_ns)); + tips.truncate(MAX_TIPS_RETURNED); + Ok(tips) + }) +} + +/// Removes tips whose retention window has passed and returns the number +/// removed. Intended for periodic housekeeping. +/// +/// Note what this does **not** do: it never touches a live tip, and it does not +/// remove a tip the moment it expires. A lapsed tip is a History row the sender +/// is entitled to see; it goes once +/// [`TIP_RETENTION_AFTER_TERMINAL_NS`] has passed. A full scan is fine for an +/// hourly sweep. Mirrors `personal_notes::share::service::prune_expired_shares`. +pub fn prune_expired_tips() -> u64 { + let now = time(); + + let collected: Vec<(TipId, Principal)> = mutate_state(|s| { + let collectable: Vec<(TipId, Principal)> = s + .tips + .iter() + .filter(|entry| { + let active_until_ns = active_until(&entry.value().0); + active_until_ns <= now + && now.saturating_sub(active_until_ns) > TIP_RETENTION_AFTER_TERMINAL_NS + }) + .map(|entry| (entry.key().clone(), entry.value().0.sender)) + .collect(); + + for (tip_id, sender) in &collectable { + remove_tip(s, tip_id, *sender); + } + collectable + }); + + // Outside the borrow above, deliberately: dropping a secret goes through + // `mutate_state` itself, and calling it from inside this closure would panic + // on the `RefCell` rather than fail politely. Without this the store grew + // forever — a tip's record was swept after its retention window while its + // ciphertext stayed behind with nothing left to point at it. + for (tip_id, sender) in &collected { + let _ = secrets::remove_tip_secret_for(*sender, tip_id.0.clone()); + } + + collected.len() as u64 +} + +#[cfg(test)] +mod tests { + use candid::Nat; + use ic_stable_structures::{ + memory_manager::{MemoryId, MemoryManager}, + DefaultMemoryImpl, + }; + use pretty_assertions::assert_eq; + use serde_bytes::ByteBuf; + + use super::*; + + fn test_principal(id: u8) -> Principal { + Principal::from_slice(&[id]) + } + + fn in_memory_by_sender_map() -> TipsBySenderMap { + let mm = MemoryManager::init(DefaultMemoryImpl::default()); + TipsBySenderMap::init(mm.get(MemoryId::new(0))) + } + + fn record_with(state: TipState, expires_at_ns: u64) -> TipRecord { + TipRecord { + sender: test_principal(1), + ledger_canister_id: test_principal(9), + amount: Nat::from(1_000u64), + expires_at_ns, + created_at_ns: 0, + message: None, + claim_code_hash: ByteBuf::from(vec![0u8; 32]), + state, + terminal_at_ns: None, + last_claim_failure: None, + } + } + + fn claimed_at(claimed_at_ns: u64) -> TipState { + TipState::Claimed { + claimer: test_principal(2), + block_index: Nat::from(1u64), + claimed_at_ns, + } + } + + #[test] + fn active_until_is_the_expiry_while_live_and_the_terminal_instant_after() { + let now = 10 * TIP_RETENTION_AFTER_TERMINAL_NS; + let expiry = now + 1; + + assert_eq!( + active_until(&record_with(TipState::Reserved, expiry)), + expiry + ); + assert_eq!( + active_until(&record_with( + TipState::Claiming { + claimer: test_principal(2), + started_at_ns: 1, + }, + expiry + )), + expiry + ); + + let claimed = record_with(claimed_at(now), expiry); + assert_eq!( + active_until(&claimed), + now, + "a claimed tip reports when it was claimed, not a sentinel" + ); + assert!( + active_until(&claimed) <= now, + "and that is already in the past, so the cap slot is free" + ); + + let cancelled = TipRecord { + terminal_at_ns: Some(now), + ..record_with(TipState::Cancelled, expiry) + }; + assert_eq!(active_until(&cancelled), now); + } + + #[test] + fn a_cancellation_written_before_terminal_at_ns_falls_back_to_creation() { + let created_at_ns = 7_000u64; + let legacy = TipRecord { + created_at_ns, + terminal_at_ns: None, + ..record_with(TipState::Cancelled, 9_000) + }; + + assert_eq!( + active_until(&legacy), + created_at_ns, + "an early retention start, not an immediate one" + ); + } + + /// The regression. The old code stored `0` for a terminal tip, and `0` is + /// what [`partition_sender_tips`] measures retention against — so + /// `now - 0 > retention` held for every claimed tip and History lost it on + /// the next create or the next hourly sweep. + /// + /// This test feeds a terminal record's `active_until` *through* the + /// partition, which is precisely what the two original tests did not do: + /// one asserted the sentinel, the other fed the partition only realistic + /// timestamps, and the bug lived in the seam between them. + #[test] + fn a_freshly_claimed_tip_survives_pruning() { + let mut map = in_memory_by_sender_map(); + let alice = test_principal(1); + let now = 10 * TIP_RETENTION_AFTER_TERMINAL_NS; + + let just_claimed = record_with(claimed_at(now), now + 1_000); + map.insert( + TipSenderKey(StoredPrincipal(alice), TipId("claimed-now".into())), + active_until(&just_claimed), + ); + + let (active, collectable) = partition_sender_tips(&map, alice, now); + + assert_eq!(active, 0, "a claimed tip must not hold a cap slot"); + assert!( + collectable.is_empty(), + "but it must stay in History: it was claimed this instant, not 30 days ago" + ); + } + + #[test] + fn a_claimed_tip_is_collected_once_past_retention() { + let mut map = in_memory_by_sender_map(); + let alice = test_principal(1); + let now = 10 * TIP_RETENTION_AFTER_TERMINAL_NS; + + let long_claimed = record_with( + claimed_at(now - TIP_RETENTION_AFTER_TERMINAL_NS - 1), + now - 1_000, + ); + map.insert( + TipSenderKey(StoredPrincipal(alice), TipId("claimed-long-ago".into())), + active_until(&long_claimed), + ); + + let (_, collectable) = partition_sender_tips(&map, alice, now); + + assert_eq!( + collectable + .iter() + .map(|id| id.0.clone()) + .collect::>(), + vec!["claimed-long-ago".to_string()], + "retention still expires — the fix keeps rows, it does not keep them forever" + ); + } + + #[test] + fn partition_counts_only_this_senders_live_tips() { + let mut map = in_memory_by_sender_map(); + let alice = test_principal(1); + let bob = test_principal(2); + let now = 10 * TIP_RETENTION_AFTER_TERMINAL_NS; + + map.insert( + TipSenderKey(StoredPrincipal(alice), TipId("a-live".into())), + now + 1, + ); + map.insert( + TipSenderKey(StoredPrincipal(alice), TipId("a-just-lapsed".into())), + now - 1, + ); + map.insert( + TipSenderKey(StoredPrincipal(bob), TipId("b-live".into())), + now + 1, + ); + + let (active, collectable) = partition_sender_tips(&map, alice, now); + assert_eq!(active, 1); + assert!( + collectable.is_empty(), + "a tip that lapsed a moment ago is still within retention" + ); + assert_eq!(partition_sender_tips(&map, bob, now).0, 1); + assert_eq!(partition_sender_tips(&map, test_principal(3), now).0, 0); + } + + #[test] + fn partition_collects_only_past_retention_and_only_for_this_sender() { + let mut map = in_memory_by_sender_map(); + let alice = test_principal(1); + let bob = test_principal(2); + let now = 10 * TIP_RETENTION_AFTER_TERMINAL_NS; + + map.insert( + TipSenderKey(StoredPrincipal(alice), TipId("a-old".into())), + now - TIP_RETENTION_AFTER_TERMINAL_NS - 1, + ); + map.insert( + TipSenderKey(StoredPrincipal(alice), TipId("a-edge".into())), + now - TIP_RETENTION_AFTER_TERMINAL_NS, + ); + map.insert( + TipSenderKey(StoredPrincipal(bob), TipId("b-old".into())), + now - TIP_RETENTION_AFTER_TERMINAL_NS - 1, + ); + + let (active, collectable) = partition_sender_tips(&map, alice, now); + assert_eq!(active, 0); + assert_eq!( + collectable + .iter() + .map(|id| id.0.clone()) + .collect::>(), + vec!["a-old".to_string()], + "retention boundary is exclusive, and another sender's rows never leak in" + ); + } +} diff --git a/src/backend/src/types/maps.rs b/src/backend/src/types/maps.rs index 39caec32a4a..cb45ef5fdbf 100644 --- a/src/backend/src/types/maps.rs +++ b/src/backend/src/types/maps.rs @@ -20,9 +20,11 @@ use shared::types::{ use crate::{ personal_notes::share::model::PersonalNoteShareRecord, + tips::model::TipRecord, types::storable::{ ActiveUserTransactionKey, Candid, ContactImageKey, PersonalNoteShareCreatorKey, - PersonalNoteShareToken, StoredPrincipal, StoredTokenId, UserTransactionKey, + PersonalNoteShareToken, StoredPrincipal, StoredTokenId, TipId, TipSenderKey, + UserTransactionKey, }, }; @@ -84,3 +86,12 @@ pub type PersonalNoteShareMap = /// [`PersonalNoteShareMap`]. pub type PersonalNoteSharesByCreatorMap = StableBTreeMap; + +/// Primary tip store: tip id → record. Readable anonymously through +/// `get_tip`, which returns only the amount, token and deadline — see +/// `tips::service`. +pub type TipMap = StableBTreeMap, VMem>; + +/// By-sender index for the active-tip cap and History: `(sender, tip_id) → expires_at_ns`. +/// Lets both range-scan one sender's tips without touching [`TipMap`]. +pub type TipsBySenderMap = StableBTreeMap; diff --git a/src/backend/src/types/storable.rs b/src/backend/src/types/storable.rs index 13cfb00cff8..031013404d0 100644 --- a/src/backend/src/types/storable.rs +++ b/src/backend/src/types/storable.rs @@ -2,7 +2,10 @@ use std::{borrow::Cow, ops::Deref}; use candid::{decode_one, encode_one, CandidType, Deserialize, Principal}; use ic_stable_structures::storable::{Blob, Bound, Storable}; -use shared::types::{personal_note_share::MAX_PERSONAL_NOTE_SHARE_TOKEN_BYTES, token_id::TokenId}; +use shared::types::{ + personal_note_share::MAX_PERSONAL_NOTE_SHARE_TOKEN_BYTES, tip::MAX_TIP_ID_BYTES, + token_id::TokenId, +}; #[derive(Default)] pub struct Candid(pub T) @@ -338,3 +341,67 @@ mod contact_image_key_tests { } } } + +/// Primary key of the tip store: the opaque, client-generated tip id that also +/// appears as `` in the share link. Bounded (not fixed-size) — the client +/// generates a 128-bit id, but the bound only enforces generous headroom. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct TipId(pub String); + +impl Storable for TipId { + const BOUND: Bound = Bound::Bounded { + max_size: MAX_TIP_ID_BYTES, + is_fixed_size: false, + }; + + fn to_bytes(&self) -> Cow<'_, [u8]> { + Cow::Borrowed(self.0.as_bytes()) + } + + fn into_bytes(self) -> Vec { + self.0.into_bytes() + } + + fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { + Self(String::from_utf8(bytes.into_owned()).expect("tip id should be valid UTF-8")) + } +} + +/// Key of the by-sender index over the tip store, laid out so that one +/// sender's tips form a contiguous range: `[u32 BE principal_len][principal_bytes][tip_id_bytes]`. +/// Same encoding as [`PersonalNoteShareCreatorKey`], for the same reason — a +/// length-prefixed principal keeps the range scan exact even though principals +/// vary in length. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct TipSenderKey(pub StoredPrincipal, pub TipId); + +impl Storable for TipSenderKey { + const BOUND: Bound = Bound::Unbounded; + + fn to_bytes(&self) -> Cow<'_, [u8]> { + let principal_bytes = self.0.to_bytes(); + let tip_id_bytes = self.1.to_bytes(); + let principal_len = + u32::try_from(principal_bytes.len()).expect("principal length should fit in u32"); + let mut buf = Vec::with_capacity(4 + principal_bytes.len() + tip_id_bytes.len()); + buf.extend_from_slice(&principal_len.to_be_bytes()); + buf.extend_from_slice(&principal_bytes); + buf.extend_from_slice(&tip_id_bytes); + Cow::Owned(buf) + } + + fn into_bytes(self) -> Vec { + self.to_bytes().to_vec() + } + + fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { + let principal_len = u32::from_be_bytes( + bytes[..4] + .try_into() + .expect("failed to decode principal length"), + ) as usize; + let principal = StoredPrincipal::from_bytes(Cow::Borrowed(&bytes[4..4 + principal_len])); + let tip_id = TipId::from_bytes(Cow::Borrowed(&bytes[4 + principal_len..])); + Self(principal, tip_id) + } +} diff --git a/src/backend/src/utils/housekeeping.rs b/src/backend/src/utils/housekeeping.rs index b015a821bd9..2156ea32940 100644 --- a/src/backend/src/utils/housekeeping.rs +++ b/src/backend/src/utils/housekeeping.rs @@ -7,6 +7,7 @@ use shared::types::signer::topup::TopUpCyclesLedgerResult; use crate::{ api, personal_notes::share::service::prune_expired_shares, + tips::service::prune_expired_tips, token::{evict_inactive_tokens, TOKEN_ACTIVITY_RETENTION_SEC}, }; @@ -96,6 +97,13 @@ async fn hourly_housekeeping_tasks() { if pruned > 0 { ic_cdk::println!("Pruned {pruned} expired personal_note_shares entries"); } + + // Only tips past their retention window; a lapsed tip stays visible in the + // sender's History until then. + let pruned_tips = prune_expired_tips(); + if pruned_tips > 0 { + ic_cdk::println!("Pruned {pruned_tips} tips past their retention window"); + } } #[cfg(test)] diff --git a/src/backend/src/utils/rate_limiter.rs b/src/backend/src/utils/rate_limiter.rs index 5b510f6e6ea..f8ea4d19b2e 100644 --- a/src/backend/src/utils/rate_limiter.rs +++ b/src/backend/src/utils/rate_limiter.rs @@ -73,6 +73,49 @@ thread_local! { pub(crate) static CONSUME_PERSONAL_NOTE_SHARE_ANONYMOUS_RATE_LIMITER: RateLimiter = RateLimiter::new(600, 60 * 1_000_000_000); + /// Rate-limits `create_tip`. The sender is an authenticated principal, so the + /// per-caller tiers (20/min, 200/hour) carry most of the weight; the global + /// tiers (300/min, 3000/hour) are what a per-caller limit cannot see, namely + /// a flood spread across many principals. + /// + /// The global numbers are generous on purpose. Each call makes two ledger + /// *queries* — a few million cycles, three orders of magnitude below a vetKD + /// derivation — so the ceiling is here to stop a runaway, not to ration + /// ordinary use. Any value is stricter than what this had before, which was + /// no global ceiling at all. + pub(crate) static CREATE_TIP_RATE_LIMITER: TieredRateLimiter = + TieredRateLimiter::with_tiers(20, 200, 300, 3000); + + /// Rate-limits `claim_tip`. Per-caller (20/min, 200/hour) is what makes + /// brute-forcing a claim code expensive — a wrong code is rejected from state + /// alone, before any ledger call — and unlike `consume_personal_note_share` a + /// claim always has a real principal to charge it to, since the payout needs + /// a destination. + /// + /// The global tiers (300/min, 3000/hour) bound what that cannot: a guessing + /// campaign spread over many fresh identities, which are free to create. + pub(crate) static CLAIM_TIP_RATE_LIMITER: TieredRateLimiter = + TieredRateLimiter::with_tiers(20, 200, 300, 3000); + + /// Rate-limits `cancel_tip`. Cheap state-only work, so the tiers exist to + /// keep a loop from writing to stable memory without limit rather than to + /// bound cycles. + pub(crate) static CANCEL_TIP_RATE_LIMITER: TieredRateLimiter = + TieredRateLimiter::with_tiers(30, 300, 400, 4000); + + /// Rate-limits `set_tip_secret`. Not about cycles: the endpoint writes a + /// 512-byte entry to stable memory, and nothing else bounds how many a caller + /// may write. `MAX_TIPS_PER_USER` counts *active* tips, not stored codes, and + /// the endpoint is deliberately not gated on the tip existing — so before this + /// limiter a single registered caller could grow the store at ingress speed + /// without creating a single tip. + /// + /// Same tiers as `CREATE_TIP_RATE_LIMITER` because the browser calls this + /// exactly once per created tip: anything a legitimate sender can do here is + /// already bounded by what they can create. + pub(crate) static SET_TIP_SECRET_RATE_LIMITER: TieredRateLimiter = + TieredRateLimiter::with_tiers(20, 200, 300, 3000); + /// Rate-limits `get_personal_notes_encrypted_vetkey` — the paid vetKD /// derivation. Per-caller (2/min, 10/hour) is checked before a shared /// global (20/min, 100/hour). See [`TieredRateLimiter`]. @@ -84,6 +127,29 @@ thread_local! { /// [`TieredRateLimiter`]. pub(crate) static GET_PERSONAL_NOTES_VETKEY_PUBLIC_KEY_RATE_LIMITER: TieredRateLimiter = TieredRateLimiter::new(); + + /// vetKD derivation for the tip-secrets store. Its own limiter rather than a + /// shared one, so a sender recovering tip links cannot exhaust the budget for + /// reading their notes, or the reverse. + /// + /// A raised burst cap (5/min instead of 2), because unlike notes this + /// derivation sits on the *create* path: a sender spends one per page load, + /// and at 2/min a third reload inside a minute failed — which silently cost + /// that tip its recoverable link. The hourly tiers are untouched, so + /// worst-case cycle spend is unchanged. + pub(crate) static GET_TIP_ENCRYPTED_VETKEY_RATE_LIMITER: TieredRateLimiter = + TieredRateLimiter::with_caller_burst(5); + + /// Verification-key reads for the tip-secrets store. + /// + /// Deliberately an ordinary limiter, not [`TieredRateLimiter`]: this + /// endpoint returns a caller-independent constant that the canister now + /// caches after the first call, so it costs no vetKD derivation and metering + /// it like one was actively harmful. The browser fetches it alongside the + /// derivation, so a rejection here used to discard a derivation that had + /// already been paid for. + pub(crate) static GET_TIP_VETKEY_PUBLIC_KEY_RATE_LIMITER: RateLimiter = + RateLimiter::new(30, 60 * 1_000_000_000); } /// Per-caller sliding-window rate limiter for IC canister methods. @@ -330,6 +396,23 @@ impl TieredRateLimiter { } } + /// Same tiers as [`Self::new`] but with a chosen per-caller **burst** cap. + /// + /// Only the per-minute caller tier moves. That tier is a burst damper, not a + /// cost control — the per-hour caller tier is, and it still binds at 10 — so + /// raising this cannot increase worst-case hourly cycle spend for a caller. + /// It exists because 2/min throttles ordinary use: one derivation is spent + /// per page load, so two reloads inside a minute made the third fail. + #[must_use] + pub fn with_caller_burst(caller_minute: u32) -> Self { + Self { + caller_minute: RateLimiter::new(caller_minute, Self::MINUTE_NS), + caller_hour: RateLimiter::new(10, Self::HOUR_NS), + global_minute: RateLimiter::new(20, Self::MINUTE_NS), + global_hour: RateLimiter::new(100, Self::HOUR_NS), + } + } + /// Checks every tier for the current IC caller at the current IC time. pub fn check_caller(&self) -> Result<(), RateLimitError> { self.check_at(msg_caller(), ic_cdk::api::time()) @@ -696,6 +779,42 @@ mod tests { ); } + #[test] + fn a_raised_burst_lifts_the_minute_tier_and_leaves_the_hour_tier_binding() { + let burst = TieredRateLimiter::with_caller_burst(5); + let caller = test_principal(1); + + // Five inside one minute, where the default would have stopped at two. + for call in 1..=5 { + assert!( + burst.check_at(caller, ONE_SEC).is_ok(), + "call {call} of the raised burst" + ); + } + assert!( + burst.check_at(caller, ONE_SEC).is_err(), + "six is still too many" + ); + + // The point of the change: worst-case hourly spend is unmoved, because + // the hour tier binds at 10 regardless of how the bursts are shaped. + // Five more across later minutes reach that cap, and the eleventh fails. + for call in 6..=10u64 { + let minute_later = ONE_SEC + call * 60 * ONE_SEC; + assert!( + burst.check_at(caller, minute_later).is_ok(), + "call {call} within the hour" + ); + } + let err = burst + .check_at(caller, ONE_SEC + 11 * 60 * ONE_SEC) + .unwrap_err(); + assert_eq!( + err.max_calls, 10, + "the hour tier is what refused, so the cost ceiling is unchanged" + ); + } + #[test] fn the_default_tiers_survive_the_move_into_with_tiers() { // All four, because `with_tiers` takes four positional numbers and diff --git a/src/backend/tests/it/main.rs b/src/backend/tests/it/main.rs index c3937c350dd..1d819b54c05 100644 --- a/src/backend/tests/it/main.rs +++ b/src/backend/tests/it/main.rs @@ -12,6 +12,7 @@ mod settings; mod signer; mod stats; mod status; +mod tips; mod transactions; mod user_profile; mod utils; diff --git a/src/backend/tests/it/stats.rs b/src/backend/tests/it/stats.rs index 60f9fe71be1..0e584f72005 100644 --- a/src/backend/tests/it/stats.rs +++ b/src/backend/tests/it/stats.rs @@ -67,6 +67,7 @@ fn stats_returns_correct_number_of_users() { active_user_transactions_count: 0, personal_notes_count: 0, personal_note_shares_count: 0, + tips_count: 0, }; let caller = controller(); diff --git a/src/backend/tests/it/tips.rs b/src/backend/tests/it/tips.rs new file mode 100644 index 00000000000..4d52f85cdb3 --- /dev/null +++ b/src/backend/tests/it/tips.rs @@ -0,0 +1,875 @@ +//! End-to-end tests for tips, against a **real** ICRC-1/2 ledger. +//! +//! A mock would not be worth writing here: every guarantee this feature makes is +//! a guarantee about what a real ledger does with an allowance — that a spender +//! subaccount scopes it, that the fee comes out of it rather than out of the +//! payout, and that a revoked allowance turns a claim into a refusal instead of +//! a loss. These tests spend as few `setup()` calls as possible (each spins up a +//! full pocket-ic instance) and group assertions per flow, the same way +//! `personal_note_shares.rs` does. + +use std::time::Duration; + +use candid::{encode_one, Nat, Principal}; +use pretty_assertions::assert_eq; +use serde_bytes::ByteBuf; +use sha2::{Digest, Sha256}; +use shared::types::{ + result_types::{CancelTipResult, GetTipSecretResult, SetTipSecretResult}, + tip::{ + CreateTipRequest, MyTip, PublicTip, SetTipSecretRequest, TipClaim, TipClaimRequest, + TipDetails, TipError, TipStatus, + }, +}; + +use crate::utils::{ + icrc1_ledger::{self, TRANSFER_FEE}, + pocketic::{controller, setup, PicBackend, PicCanisterTrait}, +}; + +// ------------------------------------------------------------------------------------------------- +// - Helpers +// ------------------------------------------------------------------------------------------------- + +const ONE_HOUR_NS: u64 = 60 * 60 * 1_000_000_000; +const TIP_AMOUNT: u64 = 500_000; +const SENDER_BALANCE: u64 = 100_000_000; + +fn now_ns(pic_setup: &PicBackend) -> u64 { + pic_setup.pic.get_time().as_nanos_since_unix_epoch() +} + +/// The tip's spender subaccount, derived here **independently** of the canister. +/// If the backend ever changed how it derives this, the allowance the test +/// grants would stop matching the one the backend spends from, and every claim +/// would fail — which is the point of computing it separately. +fn spender_subaccount(tip_id: &str) -> [u8; 32] { + Sha256::digest(tip_id.as_bytes()).into() +} + +fn claim_code_hash(claim_code: &str) -> ByteBuf { + ByteBuf::from(Sha256::digest(claim_code.as_bytes()).to_vec()) +} + +struct TipEnv { + pic_setup: PicBackend, + ledger: Principal, + sender: Principal, +} + +fn setup_tips() -> TipEnv { + let pic_setup = setup(); + let sender = Principal::self_authenticating("tip-sender"); + let ledger = icrc1_ledger::deploy(&pic_setup.pic, controller(), &[(sender, SENDER_BALANCE)]); + pic_setup.ensure_user_profile(sender); + TipEnv { + pic_setup, + ledger, + sender, + } +} + +impl TipEnv { + /// Grants the backend an allowance for this tip, sized the way the client + /// will size it: the payout **plus** one ledger fee, since the ledger draws + /// its fee from the allowance rather than from the amount. + fn approve(&self, tip_id: &str, amount: u64, expires_at_ns: Option) { + icrc1_ledger::approve( + &self.pic_setup.pic, + self.ledger, + self.sender, + icrc1_ledger::account_with_subaccount( + self.pic_setup.canister_id(), + spender_subaccount(tip_id), + ), + amount, + expires_at_ns, + ) + .expect("approve should succeed"); + } + + fn create( + &self, + tip_id: &str, + claim_code: &str, + amount: u64, + expires_at_ns: u64, + ) -> Result<(), TipError> { + self.pic_setup + .update::>( + self.sender, + "create_tip", + CreateTipRequest { + tip_id: tip_id.to_string(), + ledger_canister_id: self.ledger, + amount: Nat::from(amount), + expires_at_ns, + message: Some("thanks for the help".to_string()), + claim_code_hash: claim_code_hash(claim_code), + }, + ) + .expect("create_tip should reach the handler") + } + + /// Approve and create in one step — the pairing every real client performs. + fn reserve(&self, tip_id: &str, claim_code: &str, amount: u64) -> u64 { + let expires_at_ns = now_ns(&self.pic_setup) + ONE_HOUR_NS; + self.approve(tip_id, amount + TRANSFER_FEE, Some(expires_at_ns)); + assert_eq!( + self.create(tip_id, claim_code, amount, expires_at_ns), + Ok(()) + ); + expires_at_ns + } + + fn claim( + &self, + claimer: Principal, + tip_id: &str, + claim_code: &str, + ) -> Result { + self.pic_setup + .update::>( + claimer, + "claim_tip", + TipClaimRequest { + tip_id: tip_id.to_string(), + claim_code: claim_code.to_string(), + }, + ) + .expect("claim_tip should reach the handler") + } + + fn public_tip(&self, caller: Principal, tip_id: &str) -> Result { + self.pic_setup + .query::>(caller, "get_tip", tip_id.to_string()) + .expect("get_tip should reach the handler") + } + + fn details( + &self, + caller: Principal, + tip_id: &str, + claim_code: &str, + ) -> Result { + self.pic_setup + .query::>( + caller, + "get_tip_details", + TipClaimRequest { + tip_id: tip_id.to_string(), + claim_code: claim_code.to_string(), + }, + ) + .expect("get_tip_details should reach the handler") + } + + fn my_tips(&self, caller: Principal) -> Vec { + self.pic_setup + .query::, TipError>>(caller, "get_my_tips", ()) + .expect("get_my_tips should reach the handler") + .expect("get_my_tips should succeed") + } + + fn cancel(&self, caller: Principal, tip_id: &str) -> Result<(), TipError> { + self.pic_setup + .update::>(caller, "cancel_tip", tip_id.to_string()) + .expect("cancel_tip should reach the handler") + } + + fn balance(&self, owner: Principal) -> Nat { + icrc1_ledger::balance_of(&self.pic_setup.pic, self.ledger, owner) + } + + fn tip_allowance(&self, tip_id: &str) -> Nat { + icrc1_ledger::allowance( + &self.pic_setup.pic, + self.ledger, + self.sender, + icrc1_ledger::account_with_subaccount( + self.pic_setup.canister_id(), + spender_subaccount(tip_id), + ), + ) + .allowance + } +} + +// ------------------------------------------------------------------------------------------------- +// - Tests +// ------------------------------------------------------------------------------------------------- + +#[test] +fn a_tip_pays_a_brand_new_principal_exactly_once_and_takes_no_custody() { + let env = setup_tips(); + let tip_id = "tip-happy"; + let code = "claim-code-happy"; + + let sender_balance_before = env.balance(env.sender); + env.reserve(tip_id, code, TIP_AMOUNT); + + // Nothing has moved: the only ledger transaction so far is the approve, + // which costs the sender one fee and leaves the tip amount in their account. + assert_eq!( + env.balance(env.sender), + sender_balance_before.clone() - Nat::from(TRANSFER_FEE), + "creating a tip must not transfer the amount anywhere" + ); + + // The anonymous preview carries the amount but nothing about anyone. + let preview = env + .public_tip(Principal::anonymous(), tip_id) + .expect("an anonymous reader can see a live tip"); + assert_eq!(preview.amount, Nat::from(TIP_AMOUNT)); + assert_eq!(preview.ledger_canister_id, env.ledger); + + // A claimer who has never used OISY — no user profile at all — is exactly + // who this feature is for. + let claimer = Principal::self_authenticating("never-seen-before"); + let details = env + .details(claimer, tip_id, code) + .expect("a signed-in claimer sees the review"); + assert_eq!(details.message, Some("thanks for the help".to_string())); + + let claim = env + .claim(claimer, tip_id, code) + .expect("the claim succeeds"); + assert_eq!(claim.amount, Nat::from(TIP_AMOUNT)); + + assert_eq!( + env.balance(claimer), + Nat::from(TIP_AMOUNT), + "the claimer receives the full amount: the ledger charges its fee to the allowance" + ); + assert_eq!( + env.balance(env.sender), + sender_balance_before - Nat::from(2 * TRANSFER_FEE) - Nat::from(TIP_AMOUNT), + "the sender carries both fees — one to reserve, one to move — and the claimer \ + carries none. `transfer_from` debits amount + fee from the sender's balance \ + while crediting the amount in full, which is exactly why the allowance has \ + to be sized at amount + fee." + ); + assert_eq!( + env.tip_allowance(tip_id), + Nat::from(0u8), + "the reservation is spent exactly: amount plus one fee" + ); + + // Claiming twice pays once. The second attempt is indistinguishable from a + // tip that never existed. + assert_eq!( + env.claim(claimer, tip_id, code), + Err(TipError::NotFound), + "a claimed tip cannot be claimed again" + ); + assert_eq!(env.balance(claimer), Nat::from(TIP_AMOUNT)); + + let history = env.my_tips(env.sender); + assert_eq!(history.len(), 1); + assert_eq!(history[0].status, TipStatus::Claimed); + assert_eq!( + history[0].claimed_by, + Some(claimer), + "the sender learns who claimed, as the claim screen disclosed" + ); +} + +#[test] +fn every_unclaimable_tip_looks_the_same_from_outside() { + let env = setup_tips(); + let stranger = Principal::self_authenticating("stranger"); + + // Unknown id. + assert_eq!( + env.claim(stranger, "no-such-tip", "whatever"), + Err(TipError::NotFound) + ); + assert_eq!( + env.public_tip(Principal::anonymous(), "no-such-tip"), + Err(TipError::NotFound) + ); + + // A wrong claim code is refused, and — importantly — does not consume the + // tip: the right code still works afterwards. + let guessed = "tip-guessed"; + env.reserve(guessed, "the-real-code", TIP_AMOUNT); + assert_eq!( + env.claim(stranger, guessed, "not-the-code"), + Err(TipError::NotFound), + "a wrong code is indistinguishable from a missing tip" + ); + assert_eq!( + env.details(stranger, guessed, "not-the-code"), + Err(TipError::NotFound), + "and it does not reveal the message either" + ); + assert!(env.claim(stranger, guessed, "the-real-code").is_ok()); + + // Expiry: nothing is transferred, and History says so. + let expiring = "tip-expiring"; + env.reserve(expiring, "code-expiring", TIP_AMOUNT); + env.pic_setup.pic.advance_time(Duration::from_hours(2)); + let late = Principal::self_authenticating("late-claimer"); + assert_eq!( + env.claim(late, expiring, "code-expiring"), + Err(TipError::NotFound) + ); + assert_eq!( + env.balance(late), + Nat::from(0u8), + "an expired tip transfers nothing, to anyone" + ); + let expired_row = env + .my_tips(env.sender) + .into_iter() + .find(|tip| tip.tip_id == expiring) + .expect("an expired tip stays in History"); + assert_eq!(expired_row.status, TipStatus::Expired); + + // Cancellation. + let cancelled = "tip-cancelled"; + env.reserve(cancelled, "code-cancelled", TIP_AMOUNT); + assert_eq!(env.cancel(env.sender, cancelled), Ok(())); + assert_eq!( + env.public_tip(Principal::anonymous(), cancelled), + Err(TipError::NotFound) + ); + assert_eq!( + env.claim(stranger, cancelled, "code-cancelled"), + Err(TipError::NotFound) + ); + let cancelled_row = env + .my_tips(env.sender) + .into_iter() + .find(|tip| tip.tip_id == cancelled) + .expect("a cancelled tip stays in History"); + assert_eq!(cancelled_row.status, TipStatus::Cancelled); +} + +#[test] +fn a_revoked_allowance_tells_the_claimer_and_leaves_the_tip_recoverable() { + let env = setup_tips(); + let tip_id = "tip-uncovered"; + let code = "code-uncovered"; + let expires_at_ns = env.reserve(tip_id, code, TIP_AMOUNT); + + // The sender changes their mind at the ledger without telling us — the case + // the whole "a tip is a reservation, not a guarantee" framing exists for. + env.approve(tip_id, 0, None); + + let claimer = Principal::self_authenticating("unlucky-claimer"); + assert_eq!( + env.claim(claimer, tip_id, code), + Err(TipError::Uncovered), + "the one failure a claimer is told the truth about" + ); + assert_eq!(env.balance(claimer), Nat::from(0u8)); + + // The failed claim did not consume the tip. It now reports `Failed` rather + // than `Reserved` — a live state that says "somebody tried and the payout did + // not go through", which is the one thing in History a sender can act on. The + // re-approve below is what proves it is still live: the same link works again. + let row = env + .my_tips(env.sender) + .into_iter() + .find(|tip| tip.tip_id == tip_id) + .expect("the tip is still on record"); + assert_eq!(row.status, TipStatus::Failed); + + env.approve(tip_id, TIP_AMOUNT + TRANSFER_FEE, Some(expires_at_ns)); + assert!( + env.claim(claimer, tip_id, code).is_ok(), + "re-covering the tip makes the same link work again" + ); + assert_eq!(env.balance(claimer), Nat::from(TIP_AMOUNT)); +} + +#[test] +fn only_the_sender_can_cancel_and_only_a_principal_can_claim() { + let env = setup_tips(); + let tip_id = "tip-guards"; + let code = "code-guards"; + env.reserve(tip_id, code, TIP_AMOUNT); + + let someone_else = Principal::self_authenticating("not-the-sender"); + env.pic_setup.ensure_user_profile(someone_else); + assert_eq!( + env.cancel(someone_else, tip_id), + Err(TipError::NotYourTip), + "a tip is only its sender's to cancel" + ); + + // An anonymous caller has nowhere to receive tokens, so the guard rejects + // the call outright rather than returning a `TipError`. + assert!( + env.pic_setup + .update::>( + Principal::anonymous(), + "claim_tip", + TipClaimRequest { + tip_id: tip_id.to_string(), + claim_code: code.to_string(), + }, + ) + .is_err(), + "claiming anonymously is rejected by the guard" + ); + + // Reading the tip anonymously, however, is the whole point. + assert!(env.public_tip(Principal::anonymous(), tip_id).is_ok()); +} + +#[test] +fn a_tip_below_one_ledger_fee_is_refused() { + let env = setup_tips(); + let tip_id = "tip-dust"; + let expires_at_ns = now_ns(&env.pic_setup) + ONE_HOUR_NS; + env.approve(tip_id, TRANSFER_FEE * 10, Some(expires_at_ns)); + + assert_eq!( + env.create(tip_id, "code-dust", TRANSFER_FEE - 1, expires_at_ns), + Err(TipError::AmountTooSmall), + "a tip that cannot cover the cost of moving it is spam by construction" + ); +} + +#[test] +fn a_tip_without_a_covering_allowance_is_refused_at_creation() { + let env = setup_tips(); + let tip_id = "tip-underfunded"; + let expires_at_ns = now_ns(&env.pic_setup) + ONE_HOUR_NS; + + // One fee short of what the payout needs. + env.approve(tip_id, TIP_AMOUNT, Some(expires_at_ns)); + assert_eq!( + env.create(tip_id, "code-underfunded", TIP_AMOUNT, expires_at_ns), + Err(TipError::Uncovered), + "the allowance must cover the amount plus the fee the ledger draws from it" + ); + + // And a reservation that lapses before the tip does is refused too: it would + // advertise a deadline the ledger will not honour. + let short_lived = "tip-short-allowance"; + env.approve( + short_lived, + TIP_AMOUNT + TRANSFER_FEE, + Some(expires_at_ns - ONE_HOUR_NS / 2), + ); + assert_eq!( + env.create(short_lived, "code-short", TIP_AMOUNT, expires_at_ns), + Err(TipError::InvalidExpiry) + ); +} + +#[test] +fn a_stored_claim_code_is_readable_only_by_the_sender_who_stored_it() { + // The recovery store exists so a sender can get back to their own link. It + // must not become a way to read anybody else's: `EncryptedMaps` keys every + // map by its owner, and this pins that the isolation actually holds through + // the endpoints rather than only in the library. + let env = setup_tips(); + let tip_id = "tip-secret"; + env.reserve(tip_id, "claim-code-secret", TIP_AMOUNT); + + // Opaque bytes as far as the canister is concerned — in production this is + // AES-GCM ciphertext under a vetKey only the sender can derive. + let ciphertext = ByteBuf::from(vec![7u8; 48]); + + let stored: SetTipSecretResult = env + .pic_setup + .update( + env.sender, + "set_tip_secret", + SetTipSecretRequest { + tip_id: tip_id.to_string(), + encrypted_claim_code: ciphertext.clone(), + }, + ) + .expect("storing an encrypted claim code should succeed"); + assert!(matches!(stored, SetTipSecretResult::Ok)); + + // The sender reads back exactly what they wrote. + let mine: GetTipSecretResult = env + .pic_setup + .query(env.sender, "get_tip_secret", tip_id.to_string()) + .expect("the sender may read their own secret"); + assert_eq!( + mine, + GetTipSecretResult::Ok(Some(ciphertext)), + "a sender must get their own ciphertext back verbatim" + ); + + // Anyone else asking for the same tip id sees an empty map of their own — + // not the sender's ciphertext, and not an error that would confirm one + // exists. + let stranger = Principal::self_authenticating("tip-stranger"); + env.pic_setup.ensure_user_profile(stranger); + let theirs: GetTipSecretResult = env + .pic_setup + .query(stranger, "get_tip_secret", tip_id.to_string()) + .expect("the query itself is allowed for any registered user"); + assert_eq!( + theirs, + GetTipSecretResult::Ok(None), + "another principal must never see the sender's stored claim code" + ); +} + +#[test] +fn cancelling_a_tip_drops_its_recoverable_claim_code() { + // Once cancelled the link is worthless, so the recoverable copy should not + // outlive it. + let env = setup_tips(); + let tip_id = "tip-cancel-secret"; + env.reserve(tip_id, "claim-code-cancel", TIP_AMOUNT); + + let _: SetTipSecretResult = env + .pic_setup + .update( + env.sender, + "set_tip_secret", + SetTipSecretRequest { + tip_id: tip_id.to_string(), + encrypted_claim_code: ByteBuf::from(vec![9u8; 32]), + }, + ) + .expect("storing should succeed"); + + let cancelled: CancelTipResult = env + .pic_setup + .update(env.sender, "cancel_tip", tip_id.to_string()) + .expect("the sender may cancel their own tip"); + assert!(matches!(cancelled, CancelTipResult::Ok(()))); + + let after: GetTipSecretResult = env + .pic_setup + .query(env.sender, "get_tip_secret", tip_id.to_string()) + .expect("the query still answers"); + assert_eq!( + after, + GetTipSecretResult::Ok(None), + "cancelling must drop the stored claim code" + ); +} + +#[test] +fn claiming_a_tip_drops_its_recoverable_claim_code() { + // A spent link has nothing left to recover, so its stored copy should go with + // it. This is the case that used to leak: removal was addressed to whoever was + // calling, and a claim runs as the *recipient*, so the delete looked in the + // claimer's own (empty) map and silently succeeded while the sender's entry + // stayed put. + let env = setup_tips(); + let tip_id = "tip-claim-secret"; + let code = "claim-code-claimed"; + env.reserve(tip_id, code, TIP_AMOUNT); + + let _: SetTipSecretResult = env + .pic_setup + .update( + env.sender, + "set_tip_secret", + SetTipSecretRequest { + tip_id: tip_id.to_string(), + encrypted_claim_code: ByteBuf::from(vec![7u8; 32]), + }, + ) + .expect("storing should succeed"); + + // Asserted before the claim so a regression cannot pass by never having + // stored anything in the first place. + let before: GetTipSecretResult = env + .pic_setup + .query(env.sender, "get_tip_secret", tip_id.to_string()) + .expect("the query answers"); + assert!( + matches!(before, GetTipSecretResult::Ok(Some(_))), + "the claim code should be stored before the claim" + ); + + let claimer = Principal::self_authenticating("claims-and-clears"); + env.claim(claimer, tip_id, code) + .expect("the claim succeeds"); + + let after: GetTipSecretResult = env + .pic_setup + .query(env.sender, "get_tip_secret", tip_id.to_string()) + .expect("the query still answers"); + assert_eq!( + after, + GetTipSecretResult::Ok(None), + "a claimed tip must drop the stored claim code" + ); +} + +#[test] +fn a_swept_tip_drops_its_recoverable_claim_code() { + // The retention sweep removes the tip's record; without this its ciphertext + // stayed behind forever, pointed at by nothing. The sweep runs as the + // canister on a timer, which is the other reason removal cannot be scoped to + // the caller. + let env = setup_tips(); + let tip_id = "tip-swept-secret"; + env.reserve(tip_id, "claim-code-swept", TIP_AMOUNT); + + let _: SetTipSecretResult = env + .pic_setup + .update( + env.sender, + "set_tip_secret", + SetTipSecretRequest { + tip_id: tip_id.to_string(), + encrypted_claim_code: ByteBuf::from(vec![5u8; 32]), + }, + ) + .expect("storing should succeed"); + + // Past the one-hour expiry, then past the 30-day retention window, then far + // enough again for the hourly housekeeping timer to come round. + env.pic_setup + .pic + .advance_time(Duration::from_hours(31 * 24)); + for _ in 0..20 { + env.pic_setup.pic.tick(); + } + + let after: GetTipSecretResult = env + .pic_setup + .query(env.sender, "get_tip_secret", tip_id.to_string()) + .expect("the query still answers"); + assert_eq!( + after, + GetTipSecretResult::Ok(None), + "a swept tip must drop the stored claim code" + ); +} + +#[test] +fn storing_claim_codes_is_rate_limited() { + // `set_tip_secret` writes to stable memory and is deliberately not gated on + // the tip existing, so without a limiter one registered caller could grow the + // store without creating a single tip. That is exactly what this loop does. + let env = setup_tips(); + + let mut limited = false; + for i in 0..40u32 { + let result: SetTipSecretResult = env + .pic_setup + .update( + env.sender, + "set_tip_secret", + SetTipSecretRequest { + tip_id: format!("no-such-tip-{i}"), + encrypted_claim_code: ByteBuf::from(vec![1u8; 32]), + }, + ) + .expect("the endpoint answers"); + + if matches!(result, SetTipSecretResult::Err(TipError::RateLimited(_))) { + limited = true; + break; + } + } + + assert!( + limited, + "storing claim codes without limit is how the secrets store grows unbounded" + ); +} + +#[test] +fn a_long_tip_id_can_still_recover_its_link() { + // `MAX_TIP_ID_BYTES` is 64 and the secrets map key is a `Blob<32>`, so the + // raw bytes made ids of 33-64 a silent dead zone: creatable, claimable and + // cancellable, but the recovery secret could never be stored or read. The + // sender would only find out when the link they wanted back was not there. + let env = setup_tips(); + let tip_id = "a".repeat(48); + let claim_code = "code-long-id"; + + env.reserve(&tip_id, claim_code, TIP_AMOUNT); + + let ciphertext = ByteBuf::from(vec![9u8; 32]); + let stored: SetTipSecretResult = env + .pic_setup + .update( + env.sender, + "set_tip_secret", + SetTipSecretRequest { + tip_id: tip_id.clone(), + encrypted_claim_code: ciphertext.clone(), + }, + ) + .expect("set_tip_secret should reach the handler"); + + assert!(matches!(stored, SetTipSecretResult::Ok)); + + let read: GetTipSecretResult = env + .pic_setup + .query(env.sender, "get_tip_secret", tip_id) + .expect("get_tip_secret should reach the handler"); + + assert_eq!(read, GetTipSecretResult::Ok(Some(ciphertext))); +} + +#[test] +fn an_expired_tip_cannot_be_rewritten_as_cancelled() { + // It lapsed. Calling that a cancellation puts something in the sender's + // history they did not do, and the allowance expired with it either way. + let env = setup_tips(); + let tip_id = "tip-lapsed"; + let expires_at_ns = now_ns(&env.pic_setup) + ONE_HOUR_NS; + + env.approve(tip_id, TIP_AMOUNT + TRANSFER_FEE, Some(expires_at_ns)); + assert_eq!( + env.create(tip_id, "code-lapsed", TIP_AMOUNT, expires_at_ns), + Ok(()) + ); + + env.pic_setup + .pic + .advance_time(Duration::from_secs(60 * 60 + 1)); + env.pic_setup.pic.tick(); + + assert_eq!( + env.cancel(env.sender, tip_id), + Err(TipError::NotCancellable) + ); + + let tips = env.my_tips(env.sender); + + assert_eq!( + tips[0].status, + TipStatus::Expired, + "and it still reads as expired" + ); +} + +#[test] +fn the_secrets_store_refuses_an_empty_tip_id() { + // The store had its own length check rather than going through + // `validate_tip_id`, so it matched on the upper bound and diverged on the + // lower one: an empty id was accepted. That key matches no tip, so claim, + // cancel and prune — all of which clean up by tip id — could never remove + // what was written under it. + let env = setup_tips(); + + let stored: SetTipSecretResult = env + .pic_setup + .update( + env.sender, + "set_tip_secret", + SetTipSecretRequest { + tip_id: String::new(), + encrypted_claim_code: ByteBuf::from(vec![7u8; 48]), + }, + ) + .expect("set_tip_secret should reach the handler"); + + assert_eq!(stored, SetTipSecretResult::Err(TipError::InvalidTipId)); + + let read: GetTipSecretResult = env + .pic_setup + .query(env.sender, "get_tip_secret", String::new()) + .expect("get_tip_secret should reach the handler"); + + assert_eq!(read, GetTipSecretResult::Err(TipError::InvalidTipId)); +} + +#[test] +fn a_tip_survives_an_upgrade_and_still_pays_exactly_once() { + // Tips live in stable memory regions of their own, and those regions were + // renumbered late (main had taken `MemoryId::new(20)` for contact images + // while this branch was away). Nothing until now upgraded the canister with + // a tip in it, so "the records are still there and still mean the same + // thing afterwards" was an assumption. An upgrade that silently reopened a + // region as the wrong structure would show up here as a tip that vanished, + // or one that paid twice. + let env = setup_tips(); + let tip_id = "tip-upgrade"; + let claim_code = "code-upgrade"; + let claimer = Principal::self_authenticating("tip-claimer-upgrade"); + + // Long enough to outlive the time the upgrade helper advances to dodge + // cycle throttling, which is far more than a tip normally sees. + let expires_at_ns = now_ns(&env.pic_setup) + 48 * ONE_HOUR_NS; + env.approve(tip_id, TIP_AMOUNT + TRANSFER_FEE, Some(expires_at_ns)); + assert_eq!( + env.create(tip_id, claim_code, TIP_AMOUNT, expires_at_ns), + Ok(()) + ); + + // A claim is submitted but deliberately not awaited: after one round the + // canister has written `Claiming` and handed the transfer to the ledger, so + // the upgrade below lands on a canister mid-way through a payout. + // + // Whether the upgrade lands *before* or *after* the ledger's reply is up to + // the scheduler, and both happen — locally the claim tends to complete + // first; on CI the upgrade gets in between and destroys the callback, so the + // claim call comes back as a trap. Nothing below depends on which, because + // the guarantee does not: the record is committed before the await, the + // allowance is the source of truth for whether the money moved, and neither + // outcome may pay twice. + let in_flight = env + .pic_setup + .pic + .submit_call( + env.pic_setup.canister_id(), + claimer, + "claim_tip", + encode_one(TipClaimRequest { + tip_id: tip_id.to_string(), + claim_code: claim_code.to_string(), + }) + .unwrap(), + ) + .expect("claim_tip should be accepted"); + + env.pic_setup.pic.tick(); + + env.pic_setup + .upgrade_latest_wasm(None) + .expect("upgrade should succeed with a claim outstanding"); + + // Answered or trapped, both are fine. A lost callback is exactly the case + // the `Claiming` state and its timeout exist for. + let _ = env.pic_setup.pic.await_call(in_flight); + + // Whatever happened to the call, at most one payout may have occurred. + let tip_amount = Nat::from(TIP_AMOUNT); + let paid_immediately = env.balance(claimer); + + assert!( + paid_immediately <= tip_amount, + "a claim across an upgrade paid more than the tip: {paid_immediately}" + ); + + // The record survived the upgrade as itself. This is the half that was never + // measured: tips took stable memory regions of their own and those regions + // were renumbered late, and a region reopened as the wrong structure decodes + // into plausible rubbish rather than failing outright — so the amount and the + // deadline are checked, not just that a row came back. + let tips = env.my_tips(env.sender); + + assert_eq!(tips.len(), 1, "the tip survived the upgrade"); + assert_eq!(tips[0].tip_id, tip_id); + assert_eq!(tips[0].amount, tip_amount); + assert_eq!(tips[0].expires_at_ns, expires_at_ns); + + // The upgrade helper advances time well past the in-flight window, so a tip + // left in `Claiming` by a lost callback is claimable again by here. Retrying + // is what proves the design: if the first transfer did land, the allowance is + // spent and this fails; if it did not, this pays. Either way, once. + let _ = env.claim(claimer, tip_id, claim_code); + + assert_eq!( + env.balance(claimer), + tip_amount, + "the claimer ends up paid exactly once, whichever side of the ledger reply the upgrade landed on" + ); + assert_eq!( + env.tip_allowance(tip_id), + Nat::from(0u64), + "and the allowance that paid for it is spent, so nothing can draw on it again" + ); +} diff --git a/src/backend/tests/it/utils/icrc1_ledger.rs b/src/backend/tests/it/utils/icrc1_ledger.rs new file mode 100644 index 00000000000..5a3772546aa --- /dev/null +++ b/src/backend/tests/it/utils/icrc1_ledger.rs @@ -0,0 +1,241 @@ +//! Deploys a **real** ICRC-1/2 token ledger into a `PocketIc` instance, and +//! talks to it as a client. +//! +//! Tips cannot be tested against a mock: the whole design rests on what a real +//! ledger does with an allowance granted under a spender subaccount — that the +//! subaccount scopes it, and that the fee comes out of the allowance rather than +//! the transferred amount. This is the same `ic-icrc1-ledger` wasm that ckBTC, +//! ckETH and ckUSDC run in production. +//! +//! Note this is *not* the cycles ledger the rest of the suite uses. The +//! difference is the wasm and its install arguments, which is why `InitArgs`, +//! `ArchiveOptions` and `FeatureFlags` are still declared below — they belong to +//! this ledger's `init`, not to any ICRC standard, and no published crate ships +//! them. It is *not* that the cycles ledger speaks a different wire protocol: an +//! earlier version of this comment said it "speaks `icrc_2_approve` with +//! underscores", and the underscore is in a Rust method name, not on the wire. + +use std::{env, fs::read}; + +use candid::{decode_one, encode_one, CandidType, Deserialize, Nat, Principal}; +// The ICRC-1/2 shapes come from the canonical crate, the same one the production +// `tips::icrc2` module uses — a second copy here could drift from the encoding +// under test and still pass. Re-exported so callers keep importing the ledger +// shapes from the module that speaks to the ledger. +pub use icrc_ledger_types::{ + icrc1::account::{Account, Subaccount}, + icrc2::{ + allowance::{Allowance, AllowanceArgs}, + approve::{ApproveArgs, ApproveError}, + }, +}; +use pocket_ic::PocketIc; +use serde_bytes::ByteBuf; + +/// Where `scripts/test.backend.sh` leaves the downloaded ledger wasm, relative +/// to the crate root the tests run from. +const DEFAULT_ICRC1_LEDGER_WASM: &str = "../../icrc1-ledger.wasm.gz"; + +/// The fee every ledger call in these tests is charged. Chosen to be non-zero: +/// a zero fee would hide the very behaviour the tips design depends on, namely +/// that the fee is drawn from the allowance. +pub const TRANSFER_FEE: u64 = 10_000; + +/// Cycles for the ledger canister. Archive spawning wants a healthy balance. +const LEDGER_CYCLES: u128 = 2_000_000_000_000; + +/// `Account` is foreign now, so these two cannot be inherent constructors. Thin +/// on purpose: they exist to keep the call sites reading as one expression. +pub fn account_owner(owner: Principal) -> Account { + Account::from(owner) +} + +pub fn account_with_subaccount(owner: Principal, subaccount: Subaccount) -> Account { + Account { + owner, + subaccount: Some(subaccount), + } +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +pub enum MetadataValue { + Nat(Nat), + Int(i64), + Text(String), + Blob(ByteBuf), +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +pub struct ArchiveOptions { + pub num_blocks_to_archive: u64, + pub max_transactions_per_response: Option, + pub trigger_threshold: u64, + pub max_message_size_bytes: Option, + pub cycles_for_archive_creation: Option, + pub node_max_memory_size_bytes: Option, + pub controller_id: Principal, + pub more_controller_ids: Option>, +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +pub struct FeatureFlags { + pub icrc2: bool, +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +pub struct InitArgs { + pub minting_account: Account, + pub fee_collector_account: Option, + pub transfer_fee: Nat, + pub decimals: Option, + pub max_memo_length: Option, + pub token_symbol: String, + pub token_name: String, + pub metadata: Vec<(String, MetadataValue)>, + pub initial_balances: Vec<(Account, Nat)>, + pub feature_flags: Option, + pub maximum_number_of_accounts: Option, + pub accounts_overflow_trim_quantity: Option, + pub archive_options: ArchiveOptions, +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +pub enum LedgerArg { + /// Boxed only to keep the variants a similar size; candid encodes it the + /// same either way. + Init(Box), + Upgrade(Option<()>), +} + +fn wasm_bytes() -> Vec { + let path = env::var("ICRC1_LEDGER_WASM_FILE") + .unwrap_or_else(|_| DEFAULT_ICRC1_LEDGER_WASM.to_string()); + read(&path).unwrap_or_else(|_| { + panic!( + "Could not find the ICRC-1 ledger wasm at {path}. Run the tests through \ + `./scripts/test.backend.sh`, which downloads it, or set ICRC1_LEDGER_WASM_FILE." + ) + }) +} + +/// Installs a token ledger with `funded` accounts pre-credited, and returns its +/// canister id. The controller doubles as the minting account — no test needs to +/// mint, only to spend what it starts with. +pub fn deploy(pic: &PocketIc, controller: Principal, funded: &[(Principal, u64)]) -> Principal { + let ledger = pic.create_canister(); + pic.add_cycles(ledger, LEDGER_CYCLES); + + let arg = LedgerArg::Init(Box::new(InitArgs { + minting_account: account_owner(controller), + fee_collector_account: None, + transfer_fee: Nat::from(TRANSFER_FEE), + decimals: Some(8), + max_memo_length: Some(80), + token_symbol: "TIPTOK".to_string(), + token_name: "Tip test token".to_string(), + metadata: vec![], + initial_balances: funded + .iter() + .map(|(owner, amount)| (account_owner(*owner), Nat::from(*amount))) + .collect(), + // The reason this file exists: without ICRC-2 the ledger rejects every + // approve, and none of the tip flows are testable. + feature_flags: Some(FeatureFlags { icrc2: true }), + maximum_number_of_accounts: None, + accounts_overflow_trim_quantity: None, + archive_options: ArchiveOptions { + num_blocks_to_archive: 10_000, + max_transactions_per_response: None, + trigger_threshold: 20_000, + max_message_size_bytes: None, + cycles_for_archive_creation: Some(1_000_000_000_000), + node_max_memory_size_bytes: None, + controller_id: controller, + more_controller_ids: None, + }, + })); + + pic.install_canister(ledger, wasm_bytes(), encode_one(arg).unwrap(), None); + ledger +} + +fn update( + pic: &PocketIc, + ledger: Principal, + caller: Principal, + method: &str, + arg: impl CandidType, +) -> T +where + T: for<'a> Deserialize<'a> + CandidType, +{ + let reply = pic + .update_call(ledger, caller, method, encode_one(arg).unwrap()) + .unwrap_or_else(|err| panic!("ledger {method} rejected: {err:?}")); + decode_one(&reply).unwrap_or_else(|err| panic!("ledger {method} decode failed: {err:?}")) +} + +fn query( + pic: &PocketIc, + ledger: Principal, + caller: Principal, + method: &str, + arg: impl CandidType, +) -> T +where + T: for<'a> Deserialize<'a> + CandidType, +{ + let reply = pic + .query_call(ledger, caller, method, encode_one(arg).unwrap()) + .unwrap_or_else(|err| panic!("ledger {method} rejected: {err:?}")); + decode_one(&reply).unwrap_or_else(|err| panic!("ledger {method} decode failed: {err:?}")) +} + +/// Grants `spender` an allowance over `owner`'s balance. `amount` of `0` revokes. +pub fn approve( + pic: &PocketIc, + ledger: Principal, + owner: Principal, + spender: Account, + amount: u64, + expires_at: Option, +) -> Result { + update( + pic, + ledger, + owner, + "icrc2_approve", + ApproveArgs { + from_subaccount: None, + spender, + amount: Nat::from(amount), + expected_allowance: None, + expires_at, + fee: None, + memo: None, + created_at_time: None, + }, + ) +} + +pub fn balance_of(pic: &PocketIc, ledger: Principal, owner: Principal) -> Nat { + query(pic, ledger, owner, "icrc1_balance_of", account_owner(owner)) +} + +pub fn allowance( + pic: &PocketIc, + ledger: Principal, + owner: Principal, + spender: Account, +) -> Allowance { + query( + pic, + ledger, + owner, + "icrc2_allowance", + AllowanceArgs { + account: account_owner(owner), + spender, + }, + ) +} diff --git a/src/backend/tests/it/utils/mod.rs b/src/backend/tests/it/utils/mod.rs index 3c15bbbae10..0b125e9acf9 100644 --- a/src/backend/tests/it/utils/mod.rs +++ b/src/backend/tests/it/utils/mod.rs @@ -1,5 +1,6 @@ pub mod assertion; pub mod asserts; +pub mod icrc1_ledger; pub mod ii; pub mod mock; pub mod pocketic; diff --git a/src/backend/tests/it/utils/pocketic.rs b/src/backend/tests/it/utils/pocketic.rs index 17f6862f2fa..f28b21e2cbb 100644 --- a/src/backend/tests/it/utils/pocketic.rs +++ b/src/backend/tests/it/utils/pocketic.rs @@ -489,7 +489,6 @@ fn setup_with_ii_internal(cycles_ledger_enabled: bool) -> (PicBackend, super::ii } impl PicBackend { - #[expect(dead_code)] pub fn upgrade_latest_wasm(&self, encoded_arg: Option>) -> Result<(), String> { let backend_wasm_path = env::var("BACKEND_WASM_PATH").unwrap_or_else(|_| BACKEND_WASM.to_string()); diff --git a/src/declarations/backend/backend.did b/src/declarations/backend/backend.did index 9dd2712078d..3a38c733c36 100644 --- a/src/declarations/backend/backend.did +++ b/src/declarations/backend/backend.did @@ -364,6 +364,7 @@ type BtcGetPendingTransactionsResult = variant { }; // Bitcoin transaction data. type BtcTransactionData = record { fee : opt nat }; +type CancelTipResult = variant { Ok; Err : TipError }; // Copy of the synonymous Rosetta type. type CanisterStatusResultV2 = record { controller : principal; @@ -417,6 +418,7 @@ type ChainFusionDirection = variant { Erc20ToCkErc20; CkEthToEth }; +type ClaimTipResult = variant { Ok : TipClaim; Err : TipError }; type Config = record { // The derivation origin used for II authentication, ensuring users get a // consistent identity across different domains. @@ -495,6 +497,26 @@ type CreatePersonalNoteShareResult = variant { Ok; Err : PersonalNoteShareError }; +// Create-tip request. The canister stores the tip and verifies the sender's +// allowance covers it; it never takes custody. +type CreateTipRequest = record { + // Opaque, client-generated random id; also the map key and the `` in + // the share link. + tip_id : text; + // SHA-256 of the claim code held in the link fragment. + claim_code_hash : blob; + // Optional note shown to the claimer *after* they sign in — never in the + // anonymous preview. + message : opt text; + // Ledger the tip is denominated in. Must be ICRC-2 capable — the sender's + // allowance is what makes the tip claimable. + ledger_canister_id : principal; + // What the claimer receives, in the ledger's base units. The sender's + // allowance must additionally cover one ledger fee, which the ledger + // draws from the allowance at claim rather than from this amount. + amount : nat; + expires_at_ns : nat64 +}; type CreateUserProfileError = variant { // Sign-ups of new users are currently disabled on the backend. Callers that already have a // profile are unaffected; this variant is only returned for principals without an existing @@ -639,6 +661,7 @@ type GetContactsResult = variant { // The contacts were not retrieved due to an error. Err : ContactError }; +type GetMyTipsResult = variant { Ok : vec MyTip; Err : TipError }; type GetPersonalNoteShareResult = variant { Ok : PersonalNoteShareContent; Err : PersonalNoteShareError @@ -659,6 +682,11 @@ type GetPersonalNotesResult = variant { // The notes could not be retrieved due to an error. Err : PersonalNoteError }; +type GetTipDetailsResult = variant { Ok : TipDetails; Err : TipError }; +type GetTipResult = variant { Ok : PublicTip; Err : TipError }; +// The caller's encrypted claim code for one tip. `Ok(None)` means no secret is +// stored — a tip from before the store existed, or one already cleaned up. +type GetTipSecretResult = variant { Ok : opt blob; Err : TipError }; type GetUserProfileError = variant { NotFound }; type GetUserProfileResult = variant { // The user's profile was retrieved successfully. @@ -816,6 +844,23 @@ type LiquidiumData = record { // Amount in the token's base units. amount : nat }; +// One of the caller's own tips, as returned by `get_my_tips`. +type MyTip = record { + status : TipStatus; + // Set once claimed. The sender learns who claimed their tip; the claim + // screen discloses this before the claimer commits. + claimed_by : opt principal; + tip_id : text; + // The most recent claim that did not pay out, if any. Present alongside + // `status = Failed` for a live tip, and kept afterwards so a tip that + // eventually succeeded can still show it was not first time lucky. + last_claim_failure : opt TipClaimFailure; + created_at_ns : nat64; + message : opt text; + ledger_canister_id : principal; + amount : nat; + expires_at_ns : nat64 +}; // NEAR Intents (1Click) cross-chain swap payload. Settlement is tracked // off-chain by polling the 1Click status endpoint keyed by the deposit // address, so that address (and its optional memo, plus learned-mid-flow tx @@ -992,6 +1037,14 @@ type ProviderAgreementType = record { provider : ProviderAgreementProvider; scope : ProviderAgreementScope }; +// What an **anonymous** reader of a tip link sees. Deliberately excludes the +// message, the sender, and the claimer: enough to decide whether to sign in, +// nothing that identifies anyone. +type PublicTip = record { + ledger_canister_id : principal; + amount : nat; + expires_at_ns : nat64 +}; type QualifiedNotificationKind = variant { NoIndexCanister; UnavailableIndexCanister @@ -1025,6 +1078,17 @@ type SetShowTestnetsRequest = record { show_testnets : bool }; type SetTestnetsSettingsError = variant { VersionMismatch; UserNotFound }; +// Stores the sender's own encrypted claim code so they can recover the link. +// +// A single request struct rather than two arguments, matching +// `SetPersonalNoteRequest`: it keeps the candid signature stable if the store +// ever needs another field. +type SetTipSecretRequest = record { + tip_id : text; + // AES-GCM ciphertext of the claim code, opaque to the canister. Bounded by + // [`MAX_TIP_SECRET_CIPHERTEXT_BYTES`]. + encrypted_claim_code : blob +}; type SetUserShowTestnetsResult = variant { // The user's show testnets was set successfully. Ok; @@ -1115,12 +1179,148 @@ type Stats = record { // not yet pruned). personal_note_shares_count : nat64; agreement_history_count : nat64; + // Total number of tips ever created and not yet pruned, across all users + // and every status. Aggregate only, deliberately: the negative guarantee + // that no endpoint enumerates another principal's tips holds for this one + // too, so there is a count here and never a row. + tips_count : nat64; // Total number of stored (encrypted) personal-note entries across all users. personal_notes_count : nat64; user_timestamps_count : nat64; user_token_count : nat64 }; type TestnetsSettings = record { show_testnets : bool }; +// Outcome of a successful claim. +type TipClaim = record { + // Ledger block index of the payout, so the client can link to it. + block_index : nat; + ledger_canister_id : principal; + // What was transferred to the claimer, in base units. + amount : nat +}; +// The most recent failed claim on a tip. Returned only to the tip's own sender. +// +// Deliberately not the ledger's error text: that is written for an operator, it +// can name balances, and it has no business being rendered to a user. +type TipClaimFailure = record { at_ns : nat64; reason : TipClaimFailureReason }; +// Why a claim attempt did not pay out. +// +// Three outcomes, and the split is by what the sender can do about it. +// `Uncovered` is the reservation having been reduced or revoked, so the link is +// dead and only a new tip fixes it. `InsufficientFunds` is the reservation +// standing but the money not being there, so the same link works again once +// they top up. `TransferFailed` is everything else — the ledger refusing or +// failing to answer — where retrying is the whole advice. +type TipClaimFailureReason = variant { + Uncovered; + TransferFailed; + // The sender's account no longer holds the amount. Distinct from + // `Uncovered`: the reservation is still granted, the money is simply not + // there, so topping up makes the same link work again. + InsufficientFunds +}; +// Identifies a tip **and** proves the caller holds its link. +// +// One type for both `get_tip_details` and `claim_tip` on purpose: reading the +// claim review and claiming are the same claim of authority, differing only in +// whether they move money. Two structurally identical records would also +// collapse into one name in the generated candid anyway — better to say so than +// to have the interface say it for us. +type TipClaimRequest = record { + tip_id : text; + // The plaintext code from the link fragment. Only ever compared against the + // stored hash; never persisted, never returned. + claim_code : text +}; +// What an authenticated claimer sees before claiming: the preview plus the +// sender's message. The payout fee is not included — the client reads it from +// the ledger directly (`icrc1_fee`), which is also the value the ledger will +// actually charge. +type TipDetails = record { + message : opt text; + ledger_canister_id : principal; + amount : nat; + expires_at_ns : nat64 +}; +type TipError = variant { + // `expires_at_ns` is not strictly in the future of IC time, or is further + // out than [`MAX_TIP_EXPIRY_NS`]. + InvalidExpiry; + // A claim is already in flight for this tip. Resolves on its own: either + // it completes, or [`TIP_CLAIM_IN_FLIGHT_TIMEOUT_NS`] passes and a retry + // may take it over. + ClaimInProgress; + // The encrypted claim code exceeds [`MAX_TIP_SECRET_CIPHERTEXT_BYTES`]. + SecretCiphertextTooLarge; + // The tip exists and the claim code is right, but the sender's allowance + // no longer covers it — they spent, reduced or revoked it. The one + // deliberately distinguishable failure, reachable only with a valid link, + // because telling the claimer "come back later" is useless. + Uncovered; + // No claimable tip for this id. Also returned for an expired, cancelled or + // already-claimed tip, and for a wrong claim code — every case collapsed + // into one response so a prober can never distinguish them. + NotFound; + // The caller is not the sender of this tip. + NotYourTip; + // The `claim_code_hash` is not exactly [`TIP_CLAIM_CODE_HASH_BYTES`] long. + InvalidClaimCodeHash; + // The `tip_id` is empty or exceeds [`MAX_TIP_ID_BYTES`]. + InvalidTipId; + RateLimited : RateLimitError; + // The `tip_id` already exists; the client should generate a fresh random + // id and retry. + DuplicateTipId; + // Only a `Reserved` tip can be cancelled. + NotCancellable; + // The ledger rejected or failed to answer the payout. The tip stays + // claimable — nothing was transferred. + TransferFailed : record { msg : text }; + InternalError : record { msg : text }; + // `message` exceeds [`MAX_TIP_MESSAGE_CHARS`] characters. + MessageTooLong; + // The caller already holds [`MAX_TIPS_PER_USER`] active tips. + TooManyTips; + // The sender's account no longer holds the amount. The reservation is still + // granted and the claim code is still valid, so the same link works again + // once they top up — which is why this is not folded into `TransferFailed`. + InsufficientFunds; + // The amount is zero, or below one ledger fee — a tip that cannot cover + // its own payout is not a tip. + AmountTooSmall +}; +// Lifecycle as the **sender** sees it in History. +// +// `Uncovered` is deliberately absent: it is not a stored state but the +// outcome of a claim attempt against an allowance the sender has since spent, +// reduced or revoked. Reporting it here would mean querying every tip's +// allowance on every History read; it surfaces on the claim path instead, as +// [`TipError::Uncovered`]. +type TipStatus = variant { + // Somebody tried to claim and the payout did not go through, and the tip is + // still live. The code stays valid, so this is the one status the sender can + // act on — typically by topping up the account the tip draws from. + // + // Distinct from `Reserved` precisely because it is actionable: without it a + // tip nobody has touched and a tip that has already failed a claimer look + // identical in History. + Failed; + // Funds are authorised in the sender's own account, waiting for a claimer. + Reserved; + // A claimer moved the tokens. + Claimed; + // The sender revoked it before anyone claimed. + Cancelled; + // The deadline passed unclaimed. Nothing was ever transferred, so nothing + // is returned — the allowance simply lapsed on the ledger. + Expired +}; +// vetKey material for the tip-secrets store, or why it could not be derived. +type TipVetkeyResult = variant { + // vetKey bytes, opaque to the canister. + Ok : blob; + Err : TipError +}; // A variant describing any token type Token = variant { Erc20 : ErcToken; @@ -1430,6 +1630,26 @@ service : (Arg) -> { btc_get_pending_transactions : (BtcGetPendingTransactionsRequest) -> ( BtcGetPendingTransactionsResult ); + // Stops an unclaimed tip of the caller's from being claimable. The allowance + // itself is the caller's to revoke — the client pairs this with an + // `icrc2_approve` of zero. + // + // # Errors + // Errors are enumerated by `TipError` (`NotFound`, `NotYourTip`, + // `NotCancellable`, `ClaimInProgress`, `RateLimited`). + cancel_tip : (text) -> (CancelTipResult); + // Pays the tip out to the caller, exactly once. + // + // Guarded on a non-anonymous caller rather than a registered one: the claimer + // may be an identity that has never used OISY before — that is the point of the + // feature — so requiring a user profile would defeat it. A principal is still + // required, since the payout needs somewhere to land. + // + // # Errors + // Errors are enumerated by `TipError` (`NotFound` for an unclaimable tip or a + // wrong code, `Uncovered` when the sender's allowance no longer covers it, + // `ClaimInProgress`, `TransferFailed`, `RateLimited`). + claim_tip : (TipClaimRequest) -> (ClaimTipResult); // Gets the canister configuration. config : () -> (Config) query; // Returns a **single-use** share's content exactly once, atomically deleting @@ -1466,6 +1686,17 @@ service : (Arg) -> { create_personal_note_share : (CreatePersonalNoteShareRequest) -> ( CreatePersonalNoteShareResult ); + // Records a tip against an ICRC-2 allowance the caller has already granted to + // this canister under the tip's own spender subaccount. + // + // No tokens move here, and none are held: the amount stays in the caller's + // account until someone claims it, and lapses in place if nobody does. + // + // # Errors + // Errors are enumerated by `TipError` (e.g. `Uncovered` when the allowance does + // not cover the amount plus its fee, `TooManyTips`, `AmountTooSmall`, + // `InvalidExpiry`, `DuplicateTipId`, `RateLimited`). + create_tip : (CreateTipRequest) -> (CancelTipResult); // It creates a new user profile for the caller. // If the user has already a profile, it will return that profile. // @@ -1575,6 +1806,16 @@ service : (Arg) -> { // state (`token_activity`) and may need an update context to schedule the // background fetch. get_exchange_rates : () -> (vec record { TokenId; opt ExchangeRate }); + // Returns the caller's own tips, newest first, for History. Bounded by + // `MAX_TIPS_RETURNED`. + // + // Not rate-limited, for the same reason as [`get_tip`]: a stateful limiter is + // a no-op on the non-certified query path. The row cap is what bounds the work + // here, and a caller can only ever read their own tips. + // + // # Errors + // Errors are enumerated by `TipError`. + get_my_tips : () -> (GetMyTipsResult) query; // Returns the note ciphertext for a **reusable** (non-single-use), unexpired // share. A single-use share's content is only ever returned by // `consume_personal_note_share`. Callable anonymously — a deliberate, @@ -1622,6 +1863,58 @@ service : (Arg) -> { // # Errors // Errors are enumerated by `PersonalNoteError`. get_personal_notes_vetkey_public_key : () -> (PersonalNotesVetkeyResult); + // Returns what an anonymous holder of a tip link may see: amount, token and + // deadline — never the message, the sender, or the claimer. + // + // Callable without an identity, deliberately: the recipient of a tip link has + // no OISY account yet, and the whole feature exists so they don't need one + // first. Same narrowly-scoped exception as `get_personal_note_share`. + // + // Not rate-limited, for the same reason that endpoint isn't: state changes + // during a query are not persisted, so a stateful limiter would be a no-op on + // the non-certified query path. The abuse surface is a cheap O(log n) lookup + // against a 128-bit id space. + // + // # Errors + // `TipError::NotFound` for anything not currently claimable — unknown, + // expired, cancelled and already-claimed are indistinguishable. + get_tip : (text) -> (GetTipResult) query; + // Returns the claim review for a signed-in claimer: the public preview plus + // the sender's message. Requires the claim code, so the message is visible only + // to someone holding the full link. + // + // Not rate-limited, for the same reason as [`get_tip`]: a stateful limiter is + // a no-op on the non-certified query path, because state changes during a + // query are not persisted. The abuse surface is one O(log n) lookup that also + // has to guess a 128-bit claim code. + // + // # Errors + // `TipError::NotFound` for an unclaimable tip or a wrong claim code. + get_tip_details : (TipClaimRequest) -> (GetTipDetailsResult) query; + // Derives the caller's vetKey for the tip-secrets store, secured to a + // browser-supplied transport public key. + // + // # Errors + // Errors are enumerated by `TipError` (`RateLimited`, `InternalError`). + get_tip_encrypted_vetkey : (blob) -> (TipVetkeyResult); + // The caller's encrypted claim code for one of their own tips, if stored. + // + // `EncryptedMaps` keys every map by its owner, so this can only ever return + // the caller's own ciphertext. + // + // Not rate-limited, for the same reason as [`get_tip`]: a stateful limiter is + // a no-op on the non-certified query path. The work is one keyed lookup + // scoped to the caller. + // + // # Errors + // Errors are enumerated by `TipError` (`InvalidTipId`, `InternalError`). + get_tip_secret : (text) -> (GetTipSecretResult) query; + // The vetKey verification key for the tip-secrets store. Identical for every + // caller; the browser needs it to verify its derived vetKey. + // + // # Errors + // Errors are enumerated by `TipError` (`RateLimited`, `InternalError`). + get_tip_vetkey_public_key : () -> (TipVetkeyResult); // Returns the full agreement consent/rejection history for the caller. // // # Returns @@ -1741,6 +2034,17 @@ service : (Arg) -> { // Errors are enumerated by `PersonalNoteError` (e.g. `TooManyNotes`, // `NoteCiphertextTooLarge`, `RateLimited`). set_personal_note : (PersonalNoteEntry) -> (SetPersonalNoteResult); + // Stores the caller's encrypted claim code for one of their own tips, so they + // can recover the link after closing the share screen. + // + // The value is ciphertext the canister cannot read: the browser encrypts it + // under a vetKey only this principal can derive. Storing it changes nothing + // about who can claim the tip — the canister still only holds the code's hash. + // + // # Errors + // Errors are enumerated by `TipError` (`RateLimited`, `InvalidTipId`, + // `SecretCiphertextTooLarge`, `InternalError`). + set_tip_secret : (SetTipSecretRequest) -> (CancelTipResult); // Sets the user's preference to show (or hide) testnets in the interface. // // # Returns diff --git a/src/declarations/backend/backend.did.d.ts b/src/declarations/backend/backend.did.d.ts index 9f85b043bc6..78d60c3d938 100644 --- a/src/declarations/backend/backend.did.d.ts +++ b/src/declarations/backend/backend.did.d.ts @@ -532,6 +532,7 @@ export type BtcGetPendingTransactionsResult = export interface BtcTransactionData { fee: [] | [bigint]; } +export type CancelTipResult = { Ok: null } | { Err: TipError }; /** * Copy of the synonymous Rosetta type. */ @@ -605,6 +606,7 @@ export type ChainFusionDirection = | { CkErc20ToErc20: null } | { Erc20ToCkErc20: null } | { CkEthToEth: null }; +export type ClaimTipResult = { Ok: TipClaim } | { Err: TipError }; export interface Config { /** * The derivation origin used for II authentication, ensuring users get a @@ -711,6 +713,38 @@ export interface CreatePersonalNoteShareRequest { expires_at_ns: bigint; } export type CreatePersonalNoteShareResult = { Ok: null } | { Err: PersonalNoteShareError }; +/** + * Create-tip request. The canister stores the tip and verifies the sender's + * allowance covers it; it never takes custody. + */ +export interface CreateTipRequest { + /** + * Opaque, client-generated random id; also the map key and the `` in + * the share link. + */ + tip_id: string; + /** + * SHA-256 of the claim code held in the link fragment. + */ + claim_code_hash: Uint8Array; + /** + * Optional note shown to the claimer *after* they sign in — never in the + * anonymous preview. + */ + message: [] | [string]; + /** + * Ledger the tip is denominated in. Must be ICRC-2 capable — the sender's + * allowance is what makes the tip claimable. + */ + ledger_canister_id: Principal; + /** + * What the claimer receives, in the ledger's base units. The sender's + * allowance must additionally cover one ledger fee, which the ledger + * draws from the allowance at claim rather than from this amount. + */ + amount: bigint; + expires_at_ns: bigint; +} export type CreateUserProfileError = { /** * Sign-ups of new users are currently disabled on the backend. Callers that already have a @@ -938,6 +972,7 @@ export type GetContactsResult = */ Err: ContactError; }; +export type GetMyTipsResult = { Ok: Array } | { Err: TipError }; export type GetPersonalNoteShareResult = { Ok: PersonalNoteShareContent } | { Err: PersonalNoteShareError }; export type GetPersonalNoteSharesCountResult = { Ok: bigint } | { Err: PersonalNoteShareError }; @@ -967,6 +1002,13 @@ export type GetPersonalNotesResult = */ Err: PersonalNoteError; }; +export type GetTipDetailsResult = { Ok: TipDetails } | { Err: TipError }; +export type GetTipResult = { Ok: PublicTip } | { Err: TipError }; +/** + * The caller's encrypted claim code for one tip. `Ok(None)` means no secret is + * stored — a tip from before the store existed, or one already cleaned up. + */ +export type GetTipSecretResult = { Ok: [] | [Uint8Array] } | { Err: TipError }; export type GetUserProfileError = { NotFound: null }; export type GetUserProfileResult = | { @@ -1208,6 +1250,29 @@ export interface LiquidiumData { */ amount: bigint; } +/** + * One of the caller's own tips, as returned by `get_my_tips`. + */ +export interface MyTip { + status: TipStatus; + /** + * Set once claimed. The sender learns who claimed their tip; the claim + * screen discloses this before the claimer commits. + */ + claimed_by: [] | [Principal]; + tip_id: string; + /** + * The most recent claim that did not pay out, if any. Present alongside + * `status = Failed` for a live tip, and kept afterwards so a tip that + * eventually succeeded can still show it was not first time lucky. + */ + last_claim_failure: [] | [TipClaimFailure]; + created_at_ns: bigint; + message: [] | [string]; + ledger_canister_id: Principal; + amount: bigint; + expires_at_ns: bigint; +} /** * NEAR Intents (1Click) cross-chain swap payload. Settlement is tracked * off-chain by polling the 1Click status endpoint keyed by the deposit @@ -1493,6 +1558,16 @@ export interface ProviderAgreementType { provider: ProviderAgreementProvider; scope: ProviderAgreementScope; } +/** + * What an **anonymous** reader of a tip link sees. Deliberately excludes the + * message, the sender, and the claimer: enough to decide whether to sign in, + * nothing that identifies anyone. + */ +export interface PublicTip { + ledger_canister_id: Principal; + amount: bigint; + expires_at_ns: bigint; +} export type QualifiedNotificationKind = { NoIndexCanister: null } | { UnavailableIndexCanister: null }; /** @@ -1539,6 +1614,21 @@ export interface SetShowTestnetsRequest { show_testnets: boolean; } export type SetTestnetsSettingsError = { VersionMismatch: null } | { UserNotFound: null }; +/** + * Stores the sender's own encrypted claim code so they can recover the link. + * + * A single request struct rather than two arguments, matching + * `SetPersonalNoteRequest`: it keeps the candid signature stable if the store + * ever needs another field. + */ +export interface SetTipSecretRequest { + tip_id: string; + /** + * AES-GCM ciphertext of the claim code, opaque to the canister. Bounded by + * [`MAX_TIP_SECRET_CIPHERTEXT_BYTES`]. + */ + encrypted_claim_code: Uint8Array; +} export type SetUserShowTestnetsResult = | { /** @@ -1682,6 +1772,13 @@ export interface Stats { */ personal_note_shares_count: bigint; agreement_history_count: bigint; + /** + * Total number of tips ever created and not yet pruned, across all users + * and every status. Aggregate only, deliberately: the negative guarantee + * that no endpoint enumerates another principal's tips holds for this one + * too, so there is a count here and never a row. + */ + tips_count: bigint; /** * Total number of stored (encrypted) personal-note entries across all users. */ @@ -1692,6 +1789,244 @@ export interface Stats { export interface TestnetsSettings { show_testnets: boolean; } +/** + * Outcome of a successful claim. + */ +export interface TipClaim { + /** + * Ledger block index of the payout, so the client can link to it. + */ + block_index: bigint; + ledger_canister_id: Principal; + /** + * What was transferred to the claimer, in base units. + */ + amount: bigint; +} +/** + * The most recent failed claim on a tip. Returned only to the tip's own sender. + * + * Deliberately not the ledger's error text: that is written for an operator, it + * can name balances, and it has no business being rendered to a user. + */ +export interface TipClaimFailure { + at_ns: bigint; + reason: TipClaimFailureReason; +} +/** + * Why a claim attempt did not pay out. + * + * Three outcomes, and the split is by what the sender can do about it. + * `Uncovered` is the reservation having been reduced or revoked, so the link is + * dead and only a new tip fixes it. `InsufficientFunds` is the reservation + * standing but the money not being there, so the same link works again once + * they top up. `TransferFailed` is everything else — the ledger refusing or + * failing to answer — where retrying is the whole advice. + */ +export type TipClaimFailureReason = + | { Uncovered: null } + | { TransferFailed: null } + | { + /** + * The sender's account no longer holds the amount. Distinct from + * `Uncovered`: the reservation is still granted, the money is simply not + * there, so topping up makes the same link work again. + */ + InsufficientFunds: null; + }; +/** + * Identifies a tip **and** proves the caller holds its link. + * + * One type for both `get_tip_details` and `claim_tip` on purpose: reading the + * claim review and claiming are the same claim of authority, differing only in + * whether they move money. Two structurally identical records would also + * collapse into one name in the generated candid anyway — better to say so than + * to have the interface say it for us. + */ +export interface TipClaimRequest { + tip_id: string; + /** + * The plaintext code from the link fragment. Only ever compared against the + * stored hash; never persisted, never returned. + */ + claim_code: string; +} +/** + * What an authenticated claimer sees before claiming: the preview plus the + * sender's message. The payout fee is not included — the client reads it from + * the ledger directly (`icrc1_fee`), which is also the value the ledger will + * actually charge. + */ +export interface TipDetails { + message: [] | [string]; + ledger_canister_id: Principal; + amount: bigint; + expires_at_ns: bigint; +} +export type TipError = + | { + /** + * `expires_at_ns` is not strictly in the future of IC time, or is further + * out than [`MAX_TIP_EXPIRY_NS`]. + */ + InvalidExpiry: null; + } + | { + /** + * A claim is already in flight for this tip. Resolves on its own: either + * it completes, or [`TIP_CLAIM_IN_FLIGHT_TIMEOUT_NS`] passes and a retry + * may take it over. + */ + ClaimInProgress: null; + } + | { + /** + * The encrypted claim code exceeds [`MAX_TIP_SECRET_CIPHERTEXT_BYTES`]. + */ + SecretCiphertextTooLarge: null; + } + | { + /** + * The tip exists and the claim code is right, but the sender's allowance + * no longer covers it — they spent, reduced or revoked it. The one + * deliberately distinguishable failure, reachable only with a valid link, + * because telling the claimer "come back later" is useless. + */ + Uncovered: null; + } + | { + /** + * No claimable tip for this id. Also returned for an expired, cancelled or + * already-claimed tip, and for a wrong claim code — every case collapsed + * into one response so a prober can never distinguish them. + */ + NotFound: null; + } + | { + /** + * The caller is not the sender of this tip. + */ + NotYourTip: null; + } + | { + /** + * The `claim_code_hash` is not exactly [`TIP_CLAIM_CODE_HASH_BYTES`] long. + */ + InvalidClaimCodeHash: null; + } + | { + /** + * The `tip_id` is empty or exceeds [`MAX_TIP_ID_BYTES`]. + */ + InvalidTipId: null; + } + | { RateLimited: RateLimitError } + | { + /** + * The `tip_id` already exists; the client should generate a fresh random + * id and retry. + */ + DuplicateTipId: null; + } + | { + /** + * Only a `Reserved` tip can be cancelled. + */ + NotCancellable: null; + } + | { + /** + * The ledger rejected or failed to answer the payout. The tip stays + * claimable — nothing was transferred. + */ + TransferFailed: { msg: string }; + } + | { InternalError: { msg: string } } + | { + /** + * `message` exceeds [`MAX_TIP_MESSAGE_CHARS`] characters. + */ + MessageTooLong: null; + } + | { + /** + * The caller already holds [`MAX_TIPS_PER_USER`] active tips. + */ + TooManyTips: null; + } + | { + /** + * The sender's account no longer holds the amount. The reservation is still + * granted and the claim code is still valid, so the same link works again + * once they top up — which is why this is not folded into `TransferFailed`. + */ + InsufficientFunds: null; + } + | { + /** + * The amount is zero, or below one ledger fee — a tip that cannot cover + * its own payout is not a tip. + */ + AmountTooSmall: null; + }; +/** + * Lifecycle as the **sender** sees it in History. + * + * `Uncovered` is deliberately absent: it is not a stored state but the + * outcome of a claim attempt against an allowance the sender has since spent, + * reduced or revoked. Reporting it here would mean querying every tip's + * allowance on every History read; it surfaces on the claim path instead, as + * [`TipError::Uncovered`]. + */ +export type TipStatus = + | { + /** + * Somebody tried to claim and the payout did not go through, and the tip is + * still live. The code stays valid, so this is the one status the sender can + * act on — typically by topping up the account the tip draws from. + * + * Distinct from `Reserved` precisely because it is actionable: without it a + * tip nobody has touched and a tip that has already failed a claimer look + * identical in History. + */ + Failed: null; + } + | { + /** + * Funds are authorised in the sender's own account, waiting for a claimer. + */ + Reserved: null; + } + | { + /** + * A claimer moved the tokens. + */ + Claimed: null; + } + | { + /** + * The sender revoked it before anyone claimed. + */ + Cancelled: null; + } + | { + /** + * The deadline passed unclaimed. Nothing was ever transferred, so nothing + * is returned — the allowance simply lapsed on the ledger. + */ + Expired: null; + }; +/** + * vetKey material for the tip-secrets store, or why it could not be derived. + */ +export type TipVetkeyResult = + | { + /** + * vetKey bytes, opaque to the canister. + */ + Ok: Uint8Array; + } + | { Err: TipError }; /** * A variant describing any token */ @@ -2170,6 +2505,30 @@ export interface _SERVICE { [BtcGetPendingTransactionsRequest], BtcGetPendingTransactionsResult >; + /** + * Stops an unclaimed tip of the caller's from being claimable. The allowance + * itself is the caller's to revoke — the client pairs this with an + * `icrc2_approve` of zero. + * + * # Errors + * Errors are enumerated by `TipError` (`NotFound`, `NotYourTip`, + * `NotCancellable`, `ClaimInProgress`, `RateLimited`). + */ + cancel_tip: ActorMethod<[string], CancelTipResult>; + /** + * Pays the tip out to the caller, exactly once. + * + * Guarded on a non-anonymous caller rather than a registered one: the claimer + * may be an identity that has never used OISY before — that is the point of the + * feature — so requiring a user profile would defeat it. A principal is still + * required, since the payout needs somewhere to land. + * + * # Errors + * Errors are enumerated by `TipError` (`NotFound` for an unclaimable tip or a + * wrong code, `Uncovered` when the sender's allowance no longer covers it, + * `ClaimInProgress`, `TransferFailed`, `RateLimited`). + */ + claim_tip: ActorMethod<[TipClaimRequest], ClaimTipResult>; /** * Gets the canister configuration. */ @@ -2218,6 +2577,19 @@ export interface _SERVICE { [CreatePersonalNoteShareRequest], CreatePersonalNoteShareResult >; + /** + * Records a tip against an ICRC-2 allowance the caller has already granted to + * this canister under the tip's own spender subaccount. + * + * No tokens move here, and none are held: the amount stays in the caller's + * account until someone claims it, and lapses in place if nobody does. + * + * # Errors + * Errors are enumerated by `TipError` (e.g. `Uncovered` when the allowance does + * not cover the amount plus its fee, `TooManyTips`, `AmountTooSmall`, + * `InvalidExpiry`, `DuplicateTipId`, `RateLimited`). + */ + create_tip: ActorMethod<[CreateTipRequest], CancelTipResult>; /** * It creates a new user profile for the caller. * If the user has already a profile, it will return that profile. @@ -2347,6 +2719,18 @@ export interface _SERVICE { * background fetch. */ get_exchange_rates: ActorMethod<[], Array<[TokenId, [] | [ExchangeRate]]>>; + /** + * Returns the caller's own tips, newest first, for History. Bounded by + * `MAX_TIPS_RETURNED`. + * + * Not rate-limited, for the same reason as [`get_tip`]: a stateful limiter is + * a no-op on the non-certified query path. The row cap is what bounds the work + * here, and a caller can only ever read their own tips. + * + * # Errors + * Errors are enumerated by `TipError`. + */ + get_my_tips: ActorMethod<[], GetMyTipsResult>; /** * Returns the note ciphertext for a **reusable** (non-single-use), unexpired * share. A single-use share's content is only ever returned by @@ -2404,6 +2788,68 @@ export interface _SERVICE { * Errors are enumerated by `PersonalNoteError`. */ get_personal_notes_vetkey_public_key: ActorMethod<[], PersonalNotesVetkeyResult>; + /** + * Returns what an anonymous holder of a tip link may see: amount, token and + * deadline — never the message, the sender, or the claimer. + * + * Callable without an identity, deliberately: the recipient of a tip link has + * no OISY account yet, and the whole feature exists so they don't need one + * first. Same narrowly-scoped exception as `get_personal_note_share`. + * + * Not rate-limited, for the same reason that endpoint isn't: state changes + * during a query are not persisted, so a stateful limiter would be a no-op on + * the non-certified query path. The abuse surface is a cheap O(log n) lookup + * against a 128-bit id space. + * + * # Errors + * `TipError::NotFound` for anything not currently claimable — unknown, + * expired, cancelled and already-claimed are indistinguishable. + */ + get_tip: ActorMethod<[string], GetTipResult>; + /** + * Returns the claim review for a signed-in claimer: the public preview plus + * the sender's message. Requires the claim code, so the message is visible only + * to someone holding the full link. + * + * Not rate-limited, for the same reason as [`get_tip`]: a stateful limiter is + * a no-op on the non-certified query path, because state changes during a + * query are not persisted. The abuse surface is one O(log n) lookup that also + * has to guess a 128-bit claim code. + * + * # Errors + * `TipError::NotFound` for an unclaimable tip or a wrong claim code. + */ + get_tip_details: ActorMethod<[TipClaimRequest], GetTipDetailsResult>; + /** + * Derives the caller's vetKey for the tip-secrets store, secured to a + * browser-supplied transport public key. + * + * # Errors + * Errors are enumerated by `TipError` (`RateLimited`, `InternalError`). + */ + get_tip_encrypted_vetkey: ActorMethod<[Uint8Array], TipVetkeyResult>; + /** + * The caller's encrypted claim code for one of their own tips, if stored. + * + * `EncryptedMaps` keys every map by its owner, so this can only ever return + * the caller's own ciphertext. + * + * Not rate-limited, for the same reason as [`get_tip`]: a stateful limiter is + * a no-op on the non-certified query path. The work is one keyed lookup + * scoped to the caller. + * + * # Errors + * Errors are enumerated by `TipError` (`InvalidTipId`, `InternalError`). + */ + get_tip_secret: ActorMethod<[string], GetTipSecretResult>; + /** + * The vetKey verification key for the tip-secrets store. Identical for every + * caller; the browser needs it to verify its derived vetKey. + * + * # Errors + * Errors are enumerated by `TipError` (`RateLimited`, `InternalError`). + */ + get_tip_vetkey_public_key: ActorMethod<[], TipVetkeyResult>; /** * Returns the full agreement consent/rejection history for the caller. * @@ -2553,6 +2999,19 @@ export interface _SERVICE { * `NoteCiphertextTooLarge`, `RateLimited`). */ set_personal_note: ActorMethod<[PersonalNoteEntry], SetPersonalNoteResult>; + /** + * Stores the caller's encrypted claim code for one of their own tips, so they + * can recover the link after closing the share screen. + * + * The value is ciphertext the canister cannot read: the browser encrypts it + * under a vetKey only this principal can derive. Storing it changes nothing + * about who can claim the tip — the canister still only holds the code's hash. + * + * # Errors + * Errors are enumerated by `TipError` (`RateLimited`, `InvalidTipId`, + * `SecretCiphertextTooLarge`, `InternalError`). + */ + set_tip_secret: ActorMethod<[SetTipSecretRequest], CancelTipResult>; /** * Sets the user's preference to show (or hide) testnets in the interface. * diff --git a/src/declarations/backend/backend.factory.certified.did.js b/src/declarations/backend/backend.factory.certified.did.js index a32b73a8fe4..80dbe7427a5 100644 --- a/src/declarations/backend/backend.factory.certified.did.js +++ b/src/declarations/backend/backend.factory.certified.did.js @@ -184,6 +184,36 @@ export const idlFactory = ({ IDL }) => { Ok: BtcGetPendingTransactionsReponse, Err: BtcGetPendingTransactionsError }); + const TipError = IDL.Variant({ + InvalidExpiry: IDL.Null, + ClaimInProgress: IDL.Null, + SecretCiphertextTooLarge: IDL.Null, + Uncovered: IDL.Null, + NotFound: IDL.Null, + NotYourTip: IDL.Null, + InvalidClaimCodeHash: IDL.Null, + InvalidTipId: IDL.Null, + RateLimited: RateLimitError, + DuplicateTipId: IDL.Null, + NotCancellable: IDL.Null, + TransferFailed: IDL.Record({ msg: IDL.Text }), + InternalError: IDL.Record({ msg: IDL.Text }), + MessageTooLong: IDL.Null, + TooManyTips: IDL.Null, + InsufficientFunds: IDL.Null, + AmountTooSmall: IDL.Null + }); + const CancelTipResult = IDL.Variant({ Ok: IDL.Null, Err: TipError }); + const TipClaimRequest = IDL.Record({ + tip_id: IDL.Text, + claim_code: IDL.Text + }); + const TipClaim = IDL.Record({ + block_index: IDL.Nat, + ledger_canister_id: IDL.Principal, + amount: IDL.Nat + }); + const ClaimTipResult = IDL.Variant({ Ok: TipClaim, Err: TipError }); const Config = IDL.Record({ derivation_origin: IDL.Opt(IDL.Text), ii_canister_id: IDL.Opt(IDL.Principal), @@ -413,6 +443,14 @@ export const idlFactory = ({ IDL }) => { Ok: IDL.Null, Err: PersonalNoteShareError }); + const CreateTipRequest = IDL.Record({ + tip_id: IDL.Text, + claim_code_hash: IDL.Vec(IDL.Nat8), + message: IDL.Opt(IDL.Text), + ledger_canister_id: IDL.Principal, + amount: IDL.Nat, + expires_at_ns: IDL.Nat64 + }); const UserAgreement = IDL.Record({ last_accepted_at_ns: IDL.Opt(IDL.Nat64), text_sha256: IDL.Opt(IDL.Text), @@ -587,6 +625,37 @@ export const idlFactory = ({ IDL }) => { price: IDL.Opt(IDL.Float64) }); const ExchangeRate = IDL.Record({ usd: ExchangeData }); + const TipStatus = IDL.Variant({ + Failed: IDL.Null, + Reserved: IDL.Null, + Claimed: IDL.Null, + Cancelled: IDL.Null, + Expired: IDL.Null + }); + const TipClaimFailureReason = IDL.Variant({ + Uncovered: IDL.Null, + TransferFailed: IDL.Null, + InsufficientFunds: IDL.Null + }); + const TipClaimFailure = IDL.Record({ + at_ns: IDL.Nat64, + reason: TipClaimFailureReason + }); + const MyTip = IDL.Record({ + status: TipStatus, + claimed_by: IDL.Opt(IDL.Principal), + tip_id: IDL.Text, + last_claim_failure: IDL.Opt(TipClaimFailure), + created_at_ns: IDL.Nat64, + message: IDL.Opt(IDL.Text), + ledger_canister_id: IDL.Principal, + amount: IDL.Nat, + expires_at_ns: IDL.Nat64 + }); + const GetMyTipsResult = IDL.Variant({ + Ok: IDL.Vec(MyTip), + Err: TipError + }); const GetPersonalNoteShareResult = IDL.Variant({ Ok: PersonalNoteShareContent, Err: PersonalNoteShareError @@ -611,6 +680,30 @@ export const idlFactory = ({ IDL }) => { Ok: IDL.Vec(IDL.Nat8), Err: PersonalNoteError }); + const PublicTip = IDL.Record({ + ledger_canister_id: IDL.Principal, + amount: IDL.Nat, + expires_at_ns: IDL.Nat64 + }); + const GetTipResult = IDL.Variant({ Ok: PublicTip, Err: TipError }); + const TipDetails = IDL.Record({ + message: IDL.Opt(IDL.Text), + ledger_canister_id: IDL.Principal, + amount: IDL.Nat, + expires_at_ns: IDL.Nat64 + }); + const GetTipDetailsResult = IDL.Variant({ + Ok: TipDetails, + Err: TipError + }); + const TipVetkeyResult = IDL.Variant({ + Ok: IDL.Vec(IDL.Nat8), + Err: TipError + }); + const GetTipSecretResult = IDL.Variant({ + Ok: IDL.Opt(IDL.Vec(IDL.Nat8)), + Err: TipError + }); const AgreementType = IDL.Variant({ TermsOfUse: IDL.Null, PrivacyPolicy: IDL.Null, @@ -767,6 +860,10 @@ export const idlFactory = ({ IDL }) => { Ok: IDL.Null, Err: PersonalNoteError }); + const SetTipSecretRequest = IDL.Record({ + tip_id: IDL.Text, + encrypted_claim_code: IDL.Vec(IDL.Nat8) + }); const SetShowTestnetsRequest = IDL.Record({ current_user_version: IDL.Opt(IDL.Nat64), show_testnets: IDL.Bool @@ -807,6 +904,7 @@ export const idlFactory = ({ IDL }) => { token_activity_count: IDL.Nat64, personal_note_shares_count: IDL.Nat64, agreement_history_count: IDL.Nat64, + tips_count: IDL.Nat64, personal_notes_count: IDL.Nat64, user_timestamps_count: IDL.Nat64, user_token_count: IDL.Nat64 @@ -889,6 +987,8 @@ export const idlFactory = ({ IDL }) => { [BtcGetPendingTransactionsResult], [] ), + cancel_tip: IDL.Func([IDL.Text], [CancelTipResult], []), + claim_tip: IDL.Func([TipClaimRequest], [ClaimTipResult], []), config: IDL.Func([], [Config]), consume_personal_note_share: IDL.Func([IDL.Text], [ConsumePersonalNoteShareResult], []), create_active_user_transaction: IDL.Func( @@ -902,6 +1002,7 @@ export const idlFactory = ({ IDL }) => { [CreatePersonalNoteShareResult], [] ), + create_tip: IDL.Func([CreateTipRequest], [CancelTipResult], []), create_user_profile: IDL.Func([], [CreateUserProfileResult], []), delete_active_user_transaction: IDL.Func([IDL.Text], [DeleteActiveUserTransactionResult], []), delete_contact: IDL.Func([IDL.Nat64], [DeleteContactResult], []), @@ -916,6 +1017,7 @@ export const idlFactory = ({ IDL }) => { get_contacts: IDL.Func([], [GetContactsResult]), get_exchange_rate: IDL.Func([TokenId], [IDL.Opt(ExchangeRate)]), get_exchange_rates: IDL.Func([], [IDL.Vec(IDL.Tuple(TokenId, IDL.Opt(ExchangeRate)))], []), + get_my_tips: IDL.Func([], [GetMyTipsResult]), get_personal_note_share: IDL.Func([IDL.Text], [GetPersonalNoteShareResult]), get_personal_note_shares_count: IDL.Func([], [GetPersonalNoteSharesCountResult]), get_personal_notes: IDL.Func([], [GetPersonalNotesResult]), @@ -926,6 +1028,11 @@ export const idlFactory = ({ IDL }) => { [] ), get_personal_notes_vetkey_public_key: IDL.Func([], [PersonalNotesVetkeyResult], []), + get_tip: IDL.Func([IDL.Text], [GetTipResult]), + get_tip_details: IDL.Func([TipClaimRequest], [GetTipDetailsResult]), + get_tip_encrypted_vetkey: IDL.Func([IDL.Vec(IDL.Nat8)], [TipVetkeyResult], []), + get_tip_secret: IDL.Func([IDL.Text], [GetTipSecretResult]), + get_tip_vetkey_public_key: IDL.Func([], [TipVetkeyResult], []), get_user_agreement_history: IDL.Func([], [GetAgreementHistoryResult]), get_user_profile: IDL.Func([], [GetUserProfileResult]), get_user_transactions: IDL.Func([GetUserTransactionsRequest], [GetUserTransactionsResult]), @@ -948,6 +1055,7 @@ export const idlFactory = ({ IDL }) => { set_new_user_signups_allowed: IDL.Func([IDL.Bool], [], []), set_onramper_signing_secret: IDL.Func([IDL.Opt(IDL.Text)], [], []), set_personal_note: IDL.Func([PersonalNoteEntry], [SetPersonalNoteResult], []), + set_tip_secret: IDL.Func([SetTipSecretRequest], [CancelTipResult], []), set_user_show_testnets: IDL.Func([SetShowTestnetsRequest], [SetUserShowTestnetsResult], []), sign_onramper_widget_url: IDL.Func( [SignOnramperWidgetUrlRequest], diff --git a/src/declarations/backend/backend.factory.did.js b/src/declarations/backend/backend.factory.did.js index 243b9537ede..a48506ee307 100644 --- a/src/declarations/backend/backend.factory.did.js +++ b/src/declarations/backend/backend.factory.did.js @@ -184,6 +184,36 @@ export const idlFactory = ({ IDL }) => { Ok: BtcGetPendingTransactionsReponse, Err: BtcGetPendingTransactionsError }); + const TipError = IDL.Variant({ + InvalidExpiry: IDL.Null, + ClaimInProgress: IDL.Null, + SecretCiphertextTooLarge: IDL.Null, + Uncovered: IDL.Null, + NotFound: IDL.Null, + NotYourTip: IDL.Null, + InvalidClaimCodeHash: IDL.Null, + InvalidTipId: IDL.Null, + RateLimited: RateLimitError, + DuplicateTipId: IDL.Null, + NotCancellable: IDL.Null, + TransferFailed: IDL.Record({ msg: IDL.Text }), + InternalError: IDL.Record({ msg: IDL.Text }), + MessageTooLong: IDL.Null, + TooManyTips: IDL.Null, + InsufficientFunds: IDL.Null, + AmountTooSmall: IDL.Null + }); + const CancelTipResult = IDL.Variant({ Ok: IDL.Null, Err: TipError }); + const TipClaimRequest = IDL.Record({ + tip_id: IDL.Text, + claim_code: IDL.Text + }); + const TipClaim = IDL.Record({ + block_index: IDL.Nat, + ledger_canister_id: IDL.Principal, + amount: IDL.Nat + }); + const ClaimTipResult = IDL.Variant({ Ok: TipClaim, Err: TipError }); const Config = IDL.Record({ derivation_origin: IDL.Opt(IDL.Text), ii_canister_id: IDL.Opt(IDL.Principal), @@ -413,6 +443,14 @@ export const idlFactory = ({ IDL }) => { Ok: IDL.Null, Err: PersonalNoteShareError }); + const CreateTipRequest = IDL.Record({ + tip_id: IDL.Text, + claim_code_hash: IDL.Vec(IDL.Nat8), + message: IDL.Opt(IDL.Text), + ledger_canister_id: IDL.Principal, + amount: IDL.Nat, + expires_at_ns: IDL.Nat64 + }); const UserAgreement = IDL.Record({ last_accepted_at_ns: IDL.Opt(IDL.Nat64), text_sha256: IDL.Opt(IDL.Text), @@ -587,6 +625,37 @@ export const idlFactory = ({ IDL }) => { price: IDL.Opt(IDL.Float64) }); const ExchangeRate = IDL.Record({ usd: ExchangeData }); + const TipStatus = IDL.Variant({ + Failed: IDL.Null, + Reserved: IDL.Null, + Claimed: IDL.Null, + Cancelled: IDL.Null, + Expired: IDL.Null + }); + const TipClaimFailureReason = IDL.Variant({ + Uncovered: IDL.Null, + TransferFailed: IDL.Null, + InsufficientFunds: IDL.Null + }); + const TipClaimFailure = IDL.Record({ + at_ns: IDL.Nat64, + reason: TipClaimFailureReason + }); + const MyTip = IDL.Record({ + status: TipStatus, + claimed_by: IDL.Opt(IDL.Principal), + tip_id: IDL.Text, + last_claim_failure: IDL.Opt(TipClaimFailure), + created_at_ns: IDL.Nat64, + message: IDL.Opt(IDL.Text), + ledger_canister_id: IDL.Principal, + amount: IDL.Nat, + expires_at_ns: IDL.Nat64 + }); + const GetMyTipsResult = IDL.Variant({ + Ok: IDL.Vec(MyTip), + Err: TipError + }); const GetPersonalNoteShareResult = IDL.Variant({ Ok: PersonalNoteShareContent, Err: PersonalNoteShareError @@ -611,6 +680,30 @@ export const idlFactory = ({ IDL }) => { Ok: IDL.Vec(IDL.Nat8), Err: PersonalNoteError }); + const PublicTip = IDL.Record({ + ledger_canister_id: IDL.Principal, + amount: IDL.Nat, + expires_at_ns: IDL.Nat64 + }); + const GetTipResult = IDL.Variant({ Ok: PublicTip, Err: TipError }); + const TipDetails = IDL.Record({ + message: IDL.Opt(IDL.Text), + ledger_canister_id: IDL.Principal, + amount: IDL.Nat, + expires_at_ns: IDL.Nat64 + }); + const GetTipDetailsResult = IDL.Variant({ + Ok: TipDetails, + Err: TipError + }); + const TipVetkeyResult = IDL.Variant({ + Ok: IDL.Vec(IDL.Nat8), + Err: TipError + }); + const GetTipSecretResult = IDL.Variant({ + Ok: IDL.Opt(IDL.Vec(IDL.Nat8)), + Err: TipError + }); const AgreementType = IDL.Variant({ TermsOfUse: IDL.Null, PrivacyPolicy: IDL.Null, @@ -767,6 +860,10 @@ export const idlFactory = ({ IDL }) => { Ok: IDL.Null, Err: PersonalNoteError }); + const SetTipSecretRequest = IDL.Record({ + tip_id: IDL.Text, + encrypted_claim_code: IDL.Vec(IDL.Nat8) + }); const SetShowTestnetsRequest = IDL.Record({ current_user_version: IDL.Opt(IDL.Nat64), show_testnets: IDL.Bool @@ -807,6 +904,7 @@ export const idlFactory = ({ IDL }) => { token_activity_count: IDL.Nat64, personal_note_shares_count: IDL.Nat64, agreement_history_count: IDL.Nat64, + tips_count: IDL.Nat64, personal_notes_count: IDL.Nat64, user_timestamps_count: IDL.Nat64, user_token_count: IDL.Nat64 @@ -890,6 +988,8 @@ export const idlFactory = ({ IDL }) => { [BtcGetPendingTransactionsResult], [] ), + cancel_tip: IDL.Func([IDL.Text], [CancelTipResult], []), + claim_tip: IDL.Func([TipClaimRequest], [ClaimTipResult], []), config: IDL.Func([], [Config], ['query']), consume_personal_note_share: IDL.Func([IDL.Text], [ConsumePersonalNoteShareResult], []), create_active_user_transaction: IDL.Func( @@ -903,6 +1003,7 @@ export const idlFactory = ({ IDL }) => { [CreatePersonalNoteShareResult], [] ), + create_tip: IDL.Func([CreateTipRequest], [CancelTipResult], []), create_user_profile: IDL.Func([], [CreateUserProfileResult], []), delete_active_user_transaction: IDL.Func([IDL.Text], [DeleteActiveUserTransactionResult], []), delete_contact: IDL.Func([IDL.Nat64], [DeleteContactResult], []), @@ -921,6 +1022,7 @@ export const idlFactory = ({ IDL }) => { get_contacts: IDL.Func([], [GetContactsResult], ['query']), get_exchange_rate: IDL.Func([TokenId], [IDL.Opt(ExchangeRate)], ['query']), get_exchange_rates: IDL.Func([], [IDL.Vec(IDL.Tuple(TokenId, IDL.Opt(ExchangeRate)))], []), + get_my_tips: IDL.Func([], [GetMyTipsResult], ['query']), get_personal_note_share: IDL.Func([IDL.Text], [GetPersonalNoteShareResult], ['query']), get_personal_note_shares_count: IDL.Func([], [GetPersonalNoteSharesCountResult], ['query']), get_personal_notes: IDL.Func([], [GetPersonalNotesResult], ['query']), @@ -931,6 +1033,11 @@ export const idlFactory = ({ IDL }) => { [] ), get_personal_notes_vetkey_public_key: IDL.Func([], [PersonalNotesVetkeyResult], []), + get_tip: IDL.Func([IDL.Text], [GetTipResult], ['query']), + get_tip_details: IDL.Func([TipClaimRequest], [GetTipDetailsResult], ['query']), + get_tip_encrypted_vetkey: IDL.Func([IDL.Vec(IDL.Nat8)], [TipVetkeyResult], []), + get_tip_secret: IDL.Func([IDL.Text], [GetTipSecretResult], ['query']), + get_tip_vetkey_public_key: IDL.Func([], [TipVetkeyResult], []), get_user_agreement_history: IDL.Func([], [GetAgreementHistoryResult], ['query']), get_user_profile: IDL.Func([], [GetUserProfileResult], ['query']), get_user_transactions: IDL.Func( @@ -957,6 +1064,7 @@ export const idlFactory = ({ IDL }) => { set_new_user_signups_allowed: IDL.Func([IDL.Bool], [], []), set_onramper_signing_secret: IDL.Func([IDL.Opt(IDL.Text)], [], []), set_personal_note: IDL.Func([PersonalNoteEntry], [SetPersonalNoteResult], []), + set_tip_secret: IDL.Func([SetTipSecretRequest], [CancelTipResult], []), set_user_show_testnets: IDL.Func([SetShowTestnetsRequest], [SetUserShowTestnetsResult], []), sign_onramper_widget_url: IDL.Func( [SignOnramperWidgetUrlRequest], diff --git a/src/shared/src/types.rs b/src/shared/src/types.rs index 04ea731fce4..f7e6aa18457 100644 --- a/src/shared/src/types.rs +++ b/src/shared/src/types.rs @@ -26,6 +26,7 @@ pub mod pow; pub mod result_types; pub mod settings; pub mod signer; +pub mod tip; pub mod token; pub mod token_id; pub mod token_standard; @@ -71,4 +72,9 @@ pub struct Stats { /// Total number of stored personal-note shares across all users (active or /// not yet pruned). pub personal_note_shares_count: u64, + /// Total number of tips ever created and not yet pruned, across all users + /// and every status. Aggregate only, deliberately: the negative guarantee + /// that no endpoint enumerates another principal's tips holds for this one + /// too, so there is a count here and never a row. + pub tips_count: u64, } diff --git a/src/shared/src/types/result_types.rs b/src/shared/src/types/result_types.rs index 08652cdb43c..c3a09ddf007 100644 --- a/src/shared/src/types/result_types.rs +++ b/src/shared/src/types/result_types.rs @@ -25,6 +25,7 @@ use crate::types::{ onramper::{SignOnramperWidgetUrlError, SignOnramperWidgetUrlResponse}, personal_note::{PersonalNoteEntry, PersonalNoteError}, personal_note_share::{PersonalNoteShareContent, PersonalNoteShareError}, + tip::{MyTip, PublicTip, TipClaim, TipDetails, TipError}, transaction_settings::UpdateTransactionFilterSettingsError, user_transaction::{GetUserTransactionsResponse, UserTransactionError}, }; @@ -608,3 +609,134 @@ impl From> for GetPersonalNoteSharesCountRes } } } + +#[derive(CandidType, Deserialize, Clone, Eq, PartialEq, Debug)] +pub enum CreateTipResult { + Ok(()), + Err(TipError), +} +impl From> for CreateTipResult { + fn from(result: Result<(), TipError>) -> Self { + match result { + Ok(()) => CreateTipResult::Ok(()), + Err(err) => CreateTipResult::Err(err), + } + } +} + +#[derive(CandidType, Deserialize, Clone, Eq, PartialEq, Debug)] +pub enum GetTipResult { + Ok(PublicTip), + Err(TipError), +} +impl From> for GetTipResult { + fn from(result: Result) -> Self { + match result { + Ok(tip) => GetTipResult::Ok(tip), + Err(err) => GetTipResult::Err(err), + } + } +} + +#[derive(CandidType, Deserialize, Clone, Eq, PartialEq, Debug)] +pub enum GetTipDetailsResult { + Ok(TipDetails), + Err(TipError), +} +impl From> for GetTipDetailsResult { + fn from(result: Result) -> Self { + match result { + Ok(details) => GetTipDetailsResult::Ok(details), + Err(err) => GetTipDetailsResult::Err(err), + } + } +} + +#[derive(CandidType, Deserialize, Clone, Eq, PartialEq, Debug)] +pub enum ClaimTipResult { + Ok(TipClaim), + Err(TipError), +} +impl From> for ClaimTipResult { + fn from(result: Result) -> Self { + match result { + Ok(claim) => ClaimTipResult::Ok(claim), + Err(err) => ClaimTipResult::Err(err), + } + } +} + +#[derive(CandidType, Deserialize, Clone, Eq, PartialEq, Debug)] +pub enum CancelTipResult { + Ok(()), + Err(TipError), +} +impl From> for CancelTipResult { + fn from(result: Result<(), TipError>) -> Self { + match result { + Ok(()) => CancelTipResult::Ok(()), + Err(err) => CancelTipResult::Err(err), + } + } +} + +/// vetKey material for the tip-secrets store, or why it could not be derived. +#[derive(CandidType, Deserialize, Clone, Debug, Eq, PartialEq)] +pub enum TipVetkeyResult { + /// vetKey bytes, opaque to the canister. + Ok(ByteBuf), + Err(TipError), +} +impl From> for TipVetkeyResult { + fn from(result: Result) -> Self { + match result { + Ok(vetkey) => TipVetkeyResult::Ok(vetkey), + Err(err) => TipVetkeyResult::Err(err), + } + } +} + +/// The caller's encrypted claim code for one tip. `Ok(None)` means no secret is +/// stored — a tip from before the store existed, or one already cleaned up. +#[derive(CandidType, Deserialize, Clone, Debug, Eq, PartialEq)] +pub enum GetTipSecretResult { + Ok(Option), + Err(TipError), +} +impl From, TipError>> for GetTipSecretResult { + fn from(result: Result, TipError>) -> Self { + match result { + Ok(secret) => GetTipSecretResult::Ok(secret), + Err(err) => GetTipSecretResult::Err(err), + } + } +} + +/// Outcome of storing an encrypted claim code. +#[derive(CandidType, Deserialize, Clone, Debug, Eq, PartialEq)] +pub enum SetTipSecretResult { + Ok, + Err(TipError), +} +impl From> for SetTipSecretResult { + fn from(result: Result<(), TipError>) -> Self { + match result { + Ok(()) => SetTipSecretResult::Ok, + Err(err) => SetTipSecretResult::Err(err), + } + } +} + +#[derive(CandidType, Deserialize, Clone, Eq, PartialEq, Debug)] +pub enum GetMyTipsResult { + Ok(Vec), + Err(TipError), +} +impl From, TipError>> for GetMyTipsResult { + fn from(result: Result, TipError>) -> Self { + match result { + Ok(tips) => GetMyTipsResult::Ok(tips), + Err(err) => GetMyTipsResult::Err(err), + } + } +} diff --git a/src/shared/src/types/tip.rs b/src/shared/src/types/tip.rs new file mode 100644 index 00000000000..2670576b430 --- /dev/null +++ b/src/shared/src/types/tip.rs @@ -0,0 +1,283 @@ +//! Types for **sending a tip via link or QR code**: a sender sets aside an +//! amount of an ICRC-2 token, and whoever opens the link claims it into their +//! own wallet. +//! +//! The defining property is that **the canister never holds the tokens**. A tip +//! is an ICRC-2 allowance granted to this canister under a per-tip spender +//! subaccount, so the funds stay in the sender's account until a claim moves +//! them, and an unclaimed tip lapses on the ledger with nothing to refund. See +//! `docs/ai/spec-driven-development/specs/2026-08-05-feat-tips-via-link.md`. + +use candid::{CandidType, Deserialize, Nat, Principal}; +use serde_bytes::ByteBuf; + +use super::signer::RateLimitError; + +/// Maximum number of *active* (unexpired, unclaimed, uncancelled) tips one +/// sender may have outstanding. Each active tip holds an allowance against the +/// sender's own balance, so the cap bounds how much of their balance a single +/// user can encumber through this feature — and bounds our stored volume the +/// way [`super::personal_note_share::MAX_PERSONAL_NOTE_SHARES_PER_USER`] does. +pub const MAX_TIPS_PER_USER: usize = 100; + +/// Upper bound on the stored `tip_id`, in bytes. The client generates a +/// 128-bit random id, base64url-encoded (22 ASCII characters); this is +/// generous headroom rather than a protocol-locked length. `u32` because it +/// feeds `ic_stable_structures::storable::Bound::Bounded.max_size` directly. +pub const MAX_TIP_ID_BYTES: u32 = 64; + +/// Maximum length of the optional sender message, in **characters** (not +/// bytes) — it is user-facing text, and the design specifies 250 characters. +pub const MAX_TIP_MESSAGE_CHARS: usize = 250; + +/// Byte length of the stored claim-code hash: SHA-256 of the claim code that +/// lives only in the link fragment. The code itself never reaches the canister +/// and no endpoint returns the hash. +pub const TIP_CLAIM_CODE_HASH_BYTES: usize = 32; + +/// How much further into the future than IC time a tip's `expires_at_ns` may +/// be set (7 days — the longest expiry option in the creator UI). +/// Defense-in-depth against a client bypassing the UI to encumber a balance +/// indefinitely. +/// Largest accepted encrypted-claim-code blob. A claim code is 16 random bytes +/// base64url-encoded; AES-GCM adds a nonce and a tag, so the ciphertext is well +/// under 100 bytes. The cap is generous but bounded — the point is that this +/// store can never be used as general-purpose storage. +pub const MAX_TIP_SECRET_CIPHERTEXT_BYTES: usize = 512; + +pub const MAX_TIP_EXPIRY_NS: u64 = 7 * 24 * 60 * 60 * 1_000_000_000; + +/// How long a claim may stay in flight before another claimer may take it over. +/// +/// A claim flips the record to "claiming" *before* awaiting the ledger, so two +/// concurrent claims can never both pay out. If the canister is upgraded +/// between that write and the ledger's reply, the record would otherwise be +/// stranded in "claiming" forever. After this window a fresh claim may retry: +/// the ledger is the authority on whether the earlier transfer happened, and a +/// consumed allowance makes the retry fail rather than double-pay. +pub const TIP_CLAIM_IN_FLIGHT_TIMEOUT_NS: u64 = 5 * 60 * 1_000_000_000; + +/// How long a terminal tip (claimed, cancelled, or lapsed) is kept before the +/// housekeeping sweep removes it. +/// +/// Unlike a note share — which is pure transient state and can go the moment it +/// expires — a finished tip is a **History row** the sender is entitled to see. +/// Keeping it for a month, then dropping it, bounds storage without erasing the +/// record of where someone's money went the day after it moved. +pub const TIP_RETENTION_AFTER_TERMINAL_NS: u64 = 30 * 24 * 60 * 60 * 1_000_000_000; + +/// Upper bound on how many of a sender's tips `get_my_tips` returns, newest +/// first. Retention keeps terminal rows around, so a heavy user's History can +/// outgrow a single response; this bounds it explicitly rather than letting the +/// call fail at the message-size limit. +pub const MAX_TIPS_RETURNED: usize = 200; + +/// Create-tip request. The canister stores the tip and verifies the sender's +/// allowance covers it; it never takes custody. +#[derive(CandidType, Deserialize, Clone, Debug, Eq, PartialEq)] +pub struct CreateTipRequest { + /// Opaque, client-generated random id; also the map key and the `` in + /// the share link. + pub tip_id: String, + /// Ledger the tip is denominated in. Must be ICRC-2 capable — the sender's + /// allowance is what makes the tip claimable. + pub ledger_canister_id: Principal, + /// What the claimer receives, in the ledger's base units. The sender's + /// allowance must additionally cover one ledger fee, which the ledger + /// draws from the allowance at claim rather than from this amount. + pub amount: Nat, + pub expires_at_ns: u64, + /// Optional note shown to the claimer *after* they sign in — never in the + /// anonymous preview. + pub message: Option, + /// SHA-256 of the claim code held in the link fragment. + pub claim_code_hash: ByteBuf, +} + +/// Identifies a tip **and** proves the caller holds its link. +/// +/// One type for both `get_tip_details` and `claim_tip` on purpose: reading the +/// claim review and claiming are the same claim of authority, differing only in +/// whether they move money. Two structurally identical records would also +/// collapse into one name in the generated candid anyway — better to say so than +/// to have the interface say it for us. +#[derive(CandidType, Deserialize, Clone, Debug, Eq, PartialEq)] +pub struct TipClaimRequest { + pub tip_id: String, + /// The plaintext code from the link fragment. Only ever compared against the + /// stored hash; never persisted, never returned. + pub claim_code: String, +} + +/// What an **anonymous** reader of a tip link sees. Deliberately excludes the +/// message, the sender, and the claimer: enough to decide whether to sign in, +/// nothing that identifies anyone. +#[derive(CandidType, Deserialize, Clone, Debug, Eq, PartialEq)] +pub struct PublicTip { + pub ledger_canister_id: Principal, + pub amount: Nat, + pub expires_at_ns: u64, +} + +/// What an authenticated claimer sees before claiming: the preview plus the +/// sender's message. The payout fee is not included — the client reads it from +/// the ledger directly (`icrc1_fee`), which is also the value the ledger will +/// actually charge. +#[derive(CandidType, Deserialize, Clone, Debug, Eq, PartialEq)] +pub struct TipDetails { + pub ledger_canister_id: Principal, + pub amount: Nat, + pub expires_at_ns: u64, + pub message: Option, +} + +/// Lifecycle as the **sender** sees it in History. +/// +/// `Uncovered` is deliberately absent: it is not a stored state but the +/// outcome of a claim attempt against an allowance the sender has since spent, +/// reduced or revoked. Reporting it here would mean querying every tip's +/// allowance on every History read; it surfaces on the claim path instead, as +/// [`TipError::Uncovered`]. +#[derive(CandidType, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +pub enum TipStatus { + /// Funds are authorised in the sender's own account, waiting for a claimer. + Reserved, + /// Somebody tried to claim and the payout did not go through, and the tip is + /// still live. The code stays valid, so this is the one status the sender can + /// act on — typically by topping up the account the tip draws from. + /// + /// Distinct from `Reserved` precisely because it is actionable: without it a + /// tip nobody has touched and a tip that has already failed a claimer look + /// identical in History. + Failed, + /// A claimer moved the tokens. + Claimed, + /// The deadline passed unclaimed. Nothing was ever transferred, so nothing + /// is returned — the allowance simply lapsed on the ledger. + Expired, + /// The sender revoked it before anyone claimed. + Cancelled, +} + +/// Why a claim attempt did not pay out. +/// +/// Three outcomes, and the split is by what the sender can do about it. +/// `Uncovered` is the reservation having been reduced or revoked, so the link is +/// dead and only a new tip fixes it. `InsufficientFunds` is the reservation +/// standing but the money not being there, so the same link works again once +/// they top up. `TransferFailed` is everything else — the ledger refusing or +/// failing to answer — where retrying is the whole advice. +#[derive(CandidType, Deserialize, Clone, Debug, Eq, PartialEq)] +pub enum TipClaimFailureReason { + Uncovered, + /// The sender's account no longer holds the amount. Distinct from + /// `Uncovered`: the reservation is still granted, the money is simply not + /// there, so topping up makes the same link work again. + InsufficientFunds, + TransferFailed, +} + +/// The most recent failed claim on a tip. Returned only to the tip's own sender. +/// +/// Deliberately not the ledger's error text: that is written for an operator, it +/// can name balances, and it has no business being rendered to a user. +#[derive(CandidType, Deserialize, Clone, Debug, Eq, PartialEq)] +pub struct TipClaimFailure { + pub at_ns: u64, + pub reason: TipClaimFailureReason, +} + +/// One of the caller's own tips, as returned by `get_my_tips`. +#[derive(CandidType, Deserialize, Clone, Debug, Eq, PartialEq)] +pub struct MyTip { + pub tip_id: String, + pub ledger_canister_id: Principal, + pub amount: Nat, + pub expires_at_ns: u64, + pub created_at_ns: u64, + pub status: TipStatus, + pub message: Option, + /// Set once claimed. The sender learns who claimed their tip; the claim + /// screen discloses this before the claimer commits. + pub claimed_by: Option, + /// The most recent claim that did not pay out, if any. Present alongside + /// `status = Failed` for a live tip, and kept afterwards so a tip that + /// eventually succeeded can still show it was not first time lucky. + pub last_claim_failure: Option, +} + +/// Outcome of a successful claim. +#[derive(CandidType, Deserialize, Clone, Debug, Eq, PartialEq)] +pub struct TipClaim { + pub ledger_canister_id: Principal, + /// What was transferred to the claimer, in base units. + pub amount: Nat, + /// Ledger block index of the payout, so the client can link to it. + pub block_index: Nat, +} + +/// Stores the sender's own encrypted claim code so they can recover the link. +/// +/// A single request struct rather than two arguments, matching +/// `SetPersonalNoteRequest`: it keeps the candid signature stable if the store +/// ever needs another field. +#[derive(CandidType, Deserialize, Clone, Debug, Eq, PartialEq)] +pub struct SetTipSecretRequest { + pub tip_id: String, + /// AES-GCM ciphertext of the claim code, opaque to the canister. Bounded by + /// [`MAX_TIP_SECRET_CIPHERTEXT_BYTES`]. + pub encrypted_claim_code: ByteBuf, +} + +#[derive(CandidType, Deserialize, Clone, Debug, Eq, PartialEq)] +pub enum TipError { + /// The `tip_id` is empty or exceeds [`MAX_TIP_ID_BYTES`]. + InvalidTipId, + /// The `claim_code_hash` is not exactly [`TIP_CLAIM_CODE_HASH_BYTES`] long. + InvalidClaimCodeHash, + /// `message` exceeds [`MAX_TIP_MESSAGE_CHARS`] characters. + MessageTooLong, + /// `expires_at_ns` is not strictly in the future of IC time, or is further + /// out than [`MAX_TIP_EXPIRY_NS`]. + InvalidExpiry, + /// The amount is zero, or below one ledger fee — a tip that cannot cover + /// its own payout is not a tip. + AmountTooSmall, + /// The `tip_id` already exists; the client should generate a fresh random + /// id and retry. + DuplicateTipId, + /// The caller already holds [`MAX_TIPS_PER_USER`] active tips. + TooManyTips, + /// No claimable tip for this id. Also returned for an expired, cancelled or + /// already-claimed tip, and for a wrong claim code — every case collapsed + /// into one response so a prober can never distinguish them. + NotFound, + /// The tip exists and the claim code is right, but the sender's allowance + /// no longer covers it — they spent, reduced or revoked it. The one + /// deliberately distinguishable failure, reachable only with a valid link, + /// because telling the claimer "come back later" is useless. + Uncovered, + /// The sender's account no longer holds the amount. The reservation is still + /// granted and the claim code is still valid, so the same link works again + /// once they top up — which is why this is not folded into `TransferFailed`. + InsufficientFunds, + /// A claim is already in flight for this tip. Resolves on its own: either + /// it completes, or [`TIP_CLAIM_IN_FLIGHT_TIMEOUT_NS`] passes and a retry + /// may take it over. + ClaimInProgress, + /// The caller is not the sender of this tip. + NotYourTip, + /// Only a `Reserved` tip can be cancelled. + NotCancellable, + /// The encrypted claim code exceeds [`MAX_TIP_SECRET_CIPHERTEXT_BYTES`]. + SecretCiphertextTooLarge, + /// The ledger rejected or failed to answer the payout. The tip stays + /// claimable — nothing was transferred. + TransferFailed { + msg: String, + }, + RateLimited(RateLimitError), + InternalError { + msg: String, + }, +}