Skip to content

Cell Seal: Contracts ​

Repo cell-seal-contracts. Rust, Soroban SDK, one workspace crate per contract. The OpenZeppelin parts follow branch v0.9.0 of OpenZeppelin/stellar-contracts, pinned by commit. Primitive signatures in this doc are copied from packages/tokens/src/confidential/docs/protocol/interface.md.

1. Map ​

mint, burn, transferon the platform's railCase 1: deploy sample tokendeployconfidential_transfer(_from)deposit, withdrawbalanceis_authorizedis_authorizedget_keyverify_proofhook: require_auth(settlement)fund_of(token)platform issuerfund tokenowned by the platformfund-registryseal-tokenshare wrappersettlementseal-tokencash wrapperfund-policyseal-auditorseal-verifier

Shared conventions: share and stablecoin amounts are i128 with 7 decimal places. NAV is an i128 at scale 10^7 (1.0427 = 10_427_000). Cell Seal error codes start at 4000, separate from the OpenZeppelin ranges 3500 (token) and 3600 (compliance).

2. Fund token ​

The fund's SEP-41 token, owned by the platform: the SAC of a classic asset, or a custom Soroban token. Cell Seal does not mint, does not burn, and holds no admin role on it. The conditions the token must meet (decimals, AUTH_REQUIRED, clawback, SEP-8): integration.md section 3.

Token function Cell Seal callsCallerNote
transfer(from, to, amount)Share wrapper, inside deposit and withdrawThe wrapper is one public holder of the token and holds the pool backing every sealed balance
balance(account)settlementChecks that the settlement account has been funded with net before wrapping. Reads supply_after

The platform issuer mints and burns on its own rail: it funds net into the settlement account before close_window when net > 0, and retires |net| after close_window when net < 0.

The current prototype ships share-token (OpenZeppelin fungible, mint(to, amount, operator) for the issuer role, burn by the holder) as a sample token for tests and for Case 1 demos. It is not part of the product.

Invariant T1: balance(token, share wrapper) changes only at close_window (by exactly net) and at a holder's self-wrap deposit (section 3.3). After every close_window it equals supply_after. Full reconciliation: integration.md section 6.

3. seal-token ​

Concrete contract implementing the OpenZeppelin traits ConfidentialToken + ConfidentialCompliance

  • ConfidentialClawback. Two configurations: share wrapper (one per fund) and cash wrapper (one per stablecoin, shared across the platform).

3.1 Surface taken verbatim from the primitive ​

rust
fn register(e: Env, account: Address, auditor_id: u32, data: Bytes);
fn deposit(e: Env, from: Address, to: Address, amount: i128);
fn merge(e: Env, account: Address);
fn withdraw(e: Env, from: Address, to: Address, amount: i128, data: Bytes);
fn confidential_transfer(e: Env, from: Address, to: Address, data: Bytes);
fn confidential_transfer_from(e: Env, spender: Address, from: Address, to: Address, data: Bytes);
fn set_spender(e: Env, account: Address, spender: Address, live_until_ledger: u32, data: Bytes);
fn revoke_spender(e: Env, account: Address, spender: Address);
fn confidential_balance(e: Env, account: Address) -> ConfidentialAccount;
fn is_spender(e: Env, account: Address, spender: Address) -> bool;
fn get_spender_delegation(e: Env, account: Address, spender: Address) -> SpenderDelegation;

fn freeze(e: Env, account: Address, operator: Address);
fn unfreeze(e: Env, account: Address, operator: Address);
fn set_compliance_config(e: Env, config: ComplianceConfig, operator: Address);
fn clawback(e: Env, account: Address, amount: i128, destination: Option<Address>, data: Bytes, operator: Address);
fn force_revoke_spender(e: Env, account: Address, spender: Address, operator: Address);

require_auth for each function follows the table in the primitive's spec: from for transfers, spender (not from) for confidential_transfer_from, account for register, set_spender, revoke_spender, merge.

Two storage structs. Every Grumpkin point is 64 bytes:

