refactor(order): resolve order caller via UserAccount - #228
Conversation
…2911 PR 6/6) Resolve the cancel caller to its funding account so a whitelisted trading account can cancel its funding account's open orders (R4), and record the acting key as canceled_by on the cancel event (R13). Revocation is immediate: a revoked key becomes a stranger and can no longer cancel, while the funding account's open orders stay open and cancellable (R6). The restricted-mode check stays on the raw caller (R12). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address reviewer nits on #226: remove the R4/R6 requirement-ID prefixes from the lifecycle integration test comments (repo convention: no requirement-ID tags in code), and fold the two near-duplicate cancel success unit tests — which differed only by caller and expected canceled_by — into one table-driven test mirroring the rejection test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two self-initiated test refinements: drop the step-narrating comments in the lifecycle integration test that the code and assertions already convey (keeping the fund_base rationale and the R6 crux that a revoked key's order stays open), and assert the whole CancelLimitOrderEvent — pinning order_id alongside canceled_by — in the folded cancel unit test, matching the assert-the-exact-event principle used across the stack. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The foreign-caller cancel rejection test asserts OrderStatus::Pending but its failure message said the order "stays open"; Pending and Open are distinct statuses here, so align the message with the assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the end-to-end lifecycle test so a second trading account cancels an order placed by the first trading account of the same funding account, proving R4's account-scoped cancel authority holds across sibling keys regardless of which key placed the order. The cancel resolves to the funding account and the acting sibling key is attributed as canceled_by on the event. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The with_cancel audit-test helper passes its principal to validate_cancel_limit_order as the resolved order owner, not the acting caller (that is the separate canceled_by). Rename the parameter from user to owner to match the state fns it drives and remove the ambiguity now that both are present. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror the placed_by attribution: OrderRecord now carries canceled_by (the acting caller of a cancel, None when the owner canceled its own order), surfaced through get_my_orders so a funding account can see which orders a rogue key canceled. The cancel apply/replay handler writes it onto the record when it transitions the order to Canceled, so both the forward path and event replay persist it; replay stays byte-faithful because the event already carries the attribution. Stored as an optional trailing minicbor field and an opt principal on the candid record, both backward-compatible. Amends the DEFI-2911 spec (R13, implementation, delivery table) to place canceled_by on OrderRecord alongside the event. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olution-on-cancel # Conflicts: # canister/src/test_fixtures/mod.rs
Adding canceled_by to OrderRecord shifts the instruction counts of the order-event and get_my_orders benchmarks; refresh the persisted baseline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the two verbatim match blocks rendering placed_by and canceled_by with a single closure applied to both. Pure refactor — the rendered output is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the effective_account + (caller != owner) attribution trick in add_limit_order and cancel_limit_order with a UserAccount-driven resolution that derives (owner, acting_key) from the enum variants directly. Behavior is preserved for funding, trading, and unknown callers; read paths keep using effective_account. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
mbjorkqvist
left a comment
There was a problem hiding this comment.
🧐 VERDICT: READY — 0 blockers, 0 mediums, 0 nits; CI green.
Review details
Behavior-preserving refactor, confirmed by equivalence + mutation:
- Funding:
order_actor→(principal, None); since the funding account is keyed bycaller,principal == caller, so old(effective_account, (caller != owner).then_some(caller))→(caller, None). Identical. - Trading:
order_actor→(grant.funding, Some(principal)); a trading key is never its own funding (SelfGrant prevented), so old code'scaller != owneris always true →Some(caller). Identical. - Unknown:
unwrap_or((caller, None))matches oldeffective_account'sunwrap_or(caller)+then_some(None). Identical.
Mutation of the Trading branch to drop attribution failed resolution_on_placement::should_place_a_trading_account_order_on_the_funding_account and resolution_on_cancel::should_cancel_the_funding_account_order_and_attribute_the_acting_caller — both sides of the axis are guarded end-to-end through the productive API. Reverted; tree clean.
Scope: only add_limit_order / cancel_limit_order changed. effective_account is correctly retained (5 read-path sites in lib.rs). No candid/CBOR/spec change.
Maintainability:
- Duplication: none material.
resolve_order_callermirrorseffective_accountandorder_actormirrorseffective_principal, but each is 3 lines returning semantically distinct data (read-resolution principal vs. owner+attribution) — intentional, sub-threshold sibling parallelism, not copy-paste worth unifying. - Unused derives: N/A (no new types).
- Primitive-obsession: the
(Principal, Option<Principal>)return is a two-Principaltuple, mildly swap-prone, but consistent with the codebase's bare-Principalowner/placed_byconvention and immediately destructured at both call sites — cleared. - Divergent invariant handling: none; unknown caller degrades to
(caller, None)exactly aseffective_accountdegrades tocaller. - Silent fallbacks: none; the
unwrap_orcovers the expected unregistered-caller case (downstream ownership check rejects foreign callers, pershould_reject_cancel_by_a_foreign_caller), not an invariant breach.
Tests: no redundant tests added; existing resolution_on_placement / resolution_on_cancel table-driven tests and lifecycle coverage carry the safety net. Docs added on both new methods are accurate; no requirement-ID tags.
|
✅ Ready for your review. Behavior-preserving refactor —
📚 Stacked on #226 (base = |
Move the caller resolution into State::add_limit_order and State::cancel_limit_order so each takes the raw caller and resolves the owner and acting-key attribution via UserAccount internally, mirroring the shape cancel already had. lib.rs no longer pre-resolves to a Principal: it keeps the caller-allowed check and candid parsing, then makes a single state call. Behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| /// to: a funding account acts as itself with no separate attribution; a | ||
| /// trading account acts on its funding account's behalf, attributed to the | ||
| /// trading principal. | ||
| pub fn order_actor(&self) -> (Principal, Option<Principal>) { |
There was a problem hiding this comment.
🧐 🔵 Nit (non-gating): order_actor().0 is always equal to effective_principal(), and State::resolve_order_caller mirrors effective_account line-for-line — the order-path pair is a superset of the read-path pair. Not worth unifying here (they're 4–5 line accessors and coupling the read-path helper to order attribution semantics would arguably read worse), but flagging the parallelism in case a future change makes it cheap to collapse (e.g. effective_principal = self.order_actor().0). Also: order_actor is pub but currently only used within the crate; that matches effective_principal's visibility, so no change needed.
mbjorkqvist
left a comment
There was a problem hiding this comment.
🧐 VERDICT: READY — 0 blockers, 0 mediums, 1 nit; CI green.
Review details
Behavior-preserving refactor that moves order caller resolution into the State
methods, resolving (owner, placed_by/canceled_by) via the private
resolve_order_caller (UserAccount-driven). Confirmed against the four verify points:
- Behavior-preserving.
order_actor().0 == effective_principal()for both variants,
and the acting-key mapping (funding→None, trading→Some(trading), unknown→None)
reproduces the old(caller != owner).then_some(caller)exactly. Mutation check:
breakingorder_actor's trading acting-key fails bothresolution_on_placementand
resolution_on_cancel— the moved logic is covered by evidence. - R12 preserved.
assert_caller_is_allowedstill runs on the raw caller, before any
resolution, in bothlib.rsentry points. - Addresses the request. State methods now resolve via
UserAccount;lib.rspasses
only the rawcaller; the legacy bare-Principalpre-resolution is gone from the order
paths. - Scope. Only the two sync order paths changed;
deposit/withdrawuntouched; no
candid/CBOR/spec change;add_limit_order/cancel_limit_orderare symmetric.
Maintainability rundown:
- duplication (in-diff): none substantial.
resolve_order_caller/effective_accountand
order_actor/effective_principalare near-parallel 4–5 line pairs, but they express
distinct concepts (order path vs read path) and stay below the substantial bar — see nit. - unused derives: none (no new types).
- primitive-obsession params: none —
caller: Principal,pair: TradingPair,
pending: PendingOrderare all domain types. - divergent invariant handling: none — unknown-caller fallback
(caller, None)matches
effective_account'sunwrap_or(caller). - silent fallbacks: none — the
unwrap_or((caller, None))is an expected case (unknown
principal acts as itself), not an invariant breach; mirrors prior behavior. - test-only code in production: none.
- redundant/derivable params: improved — the old
cancel_limit_order(owner, canceled_by, …)
threaded two derivable args; both are now derived inside. This is the decision-ownership
fix the refactor set out to make.
Tests: no redundant additions; the changed-signature call sites and the table-driven
resolution_on_* tests cover both axes (funding/trading) end-to-end for owner, status,
attribution, and the emitted event.
|
✅ Ready for your review. Order-path caller resolution now happens inside the
📚 Stacked on #226. When #226 merges I'll retarget this to |
canceled_by == None has two meanings, unlike placed_by: the order is not canceled at all, or it was canceled by the owner. Reword the rustdoc on the internal and candid OrderRecord fields and the .did comment to capture both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This reverts commit 9fa66ee.
There was a problem hiding this comment.
Pull request overview
This PR refactors order placement/cancellation so the State layer resolves the raw caller into (owner, acting_key) using UserAccount, removing the legacy effective_account + (caller != owner) attribution logic from the canister entrypoints while preserving behavior.
Changes:
- Add
UserAccount::order_actor()to derive(owner, placed_by/canceled_by)directly from account classification. - Introduce
State::add_limit_order(caller, ...)and updateState::cancel_limit_order(caller, ...)to resolve caller/attribution internally via a shared helper. - Simplify
canister/src/lib.rsorder entrypoints to parse inputs and delegate toStatewith the raw caller; update affected state tests for the new signature.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| canister/src/user/mod.rs | Adds order_actor() to derive order owner + attribution from UserAccount variants. |
| canister/src/state/mod.rs | Moves caller resolution into state order methods; adds add_limit_order orchestration and a shared resolve_order_caller helper. |
| canister/src/lib.rs | Thins entrypoints by delegating order placement/cancel logic to State using the raw caller. |
| canister/src/state/tests.rs | Updates call sites to the new cancel_limit_order(caller, ...) signature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
✅ No security or compliance issues detected. Reviewed everything up to ab6870b. Security Overview
Detected Code Changes
|
…r-resolution-useraccount # Conflicts: # canister/oisy_trade.did # canister/src/lib.rs # canister/src/order/history/mod.rs # canister/src/state/mod.rs # canister/src/state/tests.rs # libs/types/src/lib.rs
gregorydemay
left a comment
There was a problem hiding this comment.
One understanding question
| @@ -172,6 +172,36 @@ impl<MH: Memory, MB: Memory> State<MH, MB> { | |||
| } | |||
There was a problem hiding this comment.
nit: here for a lack of a better place: PR description formatting is weird (probalby claude used git commit message width)
There was a problem hiding this comment.
Good catch — reflowed the description into normal paragraphs (dropped the ~72-col hard wrapping that came from a commit-message-width editor) and refreshed it for the current shape of the PR, since the resolve_order_caller indirection you asked about below is now gone. Thanks!
| let (owner, canceled_by) = self.resolve_order_caller(caller); | ||
| self.validate_cancel_limit_order(&owner, &order_id)?; |
There was a problem hiding this comment.
I don't get this new indirection via resolve_order_caller. Why not just have the validate method act directly on UserAccount (result of `UserRegistry::lookup)?
There was a problem hiding this comment.
Good call — dropped resolve_order_caller entirely. Both validate_limit_order and validate_cancel_limit_order now act directly on the UserAccount from lookup: each takes the Option<&UserAccount> and derives the owner via effective_principal(), and the add/cancel entry points read placed_by/canceled_by from the same account via order_actor(). The two paths are symmetric now.
No caller fallback is threaded into validate either — an unregistered caller resolves to None, which has no funding balance and owns no orders, so None already determines the outcome (InsufficientBalance / NotOrderOwner) without needing the principal.
The one place that stays owner-Principal-based is record_limit_order / record_cancel_limit_order: they're shared with the post-upgrade replay path, which reconstructs them from the persisted event's already-resolved owner and has no caller/UserAccount to resolve. Resolution is a live-only decision the audit log freezes at submission time (re-resolving at replay against mutable grant state could diverge from the original owner), so those keep taking the resolved principal.
Drop the `resolve_order_caller` indirection and have `validate_limit_order` and `validate_cancel_limit_order` act directly on the `Option<UserAccount>` returned by `lookup_account`, deriving the owner via `effective_principal()`. The `add_limit_order` and `cancel_limit_order` paths are now symmetric: each looks the account up once, passes it to its validate method, and reads `placed_by`/`canceled_by` from the same account via `order_actor()`. No redundant `caller` param on the validate methods — an unregistered caller (`None`) has no balance and owns no orders, so `None` already determines the outcome. `record_limit_order`/`record_cancel_limit_order` stay owner-`Principal`-based: they are shared with the post-upgrade replay path, which reconstructs them from the persisted event's already-resolved owner and has no caller to resolve. Addresses gregorydemay's review on #228. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| .get(order_id) | ||
| .ok_or(CancelLimitOrderError::OrderNotFound)?; | ||
| if &record.owner != owner { | ||
| if account.map(UserAccount::effective_principal) != Some(record.owner) { | ||
| return Err(CancelLimitOrderError::NotOrderOwner); | ||
| } |
There was a problem hiding this comment.
This is behavior-preserving on every reachable state, because an order's owner is always a registered funding principal. Orders are only ever created through record_limit_order, which enforces exactly that: lookup(user).and_then(funding_id).expect("order owner not registered — deposit registers every user") (mod.rs:299). So if caller == record.owner, the caller is registered, lookup returns Some, and effective_principal() == caller == record.owner → the check passes. The only way to hit the None branch is an unregistered caller, which by that invariant can never equal record.owner — so None != Some(record.owner) → NotOrderOwner is the correct "not the owner" answer, never a false rejection of a genuine owner.
The old effective_account self-fallback only produced owner == caller in that same unregistered case, so it too would have rejected here — the paths agree. The invariant also holds across upgrades: order_history and user_registry are both stable and replayed from the same event log, where a Deposit registers the user before any of their AddLimitOrder events.
…r-resolution-useraccount
Merging main surfaced two call sites using pre-refactor signatures: - `state/tests.rs`: the new #240 cancel tests called `cancel_limit_order(&owner, None, order_id, runtime)` — updated to the merged `cancel_limit_order(caller, order_id, runtime)`. - `benchmarks.rs`: a leftover `validate_limit_order(None, user, pair, pending)` call — updated to `validate_limit_order(account, pair, pending)`. This file is behind the `canbench-rs` feature, so `cargo check --tests` never compiled it; only the benchmark CI job did, which is what turned the PR red. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (2)
canister/src/state/mod.rs:382
validate_cancel_limit_ordertreatsaccount: Noneas "not owner" unconditionally. Previously, unknown callers were treated as acting on their own principal viaeffective_account(caller) -> caller, so an (unregistered) principal could still cancel an order it owns. This is also a mismatch with the PR description’s "unknown → self / None" behavior. To preserve prior semantics, the owner comparison needs a fallback to the rawcallerwhen noUserAccountexists (e.g., by passingcallerintovalidate_cancel_limit_order, or computing aneffective_owner = account.map(...).unwrap_or(caller)before validating).
fn validate_cancel_limit_order(
&self,
account: Option<&UserAccount>,
order_id: &OrderId,
) -> Result<(), CancelLimitOrderError> {
let record = self
.order_history
.get(order_id)
.ok_or(CancelLimitOrderError::OrderNotFound)?;
if account.map(UserAccount::effective_principal) != Some(record.owner) {
return Err(CancelLimitOrderError::NotOrderOwner);
}
canister/src/state/mod.rs:247
validate_limit_orderre-looks up the funding account even whenaccountis already aUserAccount::Funding(and thus already contains theUserId). This adds an unnecessary registry lookup and makes the flow harder to follow. Consider extracting the fundingUserIddirectly fromaccountand only doing a registry lookup for the trading-account case.
let free = account
.map(UserAccount::effective_principal)
.and_then(|owner| self.user_registry.lookup(owner))
.and_then(|funding_account| funding_account.funding_id())
.and_then(|u| self.balances.get_balance(u, &token))
.map(|b| *b.free())
.unwrap_or(Quantity::ZERO);
Purpose
Follows up on @gregorydemay's review of #226. The order entry points resolved the caller through the legacy bare-
Principaleffective_accounthelper and then re-derived "is this a delegated key" with a(caller != owner)comparison — a pattern predating the funding/trading account distinction. This PR moves caller resolution into theStateorder methods so bothadd_limit_orderandcancel_limit_ordertake the raw caller and resolve owner and acting-key attribution from the richerUserAccounttype.This is a behavior-preserving refactor: no functional change, no spec change, no candid/CBOR change. Owner and attribution (
placed_by/canceled_by) are identical for all three cases (funding, trading, unknown caller).Approach
add_limit_orderandcancel_limit_ordereach look the caller'sUserAccountup once and pass it into their validate method. The validate methods act directly on thatOption<UserAccount>, deriving the owner viaeffective_principal(); the entry points readplaced_by/canceled_byfrom the same account viaorder_actor(). The two paths are symmetric, and no separate caller-resolution helper is needed.UserAccountvariants (funding → self /None, trading → funding principal /Some(acting key), unknown → self /None), not by acaller != ownercomparison.lib.rsstays thin: it keeps the caller-allowed check on the raw caller and the candid→domain parsing, then makes a single state call.record_*methods keep their owner-Principalsignatures. They are shared with the post-upgrade replay path, which reconstructs them from the persisted event's already-resolved owner and has no caller/UserAccountto resolve — resolution is a live-only decision that the audit log freezes at submission time.📚 PR stack
UserAccount← you are hereRetargeted to
mainnow that #226 has merged.