Skip to content

Commit c12dcf6

Browse files
committed
feat(vault): authorized caller for metering
1 parent 1e9b2f4 commit c12dcf6

File tree

3 files changed

+294
-11
lines changed

3 files changed

+294
-11
lines changed

contracts/vault/ACCESS_CONTROL.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,29 @@ The `transfer_ownership` function allows the current owner to hand over full con
7575
### Admin Transition
7676

7777
The `set_admin` function allows the current admin (typically the owner initially) to delegate operational control (like settlement and distribution) to a dedicated service account.
78+
79+
---
80+
81+
## Migration: Owner-Only Metering → Backend-Signed Metering
82+
83+
### Background
84+
85+
In the initial deployment model, only the vault owner could invoke `deduct` and `batch_deduct`. This required the owner's key to be present in the metering path, which is impractical for automated, high-frequency backend services.
86+
87+
### Current Model
88+
89+
The `set_authorized_caller` function allows the owner to designate a single backend address (e.g., a matching engine or metering service) that may call deduct flows alongside the owner. Both the owner and the authorized caller are permitted; all other addresses are rejected.
90+
91+
### Migration Steps
92+
93+
1. Deploy or upgrade the vault contract containing `set_authorized_caller`.
94+
2. The owner calls `set_authorized_caller(owner, backend_address)` to register the backend signing key.
95+
3. The backend service signs and submits `deduct` / `batch_deduct` transactions using its own key.
96+
4. The owner's key is no longer required in the hot metering path.
97+
98+
### Operational Notes
99+
100+
- Only the owner may call `set_authorized_caller`; the backend cannot self-register.
101+
- Rotating the backend key requires calling `set_authorized_caller` again with the new address. The previous address is replaced atomically.
102+
- Every change emits a `set_auth_caller` event (topics: `("set_auth_caller", owner)`, data: `new_caller`) for audit purposes.
103+
- Passing the vault contract's own address or the currently stored address as `new_caller` is rejected.

contracts/vault/src/lib.rs

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -244,19 +244,45 @@ impl CalloraVault {
244244
}
245245
}
246246

