Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
3310dd5
feat(backend): record and claim tips through an ICRC-2 allowance
artkorotkikh-dfinity Aug 25, 2026
8f5a818
docs(ai): state plainly that the sender pays both tip fees
artkorotkikh-dfinity Aug 25, 2026
34b6e07
fix(backend): regenerate the tip candid after the request-record change
artkorotkikh-dfinity Aug 25, 2026
8497d41
feat(backend): recoverable tip links, and a tips counter
artkorotkikh-dfinity Aug 26, 2026
e2c02a2
docs(ai): record link recovery and the tips counter in the spec
artkorotkikh-dfinity Aug 26, 2026
39aacf6
docs(ai): record that decision 15 was never built
artkorotkikh-dfinity Aug 26, 2026
b4b2b27
fix(backend): keep claimed and cancelled tips in History for their re…
artkorotkikh-dfinity Aug 27, 2026
baeaed8
fix(backend): stop the tip vetKey limits throttling ordinary use
artkorotkikh-dfinity Aug 27, 2026
d4a5219
feat(backend): give the tip endpoints a global rate-limit tier
artkorotkikh-dfinity Aug 27, 2026
82fb171
feat(backend): record a failed claim so History can show it as Failed
artkorotkikh-dfinity Aug 27, 2026
414caeb
feat(backend): tell a claimer when the sender's balance is short
artkorotkikh-dfinity Aug 27, 2026
ee4dcab
fix(backend): move the tip-secrets store to a fresh memory region
artkorotkikh-dfinity Aug 27, 2026
deee439
fix(backend): release stored claim codes, and rate-limit storing them
artkorotkikh-dfinity Aug 28, 2026
33ff892
style(frontend): format the hand-edited backend declarations
artkorotkikh-dfinity Aug 28, 2026
edc5000
fix(backend): move the tips memories off an id main now uses
artkorotkikh-dfinity Aug 29, 2026
6935aac
Merge branch 'main' into feat/tips-1-backend
artkorotkikh-dfinity Aug 29, 2026
ad0521c
test(backend): prove a tip survives an upgrade and still pays exactly…
artkorotkikh-dfinity Aug 30, 2026
fdf5c23
🤖 Apply bindings changes
github-actions[bot] Aug 30, 2026
0be17cb
fix(backend): reject an empty tip id in the secrets store
artkorotkikh-dfinity Aug 30, 2026
fa54bdb
Merge branch 'feat/tips-1-backend' of https://github.com/dfinity/oisy…
artkorotkikh-dfinity Aug 30, 2026
323ef07
docs(backend): describe all three claim-failure outcomes
artkorotkikh-dfinity Aug 30, 2026
5d9175c
🤖 Apply bindings changes
github-actions[bot] Aug 30, 2026
197ecc7
test(backend): stop the upgrade test depending on the scheduler
artkorotkikh-dfinity Aug 30, 2026
df19b14
test(backend): stop the upgrade test depending on the scheduler
artkorotkikh-dfinity Aug 30, 2026
091641a
Merge branch 'feat/tips-1-backend' of https://github.com/dfinity/oisy…
artkorotkikh-dfinity Aug 30, 2026
f8849e7
test(backend): make a duplicate memory id fail the build
artkorotkikh-dfinity Aug 30, 2026
552fc0e
fix(backend): close three gaps the review found in the tip endpoints
artkorotkikh-dfinity Aug 31, 2026
6f3f2cd
refactor(backend): generalise the vetKey limiter to TieredRateLimiter
artkorotkikh-dfinity Aug 31, 2026
5b21944
refactor(backend): borrow the ICRC types instead of declaring them again
artkorotkikh-dfinity Aug 31, 2026
c97622b
🤖 Apply formatting changes
github-actions[bot] Aug 31, 2026
abbbf0c
test(backend): pin all four default tiers, and stop the doc overpromi…
artkorotkikh-dfinity Aug 31, 2026
e0799e1
docs(backend): say what actually makes the shared global key safe
artkorotkikh-dfinity Aug 31, 2026
b105205
Merge branch 'feat/tips-1-backend' of https://github.com/dfinity/oisy…
artkorotkikh-dfinity Aug 31, 2026
cb9f3e2
Merge branch 'refactor/tiered-rate-limiter' into feat/tips-1-backend
artkorotkikh-dfinity Aug 31, 2026
1d8a2ea
refactor(backend): take the ICRC types from icrc-ledger-types
artkorotkikh-dfinity Aug 31, 2026
b13f296
test(backend): stop the ledger helper re-declaring the ICRC types too
artkorotkikh-dfinity Aug 31, 2026
d782d3e
🤖 Apply formatting changes
github-actions[bot] Aug 31, 2026
b8bb670
Merge branch 'main' into feat/tips-1-backend
artkorotkikh-dfinity Sep 2, 2026
090bd5e
chore(backend): drop the inline Cargo.toml note on icrc-ledger-types
artkorotkikh-dfinity Sep 2, 2026
5769753
docs(backend): split the bucket-claim note by how the store is held
artkorotkikh-dfinity Sep 3, 2026
2097935
Merge branch 'main' into feat/tips-1-backend
artkorotkikh-dfinity Sep 3, 2026
eca8877
🤖 Apply bindings changes
github-actions[bot] Sep 3, 2026
ca378b9
Merge branch 'main' into feat/tips-1-backend
artkorotkikh-dfinity Sep 11, 2026
d4a9e3c
Merge branch 'main' into feat/tips-1-backend
artkorotkikh-dfinity Sep 11, 2026
5c9ecdc
Merge branch 'main' into feat/tips-1-backend
AntonioVentilii Sep 11, 2026
453623c
Merge branch 'main' into feat/tips-1-backend
AntonioVentilii Sep 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,4 @@ coverage/
tmp
.qodo
/cycles-ledger.wasm.gz
/icrc1-ledger.wasm.gz
68 changes: 68 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions docs/ai/backend/workflows/state-and-migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
<themselves>**.

## Escrow model — decided: ICRC-2 allowance, no custody

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/<id>#c=<code>` 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
Expand Down
8 changes: 8 additions & 0 deletions scripts/test.backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading
Loading