rust
ConfidentialAccount { spending_public_key, viewing_public_key, spendable_commitment, receiving_commitment, auditor_id }
SpenderDelegation   { allowance_commitment, a_tilde, escrowed_dvk, allowance_salt, live_until_ledger }

3.2 Cell Seal constructor ​

rust
fn __constructor(
    e: Env,
    admin: Address,
    token: Address,
    verifier: Address,
    auditor: Address,
    compliance: Option<ComplianceConfig>,
    seal: SealConfig,
);

struct SealConfig {
    settlement: Address,
    registry: Address,
    kind: WrapperKind,
    fund_id: Option<u32>,
}

enum WrapperKind { Share, Cash }
ArgumentShare wrapperCash wrapper
adminThe fund's issuer adminThe platform admin
tokenThe fund tokenThe stablecoin
compliance{ policy: fund-policy, sac_passthrough: false }{ policy: fund-policy, sac_passthrough: true } when the stablecoin is a SAC, so that freeze orders from the stablecoin issuer are honoured
seal.kind, seal.fund_idShare, Some(fund_id)Cash, None

seal.registry is the fund-registry address. The hooks read fund data from it: a share wrapper calls fund(fund_id) for the fund's auditor_id and settlement account, a cash wrapper calls is_settlement_account(account) and is_fund_auditor_id(auditor_id). The wrapper caches nothing, so a change in the registry applies on the next call.

3.3 SealHooks ​

The primitive offers a single extension point: the Hooks trait, invoked after authentication and payload decryption, before storage is written. SealHooks runs Cell Seal's rules and then delegates to ComplianceHooks (freeze, policy, SAC). A hook never receives the amount of a transfer.

HookShare wrapperCash wrapperReason
on_registerauditor_id must equal the fund's auditor_idis_fund_auditor_id(auditor_id) must be trueThe investor picks auditor_id and cannot change it. Without this rule the fund is blind to that investor
on_depositfrom == to, and to passes policy. The settlement account wraps the net through settlement. A Case 2 public holder self-wraps its own balancefrom == toA deposit carries a public amount together with the recipient address. Nobody may deposit on someone else's behalf. A self-wrapped amount was already public
on_withdrawSettlement account only, and called from settlementfrom == toInvestors do not unwrap shares. Shares leave the wrapper only as the net of a window
on_transfersettlement.require_auth()settlement.require_auth()Every transfer goes through settlement to carry an order reference, and so the settlement account cannot pull an allowance outside DvP
on_spender_transferas aboveas above
on_set_spenderspender must be the fund's settlement accountspender must be the settlement account of some fundBlocks arbitrary delegation
on_merge, on_revoke_spenderno extra ruleno extra ruleAn investor can always take an allowance back

settlement.require_auth() inside a hook succeeds when settlement is the contract calling directly into seal-token, and a direct call to the wrapper is rejected even when the sender signed. This was confirmed on testnet, so no extra flag is needed.

3.4 Additional errors ​

CodeNameWhen
4000AuditorNotApprovedregister with an auditor_id outside the allowed set
4001DepositNotAllowedon_deposit rule violated
4002WithdrawNotAllowedon_withdraw rule violated
4003NotViaSettlementTransfer not routed through settlement
4004SpenderNotAllowedset_spender to an address that is not a settlement account

4. seal-verifier and seal-auditor ​

The primitive's traits, plus access control.

rust
fn verify_proof(e: &Env, circuit_type: CircuitType, public_inputs: Bytes, proof: Bytes);
fn register_verification_key(e: &Env, circuit_type: CircuitType, vk: Bytes, operator: Address);
fn update_verification_key(e: &Env, circuit_type: CircuitType, vk: Bytes, operator: Address);

