Skip to content

Wrapper contract: register, deposit, merge, transfer, withdraw #710

@brozorec

Description

@brozorec

Context

The wrapper contract is the integration layer for the Confidential Token Wrapper: it holds SEP-41 tokens on behalf of users, manages confidential account state (spendable + receiving balance commitments), delegates proof verification to the verifier (#701), fetches auditor keys from the auditor contract (#702) at operation time, and emits events that drive the SDK and indexer. See internal design doc §3, §6, §7, §11 (link available in the tracking workspace).

This ticket covers the core (non-operator) path. Operator delegation lives in a separate Medium ticket so each piece can ship and audit independently. Core is where every other prerequisite ticket converges, so it depends on:

Scope

Build contracts/wrapper/ matching the core (non-operator) surface of design doc §11.

Storage

  • ConfidentialAccount keyed by Address (design doc §6.1): spending_key, viewing_public_key, spendable_balance, receiving_balance, auditor_id.
  • wrap = address_to_field(env.current_contract_address()) in instance storage, computed once at __constructor (design doc §2.7, §3.5). Read on every owner-initiated proof verification.

State-changing entry points

  • register(account, auditor_id, data) — verify Register proof (Multi-VK UltraHonk verifier contract for confidential token wrapper #701, CircuitType::Register); validate auditor_id exists on the auditor contract via get_key; reject if account is already registered (§11.1, single-use); initialize spendable_balance = receiving_balance = O. Emit Register.
  • deposit(from, to, amount) — reject amount < 0 (§3.4); SEP-41 token.transfer(from, self, amount); C_receive += a · G via the Grumpkin crate (On-chain Grumpkin point arithmetic for Soroban wrapper #700). Recipient to must be registered (§7.3). Emit Deposit.
  • merge(account)require_auth; C_spend ← C_spend + C_receive; reset C_receive ← O. No proof. Emit Merge.
  • withdraw(from, to, amount, data) — reject amount < 0; verify Withdraw proof; SEP-41 token.transfer(self, to, amount); update C_spend. Emit Withdraw with (from, to, amount, R_e, σ, b̃, b̃_aud_s) (§11.2).
  • confidential_transfer(from, to, data) — verify Transfer proof; update sender's C_spend; add C_tx to recipient's C_receive. Emit Transfer with (from, to, R_e, ṽ, σ, b̃, ṽ_aud_r, r̃_aud_r, ṽ_aud_s, b̃_aud_s) (§11.2 — the recipient-auditor r̃_aud_r ciphertext is the one that lets the recipient-auditor reconstruct C_receive's opening, §8.1).

Read view

  • confidential_balance(account) -> Bytes — returns the XDR-serialized ConfidentialAccount struct (§11.3); reverts if not registered.

Auditor-key fetch atomicity (security-critical)

For every op that produces auditor ciphertexts (withdraw, transfer), the flow is: read each account's auditor_id → call auditor.get_key(auditor_id) → assemble public inputs (including the fetched key) → call verifier.verify_proof(...). This sequence must be a single host-call boundary with no caller-supplied path between key fetch and proof verification. A race between key rotation on the auditor contract and verification here would let a stale or attacker-substituted key pass into the proof; if rotation occurs mid-flight, UltraHonk verification fails and the invocation reverts cleanly (§8.3, in-flight proofs across rotation). Treat the key-fetch helper as a security-critical primitive; cover it in dedicated tests. The operator-path ticket reuses this same helper.

Trust-boundary discipline (§7.1)

Public inputs that derive from trusted state (account storage, the wrapper's stored wrap, auditor-contract lookups) MUST be loaded by the wrapper itself. The wrapper MUST NOT accept these values from the caller's data payload. The data schema per op is fixed by design doc §11; only prover-supplied values travel in data.

Event emission utilities

Centralize event construction in a single module so the exact field set per event type (design doc §11.2) is enforced by the type system, not by ad-hoc per-handler code. The indexer (separate ticket) and SDK both contract on this schema. Operator events (added in the operator-path ticket) extend the same module.

End-to-end tests

  • Happy path (core): register two accounts → deposit to A → merge A → A confidential_transfer to B → B merges → B withdraws.
  • Negative: replay an already-verified proof (must fail because C_spend no longer matches — design doc §9.7); third-party spam transfers to A do not invalidate A's in-flight spend proof (§9.1); operations referencing an unknown auditor_id are rejected; register on an already-registered account reverts (§11.1); deposit/withdraw with negative amount revert (§3.4).

Out of scope: operator delegation (separate ticket); compliance extensions; the indexer; SDK work.

Key Questions

  • How are XDR-encoded data payloads laid out per op (design doc §11 table)? Must match the SDK's encoder byte-for-byte; lock this with the SDK ticket before merge.

Proposed Approach

Build entry-point-by-entry-point, with the auditor-key fetch helper landing first so every proof-bearing op consumes the same primitive. The operator-path ticket builds on this foundation.

  1. Scaffold contracts/wrapper/. Wire in dependencies: lib/grumpkin/ (On-chain Grumpkin point arithmetic for Soroban wrapper #700), SEP-41 token client, verifier client (Multi-VK UltraHonk verifier contract for confidential token wrapper #701), auditor client (Auditor contract for confidential token wrapper key management #702). Compute and store wrap in instance storage in __constructor (§3.5).
  2. Define storage schema for ConfidentialAccount. Add accessors and the event-emission module (extensible — operator events plug in later).
  3. Build the auditor-key fetch helper: fetch_auditor_keys(account_ids: &[u32]) -> Vec<Point>. Single host-call boundary, validates returned points via is_on_curve + is_not_identity (cheap defense-in-depth even though the auditor contract validates on write). Dedicated tests for race / substitution scenarios.
  4. Implement register and deposit. Add an integration test that exercises both against a deployed verifier with a real Register VK from Noir circuits for confidential token wrapper (epic) #703.
  5. Implement merge (no proof) and withdraw. Verify spend-proof stability under concurrent deposits (§9.1).
  6. Implement confidential_transfer. Wire the dual-auditor key fetch (recipient's + sender's auditor_id). E2E test: A → B end-to-end with B then withdrawing.
  7. Implement the negative test suite called out in Scope. Each negative test references the design doc proposition it validates.
  8. Document the per-op gas envelope in the PR description; flag any op that breaches.

Known References

Expert Brief Architecture

To be completed by the architect.

Cover: module/contract layout, interface boundaries, data model, security-critical decisions, and ecosystem-specific considerations.

Or link to a plan document.

Acceptance Criteria

  • Implementation matches architecture brief
  • Tests cover main flows and edge cases
  • Review/audit findings addressed (if applicable)
  • PR links back to this issue

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Todo

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions