diff --git a/Cargo.lock b/Cargo.lock
index b52cb3130..29268d581 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -568,6 +568,17 @@ dependencies = [
"stellar-contract-utils 0.8.0",
]
+[[package]]
+name = "confidential-rwa-token-example"
+version = "0.8.0"
+dependencies = [
+ "soroban-sdk 28.0.0",
+ "stellar-access 0.8.0",
+ "stellar-confidential",
+ "stellar-contract-utils 0.8.0",
+ "stellar-macros 0.8.0",
+]
+
[[package]]
name = "confidential-verifier-example"
version = "0.8.0"
diff --git a/examples/confidential/README.md b/examples/confidential/README.md
index 082fac529..d8308883a 100644
--- a/examples/confidential/README.md
+++ b/examples/confidential/README.md
@@ -4,7 +4,7 @@ This guide deploys a complete compliant confidential token suite on Stellar test
## Architecture Overview
-The suite consists of 5 contracts plus the underlying asset:
+The suite consists of 5 contracts plus the underlying asset; `rwa-token/` is an alternative to `compliant-token/` for regulated assets:
| Directory | Contract | Description |
| ------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------ |
@@ -12,6 +12,7 @@ The suite consists of 5 contracts plus the underlying asset:
| `auditor/` | `ConfidentialAuditorContract` | Registry of Grumpkin auditor public keys, indexed by `auditor_id` |
| `allowlist-policy/` | `AllowlistPolicyContract` | `Policy` contract that authorizes only the accounts a `manager` has listed |
| `compliant-token/` | `CompliantTokenContract` | Confidential token wired with `ComplianceHooks`: freeze, policy and SAC gates, config rotation, and clawback |
+| `rwa-token/` | `RwaTokenContract` | Confidential token wired with `RwaHooks`: the RWA profile (designated auditor, approval gate, forced transfer); not part of the walkthrough below, see [`docs/rwa.md`](../../packages/confidential/docs/rwa.md) |
| `factory/` | `ConfidentialFactoryContract` | Permissionless factory that deploys the allowlist and the token at addresses bound to their constructor args |
```text
diff --git a/examples/confidential/rwa-token/Cargo.toml b/examples/confidential/rwa-token/Cargo.toml
new file mode 100644
index 000000000..8f76f1afc
--- /dev/null
+++ b/examples/confidential/rwa-token/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "confidential-rwa-token-example"
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+publish = false
+version.workspace = true
+authors.workspace = true
+
+[package.metadata.stellar]
+cargo_inherit = true
+
+[lib]
+crate-type = ["cdylib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+stellar-access = { workspace = true }
+stellar-confidential = { workspace = true }
+stellar-macros = { workspace = true }
+
+[dev-dependencies]
+soroban-sdk = { workspace = true, features = ["testutils"] }
+stellar-contract-utils = { workspace = true }
diff --git a/examples/confidential/rwa-token/src/contract.rs b/examples/confidential/rwa-token/src/contract.rs
new file mode 100644
index 000000000..a7da57fef
--- /dev/null
+++ b/examples/confidential/rwa-token/src/contract.rs
@@ -0,0 +1,172 @@
+//! # Confidential RWA Token Example
+//!
+//! A [`ConfidentialToken`] wired with the RWA profile of the compliance
+//! extension (`packages/confidential/docs/compliance.md#rwa-profile`).
+//! `type Hooks = RwaHooks` rejects every operation until the deployment is
+//! configured, restricts accounts to the designated auditor, runs the freeze,
+//! policy, and SAC gates on the parties of every operation (with the two
+//! exceptions listed on [`RwaHooks`]: registration skips the freeze gate and
+//! `revoke_spender` does not gate the spender), and — when an approver is
+//! configured — requires its authorization for every value-moving
+//! operation. The contract implements [`ConfidentialCompliance`],
+//! [`ConfidentialClawback`], [`ConfidentialRwa`], and
+//! [`ConfidentialForcedTransfer`] on top.
+//!
+//! Wiring [`RwaHooks`] is what satisfies the deployment obligation of
+//! [`ConfidentialClawback`] and [`ConfidentialForcedTransfer`]: a frozen
+//! account cannot move its balance between the construction and the
+//! submission of an override proof.
+//!
+//! Admin authority follows the separation of duties in
+//! `packages/confidential/docs/compliance.md#admin-authority`, with one
+//! `access_control` role per duty:
+//!
+//! | Role | Entry points |
+//! |:---|:---|
+//! | `freezer` | `freeze`, `unfreeze` |
+//! | `config_manager` | `set_compliance_config`, `set_rwa_config`, `bump_approval_epoch` |
+//! | `clawback` | `clawback`, `force_revoke_spender` |
+//! | `forced_transfer` | `forced_transfer` |
+//!
+//! The constructor grants none of them; the admin grants each role after
+//! deployment. The approver is not a role: it is the address set through
+//! `set_rwa_config`, and it must not be the sender, recipient, or spender
+//! of an operation it approves.
+//!
+//! `clawback` and `forced_transfer` take a proof whose witness includes the
+//! auditor's secret key. Do not use either on a public network before the
+//! verifier accepts zero-knowledge proofs
+//! (`packages/confidential/docs/compliance.md#forced-transfer`).
+use soroban_sdk::{contract, contractimpl, Address, Bytes, Env, Symbol, Vec};
+use stellar_access::access_control::{self as access_control, AccessControl};
+use stellar_confidential::{
+ compliance::{
+ storage as compliance, ClawbackData, ComplianceConfig, ConfidentialClawback,
+ ConfidentialCompliance, ConfidentialForcedTransfer, ConfidentialRwa, RwaConfig, RwaHooks,
+ },
+ storage as confidential, ConfidentialAccount, ConfidentialToken, SpenderDelegation,
+};
+use stellar_macros::only_role;
+
+#[contract]
+pub struct RwaTokenContract;
+
+#[contractimpl]
+impl RwaTokenContract {
+ /// Binds the token to its underlying asset and registries, sets the
+ /// access-control admin, and writes both configurations.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `admin` - The access-control admin, which grants the `freezer`,
+ /// `config_manager`, `clawback`, and `forced_transfer` roles.
+ /// * `underlying_asset` - The SEP-41 token backing confidential balances.
+ /// * `verifier` - The confidential verifier registry.
+ /// * `auditor` - The confidential auditor registry.
+ /// * `compliance` - The initial compliance configuration. It must keep an
+ /// eligibility gate: a policy, SAC passthrough, or both.
+ /// * `auditor_id` - The designated auditor every account binds to. It
+ /// cannot be changed afterwards.
+ /// * `approver` - The address whose authorization every value-moving
+ /// operation additionally requires, or `None`.
+ ///
+ /// # Errors
+ ///
+ /// * refer to [`compliance::set_rwa_config`] errors.
+ #[allow(clippy::too_many_arguments)]
+ pub fn __constructor(
+ e: &Env,
+ admin: Address,
+ underlying_asset: Address,
+ verifier: Address,
+ auditor: Address,
+ compliance: ComplianceConfig,
+ auditor_id: u32,
+ approver: Option
,
+ ) {
+ confidential::set_underlying_asset(e, &underlying_asset);
+ confidential::set_verifier(e, &verifier);
+ confidential::set_auditor(e, &auditor);
+ confidential::set_address_as_field_element(e);
+ access_control::set_admin(e, &admin);
+ compliance::set_compliance_config(e, &compliance);
+ compliance::set_rwa_config(e, auditor_id, &approver);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl ConfidentialToken for RwaTokenContract {
+ type Hooks = RwaHooks;
+}
+
+#[contractimpl(contracttrait)]
+impl ConfidentialCompliance for RwaTokenContract {
+ #[only_role(operator, "freezer")]
+ fn freeze(e: &Env, account: Address, operator: Address) {
+ compliance::freeze(e, &account);
+ }
+
+ #[only_role(operator, "freezer")]
+ fn unfreeze(e: &Env, account: Address, operator: Address) {
+ compliance::unfreeze(e, &account);
+ }
+
+ #[only_role(operator, "config_manager")]
+ fn set_compliance_config(e: &Env, config: ComplianceConfig, operator: Address) {
+ compliance::set_compliance_config(e, &config);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl ConfidentialRwa for RwaTokenContract {
+ #[only_role(operator, "config_manager")]
+ fn set_rwa_config(e: &Env, auditor_id: u32, approver: Option, operator: Address) {
+ compliance::set_rwa_config(e, auditor_id, &approver);
+ }
+
+ #[only_role(operator, "config_manager")]
+ fn bump_approval_epoch(e: &Env, operator: Address) {
+ compliance::bump_approval_epoch(e);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl ConfidentialClawback for RwaTokenContract {
+ #[only_role(operator, "clawback")]
+ fn clawback(
+ e: &Env,
+ account: Address,
+ amount: i128,
+ destination: Option,
+ data: Bytes,
+ operator: Address,
+ ) {
+ let decoded: ClawbackData = confidential::decode_data(e, &data);
+ compliance::clawback(e, &account, amount, &destination, &decoded.proof);
+ }
+
+ #[only_role(operator, "clawback")]
+ fn force_revoke_spender(e: &Env, account: Address, spender: Address, operator: Address) {
+ compliance::force_revoke_spender(e, &account, &spender);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl ConfidentialForcedTransfer for RwaTokenContract {
+ #[only_role(operator, "forced_transfer")]
+ fn forced_transfer(
+ e: &Env,
+ from: Address,
+ to: Address,
+ amount: i128,
+ data: Bytes,
+ operator: Address,
+ ) {
+ let decoded: ClawbackData = confidential::decode_data(e, &data);
+ compliance::forced_transfer(e, &from, &to, amount, &decoded.proof);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl AccessControl for RwaTokenContract {}
diff --git a/examples/confidential/rwa-token/src/lib.rs b/examples/confidential/rwa-token/src/lib.rs
new file mode 100644
index 000000000..a879b6f80
--- /dev/null
+++ b/examples/confidential/rwa-token/src/lib.rs
@@ -0,0 +1,5 @@
+#![no_std]
+
+pub mod contract;
+#[cfg(test)]
+mod test;
diff --git a/examples/confidential/rwa-token/src/test.rs b/examples/confidential/rwa-token/src/test.rs
new file mode 100644
index 000000000..9aac33f28
--- /dev/null
+++ b/examples/confidential/rwa-token/src/test.rs
@@ -0,0 +1,765 @@
+extern crate std;
+
+use soroban_sdk::{
+ contract, contractimpl, contracttype,
+ testutils::{Address as _, Events, MockAuth, MockAuthInvoke},
+ token::StellarAssetClient,
+ xdr::ToXdr,
+ Address, Bytes, BytesN, Env, Error, Event, IntoVal, Symbol,
+};
+use stellar_access::access_control::AccessControlError;
+use stellar_confidential::{
+ auditor::{storage as auditor_storage, ConfidentialAuditor, ConfidentialAuditorClient},
+ compliance::{
+ storage::transfer_approval_args, ClawbackData, ComplianceConfig, ComplianceError,
+ ForcedTransfer, Policy, RwaConfig,
+ },
+ verifier::{CircuitType, ConfidentialVerifier},
+ RegisterData, RegisterPayload, TransferData, TransferPayload,
+};
+use stellar_contract_utils::crypto::grumpkin::Grumpkin;
+
+use crate::contract::{RwaTokenContract, RwaTokenContractClient};
+
+const AUDITOR_ID: u32 = 0;
+
+// ################## MOCK CONTRACTS ##################
+
+#[contract]
+struct MockVerifier;
+
+#[contractimpl(contracttrait)]
+impl ConfidentialVerifier for MockVerifier {
+ fn register_verification_key(_e: &Env, _ct: CircuitType, _vk: Bytes, _op: Address) {}
+
+ fn update_verification_key(_e: &Env, _ct: CircuitType, _vk: Bytes, _op: Address) {}
+
+ fn verify_proof(_e: &Env, _ct: CircuitType, _pi: Bytes, _proof: Bytes) -> bool {
+ true
+ }
+}
+
+#[contract]
+struct MockAuditor;
+
+#[contractimpl(contracttrait)]
+impl ConfidentialAuditor for MockAuditor {
+ fn register_key(e: &Env, auditor_id: u32, point: BytesN<64>, _operator: Address) {
+ auditor_storage::register_key(e, auditor_id, &point);
+ }
+
+ fn rotate_key(_e: &Env, _auditor_id: u32, _new_point: BytesN<64>, _operator: Address) {}
+}
+
+#[contracttype]
+enum MockAllowlistKey {
+ Allowed(Address),
+}
+
+/// Stands in for the identity policy of a regulated deployment: an
+/// allowlist of verified investors.
+#[contract]
+struct MockAllowlist;
+
+#[contractimpl]
+impl MockAllowlist {
+ pub fn allow(e: &Env, account: Address) {
+ e.storage().persistent().set(&MockAllowlistKey::Allowed(account), &());
+ }
+}
+
+#[contractimpl]
+impl Policy for MockAllowlist {
+ fn is_authorized(e: Env, account: Address, _token: Address) -> bool {
+ e.storage().persistent().has(&MockAllowlistKey::Allowed(account))
+ }
+}
+
+// ################## SETUP ##################
+
+struct Harness<'a> {
+ e: Env,
+ sac: StellarAssetClient<'a>,
+ token: RwaTokenContractClient<'a>,
+ allowlist: MockAllowlistClient<'a>,
+ admin: Address,
+ freezer: Address,
+ config_manager: Address,
+ clawback: Address,
+ forced_transfer: Address,
+ treasury: Address,
+}
+
+fn setup<'a>(approver: Option) -> Harness<'a> {
+ let e = Env::default();
+ e.mock_all_auths();
+ deploy(e, approver)
+}
+
+fn deploy<'a>(e: Env, approver: Option) -> Harness<'a> {
+ let sac = e.register_stellar_asset_contract_v2(Address::generate(&e));
+ let sac = StellarAssetClient::new(&e, &sac.address());
+
+ let verifier = e.register(MockVerifier, ());
+ let auditor = e.register(MockAuditor, ());
+ ConfidentialAuditorClient::new(&e, &auditor).register_key(
+ &AUDITOR_ID,
+ &Grumpkin::generator(&e),
+ &Address::generate(&e),
+ );
+ let allowlist = MockAllowlistClient::new(&e, &e.register(MockAllowlist, ()));
+
+ let admin = Address::generate(&e);
+ let address = e.register(
+ RwaTokenContract,
+ (
+ admin.clone(),
+ sac.address.clone(),
+ verifier,
+ auditor,
+ ComplianceConfig { policy: Some(allowlist.address.clone()), sac_passthrough: false },
+ AUDITOR_ID,
+ approver,
+ ),
+ );
+ let token = RwaTokenContractClient::new(&e, &address);
+
+ let freezer = Address::generate(&e);
+ let config_manager = Address::generate(&e);
+ let clawback = Address::generate(&e);
+ let forced_transfer = Address::generate(&e);
+ token.grant_role(&freezer, &Symbol::new(&e, "freezer"), &admin);
+ token.grant_role(&config_manager, &Symbol::new(&e, "config_manager"), &admin);
+ token.grant_role(&clawback, &Symbol::new(&e, "clawback"), &admin);
+ token.grant_role(&forced_transfer, &Symbol::new(&e, "forced_transfer"), &admin);
+
+ let treasury = Address::generate(&e);
+ Harness {
+ e,
+ sac,
+ token,
+ allowlist,
+ admin,
+ freezer,
+ config_manager,
+ clawback,
+ forced_transfer,
+ treasury,
+ }
+}
+
+impl Harness<'_> {
+ /// Onboards `account`: allowlists it and registers its confidential
+ /// account under the designated auditor.
+ fn onboard(&self, account: &Address) {
+ self.allowlist.allow(account);
+ self.token.register(account, &AUDITOR_ID, ®ister_data(&self.e));
+ }
+
+ fn investor(&self) -> Address {
+ let a = Address::generate(&self.e);
+ self.onboard(&a);
+ a
+ }
+
+ /// The issuance flow: mint to the treasury, deposit, merge.
+ fn issue_to_treasury(&self, amount: i128) {
+ self.onboard(&self.treasury);
+ self.sac.mint(&self.treasury, &amount);
+ self.token.deposit(&self.treasury, &self.treasury, &amount);
+ self.token.merge(&self.treasury);
+ }
+}
+
+fn field(e: &Env, byte: u8) -> BytesN<32> {
+ let mut b = [0u8; 32];
+ b[31] = byte;
+ BytesN::from_array(e, &b)
+}
+
+fn register_data(e: &Env) -> Bytes {
+ RegisterData {
+ payload: RegisterPayload { y: Grumpkin::generator(e), pvk: Grumpkin::generator(e) },
+ proof: Bytes::new(e),
+ }
+ .to_xdr(e)
+}
+
+fn transfer_payload(e: &Env) -> TransferPayload {
+ TransferPayload {
+ c_spend_new: Grumpkin::generator(e),
+ c_transfer: Grumpkin::generator(e),
+ r_e_point: Grumpkin::generator(e),
+ v_tilde: field(e, 1),
+ b_tilde: field(e, 2),
+ sigma: field(e, 3),
+ v_tilde_aud_r: field(e, 4),
+ r_tilde_aud_r: field(e, 5),
+ v_tilde_aud_s: field(e, 6),
+ b_tilde_aud_s: field(e, 7),
+ r_tilde_aud_s: field(e, 8),
+ }
+}
+
+fn transfer_data(e: &Env) -> Bytes {
+ TransferData { payload: transfer_payload(e), proof: Bytes::new(e) }.to_xdr(e)
+}
+
+fn proof_data(e: &Env) -> Bytes {
+ ClawbackData { proof: Bytes::new(e) }.to_xdr(e)
+}
+
+fn contract_error(code: u32) -> Error {
+ Error::from_contract_error(code)
+}
+
+// ################## CONSTRUCTION ##################
+
+#[test]
+fn constructor_writes_both_configurations() {
+ let h = setup(None);
+ assert_eq!(h.token.rwa_config(), Some(RwaConfig { auditor_id: AUDITOR_ID, approver: None }));
+ assert_eq!(h.token.approval_epoch(), 1);
+ assert!(h.token.compliance_config().is_some());
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3615)")]
+fn constructor_rejects_missing_eligibility_gate() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let sac = e.register_stellar_asset_contract_v2(Address::generate(&e));
+ let auditor = e.register(MockAuditor, ());
+ ConfidentialAuditorClient::new(&e, &auditor).register_key(
+ &AUDITOR_ID,
+ &Grumpkin::generator(&e),
+ &Address::generate(&e),
+ );
+ e.register(
+ RwaTokenContract,
+ (
+ Address::generate(&e),
+ sac.address(),
+ e.register(MockVerifier, ()),
+ auditor,
+ ComplianceConfig { policy: None, sac_passthrough: false },
+ AUDITOR_ID,
+ None::,
+ ),
+ );
+}
+
+// ################## ROLES ##################
+
+#[test]
+fn rwa_entry_points_require_their_roles() {
+ let h = setup(None);
+ let outsider = Address::generate(&h.e);
+ let unauthorized = Error::from_contract_error(AccessControlError::Unauthorized as u32);
+
+ assert_eq!(
+ h.token.try_set_rwa_config(&AUDITOR_ID, &None, &outsider).unwrap_err().unwrap(),
+ unauthorized
+ );
+ assert_eq!(h.token.try_bump_approval_epoch(&outsider).unwrap_err().unwrap(), unauthorized);
+ assert_eq!(
+ h.token
+ .try_forced_transfer(&outsider, &outsider, &1, &proof_data(&h.e), &outsider)
+ .unwrap_err()
+ .unwrap(),
+ unauthorized
+ );
+ // A role does not extend to another duty.
+ assert_eq!(h.token.try_bump_approval_epoch(&h.freezer).unwrap_err().unwrap(), unauthorized);
+ assert_eq!(
+ h.token
+ .try_forced_transfer(&outsider, &outsider, &1, &proof_data(&h.e), &h.config_manager)
+ .unwrap_err()
+ .unwrap(),
+ unauthorized
+ );
+}
+
+#[test]
+fn compliance_entry_points_require_their_roles() {
+ let h = setup(None);
+ let outsider = Address::generate(&h.e);
+ let account = h.investor();
+ let unauthorized = Error::from_contract_error(AccessControlError::Unauthorized as u32);
+ let config = h.token.compliance_config().unwrap();
+
+ assert_eq!(h.token.try_freeze(&account, &outsider).unwrap_err().unwrap(), unauthorized);
+ assert_eq!(h.token.try_unfreeze(&account, &outsider).unwrap_err().unwrap(), unauthorized);
+ assert_eq!(
+ h.token.try_set_compliance_config(&config, &outsider).unwrap_err().unwrap(),
+ unauthorized
+ );
+ assert_eq!(
+ h.token
+ .try_clawback(&account, &1, &None, &proof_data(&h.e), &outsider)
+ .unwrap_err()
+ .unwrap(),
+ unauthorized
+ );
+ assert_eq!(
+ h.token.try_force_revoke_spender(&account, &outsider, &outsider).unwrap_err().unwrap(),
+ unauthorized
+ );
+ // The forced-transfer role does not carry the clawback duty, nor the
+ // reverse.
+ assert_eq!(
+ h.token
+ .try_clawback(&account, &1, &None, &proof_data(&h.e), &h.forced_transfer)
+ .unwrap_err()
+ .unwrap(),
+ unauthorized
+ );
+ assert_eq!(
+ h.token
+ .try_forced_transfer(&account, &account, &1, &proof_data(&h.e), &h.clawback)
+ .unwrap_err()
+ .unwrap(),
+ unauthorized
+ );
+}
+
+#[test]
+fn freeze_clawback_and_unfreeze_by_their_roles() {
+ let h = setup(None);
+ let account = h.investor();
+ h.sac.mint(&account, &100);
+ h.token.deposit(&account, &account, &100);
+
+ h.token.freeze(&account, &h.freezer);
+ assert!(!h.token.can_receive(&account));
+ h.token.clawback(&account, &40, &None, &proof_data(&h.e), &h.clawback);
+ assert_eq!(
+ h.token.confidential_balance(&account).spendable_commitment,
+ Grumpkin::mul(&h.e, &Grumpkin::generator(&h.e), 60)
+ );
+ h.token.unfreeze(&account, &h.freezer);
+ assert!(h.token.can_receive(&account));
+}
+
+#[test]
+fn config_manager_changes_the_policy_and_the_epoch_advances() {
+ let h = setup(None);
+ let config =
+ ComplianceConfig { policy: Some(h.allowlist.address.clone()), sac_passthrough: true };
+ h.token.set_compliance_config(&config, &h.config_manager);
+ assert_eq!(h.token.compliance_config(), Some(config));
+ assert_eq!(h.token.approval_epoch(), 2);
+
+ // The eligibility gate cannot be removed.
+ assert_eq!(
+ h.token
+ .try_set_compliance_config(
+ &ComplianceConfig { policy: None, sac_passthrough: false },
+ &h.config_manager
+ )
+ .unwrap_err()
+ .unwrap(),
+ contract_error(ComplianceError::EligibilityRequired as u32)
+ );
+}
+
+#[test]
+fn config_manager_rotates_the_approver() {
+ let h = setup(None);
+ let approver = Address::generate(&h.e);
+ h.token.set_rwa_config(&AUDITOR_ID, &Some(approver.clone()), &h.config_manager);
+ assert_eq!(
+ h.token.rwa_config(),
+ Some(RwaConfig { auditor_id: AUDITOR_ID, approver: Some(approver) })
+ );
+ assert_eq!(h.token.approval_epoch(), 2);
+
+ h.token.bump_approval_epoch(&h.config_manager);
+ assert_eq!(h.token.approval_epoch(), 3);
+}
+
+#[test]
+fn designated_auditor_cannot_be_changed() {
+ let h = setup(None);
+ assert_eq!(
+ h.token.try_set_rwa_config(&7, &None, &h.config_manager).unwrap_err().unwrap(),
+ contract_error(ComplianceError::AuditorAlreadySet as u32)
+ );
+}
+
+// ################## TIER 0: MONITOR AND REMEDIATE ##################
+
+#[test]
+fn issuance_and_transfer_between_verified_investors() {
+ let h = setup(None);
+ h.issue_to_treasury(1_000);
+ let alice = h.investor();
+
+ assert!(h.token.can_send(&h.treasury));
+ assert!(h.token.can_receive(&alice));
+ h.token.confidential_transfer(&h.treasury, &alice, &transfer_data(&h.e));
+
+ assert_eq!(
+ h.token.confidential_balance(&alice).receiving_commitment,
+ Grumpkin::generator(&h.e)
+ );
+}
+
+#[test]
+fn unverified_address_cannot_register_or_receive() {
+ let h = setup(None);
+ h.issue_to_treasury(1_000);
+ let stranger = Address::generate(&h.e);
+
+ assert_eq!(
+ h.token.try_register(&stranger, &AUDITOR_ID, ®ister_data(&h.e)).unwrap_err().unwrap(),
+ contract_error(ComplianceError::NotAuthorizedByPolicy as u32)
+ );
+ assert!(!h.token.can_receive(&stranger));
+ assert_eq!(
+ h.token
+ .try_confidential_transfer(&h.treasury, &stranger, &transfer_data(&h.e))
+ .unwrap_err()
+ .unwrap(),
+ contract_error(ComplianceError::NotAuthorizedByPolicy as u32)
+ );
+}
+
+#[test]
+fn registration_under_another_auditor_is_rejected() {
+ let h = setup(None);
+ let alice = Address::generate(&h.e);
+ h.allowlist.allow(&alice);
+ assert_eq!(
+ h.token.try_register(&alice, &1, ®ister_data(&h.e)).unwrap_err().unwrap(),
+ contract_error(ComplianceError::AuditorNotAllowed as u32)
+ );
+}
+
+#[test]
+fn lost_account_is_recovered_by_forced_transfer() {
+ let h = setup(None);
+ let lost = h.investor();
+ h.sac.mint(&lost, &300);
+ h.token.deposit(&lost, &lost, &300);
+ let fresh = h.investor();
+
+ h.token.freeze(&lost, &h.freezer);
+ h.token.forced_transfer(&lost, &fresh, &300, &proof_data(&h.e), &h.forced_transfer);
+
+ let events = h.e.events().all();
+ assert_eq!(events.events().len(), 1);
+ assert_eq!(
+ events.events().first().unwrap(),
+ &ForcedTransfer { from: lost.clone(), to: fresh.clone(), amount: 300, nonce: 0 }
+ .to_xdr(&h.e, &h.token.address),
+ );
+ assert_eq!(
+ h.token.confidential_balance(&fresh).receiving_commitment,
+ Grumpkin::mul(&h.e, &Grumpkin::generator(&h.e), 300)
+ );
+ assert_eq!(h.token.confidential_balance(&lost).spendable_commitment, Grumpkin::identity(&h.e));
+ // The pool still backs every claim.
+ assert_eq!(h.sac.balance(&h.token.address), 300);
+ assert_eq!(h.token.clawback_nonce(&lost), 1);
+}
+
+#[test]
+fn forced_transfer_requires_a_frozen_source() {
+ let h = setup(None);
+ let a = h.investor();
+ let b = h.investor();
+ assert_eq!(
+ h.token
+ .try_forced_transfer(&a, &b, &1, &proof_data(&h.e), &h.forced_transfer)
+ .unwrap_err()
+ .unwrap(),
+ contract_error(ComplianceError::AccountNotFrozen as u32)
+ );
+}
+
+// ################## TIER 1: APPROVAL GATE ##################
+
+/// Submits `confidential_transfer(treasury, to, data)` with the treasury's
+/// authorization and, when `approved`, the approver's authorization over the
+/// approval arguments.
+fn submit_transfer(h: &Harness, approver: &Address, to: &Address, approved: bool) {
+ let e = &h.e;
+ let data = transfer_data(e);
+ let sender = MockAuthInvoke {
+ contract: &h.token.address,
+ fn_name: "confidential_transfer",
+ args: (h.treasury.clone(), to.clone(), data.clone()).into_val(e),
+ sub_invokes: &[],
+ };
+ let approval_args = e.as_contract(&h.token.address, || {
+ transfer_approval_args(e, &h.treasury, to, &transfer_payload(e))
+ });
+ let approval = MockAuthInvoke {
+ contract: &h.token.address,
+ fn_name: "confidential_transfer",
+ args: approval_args,
+ sub_invokes: &[],
+ };
+ if approved {
+ h.token
+ .mock_auths(&[
+ MockAuth { address: &h.treasury, invoke: &sender },
+ MockAuth { address: approver, invoke: &approval },
+ ])
+ .confidential_transfer(&h.treasury, to, &data);
+ } else {
+ h.token
+ .mock_auths(&[MockAuth { address: &h.treasury, invoke: &sender }])
+ .confidential_transfer(&h.treasury, to, &data);
+ }
+}
+
+fn approved_deployment<'a>() -> (Harness<'a>, Address, Address) {
+ let e = Env::default();
+ e.mock_all_auths();
+ let approver = Address::generate(&e);
+ let h = deploy(e, Some(approver.clone()));
+ h.issue_to_treasury(1_000);
+ let alice = h.investor();
+ (h, approver, alice)
+}
+
+#[test]
+fn transfer_settles_with_the_approver_authorization() {
+ let (h, approver, alice) = approved_deployment();
+ submit_transfer(&h, &approver, &alice, true);
+ assert_eq!(
+ h.token.confidential_balance(&alice).receiving_commitment,
+ Grumpkin::generator(&h.e)
+ );
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn transfer_fails_without_the_approver_authorization() {
+ let (h, approver, alice) = approved_deployment();
+ submit_transfer(&h, &approver, &alice, false);
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn approval_is_void_after_an_epoch_bump() {
+ let (h, approver, alice) = approved_deployment();
+ let e = &h.e;
+ // The approval is built for the current epoch...
+ let stale_args = e.as_contract(&h.token.address, || {
+ transfer_approval_args(e, &h.treasury, &alice, &transfer_payload(e))
+ });
+ // ...and the issuer voids outstanding approvals before it is used.
+ h.token.bump_approval_epoch(&h.config_manager);
+
+ let data = transfer_data(e);
+ let sender = MockAuthInvoke {
+ contract: &h.token.address,
+ fn_name: "confidential_transfer",
+ args: (h.treasury.clone(), alice.clone(), data.clone()).into_val(e),
+ sub_invokes: &[],
+ };
+ let approval = MockAuthInvoke {
+ contract: &h.token.address,
+ fn_name: "confidential_transfer",
+ args: stale_args,
+ sub_invokes: &[],
+ };
+ h.token
+ .mock_auths(&[
+ MockAuth { address: &h.treasury, invoke: &sender },
+ MockAuth { address: &approver, invoke: &approval },
+ ])
+ .confidential_transfer(&h.treasury, &alice, &data);
+}
+
+#[test]
+fn admin_is_not_the_approver() {
+ // Holding every role does not satisfy the approval gate.
+ let (h, _approver, alice) = approved_deployment();
+ let e = &h.e;
+ let data = transfer_data(e);
+ let sender = MockAuthInvoke {
+ contract: &h.token.address,
+ fn_name: "confidential_transfer",
+ args: (h.treasury.clone(), alice.clone(), data.clone()).into_val(e),
+ sub_invokes: &[],
+ };
+ let approval_args = e.as_contract(&h.token.address, || {
+ transfer_approval_args(e, &h.treasury, &alice, &transfer_payload(e))
+ });
+ let forged = MockAuthInvoke {
+ contract: &h.token.address,
+ fn_name: "confidential_transfer",
+ args: approval_args,
+ sub_invokes: &[],
+ };
+ let res = h
+ .token
+ .mock_auths(&[
+ MockAuth { address: &h.treasury, invoke: &sender },
+ MockAuth { address: &h.admin, invoke: &forged },
+ ])
+ .try_confidential_transfer(&h.treasury, &alice, &data);
+ assert!(res.is_err());
+}
+
+// ################## BRANCH COVERAGE ##################
+
+/// Asserts that `f` aborts on the host's authorization check. A `try_`
+/// client call reports every host error as `Error(Context, InvalidAction)`,
+/// so the panic message is inspected instead.
+fn assert_auth_failure(case: &str, f: impl FnOnce()) {
+ let payload = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f))
+ .expect_err(&std::format!("{case}: the call succeeded"));
+ let message = payload
+ .downcast_ref::()
+ .cloned()
+ .or_else(|| payload.downcast_ref::<&str>().map(|s| std::string::String::from(*s)))
+ .unwrap_or_default();
+ assert!(message.contains("Error(Auth, InvalidAction)"), "{case}: {message}");
+}
+
+// Pins branch: #[only_role] entry points / the operator holds the role but
+// did not sign / Error(Auth, InvalidAction); nothing changes
+// Verifies: INV-21
+#[test]
+fn role_holders_must_authorize() {
+ let h = setup(None);
+ let a = h.investor();
+ let b = h.investor();
+ h.token.freeze(&a, &h.freezer);
+ let config =
+ ComplianceConfig { policy: Some(h.allowlist.address.clone()), sac_passthrough: true };
+ let unsigned = h.token.mock_auths(&[]);
+
+ assert_auth_failure("freeze", || unsigned.freeze(&b, &h.freezer));
+ assert_auth_failure("unfreeze", || unsigned.unfreeze(&a, &h.freezer));
+ assert_auth_failure("set_compliance_config", || {
+ unsigned.set_compliance_config(&config, &h.config_manager)
+ });
+ assert_auth_failure("set_rwa_config", || {
+ unsigned.set_rwa_config(&AUDITOR_ID, &None, &h.config_manager)
+ });
+ assert_auth_failure("bump_approval_epoch", || unsigned.bump_approval_epoch(&h.config_manager));
+ assert_auth_failure("clawback", || {
+ unsigned.clawback(&a, &1, &None, &proof_data(&h.e), &h.clawback)
+ });
+ assert_auth_failure("force_revoke_spender", || {
+ unsigned.force_revoke_spender(&a, &b, &h.clawback)
+ });
+ assert_auth_failure("forced_transfer", || {
+ unsigned.forced_transfer(&a, &b, &1, &proof_data(&h.e), &h.forced_transfer)
+ });
+
+ h.e.mock_all_auths();
+ assert!(h.token.is_frozen(&a));
+ assert!(!h.token.is_frozen(&b));
+ assert_eq!(h.token.approval_epoch(), 1);
+}
+
+// Pins branch: ConfidentialToken entry points of the example / the
+// account is frozen / AccountFrozen #3601 until the freezer unfreezes it
+// Verifies: INV-16, the deployment obligation stated in contract.rs
+#[test]
+fn frozen_investor_is_immobile_until_unfrozen() {
+ let h = setup(None);
+ h.issue_to_treasury(1_000);
+ let alice = h.investor();
+ let frozen = contract_error(ComplianceError::AccountFrozen as u32);
+
+ h.token.freeze(&h.treasury, &h.freezer);
+ assert!(h.token.is_frozen(&h.treasury));
+ assert!(!h.token.can_send(&h.treasury));
+ assert_eq!(
+ h.token
+ .try_confidential_transfer(&h.treasury, &alice, &transfer_data(&h.e))
+ .unwrap_err()
+ .unwrap(),
+ frozen
+ );
+ assert_eq!(h.token.try_merge(&h.treasury).unwrap_err().unwrap(), frozen);
+ // A frozen account cannot be credited either.
+ h.sac.mint(&alice, &10);
+ assert_eq!(h.token.try_deposit(&alice, &h.treasury, &10).unwrap_err().unwrap(), frozen);
+
+ h.token.unfreeze(&h.treasury, &h.freezer);
+ assert!(!h.token.is_frozen(&h.treasury));
+ h.token.confidential_transfer(&h.treasury, &alice, &transfer_data(&h.e));
+ assert_eq!(
+ h.token.confidential_balance(&alice).receiving_commitment,
+ Grumpkin::generator(&h.e)
+ );
+}
+
+// Pins branch: force_revoke_spender / operator holds `clawback` / the role
+// guard passes and the library is reached (DelegationNotFound #3504)
+// Verifies: INV-21
+#[test]
+fn clawback_role_reaches_force_revoke_spender() {
+ let h = setup(None);
+ let target = h.investor();
+ let spender = h.investor();
+ h.token.freeze(&target, &h.freezer);
+ assert_eq!(
+ h.token.try_force_revoke_spender(&target, &spender, &h.clawback).unwrap_err().unwrap(),
+ contract_error(3504) // ConfidentialTokenError::DelegationNotFound
+ );
+}
+
+// Pins branch: __constructor / auditor_id not in the registry /
+// AuditorNotRegistered #3301
+// Verifies: INV-2
+#[test]
+#[should_panic(expected = "Error(Contract, #3301)")] // AuditorError::AuditorNotRegistered
+fn constructor_rejects_unregistered_auditor() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let sac = e.register_stellar_asset_contract_v2(Address::generate(&e));
+ let allowlist = e.register(MockAllowlist, ());
+ e.register(
+ RwaTokenContract,
+ (
+ Address::generate(&e),
+ sac.address(),
+ e.register(MockVerifier, ()),
+ e.register(MockAuditor, ()),
+ ComplianceConfig { policy: Some(allowlist), sac_passthrough: false },
+ AUDITOR_ID,
+ None::,
+ ),
+ );
+}
+
+// Pins branch: __constructor / approver Some / both configurations are
+// stored exactly, the admin is set, holds none of the four roles and
+// cannot call a guarded entry point
+// Verifies: INV-21
+#[test]
+fn constructor_stores_the_approver_and_grants_no_role() {
+ let e = Env::default();
+ e.mock_all_auths();
+ let approver = Address::generate(&e);
+ let h = deploy(e, Some(approver.clone()));
+ let unauthorized = Error::from_contract_error(AccessControlError::Unauthorized as u32);
+
+ assert_eq!(
+ h.token.rwa_config(),
+ Some(RwaConfig { auditor_id: AUDITOR_ID, approver: Some(approver) })
+ );
+ assert_eq!(
+ h.token.compliance_config(),
+ Some(ComplianceConfig {
+ policy: Some(h.allowlist.address.clone()),
+ sac_passthrough: false
+ })
+ );
+ assert_eq!(h.token.get_admin(), Some(h.admin.clone()));
+ for role in ["freezer", "config_manager", "clawback", "forced_transfer"] {
+ assert_eq!(h.token.has_role(&h.admin, &Symbol::new(&h.e, role)), None, "{role}");
+ }
+ assert_eq!(h.token.try_freeze(&h.treasury, &h.admin).unwrap_err().unwrap(), unauthorized);
+ assert_eq!(h.token.try_bump_approval_epoch(&h.admin).unwrap_err().unwrap(), unauthorized);
+}
diff --git a/packages/confidential/CLAUDE.md b/packages/confidential/CLAUDE.md
index adebe4324..20a6b0cf7 100644
--- a/packages/confidential/CLAUDE.md
+++ b/packages/confidential/CLAUDE.md
@@ -11,7 +11,7 @@ The crate ships one token contract at its root (`src/lib.rs` / `src/storage.rs`
| `src/lib.rs`, `src/storage.rs` | The `ConfidentialToken` trait — eleven entry points, of which `revoke_spender` is proofless — and the storage/orchestration layer |
| `src/verifier/` | Separate contract holding per-circuit UltraHonk verification keys |
| `src/auditor/` | Separate contract holding the auditor key registry |
-| `src/compliance/` | `ComplianceHooks` — freeze, SAC passthrough, policy contract — plus the opt-in `ConfidentialClawback` trait (`clawback`, `force_revoke_spender`) |
+| `src/compliance/` | `ComplianceHooks` — freeze, SAC passthrough, policy contract — plus the opt-in `ConfidentialClawback` trait (`clawback`, `force_revoke_spender`); and the RWA profile: `RwaHooks`, `ConfidentialRwa`, and the opt-in `ConfidentialForcedTransfer` (tests in `test/rwa.rs`) |
| `circuits/` | Noir workspace, compiled by `nargo`, not `cargo` |
| `docs/` | The specification set; `docs/README.md` is its map (see below) |
@@ -37,7 +37,7 @@ Beyond the root guide's conventions:
- Proof verification is mocked. `MockVerifier` / `MockAuditor` in `src/test.rs` stand in for the real contracts, and proofs are empty `Bytes::new(e)`. Do not attempt to generate real proofs in Rust tests.
- Use `fixture_point` / `fixture_field`, not arbitrary bytes. The fixtures are canonical and on-curve; random values fail the canonicality guards before reaching any logic under test.
-- Two mocks model proof semantics on purpose: the register mock in `src/test.rs` binds the first `acct_f` it sees, and `ClawbackReplayGuardVerifier` in `src/compliance/test.rs` binds the first whole blob, both standing in for UltraHonk's absorption of public inputs, so replay tests are meaningful. Keep that behaviour if either flow changes.
+- Two mocks model proof semantics on purpose: the register mock in `src/test.rs` binds the first `acct_f` it sees, and `ClawbackReplayGuardVerifier` in `src/compliance/test/mod.rs` binds the first whole blob, both standing in for UltraHonk's absorption of public inputs, so replay tests are meaningful. Keep that behaviour if either flow changes.
## Noir circuits
@@ -56,6 +56,7 @@ Each change below spans several files and lands in one PR with all of its sites:
- **Adding a circuit.** The package, the `members` list in `circuits/Nargo.toml`, and the hardcoded `CIRCUITS` arrays in both `circuits/scripts/extract_vks.sh` and `circuits/scripts/build_vk_bins.sh`; then regenerate `constraints.baseline` and both VK formats.
- **Renaming a package.** Directory `transfer/` is package `circuit_transfer`. Both VK scripts derive `circuit_${name}` and `target/${pkg}.json` from the directory name, and every row of `constraints.baseline` is keyed on the package name.
- **Bumping the toolchain.** `NARGO_VERSION` and `BB_VERSION` in `noir.yml` are the source of truth and move together; the VKs are byte-sensitive to bb. Both versions are restated in the comment above `compiler_version` in every package `Nargo.toml` and in `circuits/vks/README.md`; the nargo version in the `constraints.baseline` header; the bb version in `circuits/scripts/check_witness_lookups.sh`, whose blackbox allowlist must be re-confirmed against the new bb, and in `README.md`, which names the bb version the on-chain verifier accepts. The CI installers are pinned by commit with SHA256 verification; bump URL and hash together, then regenerate the baseline and both VK formats.
+- **`forced_destination_to_field` vectors.** `circuits/lib/testdata/forced_destination_to_field.json` is guarded on the contract side by `forced_destination_to_field_matches_testdata_vectors` in `src/compliance/test/rwa.rs`, and its Poseidon2 step by `fixtures_match_testdata` in `circuits/lib/src/tests.nr`; both transcribe the hex values. Update all three together.
- **`address_to_field` vectors.** The derivation has no Noir implementation (circuits take `addr_f` as an opaque public input), so `circuits/lib/testdata/address_to_field.json` is guarded only by `address_to_field_matches_testdata_vectors` in `src/test.rs`, which transcribes the hex values as literals rather than reading the JSON. Update both together.
Regenerating `constraints.baseline` with the command in its header overwrites that header, and CI strips comments before diffing, so nothing flags the loss. Restore the header by hand.
@@ -74,7 +75,7 @@ Most facts now have exactly one owning section and are linked from everywhere el
| Content | Owner | Copies live in |
|:---|:---|:---|
-| `Clawback` is a `T₀` anchor alongside `Merge` | `docs/protocol/wallet-state.md#recovery` | `docs/compliance.md#wallet-and-auditor-consequences`, the role column of `docs/indexer.md#events-in-scope` |
+| `Clawback`, and a `ForcedTransfer` for its `from`, is a `T₀` anchor alongside `Merge` | `docs/protocol/wallet-state.md#recovery` | `docs/compliance.md#wallet-and-auditor-consequences`, the role column of `docs/indexer.md#events-in-scope` |
| Per-circuit public-input counts | the `Public inputs` heading of each `docs/protocol/operations/*.md` and `docs/compliance.md#circuit` | header comments of `circuits/*/src/main.nr` |
| Domain-separation tag values | `docs/protocol/domain-separators.md` | `circuits/lib/src/lib.nr`, `circuits/lib/testdata/*.json` |
diff --git a/packages/confidential/README.md b/packages/confidential/README.md
index ab1ec6936..8bc64441f 100644
--- a/packages/confidential/README.md
+++ b/packages/confidential/README.md
@@ -76,6 +76,8 @@ Stores one UltraHonk verification key per [`CircuitType`]. Updating a VK is **so
A turnkey [`Hooks`] implementation layering deployer-configurable controls on top of the token: per-account freezing, SAC `authorized()` passthrough, and an optional external authorization policy. Wire as `type Hooks = ComplianceHooks;`. See [`docs/compliance.md`](./docs/compliance.md) for the specification.
+For regulated assets the module also provides the RWA profile: `RwaHooks`, which binds every account to a designated auditor and can require an approver's authorization for every value-moving operation, and the opt-in `ConfidentialForcedTransfer`. See [RWA Profile](./docs/compliance.md#rwa-profile) and the integrator guide [`docs/rwa.md`](./docs/rwa.md).
+
### `circuits`
Noir sources, build scripts, and the pinned UltraHonk VKs that the verifier registry serves. Regeneration is gated on a CI diff against the committed `circuits/vks/*.vk.json` files; see [`circuits/vks/README.md`](./circuits/vks/README.md) for the toolchain pin and re-extraction procedure.
diff --git a/packages/confidential/circuits/clawback/src/main.nr b/packages/confidential/circuits/clawback/src/main.nr
index c74f56faf..6abd3f702 100644
--- a/packages/confidential/circuits/clawback/src/main.nr
+++ b/packages/confidential/circuits/clawback/src/main.nr
@@ -61,6 +61,9 @@ mod tests;
// per-call.
// 9 _dest_f dest_f `address_to_field(destination)` under
// `Some`, the zero field under `None`;
+// `Poseidon2(18, address_to_field(to))` for
+// a forced transfer
+// (docs/compliance.md#forced-transfer);
// computed per-call.
// 10 _nonce nonce The target's clawback nonce, loaded from
// storage; the contract increments it on
diff --git a/packages/confidential/circuits/lib/src/lib.nr b/packages/confidential/circuits/lib/src/lib.nr
index 5bbb48b7b..39e28ec90 100644
--- a/packages/confidential/circuits/lib/src/lib.nr
+++ b/packages/confidential/circuits/lib/src/lib.nr
@@ -91,6 +91,7 @@ global POSEIDON2_IV_BASE: Field = 18446744073709551616; // 2^64
/// | `delta_aud_r` | 12 | `AUDITOR_RECIPIENT` |
/// | `delta_ecdh` | 13 | `ECDH_SHARED_SECRET` |
/// | `delta_esc_allow_r_aud` | 17 | `ESCROWED_ALLOWANCE_BLINDING_AUDITOR` |
+/// | `delta_forced_dest` | 18 | `FORCED_DESTINATION` |
///
/// Tags 14-16 are not implemented here (docs/protocol/domain-separators.md).
pub mod domain {
@@ -151,6 +152,11 @@ pub mod domain {
/// `Poseidon2(ESCROWED_ALLOWANCE_BLINDING_AUDITOR, s_a_s, op_i)`.
/// Constraint S14, docs/protocol/auditing.md#spender-allowance-auditing (`delta_esc_allow_r_aud`).
pub global ESCROWED_ALLOWANCE_BLINDING_AUDITOR: Field = 17;
+ /// Destination public input of a forced transfer:
+ /// `Poseidon2(FORCED_DESTINATION, address_to_field(to))`.
+ /// docs/compliance.md#forced-transfer (`delta_forced_dest`). Like `ADDRESS`, the contract computes
+ /// it off-circuit; the clawback circuit consumes the resulting Field as an opaque public input.
+ pub global FORCED_DESTINATION: Field = 18;
}
// ################## CORE PRIMITIVES ##################
diff --git a/packages/confidential/circuits/lib/src/tests.nr b/packages/confidential/circuits/lib/src/tests.nr
index 3fdff8eeb..229a155fd 100644
--- a/packages/confidential/circuits/lib/src/tests.nr
+++ b/packages/confidential/circuits/lib/src/tests.nr
@@ -419,6 +419,12 @@ fn print_fixtures() {
// poseidon_with_domain(VK, [sk, addr_f]) -- direct funnel access, equals vk_from_sk
let pwd = poseidon_with_domain(domain::VIEWING_KEY, [sk, addr_f]);
println(f"poseidon_with_domain_VK_2 = {pwd}");
+
+ // forced_destination_to_field over the two `address_to_field.json` outputs
+ let fd_g = poseidon_with_domain(domain::FORCED_DESTINATION, [ADDRESS_TO_FIELD_G]);
+ let fd_c = poseidon_with_domain(domain::FORCED_DESTINATION, [ADDRESS_TO_FIELD_C]);
+ println(f"forced_destination_to_field_G = {fd_g}");
+ println(f"forced_destination_to_field_C = {fd_c}");
}
/// Pins every primitive's output to the exact bytes published in
@@ -529,8 +535,28 @@ fn fixtures_match_testdata() {
// poseidon_with_domain funnel is the same operation that backs vk_from_sk.
assert(poseidon_with_domain(domain::VIEWING_KEY, [sk, addr_f]) == vk);
+
+ // forced_destination_to_field: the Poseidon2 step of the contract-side
+ // derivation, reproduced here from the `address_to_field.json` outputs and
+ // mirrored in `testdata/forced_destination_to_field.json`.
+ assert(
+ poseidon_with_domain(domain::FORCED_DESTINATION, [ADDRESS_TO_FIELD_G])
+ == 0x2b07bfd7c614007acad5b1d37a3fdc64a915b44dd0dafc5d5cf0bd2d2ef3ab7a,
+ );
+ assert(
+ poseidon_with_domain(domain::FORCED_DESTINATION, [ADDRESS_TO_FIELD_C])
+ == 0x2bbf4aadf66599f9f9a99357f94b9c3c2657aa890e277ed9fe366ac4c8f6b5be,
+ );
}
+// `address_to_field` outputs for the all-zero account and contract strkeys,
+// from `testdata/address_to_field.json`. That derivation has no Noir
+// implementation; these are inputs here, not checked values.
+global ADDRESS_TO_FIELD_G: Field =
+ 0x1d3b0901201ea22ad61ed4600b49dee57bb73369bf07bdeab17cbf0e54debd4f;
+global ADDRESS_TO_FIELD_C: Field =
+ 0x1997b0390a25f684e91575771f4c3ca72ac8f20f45a462838ea918bbe8c4e19c;
+
// Sponge fixture outputs -- pinned by `print_fixtures` and mirrored in
// `testdata/sponge_squeeze_2.json`. Update both sides together if the lib's
// sponge construction or domain constants change.
diff --git a/packages/confidential/circuits/lib/testdata/README.md b/packages/confidential/circuits/lib/testdata/README.md
index bb5db102a..1b52f118e 100644
--- a/packages/confidential/circuits/lib/testdata/README.md
+++ b/packages/confidential/circuits/lib/testdata/README.md
@@ -22,6 +22,8 @@ That test (in `lib/src/tests.nr`) feeds a fixed input set through every primitiv
**Exception: `address_to_field.json`.** That derivation is not implemented in Noir at all — the circuits receive `addr_f` as an opaque public input — so `print_fixtures` does not emit it and `fixtures_match_testdata` does not cover it. It exists in the contract (`storage.rs::address_to_field`, over `soroban-poseidon`) and in every client, which makes it the one primitive with two independent implementations. Its guard is the Rust test `address_to_field_matches_testdata_vectors` in `../../../test.rs`; that test and the JSON must be updated together. Its inputs are 56-character strkeys rather than field elements.
+**Half exception: `forced_destination_to_field.json`.** The derivation is `Poseidon2(FORCED_DESTINATION, address_to_field(a))`. Its outer hash is implemented here: `print_fixtures` emits it and `fixtures_match_testdata` checks it, taking the two `address_to_field.json` outputs as inputs. Its inner `address_to_field` step is the exception above. The contract side is guarded by the Rust test `forced_destination_to_field_matches_testdata_vectors` in `../../../compliance/test/rwa.rs`; the JSON, that test and `fixtures_match_testdata` must be updated together.
+
`fixtures_match_testdata` is the in-Noir guard: it asserts that every value documented here is still what the lib produces. If one changes a primitive without updating both this directory *and* that test, CI fails. If a primitive is intentionally changed, the cross-language contract is broken — every downstream consumer must be updated and the change should bump a version (see `Cargo.toml` once the SDK lands).
## Inputs (shared across fixtures)
diff --git a/packages/confidential/circuits/lib/testdata/forced_destination_to_field.json b/packages/confidential/circuits/lib/testdata/forced_destination_to_field.json
new file mode 100644
index 000000000..72c12721a
--- /dev/null
+++ b/packages/confidential/circuits/lib/testdata/forced_destination_to_field.json
@@ -0,0 +1,15 @@
+{
+ "primitive": "forced_destination_to_field",
+ "design_doc_refs": ["docs/compliance.md (Forced Transfer)", "docs/protocol/domain-separators.md (delta_forced_dest = 18)"],
+ "description": "Destination public input of a forced transfer: forced_destination_to_field(a) = Poseidon2(FORCED_DEST, address_to_field(a)). The clawback circuit receives it as an opaque public input, so the full derivation exists in the contract (storage.rs::forced_destination_to_field) and in each auditor client that builds the proof. The vectors use the two addresses of address_to_field.json. Their Poseidon2 step has two independent implementations: the contract (soroban-poseidon), guarded by the Rust test forced_destination_to_field_matches_testdata_vectors, and this library, where fixtures_match_testdata recomputes poseidon_with_domain(FORCED_DESTINATION, [address_to_field output]) and print_fixtures emits it. The address_to_field step is covered by address_to_field.json.",
+ "vectors": [
+ {
+ "inputs": { "strkey": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" },
+ "output": "0x2b07bfd7c614007acad5b1d37a3fdc64a915b44dd0dafc5d5cf0bd2d2ef3ab7a"
+ },
+ {
+ "inputs": { "strkey": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4" },
+ "output": "0x2bbf4aadf66599f9f9a99357f94b9c3c2657aa890e277ed9fe366ac4c8f6b5be"
+ }
+ ]
+}
diff --git a/packages/confidential/docs/README.md b/packages/confidential/docs/README.md
index c232ccc06..a0de23194 100644
--- a/packages/confidential/docs/README.md
+++ b/packages/confidential/docs/README.md
@@ -31,13 +31,14 @@ Table order is reading order.
| [protocol/security.md](protocol/security.md) | Griefing resistance, merge safety, balance conservation, privacy properties, revert safety, replay protection |
| [protocol/proof-system.md](protocol/proof-system.md) | Circuit set and costs, Noir primitives, verification flow, structured reference string, CAP-80, on-chain point arithmetic |
| [protocol/interface.md](protocol/interface.md) | Entry points, payload encoding, authorization model, event schema, read methods |
-| [protocol/domain-separators.md](protocol/domain-separators.md) | The seventeen Poseidon2 domain tags |
+| [protocol/domain-separators.md](protocol/domain-separators.md) | The eighteen Poseidon2 domain tags |
### Companions
| File | Owns |
|:---|:---|
-| [compliance.md](compliance.md) | Freeze, SAC passthrough, policy contract, `Hooks` customisation, clawback and forced revocation |
+| [compliance.md](compliance.md) | Freeze, SAC passthrough, policy contract, `Hooks` customisation, clawback and forced revocation, the RWA profile (designated auditor, approval gate, forced transfer) |
+| [rwa.md](rwa.md) | Integrator guide to the RWA profile: deployment models, the approver service, standards mapping (non-normative) |
| [selective-disclosure/README.md](selective-disclosure/README.md) | Motivation, goals, preliminaries, threat model of the disclosure layer |
| [selective-disclosure/protocol.md](selective-disclosure/protocol.md) | Disclosure ciphertext, proof bundle, verifier protocol, on-chain read surface, end-to-end flow |
| [selective-disclosure/circuits/](selective-disclosure/circuits/) | D-recipient, D-sender, D-auditor, D-balance, aggregate disclosures |
diff --git a/packages/confidential/docs/compliance.md b/packages/confidential/docs/compliance.md
index 744bb8cc1..2eb9549d7 100644
--- a/packages/confidential/docs/compliance.md
+++ b/packages/confidential/docs/compliance.md
@@ -2,9 +2,9 @@
## Abstract
-This document specifies optional, deployer-configurable controls layered on top of the core Confidential Token (see [the protocol specification](protocol/README.md)). It covers account freezing, SAC authorization passthrough, pluggable authorization policies, customization patterns for the `Hooks` extension surface, and the pooled-custody clawback flow.
+This document specifies optional, deployer-configurable controls layered on top of the core Confidential Token (see [the protocol specification](protocol/README.md)). It covers account freezing, SAC authorization passthrough, pluggable authorization policies, customization patterns for the `Hooks` extension surface, the pooled-custody clawback flow, and the RWA profile for regulated assets.
-All controls are configured at construction time through a single `compliance: Option` entry. A vanilla deployment leaves the entry empty and pays no compliance overhead. Regulated deployments populate the entry once; subsequent state changes (freeze toggles, admin rotation, policy swap) flow through admin-gated entry points.
+All controls are configured at construction time through a single `compliance: Option` entry. A vanilla deployment leaves the entry empty and pays no compliance overhead. Regulated deployments populate the entry once; subsequent state changes (freeze toggles, admin rotation, policy swap) flow through admin-gated entry points. The RWA profile keeps a second entry, `RwaConfig` ([RWA Profile](#rwa-profile)).
---
@@ -265,6 +265,86 @@ Commitment equality is not freshness. A seizure of `alpha` from `(C, O)` leaves
---
+## RWA Profile
+
+The RWA profile is the configuration for regulated assets whose issuer must see every balance and may have to enforce rules on them. It is delivered as `RwaHooks`, a `Hooks` implementation a deployment wires instead of `ComplianceHooks`, plus the `ConfidentialRwa` trait and the opt-in `ConfidentialForcedTransfer` trait. It adds no circuit and changes no core entry point. [rwa.md](rwa.md) is the integrator guide: deployment models, the approver service, and the mapping to ERC-7984, ERC-8424 and ERC-3643.
+
+The profile splits compliance by what the chain can see. Rules over public addresses (eligibility, freeze) run on-chain. Rules over hidden balances and amounts are evaluated off-chain by the issuer's compliance service, which holds the designated auditor key and therefore decrypts every operation; it either monitors and remediates, or, when configured as the approver, authorizes each value-moving operation before it settles.
+
+```rust
+struct RwaConfig {
+ auditor_id: u32, // Designated Auditor
+ approver: Option, // Approval Gate
+}
+```
+
+`RwaConfig` is stored under its own key; `ComplianceConfig` is unchanged. `RwaHooks` differs from `ComplianceHooks` as follows:
+
+- **Fails closed.** Every callback reverts with `RwaNotConfigured` without an `RwaConfig`, with `NotConfigured` without a `ComplianceConfig`, and with `EligibilityRequired` when the `ComplianceConfig` has neither a policy nor SAC passthrough. While an `RwaConfig` exists, `set_compliance_config` rejects a configuration with neither.
+- **Spender.** The spender of a delegated flow passes the freeze, policy and SAC gates, not the policy gate alone; under SAC passthrough it must therefore be an authorized holder of the base asset, which registration already requires. `revoke_spender` still gates only the owner.
+- **Deposits.** The recipient must be a registered account.
+
+### Designated Auditor
+
+The compliance service can monitor, approve and seize only what it can decrypt, so every account of an RWA deployment is bound to one auditor. `RwaConfig.auditor_id` is fixed by the first `set_rwa_config` call, which requires the id to resolve in the auditor registry; a later call with another id reverts with `AuditorAlreadySet`. Auditor keys under that id still rotate ([Auditor Key Management and Rotation](protocol/auditing.md#auditor-key-management-and-rotation)).
+
+`register` reverts with `AuditorNotAllowed` for any other id. The confidential accounts named by `deposit` (`to`), `confidential_transfer` and `confidential_transfer_from` (`from`, `to`), `set_spender` (`account`) and `forced_transfer` (`to`) must be bound to the designated auditor, else `AuditorMismatch`. The spender's binding is not checked.
+
+`merge`, `revoke_spender` and `withdraw` carry no such check. An account bound to another auditor, which can exist only where a deployment adopted the profile after accounts had registered, can therefore fold its balances and leave publicly, and can be seized against a proof from the auditor it is bound to; it cannot send, receive or delegate. Such deployments should drain those accounts before adopting the profile.
+
+### Approval Gate
+
+When `RwaConfig.approver` is set, `deposit`, `withdraw`, `confidential_transfer` and `confidential_transfer_from` additionally require the approver's authorization. The hook calls `approver.require_auth_for_args(args)` inside the token's own invocation, so the host binds the authorization to the network, to this contract and to the entry point being executed; `args` bind the rest:
+
+| Entry point | `args` |
+|:---|:---|
+| `deposit` | `(epoch, from, to, amount)` |
+| `withdraw` | `(epoch, from, to, amount, payload)` |
+| `confidential_transfer` | `(epoch, from, to, payload)` |
+| `confidential_transfer_from` | `(epoch, spender, from, to, payload)` |
+
+`payload` is the operation's decoded payload ([Interface](protocol/interface.md)), so an authorization covers one statement: any valid proof of it is acceptable, and no other payload is. The approver obtains the amount of a transfer from the recipient-auditor ciphertexts in that payload ([Per-Transfer Auditor Ciphertexts](protocol/auditing.md#per-transfer-auditor-ciphertexts)).
+
+The approval is additional. The operation's own authorizer, its proof and every other gate are still required, and an approver cannot move, create or destroy value. `set_spender`, `merge` and `revoke_spender` change no ownership and require no approval. `clawback` and `forced_transfer` are administrative overrides and require none either.
+
+**Epoch.** `epoch` is a contract-managed `u32` that only increases: `set_rwa_config`, `bump_approval_epoch`, and `set_compliance_config` while an `RwaConfig` exists each advance it, and it reverts with `ApprovalEpochOverflow` instead of wrapping. An authorization names the epoch it was issued for, so removing or replacing the approver, swapping the policy, or an explicit bump voids every outstanding one; disabling and re-enabling the approver does not return to an earlier epoch.
+
+**Who the approver may be.** The approver must not be a party to the operation it approves (`from`, `to`, or `spender` for a delegated transfer): the hook reverts with `ApproverIsParty`. A party approving its own operation gives no independent check, and an approver that also authorized the invocation would authorize it twice. `set_rwa_config` rejects this contract's own address with `InvalidApprover`. Three host behaviours bound the guarantee above:
+
+- A detached address credential binds `args`, a nonce and an expiry ledger. This is the form an approver service should issue.
+- A transaction whose source account is the approver satisfies the gate through that account's signature on the envelope. The entry still carries `args`, but has no expiry of its own; such envelopes need time bounds.
+- A contract approver that itself invokes the token is authorized without `args` being matched and without its `__check_auth` running. Any path through which that contract calls the token must itself check the token address, the expected epoch, the entry point and the full `args`; a generic forwarding function, even one gated by the contract's own authorization, carries no epoch and is not voided by a bump.
+
+### Forced Transfer
+
+`forced_transfer(from, to, amount, data, operator)` moves a public `amount` from a frozen account to another registered account inside the pool. It is `clawback` with a confidential destination: the same proof statement, verified against the same key, with no underlying moved. Recovery of a lost account is a forced transfer of its full balance to a fresh account.
+
+The proof is a clawback proof ([Circuit](#circuit)) built by the auditor `from` is bound to. Its public inputs are those of [Contract Flow](#contract-flow) step 2, in the same order and from the same sources, except the destination input:
+
+| Entry point | `dest_f` |
+|:---|:---|
+| `clawback`, `Some(d)` | `address_to_field(d)` |
+| `clawback`, `None` | the zero field |
+| `forced_transfer` | `Poseidon2(18, address_to_field(to))` ([Domain Separation Constants](protocol/domain-separators.md)) |
+
+The circuit references `dest_f` in no gate, so the tag is what assigns a proof to one settlement mode: a proof built for a forced transfer verifies in neither clawback branch, and a clawback proof does not verify as a forced transfer. A `to` whose `dest_f` is zero is rejected, keeping the third row disjoint from the second. Both entry points read and advance the same `ClawbackNonce(from)` ([Anti-Replay and the Freeze](#anti-replay-and-the-freeze)), so a proof executes at most once across both.
+
+`forced_transfer` runs, after the deployment's access-control check on `operator`:
+
+1. **Preconditions**, in this order. Both configurations valid, as for `RwaHooks`. `is_frozen(from)`, else `AccountNotFrozen`. `amount > 0`, else `InvalidForcedTransferAmount`. `to` is neither `from` nor this contract and its `dest_f` is non-zero, else `InvalidForcedTransferDestination`. `from` is registered. `to` passes the freeze, policy and SAC gates. `to` is registered and bound to the designated auditor, else `AuditorMismatch`. `from` passes no eligibility gate and may be bound to any auditor.
+2. **Verification** against `CircuitType::Clawback`.
+3. **State update.** `from.C_spend <- C_spend + C_receive - amount·G`, `from.C_receive <- O`, then `to.C_receive <- to.C_receive + amount·G`. The sum of all commitments is unchanged and the pool's balance is untouched.
+4. **Nonce.** `ClawbackNonce(from) <- nonce + 1`, reverting with `NonceOverflow` instead of wrapping.
+5. **Event.** `ForcedTransfer { from, to, amount, nonce }`, where `nonce` is the value step 4 consumed.
+
+No `Hooks` callback is invoked. For `from`, a `ForcedTransfer` is what a `Clawback` is: a receiving-side reset and a `T_0` anchor, not a checkpoint, and the source owner and its auditor apply the rule of [Wallet and Auditor Consequences](#wallet-and-auditor-consequences); the destination owner and its auditor apply the `Deposit` rule, the credit carrying zero blinding ([Wallet State and Recovery](protocol/wallet-state.md)).
+
+**Recovery procedure.** Freeze the lost account; `force_revoke_spender` each of its delegations, including expired ones, which the contract cannot enumerate and the operator takes from the event archive; then force-transfer the full balance. The circuit proves `amount <= v_spend + v_receive`, so a smaller amount leaves a remainder a further forced transfer can move.
+
+**Restrictions.** A deployment that implements `ConfidentialForcedTransfer` MUST wire `RwaHooks`, for the reason given in [Anti-Replay and the Freeze](#anti-replay-and-the-freeze). An issued proof stays executable by the operator until the source's commitments, its nonce or the auditor key change. The amount is public. The proof's witness includes the auditor's secret key, and the verifier accepts only non-zero-knowledge proofs today (see the package `README.md`): neither `forced_transfer` nor `clawback` should be used on a public network until it accepts zero-knowledge ones, and nothing on-chain enforces that.
+
+---
+
## Interface Summary
```rust
@@ -290,9 +370,23 @@ impl Token {
data: Bytes /* XDR ClawbackData { proof } */, operator: Address);
fn force_revoke_spender(e: Env, account: Address, spender: Address, operator: Address);
fn clawback_nonce(e: Env, account: Address) -> u32;
+
+ // RWA profile, via ConfidentialRwa (admin auth per Admin Authority)
+ fn set_rwa_config(e: Env, auditor_id: u32, approver: Option, operator: Address);
+ fn bump_approval_epoch(e: Env, operator: Address);
+ fn rwa_config(e: Env) -> Option;
+ fn approval_epoch(e: Env) -> u32;
+ fn can_send(e: Env, account: Address) -> bool;
+ fn can_receive(e: Env, account: Address) -> bool;
+
+ // Forced transfer, opt-in via ConfidentialForcedTransfer
+ fn forced_transfer(e: Env, from: Address, to: Address, amount: i128,
+ data: Bytes /* XDR ClawbackData { proof } */, operator: Address);
}
```
+`can_send` and `can_receive` are the same predicate: both configurations are set with an eligibility gate, and `account` is registered, bound to the designated auditor, not frozen, and passes the policy and SAC gates. They describe public eligibility of a confidential account, not whether an operation will settle, and they return `false` instead of raising a contract error, including when the policy or SAC call fails recoverably.
+
`operator` is the address whose authorization the deployment's access-control module checks ([Admin Authority](#admin-authority)).
### Events
@@ -303,6 +397,9 @@ impl Token {
| `ComplianceConfigChanged` | `policy`, `sac_passthrough` |
| `Clawback` | `account` (topic), `amount`, `destination` |
| `RevokeSpender` | as [Event Schema](protocol/interface.md#event-schema), when emitted by `force_revoke_spender` |
+| `RwaConfigChanged` | `auditor_id`, `approver`, `epoch` |
+| `ApprovalEpochBumped` | `epoch`; also emitted after `ComplianceConfigChanged` while an `RwaConfig` exists |
+| `ForcedTransfer` | `from` (topic), `to` (topic), `amount`, `nonce` |
### Errors
@@ -314,6 +411,22 @@ The seizure entry points add three variants to `ComplianceError`:
| `InvalidClawbackAmount` | 3605 | `amount <= 0` |
| `InvalidClawbackDestination` | 3606 | `destination` is `Some` naming the contract's own address |
+The RWA profile adds:
+
+| Error | Code | Raised when |
+|:---|:--|:---|
+| `RwaNotConfigured` | 3607 | `RwaHooks` or an RWA entry point runs without an `RwaConfig` |
+| `AuditorNotAllowed` | 3608 | `register` names an auditor other than the designated one |
+| `AuditorMismatch` | 3609 | an operation names a confidential account bound to another auditor |
+| `AuditorAlreadySet` | 3610 | `set_rwa_config` names an auditor other than the stored one |
+| `InvalidApprover` | 3611 | the approver is the contract's own address |
+| `ApproverIsParty` | 3612 | the approver is `from`, `to` or `spender` of the operation |
+| `InvalidForcedTransferAmount` | 3613 | `amount <= 0` |
+| `InvalidForcedTransferDestination` | 3614 | `to` is `from` or the contract's own address, or its `dest_f` is zero |
+| `EligibilityRequired` | 3615 | the compliance configuration of an RWA deployment has neither a policy nor SAC passthrough |
+| `ApprovalEpochOverflow` | 3616 | the approval epoch is `u32::MAX` |
+| `NonceOverflow` | 3617 | the clawback nonce is `u32::MAX` in `forced_transfer` |
+
---
Up: [Index](README.md#companions)
diff --git a/packages/confidential/docs/indexer.md b/packages/confidential/docs/indexer.md
index f995ec009..a833f1f27 100644
--- a/packages/confidential/docs/indexer.md
+++ b/packages/confidential/docs/indexer.md
@@ -52,6 +52,7 @@ All events emitted by the confidential token ([Event Schema](protocol/interface.
| `SpenderTransfer` (recipient side) | Receiving-side replay, as above. |
| `Merge` | Anchor: folds the receiving opening into the spendable opening; resets the receiving side. |
| `Clawback` | Anchor, as `Merge`, less the public `amount` on the spendable side; not a checkpoint. Emitted only by deployments that enable the compliance extension ([Wallet and Auditor Consequences](compliance.md#wallet-and-auditor-consequences)). |
+| `ForcedTransfer` | For `from`, an anchor exactly as `Clawback`; for `to`, receiving-side replay exactly as `Deposit`. Emitted only by deployments that enable the forced transfer of the compliance extension ([Forced Transfer](compliance.md#forced-transfer)). |
| `Withdraw`, `Transfer` (sender side), `SetSpender` | **Checkpoints**: publish `(b_tilde, sigma)` for the owner's spendable balance. `SetSpender` is in scope as an owner checkpoint only — a spender recovers allowance state from the on-chain delegation entry (`allowance_commitment`, `a_tilde`, `escrowed_dvk`, `allowance_salt`), not from the archive. |
| `RevokeSpender` | Spendable-side fold, not a checkpoint: the owner adds the reclaimed allowance opening to the spendable opening, deriving it from the event's `a_tilde` and `allowance_salt` ([Revoke Spender](protocol/operations/revoke-spender.md)). May be emitted by the compliance extension's `force_revoke_spender` without an owner-signed transaction; the fold is the same ([Forced Revocation](compliance.md#forced-revocation)). |
@@ -63,7 +64,7 @@ Configuration events (`UnderlyingAssetSet`, `VerifierSet`, `AuditorSet`, `Addres
### Account attribution
-Recovery is per-account, and an event belongs to **each** account address appearing in its topics — a `Transfer` to both the sender's and the recipient's history, a `SpenderTransfer` to the owner's, recipient's, and spender's, a `RevokeSpender` to the owner's and the spender's alone, a `Clawback` to the target's alone. Attribution MUST come from the event topics, never from the transaction source account.
+Recovery is per-account, and an event belongs to **each** account address appearing in its topics — a `Transfer` to both the sender's and the recipient's history, a `SpenderTransfer` to the owner's, recipient's, and spender's, a `RevokeSpender` to the owner's and the spender's alone, a `Clawback` to the target's alone, a `ForcedTransfer` to both `from`'s and `to`'s. Attribution MUST come from the event topics, never from the transaction source account.
The indexer MAY apply this attribution server-side (per-account queries, [API Surface](#api-surface) C2) or serve the whole per-contract stream and leave the client to select the events touching its account; both conform, since attribution is a pure function of the topics. Server-side per-account filtering is RECOMMENDED for high-volume contracts, where downloading the full contract history to every wallet does not scale.
@@ -84,14 +85,14 @@ The indexer MUST preserve and expose the total order `(ledger_seq, tx_applicatio
The indexer MUST retain the full per-account history of every in-scope event **indefinitely**. No pruning horizon is safe in general:
- The spendable opening is taken from the account's latest checkpoint, which is arbitrarily old for a dormant account.
-- The receiving side is replayed from the last `Merge` or `Clawback` at or before that checkpoint, which for an account that receives but never merges is its registration.
+- The receiving side is replayed from the last `Merge`, `Clawback`, or `ForcedTransfer` naming the account as `from`, at or before that checkpoint, which for an account that receives but never merges is its registration.
- A `RevokeSpender` after the latest checkpoint is the only surviving record of the addend it folded: the delegation entry carrying `a_tilde` and `allowance_salt` is deleted in the same invocation ([Revoke Spender](protocol/operations/revoke-spender.md)).
## API Surface
C2–C4 below are normative; C1 is RECOMMENDED. The REST shape is RECOMMENDED — any transport exposing the same capabilities conforms.
-- **C1 — Latest checkpoint (RECOMMENDED).** Return the most recent checkpoint event for `(contract_id, account)` at or before a given ledger. This is an optimization, not a correctness requirement: each checkpoint carries a self-contained `(b_tilde, sigma)` that re-derives the spendable opening as of that checkpoint, so a client can also obtain the latest checkpoint by scanning the ordered history (C2). Exposing C1 lets a dormant account with a long history skip transferring that history. A client additionally needs `T_0`, the last `Merge` or `Clawback` at or before that checkpoint ([Terminology](#terminology)), which C1 does not return and which is obtainable from C2's ordered history.
+- **C1 — Latest checkpoint (RECOMMENDED).** Return the most recent checkpoint event for `(contract_id, account)` at or before a given ledger. This is an optimization, not a correctness requirement: each checkpoint carries a self-contained `(b_tilde, sigma)` that re-derives the spendable opening as of that checkpoint, so a client can also obtain the latest checkpoint by scanning the ordered history (C2). Exposing C1 lets a dormant account with a long history skip transferring that history. A client additionally needs `T_0`, the last `Merge`, `Clawback`, or `ForcedTransfer` naming the account as `from`, at or before that checkpoint ([Terminology](#terminology)), which C1 does not return and which is obtainable from C2's ordered history.
- **C2 — Ordered history.** Return all in-scope events for `(contract_id, account)` within a ledger range, in the total order of [Ordering](#ordering), paginated, each carrying its event id and payload ([Archived record](#archived-record)). An indexer that serves only the per-contract stream ([Account attribution](#account-attribution)) satisfies C2 by delivering that stream in order for client-side attribution.
- **C3 — Completeness signal.** Every response MUST state whether the served range is complete (`complete: true` only when the indexer holds a gap-free history for the whole requested range).
- **C4 — Ingestion status.** Expose the latest fully-ingested ledger so clients can bound staleness. A hybrid client ([Why the Indexer Is Load-Bearing](#why-the-indexer-is-load-bearing)) compares it against the seam it derives from the RPC retention floor; if the archive has not ingested through the seam, the client MUST treat the crossing range as incomplete (C3).
diff --git a/packages/confidential/docs/protocol/domain-separators.md b/packages/confidential/docs/protocol/domain-separators.md
index 6c2ded796..d92673cfd 100644
--- a/packages/confidential/docs/protocol/domain-separators.md
+++ b/packages/confidential/docs/protocol/domain-separators.md
@@ -21,8 +21,9 @@ Each $$\delta$$ is a small positive integer in $$\mathbb{F}\_r$$, fixed for the
| $$\delta\_{\text{disc\\\_bind}}$$ | 15 | Off-chain | Disclosure ciphertext to the disclosure recipient, aggregate variant ([Aggregate Disclosures](../selective-disclosure/circuits/aggregate.md)) |
| $$\delta\_{\text{disc}}$$ | 16 | Off-chain | Disclosure ciphertext to the disclosure recipient ([Disclosure Ciphertext to Recipient](../selective-disclosure/protocol.md#disclosure-ciphertext-to-recipient)) |
| $$\delta\_{\text{esc\\\_allow\\\_r\\\_aud}}$$ | 17 | Circuit | Allowance-blinding escrow to the owner's auditor (S14, [Spender Allowance Auditing](auditing.md#spender-allowance-auditing)); fixture `circuits/lib/testdata/encrypt_esc_allow_r_auditor.json` |
+| $$\delta\_{\text{forced\\\_dest}}$$ | 18 | Contract | Destination public input of a forced transfer ([Forced Transfer](../compliance.md#forced-transfer)); fixture `circuits/lib/testdata/forced_destination_to_field.json` |
-This table assigns all seventeen values; no other document assigns them. Tag 1 is absorbed by the contract rather than by a circuit — it derives $$\text{addr\\\_f}$$ and $$\text{op}\_i$$ on-chain and the circuits receive them as opaque public inputs ([Usage sites](primitives.md#usage-sites)) — and is part of the on-chain wire contract all the same. Tags 14–16 are never absorbed inside a core circuit or on-chain — 14 is derived off-circuit ([ECDH-Derived Blinding](keys-and-commitments.md#ecdh-derived-blinding) makes its derivation normative for every operation whose originator holds a viewing key), and 15–16 belong to the off-chain selective-disclosure layer ([Domain Separators](../selective-disclosure/README.md#domain-separators)) — so they are not part of the on-chain wire contract, but all three are part of the cross-client contract because two wallets serving the same account must agree on them ([Vectors this specification requires](../sdk/conformance.md#vectors-this-specification-requires)); `circuits/lib/src/lib.nr` accordingly implements 1–13 and 17. All seventeen values MUST still be distinct and each MUST be confined to a single sponge mode, so a deployment treats them as one namespace. Tag 17's construction is specified in [Spender Allowance Auditing](auditing.md#spender-allowance-auditing).
+This table assigns all eighteen values; no other document assigns them. Tags 1 and 18 are absorbed by the contract rather than by a circuit — it derives $$\text{addr\\\_f}$$ and $$\text{op}\_i$$ on-chain under tag 1 ([Usage sites](primitives.md#usage-sites)) and the destination input of a forced transfer under tag 18, and the circuits receive them as opaque public inputs — and both are part of the on-chain wire contract all the same. Tags 14–16 are never absorbed inside a core circuit or on-chain — 14 is derived off-circuit ([ECDH-Derived Blinding](keys-and-commitments.md#ecdh-derived-blinding) makes its derivation normative for every operation whose originator holds a viewing key), and 15–16 belong to the off-chain selective-disclosure layer ([Domain Separators](../selective-disclosure/README.md#domain-separators)) — so they are not part of the on-chain wire contract, but all three are part of the cross-client contract because two wallets serving the same account must agree on them ([Vectors this specification requires](../sdk/conformance.md#vectors-this-specification-requires)); `circuits/lib/src/lib.nr` accordingly implements 1–13, 17 and 18. All eighteen values MUST still be distinct and each MUST be confined to a single sponge mode, so a deployment treats them as one namespace. Tag 17's construction is specified in [Spender Allowance Auditing](auditing.md#spender-allowance-auditing).
**Provenance.** Sequential small integers are the simplest assignment that satisfies the requirement of *distinctness* across all Poseidon2 invocations in this protocol -- [Threat Model](system-model.md#threat-model) models Poseidon2 as a pseudorandom function, so evaluations whose leading input differs are computationally independent. Distinctness alone is not sufficient: each tag must also be confined to a single sponge mode, since the multi-lane forms of [Poseidon2 Hash](primitives.md#poseidon2-hash) share `lane[0]` with the single-output form on the same inputs; $$\delta\_{\text{aud\\\_s}}$$ is the one tag ever squeezed three-wide, and the widths it is read at agree on their shared lanes ([Mode exclusivity](primitives.md#mode-exclusivity)). The values themselves carry no semantic meaning; the binding is purely positional and the table is the only authoritative source. Implementations MUST hardcode these exact numeric values.
diff --git a/packages/confidential/docs/protocol/interface.md b/packages/confidential/docs/protocol/interface.md
index e3ab112bc..0cdbc6c8c 100644
--- a/packages/confidential/docs/protocol/interface.md
+++ b/packages/confidential/docs/protocol/interface.md
@@ -88,15 +88,16 @@ Each state-modifying operation emits a structured event. Events carry the data n
| `SetSpender` | `account`, `spender`, `live_until_ledger`, $$R\_e$$, $$\sigma$$, $$\tilde{b}$$, $$\tilde{v}\_{\text{aud,s}}$$, $$\tilde{b}\_{\text{aud,s}}$$, $$\tilde{r}\_{\text{aud,s}}$$, $$\tilde{r}\_{a,\text{aud,s}}$$ |
| `RevokeSpender` | `account`, `spender`, $$\tilde{a}$$, $$\sigma\_a$$ |
| `Clawback` | `account`, `amount`, `destination` |
+| `ForcedTransfer` | `from`, `to`, `amount`, `nonce` |
-Amount fields in `Deposit`, `Withdraw`, and `Clawback` are typed `i128`, matching SEP-41.
+Amount fields in `Deposit`, `Withdraw`, `Clawback`, and `ForcedTransfer` are typed `i128`, matching SEP-41.
-`Clawback` is emitted only by deployments that enable the optional compliance extension ([Clawback](../compliance.md#clawback)).
+`Clawback` and `ForcedTransfer` are emitted only by deployments that enable the corresponding parts of the optional compliance extension ([Clawback](../compliance.md#clawback), [Forced Transfer](../compliance.md#forced-transfer)).
**Usage by consumers:**
- **Recipient wallet**: processes `Transfer` and `SpenderTransfer` events using $$(R\_e, \tilde{v}, \sigma)$$ -- with $$\sigma\_a'$$ in place of $$\sigma$$ for `SpenderTransfer` -- to derive $$v\_{\text{transfer}}$$ and $$r\_{\text{transfer}}$$ ([ECDH-Derived Blinding](keys-and-commitments.md#ecdh-derived-blinding)).
-- **Owner wallet**: processes all events for recovery ([Wallet State and Recovery](wallet-state.md)). The $$(\tilde{b}, \sigma)$$ pair from the most recent checkpoint event (`Withdraw`, `Transfer` as sender, `SetSpender`) anchors $$W\_{\text{spend}}$$; `Merge`, `RevokeSpender`, and `Clawback` are folded into it during replay.
+- **Owner wallet**: processes all events for recovery ([Wallet State and Recovery](wallet-state.md)). The $$(\tilde{b}, \sigma)$$ pair from the most recent checkpoint event (`Withdraw`, `Transfer` as sender, `SetSpender`) anchors $$W\_{\text{spend}}$$; `Merge`, `RevokeSpender`, `Clawback`, and a `ForcedTransfer` naming the account as `from` are folded into it during replay.
- **Auditor**: processes events containing $$R\_e$$ to compute ECDH shared secrets and decrypt amounts, balance checkpoints, and the escrowed blindings ([Per-Transfer Auditor Ciphertexts](auditing.md#per-transfer-auditor-ciphertexts), [Auditor Visibility Properties](auditing.md#auditor-visibility-properties), [Spender Allowance Auditing](auditing.md#spender-allowance-auditing)); folds `Merge`, `RevokeSpender`, and `Clawback` into its accumulators as [Per-Transfer Auditor Ciphertexts](auditing.md#per-transfer-auditor-ciphertexts) and [Wallet and Auditor Consequences](../compliance.md#wallet-and-auditor-consequences) specify.
- **Indexer**: reconciles the pooled balance against the sum of claims ([Balance Conservation](security.md#balance-conservation), [Indexing and Off-Chain State Recovery](../indexer.md)).
diff --git a/packages/confidential/docs/protocol/wallet-state.md b/packages/confidential/docs/protocol/wallet-state.md
index e3def8974..4c58e2dde 100644
--- a/packages/confidential/docs/protocol/wallet-state.md
+++ b/packages/confidential/docs/protocol/wallet-state.md
@@ -22,10 +22,11 @@ Each balance-modifying event updates the accumulators as follows:
| Set spender (escrow amount $$a$$) | Proof outputs new commitment. $$W\_{\text{spend}} \leftarrow (v\_s - a, \\; \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk, \sigma))$$ |
| Revoke spender | Proofless fold. $$W\_{\text{spend}} \mathrel{+}= (v\_a, r\_a)$$, the escrow opening recovered from the event per [Revoke Spender](operations/revoke-spender.md) |
| Clawback (compliance extension) | The Merge row, then the event's public `amount` is subtracted from the spendable value ([Contract Flow](../compliance.md#contract-flow)) |
+| Forced transfer (compliance extension) | Source (the event's `from`): the Clawback row, and the event counts as a `Clawback` wherever this document names one. Destination (its `to`): the Deposit row with the event's public `amount` ([Forced Transfer](../compliance.md#forced-transfer)) |
-The Merge, Revoke spender, and Clawback rows fold openings the wallet already holds, leaving $$W\_{\text{spend}}.r$$ a sum; [Post-merge witness availability](proof-system.md#post-merge-witness-availability) covers the case where no circuit can witness it.
+The Merge, Revoke spender, Clawback, and source-side Forced transfer rows fold openings the wallet already holds, leaving $$W\_{\text{spend}}.r$$ a sum; [Post-merge witness availability](proof-system.md#post-merge-witness-availability) covers the case where no circuit can witness it.
-After every owner-initiated operation that produces a proof, $$r\_s$$ resets to a deterministic value. This is the **normalization** property: the spendable balance's blinding factor is always recoverable from $$(vk, \sigma)$$ at spend boundaries. Together with $$\tilde{b}$$, both emitted in the spend-boundary event, each spend boundary forms a **checkpoint** from which the spendable opening $$(v\_s, r\_s)$$ *as of that boundary* is recoverable via a single event lookup, with no replay of the spend history preceding it. $$W\_{\text{receive}}$$ has no such anchor: a checkpoint leaves $$C\_{\text{receive}}$$ untouched, which only `Merge` ([Merge](operations/merge.md)) and `Clawback` reset, so recovering it requires a replay (see Recovery below).
+After every owner-initiated operation that produces a proof, $$r\_s$$ resets to a deterministic value. This is the **normalization** property: the spendable balance's blinding factor is always recoverable from $$(vk, \sigma)$$ at spend boundaries. Together with $$\tilde{b}$$, both emitted in the spend-boundary event, each spend boundary forms a **checkpoint** from which the spendable opening $$(v\_s, r\_s)$$ *as of that boundary* is recoverable via a single event lookup, with no replay of the spend history preceding it. $$W\_{\text{receive}}$$ has no such anchor: a checkpoint leaves $$C\_{\text{receive}}$$ untouched, which only `Merge` ([Merge](operations/merge.md)), `Clawback`, and a source-side `ForcedTransfer` reset, so recovering it requires a replay (see Recovery below).
## Consistency check
@@ -33,13 +34,13 @@ At any time, the wallet can verify its state: $$C\_{\text{spend}} \stackrel{?}{=
## Recovery
-If the wallet loses local state, it recovers from **two anchors**: the account's last **checkpoint** for the spendable side (steps 1-4), and $$T\_0$$ -- its most recent `Merge` or `Clawback` at or before that checkpoint -- for the receiving side (step 5). Since $$W\_{\text{receive}}$$ restarts at $$(0, 0)$$ as of $$T\_0$$, one replay window $$(T\_0, \text{now}]$$ serves both: it rebuilds the receiving side and carries the post-checkpoint proofless folds onto the spendable one (step 6). [Why the two anchors compose](#why-the-two-anchors-compose) gives the argument that they do:
+If the wallet loses local state, it recovers from **two anchors**: the account's last **checkpoint** for the spendable side (steps 1-4), and $$T\_0$$ -- its most recent `Merge`, `Clawback`, or source-side `ForcedTransfer` at or before that checkpoint -- for the receiving side (step 5). Since $$W\_{\text{receive}}$$ restarts at $$(0, 0)$$ as of $$T\_0$$, one replay window $$(T\_0, \text{now}]$$ serves both: it rebuilds the receiving side and carries the post-checkpoint proofless folds onto the spendable one (step 6). [Why the two anchors compose](#why-the-two-anchors-compose) gives the argument that they do:
-1. Fetch $$(\tilde{b}, \sigma)$$ from the most recent **checkpoint event** for this account: exactly one of `Withdraw`, `Transfer` (where the account is the `from`), or `SetSpender` -- the three event types carrying a proof-bound $$(\tilde{b}, \sigma)$$ for this account's spendable balance. `Merge`, `RevokeSpender`, and `Clawback` do change $$C\_{\text{spend}}$$ but are not checkpoints: none binds a $$\tilde{b}$$ to the resulting commitment ([Encrypted balance](operations/merge.md#encrypted-balance), [Encrypted balance](operations/revoke-spender.md#encrypted-balance), [Wallet and Auditor Consequences](../compliance.md#wallet-and-auditor-consequences)), and their effect is absorbed into the next checkpoint. **No-checkpoint case:** if the account has no checkpoint event since `Register`, initialize $$W\_{\text{spend}} \leftarrow (0, 0)$$ and skip to step 5 with $$T\_0$$ = the `Register` event.
+1. Fetch $$(\tilde{b}, \sigma)$$ from the most recent **checkpoint event** for this account: exactly one of `Withdraw`, `Transfer` (where the account is the `from`), or `SetSpender` -- the three event types carrying a proof-bound $$(\tilde{b}, \sigma)$$ for this account's spendable balance. `Merge`, `RevokeSpender`, `Clawback`, and a source-side `ForcedTransfer` do change $$C\_{\text{spend}}$$ but are not checkpoints: none binds a $$\tilde{b}$$ to the resulting commitment ([Encrypted balance](operations/merge.md#encrypted-balance), [Encrypted balance](operations/revoke-spender.md#encrypted-balance), [Wallet and Auditor Consequences](../compliance.md#wallet-and-auditor-consequences)), and their effect is absorbed into the next checkpoint. **No-checkpoint case:** if the account has no checkpoint event since `Register`, initialize $$W\_{\text{spend}} \leftarrow (0, 0)$$ and skip to step 5 with $$T\_0$$ = the `Register` event.
2. Recover the spendable balance value: $$v\_s = \tilde{b} - \text{Poseidon}(\delta\_{\text{enc\\\_bal}}, vk, \sigma)$$.
3. Recover the spendable balance blinding: $$r\_s = \text{Poseidon}(\delta\_{\text{spend\\\_r}}, vk, \sigma)$$.
4. Set $$W\_{\text{spend}} \leftarrow (v\_s, r\_s)$$.
-5. Locate $$T\_0$$: the account's most recent `Merge` or `Clawback` event at or before the checkpoint of step 1, or its `Register` event if neither exists. Set $$W\_{\text{receive}} \leftarrow (0, 0)$$ as of $$T\_0$$.
+5. Locate $$T\_0$$: the account's most recent `Merge`, `Clawback`, or source-side `ForcedTransfer` event at or before the checkpoint of step 1, or its `Register` event if none exists. Set $$W\_{\text{receive}} \leftarrow (0, 0)$$ as of $$T\_0$$.
6. Replay every event after $$T\_0$$ in ledger order, applying the *Update rules* above with three replay-specific amendments:
- **Checkpoint event**: skip the spendable side -- step 1 already captured it.
- **Revoke spender**: skip if at or before the checkpoint of step 1, which absorbed it; otherwise fold as the table specifies.
@@ -60,7 +61,7 @@ The recovery procedure, the definition of the **checkpoint** it is built around,
### Why the two anchors compose
-No `Merge` or `Clawback` falls strictly between $$T\_0$$ and the checkpoint, by construction of $$T\_0$$ as the last of either at or before it. Within $$(T\_0, \text{checkpoint}]$$ the only spendable-affecting events are therefore checkpoint events and `RevokeSpender` folds, and the latest checkpoint's $$(\tilde{b}, \sigma)$$ supersedes both — which is why the replay skips them ([Recovery](#recovery), step 6). Any `Merge` the replay encounters necessarily sits after the checkpoint and is folded normally, against the receiving opening it actually consumed.
+No `Merge`, `Clawback`, or source-side `ForcedTransfer` falls strictly between $$T\_0$$ and the checkpoint, by construction of $$T\_0$$ as the last of them at or before it. Within $$(T\_0, \text{checkpoint}]$$ the only spendable-affecting events are therefore checkpoint events and `RevokeSpender` folds, and the latest checkpoint's $$(\tilde{b}, \sigma)$$ supersedes both — which is why the replay skips them ([Recovery](#recovery), step 6). Any `Merge` the replay encounters necessarily sits after the checkpoint and is folded normally, against the receiving opening it actually consumed.
### Recoverability
diff --git a/packages/confidential/docs/rwa.md b/packages/confidential/docs/rwa.md
new file mode 100644
index 000000000..db611b866
--- /dev/null
+++ b/packages/confidential/docs/rwa.md
@@ -0,0 +1,113 @@
+# Confidential Token: RWA Integrator Guide
+
+Non-normative. This guide shows how issuers, wallets, compliance services and venues use the RWA profile specified in [RWA Profile](compliance.md#rwa-profile). The reference deployment is [`examples/confidential/rwa-token`](../../../examples/confidential/rwa-token/src/contract.rs).
+
+## What the profile is
+
+A confidential token for a regulated asset has to answer two kinds of rule. Rules about who the parties are (KYC, jurisdiction, freeze) concern public addresses, and the contract checks them. Rules about how much a party holds or moves (holding caps, volume limits, lockups, holder counts) concern hidden values, and no party to a transfer can prove them about the other side. The profile gives the second kind to the issuer's compliance service, which holds the designated auditor key and therefore sees every amount:
+
+| Tier | Balance-dependent rules | Online party for a transfer |
+|:---|:---|:---|
+| 0, monitor and remediate (`approver: None`) | Detected after settlement; remedied with `freeze` and `forced_transfer` | None |
+| 1, approval gate (`approver: Some`) | Enforced before settlement by the approver | The issuer's approval service, for every value-moving operation |
+
+Tier 0 fits rules whose breach can be cured afterwards. A holder can pass an excess on to another eligible holder, or withdraw it where the public side is open, before the breach is detected. Tier 1 fits rules that must hold at every instant, at the price of an online dependency and of trusting the approver for the correctness of those rules. In both tiers the proofs still guarantee that nobody, the issuer included, can create value, and that a holder's balance moves only by the holder's own proof or by an administrative override, which needs a role holder, the auditor's proof and a frozen account ([Overrides and recovery](#overrides-and-recovery)).
+
+## Issuer
+
+**Base asset.** The confidential token holds an issuer-controlled SEP-41 asset. Two variants:
+
+- *Closed public side.* Only the treasury and the confidential token hold the base asset. Eligibility comes from a policy contract ([Policy Contract](compliance.md#policy-contract)), typically an adapter over the identity registry; `sac_passthrough` is off.
+- *Open public side.* Investors are also authorized holders of the base asset, a Stellar Asset Contract with `AUTH_REQUIRED`. `sac_passthrough` is on, so the issuer's authorization list gates the confidential layer too ([SAC Authorization Passthrough](compliance.md#sac-authorization-passthrough)). Public transfers of the base asset do not pass the approval gate, so balance and volume rules cover public holdings only by monitoring.
+
+A token whose own compliance modules depend on balances (for example a SEP-57 token with a maximum-balance module) is not a suitable base asset: its modules would see the pool as one investor.
+
+**Deployment.** Wire `type Hooks = RwaHooks`, and set both configurations in the constructor; until both exist every operation reverts. Give the `freezer`, `config_manager`, `clawback` and `forced_transfer` duties to different signers ([Admin Authority](compliance.md#admin-authority)). Keep the auditor decryption key, the approver signing key and the admin roles in separate custody: the approver's mistakes should be visible to someone who does not hold its key. The approver is a dedicated address: it cannot be the sender, recipient or spender of an operation it approves, so the treasury cannot be its own approver.
+
+**Issuance.** There is no confidential mint. Mint base units to the treasury, `deposit(treasury, treasury, amount)`, `merge`, then `confidential_transfer` to each investor. The mint and deposit amounts are public; the per-investor amounts are not. Funding the treasury in bulk keeps individual subscriptions from being matched to deposits. Redemption is the reverse: a confidential transfer to the treasury, then `withdraw` and burn.
+
+**Identity changes.** The identity registry cannot see confidential balances. Do not remove or reassign an identity while its accounts hold confidential value.
+
+**Adopting the profile on a live token.** Accounts registered under another auditor cannot send, receive or delegate once `RwaHooks` is active ([Designated Auditor](compliance.md#designated-auditor)). Drain them first.
+
+## Wallet and custodian
+
+| Step | Tier 0 | Tier 1 |
+|:---|:---|:---|
+| Onboard | Pass the issuer's KYC | Same |
+| Register | `register` with the designated `auditor_id` from `rwa_config()` | Same |
+| Preflight | `can_send(from)`, `can_receive(to)` | Same; `rwa_config().approver` tells the wallet an approval is needed |
+| Send | Build the proof, sign, submit | Build the proof, sign a detached authorization entry, send both to the issuer's approval service |
+| Receive | Ingest the event; no signature or acceptance | Same |
+
+`can_send` and `can_receive` report public eligibility only. They cannot promise that an operation will settle: that also needs a valid proof and, in Tier 1, an approval.
+
+### Tier 1 submission
+
+*Service submits (default).*
+
+1. The wallet builds the invocation and its proof, and signs its own authorization entry as a detached address credential with a short expiry.
+2. It sends the invocation and that entry to the approval service.
+3. The service decrypts the amount from the recipient-auditor ciphertexts, checks it against `c_transfer`, evaluates its rules and reserves capacity.
+4. The approver signer signs the approver's entry over the arguments of [Approval Gate](compliance.md#approval-gate), and the service simulates the complete transaction with authorization enforced. This simulation is what checks the sender's signature and the proof.
+5. The service submits the transaction with its own source account and returns the hash.
+
+*Wallet submits.* As above, except that the service returns the signed approver entry and the wallet attaches it, signs the envelope and submits. Routers and settlement venues need this form.
+
+A request the service cannot decide at once stays pending and holds its reservation. The proof behind it stays valid until the sender spends, merges or changes a delegation, the auditor key rotates, the sender's entry expires, or the approval epoch advances. After any of those the wallet builds a new proof, which is a new request.
+
+Each refusal tells the sender one bit about the rule that refused it. Services should authenticate requests, prefer submitting themselves so that an approval is not handed out unused, and return generic reasons.
+
+## Approval service
+
+The service is the approver's signing component plus the auditor's ledger. Requirements for a correct one:
+
+- **Ledger.** Per identity: spendable, receiving and escrowed allowance values over all its accounts, plus public base-asset balances where the public side is open. `ForcedTransfer` and `Clawback` events adjust it.
+- **Reservations.** Serialize decisions per identity and per global limit. Confirm a reservation only after the enforcing simulation passes. Release it on observed settlement or once the approval's expiry ledger has closed, never on a submission error alone. Credit outgoing value back only when it is observed on-chain.
+- **Freshness.** Refuse while the indexer is behind. Evaluate time-dependent rules for every ledger in the approval's validity window.
+- **Issue only detached entries.** A contract approver that invokes the token itself is not bound by the approval epoch ([Approval Gate](compliance.md#approval-gate)). A smart-account approver should forbid generic execution calls that target the token.
+- **Keys.** Retain every historical key of the designated auditor id; a rotated key cannot open earlier ciphertexts ([Auditor Key Management and Rotation](protocol/auditing.md#auditor-key-management-and-rotation)).
+
+While the service is down, value-moving operations on a Tier 1 token stop. The `config_manager` can replace the approver or disable the gate; both void outstanding approvals.
+
+## Overrides and recovery
+
+`forced_transfer` and `clawback` need three things: a role holder, a proof from the auditor the source account is bound to, and a frozen source. They are not subject to approval. [Forced Transfer](compliance.md#forced-transfer) gives the recovery procedure for a lost account and the restriction on using either entry point before the verifier accepts zero-knowledge proofs; until then a public-network deployment has freeze as its only remedy.
+
+## Venues and contracts
+
+- **Atomic settlement.** A router contract can call two `confidential_transfer` legs in one transaction. Each party proves its own leg and checks the counterparty's leg off-chain from the ciphertext addressed to it. A leg is never invalidated by activity on the recipient's account; it is invalidated by the sender's own outgoing activity, a key rotation or an epoch change, so a venue needs timeouts.
+- **Binding legs together.** Approver entries are rooted at the token and do not bind each other. A router that needs inseparable legs requires each trader's and each approver's authorization over the settlement terms itself; the token-level authorizations then sit under that entry.
+- **Contract-owned accounts.** A contract can own a confidential account and authorize spends as the caller, while an off-chain agent holds the spending key and builds proofs. The agent cannot move funds without the contract; it can withhold service and sees that account's balance. This serves escrow, settlement custody and collateral lockboxes with amounts agreed off-chain. A contract cannot read or compare a hidden balance, so lending, vaults and automated market makers need a different design.
+
+## Standards mapping
+
+The base token's mapping to ERC-7984 is unchanged by the profile. The profile adds the ERC-8424 surface where a proof-based token can provide it:
+
+| Standard element | Here | Status |
+|:---|:---|:---|
+| ERC-8424 `canSend`, `canReceive` | `can_send`, `can_receive` | Adopted: plain eligibility, no quantitative rule, `false` instead of a contract error |
+| ERC-8424 `forceConfidentialTransferFrom` | `forced_transfer` | Adapted: ignores the source's eligibility, requires the destination's, reaches frozen funds. The amount is public, the source must be frozen, an insufficient balance reverts, and only `ForcedTransfer` is emitted |
+| ERC-8424 `confidentialCanTransfer` | Tier 1: a dry run at the approval service. Tier 0: none | Not on-chain: the contract cannot compute a hidden boolean |
+| ERC-8424 `confidentialAvailableBalanceOf` | None | Not carried over; the profile has whole-account freeze only |
+| ERC-8424 transfer of zero on failure | Revert | The standard permits reverting; in Tier 1 the refusal reaches the wallet before any transaction exists |
+| ERC-3643 `isVerified`, SEP-57 `verify_identity` | `Policy::is_authorized` over the identity registry | Through a policy adapter |
+| ERC-3643 `setAddressFrozen` | `freeze`, `unfreeze` | Whole-address freezing |
+| ERC-3643 `freezePartialTokens` | None | Not available on hidden balances |
+| ERC-3643 `forcedTransfer`, `recoveryAddress` | `forced_transfer` | The movement only; relinking the identity and carrying over restrictions are the issuer's procedure |
+| ERC-3643 compliance modules | Identity modules behind the policy; balance and volume modules in the approval service | Split by what the chain can see |
+| SEP-8 approval server | Approval service issuing Soroban authorization entries | Same role, different mechanism |
+
+ERC-7984 and ERC-8424 are technology-agnostic. The differences above come from this implementation: it matches the names and the public-eligibility functions, and it cannot provide behaviours that need a party computing on hidden values. Describe the token as aligned with those standards, not as conforming to them.
+
+## Trade-offs
+
+- Tier 1 trusts the approver for balance and volume rules, and makes it an online dependency.
+- Tier 0 detects a breach after it has settled.
+- `forced_transfer` and recovery reveal the amount.
+- With an open public side, public transfers bypass the approval gate.
+- Wallets and custodians carry the base token's burden unchanged (proving, key derivation, event ingestion), plus the approval exchange in Tier 1.
+
+---
+
+Up: [Index](README.md#companions)
diff --git a/packages/confidential/docs/sdk/conformance.md b/packages/confidential/docs/sdk/conformance.md
index 6fda60e99..005405e23 100644
--- a/packages/confidential/docs/sdk/conformance.md
+++ b/packages/confidential/docs/sdk/conformance.md
@@ -17,11 +17,12 @@ An implementation MUST therefore additionally, for every circuit it supports:
## Vectors this specification requires
-Three derivations this document specifies or relies on are computed outside the core circuits, so the Noir library's `print_fixtures` does not emit them. The first already has a fixture; the other two require one:
+Four derivations this document specifies or relies on are computed outside the core circuits. The Noir library's `print_fixtures` emits none of the first three; it emits the outer hash of the fourth. The first and fourth already have a fixture; the other two require one:
- **$$\text{address\\\_to\\\_field}$$** ([Address compression](crypto-core.md#address-compression)) is pinned by `circuits/lib/testdata/address_to_field.json`, which [Primitive fixtures](#primitive-fixtures)'s obligation already covers. The circuits receive $$\text{addr\\\_f}$$ as an opaque public input, so this derivation is implemented twice — by the contract on-chain and by every client — making it the sole primitive with two independent implementations and no Noir version, and [Address compression](crypto-core.md#address-compression)'s bootstrap check detects a divergence only against an already-deployed contract. Being outside `print_fixtures`, it is also outside the in-Noir `fixtures_match_testdata` guard; the Rust test `address_to_field_matches_testdata_vectors` guards it instead (`circuits/lib/testdata/README.md`).
- **$$\delta\_{\text{eph}}$$ derivation** ([ECDH-Derived Blinding](../protocol/keys-and-commitments.md#ecdh-derived-blinding), restated in [Deterministic ephemeral scalars](wallet.md#deterministic-ephemeral-scalars)). No circuit constrains $$r\_e$$, so a fixture is the only mechanism keeping a user's clients in agreement; where two disagree, transfers sent from one are not disclosable from the other.
- **The [Derivation](key-derivation.md#derivation) $$sk$$ chain**, from a fixed root, $$\text{addr\\\_f}$$, and $$\text{acct\\\_f}$$ through to $$sk$$, $$vk$$, $$Y$$, and $$\text{PVK}$$, without which recovery from backup material is untestable across implementations. The vector MUST start from a fixed ed25519 secret and run through [Signer roots](key-derivation.md#signer-roots)'s message, its SEP-0053 preimage, and the resulting signature, since the signature is where two clients most plausibly diverge — the only step in the chain whose format is set outside this document. A second vector from a fixed raw root covers [Raw roots and imported keys](key-derivation.md#raw-roots-and-imported-keys).
+- **The forced-transfer destination input** ([Forced Transfer](../compliance.md#forced-transfer)), computed by the contract and by every auditor client that builds a forced-transfer proof, is pinned by `circuits/lib/testdata/forced_destination_to_field.json`. A client that derives it differently produces proofs the contract rejects.
---
diff --git a/packages/confidential/docs/sdk/crypto-core.md b/packages/confidential/docs/sdk/crypto-core.md
index 463bf41dc..e4ac9d445 100644
--- a/packages/confidential/docs/sdk/crypto-core.md
+++ b/packages/confidential/docs/sdk/crypto-core.md
@@ -57,7 +57,7 @@ $$\text{Com}(v\_1, r\_1) + \text{Com}(v\_2, r\_2) = \text{Com}(v\_1 + v\_2, \\,
Reducing modulo $$r$$ instead yields an opening that is off and no longer matches the on-chain point, and for two full-size blindings the integer sum crosses $$q$$ roughly half the time. Implementations MUST provide distinct, clearly named reduction operations for the two moduli.
-**One reduction point.** An implementation MUST fold blindings with unbounded integer addition and MUST NOT reduce by either modulus as folds compose. This governs merge ([Merge](../protocol/operations/merge.md)), the `RevokeSpender` and `Clawback` folds, and receiving-balance credit ([Update rules](../protocol/wallet-state.md#update-rules)) alike.
+**One reduction point.** An implementation MUST fold blindings with unbounded integer addition and MUST NOT reduce by either modulus as folds compose. This governs merge ([Merge](../protocol/operations/merge.md)), the `RevokeSpender`, `Clawback` and source-side `ForcedTransfer` folds, and receiving-balance credit ([Update rules](../protocol/wallet-state.md#update-rules)) alike.
An implementation MUST reduce modulo $$q$$ to the canonical representative only where a blinding leaves an accumulator for the curve: a proof witness, or the recommit of the [Consistency checking](wallet.md#consistency-checking) consistency check. [The unspendable-blinding case](wallet.md#the-unspendable-blinding-case) tests for encodability at that same point.
@@ -75,7 +75,7 @@ The requirement is unpredictability and uniform distribution, not confidentialit
## Domain separators
-The seventeen tag values, the layer that absorbs each, and the sponge mode each is confined to are fixed in [Domain Separation Constants](../protocol/domain-separators.md). An implementation MUST hardcode those values and MUST NOT read any tag at a second arity ([Mode exclusivity](../protocol/primitives.md#mode-exclusivity)).
+The eighteen tag values, the layer that absorbs each, and the sponge mode each is confined to are fixed in [Domain Separation Constants](../protocol/domain-separators.md). An implementation MUST hardcode those values and MUST NOT read any tag at a second arity ([Mode exclusivity](../protocol/primitives.md#mode-exclusivity)).
## Address compression
diff --git a/packages/confidential/docs/sdk/wallet.md b/packages/confidential/docs/sdk/wallet.md
index b6205bb06..7ed6c76f7 100644
--- a/packages/confidential/docs/sdk/wallet.md
+++ b/packages/confidential/docs/sdk/wallet.md
@@ -61,7 +61,7 @@ Implementations MUST report which accumulator diverged, since the two have diffe
## The unspendable-blinding case
-$$W\_{\text{spend}}.r$$ is an exact integer, but a proof witnesses it as a single $$\mathbb{F}\_r$$ `Field`. After any of the three proofless folds — `Merge`, `RevokeSpender`, `Clawback` — its canonical $$\mathbb{F}\_q$$ representative can land in $$[r, q)$$, which no `Field` encodes, so no proof can be constructed against that commitment. The state itself is sound: the commitment is a well-formed Grumpkin point, and [Consistency checking](#consistency-checking)'s check recommits from the same representative and still passes ([Post-merge witness availability](../protocol/proof-system.md#post-merge-witness-availability)).
+$$W\_{\text{spend}}.r$$ is an exact integer, but a proof witnesses it as a single $$\mathbb{F}\_r$$ `Field`. After any of the proofless folds — `Merge`, `RevokeSpender`, `Clawback`, and a `ForcedTransfer` naming the account as `from` — its canonical $$\mathbb{F}\_q$$ representative can land in $$[r, q)$$, which no `Field` encodes, so no proof can be constructed against that commitment. The state itself is sound: the commitment is a well-formed Grumpkin point, and [Consistency checking](#consistency-checking)'s check recommits from the same representative and still passes ([Post-merge witness availability](../protocol/proof-system.md#post-merge-witness-availability)).
An implementation MUST test the reduction rather than the accumulator: before attempting a proof, reduce $$W\_{\text{spend}}.r$$ modulo $$q$$ at [Blinding accumulation](crypto-core.md#blinding-accumulation)'s reduction point and compare the representative against $$r$$. The unreduced accumulator carries no signal: after a few folds an integer sum past $$r$$ is the ordinary case.
diff --git a/packages/confidential/src/compliance/mod.rs b/packages/confidential/src/compliance/mod.rs
index 57f291bb3..8b5df6210 100644
--- a/packages/confidential/src/compliance/mod.rs
+++ b/packages/confidential/src/compliance/mod.rs
@@ -20,6 +20,16 @@
//! denylist / KYC / sanctions registry.
//! 5. Storage helpers in [`storage`].
//!
+//! ## RWA profile
+//!
+//! A regulated deployment wires [`RwaHooks`] instead of [`ComplianceHooks`]
+//! and implements [`ConfidentialRwa`]. The profile adds a designated auditor
+//! every account must bind to, an optional approver whose authorization
+//! every value-moving operation additionally requires, and — through the
+//! opt-in [`ConfidentialForcedTransfer`] — a forced transfer between
+//! confidential accounts. See `docs/compliance.md#rwa-profile` and
+//! `docs/rwa.md`.
+//!
//! Deployments that never write a configuration pay one instance-storage read
//! per operation: [`ComplianceHooks`] returns early when
//! [`storage::compliance_config`] is `None`.
@@ -30,13 +40,14 @@ pub mod storage;
mod test;
use soroban_sdk::{
- contractclient, contracterror, contractevent, contracttrait, Address, Bytes, Env,
+ contractclient, contracterror, contractevent, contracttrait, panic_with_error, Address, Bytes,
+ Env,
};
-pub use storage::{ClawbackData, ComplianceConfig, ComplianceStorageKey};
+pub use storage::{ClawbackData, ComplianceConfig, ComplianceStorageKey, RwaConfig};
use crate::{
- ConfidentialToken, Hooks, RegisterPayload, SetSpenderPayload, SpenderTransferPayload,
- TransferPayload, WithdrawPayload,
+ storage::get_account, ConfidentialToken, Hooks, RegisterPayload, SetSpenderPayload,
+ SpenderTransferPayload, TransferPayload, WithdrawPayload,
};
// ################## POLICY ##################
@@ -303,6 +314,179 @@ pub trait ConfidentialClawback: ConfidentialCompliance {
}
}
+// ################## RWA TRAITS ##################
+
+/// Admin and read surface of the RWA profile
+/// (`docs/compliance.md#rwa-profile`).
+///
+/// A contract that implements this trait MUST wire `type Hooks = RwaHooks;`
+/// on its [`ConfidentialToken`] impl: the configuration written here is
+/// enforced only by [`RwaHooks`].
+///
+/// The implementor supplies the bodies of the two admin entry points and
+/// thereby the access-control model, as for [`ConfidentialCompliance`].
+#[contracttrait]
+pub trait ConfidentialRwa: ConfidentialCompliance {
+ /// Sets the designated auditor and the approver, and advances the
+ /// approval epoch. The designated auditor is fixed by the first call.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `auditor_id` - The only auditor id accounts may be bound to.
+ /// * `approver` - The address whose authorization every value-moving
+ /// operation additionally requires, or `None` to disable the approval
+ /// gate.
+ /// * `operator` - The address authorizing this call.
+ ///
+ /// # Errors
+ ///
+ /// * refer to [`storage::set_rwa_config`] errors.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["rwa_config_changed"]`
+ /// * data - `[auditor_id: u32, approver: Option, epoch: u32]`
+ ///
+ /// # Notes
+ ///
+ /// A contract approver is authorized automatically whenever it invokes
+ /// the token itself, without its `__check_auth` running. It must expose
+ /// no path through which a third party can make it call the token, and
+ /// any such path of its own must check the approval epoch
+ /// (`docs/compliance.md#approval-gate`).
+ ///
+ /// # Security Warning
+ ///
+ /// Implementations MUST authorize `operator` before calling
+ /// [`storage::set_rwa_config`]. The method has no default body because
+ /// the trait does not prescribe an access-control model.
+ fn set_rwa_config(e: &Env, auditor_id: u32, approver: Option, operator: Address);
+
+ /// Advances the approval epoch, voiding every outstanding approver
+ /// authorization.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `operator` - The address authorizing this call.
+ ///
+ /// # Errors
+ ///
+ /// * refer to [`storage::bump_approval_epoch`] errors.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["approval_epoch_bumped"]`
+ /// * data - `[epoch: u32]`
+ ///
+ /// # Security Warning
+ ///
+ /// Implementations MUST authorize `operator` before calling
+ /// [`storage::bump_approval_epoch`].
+ fn bump_approval_epoch(e: &Env, operator: Address);
+
+ /// Returns the active [`RwaConfig`], or `None` when the profile has not
+ /// been configured.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ fn rwa_config(e: &Env) -> Option {
+ storage::rwa_config(e)
+ }
+
+ /// Returns the current approval epoch.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ fn approval_epoch(e: &Env) -> u32 {
+ storage::approval_epoch(e)
+ }
+
+ /// Returns whether `account` is publicly eligible to send. Mirrors
+ /// ERC-8424 `canSend`: an eligibility predicate that encodes no
+ /// quantitative rule and raises no contract error. See
+ /// [`storage::can_transact`].
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `account` - The confidential account to query.
+ fn can_send(e: &Env, account: Address) -> bool {
+ storage::can_transact(e, &account)
+ }
+
+ /// Returns whether `account` is publicly eligible to receive. Mirrors
+ /// ERC-8424 `canReceive`. See [`storage::can_transact`].
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `account` - The confidential account to query.
+ fn can_receive(e: &Env, account: Address) -> bool {
+ storage::can_transact(e, &account)
+ }
+}
+
+/// Opt-in forced transfer between confidential accounts
+/// (`docs/compliance.md#forced-transfer`). Recovery of a lost account is a
+/// forced transfer of its full balance.
+///
+/// # Deployment obligation
+///
+/// As for [`ConfidentialClawback`], the source must be frozen and the freeze
+/// must actually gate it: a contract implementing this trait MUST wire
+/// [`RwaHooks`].
+///
+/// # Proof-system restriction
+///
+/// The proof carries the auditor's secret key as a witness. Until the
+/// verifier accepts zero-knowledge proofs
+/// (`packages/confidential/README.md`), each proof may leak bits of that
+/// key; do not use this entry point, or [`ConfidentialClawback::clawback`],
+/// on a public network before then.
+#[contracttrait]
+pub trait ConfidentialForcedTransfer: ConfidentialClawback + ConfidentialRwa {
+ /// Moves the public `amount` from the frozen account `from` to the
+ /// registered account `to` inside the pool, against a clawback proof
+ /// built by the auditor `from` is bound to.
+ ///
+ /// # Arguments
+ ///
+ /// * `e` - Access to the Soroban environment.
+ /// * `from` - The frozen confidential account being debited.
+ /// * `to` - The confidential account being credited.
+ /// * `amount` - The strictly positive amount.
+ /// * `data` - XDR-encoded [`ClawbackData`].
+ /// * `operator` - The address authorizing this call.
+ ///
+ /// # Errors
+ ///
+ /// * refer to [`crate::storage::decode_data`] errors.
+ /// * refer to [`storage::forced_transfer`] errors.
+ ///
+ /// # Events
+ ///
+ /// * topics - `["forced_transfer", from: Address, to: Address]`
+ /// * data - `[amount: i128, nonce: u32]`
+ ///
+ /// # Security Warning
+ ///
+ /// Implementations MUST authorize `operator` before decoding `data` into
+ /// [`ClawbackData`] and calling [`storage::forced_transfer`]. The call
+ /// moves funds without the owner's authorization.
+ fn forced_transfer(
+ e: &Env,
+ from: Address,
+ to: Address,
+ amount: i128,
+ data: Bytes,
+ operator: Address,
+ );
+}
+
// ################## HOOKS IMPL ##################
/// [`Hooks`] implementation that checks every token callback against the
@@ -433,6 +617,129 @@ impl Hooks for ComplianceHooks {
}
}
+/// [`Hooks`] implementation of the RWA profile
+/// (`docs/compliance.md#rwa-profile`). Wire it as `type Hooks = RwaHooks;`
+/// on a contract that implements [`ConfidentialToken`] and
+/// [`ConfidentialRwa`].
+///
+/// It differs from [`ComplianceHooks`] in five ways:
+///
+/// 1. **Fails closed.** Every callback reverts unless both the
+/// [`ComplianceConfig`] and the [`RwaConfig`] are set and the former keeps
+/// an eligibility gate (a policy or SAC passthrough).
+/// 2. **Designated auditor.** `on_register` accepts only the designated auditor
+/// id. Sending, receiving and delegating require the confidential accounts
+/// involved to be bound to it. `on_merge`, `on_revoke_spender` and
+/// `on_withdraw` do not, so an account bound to another auditor (registered
+/// before the profile was adopted) can still exit.
+/// 3. **Approval gate.** When an approver is configured, `on_deposit`,
+/// `on_withdraw`, `on_transfer` and `on_spender_transfer` additionally
+/// require its authorization over the approval epoch, the parties and the
+/// operation's payload (see [`storage::require_approval`]).
+/// 4. **Spender.** The spender of a delegated flow passes the freeze, policy
+/// and SAC gates, not the policy gate alone: a frozen or unauthorized party
+/// must not direct regulated value. `on_revoke_spender` still gates only the
+/// owner.
+/// 5. **Deposits.** The recipient of a deposit must be a registered account.
+///
+/// The hooks enforce no balance or volume rule themselves; such rules are the
+/// approver's (`docs/rwa.md`).
+pub struct RwaHooks;
+
+impl Hooks for RwaHooks {
+ fn on_register(e: &Env, account: &Address, auditor_id: u32, _payload: &RegisterPayload) {
+ let (config, rwa) = storage::rwa_context(e);
+ if auditor_id != rwa.auditor_id {
+ panic_with_error!(e, ComplianceError::AuditorNotAllowed);
+ }
+ storage::check_policy(e, account, &config);
+ storage::check_sac(e, account, &config);
+ }
+
+ fn on_deposit(e: &Env, from: &Address, to: &Address, amount: i128) {
+ let (config, rwa) = storage::rwa_context(e);
+ storage::gate_account(e, from, &config);
+ storage::gate_account(e, to, &config);
+ storage::check_auditor(e, &get_account(e, to), &rwa);
+ storage::require_approval(
+ e,
+ &rwa,
+ &[from, to],
+ storage::deposit_approval_args(e, from, to, amount),
+ );
+ }
+
+ fn on_merge(e: &Env, account: &Address) {
+ let (config, _) = storage::rwa_context(e);
+ storage::gate_account(e, account, &config);
+ }
+
+ fn on_withdraw(e: &Env, from: &Address, to: &Address, amount: i128, payload: &WithdrawPayload) {
+ let (config, rwa) = storage::rwa_context(e);
+ storage::gate_account(e, from, &config);
+ storage::gate_account(e, to, &config);
+ storage::require_approval(
+ e,
+ &rwa,
+ &[from, to],
+ storage::withdraw_approval_args(e, from, to, amount, payload),
+ );
+ }
+
+ fn on_transfer(e: &Env, from: &Address, to: &Address, payload: &TransferPayload) {
+ let (config, rwa) = storage::rwa_context(e);
+ storage::gate_account(e, from, &config);
+ storage::gate_account(e, to, &config);
+ storage::check_auditor(e, &get_account(e, from), &rwa);
+ storage::check_auditor(e, &get_account(e, to), &rwa);
+ storage::require_approval(
+ e,
+ &rwa,
+ &[from, to],
+ storage::transfer_approval_args(e, from, to, payload),
+ );
+ }
+
+ fn on_spender_transfer(
+ e: &Env,
+ spender: &Address,
+ from: &Address,
+ to: &Address,
+ payload: &SpenderTransferPayload,
+ ) {
+ let (config, rwa) = storage::rwa_context(e);
+ storage::gate_account(e, from, &config);
+ storage::gate_account(e, to, &config);
+ storage::gate_account(e, spender, &config);
+ storage::check_auditor(e, &get_account(e, from), &rwa);
+ storage::check_auditor(e, &get_account(e, to), &rwa);
+ storage::require_approval(
+ e,
+ &rwa,
+ &[spender, from, to],
+ storage::spender_transfer_approval_args(e, spender, from, to, payload),
+ );
+ }
+
+ fn on_set_spender(
+ e: &Env,
+ account: &Address,
+ spender: &Address,
+ _live_until_ledger: u32,
+ _payload: &SetSpenderPayload,
+ ) {
+ let (config, rwa) = storage::rwa_context(e);
+ storage::gate_account(e, account, &config);
+ storage::gate_account(e, spender, &config);
+ storage::check_auditor(e, &get_account(e, account), &rwa);
+ }
+
+ fn on_revoke_spender(e: &Env, account: &Address, _spender: &Address) {
+ let (config, _) = storage::rwa_context(e);
+ storage::gate_account(e, account, &config);
+ }
+}
+
// ################## ERRORS ##################
#[contracterror]
@@ -457,6 +764,33 @@ pub enum ComplianceError {
InvalidClawbackAmount = 3605,
/// Indicates `destination` names this contract's own address.
InvalidClawbackDestination = 3606,
+ /// Indicates an RWA operation was invoked before
+ /// [`storage::set_rwa_config`] established an [`RwaConfig`].
+ RwaNotConfigured = 3607,
+ /// Indicates a registration named an auditor other than the designated
+ /// one.
+ AuditorNotAllowed = 3608,
+ /// Indicates an operation named a confidential account bound to an
+ /// auditor other than the designated one.
+ AuditorMismatch = 3609,
+ /// Indicates an attempt to change the designated auditor.
+ AuditorAlreadySet = 3610,
+ /// Indicates the approver is this contract's own address.
+ InvalidApprover = 3611,
+ /// Indicates the approver is a party to the operation it would approve.
+ ApproverIsParty = 3612,
+ /// Indicates the forced-transfer amount is not strictly positive.
+ InvalidForcedTransferAmount = 3613,
+ /// Indicates the forced-transfer destination is the source, this
+ /// contract's own address, or maps to the zero field.
+ InvalidForcedTransferDestination = 3614,
+ /// Indicates a compliance configuration of an RWA deployment has neither
+ /// a policy nor SAC passthrough.
+ EligibilityRequired = 3615,
+ /// Indicates the approval epoch cannot advance past `u32::MAX`.
+ ApprovalEpochOverflow = 3616,
+ /// Indicates the account's clawback nonce cannot advance past `u32::MAX`.
+ NonceOverflow = 3617,
}
// ################## CONSTANTS ##################
@@ -524,3 +858,51 @@ pub struct ComplianceConfigChanged {
pub fn emit_compliance_config_changed(e: &Env, policy: &Option, sac_passthrough: bool) {
ComplianceConfigChanged { policy: policy.clone(), sac_passthrough }.publish(e);
}
+
+/// Event emitted when the RWA profile configuration is set or changed.
+/// `approver` is `None` when the approval gate is disabled. `epoch` is the
+/// approval epoch in force after the change.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RwaConfigChanged {
+ pub auditor_id: u32,
+ pub approver: Option,
+ pub epoch: u32,
+}
+
+/// Emits an [`RwaConfigChanged`] event.
+pub fn emit_rwa_config_changed(e: &Env, auditor_id: u32, approver: &Option, epoch: u32) {
+ RwaConfigChanged { auditor_id, approver: approver.clone(), epoch }.publish(e);
+}
+
+/// Event emitted when the approval epoch advances without a change of the
+/// [`RwaConfig`]. Authorizations issued for an earlier epoch are void.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ApprovalEpochBumped {
+ pub epoch: u32,
+}
+
+/// Emits an [`ApprovalEpochBumped`] event.
+pub fn emit_approval_epoch_bumped(e: &Env, epoch: u32) {
+ ApprovalEpochBumped { epoch }.publish(e);
+}
+
+/// Event emitted when a public amount is moved between two confidential
+/// accounts by a forced transfer. `nonce` is the source's clawback nonce
+/// consumed by the call.
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ForcedTransfer {
+ #[topic]
+ pub from: Address,
+ #[topic]
+ pub to: Address,
+ pub amount: i128,
+ pub nonce: u32,
+}
+
+/// Emits a [`ForcedTransfer`] event.
+pub fn emit_forced_transfer(e: &Env, from: &Address, to: &Address, amount: i128, nonce: u32) {
+ ForcedTransfer { from: from.clone(), to: to.clone(), amount, nonce }.publish(e);
+}
diff --git a/packages/confidential/src/compliance/storage.rs b/packages/confidential/src/compliance/storage.rs
index b21bccf3e..679c3616b 100644
--- a/packages/confidential/src/compliance/storage.rs
+++ b/packages/confidential/src/compliance/storage.rs
@@ -1,19 +1,24 @@
-use soroban_sdk::{contracttype, panic_with_error, token, Address, Bytes, BytesN, Env};
+use soroban_sdk::{
+ contracttype, panic_with_error, token, Address, Bytes, BytesN, Env, IntoVal, Val, Vec,
+};
use stellar_contract_utils::crypto::grumpkin::Grumpkin;
use crate::{
auditor::ConfidentialAuditorClient,
compliance::{
- emit_clawback, emit_compliance_config_changed, emit_frozen, emit_unfrozen, ComplianceError,
+ emit_approval_epoch_bumped, emit_clawback, emit_compliance_config_changed,
+ emit_forced_transfer, emit_frozen, emit_rwa_config_changed, emit_unfrozen, ComplianceError,
PolicyClient, CLAWBACK_NONCE_EXTEND_AMOUNT, CLAWBACK_NONCE_TTL_THRESHOLD,
FROZEN_EXTEND_AMOUNT, FROZEN_TTL_THRESHOLD,
},
storage::{
- address_to_field, append_amount, append_field, append_point, get_account,
- get_address_as_field_element, get_auditor, get_underlying_asset, revoke_spender,
- set_commitments, verify,
+ account_exists, add_to_receiving, address_to_field, append_amount, append_field,
+ append_point, forced_destination_to_field, get_account, get_address_as_field_element,
+ get_auditor, get_underlying_asset, revoke_spender, set_commitments, verify,
},
verifier::CircuitType,
+ ConfidentialAccount, ConfidentialTokenStorageKey, SpenderTransferPayload, TransferPayload,
+ WithdrawPayload,
};
// ################## TYPES ##################
@@ -45,6 +50,20 @@ pub struct ClawbackData {
pub proof: Bytes,
}
+/// RWA profile configuration (`docs/compliance.md#rwa-profile`). Stored as
+/// an instance storage entry under its own key; [`ComplianceConfig`] is
+/// unchanged.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RwaConfig {
+ /// The only auditor id accounts of this token may be bound to. Set by
+ /// the first [`set_rwa_config`] call and immutable afterwards.
+ pub auditor_id: u32,
+ /// Address whose authorization every value-moving operation
+ /// additionally requires. `None` disables the approval gate.
+ pub approver: Option,
+}
+
/// Storage keys for the confidential token compliance extension.
#[contracttype]
pub enum ComplianceStorageKey {
@@ -53,9 +72,14 @@ pub enum ComplianceStorageKey {
/// Per-account frozen flag. Persistent storage; only set when an account
/// is frozen and removed on unfreeze.
Frozen(Address),
- /// Number of seizures executed against the account. Persistent storage;
- /// absent until the first seizure.
+ /// Number of seizures and forced transfers executed against the account.
+ /// Persistent storage; absent until the first one.
ClawbackNonce(Address),
+ /// Singleton [`RwaConfig`]. Instance storage.
+ RwaConfig,
+ /// Approval epoch, a `u32` that only increases. Instance storage; absent
+ /// until the first bump and read as `0`.
+ ApprovalEpoch,
}
// ################## QUERY STATE ##################
@@ -92,9 +116,9 @@ pub fn is_frozen(e: &Env, account: &Address) -> bool {
}
}
-/// Returns the number of seizures executed against `account`, or `0` when it
-/// has never been seized from. The value is a public input of the next
-/// clawback proof (`docs/compliance.md#circuit`).
+/// Returns the number of seizures and forced transfers executed against
+/// `account`, or `0` when there has been none. The value is a public input of
+/// the next clawback proof (`docs/compliance.md#circuit`).
///
/// # Arguments
///
@@ -114,6 +138,75 @@ pub fn clawback_nonce(e: &Env, account: &Address) -> u32 {
}
}
+/// Returns the active [`RwaConfig`], or `None` when the RWA profile has not
+/// been configured for this deployment.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+pub fn rwa_config(e: &Env) -> Option {
+ e.storage().instance().get(&ComplianceStorageKey::RwaConfig)
+}
+
+/// Returns the current approval epoch. Every approver authorization binds
+/// the epoch it was issued for, so an authorization issued for an earlier
+/// epoch no longer matches (`docs/compliance.md#approval-gate`).
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+pub fn approval_epoch(e: &Env) -> u32 {
+ e.storage().instance().get(&ComplianceStorageKey::ApprovalEpoch).unwrap_or(0)
+}
+
+/// Returns whether `account` is publicly eligible to take part in
+/// value-moving operations of an RWA deployment: both configurations are
+/// set with an eligibility gate, and `account` is registered, bound to the
+/// designated auditor, not frozen, and authorized by the policy and the SAC
+/// (where configured).
+///
+/// This is an eligibility predicate, not a settlement guarantee: it says
+/// nothing about approval, proof validity or balances. It raises no contract
+/// error: a missing configuration, a missing account, a rejection, or a
+/// recoverable failure of the policy or SAC call all yield `false`.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `account` - The address to query.
+pub fn can_transact(e: &Env, account: &Address) -> bool {
+ let (Some(config), Some(rwa)) = (compliance_config(e), rwa_config(e)) else {
+ return false;
+ };
+ if !has_eligibility_gate(&config) || !account_exists(e, account) {
+ return false;
+ }
+ if get_account(e, account).auditor_id != rwa.auditor_id || is_frozen(e, account) {
+ return false;
+ }
+ if let Some(policy_addr) = &config.policy {
+ let policy = PolicyClient::new(e, policy_addr);
+ if !matches!(policy.try_is_authorized(account, &e.current_contract_address()), Ok(Ok(true)))
+ {
+ return false;
+ }
+ }
+ if config.sac_passthrough {
+ // Read the underlying asset without `get_underlying_asset`, which
+ // raises a contract error when the entry is missing.
+ let Some(underlying) =
+ e.storage().instance().get::<_, Address>(&ConfidentialTokenStorageKey::UnderlyingAsset)
+ else {
+ return false;
+ };
+ let sac = token::StellarAssetClient::new(e, &underlying);
+ if !matches!(sac.try_authorized(account), Ok(Ok(true))) {
+ return false;
+ }
+ }
+ true
+}
+
// ################## CHANGE STATE ##################
/// Writes `config` into instance storage, overwriting any prior value. The
@@ -126,12 +219,31 @@ pub fn clawback_nonce(e: &Env, account: &Address) -> u32 {
/// * `e` - Access to the Soroban environment.
/// * `config` - The new [`ComplianceConfig`].
///
+/// # Errors
+///
+/// * [`ComplianceError::EligibilityRequired`] - When an [`RwaConfig`] exists
+/// and `config` has neither a policy nor SAC passthrough.
+/// * [`ComplianceError::ApprovalEpochOverflow`] - When an [`RwaConfig`] exists
+/// and the approval epoch cannot advance.
+///
/// # Events
///
/// * topics - `["compliance_config_changed"]`
/// * data - `[policy: Option, sac_passthrough: bool]` (`policy` is
/// left out of the data when it is `None`)
///
+/// When an [`RwaConfig`] exists, additionally:
+///
+/// * topics - `["approval_epoch_bumped"]`
+/// * data - `[epoch: u32]`
+///
+/// # Notes
+///
+/// Without an [`RwaConfig`] the behaviour is unchanged. With one, the
+/// eligibility gate cannot be removed and every change advances the
+/// approval epoch, which voids outstanding approver authorizations
+/// (`docs/compliance.md#approval-gate`).
+///
/// # Security Warning
///
/// **IMPORTANT**: This function bypasses authorization checks and should
@@ -142,8 +254,18 @@ pub fn clawback_nonce(e: &Env, account: &Address) -> u32 {
/// Using this function in public-facing methods may create significant
/// security risks as it could allow unauthorized modifications.
pub fn set_compliance_config(e: &Env, config: &ComplianceConfig) {
+ let is_rwa = rwa_config(e).is_some();
+ if is_rwa && !has_eligibility_gate(config) {
+ panic_with_error!(e, ComplianceError::EligibilityRequired);
+ }
e.storage().instance().set(&ComplianceStorageKey::Config, config);
emit_compliance_config_changed(e, &config.policy, config.sac_passthrough);
+ if is_rwa {
+ // An approval issued under the previous configuration must not
+ // execute under this one.
+ let epoch = advance_approval_epoch(e);
+ emit_approval_epoch_bumped(e, epoch);
+ }
}
/// Marks `account` as frozen.
@@ -308,23 +430,7 @@ pub fn clawback(
};
let nonce = clawback_nonce(e, account);
- // PI order (docs/compliance.md#circuit):
- // C_spend, C_receive, K_aud, alpha, addr_f, acct_f, dest_f, nonce
- //
- // `addr_f`, `acct_f`, `dest_f` and `nonce` are referenced by no
- // constraint; their membership in the public-input set binds the proof to
- // one contract, one account, one settlement destination, and one
- // execution, on the `register` precedent.
- let mut pi = Bytes::new(e);
- append_point(&mut pi, &data.spendable_commitment);
- append_point(&mut pi, &data.receiving_commitment);
- append_point(&mut pi, &k_aud);
- append_amount(&mut pi, e, amount);
- append_field(&mut pi, &addr_f);
- append_field(&mut pi, &address_to_field(e, account));
- append_field(&mut pi, &dest_f);
- append_amount(&mut pi, e, i128::from(nonce));
-
+ let pi = clawback_public_inputs(e, &data, &k_aud, amount, &addr_f, account, &dest_f, nonce);
verify(e, CircuitType::Clawback, &pi, proof);
let seized = Grumpkin::mul(e, &Grumpkin::generator(e), amount as u128);
@@ -385,8 +491,422 @@ pub fn force_revoke_spender(e: &Env, account: &Address, spender: &Address) {
revoke_spender(e, account, spender);
}
+/// Writes the RWA profile configuration and advances the approval epoch.
+///
+/// The first call fixes the designated auditor; later calls may only change
+/// the approver.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `auditor_id` - The only auditor id accounts of this token may be bound to.
+/// * `approver` - The address whose authorization every value-moving operation
+/// additionally requires, or `None` to disable the approval gate.
+///
+/// # Errors
+///
+/// * [`ComplianceError::NotConfigured`] - When [`compliance_config`] returns
+/// `None`.
+/// * [`ComplianceError::EligibilityRequired`] - When the compliance
+/// configuration has neither a policy nor SAC passthrough.
+/// * [`ComplianceError::AuditorAlreadySet`] - When an [`RwaConfig`] exists and
+/// `auditor_id` differs from the stored one.
+/// * [`ComplianceError::InvalidApprover`] - When `approver` is this contract's
+/// own address.
+/// * [`ComplianceError::ApprovalEpochOverflow`] - When the approval epoch
+/// cannot advance.
+/// * refer to [`crate::auditor::ConfidentialAuditor::get_key`] errors (first
+/// call only).
+///
+/// # Events
+///
+/// * topics - `["rwa_config_changed"]`
+/// * data - `[auditor_id: u32, approver: Option, epoch: u32]`
+/// (`approver` is left out of the data when it is `None`)
+///
+/// # Security Warning
+///
+/// **IMPORTANT**: This function bypasses authorization checks and should
+/// only be used:
+/// - During contract initialization/construction
+/// - In admin functions that implement their own authorization logic
+///
+/// Using this function in public-facing methods may create significant
+/// security risks as it could allow unauthorized modifications.
+pub fn set_rwa_config(e: &Env, auditor_id: u32, approver: &Option) {
+ let Some(config) = compliance_config(e) else {
+ panic_with_error!(e, ComplianceError::NotConfigured);
+ };
+ if !has_eligibility_gate(&config) {
+ panic_with_error!(e, ComplianceError::EligibilityRequired);
+ }
+ match rwa_config(e) {
+ Some(current) => {
+ if current.auditor_id != auditor_id {
+ panic_with_error!(e, ComplianceError::AuditorAlreadySet);
+ }
+ }
+ // The designated auditor must resolve to a registered key.
+ None => {
+ ConfidentialAuditorClient::new(e, &get_auditor(e)).get_key(&auditor_id);
+ }
+ }
+ if approver.as_ref() == Some(&e.current_contract_address()) {
+ panic_with_error!(e, ComplianceError::InvalidApprover);
+ }
+
+ let rwa = RwaConfig { auditor_id, approver: approver.clone() };
+ e.storage().instance().set(&ComplianceStorageKey::RwaConfig, &rwa);
+ let epoch = advance_approval_epoch(e);
+ emit_rwa_config_changed(e, auditor_id, approver, epoch);
+}
+
+/// Advances the approval epoch, voiding every outstanding approver
+/// authorization.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+///
+/// # Errors
+///
+/// * [`ComplianceError::RwaNotConfigured`] - When [`rwa_config`] returns
+/// `None`.
+/// * [`ComplianceError::ApprovalEpochOverflow`] - When the epoch is `u32::MAX`.
+///
+/// # Events
+///
+/// * topics - `["approval_epoch_bumped"]`
+/// * data - `[epoch: u32]`
+///
+/// # Security Warning
+///
+/// **IMPORTANT**: This function bypasses authorization checks and should
+/// only be used in admin functions that implement their own authorization
+/// logic.
+pub fn bump_approval_epoch(e: &Env) {
+ if rwa_config(e).is_none() {
+ panic_with_error!(e, ComplianceError::RwaNotConfigured);
+ }
+ let epoch = advance_approval_epoch(e);
+ emit_approval_epoch_bumped(e, epoch);
+}
+
+/// Moves the public `amount` from the frozen account `from` to the
+/// registered account `to` inside the pool. No underlying moves.
+///
+/// The proof is a clawback proof (`docs/compliance.md#circuit`): it
+/// establishes that the prover holds the secret key of the auditor `from` is
+/// bound to, knows the openings of `from`'s commitments, and that
+/// `amount <= v_spend + v_receive`. Its destination public input is
+/// [`forced_destination_to_field`]`(to)`, not `address_to_field(to)`, so a
+/// proof built for a forced transfer cannot execute a clawback and a
+/// clawback proof cannot execute a forced transfer. Both share the account's
+/// clawback nonce, so one proof executes once.
+///
+/// State update: `from.C_spend <- C_spend + C_receive - amount * G`,
+/// `from.C_receive <- O`, `to.C_receive <- to.C_receive + amount * G`. The
+/// credit carries zero blinding, like a deposit, so `to` can always open it.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `from` - The frozen confidential account being debited.
+/// * `to` - The confidential account being credited.
+/// * `amount` - The strictly positive amount.
+/// * `proof` - The raw UltraHonk proof bytes, from the decoded
+/// [`ClawbackData`].
+///
+/// # Errors
+///
+/// * refer to [`rwa_context`] errors.
+/// * [`ComplianceError::AccountNotFrozen`] - When `from` is not frozen.
+/// * [`ComplianceError::InvalidForcedTransferAmount`] - When `amount <= 0`.
+/// * [`ComplianceError::InvalidForcedTransferDestination`] - When `to` is
+/// `from` or this contract's own address, or its destination field is zero.
+/// * refer to [`gate_account`] errors (for `to`).
+/// * refer to [`crate::storage::get_account`] errors (for `from` and `to`).
+/// * [`ComplianceError::AuditorMismatch`] - When `to` is bound to an auditor
+/// other than the designated one.
+/// * refer to [`crate::auditor::ConfidentialAuditor::get_key`] errors.
+/// * [`crate::ConfidentialTokenError::NonCanonicalEncoding`] - When a stored
+/// commitment or auditor key coordinate is not a canonical `Bn254Fr` value.
+/// * [`crate::ConfidentialTokenError::InvalidProof`] - When the proof fails
+/// verification.
+/// * [`ComplianceError::NonceOverflow`] - When the clawback nonce is
+/// `u32::MAX`.
+///
+/// # Events
+///
+/// * topics - `["forced_transfer", from: Address, to: Address]`
+/// * data - `[amount: i128, nonce: u32]` (`nonce` is the value consumed by this
+/// call, as in the public inputs)
+///
+/// # Notes
+///
+/// * This is an administrative override: it invokes no [`Hooks`](crate::Hooks)
+/// callback and requires no approver authorization. `from` passes no
+/// eligibility gate and may be bound to any auditor; the proof must come from
+/// that auditor.
+/// * Escrowed allowances of `from` are out of reach until
+/// [`force_revoke_spender`] folds them back.
+/// * The amount is public.
+///
+/// # Security Warning
+///
+/// **IMPORTANT**: This function bypasses authorization checks. The trait
+/// entry point
+/// [`crate::compliance::ConfidentialForcedTransfer::forced_transfer`] is
+/// responsible for authorizing `operator`.
+pub fn forced_transfer(e: &Env, from: &Address, to: &Address, amount: i128, proof: &Bytes) {
+ let (config, rwa) = rwa_context(e);
+
+ if !is_frozen(e, from) {
+ panic_with_error!(e, ComplianceError::AccountNotFrozen);
+ }
+ if amount <= 0 {
+ panic_with_error!(e, ComplianceError::InvalidForcedTransferAmount);
+ }
+ if to == from || to == &e.current_contract_address() {
+ panic_with_error!(e, ComplianceError::InvalidForcedTransferDestination);
+ }
+ let dest_f = forced_destination_to_field(e, to);
+ // Zero is clawback's `None` sentinel; keep the two domains disjoint.
+ if dest_f == BytesN::from_array(e, &[0u8; 32]) {
+ panic_with_error!(e, ComplianceError::InvalidForcedTransferDestination);
+ }
+
+ let data = get_account(e, from);
+ gate_account(e, to, &config);
+ check_auditor(e, &get_account(e, to), &rwa);
+
+ let auditor = ConfidentialAuditorClient::new(e, &get_auditor(e));
+ let k_aud = auditor.get_key(&data.auditor_id);
+ let addr_f = get_address_as_field_element(e);
+ let nonce = clawback_nonce(e, from);
+
+ let pi = clawback_public_inputs(e, &data, &k_aud, amount, &addr_f, from, &dest_f, nonce);
+ verify(e, CircuitType::Clawback, &pi, proof);
+
+ let moved = Grumpkin::mul(e, &Grumpkin::generator(e), amount as u128);
+ let c_spend_new = Grumpkin::sub(
+ e,
+ &Grumpkin::add(e, &data.spendable_commitment, &data.receiving_commitment),
+ &moved,
+ );
+ set_commitments(e, from, &c_spend_new, &Grumpkin::identity(e));
+ add_to_receiving(e, to, &moved);
+ let Some(next_nonce) = nonce.checked_add(1) else {
+ panic_with_error!(e, ComplianceError::NonceOverflow);
+ };
+ e.storage().persistent().set(&ComplianceStorageKey::ClawbackNonce(from.clone()), &next_nonce);
+
+ emit_forced_transfer(e, from, to, amount, nonce);
+}
+
// ################## LOW-LEVEL HELPERS ##################
+/// Returns both configurations of an RWA deployment, reverting unless the
+/// deployment is fully configured. Every [`crate::compliance::RwaHooks`]
+/// callback and [`forced_transfer`] start here, so an unconfigured
+/// deployment rejects every operation instead of passing it ungated.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+///
+/// # Errors
+///
+/// * [`ComplianceError::RwaNotConfigured`] - When [`rwa_config`] returns
+/// `None`.
+/// * [`ComplianceError::NotConfigured`] - When [`compliance_config`] returns
+/// `None`.
+/// * [`ComplianceError::EligibilityRequired`] - When the compliance
+/// configuration has neither a policy nor SAC passthrough.
+pub fn rwa_context(e: &Env) -> (ComplianceConfig, RwaConfig) {
+ let Some(rwa) = rwa_config(e) else {
+ panic_with_error!(e, ComplianceError::RwaNotConfigured);
+ };
+ let Some(config) = compliance_config(e) else {
+ panic_with_error!(e, ComplianceError::NotConfigured);
+ };
+ if !has_eligibility_gate(&config) {
+ panic_with_error!(e, ComplianceError::EligibilityRequired);
+ }
+ (config, rwa)
+}
+
+/// Asserts that `account` is bound to the designated auditor.
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `account` - The stored account record.
+/// * `rwa` - The active [`RwaConfig`].
+///
+/// # Errors
+///
+/// * [`ComplianceError::AuditorMismatch`] - When `account.auditor_id` is not
+/// the designated auditor.
+pub fn check_auditor(e: &Env, account: &ConfidentialAccount, rwa: &RwaConfig) {
+ if account.auditor_id != rwa.auditor_id {
+ panic_with_error!(e, ComplianceError::AuditorMismatch);
+ }
+}
+
+/// Requires the approver's authorization for the current invocation with
+/// `args`. A no-op when `rwa.approver` is `None`.
+///
+/// The host binds the authorization to this contract and to the entry point
+/// being executed; `args` bind the approval epoch, the parties and the
+/// operation's payload (`docs/compliance.md#approval-gate`).
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `rwa` - The active [`RwaConfig`].
+/// * `parties` - Every address the operation names (`from`, `to`, and `spender`
+/// for a delegated transfer).
+/// * `args` - The approval arguments.
+///
+/// # Errors
+///
+/// * [`ComplianceError::ApproverIsParty`] - When the approver is one of
+/// `parties`. A party cannot approve an operation it takes part in; this also
+/// keeps one address from authorizing the same invocation twice.
+pub fn require_approval(e: &Env, rwa: &RwaConfig, parties: &[&Address], args: Vec) {
+ if let Some(approver) = &rwa.approver {
+ if parties.contains(&approver) {
+ panic_with_error!(e, ComplianceError::ApproverIsParty);
+ }
+ approver.require_auth_for_args(args);
+ }
+}
+
+/// Approval arguments of a `deposit`: `(epoch, from, to, amount)`.
+///
+/// The tuple is a wire contract with the approver, which signs exactly
+/// these values (`docs/compliance.md#approval-gate`).
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `from` - The depositor.
+/// * `to` - The credited confidential account.
+/// * `amount` - The public amount.
+pub fn deposit_approval_args(e: &Env, from: &Address, to: &Address, amount: i128) -> Vec {
+ (approval_epoch(e), from.clone(), to.clone(), amount).into_val(e)
+}
+
+/// Approval arguments of a `withdraw`: `(epoch, from, to, amount, payload)`.
+/// See [`deposit_approval_args`].
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `from` - The debited confidential account.
+/// * `to` - The address receiving the underlying asset.
+/// * `amount` - The public amount.
+/// * `payload` - The decoded withdraw payload.
+pub fn withdraw_approval_args(
+ e: &Env,
+ from: &Address,
+ to: &Address,
+ amount: i128,
+ payload: &WithdrawPayload,
+) -> Vec {
+ (approval_epoch(e), from.clone(), to.clone(), amount, payload.clone()).into_val(e)
+}
+
+/// Approval arguments of a `confidential_transfer`:
+/// `(epoch, from, to, payload)`. See [`deposit_approval_args`].
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `from` - The sender.
+/// * `to` - The recipient.
+/// * `payload` - The decoded transfer payload.
+pub fn transfer_approval_args(
+ e: &Env,
+ from: &Address,
+ to: &Address,
+ payload: &TransferPayload,
+) -> Vec {
+ (approval_epoch(e), from.clone(), to.clone(), payload.clone()).into_val(e)
+}
+
+/// Approval arguments of a `confidential_transfer_from`:
+/// `(epoch, spender, from, to, payload)`. See [`deposit_approval_args`].
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `spender` - The delegated spender.
+/// * `from` - The owner whose allowance is spent.
+/// * `to` - The recipient.
+/// * `payload` - The decoded spender-transfer payload.
+pub fn spender_transfer_approval_args(
+ e: &Env,
+ spender: &Address,
+ from: &Address,
+ to: &Address,
+ payload: &SpenderTransferPayload,
+) -> Vec {
+ (approval_epoch(e), spender.clone(), from.clone(), to.clone(), payload.clone()).into_val(e)
+}
+
+/// Returns whether `config` keeps an eligibility gate: an external policy or
+/// the SAC `authorized()` passthrough.
+fn has_eligibility_gate(config: &ComplianceConfig) -> bool {
+ config.policy.is_some() || config.sac_passthrough
+}
+
+/// Increments the approval epoch and returns the new value.
+///
+/// # Errors
+///
+/// * [`ComplianceError::ApprovalEpochOverflow`] - When the epoch is `u32::MAX`.
+fn advance_approval_epoch(e: &Env) -> u32 {
+ let Some(epoch) = approval_epoch(e).checked_add(1) else {
+ panic_with_error!(e, ComplianceError::ApprovalEpochOverflow);
+ };
+ e.storage().instance().set(&ComplianceStorageKey::ApprovalEpoch, &epoch);
+ epoch
+}
+
+/// Assembles the public inputs of the clawback circuit
+/// (`docs/compliance.md#circuit`):
+/// `C_spend, C_receive, K_aud, alpha, addr_f, acct_f, dest_f, nonce`.
+///
+/// `addr_f`, `acct_f`, `dest_f` and `nonce` are referenced by no constraint;
+/// their membership in the public-input set binds the proof to one contract,
+/// one account, one settlement destination, and one execution, on the
+/// `register` precedent. [`clawback`] and [`forced_transfer`] differ only in
+/// how they derive `dest_f`.
+#[allow(clippy::too_many_arguments)]
+fn clawback_public_inputs(
+ e: &Env,
+ data: &ConfidentialAccount,
+ k_aud: &BytesN<64>,
+ amount: i128,
+ addr_f: &BytesN<32>,
+ account: &Address,
+ dest_f: &BytesN<32>,
+ nonce: u32,
+) -> Bytes {
+ let mut pi = Bytes::new(e);
+ append_point(&mut pi, &data.spendable_commitment);
+ append_point(&mut pi, &data.receiving_commitment);
+ append_point(&mut pi, k_aud);
+ append_amount(&mut pi, e, amount);
+ append_field(&mut pi, addr_f);
+ append_field(&mut pi, &address_to_field(e, account));
+ append_field(&mut pi, dest_f);
+ append_amount(&mut pi, e, i128::from(nonce));
+ pi
+}
+
/// Asserts that `account` passes every configured compliance gate against the
/// given `config`: not frozen, authorized by the policy (when one is set),
/// and authorized by the SAC (when `sac_passthrough` is enabled).
diff --git a/packages/confidential/src/compliance/test.rs b/packages/confidential/src/compliance/test/mod.rs
similarity index 99%
rename from packages/confidential/src/compliance/test.rs
rename to packages/confidential/src/compliance/test/mod.rs
index f159fbef8..64b8a247a 100644
--- a/packages/confidential/src/compliance/test.rs
+++ b/packages/confidential/src/compliance/test/mod.rs
@@ -1,5 +1,7 @@
extern crate std;
+mod rwa;
+
use soroban_sdk::{
contract, contractimpl, symbol_short,
testutils::{Address as _, Events},
diff --git a/packages/confidential/src/compliance/test/rwa.rs b/packages/confidential/src/compliance/test/rwa.rs
new file mode 100644
index 000000000..80b958f0a
--- /dev/null
+++ b/packages/confidential/src/compliance/test/rwa.rs
@@ -0,0 +1,3395 @@
+extern crate std;
+
+use soroban_sdk::{
+ contract, contractimpl, symbol_short,
+ testutils::{Address as _, Events, MockAuth, MockAuthInvoke},
+ token::StellarAssetClient,
+ xdr::{AccountFlags, ToXdr},
+ Address, Bytes, BytesN, Env, Error, Event, IntoVal, Val, Vec,
+};
+use stellar_contract_utils::crypto::grumpkin::Grumpkin;
+
+use crate::{
+ compliance::{
+ storage::{
+ approval_epoch, bump_approval_epoch, can_transact, clawback, compliance_config,
+ deposit_approval_args, force_revoke_spender, forced_transfer, freeze, rwa_config,
+ set_compliance_config, set_rwa_config, spender_transfer_approval_args,
+ transfer_approval_args, unfreeze, withdraw_approval_args,
+ },
+ ApprovalEpochBumped, ClawbackData, ComplianceConfig, ComplianceConfigChanged,
+ ComplianceError, ComplianceStorageKey, ConfidentialClawback, ConfidentialClawbackClient,
+ ConfidentialCompliance, ConfidentialComplianceClient, ConfidentialForcedTransfer,
+ ConfidentialForcedTransferClient, ConfidentialRwa, ConfidentialRwaClient, ForcedTransfer,
+ Policy, RwaConfig, RwaConfigChanged, RwaHooks,
+ },
+ storage::{
+ address_to_field, decode_data, forced_destination_to_field, get_account,
+ get_address_as_field_element, get_auditor, set_address_as_field_element, set_auditor,
+ set_underlying_asset, set_verifier,
+ },
+ verifier::CircuitType,
+ ConfidentialAccount, ConfidentialToken, ConfidentialTokenClient, ConfidentialTokenError,
+ ConfidentialTokenStorageKey, Hooks, RegisterData, RegisterPayload, SetSpenderData,
+ SetSpenderPayload, SpenderDelegation, SpenderTransferData, SpenderTransferPayload,
+ TransferData, TransferPayload, WithdrawData, WithdrawPayload,
+};
+
+// ################## MOCK CONTRACTS ##################
+
+#[contract]
+struct RwaHost;
+
+#[contractimpl]
+impl RwaHost {
+ pub fn __constructor(e: &Env, token: Address, verifier: Address, auditor: Address) {
+ set_underlying_asset(e, &token);
+ set_verifier(e, &verifier);
+ set_auditor(e, &auditor);
+ set_address_as_field_element(e);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl ConfidentialToken for RwaHost {
+ type Hooks = RwaHooks;
+}
+
+#[contractimpl(contracttrait)]
+impl ConfidentialCompliance for RwaHost {
+ fn freeze(e: &Env, account: Address, admin: Address) {
+ admin.require_auth();
+ freeze(e, &account);
+ }
+
+ fn unfreeze(e: &Env, account: Address, admin: Address) {
+ admin.require_auth();
+ unfreeze(e, &account);
+ }
+
+ fn set_compliance_config(e: &Env, config: ComplianceConfig, admin: Address) {
+ admin.require_auth();
+ set_compliance_config(e, &config);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl ConfidentialClawback for RwaHost {
+ fn clawback(
+ e: &Env,
+ account: Address,
+ amount: i128,
+ destination: Option,
+ data: Bytes,
+ admin: Address,
+ ) {
+ admin.require_auth();
+ let d: ClawbackData = decode_data(e, &data);
+ clawback(e, &account, amount, &destination, &d.proof);
+ }
+
+ fn force_revoke_spender(e: &Env, account: Address, spender: Address, admin: Address) {
+ admin.require_auth();
+ force_revoke_spender(e, &account, &spender);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl ConfidentialRwa for RwaHost {
+ fn set_rwa_config(e: &Env, auditor_id: u32, approver: Option, admin: Address) {
+ admin.require_auth();
+ set_rwa_config(e, auditor_id, &approver);
+ }
+
+ fn bump_approval_epoch(e: &Env, admin: Address) {
+ admin.require_auth();
+ bump_approval_epoch(e);
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl ConfidentialForcedTransfer for RwaHost {
+ fn forced_transfer(
+ e: &Env,
+ from: Address,
+ to: Address,
+ amount: i128,
+ data: Bytes,
+ admin: Address,
+ ) {
+ admin.require_auth();
+ let d: ClawbackData = decode_data(e, &data);
+ forced_transfer(e, &from, &to, amount, &d.proof);
+ }
+}
+
+/// Authorizes every address except the one set through `block`.
+#[contract]
+struct ListPolicy;
+
+#[contractimpl]
+impl ListPolicy {
+ pub fn block(e: &Env, account: Address) {
+ e.storage().instance().set(&symbol_short!("blocked"), &account);
+ }
+}
+
+#[contractimpl]
+impl Policy for ListPolicy {
+ fn is_authorized(e: Env, account: Address, _token: Address) -> bool {
+ e.storage().instance().get::<_, Address>(&symbol_short!("blocked")) != Some(account)
+ }
+}
+
+/// A policy whose call always fails.
+#[contract]
+struct PanicPolicy;
+
+#[contractimpl]
+impl Policy for PanicPolicy {
+ fn is_authorized(_e: Env, _account: Address, _token: Address) -> bool {
+ panic!("policy unavailable")
+ }
+}
+
+/// Accepts every proof and records the public inputs of the last
+/// verification, so tests can inspect what the contract assembled.
+#[contract]
+struct RecordingVerifier;
+
+#[contractimpl]
+impl RecordingVerifier {
+ pub fn last_public_inputs(e: &Env) -> Bytes {
+ e.storage().instance().get(&symbol_short!("pi")).unwrap()
+ }
+
+ pub fn last_circuit(e: &Env) -> CircuitType {
+ e.storage().instance().get(&symbol_short!("ct")).unwrap()
+ }
+}
+
+#[contractimpl(contracttrait)]
+impl crate::verifier::ConfidentialVerifier for RecordingVerifier {
+ fn register_verification_key(_e: &Env, _ct: CircuitType, _vk: Bytes, _op: Address) {}
+
+ fn update_verification_key(_e: &Env, _ct: CircuitType, _vk: Bytes, _op: Address) {}
+
+ fn verify_proof(e: &Env, ct: CircuitType, pi: Bytes, _proof: Bytes) -> bool {
+ e.storage().instance().set(&symbol_short!("pi"), &pi);
+ e.storage().instance().set(&symbol_short!("ct"), &ct);
+ true
+ }
+}
+
+/// Models a proof's binding to its public inputs: the first blob it accepts
+/// becomes the statement, and every later verification succeeds only on the
+/// identical blob.
+#[contract]
+struct BoundProofVerifier;
+
+#[contractimpl(contracttrait)]
+impl crate::verifier::ConfidentialVerifier for BoundProofVerifier {
+ fn register_verification_key(_e: &Env, _ct: CircuitType, _vk: Bytes, _op: Address) {}
+
+ fn update_verification_key(_e: &Env, _ct: CircuitType, _vk: Bytes, _op: Address) {}
+
+ fn verify_proof(e: &Env, _ct: CircuitType, pi: Bytes, _proof: Bytes) -> bool {
+ let key = symbol_short!("bound");
+ match e.storage().instance().get::<_, Bytes>(&key) {
+ None => {
+ e.storage().instance().set(&key, &pi);
+ true
+ }
+ Some(bound) => bound == pi,
+ }
+ }
+}
+
+/// Rejects every proof.
+#[contract]
+struct RejectingVerifier;
+
+#[contractimpl(contracttrait)]
+impl crate::verifier::ConfidentialVerifier for RejectingVerifier {
+ fn register_verification_key(_e: &Env, _ct: CircuitType, _vk: Bytes, _op: Address) {}
+
+ fn update_verification_key(_e: &Env, _ct: CircuitType, _vk: Bytes, _op: Address) {}
+
+ fn verify_proof(_e: &Env, _ct: CircuitType, _pi: Bytes, _proof: Bytes) -> bool {
+ false
+ }
+}
+
+#[contract]
+struct MockAuditor;
+
+#[contractimpl(contracttrait)]
+impl crate::auditor::ConfidentialAuditor for MockAuditor {
+ fn register_key(e: &Env, auditor_id: u32, point: BytesN<64>, _operator: Address) {
+ crate::auditor::storage::register_key(e, auditor_id, &point);
+ }
+
+ fn rotate_key(_e: &Env, _auditor_id: u32, _new_point: BytesN<64>, _operator: Address) {}
+}
+
+// ################## SETUP ##################
+
+/// The designated auditor id of every configured harness.
+const AUDITOR: u32 = 0;
+/// A second registered auditor id, standing for a legacy binding.
+const LEGACY_AUDITOR: u32 = 1;
+
+struct Harness<'a> {
+ e: Env,
+ host: Address,
+ verifier: Address,
+ sac_addr: Address,
+ sac: StellarAssetClient<'a>,
+ policy: Address,
+ admin: Address,
+}
+
+fn setup<'a>() -> Harness<'a> {
+ setup_with(|e| e.register(RecordingVerifier, ()))
+}
+
+fn setup_with<'a>(register_verifier: impl FnOnce(&Env) -> Address) -> Harness<'a> {
+ let e = Env::default();
+ e.mock_all_auths();
+
+ let issuer = Address::generate(&e);
+ let sac = e.register_stellar_asset_contract_v2(issuer);
+ let sac_addr = sac.address();
+ // SAC v2 honours `set_authorized` only when the issuer is revocable.
+ sac.issuer().set_flag(AccountFlags::RevocableFlag);
+ let sac_client = StellarAssetClient::new(&e, &sac_addr);
+
+ let verifier = register_verifier(&e);
+ let auditor = e.register(MockAuditor, ());
+ let admin = Address::generate(&e);
+ let auditor_client = crate::auditor::ConfidentialAuditorClient::new(&e, &auditor);
+ auditor_client.register_key(&AUDITOR, &Grumpkin::generator(&e), &admin);
+ // A distinct key, so tests can tell which auditor's key a proof binds.
+ auditor_client.register_key(
+ &LEGACY_AUDITOR,
+ &Grumpkin::mul(&e, &Grumpkin::generator(&e), 2),
+ &admin,
+ );
+
+ let policy = e.register(ListPolicy, ());
+ let host = e.register(RwaHost, (sac_addr.clone(), verifier.clone(), auditor));
+
+ Harness { e, host, verifier, sac_addr, sac: sac_client, policy, admin }
+}
+
+/// A harness with both configurations set: a policy gate, the designated
+/// auditor, and `approver`.
+fn configured<'a>(approver: Option) -> Harness<'a> {
+ let h = setup();
+ configure(&h, approver);
+ h
+}
+
+fn configure(h: &Harness, approver: Option) {
+ h.e.as_contract(&h.host, || {
+ set_compliance_config(&h.e, &policy_config(h));
+ set_rwa_config(&h.e, AUDITOR, &approver);
+ });
+}
+
+fn policy_config(h: &Harness) -> ComplianceConfig {
+ ComplianceConfig { policy: Some(h.policy.clone()), sac_passthrough: false }
+}
+
+fn point(e: &Env) -> BytesN<64> {
+ Grumpkin::generator(e)
+}
+
+fn field(e: &Env, byte: u8) -> BytesN<32> {
+ let mut b = [0u8; 32];
+ b[31] = byte;
+ BytesN::from_array(e, &b)
+}
+
+fn g_times(e: &Env, n: u128) -> BytesN<64> {
+ Grumpkin::mul(e, &Grumpkin::generator(e), n)
+}
+
+/// Writes an account record directly, bypassing registration.
+fn store_account(
+ h: &Harness,
+ account: &Address,
+ auditor_id: u32,
+ spendable: u128,
+ receiving: u128,
+) {
+ h.e.as_contract(&h.host, || {
+ let acc = ConfidentialAccount {
+ spending_public_key: point(&h.e),
+ viewing_public_key: point(&h.e),
+ spendable_commitment: g_times(&h.e, spendable),
+ receiving_commitment: g_times(&h.e, receiving),
+ auditor_id,
+ };
+ h.e.storage()
+ .persistent()
+ .set(&ConfidentialTokenStorageKey::Account(account.clone()), &acc);
+ });
+}
+
+fn account(h: &Harness, auditor_id: u32) -> Address {
+ let a = Address::generate(&h.e);
+ store_account(h, &a, auditor_id, 0, 0);
+ a
+}
+
+fn store_delegation(h: &Harness, owner: &Address, spender: &Address, allowance: u128) {
+ h.e.as_contract(&h.host, || {
+ let d = SpenderDelegation {
+ allowance_commitment: g_times(&h.e, allowance),
+ a_tilde: field(&h.e, 1),
+ escrowed_dvk: point(&h.e),
+ allowance_salt: field(&h.e, 2),
+ live_until_ledger: u32::MAX,
+ };
+ h.e.storage()
+ .persistent()
+ .set(&ConfidentialTokenStorageKey::Delegation(owner.clone(), spender.clone()), &d);
+ });
+}
+
+fn register_payload(e: &Env) -> RegisterPayload {
+ RegisterPayload { y: point(e), pvk: point(e) }
+}
+
+fn withdraw_payload(e: &Env) -> WithdrawPayload {
+ WithdrawPayload {
+ c_spend_new: point(e),
+ b_tilde: field(e, 1),
+ r_e_point: point(e),
+ sigma: field(e, 2),
+ b_tilde_aud_s: field(e, 3),
+ r_tilde_aud_s: field(e, 4),
+ }
+}
+
+fn transfer_payload(e: &Env) -> TransferPayload {
+ TransferPayload {
+ c_spend_new: point(e),
+ c_transfer: point(e),
+ r_e_point: point(e),
+ v_tilde: field(e, 1),
+ b_tilde: field(e, 2),
+ sigma: field(e, 3),
+ v_tilde_aud_r: field(e, 4),
+ r_tilde_aud_r: field(e, 5),
+ v_tilde_aud_s: field(e, 6),
+ b_tilde_aud_s: field(e, 7),
+ r_tilde_aud_s: field(e, 8),
+ }
+}
+
+fn spender_transfer_payload(e: &Env) -> SpenderTransferPayload {
+ SpenderTransferPayload {
+ c_a_new: point(e),
+ c_transfer: point(e),
+ r_e_point: point(e),
+ v_tilde: field(e, 1),
+ a_tilde_new: field(e, 2),
+ sigma_a_new: field(e, 3),
+ v_tilde_aud_r: field(e, 4),
+ r_tilde_aud_r: field(e, 5),
+ v_tilde_aud_s: field(e, 6),
+ a_tilde_aud_s: field(e, 7),
+ r_tilde_aud_s: field(e, 8),
+ }
+}
+
+fn set_spender_payload(e: &Env) -> SetSpenderPayload {
+ SetSpenderPayload {
+ c_spend_new: point(e),
+ c_a: point(e),
+ escrowed_dvk: point(e),
+ b_tilde: field(e, 1),
+ a_tilde: field(e, 2),
+ r_e_point: point(e),
+ sigma: field(e, 3),
+ sigma_a: field(e, 4),
+ v_tilde_aud_s: field(e, 5),
+ b_tilde_aud_s: field(e, 6),
+ r_tilde_aud_s: field(e, 7),
+ r_a_tilde_aud_s: field(e, 8),
+ }
+}
+
+fn transfer_data(e: &Env) -> Bytes {
+ TransferData { payload: transfer_payload(e), proof: Bytes::new(e) }.to_xdr(e)
+}
+
+fn withdraw_data(e: &Env) -> Bytes {
+ WithdrawData { payload: withdraw_payload(e), proof: Bytes::new(e) }.to_xdr(e)
+}
+
+fn spender_transfer_data(e: &Env) -> Bytes {
+ SpenderTransferData { payload: spender_transfer_payload(e), proof: Bytes::new(e) }.to_xdr(e)
+}
+
+fn set_spender_data(e: &Env) -> Bytes {
+ SetSpenderData { payload: set_spender_payload(e), proof: Bytes::new(e) }.to_xdr(e)
+}
+
+fn proof_data(e: &Env) -> Bytes {
+ ClawbackData { proof: Bytes::new(e) }.to_xdr(e)
+}
+
+fn freeze_account(h: &Harness, account: &Address) {
+ h.e.as_contract(&h.host, || freeze(&h.e, account));
+}
+
+/// Runs every `RwaHooks` callback once against `a` and `b`.
+fn run_hook(h: &Harness, which: u32, a: &Address, b: &Address) {
+ let e = &h.e;
+ e.as_contract(&h.host, || match which {
+ 0 => RwaHooks::on_register(e, a, AUDITOR, ®ister_payload(e)),
+ 1 => RwaHooks::on_deposit(e, a, b, 1),
+ 2 => RwaHooks::on_merge(e, a),
+ 3 => RwaHooks::on_withdraw(e, a, b, 1, &withdraw_payload(e)),
+ 4 => RwaHooks::on_transfer(e, a, b, &transfer_payload(e)),
+ 5 => RwaHooks::on_spender_transfer(e, b, a, b, &spender_transfer_payload(e)),
+ 6 => RwaHooks::on_set_spender(e, a, b, 100, &set_spender_payload(e)),
+ _ => RwaHooks::on_revoke_spender(e, a, b),
+ });
+}
+
+const HOOK_COUNT: u32 = 8;
+
+/// Runs hook `which` and returns the panic message, or `None` when it passes.
+fn hook_panic(h: &Harness, which: u32, a: &Address, b: &Address) -> Option {
+ let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_hook(h, which, a, b)));
+ res.err().map(|payload| {
+ payload
+ .downcast_ref::()
+ .cloned()
+ .or_else(|| payload.downcast_ref::<&str>().map(|s| std::string::String::from(*s)))
+ .unwrap_or_default()
+ })
+}
+
+/// Asserts that every hook reverts with the contract error `code`.
+fn assert_every_hook_reverts_with(h: &Harness, a: &Address, b: &Address, code: u32) {
+ let expected = std::format!("Error(Contract, #{code})");
+ for which in 0..HOOK_COUNT {
+ let msg = hook_panic(h, which, a, b)
+ .unwrap_or_else(|| panic!("hook {which} passed, expected {expected}"));
+ assert!(msg.contains(&expected), "hook {which}: expected {expected}, got {msg}");
+ }
+}
+
+// ################## CONFIGURATION ##################
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3600)")]
+fn set_rwa_config_requires_compliance_config() {
+ let h = setup();
+ h.e.as_contract(&h.host, || set_rwa_config(&h.e, AUDITOR, &None));
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3615)")]
+fn set_rwa_config_requires_eligibility_gate() {
+ let h = setup();
+ h.e.as_contract(&h.host, || {
+ set_compliance_config(&h.e, &ComplianceConfig { policy: None, sac_passthrough: false });
+ set_rwa_config(&h.e, AUDITOR, &None);
+ });
+}
+
+#[test]
+fn set_rwa_config_accepts_sac_passthrough_as_gate() {
+ let h = setup();
+ h.e.as_contract(&h.host, || {
+ set_compliance_config(&h.e, &ComplianceConfig { policy: None, sac_passthrough: true });
+ set_rwa_config(&h.e, AUDITOR, &None);
+ assert_eq!(rwa_config(&h.e), Some(RwaConfig { auditor_id: AUDITOR, approver: None }));
+ });
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3301)")]
+fn set_rwa_config_rejects_unregistered_auditor() {
+ let h = setup();
+ h.e.as_contract(&h.host, || {
+ set_compliance_config(&h.e, &policy_config(&h));
+ set_rwa_config(&h.e, 7, &None);
+ });
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3610)")]
+fn set_rwa_config_rejects_auditor_change() {
+ let h = configured(None);
+ h.e.as_contract(&h.host, || set_rwa_config(&h.e, LEGACY_AUDITOR, &None));
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3611)")]
+fn set_rwa_config_rejects_token_as_approver() {
+ let h = setup();
+ h.e.as_contract(&h.host, || {
+ set_compliance_config(&h.e, &policy_config(&h));
+ set_rwa_config(&h.e, AUDITOR, &Some(h.host.clone()));
+ });
+}
+
+#[test]
+fn set_rwa_config_stores_emits_and_advances_epoch() {
+ let h = setup();
+ let approver = Address::generate(&h.e);
+ let client = ConfidentialRwaClient::new(&h.e, &h.host);
+ ConfidentialComplianceClient::new(&h.e, &h.host)
+ .set_compliance_config(&policy_config(&h), &h.admin);
+ assert_eq!(client.rwa_config(), None);
+ assert_eq!(client.approval_epoch(), 0);
+
+ client.set_rwa_config(&AUDITOR, &Some(approver.clone()), &h.admin);
+
+ let events = h.e.events().all();
+ assert_eq!(events.events().len(), 1);
+ assert_eq!(
+ events.events().first().unwrap(),
+ &RwaConfigChanged { auditor_id: AUDITOR, approver: Some(approver.clone()), epoch: 1 }
+ .to_xdr(&h.e, &h.host),
+ );
+ assert_eq!(
+ client.rwa_config(),
+ Some(RwaConfig { auditor_id: AUDITOR, approver: Some(approver) })
+ );
+ assert_eq!(client.approval_epoch(), 1);
+}
+
+#[test]
+fn approver_can_be_changed_and_disabled_and_epoch_never_repeats() {
+ let h = configured(None);
+ let client = ConfidentialRwaClient::new(&h.e, &h.host);
+ let a = Address::generate(&h.e);
+ assert_eq!(client.approval_epoch(), 1);
+
+ client.set_rwa_config(&AUDITOR, &Some(a.clone()), &h.admin);
+ assert_eq!(client.approval_epoch(), 2);
+ client.set_rwa_config(&AUDITOR, &None, &h.admin);
+ assert_eq!(client.approval_epoch(), 3);
+ // Re-enabling the same approver does not return to the epoch it was
+ // enabled under before.
+ client.set_rwa_config(&AUDITOR, &Some(a), &h.admin);
+ assert_eq!(client.approval_epoch(), 4);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3607)")]
+fn bump_approval_epoch_requires_rwa_config() {
+ let h = setup();
+ h.e.as_contract(&h.host, || bump_approval_epoch(&h.e));
+}
+
+#[test]
+fn bump_approval_epoch_increments_and_emits() {
+ let h = configured(None);
+ let client = ConfidentialRwaClient::new(&h.e, &h.host);
+ client.bump_approval_epoch(&h.admin);
+
+ let events = h.e.events().all();
+ assert_eq!(events.events().len(), 1);
+ assert_eq!(
+ events.events().first().unwrap(),
+ &ApprovalEpochBumped { epoch: 2 }.to_xdr(&h.e, &h.host),
+ );
+ assert_eq!(client.approval_epoch(), 2);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3616)")]
+fn approval_epoch_overflow_panics() {
+ let h = configured(None);
+ h.e.as_contract(&h.host, || {
+ h.e.storage().instance().set(&ComplianceStorageKey::ApprovalEpoch, &u32::MAX);
+ bump_approval_epoch(&h.e);
+ });
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3615)")]
+fn set_compliance_config_cannot_remove_eligibility_gate_under_rwa() {
+ let h = configured(None);
+ h.e.as_contract(&h.host, || {
+ set_compliance_config(&h.e, &ComplianceConfig { policy: None, sac_passthrough: false });
+ });
+}
+
+#[test]
+fn set_compliance_config_advances_epoch_under_rwa() {
+ let h = configured(None);
+ let client = ConfidentialComplianceClient::new(&h.e, &h.host);
+ let config = ComplianceConfig { policy: Some(h.policy.clone()), sac_passthrough: true };
+ client.set_compliance_config(&config, &h.admin);
+
+ let events = h.e.events().all();
+ assert_eq!(events.events().len(), 2);
+ assert_eq!(
+ events.events().first().unwrap(),
+ &ComplianceConfigChanged { policy: Some(h.policy.clone()), sac_passthrough: true }
+ .to_xdr(&h.e, &h.host),
+ );
+ assert_eq!(
+ events.events().get(1).unwrap(),
+ &ApprovalEpochBumped { epoch: 2 }.to_xdr(&h.e, &h.host),
+ );
+ h.e.as_contract(&h.host, || assert_eq!(approval_epoch(&h.e), 2));
+}
+
+#[test]
+fn set_compliance_config_is_unchanged_without_rwa_config() {
+ let h = setup();
+ let client = ConfidentialComplianceClient::new(&h.e, &h.host);
+ let open = ComplianceConfig { policy: None, sac_passthrough: false };
+ client.set_compliance_config(&open, &h.admin);
+
+ assert_eq!(h.e.events().all().events().len(), 1);
+ h.e.as_contract(&h.host, || {
+ assert_eq!(compliance_config(&h.e), Some(open.clone()));
+ assert_eq!(approval_epoch(&h.e), 0);
+ });
+}
+
+// ################## FAIL CLOSED ##################
+
+#[test]
+fn every_hook_reverts_without_rwa_config() {
+ let h = setup();
+ h.e.as_contract(&h.host, || set_compliance_config(&h.e, &policy_config(&h)));
+ let a = account(&h, AUDITOR);
+ let b = account(&h, AUDITOR);
+ assert_every_hook_reverts_with(&h, &a, &b, 3607);
+}
+
+#[test]
+fn every_hook_reverts_without_compliance_config() {
+ let h = configured(None);
+ let a = account(&h, AUDITOR);
+ let b = account(&h, AUDITOR);
+ h.e.as_contract(&h.host, || h.e.storage().instance().remove(&ComplianceStorageKey::Config));
+ assert_every_hook_reverts_with(&h, &a, &b, 3600);
+}
+
+#[test]
+fn every_hook_reverts_without_eligibility_gate() {
+ let h = configured(None);
+ let a = account(&h, AUDITOR);
+ let b = account(&h, AUDITOR);
+ h.e.as_contract(&h.host, || {
+ h.e.storage().instance().set(
+ &ComplianceStorageKey::Config,
+ &ComplianceConfig { policy: None, sac_passthrough: false },
+ );
+ });
+ assert_every_hook_reverts_with(&h, &a, &b, 3615);
+}
+
+#[test]
+fn every_hook_passes_when_configured_without_approver() {
+ let h = configured(None);
+ let a = account(&h, AUDITOR);
+ let b = account(&h, AUDITOR);
+ for which in 0..HOOK_COUNT {
+ run_hook(&h, which, &a, &b);
+ }
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3607)")]
+fn on_transfer_without_rwa_config_panics_rwa_not_configured() {
+ let h = setup();
+ h.e.as_contract(&h.host, || set_compliance_config(&h.e, &policy_config(&h)));
+ let a = account(&h, AUDITOR);
+ let b = account(&h, AUDITOR);
+ run_hook(&h, 4, &a, &b);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3600)")]
+fn on_transfer_without_compliance_config_panics_not_configured() {
+ let h = configured(None);
+ let a = account(&h, AUDITOR);
+ let b = account(&h, AUDITOR);
+ h.e.as_contract(&h.host, || h.e.storage().instance().remove(&ComplianceStorageKey::Config));
+ run_hook(&h, 4, &a, &b);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3615)")]
+fn on_transfer_without_eligibility_gate_panics() {
+ let h = configured(None);
+ let a = account(&h, AUDITOR);
+ let b = account(&h, AUDITOR);
+ h.e.as_contract(&h.host, || {
+ h.e.storage().instance().set(
+ &ComplianceStorageKey::Config,
+ &ComplianceConfig { policy: None, sac_passthrough: false },
+ );
+ });
+ run_hook(&h, 4, &a, &b);
+}
+
+// ################## DESIGNATED AUDITOR ##################
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3608)")]
+fn on_register_rejects_other_auditor() {
+ let h = configured(None);
+ let a = Address::generate(&h.e);
+ h.e.as_contract(&h.host, || {
+ RwaHooks::on_register(&h.e, &a, LEGACY_AUDITOR, ®ister_payload(&h.e));
+ });
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3608)")]
+fn register_entry_point_rejects_other_auditor() {
+ let h = configured(None);
+ let a = Address::generate(&h.e);
+ let data = RegisterData { payload: register_payload(&h.e), proof: Bytes::new(&h.e) };
+ ConfidentialTokenClient::new(&h.e, &h.host).register(&a, &LEGACY_AUDITOR, &data.to_xdr(&h.e));
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3602)")]
+fn on_register_rejects_policy_denied_account() {
+ let h = configured(None);
+ let a = Address::generate(&h.e);
+ ListPolicyClient::new(&h.e, &h.policy).block(&a);
+ h.e.as_contract(&h.host, || {
+ RwaHooks::on_register(&h.e, &a, AUDITOR, ®ister_payload(&h.e));
+ });
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3609)")]
+fn on_transfer_rejects_legacy_sender() {
+ let h = configured(None);
+ let from = account(&h, LEGACY_AUDITOR);
+ let to = account(&h, AUDITOR);
+ run_hook(&h, 4, &from, &to);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3609)")]
+fn on_transfer_rejects_legacy_recipient() {
+ let h = configured(None);
+ let from = account(&h, AUDITOR);
+ let to = account(&h, LEGACY_AUDITOR);
+ run_hook(&h, 4, &from, &to);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3609)")]
+fn on_spender_transfer_rejects_legacy_owner() {
+ let h = configured(None);
+ let owner = account(&h, LEGACY_AUDITOR);
+ let to = account(&h, AUDITOR);
+ run_hook(&h, 5, &owner, &to);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3609)")]
+fn on_deposit_rejects_legacy_recipient() {
+ let h = configured(None);
+ let from = Address::generate(&h.e);
+ let to = account(&h, LEGACY_AUDITOR);
+ run_hook(&h, 1, &from, &to);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3609)")]
+fn on_set_spender_rejects_legacy_owner() {
+ let h = configured(None);
+ let owner = account(&h, LEGACY_AUDITOR);
+ let spender = account(&h, AUDITOR);
+ run_hook(&h, 6, &owner, &spender);
+}
+
+#[test]
+fn legacy_account_can_merge_revoke_and_withdraw() {
+ let h = configured(None);
+ let legacy = account(&h, LEGACY_AUDITOR);
+ let other = account(&h, AUDITOR);
+ run_hook(&h, 2, &legacy, &other);
+ run_hook(&h, 7, &legacy, &other);
+ run_hook(&h, 3, &legacy, &other);
+}
+
+#[test]
+fn spender_auditor_binding_is_not_checked() {
+ let h = configured(None);
+ let owner = account(&h, AUDITOR);
+ let legacy_spender = account(&h, LEGACY_AUDITOR);
+ run_hook(&h, 6, &owner, &legacy_spender);
+}
+
+// ################## ELIGIBILITY GATES ##################
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3501)")]
+fn on_deposit_rejects_unregistered_recipient() {
+ let h = configured(None);
+ let from = Address::generate(&h.e);
+ let to = Address::generate(&h.e);
+ run_hook(&h, 1, &from, &to);
+}
+
+#[test]
+fn on_deposit_accepts_unregistered_depositor() {
+ let h = configured(None);
+ let from = Address::generate(&h.e);
+ let to = account(&h, AUDITOR);
+ run_hook(&h, 1, &from, &to);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3601)")]
+fn on_set_spender_rejects_frozen_spender() {
+ let h = configured(None);
+ let owner = account(&h, AUDITOR);
+ let spender = account(&h, AUDITOR);
+ freeze_account(&h, &spender);
+ run_hook(&h, 6, &owner, &spender);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3601)")]
+fn on_spender_transfer_rejects_frozen_spender() {
+ let h = configured(None);
+ let owner = account(&h, AUDITOR);
+ let to = account(&h, AUDITOR);
+ let spender = account(&h, AUDITOR);
+ freeze_account(&h, &spender);
+ h.e.as_contract(&h.host, || {
+ RwaHooks::on_spender_transfer(&h.e, &spender, &owner, &to, &spender_transfer_payload(&h.e));
+ });
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3602)")]
+fn on_spender_transfer_rejects_policy_denied_spender() {
+ let h = configured(None);
+ let owner = account(&h, AUDITOR);
+ let to = account(&h, AUDITOR);
+ let spender = account(&h, AUDITOR);
+ ListPolicyClient::new(&h.e, &h.policy).block(&spender);
+ h.e.as_contract(&h.host, || {
+ RwaHooks::on_spender_transfer(&h.e, &spender, &owner, &to, &spender_transfer_payload(&h.e));
+ });
+}
+
+#[test]
+fn on_revoke_spender_ignores_frozen_and_denied_spender() {
+ let h = configured(None);
+ let owner = account(&h, AUDITOR);
+ let spender = account(&h, AUDITOR);
+ freeze_account(&h, &spender);
+ ListPolicyClient::new(&h.e, &h.policy).block(&spender);
+ run_hook(&h, 7, &owner, &spender);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3601)")]
+fn on_merge_rejects_frozen_account() {
+ let h = configured(None);
+ let a = account(&h, AUDITOR);
+ freeze_account(&h, &a);
+ run_hook(&h, 2, &a, &a);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3602)")]
+fn on_withdraw_rejects_policy_denied_recipient() {
+ let h = configured(None);
+ let from = account(&h, AUDITOR);
+ let to = Address::generate(&h.e);
+ ListPolicyClient::new(&h.e, &h.policy).block(&to);
+ run_hook(&h, 3, &from, &to);
+}
+
+/// Which party of a hook a gate test makes ineligible.
+#[derive(Clone, Copy)]
+enum Party {
+ A,
+ B,
+}
+
+/// Runs hook `which` with one party frozen and asserts `AccountFrozen`.
+fn assert_frozen_party_rejected(which: u32, party: Party) {
+ let h = configured(None);
+ let a = account(&h, AUDITOR);
+ let b = account(&h, AUDITOR);
+ freeze_account(
+ &h,
+ match party {
+ Party::A => &a,
+ Party::B => &b,
+ },
+ );
+ let msg = hook_panic(&h, which, &a, &b).expect("hook passed with a frozen party");
+ assert!(msg.contains("Error(Contract, #3601)"), "hook {which}: {msg}");
+}
+
+/// Runs hook `which` with one party denied by the policy and asserts
+/// `NotAuthorizedByPolicy`.
+fn assert_denied_party_rejected(which: u32, party: Party) {
+ let h = configured(None);
+ let a = account(&h, AUDITOR);
+ let b = account(&h, AUDITOR);
+ ListPolicyClient::new(&h.e, &h.policy).block(match party {
+ Party::A => &a,
+ Party::B => &b,
+ });
+ let msg = hook_panic(&h, which, &a, &b).expect("hook passed with a policy-denied party");
+ assert!(msg.contains("Error(Contract, #3602)"), "hook {which}: {msg}");
+}
+
+#[test]
+fn every_gated_party_is_rejected_when_frozen() {
+ // (hook, party): deposit from/to, merge account, withdraw from/to,
+ // transfer from/to, spender transfer owner/recipient-and-spender,
+ // set_spender account/spender, revoke account.
+ for (which, party) in [
+ (1, Party::A),
+ (1, Party::B),
+ (2, Party::A),
+ (3, Party::A),
+ (3, Party::B),
+ (4, Party::A),
+ (4, Party::B),
+ (5, Party::A),
+ (5, Party::B),
+ (6, Party::A),
+ (6, Party::B),
+ (7, Party::A),
+ ] {
+ assert_frozen_party_rejected(which, party);
+ }
+}
+
+#[test]
+fn every_gated_party_is_rejected_when_policy_denied() {
+ for (which, party) in [
+ (0, Party::A),
+ (1, Party::A),
+ (1, Party::B),
+ (2, Party::A),
+ (3, Party::A),
+ (3, Party::B),
+ (4, Party::A),
+ (4, Party::B),
+ (5, Party::A),
+ (5, Party::B),
+ (6, Party::A),
+ (6, Party::B),
+ (7, Party::A),
+ ] {
+ assert_denied_party_rejected(which, party);
+ }
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3601)")]
+fn on_spender_transfer_rejects_frozen_recipient() {
+ // In `run_hook` the recipient doubles as the spender; here they differ.
+ let h = configured(None);
+ let owner = account(&h, AUDITOR);
+ let to = account(&h, AUDITOR);
+ let spender = account(&h, AUDITOR);
+ freeze_account(&h, &to);
+ h.e.as_contract(&h.host, || {
+ RwaHooks::on_spender_transfer(&h.e, &spender, &owner, &to, &spender_transfer_payload(&h.e));
+ });
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3609)")]
+fn on_spender_transfer_rejects_legacy_recipient() {
+ let h = configured(None);
+ let owner = account(&h, AUDITOR);
+ let to = account(&h, LEGACY_AUDITOR);
+ let spender = account(&h, AUDITOR);
+ h.e.as_contract(&h.host, || {
+ RwaHooks::on_spender_transfer(&h.e, &spender, &owner, &to, &spender_transfer_payload(&h.e));
+ });
+}
+
+#[test]
+fn can_send_and_can_receive_follow_public_eligibility() {
+ let h = configured(None);
+ let client = ConfidentialRwaClient::new(&h.e, &h.host);
+ let ok = account(&h, AUDITOR);
+ let unregistered = Address::generate(&h.e);
+ let legacy = account(&h, LEGACY_AUDITOR);
+ let frozen = account(&h, AUDITOR);
+ freeze_account(&h, &frozen);
+ let denied = account(&h, AUDITOR);
+ ListPolicyClient::new(&h.e, &h.policy).block(&denied);
+
+ assert!(client.can_send(&ok));
+ assert!(client.can_receive(&ok));
+ for a in [&unregistered, &legacy, &frozen, &denied] {
+ assert!(!client.can_send(a));
+ assert!(!client.can_receive(a));
+ }
+}
+
+#[test]
+fn can_transact_is_false_when_unconfigured() {
+ let h = setup();
+ let a = account(&h, AUDITOR);
+ h.e.as_contract(&h.host, || assert!(!can_transact(&h.e, &a)));
+
+ h.e.as_contract(&h.host, || set_compliance_config(&h.e, &policy_config(&h)));
+ h.e.as_contract(&h.host, || assert!(!can_transact(&h.e, &a)));
+}
+
+#[test]
+fn can_transact_is_false_when_policy_call_fails() {
+ let h = setup();
+ let failing = h.e.register(PanicPolicy, ());
+ h.e.as_contract(&h.host, || {
+ set_compliance_config(
+ &h.e,
+ &ComplianceConfig { policy: Some(failing.clone()), sac_passthrough: false },
+ );
+ set_rwa_config(&h.e, AUDITOR, &None);
+ });
+ let a = account(&h, AUDITOR);
+ assert!(!ConfidentialRwaClient::new(&h.e, &h.host).can_send(&a));
+}
+
+#[test]
+fn can_transact_follows_sac_authorization() {
+ let h = setup();
+ h.e.as_contract(&h.host, || {
+ set_compliance_config(&h.e, &ComplianceConfig { policy: None, sac_passthrough: true });
+ set_rwa_config(&h.e, AUDITOR, &None);
+ });
+ let a = account(&h, AUDITOR);
+ let client = ConfidentialRwaClient::new(&h.e, &h.host);
+ assert!(client.can_receive(&a));
+
+ h.sac.set_authorized(&a, &false);
+ assert!(!client.can_receive(&a));
+}
+
+#[test]
+fn can_transact_is_false_when_underlying_asset_is_missing() {
+ let h = setup();
+ h.e.as_contract(&h.host, || {
+ set_compliance_config(&h.e, &ComplianceConfig { policy: None, sac_passthrough: true });
+ set_rwa_config(&h.e, AUDITOR, &None);
+ });
+ let a = account(&h, AUDITOR);
+ h.e.as_contract(&h.host, || {
+ h.e.storage().instance().remove(&ConfidentialTokenStorageKey::UnderlyingAsset);
+ });
+ assert!(!ConfidentialRwaClient::new(&h.e, &h.host).can_send(&a));
+}
+
+#[test]
+fn hooks_reject_sac_unauthorized_parties_under_passthrough() {
+ let h = setup();
+ h.e.as_contract(&h.host, || {
+ set_compliance_config(&h.e, &ComplianceConfig { policy: None, sac_passthrough: true });
+ set_rwa_config(&h.e, AUDITOR, &None);
+ });
+ let a = account(&h, AUDITOR);
+ let b = account(&h, AUDITOR);
+ h.sac.set_authorized(&b, &false);
+ for which in [0, 2, 7] {
+ // `b` is the gated account of register, merge and revoke here.
+ let msg = hook_panic(&h, which, &b, &a).expect("hook passed for a SAC-denied account");
+ assert!(msg.contains("Error(Contract, #3603)"), "hook {which}: {msg}");
+ }
+ for which in [1, 3, 4, 5, 6] {
+ let msg = hook_panic(&h, which, &a, &b).expect("hook passed for a SAC-denied party");
+ assert!(msg.contains("Error(Contract, #3603)"), "hook {which}: {msg}");
+ }
+}
+
+// ################## APPROVAL GATE ##################
+
+/// Registered, funded sender and a registered recipient on a deployment whose
+/// approver is `approver`.
+struct Flow<'a> {
+ h: Harness<'a>,
+ approver: Address,
+ alice: Address,
+ bob: Address,
+}
+
+fn approval_flow<'a>() -> Flow<'a> {
+ let h = setup();
+ let approver = Address::generate(&h.e);
+ configure(&h, Some(approver.clone()));
+ let alice = account(&h, AUDITOR);
+ let bob = account(&h, AUDITOR);
+ store_account(&h, &alice, AUDITOR, 1_000, 0);
+ h.sac.mint(&alice, &1_000);
+ h.sac.mint(&h.host, &1_000);
+ Flow { h, approver, alice, bob }
+}
+
+fn invoke<'a>(
+ contract: &'a Address,
+ fn_name: &'a str,
+ args: Vec,
+ sub_invokes: &'a [MockAuthInvoke<'a>],
+) -> MockAuthInvoke<'a> {
+ MockAuthInvoke { contract, fn_name, args, sub_invokes }
+}
+
+/// Runs `deposit(alice, bob, amount)` with exactly the sender's authorization
+/// and, when given, an approver authorization over `approver_args`.
+fn deposit_with(f: &Flow, amount: i128, approver_args: Option>) {
+ let e = &f.h.e;
+ let sac_transfer = [invoke(
+ &f.h.sac_addr,
+ "transfer",
+ (f.alice.clone(), f.h.host.clone(), amount).into_val(e),
+ &[],
+ )];
+ let sender = invoke(
+ &f.h.host,
+ "deposit",
+ (f.alice.clone(), f.bob.clone(), amount).into_val(e),
+ &sac_transfer,
+ );
+ let token = ConfidentialTokenClient::new(e, &f.h.host);
+ match approver_args {
+ None => {
+ token
+ .mock_auths(&[MockAuth { address: &f.alice, invoke: &sender }])
+ .deposit(&f.alice, &f.bob, &amount);
+ }
+ Some(args) => {
+ let approval = invoke(&f.h.host, "deposit", args, &[]);
+ token
+ .mock_auths(&[
+ MockAuth { address: &f.alice, invoke: &sender },
+ MockAuth { address: &f.approver, invoke: &approval },
+ ])
+ .deposit(&f.alice, &f.bob, &amount);
+ }
+ }
+}
+
+fn current_deposit_args(f: &Flow, amount: i128) -> Vec {
+ f.h.e.as_contract(&f.h.host, || deposit_approval_args(&f.h.e, &f.alice, &f.bob, amount))
+}
+
+#[test]
+fn deposit_with_exact_approval_succeeds() {
+ let f = approval_flow();
+ deposit_with(&f, 100, Some(current_deposit_args(&f, 100)));
+
+ let auths = f.h.e.auths();
+ assert_eq!(auths.len(), 2);
+ assert!(auths.iter().any(|(addr, _)| addr == &f.approver));
+ f.h.e.as_contract(&f.h.host, || {
+ assert_eq!(get_account(&f.h.e, &f.bob).receiving_commitment, g_times(&f.h.e, 100));
+ });
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn deposit_without_approval_fails() {
+ let f = approval_flow();
+ deposit_with(&f, 100, None);
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn deposit_approval_for_other_amount_fails() {
+ let f = approval_flow();
+ deposit_with(&f, 100, Some(current_deposit_args(&f, 99)));
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn deposit_approval_for_other_recipient_fails() {
+ let f = approval_flow();
+ let carol = account(&f.h, AUDITOR);
+ let args =
+ f.h.e.as_contract(&f.h.host, || deposit_approval_args(&f.h.e, &f.alice, &carol, 100));
+ deposit_with(&f, 100, Some(args));
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn approval_does_not_survive_epoch_bump() {
+ let f = approval_flow();
+ let stale = current_deposit_args(&f, 100);
+ ConfidentialRwaClient::new(&f.h.e, &f.h.host).bump_approval_epoch(&f.h.admin);
+ deposit_with(&f, 100, Some(stale));
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn approval_does_not_survive_disable_and_reenable() {
+ let f = approval_flow();
+ let stale = current_deposit_args(&f, 100);
+ let client = ConfidentialRwaClient::new(&f.h.e, &f.h.host);
+ client.set_rwa_config(&AUDITOR, &None, &f.h.admin);
+ client.set_rwa_config(&AUDITOR, &Some(f.approver.clone()), &f.h.admin);
+ deposit_with(&f, 100, Some(stale));
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn approval_does_not_survive_policy_change() {
+ let f = approval_flow();
+ let stale = current_deposit_args(&f, 100);
+ let config = ComplianceConfig { policy: Some(f.h.policy.clone()), sac_passthrough: true };
+ ConfidentialComplianceClient::new(&f.h.e, &f.h.host).set_compliance_config(&config, &f.h.admin);
+ deposit_with(&f, 100, Some(stale));
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn withdraw_approval_under_another_function_name_fails() {
+ // The exact `withdraw` tuple, authorized for `deposit`: only the
+ // function name differs from the passing case
+ // (`withdraw_approval_tuple_is_epoch_from_to_amount_payload`).
+ let f = approval_flow();
+ let e = &f.h.e;
+ let data = withdraw_data(e);
+ let sender = invoke(
+ &f.h.host,
+ "withdraw",
+ (f.alice.clone(), f.alice.clone(), 100i128, data.clone()).into_val(e),
+ &[],
+ );
+ let args: Vec = (
+ ConfidentialRwaClient::new(e, &f.h.host).approval_epoch(),
+ f.alice.clone(),
+ f.alice.clone(),
+ 100i128,
+ withdraw_payload(e),
+ )
+ .into_val(e);
+ let approval = invoke(&f.h.host, "deposit", args, &[]);
+ ConfidentialTokenClient::new(e, &f.h.host)
+ .mock_auths(&[
+ MockAuth { address: &f.alice, invoke: &sender },
+ MockAuth { address: &f.approver, invoke: &approval },
+ ])
+ .withdraw(&f.alice, &f.alice, &100, &data);
+}
+
+#[test]
+fn withdraw_with_exact_approval_succeeds() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let data = withdraw_data(e);
+ let sender = invoke(
+ &f.h.host,
+ "withdraw",
+ (f.alice.clone(), f.alice.clone(), 100i128, data.clone()).into_val(e),
+ &[],
+ );
+ let args = e.as_contract(&f.h.host, || {
+ withdraw_approval_args(e, &f.alice, &f.alice, 100, &withdraw_payload(e))
+ });
+ let approval = invoke(&f.h.host, "withdraw", args, &[]);
+ ConfidentialTokenClient::new(e, &f.h.host)
+ .mock_auths(&[
+ MockAuth { address: &f.alice, invoke: &sender },
+ MockAuth { address: &f.approver, invoke: &approval },
+ ])
+ .withdraw(&f.alice, &f.alice, &100, &data);
+
+ assert_eq!(StellarAssetClient::new(e, &f.h.sac_addr).balance(&f.alice), 1_100);
+}
+
+fn transfer_with(f: &Flow, approver_args: Option>) {
+ let e = &f.h.e;
+ let data = transfer_data(e);
+ let sender = invoke(
+ &f.h.host,
+ "confidential_transfer",
+ (f.alice.clone(), f.bob.clone(), data.clone()).into_val(e),
+ &[],
+ );
+ let token = ConfidentialTokenClient::new(e, &f.h.host);
+ match approver_args {
+ None => {
+ token
+ .mock_auths(&[MockAuth { address: &f.alice, invoke: &sender }])
+ .confidential_transfer(&f.alice, &f.bob, &data);
+ }
+ Some(args) => {
+ let approval = invoke(&f.h.host, "confidential_transfer", args, &[]);
+ token
+ .mock_auths(&[
+ MockAuth { address: &f.alice, invoke: &sender },
+ MockAuth { address: &f.approver, invoke: &approval },
+ ])
+ .confidential_transfer(&f.alice, &f.bob, &data);
+ }
+ }
+}
+
+#[test]
+fn transfer_with_exact_approval_succeeds() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let args = e.as_contract(&f.h.host, || {
+ transfer_approval_args(e, &f.alice, &f.bob, &transfer_payload(e))
+ });
+ transfer_with(&f, Some(args));
+
+ e.as_contract(&f.h.host, || {
+ assert_eq!(get_account(e, &f.bob).receiving_commitment, point(e));
+ });
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn transfer_without_approval_fails() {
+ let f = approval_flow();
+ transfer_with(&f, None);
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn transfer_approval_for_other_payload_fails() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let mut other = transfer_payload(e);
+ other.c_transfer = g_times(e, 2);
+ let args = e.as_contract(&f.h.host, || transfer_approval_args(e, &f.alice, &f.bob, &other));
+ transfer_with(&f, Some(args));
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn transfer_approval_for_other_sender_side_payload_fails() {
+ // The approval binds the whole payload, not only `c_transfer`.
+ let f = approval_flow();
+ let e = &f.h.e;
+ let mut other = transfer_payload(e);
+ other.c_spend_new = g_times(e, 2);
+ let args = e.as_contract(&f.h.host, || transfer_approval_args(e, &f.alice, &f.bob, &other));
+ transfer_with(&f, Some(args));
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn spender_transfer_approval_under_another_function_name_fails() {
+ // The exact `confidential_transfer_from` tuple, authorized for
+ // `confidential_transfer`: only the function name differs from the
+ // passing case
+ // (`spender_transfer_approval_tuple_is_epoch_spender_from_to_payload`).
+ let f = approval_flow();
+ let e = &f.h.e;
+ let spender = account(&f.h, AUDITOR);
+ store_delegation(&f.h, &f.alice, &spender, 500);
+ let data = spender_transfer_data(e);
+ let sender = invoke(
+ &f.h.host,
+ "confidential_transfer_from",
+ (spender.clone(), f.alice.clone(), f.bob.clone(), data.clone()).into_val(e),
+ &[],
+ );
+ let args: Vec = (
+ ConfidentialRwaClient::new(e, &f.h.host).approval_epoch(),
+ spender.clone(),
+ f.alice.clone(),
+ f.bob.clone(),
+ spender_transfer_payload(e),
+ )
+ .into_val(e);
+ let approval = invoke(&f.h.host, "confidential_transfer", args, &[]);
+ ConfidentialTokenClient::new(e, &f.h.host)
+ .mock_auths(&[
+ MockAuth { address: &spender, invoke: &sender },
+ MockAuth { address: &f.approver, invoke: &approval },
+ ])
+ .confidential_transfer_from(&spender, &f.alice, &f.bob, &data);
+}
+
+#[test]
+fn spender_transfer_with_exact_approval_succeeds() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let spender = account(&f.h, AUDITOR);
+ store_delegation(&f.h, &f.alice, &spender, 500);
+ let data = spender_transfer_data(e);
+ let sender = invoke(
+ &f.h.host,
+ "confidential_transfer_from",
+ (spender.clone(), f.alice.clone(), f.bob.clone(), data.clone()).into_val(e),
+ &[],
+ );
+ let args = e.as_contract(&f.h.host, || {
+ spender_transfer_approval_args(e, &spender, &f.alice, &f.bob, &spender_transfer_payload(e))
+ });
+ let approval = invoke(&f.h.host, "confidential_transfer_from", args, &[]);
+ ConfidentialTokenClient::new(e, &f.h.host)
+ .mock_auths(&[
+ MockAuth { address: &spender, invoke: &sender },
+ MockAuth { address: &f.approver, invoke: &approval },
+ ])
+ .confidential_transfer_from(&spender, &f.alice, &f.bob, &data);
+
+ e.as_contract(&f.h.host, || {
+ assert_eq!(get_account(e, &f.bob).receiving_commitment, point(e));
+ });
+}
+
+/// The current approval epoch, read through the public getter.
+fn epoch(f: &Flow) -> u32 {
+ ConfidentialRwaClient::new(&f.h.e, &f.h.host).approval_epoch()
+}
+
+/// Runs `withdraw(alice, alice, 100, data)` with the sender's authorization
+/// and an approver authorization over `approver_args`.
+fn withdraw_with(f: &Flow, approver_args: Vec) {
+ let e = &f.h.e;
+ let data = withdraw_data(e);
+ let sender = invoke(
+ &f.h.host,
+ "withdraw",
+ (f.alice.clone(), f.alice.clone(), 100i128, data.clone()).into_val(e),
+ &[],
+ );
+ let approval = invoke(&f.h.host, "withdraw", approver_args, &[]);
+ ConfidentialTokenClient::new(e, &f.h.host)
+ .mock_auths(&[
+ MockAuth { address: &f.alice, invoke: &sender },
+ MockAuth { address: &f.approver, invoke: &approval },
+ ])
+ .withdraw(&f.alice, &f.alice, &100, &data);
+}
+
+/// Runs `confidential_transfer_from(spender, alice, bob, data)` with the
+/// spender's authorization and an approver authorization over
+/// `approver_args`.
+fn spender_transfer_with(f: &Flow, spender: &Address, approver_args: Vec) {
+ let e = &f.h.e;
+ let data = spender_transfer_data(e);
+ let sender = invoke(
+ &f.h.host,
+ "confidential_transfer_from",
+ (spender.clone(), f.alice.clone(), f.bob.clone(), data.clone()).into_val(e),
+ &[],
+ );
+ let approval = invoke(&f.h.host, "confidential_transfer_from", approver_args, &[]);
+ ConfidentialTokenClient::new(e, &f.h.host)
+ .mock_auths(&[
+ MockAuth { address: spender, invoke: &sender },
+ MockAuth { address: &f.approver, invoke: &approval },
+ ])
+ .confidential_transfer_from(spender, &f.alice, &f.bob, &data);
+}
+
+fn delegated_flow<'a>() -> (Flow<'a>, Address) {
+ let f = approval_flow();
+ let spender = account(&f.h, AUDITOR);
+ store_delegation(&f.h, &f.alice, &spender, 500);
+ (f, spender)
+}
+
+// The four tests below build the approval tuple by hand, so the wire shape
+// of each tuple is pinned independently of the library's own helpers.
+
+#[test]
+fn deposit_approval_tuple_is_epoch_from_to_amount() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let args: Vec = (epoch(&f), f.alice.clone(), f.bob.clone(), 100i128).into_val(e);
+ deposit_with(&f, 100, Some(args));
+}
+
+#[test]
+fn withdraw_approval_tuple_is_epoch_from_to_amount_payload() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let args: Vec =
+ (epoch(&f), f.alice.clone(), f.alice.clone(), 100i128, withdraw_payload(e)).into_val(e);
+ withdraw_with(&f, args);
+}
+
+#[test]
+fn transfer_approval_tuple_is_epoch_from_to_payload() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let args: Vec =
+ (epoch(&f), f.alice.clone(), f.bob.clone(), transfer_payload(e)).into_val(e);
+ transfer_with(&f, Some(args));
+}
+
+#[test]
+fn spender_transfer_approval_tuple_is_epoch_spender_from_to_payload() {
+ let (f, spender) = delegated_flow();
+ let e = &f.h.e;
+ let args: Vec =
+ (epoch(&f), spender.clone(), f.alice.clone(), f.bob.clone(), spender_transfer_payload(e))
+ .into_val(e);
+ spender_transfer_with(&f, &spender, args);
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn withdraw_approval_for_other_amount_fails() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let args: Vec =
+ (epoch(&f), f.alice.clone(), f.alice.clone(), 99i128, withdraw_payload(e)).into_val(e);
+ withdraw_with(&f, args);
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn withdraw_approval_for_other_recipient_fails() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let args: Vec =
+ (epoch(&f), f.alice.clone(), f.bob.clone(), 100i128, withdraw_payload(e)).into_val(e);
+ withdraw_with(&f, args);
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn withdraw_approval_for_other_payload_fails() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let mut other = withdraw_payload(e);
+ other.c_spend_new = g_times(e, 2);
+ let args: Vec = (epoch(&f), f.alice.clone(), f.alice.clone(), 100i128, other).into_val(e);
+ withdraw_with(&f, args);
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn withdraw_approval_without_payload_fails() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let args: Vec = (epoch(&f), f.alice.clone(), f.alice.clone(), 100i128).into_val(e);
+ withdraw_with(&f, args);
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn withdraw_approval_for_stale_epoch_fails() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let args: Vec =
+ (epoch(&f) - 1, f.alice.clone(), f.alice.clone(), 100i128, withdraw_payload(e)).into_val(e);
+ withdraw_with(&f, args);
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn spender_transfer_approval_for_other_spender_fails() {
+ let (f, spender) = delegated_flow();
+ let e = &f.h.e;
+ let other = account(&f.h, AUDITOR);
+ let args: Vec =
+ (epoch(&f), other, f.alice.clone(), f.bob.clone(), spender_transfer_payload(e)).into_val(e);
+ spender_transfer_with(&f, &spender, args);
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn spender_transfer_approval_for_other_owner_fails() {
+ let (f, spender) = delegated_flow();
+ let e = &f.h.e;
+ let other = account(&f.h, AUDITOR);
+ let args: Vec =
+ (epoch(&f), spender.clone(), other, f.bob.clone(), spender_transfer_payload(e)).into_val(e);
+ spender_transfer_with(&f, &spender, args);
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn spender_transfer_approval_for_other_recipient_fails() {
+ let (f, spender) = delegated_flow();
+ let e = &f.h.e;
+ let other = account(&f.h, AUDITOR);
+ let args: Vec =
+ (epoch(&f), spender.clone(), f.alice.clone(), other, spender_transfer_payload(e))
+ .into_val(e);
+ spender_transfer_with(&f, &spender, args);
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn spender_transfer_approval_for_other_payload_fails() {
+ let (f, spender) = delegated_flow();
+ let e = &f.h.e;
+ let mut other = spender_transfer_payload(e);
+ other.c_transfer = g_times(e, 2);
+ let args: Vec =
+ (epoch(&f), spender.clone(), f.alice.clone(), f.bob.clone(), other).into_val(e);
+ spender_transfer_with(&f, &spender, args);
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn spender_transfer_approval_for_stale_epoch_fails() {
+ let (f, spender) = delegated_flow();
+ let e = &f.h.e;
+ let args: Vec = (
+ epoch(&f) - 1,
+ spender.clone(),
+ f.alice.clone(),
+ f.bob.clone(),
+ spender_transfer_payload(e),
+ )
+ .into_val(e);
+ spender_transfer_with(&f, &spender, args);
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn transfer_approval_for_other_sender_fails() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let other = account(&f.h, AUDITOR);
+ let args: Vec = (epoch(&f), other, f.bob.clone(), transfer_payload(e)).into_val(e);
+ transfer_with(&f, Some(args));
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn transfer_approval_for_other_recipient_fails() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let other = account(&f.h, AUDITOR);
+ let args: Vec = (epoch(&f), f.alice.clone(), other, transfer_payload(e)).into_val(e);
+ transfer_with(&f, Some(args));
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn deposit_approval_for_other_depositor_fails() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let other = Address::generate(e);
+ let args: Vec = (epoch(&f), other, f.bob.clone(), 100i128).into_val(e);
+ deposit_with(&f, 100, Some(args));
+}
+
+#[test]
+fn set_spender_merge_and_revoke_need_no_approval() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let spender = account(&f.h, AUDITOR);
+ let token = ConfidentialTokenClient::new(e, &f.h.host);
+ let data = set_spender_data(e);
+
+ let set = invoke(
+ &f.h.host,
+ "set_spender",
+ (f.alice.clone(), spender.clone(), 1_000u32, data.clone()).into_val(e),
+ &[],
+ );
+ token
+ .mock_auths(&[MockAuth { address: &f.alice, invoke: &set }])
+ .set_spender(&f.alice, &spender, &1_000, &data);
+
+ let merge = invoke(&f.h.host, "merge", (f.alice.clone(),).into_val(e), &[]);
+ token.mock_auths(&[MockAuth { address: &f.alice, invoke: &merge }]).merge(&f.alice);
+
+ let revoke =
+ invoke(&f.h.host, "revoke_spender", (f.alice.clone(), spender.clone()).into_val(e), &[]);
+ token
+ .mock_auths(&[MockAuth { address: &f.alice, invoke: &revoke }])
+ .revoke_spender(&f.alice, &spender);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3612)")]
+fn approver_cannot_be_the_sender() {
+ let h = setup();
+ let alice = Address::generate(&h.e);
+ configure(&h, Some(alice.clone()));
+ store_account(&h, &alice, AUDITOR, 1_000, 0);
+ let bob = account(&h, AUDITOR);
+ run_hook(&h, 4, &alice, &bob);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3612)")]
+fn approver_cannot_be_the_depositor() {
+ let h = setup();
+ let alice = Address::generate(&h.e);
+ configure(&h, Some(alice.clone()));
+ let bob = account(&h, AUDITOR);
+ run_hook(&h, 1, &alice, &bob);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3612)")]
+fn approver_cannot_be_the_withdrawer() {
+ let h = setup();
+ let alice = Address::generate(&h.e);
+ configure(&h, Some(alice.clone()));
+ store_account(&h, &alice, AUDITOR, 1_000, 0);
+ // A distinct recipient, so the rule is triggered by `from` alone.
+ let other = Address::generate(&h.e);
+ run_hook(&h, 3, &alice, &other);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3612)")]
+fn approver_cannot_be_the_spender() {
+ let h = setup();
+ let spender = Address::generate(&h.e);
+ configure(&h, Some(spender.clone()));
+ store_account(&h, &spender, AUDITOR, 0, 0);
+ let owner = account(&h, AUDITOR);
+ let to = account(&h, AUDITOR);
+ h.e.as_contract(&h.host, || {
+ RwaHooks::on_spender_transfer(&h.e, &spender, &owner, &to, &spender_transfer_payload(&h.e));
+ });
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3612)")]
+fn approver_cannot_be_the_recipient() {
+ let h = setup();
+ let approver = Address::generate(&h.e);
+ configure(&h, Some(approver.clone()));
+ store_account(&h, &approver, AUDITOR, 0, 0);
+ let alice = account(&h, AUDITOR);
+ run_hook(&h, 4, &alice, &approver);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3612)")]
+fn approver_cannot_be_the_deposit_recipient() {
+ let h = setup();
+ let approver = Address::generate(&h.e);
+ configure(&h, Some(approver.clone()));
+ store_account(&h, &approver, AUDITOR, 0, 0);
+ let depositor = Address::generate(&h.e);
+ run_hook(&h, 1, &depositor, &approver);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3612)")]
+fn approver_cannot_be_the_withdraw_recipient() {
+ let h = setup();
+ let approver = Address::generate(&h.e);
+ configure(&h, Some(approver.clone()));
+ let alice = account(&h, AUDITOR);
+ run_hook(&h, 3, &alice, &approver);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3612)")]
+fn approver_cannot_be_the_delegating_owner() {
+ let h = setup();
+ let approver = Address::generate(&h.e);
+ configure(&h, Some(approver.clone()));
+ store_account(&h, &approver, AUDITOR, 0, 0);
+ let to = account(&h, AUDITOR);
+ let spender = account(&h, AUDITOR);
+ h.e.as_contract(&h.host, || {
+ RwaHooks::on_spender_transfer(
+ &h.e,
+ &spender,
+ &approver,
+ &to,
+ &spender_transfer_payload(&h.e),
+ );
+ });
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3612)")]
+fn approver_cannot_be_the_spender_transfer_recipient() {
+ let h = setup();
+ let approver = Address::generate(&h.e);
+ configure(&h, Some(approver.clone()));
+ store_account(&h, &approver, AUDITOR, 0, 0);
+ let owner = account(&h, AUDITOR);
+ let spender = account(&h, AUDITOR);
+ h.e.as_contract(&h.host, || {
+ RwaHooks::on_spender_transfer(
+ &h.e,
+ &spender,
+ &owner,
+ &approver,
+ &spender_transfer_payload(&h.e),
+ );
+ });
+}
+
+#[test]
+fn approver_may_take_part_in_operations_outside_the_gate() {
+ // `set_spender`, `merge` and `revoke_spender` do not consult the approver.
+ let h = setup();
+ let approver = Address::generate(&h.e);
+ configure(&h, Some(approver.clone()));
+ store_account(&h, &approver, AUDITOR, 0, 0);
+ let other = account(&h, AUDITOR);
+ run_hook(&h, 6, &approver, &other);
+ run_hook(&h, 2, &approver, &other);
+ run_hook(&h, 7, &approver, &other);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3617)")]
+fn forced_transfer_nonce_overflow_panics() {
+ let f = forced_flow(None);
+ let e = &f.h.e;
+ e.as_contract(&f.h.host, || {
+ e.storage()
+ .persistent()
+ .set(&ComplianceStorageKey::ClawbackNonce(f.from.clone()), &u32::MAX);
+ });
+ force(&f, 40);
+}
+
+// ################## FORCED TRANSFER ##################
+
+/// A frozen source holding `spendable` + `receiving` and a registered
+/// destination, on a configured deployment with a funded pool.
+struct Forced<'a> {
+ h: Harness<'a>,
+ from: Address,
+ to: Address,
+}
+
+fn forced_flow<'a>(approver: Option) -> Forced<'a> {
+ forced_flow_with(approver, |e| e.register(RecordingVerifier, ()))
+}
+
+fn forced_flow_with<'a>(
+ approver: Option,
+ register_verifier: impl FnOnce(&Env) -> Address,
+) -> Forced<'a> {
+ let h = setup_with(register_verifier);
+ configure(&h, approver);
+ let from = Address::generate(&h.e);
+ store_account(&h, &from, AUDITOR, 70, 30);
+ freeze_account(&h, &from);
+ let to = Address::generate(&h.e);
+ store_account(&h, &to, AUDITOR, 5, 7);
+ h.sac.mint(&h.host, &1_000);
+ Forced { h, from, to }
+}
+
+fn force(f: &Forced, amount: i128) {
+ ConfidentialForcedTransferClient::new(&f.h.e, &f.h.host).forced_transfer(
+ &f.from,
+ &f.to,
+ &amount,
+ &proof_data(&f.h.e),
+ &f.h.admin,
+ );
+}
+
+#[test]
+fn forced_transfer_moves_amount_and_conserves_value() {
+ let f = forced_flow(None);
+ let e = &f.h.e;
+ force(&f, 40);
+
+ let events = e.events().all();
+ assert_eq!(events.events().len(), 1);
+ assert_eq!(
+ events.events().first().unwrap(),
+ &ForcedTransfer { from: f.from.clone(), to: f.to.clone(), amount: 40, nonce: 0 }
+ .to_xdr(e, &f.h.host),
+ );
+
+ e.as_contract(&f.h.host, || {
+ let src = get_account(e, &f.from);
+ // C_spend <- 70G + 30G - 40G, C_receive <- O.
+ assert_eq!(src.spendable_commitment, g_times(e, 60));
+ assert_eq!(src.receiving_commitment, Grumpkin::identity(e));
+ let dst = get_account(e, &f.to);
+ // Spendable untouched; the credit lands in the receiving balance.
+ assert_eq!(dst.spendable_commitment, g_times(e, 5));
+ assert_eq!(dst.receiving_commitment, g_times(e, 47));
+ });
+ // No underlying moved.
+ assert_eq!(StellarAssetClient::new(e, &f.h.sac_addr).balance(&f.h.host), 1_000);
+ assert_eq!(StellarAssetClient::new(e, &f.h.sac_addr).balance(&f.to), 0);
+}
+
+#[test]
+fn forced_transfer_can_move_the_full_balance() {
+ let f = forced_flow(None);
+ let e = &f.h.e;
+ force(&f, 100);
+ e.as_contract(&f.h.host, || {
+ let src = get_account(e, &f.from);
+ assert_eq!(src.spendable_commitment, Grumpkin::identity(e));
+ assert_eq!(src.receiving_commitment, Grumpkin::identity(e));
+ assert_eq!(get_account(e, &f.to).receiving_commitment, g_times(e, 107));
+ });
+}
+
+#[test]
+fn forced_transfer_needs_no_approval() {
+ let f = forced_flow(None);
+ let e = &f.h.e;
+ let approver = Address::generate(e);
+ ConfidentialRwaClient::new(e, &f.h.host).set_rwa_config(&AUDITOR, &Some(approver), &f.h.admin);
+ let call = invoke(
+ &f.h.host,
+ "forced_transfer",
+ (f.from.clone(), f.to.clone(), 40i128, proof_data(e), f.h.admin.clone()).into_val(e),
+ &[],
+ );
+ // Only the operator authorizes; the approver does not.
+ ConfidentialForcedTransferClient::new(e, &f.h.host)
+ .mock_auths(&[MockAuth { address: &f.h.admin, invoke: &call }])
+ .forced_transfer(&f.from, &f.to, &40, &proof_data(e), &f.h.admin);
+ e.as_contract(&f.h.host, || {
+ assert_eq!(get_account(e, &f.to).receiving_commitment, g_times(e, 47));
+ });
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn forced_transfer_requires_the_operator() {
+ let f = forced_flow(None);
+ let e = &f.h.e;
+ ConfidentialForcedTransferClient::new(e, &f.h.host).mock_auths(&[]).forced_transfer(
+ &f.from,
+ &f.to,
+ &40,
+ &proof_data(e),
+ &f.h.admin,
+ );
+}
+
+#[test]
+fn forced_transfer_shares_and_advances_the_clawback_nonce() {
+ let f = forced_flow(None);
+ let e = &f.h.e;
+ let clawback_client = ConfidentialClawbackClient::new(e, &f.h.host);
+ assert_eq!(clawback_client.clawback_nonce(&f.from), 0);
+
+ force(&f, 10);
+ assert_eq!(clawback_client.clawback_nonce(&f.from), 1);
+
+ clawback_client.clawback(&f.from, &10, &None, &proof_data(e), &f.h.admin);
+ assert_eq!(clawback_client.clawback_nonce(&f.from), 2);
+
+ force(&f, 10);
+ let events = e.events().all();
+ assert_eq!(clawback_client.clawback_nonce(&f.from), 3);
+ assert_eq!(
+ events.events().first().unwrap(),
+ &ForcedTransfer { from: f.from.clone(), to: f.to.clone(), amount: 10, nonce: 2 }
+ .to_xdr(e, &f.h.host),
+ );
+}
+
+/// Splits a public-input blob into its eleven 32-byte fields.
+fn fields(e: &Env, pi: &Bytes) -> std::vec::Vec> {
+ assert_eq!(pi.len(), 11 * 32);
+ (0..11u32)
+ .map(|i| {
+ let mut buf = [0u8; 32];
+ pi.slice(i * 32..(i + 1) * 32).copy_into_slice(&mut buf);
+ BytesN::from_array(e, &buf)
+ })
+ .collect()
+}
+
+fn last_public_inputs(h: &Harness) -> std::vec::Vec> {
+ let pi = RecordingVerifierClient::new(&h.e, &h.verifier).last_public_inputs();
+ fields(&h.e, &pi)
+}
+
+/// The 64 bytes of a point as its two 32-byte coordinates.
+fn coordinates(e: &Env, p: &BytesN<64>) -> (BytesN<32>, BytesN<32>) {
+ let bytes = p.to_array();
+ let mut x = [0u8; 32];
+ let mut y = [0u8; 32];
+ x.copy_from_slice(&bytes[..32]);
+ y.copy_from_slice(&bytes[32..]);
+ (BytesN::from_array(e, &x), BytesN::from_array(e, &y))
+}
+
+#[test]
+fn forced_transfer_public_inputs_differ_from_clawback_only_in_destination() {
+ // One deployment, one source account, one destination, one nonce: the
+ // forced transfer is executed, the source is put back to the same
+ // commitments and nonce, and the same statement is executed as a
+ // clawback. Token (field 7) and source account (field 8) bindings are
+ // therefore identical and are compared too.
+ let f = forced_flow(None);
+ let e = &f.h.e;
+ let verifier = RecordingVerifierClient::new(e, &f.h.verifier);
+
+ force(&f, 40);
+ let forced_pi = last_public_inputs(&f.h);
+ assert_eq!(verifier.last_circuit(), CircuitType::Clawback);
+
+ store_account(&f.h, &f.from, AUDITOR, 70, 30);
+ e.as_contract(&f.h.host, || {
+ e.storage().persistent().remove(&ComplianceStorageKey::ClawbackNonce(f.from.clone()));
+ });
+ ConfidentialClawbackClient::new(e, &f.h.host).clawback(
+ &f.from,
+ &40,
+ &Some(f.to.clone()),
+ &proof_data(e),
+ &f.h.admin,
+ );
+ let clawback_pi = last_public_inputs(&f.h);
+ assert_eq!(verifier.last_circuit(), CircuitType::Clawback);
+
+ for i in (0..11usize).filter(|i| *i != 9) {
+ assert_eq!(forced_pi[i].to_array(), clawback_pi[i].to_array(), "field {i}");
+ }
+ e.as_contract(&f.h.host, || {
+ // Fields 0-3: the source's commitments as stored before the call.
+ let (sx, sy) = coordinates(e, &g_times(e, 70));
+ let (rx, ry) = coordinates(e, &g_times(e, 30));
+ assert_eq!([&forced_pi[0], &forced_pi[1]], [&sx, &sy]);
+ assert_eq!([&forced_pi[2], &forced_pi[3]], [&rx, &ry]);
+ // Fields 4-5: the designated auditor's key.
+ let (kx, ky) = coordinates(e, &Grumpkin::generator(e));
+ assert_eq!([&forced_pi[4], &forced_pi[5]], [&kx, &ky]);
+ // Field 6: the public amount. Field 7: this token. Field 8: the
+ // source account. Field 9: the destination binding.
+ assert_eq!(forced_pi[6], field(e, 40));
+ assert_eq!(forced_pi[7], address_to_field(e, &f.h.host));
+ assert_eq!(forced_pi[8], address_to_field(e, &f.from));
+ assert_eq!(forced_pi[9], forced_destination_to_field(e, &f.to));
+ assert_eq!(clawback_pi[9], address_to_field(e, &f.to));
+ assert_ne!(forced_pi[9], clawback_pi[9]);
+ assert_ne!(forced_pi[9], BytesN::from_array(e, &[0u8; 32]));
+ });
+}
+
+#[test]
+fn forced_destination_field_is_bound_to_the_destination() {
+ let h = setup();
+ let a = Address::generate(&h.e);
+ let b = Address::generate(&h.e);
+ h.e.as_contract(&h.host, || {
+ assert_eq!(forced_destination_to_field(&h.e, &a), forced_destination_to_field(&h.e, &a));
+ assert_ne!(forced_destination_to_field(&h.e, &a), forced_destination_to_field(&h.e, &b));
+ assert_ne!(forced_destination_to_field(&h.e, &a), address_to_field(&h.e, &a));
+ });
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3506)")]
+fn forced_transfer_proof_does_not_execute_as_clawback() {
+ // The verifier binds itself to the public inputs of the first proof it
+ // sees (the forced transfer). After the source is restored to the same
+ // commitments and nonce, the same statement in clawback mode differs in
+ // the destination field and is rejected.
+ let f = forced_flow_with(None, |e| e.register(BoundProofVerifier, ()));
+ let e = &f.h.e;
+ force(&f, 40);
+ store_account(&f.h, &f.from, AUDITOR, 70, 30);
+ e.as_contract(&f.h.host, || {
+ e.storage().persistent().remove(&ComplianceStorageKey::ClawbackNonce(f.from.clone()));
+ });
+ ConfidentialClawbackClient::new(e, &f.h.host).clawback(
+ &f.from,
+ &40,
+ &Some(f.to.clone()),
+ &proof_data(e),
+ &f.h.admin,
+ );
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3506)")]
+fn forced_transfer_proof_does_not_execute_twice() {
+ // Restoring the source's commitments leaves the nonce advanced, so the
+ // first proof's statement no longer matches.
+ let f = forced_flow_with(None, |e| e.register(BoundProofVerifier, ()));
+ force(&f, 40);
+ store_account(&f.h, &f.from, AUDITOR, 70, 30);
+ force(&f, 40);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3506)")]
+fn forced_transfer_proof_is_bound_to_the_destination() {
+ let f = forced_flow_with(None, |e| e.register(BoundProofVerifier, ()));
+ let e = &f.h.e;
+ force(&f, 40);
+ store_account(&f.h, &f.from, AUDITOR, 70, 30);
+ e.as_contract(&f.h.host, || {
+ e.storage().persistent().remove(&ComplianceStorageKey::ClawbackNonce(f.from.clone()));
+ });
+ let other = account(&f.h, AUDITOR);
+ ConfidentialForcedTransferClient::new(e, &f.h.host).forced_transfer(
+ &f.from,
+ &other,
+ &40,
+ &proof_data(e),
+ &f.h.admin,
+ );
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3607)")]
+fn forced_transfer_requires_rwa_config() {
+ let h = setup();
+ h.e.as_contract(&h.host, || set_compliance_config(&h.e, &policy_config(&h)));
+ let from = account(&h, AUDITOR);
+ freeze_account(&h, &from);
+ let to = account(&h, AUDITOR);
+ h.e.as_contract(&h.host, || forced_transfer(&h.e, &from, &to, 1, &Bytes::new(&h.e)));
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3604)")]
+fn forced_transfer_requires_frozen_source() {
+ let f = forced_flow(None);
+ f.h.e.as_contract(&f.h.host, || unfreeze(&f.h.e, &f.from));
+ force(&f, 40);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3613)")]
+fn forced_transfer_zero_amount_panics() {
+ let f = forced_flow(None);
+ force(&f, 0);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3613)")]
+fn forced_transfer_negative_amount_panics() {
+ let f = forced_flow(None);
+ force(&f, -1);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3614)")]
+fn forced_transfer_to_self_panics() {
+ let f = forced_flow(None);
+ let e = &f.h.e;
+ ConfidentialForcedTransferClient::new(e, &f.h.host).forced_transfer(
+ &f.from,
+ &f.from,
+ &40,
+ &proof_data(e),
+ &f.h.admin,
+ );
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3614)")]
+fn forced_transfer_to_the_token_panics() {
+ let f = forced_flow(None);
+ let e = &f.h.e;
+ ConfidentialForcedTransferClient::new(e, &f.h.host).forced_transfer(
+ &f.from,
+ &f.h.host,
+ &40,
+ &proof_data(e),
+ &f.h.admin,
+ );
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3501)")]
+fn forced_transfer_to_unregistered_account_panics() {
+ let f = forced_flow(None);
+ let e = &f.h.e;
+ let nobody = Address::generate(e);
+ ConfidentialForcedTransferClient::new(e, &f.h.host).forced_transfer(
+ &f.from,
+ &nobody,
+ &40,
+ &proof_data(e),
+ &f.h.admin,
+ );
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3601)")]
+fn forced_transfer_to_frozen_account_panics() {
+ let f = forced_flow(None);
+ freeze_account(&f.h, &f.to);
+ force(&f, 40);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3602)")]
+fn forced_transfer_to_policy_denied_account_panics() {
+ let f = forced_flow(None);
+ ListPolicyClient::new(&f.h.e, &f.h.policy).block(&f.to);
+ force(&f, 40);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract, #3609)")]
+fn forced_transfer_to_legacy_account_panics() {
+ let f = forced_flow(None);
+ let e = &f.h.e;
+ let legacy = account(&f.h, LEGACY_AUDITOR);
+ ConfidentialForcedTransferClient::new(e, &f.h.host).forced_transfer(
+ &f.from,
+ &legacy,
+ &40,
+ &proof_data(e),
+ &f.h.admin,
+ );
+}
+
+#[test]
+fn forced_transfer_from_legacy_source_uses_its_own_auditor_key() {
+ let h = setup();
+ configure(&h, None);
+ let from = Address::generate(&h.e);
+ store_account(&h, &from, LEGACY_AUDITOR, 50, 0);
+ freeze_account(&h, &from);
+ let to = account(&h, AUDITOR);
+ ConfidentialForcedTransferClient::new(&h.e, &h.host).forced_transfer(
+ &from,
+ &to,
+ &50,
+ &proof_data(&h.e),
+ &h.admin,
+ );
+
+ // The proof is verified against the key of the auditor the source is
+ // bound to, not the designated auditor's.
+ let pi = last_public_inputs(&h);
+ let (kx, ky) = coordinates(&h.e, &g_times(&h.e, 2));
+ assert_eq!([&pi[4], &pi[5]], [&kx, &ky]);
+ h.e.as_contract(&h.host, || {
+ assert_eq!(get_account(&h.e, &to).receiving_commitment, g_times(&h.e, 50));
+ });
+}
+
+#[test]
+fn rejected_forced_transfer_proof_changes_nothing() {
+ let f = forced_flow_with(None, |e| e.register(RejectingVerifier, ()));
+ let e = &f.h.e;
+ let res = ConfidentialForcedTransferClient::new(e, &f.h.host).try_forced_transfer(
+ &f.from,
+ &f.to,
+ &40,
+ &proof_data(e),
+ &f.h.admin,
+ );
+ assert!(res.is_err());
+
+ e.as_contract(&f.h.host, || {
+ let src = get_account(e, &f.from);
+ assert_eq!(src.spendable_commitment, g_times(e, 70));
+ assert_eq!(src.receiving_commitment, g_times(e, 30));
+ assert_eq!(get_account(e, &f.to).receiving_commitment, g_times(e, 7));
+ });
+ assert_eq!(ConfidentialClawbackClient::new(e, &f.h.host).clawback_nonce(&f.from), 0);
+}
+
+#[test]
+fn recovery_revokes_delegations_then_moves_the_full_balance() {
+ // Freeze, fold every delegation back, then force-transfer everything to
+ // a fresh account.
+ let f = forced_flow(None);
+ let e = &f.h.e;
+ let spender = account(&f.h, AUDITOR);
+ store_delegation(&f.h, &f.from, &spender, 25);
+
+ ConfidentialClawbackClient::new(e, &f.h.host)
+ .force_revoke_spender(&f.from, &spender, &f.h.admin);
+ let fresh = account(&f.h, AUDITOR);
+ ConfidentialForcedTransferClient::new(e, &f.h.host).forced_transfer(
+ &f.from,
+ &fresh,
+ &125,
+ &proof_data(e),
+ &f.h.admin,
+ );
+
+ e.as_contract(&f.h.host, || {
+ let src = get_account(e, &f.from);
+ assert_eq!(src.spendable_commitment, Grumpkin::identity(e));
+ assert_eq!(src.receiving_commitment, Grumpkin::identity(e));
+ assert_eq!(get_account(e, &fresh).receiving_commitment, g_times(e, 125));
+ });
+}
+
+#[test]
+fn rejected_hook_leaves_commitments_unchanged() {
+ let h = configured(None);
+ let alice = Address::generate(&h.e);
+ store_account(&h, &alice, AUDITOR, 1_000, 0);
+ let legacy = account(&h, LEGACY_AUDITOR);
+ let token = ConfidentialTokenClient::new(&h.e, &h.host);
+
+ let res = token.try_confidential_transfer(&alice, &legacy, &transfer_data(&h.e));
+ assert!(res.is_err());
+ h.e.as_contract(&h.host, || {
+ assert_eq!(get_account(&h.e, &alice).spendable_commitment, g_times(&h.e, 1_000));
+ assert_eq!(get_account(&h.e, &legacy).receiving_commitment, Grumpkin::identity(&h.e));
+ });
+}
+
+fn hex32(s: &str) -> [u8; 32] {
+ let mut out = [0u8; 32];
+ for (i, byte) in out.iter_mut().enumerate() {
+ *byte = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).unwrap();
+ }
+ out
+}
+
+#[test]
+fn forced_destination_to_field_matches_testdata_vectors() {
+ let h = setup();
+ let e = &h.e;
+
+ // (strkey, expected) from
+ // circuits/lib/testdata/forced_destination_to_field.json: the two
+ // addresses of the `address_to_field` vectors.
+ let vectors: [(&str, [u8; 32]); 2] = [
+ (
+ "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
+ hex32("2b07bfd7c614007acad5b1d37a3fdc64a915b44dd0dafc5d5cf0bd2d2ef3ab7a"),
+ ),
+ (
+ "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4",
+ hex32("2bbf4aadf66599f9f9a99357f94b9c3c2657aa890e277ed9fe366ac4c8f6b5be"),
+ ),
+ ];
+
+ for (strkey, expected) in vectors {
+ let addr = Address::from_str(e, strkey);
+ let got = e.as_contract(&h.host, || forced_destination_to_field(e, &addr));
+ assert_eq!(got, BytesN::from_array(e, &expected), "forced_destination_to_field({strkey})");
+ }
+}
+
+/// Runs `withdraw(alice, recipient, 100, data)` with the sender's
+/// authorization and an approver authorization over `approver_args`.
+fn withdraw_to_with(f: &Flow, recipient: &Address, approver_args: Vec) {
+ let e = &f.h.e;
+ let data = withdraw_data(e);
+ let sender = invoke(
+ &f.h.host,
+ "withdraw",
+ (f.alice.clone(), recipient.clone(), 100i128, data.clone()).into_val(e),
+ &[],
+ );
+ let approval = invoke(&f.h.host, "withdraw", approver_args, &[]);
+ ConfidentialTokenClient::new(e, &f.h.host)
+ .mock_auths(&[
+ MockAuth { address: &f.alice, invoke: &sender },
+ MockAuth { address: &f.approver, invoke: &approval },
+ ])
+ .withdraw(&f.alice, recipient, &100, &data);
+}
+
+#[test]
+fn withdraw_to_another_address_binds_from_then_to() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let carol = Address::generate(e);
+ let args: Vec =
+ (epoch(&f), f.alice.clone(), carol.clone(), 100i128, withdraw_payload(e)).into_val(e);
+ withdraw_to_with(&f, &carol, args);
+ assert_eq!(StellarAssetClient::new(e, &f.h.sac_addr).balance(&carol), 100);
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn withdraw_approval_with_from_and_to_swapped_fails() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let carol = Address::generate(e);
+ let args: Vec =
+ (epoch(&f), carol.clone(), f.alice.clone(), 100i128, withdraw_payload(e)).into_val(e);
+ withdraw_to_with(&f, &carol, args);
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn deposit_approval_with_from_and_to_swapped_fails() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let args: Vec = (epoch(&f), f.bob.clone(), f.alice.clone(), 100i128).into_val(e);
+ deposit_with(&f, 100, Some(args));
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn transfer_approval_with_from_and_to_swapped_fails() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let args: Vec =
+ (epoch(&f), f.bob.clone(), f.alice.clone(), transfer_payload(e)).into_val(e);
+ transfer_with(&f, Some(args));
+}
+
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn spender_transfer_approval_with_spender_and_owner_swapped_fails() {
+ let (f, spender) = delegated_flow();
+ let e = &f.h.e;
+ let args: Vec =
+ (epoch(&f), f.alice.clone(), spender.clone(), f.bob.clone(), spender_transfer_payload(e))
+ .into_val(e);
+ spender_transfer_with(&f, &spender, args);
+}
+
+// ################## BRANCH COVERAGE ##################
+//
+// Tests derived from a branching-tree pass over the RWA profile: one test
+// per branch the tests above leave uncovered.
+
+// ################## BRANCH COVERAGE HELPERS ##################
+
+fn err(code: ComplianceError) -> Result<(), Error> {
+ Err(Error::from_contract_error(code as u32))
+}
+
+fn token_err(code: ConfidentialTokenError) -> Result<(), Error> {
+ Err(Error::from_contract_error(code as u32))
+}
+
+/// Runs `f` inside the token's frame and returns the error it aborts with.
+/// Unlike `hook_panic`, the result is a typed `Error`, and a contract error
+/// is told apart from a host trap.
+fn try_in_host(h: &Harness, f: impl FnOnce()) -> Result<(), Error> {
+ h.e.try_as_contract::<(), Error>(&h.host, f).map_err(|r| r.unwrap())
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum Op {
+ Register,
+ Deposit,
+ Merge,
+ Withdraw,
+ Transfer,
+ SpenderTransfer,
+ SetSpender,
+ Revoke,
+}
+
+const OPS: [Op; 8] = [
+ Op::Register,
+ Op::Deposit,
+ Op::Merge,
+ Op::Withdraw,
+ Op::Transfer,
+ Op::SpenderTransfer,
+ Op::SetSpender,
+ Op::Revoke,
+];
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum Role {
+ From,
+ Spender,
+}
+
+/// Three distinct parties, so the spender never doubles as the recipient
+/// (as it does in `run_hook`).
+struct Parties {
+ from: Address,
+ to: Address,
+ spender: Address,
+}
+
+fn parties(h: &Harness) -> Parties {
+ Parties { from: account(h, AUDITOR), to: account(h, AUDITOR), spender: account(h, AUDITOR) }
+}
+
+impl Parties {
+ fn get(&self, role: Role) -> &Address {
+ match role {
+ Role::From => &self.from,
+ Role::Spender => &self.spender,
+ }
+ }
+}
+
+fn try_hook(h: &Harness, op: Op, p: &Parties) -> Result<(), Error> {
+ let e = &h.e;
+ try_in_host(h, || match op {
+ Op::Register => RwaHooks::on_register(e, &p.from, AUDITOR, ®ister_payload(e)),
+ Op::Deposit => RwaHooks::on_deposit(e, &p.from, &p.to, 1),
+ Op::Merge => RwaHooks::on_merge(e, &p.from),
+ Op::Withdraw => RwaHooks::on_withdraw(e, &p.from, &p.to, 1, &withdraw_payload(e)),
+ Op::Transfer => RwaHooks::on_transfer(e, &p.from, &p.to, &transfer_payload(e)),
+ Op::SpenderTransfer => RwaHooks::on_spender_transfer(
+ e,
+ &p.spender,
+ &p.from,
+ &p.to,
+ &spender_transfer_payload(e),
+ ),
+ Op::SetSpender => {
+ RwaHooks::on_set_spender(e, &p.from, &p.spender, 100, &set_spender_payload(e))
+ }
+ Op::Revoke => RwaHooks::on_revoke_spender(e, &p.from, &p.spender),
+ })
+}
+
+fn try_force(f: &Forced, from: &Address, to: &Address, amount: i128) -> Result<(), Error> {
+ ConfidentialForcedTransferClient::new(&f.h.e, &f.h.host)
+ .try_forced_transfer(from, to, &amount, &proof_data(&f.h.e), &f.h.admin)
+ .map(|r| r.unwrap())
+ .map_err(|r| r.unwrap())
+}
+
+// ################## P1: APPROVAL GATE ##################
+
+/// `withdraw(alice, alice, 100, data)` with the sender's entry and, when
+/// given, `approval` (entry point name, arguments) signed by `signer`.
+fn gate_withdraw(f: &Flow, signer: &Address, approval: Option<(&str, Vec)>) {
+ let e = &f.h.e;
+ let data = withdraw_data(e);
+ let sender = invoke(
+ &f.h.host,
+ "withdraw",
+ (f.alice.clone(), f.alice.clone(), 100i128, data.clone()).into_val(e),
+ &[],
+ );
+ let token = ConfidentialTokenClient::new(e, &f.h.host);
+ match approval {
+ None => token
+ .mock_auths(&[MockAuth { address: &f.alice, invoke: &sender }])
+ .withdraw(&f.alice, &f.alice, &100, &data),
+ Some((fn_name, args)) => {
+ let approval = invoke(&f.h.host, fn_name, args, &[]);
+ token
+ .mock_auths(&[
+ MockAuth { address: &f.alice, invoke: &sender },
+ MockAuth { address: signer, invoke: &approval },
+ ])
+ .withdraw(&f.alice, &f.alice, &100, &data)
+ }
+ }
+}
+
+fn exact_withdraw_args(f: &Flow) -> Vec {
+ let e = &f.h.e;
+ e.as_contract(&f.h.host, || {
+ withdraw_approval_args(e, &f.alice, &f.alice, 100, &withdraw_payload(e))
+ })
+}
+
+// Pins branch: on_withdraw -> require_approval / approver configured, no
+// approver entry / Error(Auth, InvalidAction)
+// Verifies: INV-14
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn withdraw_without_approval_fails() {
+ let f = approval_flow();
+ gate_withdraw(&f, &f.approver, None);
+}
+
+// Pins branch: on_withdraw -> require_approval / the exact tuple signed by
+// an address that is not the approver / Error(Auth, InvalidAction)
+// Verifies: INV-14
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn withdraw_approval_signed_by_another_address_fails() {
+ let f = approval_flow();
+ let outsider = Address::generate(&f.h.e);
+ gate_withdraw(&f, &outsider, Some(("withdraw", exact_withdraw_args(&f))));
+}
+
+/// `confidential_transfer_from(spender, alice, bob, data)` with the
+/// spender's entry and, when given, `approval` signed by `signer`.
+fn gate_spend(f: &Flow, spender: &Address, signer: &Address, approval: Option<(&str, Vec)>) {
+ let e = &f.h.e;
+ let data = spender_transfer_data(e);
+ let sender = invoke(
+ &f.h.host,
+ "confidential_transfer_from",
+ (spender.clone(), f.alice.clone(), f.bob.clone(), data.clone()).into_val(e),
+ &[],
+ );
+ let token = ConfidentialTokenClient::new(e, &f.h.host);
+ match approval {
+ None => token
+ .mock_auths(&[MockAuth { address: spender, invoke: &sender }])
+ .confidential_transfer_from(spender, &f.alice, &f.bob, &data),
+ Some((fn_name, args)) => {
+ let approval = invoke(&f.h.host, fn_name, args, &[]);
+ token
+ .mock_auths(&[
+ MockAuth { address: spender, invoke: &sender },
+ MockAuth { address: signer, invoke: &approval },
+ ])
+ .confidential_transfer_from(spender, &f.alice, &f.bob, &data)
+ }
+ }
+}
+
+// Pins branch: on_spender_transfer -> require_approval / approver
+// configured, no approver entry / Error(Auth, InvalidAction)
+// Verifies: INV-14
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn spender_transfer_without_approval_fails() {
+ let (f, spender) = delegated_flow();
+ gate_spend(&f, &spender, &f.approver, None);
+}
+
+// Pins branch: on_spender_transfer -> require_approval / the exact tuple
+// signed by an address that is not the approver / Error(Auth, InvalidAction)
+// Verifies: INV-14
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn spender_transfer_approval_signed_by_another_address_fails() {
+ let (f, spender) = delegated_flow();
+ let e = &f.h.e;
+ let outsider = Address::generate(e);
+ let args = e.as_contract(&f.h.host, || {
+ spender_transfer_approval_args(e, &spender, &f.alice, &f.bob, &spender_transfer_payload(e))
+ });
+ gate_spend(&f, &spender, &outsider, Some(("confidential_transfer_from", args)));
+}
+
+/// `deposit(alice, bob, 100)` with the sender's entry and `approval` signed
+/// by `signer`.
+fn deposit_with_entry(f: &Flow, signer: &Address, approval: &MockAuthInvoke) {
+ let e = &f.h.e;
+ let sac_transfer = [invoke(
+ &f.h.sac_addr,
+ "transfer",
+ (f.alice.clone(), f.h.host.clone(), 100i128).into_val(e),
+ &[],
+ )];
+ let sender = invoke(
+ &f.h.host,
+ "deposit",
+ (f.alice.clone(), f.bob.clone(), 100i128).into_val(e),
+ &sac_transfer,
+ );
+ ConfidentialTokenClient::new(e, &f.h.host)
+ .mock_auths(&[
+ MockAuth { address: &f.alice, invoke: &sender },
+ MockAuth { address: signer, invoke: approval },
+ ])
+ .deposit(&f.alice, &f.bob, &100);
+}
+
+// Pins branch: on_deposit -> require_approval / the exact tuple signed by
+// an address that is not the approver / Error(Auth, InvalidAction)
+// Verifies: INV-14
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn deposit_approval_signed_by_another_address_fails() {
+ let f = approval_flow();
+ let outsider = Address::generate(&f.h.e);
+ let approval = invoke(&f.h.host, "deposit", current_deposit_args(&f, 100), &[]);
+ deposit_with_entry(&f, &outsider, &approval);
+}
+
+// Pins branch: on_deposit -> require_approval / the exact tuple authorized
+// for another entry point of the token / Error(Auth, InvalidAction)
+// Verifies: INV-18
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn deposit_approval_under_another_function_name_fails() {
+ let f = approval_flow();
+ let approval = invoke(&f.h.host, "withdraw", current_deposit_args(&f, 100), &[]);
+ deposit_with_entry(&f, &f.approver, &approval);
+}
+
+// Pins branch: on_deposit -> require_approval / the exact tuple authorized
+// for another token contract / Error(Auth, InvalidAction)
+// Verifies: INV-18
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn deposit_approval_for_another_token_fails() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let registry = e.as_contract(&f.h.host, || get_auditor(e));
+ let other_token = e.register(RwaHost, (f.h.sac_addr.clone(), f.h.verifier.clone(), registry));
+ let approval = invoke(&other_token, "deposit", current_deposit_args(&f, 100), &[]);
+ deposit_with_entry(&f, &f.approver, &approval);
+}
+
+// Pins branch: on_transfer -> require_approval / the exact tuple
+// authorized for another entry point / Error(Auth, InvalidAction)
+// Verifies: INV-18
+#[test]
+#[should_panic(expected = "Error(Auth, InvalidAction)")]
+fn transfer_approval_under_another_function_name_fails() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ let data = transfer_data(e);
+ let sender = invoke(
+ &f.h.host,
+ "confidential_transfer",
+ (f.alice.clone(), f.bob.clone(), data.clone()).into_val(e),
+ &[],
+ );
+ let args = e.as_contract(&f.h.host, || {
+ transfer_approval_args(e, &f.alice, &f.bob, &transfer_payload(e))
+ });
+ let approval = invoke(&f.h.host, "confidential_transfer_from", args, &[]);
+ ConfidentialTokenClient::new(e, &f.h.host)
+ .mock_auths(&[
+ MockAuth { address: &f.alice, invoke: &sender },
+ MockAuth { address: &f.approver, invoke: &approval },
+ ])
+ .confidential_transfer(&f.alice, &f.bob, &data);
+}
+
+// Pins branch: require_approval / approver None (disabled after having
+// been set) / deposit and transfer settle with the sender's entry alone
+// Verifies: INV-14
+#[test]
+fn without_an_approver_only_the_sender_authorizes() {
+ let f = approval_flow();
+ let e = &f.h.e;
+ ConfidentialRwaClient::new(e, &f.h.host).set_rwa_config(&AUDITOR, &None, &f.h.admin);
+
+ deposit_with(&f, 100, None);
+ assert_eq!(e.auths().len(), 1);
+ assert_eq!(e.auths()[0].0, f.alice);
+
+ transfer_with(&f, None);
+ assert_eq!(e.auths().len(), 1);
+ assert_eq!(e.auths()[0].0, f.alice);
+}
+
+// Pins branch: on_register / approver configured / registration needs the
+// account's authorization only
+// Verifies: INV-14
+#[test]
+fn register_needs_no_approval() {
+ let h = setup();
+ let approver = Address::generate(&h.e);
+ configure(&h, Some(approver));
+ let a = Address::generate(&h.e);
+ let data =
+ RegisterData { payload: register_payload(&h.e), proof: Bytes::new(&h.e) }.to_xdr(&h.e);
+ let call = invoke(&h.host, "register", (a.clone(), AUDITOR, data.clone()).into_val(&h.e), &[]);
+ ConfidentialTokenClient::new(&h.e, &h.host)
+ .mock_auths(&[MockAuth { address: &a, invoke: &call }])
+ .register(&a, &AUDITOR, &data);
+ assert_eq!(h.e.auths().len(), 1);
+ h.e.as_contract(&h.host, || assert_eq!(get_account(&h.e, &a).auditor_id, AUDITOR));
+}
+
+// ################## P2: SAC GATE AND FAIL-CLOSED EXTERNAL CALLS
+// ##################
+
+// Pins branch: gate_account -> check_sac / the sender, the owner, the
+// delegating account or the spender is SAC-deauthorized /
+// NotAuthorizedBySac #3603
+// Verifies: INV-16
+#[test]
+fn sac_gate_covers_the_source_and_the_spender() {
+ let h = setup();
+ h.e.as_contract(&h.host, || {
+ set_compliance_config(&h.e, &ComplianceConfig { policy: None, sac_passthrough: true });
+ set_rwa_config(&h.e, AUDITOR, &None);
+ });
+ for (op, role) in [
+ (Op::Deposit, Role::From),
+ (Op::Withdraw, Role::From),
+ (Op::Transfer, Role::From),
+ (Op::SpenderTransfer, Role::From),
+ (Op::SpenderTransfer, Role::Spender),
+ (Op::SetSpender, Role::From),
+ ] {
+ let p = parties(&h);
+ h.sac.set_authorized(p.get(role), &false);
+ assert_eq!(
+ try_hook(&h, op, &p),
+ err(ComplianceError::NotAuthorizedBySac),
+ "{op:?} {role:?}"
+ );
+ }
+}
+
+// Pins branch: check_policy / the policy call fails / every hook traps
+// Verifies: INV-20
+#[test]
+fn hooks_trap_when_the_policy_call_fails() {
+ let h = setup();
+ let failing = h.e.register(PanicPolicy, ());
+ h.e.as_contract(&h.host, || {
+ set_compliance_config(
+ &h.e,
+ &ComplianceConfig { policy: Some(failing.clone()), sac_passthrough: false },
+ );
+ set_rwa_config(&h.e, AUDITOR, &None);
+ });
+ for op in OPS {
+ let p = parties(&h);
+ assert!(try_hook(&h, op, &p).is_err(), "{op:?} passed with an unavailable policy");
+ }
+}
+
+/// A token without the SAC admin interface.
+#[contract]
+struct PlainToken;
+
+#[contractimpl]
+impl PlainToken {
+ pub fn decimals(_e: &Env) -> u32 {
+ 7
+ }
+}
+
+// Pins branch: can_transact, check_sac / sac_passthrough over a token with
+// no `authorized` / the views return false and every hook traps
+// Verifies: INV-16, INV-20
+#[test]
+fn sac_passthrough_over_a_non_sac_underlying_fails_closed() {
+ let h = setup();
+ let plain = h.e.register(PlainToken, ());
+ h.e.as_contract(&h.host, || {
+ h.e.storage().instance().set(&ConfidentialTokenStorageKey::UnderlyingAsset, &plain);
+ set_compliance_config(&h.e, &ComplianceConfig { policy: None, sac_passthrough: true });
+ set_rwa_config(&h.e, AUDITOR, &None);
+ });
+ let a = account(&h, AUDITOR);
+ let client = ConfidentialRwaClient::new(&h.e, &h.host);
+ assert!(!client.can_send(&a));
+ assert!(!client.can_receive(&a));
+ for op in OPS {
+ let p = parties(&h);
+ assert!(try_hook(&h, op, &p).is_err(), "{op:?} passed over a non-SAC underlying");
+ }
+}
+
+// ################## P3: FORCED TRANSFER ##################
+
+// Pins branch: forced_transfer / two preconditions fail at once / the
+// order is freeze, amount, destination identity, source registration,
+// destination gates, destination registration and auditor; state unchanged
+// Verifies: INV-6
+#[test]
+fn forced_transfer_checks_run_in_documented_order() {
+ let f = forced_flow(None);
+ let h = &f.h;
+ let e = &h.e;
+
+ // 1. Freeze before amount, destination and registration.
+ let unfrozen = account(h, AUDITOR);
+ assert_eq!(try_force(&f, &unfrozen, &unfrozen, 0), err(ComplianceError::AccountNotFrozen));
+ let nobody = Address::generate(e);
+ assert_eq!(try_force(&f, &nobody, &f.to, 1), err(ComplianceError::AccountNotFrozen));
+
+ // 2. Amount before destination.
+ assert_eq!(
+ try_force(&f, &f.from, &f.from, 0),
+ err(ComplianceError::InvalidForcedTransferAmount)
+ );
+
+ // 3. Destination identity before the source's registration.
+ let ghost = Address::generate(e);
+ freeze_account(h, &ghost);
+ assert_eq!(
+ try_force(&f, &ghost, &ghost, 1),
+ err(ComplianceError::InvalidForcedTransferDestination)
+ );
+ assert_eq!(
+ try_force(&f, &ghost, &h.host, 1),
+ err(ComplianceError::InvalidForcedTransferDestination)
+ );
+
+ // 4. Source registration before the destination's gates.
+ let frozen_to = account(h, AUDITOR);
+ freeze_account(h, &frozen_to);
+ assert_eq!(
+ try_force(&f, &ghost, &frozen_to, 1),
+ token_err(ConfidentialTokenError::AccountNotRegistered)
+ );
+
+ // 5. Destination gates before its registration and auditor binding.
+ let legacy_frozen = account(h, LEGACY_AUDITOR);
+ freeze_account(h, &legacy_frozen);
+ assert_eq!(try_force(&f, &f.from, &legacy_frozen, 1), err(ComplianceError::AccountFrozen));
+ let denied_unregistered = Address::generate(e);
+ ListPolicyClient::new(e, &h.policy).block(&denied_unregistered);
+ assert_eq!(
+ try_force(&f, &f.from, &denied_unregistered, 1),
+ err(ComplianceError::NotAuthorizedByPolicy)
+ );
+
+ // Nothing moved and the nonce did not advance.
+ e.as_contract(&h.host, || {
+ assert_eq!(get_account(e, &f.from).spendable_commitment, g_times(e, 70));
+ assert_eq!(get_account(e, &f.from).receiving_commitment, g_times(e, 30));
+ assert_eq!(get_account(e, &f.to).receiving_commitment, g_times(e, 7));
+ });
+ assert_eq!(ConfidentialClawbackClient::new(e, &h.host).clawback_nonce(&f.from), 0);
+}
+
+// Pins branch: forced_transfer -> gate_account(to) / SAC passthrough, `to`
+// deauthorized / NotAuthorizedBySac #3603; `from` passes no gate
+// Verifies: INV-6
+#[test]
+fn forced_transfer_gates_the_destination_on_the_sac_and_not_the_source() {
+ let f = forced_flow(None);
+ let e = &f.h.e;
+ ConfidentialComplianceClient::new(e, &f.h.host).set_compliance_config(
+ &ComplianceConfig { policy: Some(f.h.policy.clone()), sac_passthrough: true },
+ &f.h.admin,
+ );
+ f.h.sac.set_authorized(&f.to, &false);
+ assert_eq!(try_force(&f, &f.from, &f.to, 40), err(ComplianceError::NotAuthorizedBySac));
+
+ // The source is not gated: denying it does not block the override.
+ f.h.sac.set_authorized(&f.to, &true);
+ f.h.sac.set_authorized(&f.from, &false);
+ ListPolicyClient::new(e, &f.h.policy).block(&f.from);
+ assert_eq!(try_force(&f, &f.from, &f.to, 40), Ok(()));
+}
+
+// Pins branch: forced_transfer -> verify / the statement of an executed
+// proof replayed against another account in the same state / InvalidProof
+// Verifies: INV-12
+#[test]
+fn forced_transfer_proof_is_bound_to_the_source_account() {
+ let f = forced_flow_with(None, |e| e.register(BoundProofVerifier, ()));
+ force(&f, 40);
+ // A second frozen account in the state the proof was built against.
+ let twin = Address::generate(&f.h.e);
+ store_account(&f.h, &twin, AUDITOR, 70, 30);
+ freeze_account(&f.h, &twin);
+ assert_eq!(try_force(&f, &twin, &f.to, 40), token_err(ConfidentialTokenError::InvalidProof));
+}
+
+// Pins branch: forced_transfer -> verify / the statement replayed with
+// another amount / InvalidProof
+// Verifies: INV-12
+#[test]
+fn forced_transfer_proof_is_bound_to_the_amount() {
+ let f = forced_flow_with(None, |e| e.register(BoundProofVerifier, ()));
+ force(&f, 40);
+ store_account(&f.h, &f.from, AUDITOR, 70, 30);
+ f.h.e.as_contract(&f.h.host, || {
+ f.h.e.storage().persistent().remove(&ComplianceStorageKey::ClawbackNonce(f.from.clone()));
+ });
+ assert_eq!(try_force(&f, &f.from, &f.to, 41), token_err(ConfidentialTokenError::InvalidProof));
+}
+
+// Pins branch: forced_transfer / success / the amount, token and nonce
+// public inputs carry the call's amount, this contract's field element
+// and the stored nonce
+// Verifies: INV-11, INV-12
+#[test]
+fn forced_transfer_public_inputs_carry_amount_token_and_nonce() {
+ let f = forced_flow(None);
+ let e = &f.h.e;
+ e.as_contract(&f.h.host, || {
+ e.storage().persistent().set(&ComplianceStorageKey::ClawbackNonce(f.from.clone()), &5u32);
+ });
+ force(&f, 40);
+
+ let pi = last_public_inputs(&f.h);
+ assert_eq!(pi[6], field(e, 40));
+ e.as_contract(&f.h.host, || assert_eq!(pi[7], get_address_as_field_element(e)));
+ assert_eq!(pi[10], field(e, 5));
+}
+
+// Pins branch: forced_transfer -> auditor.get_key / `from` bound to an id
+// the registry does not hold / AuditorNotRegistered #3301
+// Verifies: INV-6
+#[test]
+fn forced_transfer_from_source_with_unregistered_auditor_fails() {
+ let f = forced_flow(None);
+ let orphan = Address::generate(&f.h.e);
+ store_account(&f.h, &orphan, 7, 50, 0);
+ freeze_account(&f.h, &orphan);
+ // AuditorError::AuditorNotRegistered
+ assert_eq!(try_force(&f, &orphan, &f.to, 10), Err(Error::from_contract_error(3301)));
+}
+
+// Pins branch: forced_transfer / stored configuration invalid / it reverts
+// with EligibilityRequired #3615 or NotConfigured #3600
+// Verifies: INV-1, INV-6
+#[test]
+fn forced_transfer_fails_closed_on_an_invalid_stored_configuration() {
+ let f = forced_flow(None);
+ let e = &f.h.e;
+ e.as_contract(&f.h.host, || {
+ e.storage().instance().set(
+ &ComplianceStorageKey::Config,
+ &ComplianceConfig { policy: None, sac_passthrough: false },
+ );
+ });
+ assert_eq!(try_force(&f, &f.from, &f.to, 40), err(ComplianceError::EligibilityRequired));
+ e.as_contract(&f.h.host, || e.storage().instance().remove(&ComplianceStorageKey::Config));
+ assert_eq!(try_force(&f, &f.from, &f.to, 40), err(ComplianceError::NotConfigured));
+}
+
+// Pins branch: forced_transfer entry point / `data` is not a ClawbackData /
+// InvalidData #3507
+// Verifies: INV-6
+#[test]
+fn forced_transfer_rejects_malformed_data() {
+ let f = forced_flow(None);
+ let e = &f.h.e;
+ let res = ConfidentialForcedTransferClient::new(e, &f.h.host).try_forced_transfer(
+ &f.from,
+ &f.to,
+ &40,
+ &42u32.to_xdr(e),
+ &f.h.admin,
+ );
+ assert_eq!(
+ res.unwrap_err().unwrap(),
+ Error::from_contract_error(ConfidentialTokenError::InvalidData as u32)
+ );
+}
+
+// ################## P4: ORDER OF CHECKS IN THE HOOKS ##################
+
+// Pins branch: rwa_context / both configurations absent / every hook
+// reports RwaNotConfigured #3607, not NotConfigured #3600
+// Verifies: INV-1
+#[test]
+fn rwa_config_is_checked_before_compliance_config() {
+ let h = setup();
+ for op in OPS {
+ let p = parties(&h);
+ assert_eq!(try_hook(&h, op, &p), err(ComplianceError::RwaNotConfigured), "{op:?}");
+ }
+}
+
+// Pins branch: RwaHooks::on_* / two conditions fail at once / the earlier
+// check's error is reported
+// Verifies: INV-4, INV-5, INV-16
+#[test]
+fn hook_checks_run_in_documented_order() {
+ let h = configured(None);
+ let policy = ListPolicyClient::new(&h.e, &h.policy);
+
+ // on_register: auditor id before the policy.
+ let a = Address::generate(&h.e);
+ policy.block(&a);
+ assert_eq!(
+ try_in_host(&h, || RwaHooks::on_register(
+ &h.e,
+ &a,
+ LEGACY_AUDITOR,
+ ®ister_payload(&h.e)
+ )),
+ err(ComplianceError::AuditorNotAllowed)
+ );
+
+ // on_transfer: `from` gates before `to` gates.
+ let p = parties(&h);
+ freeze_account(&h, &p.from);
+ policy.block(&p.to);
+ assert_eq!(try_hook(&h, Op::Transfer, &p), err(ComplianceError::AccountFrozen));
+
+ // on_transfer: eligibility gates before the auditor binding.
+ let p = Parties {
+ from: account(&h, LEGACY_AUDITOR),
+ to: account(&h, AUDITOR),
+ spender: account(&h, AUDITOR),
+ };
+ freeze_account(&h, &p.to);
+ assert_eq!(try_hook(&h, Op::Transfer, &p), err(ComplianceError::AccountFrozen));
+
+ // on_deposit: policy on `to` before its registration and auditor checks.
+ let p = Parties {
+ from: Address::generate(&h.e),
+ to: Address::generate(&h.e),
+ spender: Address::generate(&h.e),
+ };
+ policy.block(&p.to);
+ assert_eq!(try_hook(&h, Op::Deposit, &p), err(ComplianceError::NotAuthorizedByPolicy));
+
+ // on_spender_transfer: `to` gates before `spender` gates.
+ let p = parties(&h);
+ policy.block(&p.to);
+ freeze_account(&h, &p.spender);
+ assert_eq!(try_hook(&h, Op::SpenderTransfer, &p), err(ComplianceError::NotAuthorizedByPolicy));
+}
+
+// Pins branch: on_transfer / approver == from and from bound to another
+// auditor / AuditorMismatch #3609 wins over ApproverIsParty #3612
+// Verifies: INV-3, INV-5
+#[test]
+fn auditor_binding_is_checked_before_the_approver_is_party_rule() {
+ let h = setup();
+ let alice = Address::generate(&h.e);
+ configure(&h, Some(alice.clone()));
+ store_account(&h, &alice, LEGACY_AUDITOR, 0, 0);
+ let p = Parties { from: alice, to: account(&h, AUDITOR), spender: account(&h, AUDITOR) };
+ assert_eq!(try_hook(&h, Op::Transfer, &p), err(ComplianceError::AuditorMismatch));
+}
+
+// ################## P5: HOOK VARIANTS ##################
+
+// Pins branch: on_spender_transfer / the spender is bound to another
+// auditor or not registered / the hook passes
+// Verifies: INV-5
+#[test]
+fn spender_binding_and_registration_are_not_checked_on_spend() {
+ let h = configured(None);
+ for spender in [account(&h, LEGACY_AUDITOR), Address::generate(&h.e)] {
+ let p = Parties { from: account(&h, AUDITOR), to: account(&h, AUDITOR), spender };
+ assert_eq!(try_hook(&h, Op::SpenderTransfer, &p), Ok(()));
+ }
+}
+
+// Pins branch: on_transfer, on_spender_transfer, on_set_spender / a
+// confidential party is not registered / AccountNotRegistered #3501;
+// on_withdraw accepts an unregistered recipient
+// Verifies: INV-5, INV-16
+#[test]
+fn hooks_reject_unregistered_confidential_parties() {
+ let h = configured(None);
+ let not_registered = token_err(ConfidentialTokenError::AccountNotRegistered);
+ let p = Parties {
+ from: Address::generate(&h.e),
+ to: account(&h, AUDITOR),
+ spender: account(&h, AUDITOR),
+ };
+ assert_eq!(try_hook(&h, Op::Transfer, &p), not_registered);
+ assert_eq!(try_hook(&h, Op::SpenderTransfer, &p), not_registered);
+ assert_eq!(try_hook(&h, Op::SetSpender, &p), not_registered);
+ let p = Parties {
+ from: account(&h, AUDITOR),
+ to: Address::generate(&h.e),
+ spender: account(&h, AUDITOR),
+ };
+ assert_eq!(try_hook(&h, Op::Transfer, &p), not_registered);
+ assert_eq!(try_hook(&h, Op::SpenderTransfer, &p), not_registered);
+ // A withdrawal pays out to a public address.
+ assert_eq!(try_hook(&h, Op::Withdraw, &p), Ok(()));
+}
+
+// Pins branch: on_register / the address carries a frozen flag / it
+// registers; can_receive is false afterwards
+// Verifies: INV-16
+#[test]
+fn frozen_address_can_register_but_not_transact() {
+ let h = configured(None);
+ let a = Address::generate(&h.e);
+ freeze_account(&h, &a);
+ assert_eq!(
+ try_in_host(&h, || RwaHooks::on_register(&h.e, &a, AUDITOR, ®ister_payload(&h.e))),
+ Ok(())
+ );
+ store_account(&h, &a, AUDITOR, 0, 0);
+ assert!(!ConfidentialRwaClient::new(&h.e, &h.host).can_receive(&a));
+}
+
+// ################## P6: can_transact ##################
+
+// Pins branch: can_transact / the policy address is not a contract / false
+// Verifies: INV-20
+#[test]
+fn can_transact_is_false_when_the_policy_is_not_a_contract() {
+ let h = setup();
+ let missing = Address::generate(&h.e);
+ h.e.as_contract(&h.host, || {
+ set_compliance_config(
+ &h.e,
+ &ComplianceConfig { policy: Some(missing.clone()), sac_passthrough: false },
+ );
+ set_rwa_config(&h.e, AUDITOR, &None);
+ });
+ let a = account(&h, AUDITOR);
+ assert!(!ConfidentialRwaClient::new(&h.e, &h.host).can_send(&a));
+}
+
+// Pins branch: can_transact / policy and SAC both configured / true only
+// when both authorize
+// Verifies: INV-16
+#[test]
+fn can_transact_requires_both_gates_when_both_are_configured() {
+ let h = setup();
+ h.e.as_contract(&h.host, || {
+ set_compliance_config(
+ &h.e,
+ &ComplianceConfig { policy: Some(h.policy.clone()), sac_passthrough: true },
+ );
+ set_rwa_config(&h.e, AUDITOR, &None);
+ });
+ let client = ConfidentialRwaClient::new(&h.e, &h.host);
+ let ok = account(&h, AUDITOR);
+ let denied_by_policy = account(&h, AUDITOR);
+ ListPolicyClient::new(&h.e, &h.policy).block(&denied_by_policy);
+ let denied_by_sac = account(&h, AUDITOR);
+ h.sac.set_authorized(&denied_by_sac, &false);
+
+ assert!(client.can_send(&ok));
+ assert!(!client.can_send(&denied_by_policy));
+ assert!(!client.can_send(&denied_by_sac));
+}
+
+// Pins branch: can_transact / RwaConfig set, ComplianceConfig without a
+// gate or absent / false
+// Verifies: INV-16
+#[test]
+fn can_transact_is_false_for_an_invalid_stored_configuration() {
+ let h = configured(None);
+ let a = account(&h, AUDITOR);
+ let client = ConfidentialRwaClient::new(&h.e, &h.host);
+ assert!(client.can_send(&a));
+
+ // An eligibility-free configuration, which no setter can write.
+ h.e.as_contract(&h.host, || {
+ h.e.storage().instance().set(
+ &ComplianceStorageKey::Config,
+ &ComplianceConfig { policy: None, sac_passthrough: false },
+ );
+ });
+ assert!(!client.can_send(&a));
+
+ h.e.as_contract(&h.host, || h.e.storage().instance().remove(&ComplianceStorageKey::Config));
+ assert!(!client.can_send(&a));
+}
+
+// ################## P7: CONFIGURATION ##################
+
+// Pins branch: set_rwa_config / auditor and approver both invalid / the
+// auditor check wins; the token is rejected as approver on a later call
+// too; a rejected call writes nothing
+// Verifies: INV-2, INV-3, INV-8
+#[test]
+fn set_rwa_config_checks_the_auditor_before_the_approver() {
+ let h = setup();
+ h.e.as_contract(&h.host, || set_compliance_config(&h.e, &policy_config(&h)));
+ let client = ConfidentialRwaClient::new(&h.e, &h.host);
+ let this = Some(h.host.clone());
+
+ // First call: registry lookup first.
+ assert_eq!(
+ client.try_set_rwa_config(&7, &this, &h.admin).unwrap_err().unwrap(),
+ Error::from_contract_error(3301) // AuditorError::AuditorNotRegistered
+ );
+ client.set_rwa_config(&AUDITOR, &None, &h.admin);
+ // Later calls: the immutable auditor first, then the approver.
+ assert_eq!(
+ client.try_set_rwa_config(&LEGACY_AUDITOR, &this, &h.admin).unwrap_err().unwrap(),
+ Error::from_contract_error(ComplianceError::AuditorAlreadySet as u32)
+ );
+ assert_eq!(
+ client.try_set_rwa_config(&AUDITOR, &this, &h.admin).unwrap_err().unwrap(),
+ Error::from_contract_error(ComplianceError::InvalidApprover as u32)
+ );
+ assert_eq!(client.rwa_config(), Some(RwaConfig { auditor_id: AUDITOR, approver: None }));
+ assert_eq!(client.approval_epoch(), 1);
+}
+
+// Pins branch: set_rwa_config / later call, approver None / exactly one
+// RwaConfigChanged without an approver, carrying the new epoch
+// Verifies: INV-22
+#[test]
+fn disabling_the_approver_emits_rwa_config_changed_without_approver() {
+ let h = setup();
+ let approver = Address::generate(&h.e);
+ configure(&h, Some(approver));
+ let client = ConfidentialRwaClient::new(&h.e, &h.host);
+ client.set_rwa_config(&AUDITOR, &None, &h.admin);
+
+ let events = h.e.events().all();
+ assert_eq!(events.events().len(), 1);
+ assert_eq!(
+ events.events().first().unwrap(),
+ &RwaConfigChanged { auditor_id: AUDITOR, approver: None, epoch: 2 }.to_xdr(&h.e, &h.host),
+ );
+ assert_eq!(client.rwa_config(), Some(RwaConfig { auditor_id: AUDITOR, approver: None }));
+}
+
+// Pins branch: set_compliance_config, set_rwa_config / rejected with
+// EligibilityRequired #3615 or ApprovalEpochOverflow #3616 / the stored
+// configurations and the epoch are unchanged
+// Verifies: INV-7, INV-8, INV-9
+#[test]
+fn rejected_setters_leave_configuration_and_epoch_unchanged() {
+ let h = configured(None);
+ let rwa = ConfidentialRwaClient::new(&h.e, &h.host);
+ let compliance = ConfidentialComplianceClient::new(&h.e, &h.host);
+ let open = ComplianceConfig { policy: None, sac_passthrough: false };
+
+ assert_eq!(
+ compliance.try_set_compliance_config(&open, &h.admin).unwrap_err().unwrap(),
+ Error::from_contract_error(ComplianceError::EligibilityRequired as u32)
+ );
+ assert_eq!(compliance.compliance_config(), Some(policy_config(&h)));
+ assert_eq!(rwa.approval_epoch(), 1);
+
+ // At the last epoch every setter is rejected and nothing is written.
+ h.e.as_contract(&h.host, || {
+ h.e.storage().instance().set(&ComplianceStorageKey::ApprovalEpoch, &u32::MAX);
+ });
+ let overflow = Error::from_contract_error(ComplianceError::ApprovalEpochOverflow as u32);
+ let rotated = ComplianceConfig { policy: Some(h.policy.clone()), sac_passthrough: true };
+ assert_eq!(
+ compliance.try_set_compliance_config(&rotated, &h.admin).unwrap_err().unwrap(),
+ overflow
+ );
+ assert_eq!(compliance.compliance_config(), Some(policy_config(&h)));
+ let approver = Address::generate(&h.e);
+ assert_eq!(
+ rwa.try_set_rwa_config(&AUDITOR, &Some(approver), &h.admin).unwrap_err().unwrap(),
+ overflow
+ );
+ assert_eq!(rwa.rwa_config(), Some(RwaConfig { auditor_id: AUDITOR, approver: None }));
+ assert_eq!(rwa.approval_epoch(), u32::MAX);
+}
+
+// Pins branch: advance_approval_epoch / epoch == u32::MAX - 1 / the bump
+// succeeds and the epoch reads u32::MAX
+// Verifies: INV-7
+#[test]
+fn approval_epoch_reaches_u32_max() {
+ let h = configured(None);
+ h.e.as_contract(&h.host, || {
+ h.e.storage().instance().set(&ComplianceStorageKey::ApprovalEpoch, &(u32::MAX - 1));
+ });
+ let client = ConfidentialRwaClient::new(&h.e, &h.host);
+ client.bump_approval_epoch(&h.admin);
+ assert_eq!(client.approval_epoch(), u32::MAX);
+}
+
+// Pins branch: set_compliance_config / RwaConfig set, SAC passthrough as
+// the only gate / stored; epoch advances
+// Verifies: INV-8, INV-9
+#[test]
+fn set_compliance_config_under_rwa_accepts_sac_passthrough_alone() {
+ let h = configured(None);
+ let client = ConfidentialComplianceClient::new(&h.e, &h.host);
+ let sac_only = ComplianceConfig { policy: None, sac_passthrough: true };
+ client.set_compliance_config(&sac_only, &h.admin);
+ assert_eq!(client.compliance_config(), Some(sac_only));
+ assert_eq!(ConfidentialRwaClient::new(&h.e, &h.host).approval_epoch(), 2);
+}
+
+#[test]
+fn contract_poseidon2_matches_the_noir_library_fixture() {
+ // `circuits/lib/testdata/poseidon_with_domain.json`: domain 2 over
+ // [0xdead, 0xbeef]. The contract's Poseidon2 and the Noir library's
+ // `poseidon_with_domain` are the same function, which is what lets
+ // `fixtures_match_testdata` (Noir) stand as an independent check of
+ // `forced_destination_to_field`.
+ use soroban_poseidon::poseidon2_hash;
+ use soroban_sdk::{crypto::bn254::Bn254Fr, U256};
+
+ let e = Env::default();
+ let inputs = soroban_sdk::vec![
+ &e,
+ U256::from_u32(&e, 2),
+ U256::from_u32(&e, 0xdead),
+ U256::from_u32(&e, 0xbeef)
+ ];
+ let hash: U256 = poseidon2_hash::<4, Bn254Fr>(&e, &inputs);
+ let expected = hex32("208fbdb70d2faacf04f987b54f12aeeaeb432acc29d650c86ce0f6275b958eb8");
+ assert_eq!(hash.to_be_bytes(), Bytes::from_array(&e, &expected));
+}
diff --git a/packages/confidential/src/lib.rs b/packages/confidential/src/lib.rs
index 1d5f0032e..f469b1073 100644
--- a/packages/confidential/src/lib.rs
+++ b/packages/confidential/src/lib.rs
@@ -614,6 +614,12 @@ pub const DELEGATION_TTL_THRESHOLD: u32 = DELEGATION_EXTEND_AMOUNT - DAY_IN_LEDG
/// Domain-separation tag for `address_to_field`.
pub const DELTA_ADDR: u32 = 1;
+/// Domain-separation tag for the destination public input of a forced
+/// transfer (`docs/compliance.md#forced-transfer`). Distinct from
+/// [`DELTA_ADDR`], so a proof built for a forced transfer does not verify
+/// as a clawback and vice versa.
+pub const DELTA_FORCED_DEST: u32 = 18;
+
// ################## EVENTS ##################
/// Event emitted when a confidential account is registered.
diff --git a/packages/confidential/src/storage.rs b/packages/confidential/src/storage.rs
index 86fc1148f..f32074f91 100644
--- a/packages/confidential/src/storage.rs
+++ b/packages/confidential/src/storage.rs
@@ -12,7 +12,7 @@ use crate::{
emit_underlying_asset_set, emit_verifier_set, emit_withdraw,
verifier::{CircuitType, ConfidentialVerifierClient},
ConfidentialTokenError, ACCOUNT_EXTEND_AMOUNT, ACCOUNT_TTL_THRESHOLD, DELEGATION_EXTEND_AMOUNT,
- DELEGATION_TTL_THRESHOLD, DELTA_ADDR,
+ DELEGATION_TTL_THRESHOLD, DELTA_ADDR, DELTA_FORCED_DEST,
};
/// Storage keys for the confidential token.
@@ -1200,7 +1200,7 @@ fn set_spendable(e: &Env, account: &Address, c_spend_new: &Point) {
///
/// * [`ConfidentialTokenError::AccountNotRegistered`] - When `account` is not
/// registered.
-fn add_to_receiving(e: &Env, account: &Address, c_transfer: &Point) {
+pub(crate) fn add_to_receiving(e: &Env, account: &Address, c_transfer: &Point) {
let mut data = get_account(e, account);
data.receiving_commitment = Grumpkin::add(e, &data.receiving_commitment, c_transfer);
e.storage().persistent().set(&ConfidentialTokenStorageKey::Account(account.clone()), &data);
@@ -1352,6 +1352,27 @@ pub(crate) fn address_to_field(e: &Env, addr: &Address) -> BytesN<32> {
u256_to_bytes_n_32(e, &hash)
}
+/// Derives the destination public input of a forced transfer:
+/// `Poseidon2(δ_forced_dest, address_to_field(addr))`, as a canonical 32-byte
+/// big-endian `Bn254Fr` representative.
+///
+/// The clawback circuit treats its destination input as an opaque binding.
+/// Clawback supplies `address_to_field(destination)` (or zero for `None`);
+/// a forced transfer supplies this value instead, so one proof authorizes
+/// exactly one settlement mode
+/// (`docs/compliance.md#forced-transfer`).
+///
+/// # Arguments
+///
+/// * `e` - Access to the Soroban environment.
+/// * `addr` - The confidential account credited by the forced transfer.
+pub(crate) fn forced_destination_to_field(e: &Env, addr: &Address) -> BytesN<32> {
+ let addr_f = U256::from_be_bytes(e, &Bytes::from(address_to_field(e, addr)));
+ let inputs = soroban_sdk::vec![e, U256::from_u32(e, DELTA_FORCED_DEST), addr_f];
+ let hash: U256 = poseidon2_hash::<4, Bn254Fr>(e, &inputs);
+ u256_to_bytes_n_32(e, &hash)
+}
+
/// Interprets a 28-byte slice in little-endian order as a `U256`. The caller
/// passes the lower or upper 28 bytes of the 56-byte strkey;
/// both fit in `Bn254Fr` since each limb is bounded by `2^224 ≪ r`.