enum CircuitType { Register = 0, Withdraw = 1, Transfer = 2, SpenderTransfer = 3, SetSpender = 4, Clawback = 100 }
rust
fn register_key(e: &Env, auditor_id: u32, point: BytesN<64>, operator: Address);
fn rotate_key(e: &Env, auditor_id: u32, new_point: BytesN<64>, operator: Address);
fn get_key(e: &Env, auditor_id: u32) -> BytesN<64>;
RuleContent
Who writes verification keysoperator is the platform admin (multisig). register_verification_key works once per circuit_type. update_verification_key is two calls by the same operator: the first stores the proposal with the current ledger, the second applies it once timelock_ledgers have passed. timelock_ledgers is a constructor argument, default 17280 (about 24 hours)
Who writes auditor keysregister_key: operator must be the fund-registry contract, which calls it inside create_fund. rotate_key: operator must be the issuer admin of the fund that owns that auditor_id, read from fund-registry.fund_of_auditor(auditor_id)
Effect of rotate_keyIn-flight proofs revert. Readability only applies going forward, so key holders must keep old key shares to read history
CircuitType is fixedThe primitive's trait fixes the enum. Any new Cell Seal circuit needs its own verifier. This enum is not extended
CodeNameWhen
4200NotPlatformAdminseal-verifier: operator is not the platform admin
4201KeyAlreadyRegisteredregister_verification_key for a circuit_type that has a key
4202TimelockPendingThe second update_verification_key call comes before timelock_ledgers have passed
4203NoPendingUpdateThe second call has no stored proposal, or the proposal differs from the stored one
4300AuditorIdTakenregister_key for an auditor_id that exists
4301NotKeyOwnerrotate_key by an operator who is not that fund's issuer admin
4302UnknownAuditorIdget_key or rotate_key for an unknown id

5. fund-policy ​

Implements the single interface the primitive calls:

rust
fn is_authorized(e: Env, account: Address, token: Address) -> bool;

Called for both from and to on every operation. The policy only sees an address and a token.

rust
fn set_investor(e: Env, fund_id: u32, account: Address, status: InvestorStatus, kyc_expiry: u64, operator: Address);
fn investor(e: Env, fund_id: u32, account: Address) -> Option<InvestorEntry>;

enum InvestorStatus { Approved, Suspended, Removed }
struct InvestorEntry { status: InvestorStatus, kyc_expiry: u64, updated_ledger: u32 }

The policy tells the two wrapper kinds apart through the registry: fund_of(token) returns Some(f) for the share wrapper of fund f and None for a cash wrapper.

Tokenis_authorized returns true when
Share wrapper of fund faccount is the settlement account of f, or investor(f, account) is Approved and kyc_expiry has not passed
Cash wrapperaccount is the settlement account of some fund, or account is Approved with a valid kyc_expiry in at least one fund

kyc_expiry is a Unix timestamp compared with ledger.timestamp(). A Suspended or Removed investor fails the check at once, so a pending transfer to or from that account reverts.

set_investor requires operator.require_auth() and fund-registry.has_role(fund_id, operator, Compliance). The issuer admin grants that role. The KYC source is the issuer, who attests kyc_expiry from its own KYC process. An external attestation provider plugs in behind the same set_investor call without a contract change.

CodeNameWhen
4400NotComplianceset_investor by an operator without the Compliance role on that fund
4401UnknownFundfund_id not in the registry

Event InvestorSet { fund_id, account, status, kyc_expiry }. The policy stores one InvestorEntry per (fund_id, account) in persistent storage. A Removed entry stays, so the history of an account is readable from chain.

6. settlement ​

The single gate for every sealed movement. Holds no assets, holds no keys, sees no amount, except the window's net, which is public by design. Does not mint and does not burn the fund token.

6.1 Types ​

rust
struct Leg {
    token: Address,
    kind: LegKind,
    from: Address,
    to: Address,
    data: Bytes,
}

enum LegKind { Direct, Spender }

enum LegRole { Share, Cash }

struct WindowState {
    window_id: u32,
    period_secs: u64,
    next_close: u64,
}

order_id: BytesN<32> is SHA-256(fund_id || investor || client_nonce), generated on the client. It is the only part of an order that appears publicly.

Storage, all persistent:

KeyValueWritten by
Fund(fund_id)FundLink { share_wrapper, token, settlement_account, issuer_admin }register_fund
Window(fund_id)WindowStateregister_fund, close_window
PilotCap(fund_id)i128, sealed supply the fund may not exceedregister_fund (default from the constructor), set_pilot_cap
Settled(order_id, role)()submit_leg, settle_dvp, settle_pair
AdminThe platform admin addressConstructor
RegistryThe fund-registry addressConstructor