247-
/// Sets the authorized caller permitted to trigger deductions.
248-
/// Can only be called by the Owner.
249-
pub fn set_authorized_caller(env: Env, caller: Address) {
247+
/// Sets the address permitted to invoke deduct flows alongside the owner.
248+
///
249+
/// Only the vault owner may call this function. The new authorized caller must
250+
/// differ from the address already stored; passing the same address is rejected
251+
/// as a meaningless no-op. Passing the vault contract's own address is also
252+
/// rejected.
253+
///
254+
/// # Arguments
255+
/// * `env` – The environment running the contract.
256+
/// * `owner` – Must be the current vault owner; must authorize this call.
257+
/// * `new_caller` – Address to grant deduction rights.
258+
///
259+
/// # Panics
260+
/// * `"unauthorized: owner only"` – if `owner` is not the vault owner.
261+
/// * `"new_caller must differ from current authorized caller"` – if `new_caller`
262+
/// is already the stored authorized caller (meaningless update).
263+
/// * `"new_caller must not be the vault contract itself"` – if `new_caller` is
264+
/// the vault's own contract address.
265+
///
266+
/// # Events
267+
/// Emits topic `("set_auth_caller", owner)` with data `new_caller` on success.
268+
pub fn set_authorized_caller(env: Env, owner: Address, new_caller: Address) {
269+
owner.require_auth();
270+
Self::require_owner(env.clone(), owner.clone());
271+
assert!(
272+
new_caller != env.current_contract_address(),
273+
"new_caller must not be the vault contract itself"
274+
);
250275
let mut meta = Self::get_meta(env.clone());
251-
meta.owner.require_auth();
252-
253-
meta.authorized_caller = Some(caller.clone());
276+
if let Some(ref current) = meta.authorized_caller {
277+
assert!(
278+
new_caller != *current,
279+
"new_caller must differ from current authorized caller"
280+
);
281+
}
282+
meta.authorized_caller = Some(new_caller.clone());
254283
env.storage().instance().set(&StorageKey::Meta, &meta);
255-
256-
env.events().publish(
257-
(Symbol::new(&env, "set_auth_caller"), meta.owner.clone()),
258-
caller,
259-
);
284+
env.events()
285+
.publish((Symbol::new(&env, "set_auth_caller"), owner), new_caller);
260286
}
261287

262288
/// Deposits USDC into the vault.

contracts/vault/src/test.rs

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1409,3 +1409,234 @@ fn get_settlement_before_set_panics() {
14091409
client.init(&owner, &usdc, &None, &None, &None, &None, &None);
14101410
client.get_settlement();
14111411
}
1412+
1413+
// ---------------------------------------------------------------------------
1414+
// set_authorized_caller + deduct authorization matrix tests
1415+
// ---------------------------------------------------------------------------
1416+
1417+
/// Helper: init a vault with `balance` and no pre-set authorized caller.
1418+
/// Caller must have already called `env.mock_all_auths()`.
1419+
fn init_vault_no_auth_caller<'a>(
1420+
env: &'a Env,
1421+
owner: &Address,
1422+
balance: i128,
1423+
) -> (Address, CalloraVaultClient<'a>) {
1424+
let (vault_address, client) = create_vault(env);
1425+
let (usdc, _, usdc_admin) = create_usdc(env, owner);
1426+
fund_vault(&usdc_admin, &vault_address, balance);
1427+
client.init(owner, &usdc, &Some(balance), &None, &None, &None, &None);
1428+
(vault_address, client)
1429+
}
1430+
1431+
#[test]
1432+
fn set_authorized_caller_stores_address_and_emits_event() {
1433+
let env = Env::default();
1434+
let owner = Address::generate(&env);
1435+
let backend = Address::generate(&env);
1436+
env.mock_all_auths();
1437+
let (vault_address, client) = init_vault_no_auth_caller(&env, &owner, 100);
1438+
1439+
client.set_authorized_caller(&owner, &backend);
1440+
1441+
// Event emitted: topic ("set_auth_caller", owner), data = backend
1442+
let events = env.events().all();
1443+
let ev = events
1444+
.iter()
1445+
.find(|e| {
1446+
!e.1.is_empty() && {
1447+
let t: Symbol = e.1.get(0).unwrap().into_val(&env);
1448+
t == Symbol::new(&env, "set_auth_caller")
1449+
}
1450+
})
1451+
.expect("expected set_auth_caller event");
1452+
1453+
assert_eq!(ev.0, vault_address);
1454+
let topic_owner: Address = ev.1.get(1).unwrap().into_val(&env);
1455+
assert_eq!(topic_owner, owner);
1456+
let data: Address = ev.2.into_val(&env);
1457+
assert_eq!(data, backend);
1458+
1459+
// Stored correctly (checked after event assertion to avoid resetting event log)
1460+
let meta = client.get_meta();
1461+
assert_eq!(meta.authorized_caller, Some(backend));
1462+
}
1463+
1464+
#[test]
1465+
fn set_authorized_caller_can_be_updated_to_different_address() {
1466+
let env = Env::default();
1467+
let owner = Address::generate(&env);
1468+
let backend_v1 = Address::generate(&env);
1469+
let backend_v2 = Address::generate(&env);
1470+
env.mock_all_auths();
1471+
let (_, client) = init_vault_no_auth_caller(&env, &owner, 100);
1472+
client.set_authorized_caller(&owner, &backend_v1);
1473+
client.set_authorized_caller(&owner, &backend_v2);
1474+
1475+
let meta = client.get_meta();
1476+
assert_eq!(meta.authorized_caller, Some(backend_v2));
1477+
}
1478+
1479+
#[test]
1480+
#[should_panic(expected = "unauthorized: owner only")]
1481+
fn set_authorized_caller_non_owner_rejected() {
1482+
let env = Env::default();
1483+
let owner = Address::generate(&env);
1484+
let attacker = Address::generate(&env);
1485+
let backend = Address::generate(&env);
1486+
env.mock_all_auths();
1487+
let (_, client) = init_vault_no_auth_caller(&env, &owner, 100);
1488+
1489+
client.set_authorized_caller(&attacker, &backend);
1490+
}
1491+
1492+
#[test]
1493+
#[should_panic(expected = "new_caller must differ from current authorized caller")]
1494+
fn set_authorized_caller_same_address_rejected() {
1495+
let env = Env::default();
1496+
let owner = Address::generate(&env);
1497+
let backend = Address::generate(&env);
1498+
env.mock_all_auths();
1499+
let (_, client) = init_vault_no_auth_caller(&env, &owner, 100);
1500+
1501+
client.set_authorized_caller(&owner, &backend);
1502+
// Setting the same address again must be rejected
1503+
client.set_authorized_caller(&owner, &backend);
1504+
}
1505+
1506+
#[test]
1507+
#[should_panic(expected = "new_caller must not be the vault contract itself")]
1508+
fn set_authorized_caller_vault_address_rejected() {
1509+
let env = Env::default();
1510+
let owner = Address::generate(&env);
1511+
env.mock_all_auths();
1512+
let (vault_address, client) = init_vault_no_auth_caller(&env, &owner, 100);
1513+
1514+
client.set_authorized_caller(&owner, &vault_address);
1515+
}
1516+
1517+
// --- Deduct authorization matrix ---
1518+
1519+
#[test]
1520+
fn deduct_matrix_owner_is_allowed() {
1521+
let env = Env::default();
1522+
let owner = Address::generate(&env);
1523+
env.mock_all_auths();
1524+
let (_, client) = init_vault_no_auth_caller(&env, &owner, 500);
1525+
1526+
// Owner can deduct even without an authorized_caller set
1527+
let remaining = client.deduct(&owner, &100, &None);
1528+
assert_eq!(remaining, 400);
1529+
}
1530+
1531+
#[test]
1532+
fn deduct_matrix_authorized_caller_is_allowed() {
1533+
let env = Env::default();
1534+
let owner = Address::generate(&env);
1535+
let backend = Address::generate(&env);
1536+
env.mock_all_auths();
1537+
let (_, client) = init_vault_no_auth_caller(&env, &owner, 500);
1538+
1539+
client.set_authorized_caller(&owner, &backend);
1540+
1541+
let remaining = client.deduct(&backend, &150, &None);
1542+
assert_eq!(remaining, 350);
1543+
}
1544+
1545+
#[test]
1546+
fn deduct_matrix_other_address_is_rejected() {
1547+
let env = Env::default();
1548+
let owner = Address::generate(&env);
1549+
let backend = Address::generate(&env);
1550+
let stranger = Address::generate(&env);
1551+
env.mock_all_auths();
1552+
let (_, client) = init_vault_no_auth_caller(&env, &owner, 500);
1553+
1554+
client.set_authorized_caller(&owner, &backend);
1555+
1556+
let result = client.try_deduct(&stranger, &50, &None);
1557+
assert!(result.is_err(), "stranger must be rejected from deduct");
1558+
}
1559+
1560+
#[test]
1561+
fn deduct_matrix_no_authorized_caller_set_non_owner_rejected() {
1562+
let env = Env::default();
1563+
let owner = Address::generate(&env);
1564+
let stranger = Address::generate(&env);
1565+
env.mock_all_auths();
1566+
let (_, client) = init_vault_no_auth_caller(&env, &owner, 500);
1567+
1568+
// No authorized_caller configured — only owner may deduct
1569+
let result = client.try_deduct(&stranger, &50, &None);
1570+
assert!(
1571+
result.is_err(),
1572+
"non-owner must be rejected when no authorized_caller is set"
1573+
);
1574+
}
1575+
1576+
#[test]
1577+
fn batch_deduct_matrix_owner_is_allowed() {
1578+
let env = Env::default();
1579+
let owner = Address::generate(&env);
1580+
env.mock_all_auths();
1581+
let (_, client) = init_vault_no_auth_caller(&env, &owner, 500);
1582+
1583+
let items = soroban_sdk::vec![
1584+
&env,
1585+
DeductItem {
1586+
amount: 100,
1587+
request_id: None
1588+
},
1589+
DeductItem {
1590+
amount: 50,
1591+
request_id: None
1592+
},
1593+
];
1594+
let remaining = client.batch_deduct(&owner, &items);
1595+
assert_eq!(remaining, 350);
1596+
}
1597+
1598+
#[test]
1599+
fn batch_deduct_matrix_authorized_caller_is_allowed() {
1600+
let env = Env::default();
1601+
let owner = Address::generate(&env);
1602+
let backend = Address::generate(&env);
1603+
env.mock_all_auths();
1604+
let (_, client) = init_vault_no_auth_caller(&env, &owner, 500);
1605+
1606+
client.set_authorized_caller(&owner, &backend);
1607+
1608+
let items = soroban_sdk::vec![
1609+
&env,
1610+
DeductItem {
1611+
amount: 200,
1612+
request_id: None
1613+
},
1614+
];
1615+
let remaining = client.batch_deduct(&backend, &items);
1616+
assert_eq!(remaining, 300);
1617+
}
1618+
1619+
#[test]
1620+
fn batch_deduct_matrix_other_address_is_rejected() {
1621+
let env = Env::default();
1622+
let owner = Address::generate(&env);
1623+
let backend = Address::generate(&env);
1624+
let stranger = Address::generate(&env);
1625+
env.mock_all_auths();
1626+
let (_, client) = init_vault_no_auth_caller(&env, &owner, 500);
1627+
1628+
client.set_authorized_caller(&owner, &backend);
1629+
1630+
let items = soroban_sdk::vec![
1631+
&env,
1632+
DeductItem {
1633+
amount: 50,
1634+
request_id: None
1635+
},
1636+
];
1637+
let result = client.try_batch_deduct(&stranger, &items);
1638+
assert!(
1639+
result.is_err(),
1640+
"stranger must be rejected from batch_deduct"
1641+
);
1642+
}

0 commit comments

Comments
 (0)