Cash wrappers are not stored here. "Both tokens in the fund" means: the share wrapper of fund_id, plus any address in fund-registry.fund(fund_id).terms.cash_wrappers.

6.2 Functions ​

rust
fn register_fund(e: Env, fund_id: u32, share_wrapper: Address, token: Address, settlement_account: Address, issuer_admin: Address, period_secs: u64, first_close: u64);
fn set_pilot_cap(e: Env, fund_id: u32, cap: i128, operator: Address);
fn submit_leg(e: Env, fund_id: u32, order_id: BytesN<32>, role: LegRole, leg: Leg);
fn settle_dvp(e: Env, fund_id: u32, order_id: BytesN<32>, pull: Leg, pay: Leg);
fn settle_pair(e: Env, fund_id: u32, order_id: BytesN<32>, a: Leg, b: Leg);
fn close_window(e: Env, fund_id: u32, window_id: u32, net: i128, withdraw_data: Option<Bytes>);
fn window(e: Env, fund_id: u32) -> WindowState;
fn leg_settled(e: Env, order_id: BytesN<32>, role: LegRole) -> bool;
FunctionSignerChecksExecution
register_fundThe fund-registry contract (Registry.require_auth())fund_id not yet registeredWrites FundLink, WindowState { window_id: 1, period_secs, next_close: first_close }, the default pilot cap. Emits FundRegistered
set_pilot_capPlatform adminWrites PilotCap. Emits PilotCapSet
submit_legleg.fromleg.kind == Direct. leg.token belongs to the fund. (order_id, role) not yet settled. One end of the leg is the settlement accountCalls confidential_transfer. Records (order_id, role)
settle_dvpSettlement accountpull.kind == Spender, pay.kind == Direct. Two different tokens, both in the fund. pull.to == pay.from == settlement account. pull.from == pay.to. Neither role settled yetconfidential_transfer_from then confidential_transfer, same invocation
settle_paira.from and b.from, one authorization entry eachTwo different tokens, both in the fund. a.from == b.to, a.to == b.from. Neither side is the settlement accountTwo confidential_transfers, same invocation
close_windowIssuer admin and settlement accountwindow_id is the current window. ledger.timestamp() >= next_close. If net > 0 then balance(token, settlement account) >= net. If net < 0 then withdraw_data must be present. If net = 0, neither check applies. balance(token, share wrapper) + net <= PilotCapnet > 0: deposit(from = to = settlement account, net). net < 0: withdraw(from = to = settlement account, |net|, withdraw_data). net = 0: no token moves, same signers, the window still advances and emits WindowClosed. No mint, no burn. Increments window_id. Re-reads window_period_secs from the registry into period_secs and sets next_close = next_close + period_secs. Emits WindowClosed

The settlement account signs for deposit and withdraw. The issuer admin signs to approve the net figure that is about to become public. Funding net public tokens into the settlement account before closing (net > 0) and retiring |net| after closing (net < 0) is the issuer's job on the platform's rail, integration.md section 4. The interval during which public tokens sit in the settlement account between those two steps should be short. The SDK merges them into one operational step. The contract checks balance >= net so that a stray dust transfer cannot block a close. The SDK is stricter by design and closes only when the balance equals net (E_NET_NOT_FUNDED, E_NET_OVERFUNDED, sdk.md section 4.4), because a surplus is a leak (L16).

close_window refuses to run before next_close. This is the on-chain enforcement of the anti-leak rule "unwrap on a schedule, not per order": the issuer cannot unwrap right after a large redemption even if it wants to.

Two UltraHonk proofs in one invocation (settle_dvp, settle_pair) fit in one Soroban transaction: two transfer proofs measured on testnet at about 188 M instructions against the 400 M ceiling (primitives.md section 8).

6.3 Events ​

EventFieldsNote
FundRegisteredfund_id, share_wrapper, token, settlement_accountOnce per fund
PilotCapSetfund_id, cap
LegSettledfund_id, order_id, role, token, from, toNo amount
DvpSettledfund_id, order_id, investorinvestor is pull.from
PairSettledfund_id, order_id, a, ba is a.from, b is b.from
WindowClosedfund_id, window_id, net, supply_after, next_closenet is the window's only public figure. supply_after is balance(token, wrapper), that is sealed supply, not total_supply

The primitive's own events (Transfer, SpenderTransfer, Deposit, Withdraw and the rest) are still emitted by seal-token and carry the ciphertexts for the recipient and for the auditor.

6.4 Errors ​

CodeName
4100UnknownFund
4101TokenNotInFund
4102LegAlreadySettled
4103LegShapeInvalid
4104WindowNotDue
4105WindowIdMismatch
4106WithdrawDataMissing
4107PilotCapExceeded
4108NetNotFunded
4109FundAlreadyRegistered
4110NotRegistry
4111NotPlatformAdmin

4103 LegShapeInvalid covers every shape rule in the table above: wrong kind, wrong direction, same token on both legs, a settlement account where an investor is required or the reverse.

7. fund-registry ​

rust
fn create_fund(e: Env, params: FundParams, token: Option<Address>, issuer_admin: Address, settlement_account: Address, auditor_key: BytesN<64>) -> u32;
fn fund(e: Env, fund_id: u32) -> FundInfo;
fn funds(e: Env, start: u32, limit: u32) -> Vec<FundInfo>;
fn fund_of(e: Env, token: Address) -> Option<u32>;
fn fund_of_auditor(e: Env, auditor_id: u32) -> Option<u32>;
fn is_fund_auditor_id(e: Env, auditor_id: u32) -> bool;
fn is_settlement_account(e: Env, account: Address) -> bool;
fn has_role(e: Env, fund_id: u32, account: Address, role: FundRole) -> bool;
fn publish_nav(e: Env, fund_id: u32, dealing_day: u32, nav: i128, operator: Address);
fn nav(e: Env, fund_id: u32, dealing_day: u32) -> Option<i128>;
fn update_terms(e: Env, fund_id: u32, terms: FundTerms, operator: Address);
fn grant_role(e: Env, fund_id: u32, account: Address, role: FundRole, operator: Address);
fn revoke_role(e: Env, fund_id: u32, account: Address, role: FundRole, operator: Address);

struct FundParams { name: String, symbol: String, currency: Symbol, manager: String, terms: FundTerms }
struct FundTerms  { dealing: Dealing, cutoff_secs_before: u64, window_period_secs: u64, sub_fee_bps: u32, red_fee_bps: u32, min_investment: i128, pay_bank: bool, cash_wrappers: Vec<Address>, docs_hash: BytesN<32> }
struct FundInfo   { fund_id: u32, params: FundParams, token: Address, share_wrapper: Address, settlement_account: Address, issuer_admin: Address, auditor_id: u32, box_pk: BytesN<32>, created_ledger: u32 }
enum Dealing  { Daily, Weekly, Monthly }
enum FundRole { Ops, Compliance, NavPublisher }

create_fund requires issuer_admin.require_auth() and runs in one invocation:

  1. token = Some(address): uses the platform's token (Case 1 and Case 2 in production). token = None: deploys the sample share-token with issuer_admin as minter. The sandbox uses this form so a platform can try the rail without preparing a token.
  2. Deploys the share wrapper through the Soroban deployer with the pinned wasm hash, admin = issuer_admin, token, the platform's verifier, auditor and policy, seal.fund_id = fund_id.
  3. Allocates auditor_id = fund_id and calls seal-auditor.register_key(auditor_id, auditor_key).
  4. Calls settlement.register_fund(fund_id, share_wrapper, token, settlement_account, issuer_admin, terms.window_period_secs, first_close), with first_close = ledger.timestamp() + window_period_secs.
  5. Stores FundInfo, grants issuer_admin every FundRole, and returns fund_id.

dealing_day is the number of days since 1970-01-01 (UTC). publish_nav requires the NavPublisher role, refuses a second NAV for the same dealing_day and emits NavPublished. update_terms, grant_role and revoke_role require operator to be the fund's issuer admin. update_terms cannot change window_period_secs of the window in progress: the new value applies from the next close_window, which reads it from the registry.

publish_nav puts the NAV on chain: investors read the price from the chain, not from the backend, and the NAV has a ledger anchor for dispute resolution. Every field of FundInfo is public and is the data source for any fund list a platform shows, the sandbox investor app included.

Storage, all persistent: Fund(fund_id), TokenIndex(token) -> fund_id, AuditorIndex(auditor_id) -> fund_id, SettlementIndex(account) -> fund_id, Role(fund_id, account, role), Nav(fund_id, dealing_day), NextFundId.

EventFields
FundCreatedfund_id, token, share_wrapper, settlement_account, issuer_admin, auditor_id
NavPublishedfund_id, dealing_day, nav
TermsUpdatedfund_id
RoleGranted, RoleRevokedfund_id, account, role
CodeNameWhen
4500UnknownFund
4501NotIssuerAdminupdate_terms, grant_role, revoke_role by someone else
4502RoleMissingpublish_nav without NavPublisher
4503NavAlreadyPublishedSecond NAV for the same dealing_day
4504TokenAlreadyWrappedcreate_fund with a token that already has a fund
4505SettlementAccountInUsecreate_fund with a settlement_account that belongs to another fund

8. Permission matrix ​

OperationPlatform adminIssuer adminSettlement accountFund opsInvestor
Deploy, upgrade platform contractsyes, multisig + timelock
Write verification keysyes
create_fund, update_terms, grant_role, revoke_roleyes
register_fund in settlement, register_key in seal-auditorcalled by fund-registry inside create_fund
set_pilot_capyes
rotate_key for the fundyes
freeze, clawbackyes
close_windowco-signsco-signs
Mint, burn the fund tokenyes, on the platform's rail, outside Cell Seal
settle_dvp, deliver subscription sharesyesoperator
set_investor, publish_navyes, by role
register, submit_leg, set_spender, revoke_spender, mergeyes
settle_pairyes, both sides

9. Invariants ​

#InvariantEnforced by
T1balance(token, share wrapper) changes only at close_window, by exactly net, and at a holder's self-wrap deposit. After every close_window it equals supply_aftersettlement, on_deposit, on_withdraw. Indexer
T2The sum of sealed claims inside the wrapper equals the underlying token balance the wrapper holdsThe primitive's conservation proof. Indexer reconciliation
C1No sealed transfer happens outside settlementSealHooks
C2Every sealed transfer is bound to exactly one (order_id, role), and each such pair settles at most oncesettlement
C3The window's net enters or leaves the wrapper only at close_window, never before next_close. The issuer funds exactly net before and retires exactly |net| after, on the platform's railsettlement. Indexer reconciliation T1c in integration.md section 6
C4Every account in the share wrapper of fund f carries the auditor_id of fon_register
C5An allowance can only be granted to a settlement accounton_set_spender
C6No Cell Seal contract receives a plaintext amount, except the net of close_windowReview + tests

10. Operations ​

TopicRule
Storage TTLConfidentialAccount and SpenderDelegation entries are persistent. A host job (sandbox: seal-jobs) extends the TTL of every active account. An expired entry can still be restored but costs a restore fee
Upgradesettlement, fund-policy, fund-registry: platform admin, timelock. A fund's seal-token: that fund's issuer admin. The wrapper address is an input of every investor's key derivation (addr_f in keys.md section 2.1), so a wrapper is upgraded in place and never redeployed
Pilot capsettlement keeps a per-fund PilotCap. close_window refuses when the sealed supply after the close would exceed it (4107). Lifting the cap is set_pilot_cap by the platform admin, after audit
Pooled custody riskThe wrapper holds the underlying token for every holder. A SAC issuer that freezes or claws back the wrapper address affects every holder. For the fund token the issuer is the platform or a fund on the platform: the onboarding checklist in integration.md section 3 requires a commitment not to claw back the wrapper. For stablecoins this is a third-party risk and must be stated in the fund-facing documentation

Cell Private technical documentation.