From 7a9855edd0965cf1abf559dc3d0a0b646b597427 Mon Sep 17 00:00:00 2001 From: Islam-Imad Date: Mon, 31 Aug 2026 12:44:07 +0300 Subject: [PATCH 1/2] support mint_lock stellar example Signed-off-by: Islam-Imad --- docs/examples/soroban/mint_lock.sol | 91 ++++ docs/targets/soroban_examples_coverage.rst | 105 ++++- examples/soroban/mint_lock.sol | 91 ++++ src/codegen/expression.rs | 74 ++-- src/codegen/mod.rs | 2 + src/codegen/targets/soroban/mod.rs | 39 ++ src/emit/soroban/mod.rs | 5 + tests/soroban_testcases/address_compare.rs | 143 ++++++ tests/soroban_testcases/example_mint_lock.rs | 436 +++++++++++++++++++ tests/soroban_testcases/mod.rs | 2 + 10 files changed, 939 insertions(+), 49 deletions(-) create mode 100644 docs/examples/soroban/mint_lock.sol create mode 100644 examples/soroban/mint_lock.sol create mode 100644 tests/soroban_testcases/address_compare.rs create mode 100644 tests/soroban_testcases/example_mint_lock.rs diff --git a/docs/examples/soroban/mint_lock.sol b/docs/examples/soroban/mint_lock.sol new file mode 100644 index 000000000..ebc4c66a9 --- /dev/null +++ b/docs/examples/soroban/mint_lock.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +// Mapping of https://github.com/stellar/soroban-examples/tree/main/mint-lock +pragma solidity ^0.8.20; + +contract mint_lock { + struct MinterConfig { + int128 limit; + uint32 epoch_length; + } + + struct MinterStats { + int128 consumed_limit; + } + + struct MinterInfo { + MinterConfig config; + uint32 epoch; + MinterStats minter_stats; + } + + address instance admin; + + mapping(address => mapping(address => MinterConfig)) minters; + mapping(address => mapping(address => bool)) minter_exists; + + mapping(address => + mapping(address => + mapping(uint32 => + mapping(uint32 => MinterStats)))) stats; + + constructor(address admin_) { + admin = admin_; + } + + function set_admin(address new_admin) public { + admin.requireAuth(); + admin = new_admin; + } + + function get_admin() public view returns (address) { + return admin; + } + + function set_minter( + address contract_, + address minter_, + MinterConfig memory config + ) public { + admin.requireAuth(); + minters[contract_][minter_] = config; + minter_exists[contract_][minter_] = true; + } + + function minter(address contract_, address minter_) + public + view + returns (MinterInfo memory) + { + require(minter_exists[contract_][minter_], "not authorized minter"); + MinterConfig memory config = minters[contract_][minter_]; + uint32 epoch = uint32(block.number / uint64(config.epoch_length)); + MinterStats memory minter_stats = stats[contract_][minter_][config.epoch_length][epoch]; + return MinterInfo({config: config, epoch: epoch, minter_stats: minter_stats}); + } + + function mint( + address contract_, + address minter_, + address to, + int128 amount + ) public { + minter_.requireAuth(); + + require(amount >= 0, "negative amount"); + + if (admin != minter_) { + require(minter_exists[contract_][minter_], "not authorized minter"); + MinterConfig memory config = minters[contract_][minter_]; + + uint32 epoch = uint32(block.number / uint64(config.epoch_length)); + MinterStats memory minter_stats = stats[contract_][minter_][config.epoch_length][epoch]; + int128 new_consumed = minter_stats.consumed_limit + amount; + require(new_consumed <= config.limit, "daily limit insufficient"); + minter_stats.consumed_limit = new_consumed; + stats[contract_][minter_][config.epoch_length][epoch] = minter_stats; + } + + bytes memory payload = abi.encode("mint", to, amount); + (bool success, bytes memory returndata) = contract_.call(payload); + } +} diff --git a/docs/targets/soroban_examples_coverage.rst b/docs/targets/soroban_examples_coverage.rst index 14bf6d952..ecf99c694 100644 --- a/docs/targets/soroban_examples_coverage.rst +++ b/docs/targets/soroban_examples_coverage.rst @@ -61,6 +61,9 @@ Documented Counterparts * - `merkle_distribution `_ - `docs/examples/soroban/merkle_distribution.sol `_ and `tests/soroban_testcases/example_merkle_distribution.rs `_ - Merkle-proof airdrop: recipients claim tokens by supplying a Merkle proof against a stored root, and verified claims trigger a cross-contract token transfer. Demonstrates ``sha256`` hashing, the ``to_xdr`` builtin for canonical XDR leaf serialization, ``bytes.concat`` for a real 64-byte inner-node preimage, a ``bytes32[]`` proof parameter, a ``mapping(uint32 => bool)`` claimed-set, and cross-contract ``call`` via ``abi.encode``. The leaf preimage is ``sha256(to_xdr(Receiver{index, recipient, amount}))``, mirroring upstream's ``sha256(Receiver.to_xdr())``; it differs only in the field name ``recipient`` (Solidity reserves ``address``), which changes the struct's ScMap keys, so off-chain tree builders must serialize with this contract's exact struct. Tested via ``merkle_distribution_*`` test cases. + * - `mint-lock `_ + - `docs/examples/soroban/mint_lock.sol `_ and `tests/soroban_testcases/example_mint_lock.rs `_ + - Admin-controlled minting proxy: an admin authorizes per-contract minters, each with a per-epoch limit, and every mint is dispatched as a cross-contract ``call`` into the wrapped token. Demonstrates deeply nested mappings of structs (``minters`` and the four-level ``stats``), ``block.number`` as the epoch source, struct storage and struct ABI return (``MinterInfo``), ``requireAuth()`` on both admin and minter, address ``==`` comparison, and ``require``-based guards. The per-epoch ``stats`` use ``persistent`` rather than upstream's ``temporary`` storage: a temporary entry would be evicted once the ledger sequence advances past its (un-extended) TTL, which the tests trigger by jumping to mid-epoch. Tested via ``mint_lock_*`` test cases. * - `timelock `_ - `docs/examples/soroban/timelock `_ - Timelock-style example using enums, mappings, authorization, and ``block.timestamp``. @@ -647,6 +650,107 @@ The upstream example decodes an ABI-encoded ``Input``, computes an ``Output`` wh } } +mint-lock +^^^^^^^^^ + +Upstream Soroban example: `mint-lock `_ + +Solang Solidity example: `docs/examples/soroban/mint_lock.sol `_ + +An admin-controlled minting proxy. The admin authorizes minters against a wrapped token, each with a per-epoch limit; the admin can always mint, an authorized minter can mint until it exhausts the epoch's limit, and anyone else is rejected. The upstream composite storage keys become nested mappings (``minters[contract_][minter_]`` and the four-level ``stats[contract_][minter_][epoch_length][epoch]``), the epoch is derived from ``block.number`` (Soroban's ledger sequence), and each mint is dispatched with ``contract_.call(abi.encode("mint", to, amount))``. The per-epoch ``stats`` are declared ``persistent`` rather than ``temporary``: a temporary entry would be evicted once the ledger sequence advances past its un-extended TTL, and the tests jump the sequence to mid-epoch. + +.. code-block:: solidity + + contract mint_lock { + struct MinterConfig { + int128 limit; + uint32 epoch_length; + } + + struct MinterStats { + int128 consumed_limit; + } + + struct MinterInfo { + MinterConfig config; + uint32 epoch; + MinterStats minter_stats; + } + + address instance admin; + + mapping(address => mapping(address => MinterConfig)) persistent minters; + mapping(address => mapping(address => bool)) persistent minter_exists; + + mapping(address => + mapping(address => + mapping(uint32 => + mapping(uint32 => MinterStats)))) persistent stats; + + constructor(address admin_) { + admin = admin_; + } + + function set_admin(address new_admin) public { + admin.requireAuth(); + admin = new_admin; + } + + function get_admin() public view returns (address) { + return admin; + } + + function set_minter( + address contract_, + address minter_, + MinterConfig memory config + ) public { + admin.requireAuth(); + minters[contract_][minter_] = config; + minter_exists[contract_][minter_] = true; + } + + function minter(address contract_, address minter_) + public + view + returns (MinterInfo memory) + { + require(minter_exists[contract_][minter_], "not authorized minter"); + MinterConfig memory config = minters[contract_][minter_]; + uint32 epoch = uint32(block.number / uint64(config.epoch_length)); + MinterStats memory minter_stats = stats[contract_][minter_][config.epoch_length][epoch]; + return MinterInfo({config: config, epoch: epoch, minter_stats: minter_stats}); + } + + function mint( + address contract_, + address minter_, + address to, + int128 amount + ) public { + minter_.requireAuth(); + + require(amount >= 0, "negative amount"); + + // The admin can always mint; everyone else is rate-limited per epoch. + if (admin != minter_) { + require(minter_exists[contract_][minter_], "not authorized minter"); + MinterConfig memory config = minters[contract_][minter_]; + + uint32 epoch = uint32(block.number / uint64(config.epoch_length)); + MinterStats memory minter_stats = stats[contract_][minter_][config.epoch_length][epoch]; + int128 new_consumed = minter_stats.consumed_limit + amount; + require(new_consumed <= config.limit, "daily limit insufficient"); + minter_stats.consumed_limit = new_consumed; + stats[contract_][minter_][config.epoch_length][epoch] = minter_stats; + } + + // Dispatch the actual mint into the wrapped token contract. + bytes memory payload = abi.encode("mint", to, amount); + (bool success, bytes memory returndata) = contract_.call(payload); + } + } + Upstream Examples Not Yet Documented as Supported +++++++++++++++++++++++++++++++++++++++++++++++++ @@ -655,7 +759,6 @@ The following upstream examples do not currently have a documented Solidity coun - `deployer `_ - `errors `_ - `fuzzing `_ -- `mint-lock `_ - `privacy-pools `_ - `simple_account `_ - `workspace `_ diff --git a/examples/soroban/mint_lock.sol b/examples/soroban/mint_lock.sol new file mode 100644 index 000000000..ebc4c66a9 --- /dev/null +++ b/examples/soroban/mint_lock.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +// Mapping of https://github.com/stellar/soroban-examples/tree/main/mint-lock +pragma solidity ^0.8.20; + +contract mint_lock { + struct MinterConfig { + int128 limit; + uint32 epoch_length; + } + + struct MinterStats { + int128 consumed_limit; + } + + struct MinterInfo { + MinterConfig config; + uint32 epoch; + MinterStats minter_stats; + } + + address instance admin; + + mapping(address => mapping(address => MinterConfig)) minters; + mapping(address => mapping(address => bool)) minter_exists; + + mapping(address => + mapping(address => + mapping(uint32 => + mapping(uint32 => MinterStats)))) stats; + + constructor(address admin_) { + admin = admin_; + } + + function set_admin(address new_admin) public { + admin.requireAuth(); + admin = new_admin; + } + + function get_admin() public view returns (address) { + return admin; + } + + function set_minter( + address contract_, + address minter_, + MinterConfig memory config + ) public { + admin.requireAuth(); + minters[contract_][minter_] = config; + minter_exists[contract_][minter_] = true; + } + + function minter(address contract_, address minter_) + public + view + returns (MinterInfo memory) + { + require(minter_exists[contract_][minter_], "not authorized minter"); + MinterConfig memory config = minters[contract_][minter_]; + uint32 epoch = uint32(block.number / uint64(config.epoch_length)); + MinterStats memory minter_stats = stats[contract_][minter_][config.epoch_length][epoch]; + return MinterInfo({config: config, epoch: epoch, minter_stats: minter_stats}); + } + + function mint( + address contract_, + address minter_, + address to, + int128 amount + ) public { + minter_.requireAuth(); + + require(amount >= 0, "negative amount"); + + if (admin != minter_) { + require(minter_exists[contract_][minter_], "not authorized minter"); + MinterConfig memory config = minters[contract_][minter_]; + + uint32 epoch = uint32(block.number / uint64(config.epoch_length)); + MinterStats memory minter_stats = stats[contract_][minter_][config.epoch_length][epoch]; + int128 new_consumed = minter_stats.consumed_limit + amount; + require(new_consumed <= config.limit, "daily limit insufficient"); + minter_stats.consumed_limit = new_consumed; + stats[contract_][minter_][config.epoch_length][epoch] = minter_stats; + } + + bytes memory payload = abi.encode("mint", to, amount); + (bool success, bytes memory returndata) = contract_.call(payload); + } +} diff --git a/src/codegen/expression.rs b/src/codegen/expression.rs index bea5a84fa..234ba40bc 100644 --- a/src/codegen/expression.rs +++ b/src/codegen/expression.rs @@ -17,8 +17,8 @@ use crate::codegen::targets::soroban::bytes::{ soroban_bytes_length, soroban_bytes_subscript_read, soroban_strings_length, }; use crate::codegen::targets::soroban::{ - soroban_storage_array_length_ast, soroban_storage_assign, soroban_storage_incdec, - soroban_storage_load, + soroban_address_compare, soroban_storage_array_length_ast, soroban_storage_assign, + soroban_storage_incdec, soroban_storage_load, }; use crate::codegen::unused_variable::should_remove_assignment; use crate::codegen::{Builtin, Expression}; @@ -393,52 +393,30 @@ pub fn expression( )), signed: *sign, }, - ast::Expression::Equal { loc, left, right } => Expression::Equal { - loc: *loc, - left: Box::new(expression( - left, - cfg, - contract_no, - func, - ns, - vartab, - opt, - target, - )), - right: Box::new(expression( - right, - cfg, - contract_no, - func, - ns, - vartab, - opt, - target, - )), - }, - ast::Expression::NotEqual { loc, left, right } => Expression::NotEqual { - loc: *loc, - left: Box::new(expression( - left, - cfg, - contract_no, - func, - ns, - vartab, - opt, - target, - )), - right: Box::new(expression( - right, - cfg, - contract_no, - func, - ns, - vartab, - opt, - target, - )), - }, + ast::Expression::Equal { loc, left, right } => { + let l = expression(left, cfg, contract_no, func, ns, vartab, opt, target); + let r = expression(right, cfg, contract_no, func, ns, vartab, opt, target); + if ns.target == Target::Soroban && l.ty().is_address() { + return soroban_address_compare(loc, l, r, true, cfg, vartab); + } + Expression::Equal { + loc: *loc, + left: Box::new(l), + right: Box::new(r), + } + } + ast::Expression::NotEqual { loc, left, right } => { + let l = expression(left, cfg, contract_no, func, ns, vartab, opt, target); + let r = expression(right, cfg, contract_no, func, ns, vartab, opt, target); + if ns.target == Target::Soroban && l.ty().is_address() { + return soroban_address_compare(loc, l, r, false, cfg, vartab); + } + Expression::NotEqual { + loc: *loc, + left: Box::new(l), + right: Box::new(r), + } + } ast::Expression::More { loc, left, right } => { let l = expression(left, cfg, contract_no, func, ns, vartab, opt, target); let r = expression(right, cfg, contract_no, func, ns, vartab, opt, target); diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index b60671292..4e46b0419 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -158,6 +158,7 @@ pub enum HostFunctions { ContractEvent, SerializeToBytes, DeserializeFromBytes, + ObjCmp, } impl HostFunctions { @@ -225,6 +226,7 @@ impl HostFunctions { HostFunctions::ContractEvent => "x.1", HostFunctions::SerializeToBytes => "b._", HostFunctions::DeserializeFromBytes => "b.0", + HostFunctions::ObjCmp => "x.0", HostFunctions::VecLen => "v.3", HostFunctions::VecPopBack => "v.7", HostFunctions::VecGet => "v.1", diff --git a/src/codegen/targets/soroban/mod.rs b/src/codegen/targets/soroban/mod.rs index 13b6e09bd..6023c0d5d 100644 --- a/src/codegen/targets/soroban/mod.rs +++ b/src/codegen/targets/soroban/mod.rs @@ -1389,6 +1389,45 @@ pub(crate) fn soroban_host_call( } } +pub(crate) fn soroban_address_compare( + loc: &pt::Loc, + left: Expression, + right: Expression, + equal: bool, + cfg: &mut ControlFlowGraph, + vartab: &mut Vartable, +) -> Expression { + let ordering = soroban_host_call( + loc, + "obj_cmp", + HostFunctions::ObjCmp, + &Type::Int(64), + vec![left, right], + cfg, + vartab, + ); + + let zero = Expression::NumberLiteral { + loc: *loc, + ty: Type::Int(64), + value: BigInt::zero(), + }; + + if equal { + Expression::Equal { + loc: *loc, + left: Box::new(ordering), + right: Box::new(zero), + } + } else { + Expression::NotEqual { + loc: *loc, + left: Box::new(ordering), + right: Box::new(zero), + } + } +} + pub(super) fn validate_accessor_abi_types(contract_no: usize, ns: &mut Namespace) { for variable in &ns.contracts[contract_no].variables { if !matches!(variable.visibility, pt::Visibility::Public(_)) { diff --git a/src/emit/soroban/mod.rs b/src/emit/soroban/mod.rs index ea70ad16a..67ede1e7a 100644 --- a/src/emit/soroban/mod.rs +++ b/src/emit/soroban/mod.rs @@ -209,6 +209,10 @@ impl HostFunctions { HostFunctions::DeserializeFromBytes => { bin.context.i64_type().fn_type(&[ty.into()], false) } + HostFunctions::ObjCmp => bin + .context + .i64_type() + .fn_type(&[ty.into(), ty.into()], false), } } } @@ -582,6 +586,7 @@ impl SorobanTarget { HostFunctions::ComputeHashKeccak256, HostFunctions::SerializeToBytes, HostFunctions::DeserializeFromBytes, + HostFunctions::ObjCmp, ]; for func in &host_functions { diff --git a/tests/soroban_testcases/address_compare.rs b/tests/soroban_testcases/address_compare.rs new file mode 100644 index 000000000..fd13ea8ef --- /dev/null +++ b/tests/soroban_testcases/address_compare.rs @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 + +use crate::{build_solidity, SorobanEnv}; +use soroban_sdk::{testutils::Address as _, Address, FromVal, IntoVal}; + +const SETTER_SRC: &str = r#"contract addr_cmp { + address instance admin; + + function set_admin(address a) public { + admin = a; + } + + function is_admin(address who) public view returns (bool) { + return admin == who; + } + + function not_admin(address who) public view returns (bool) { + return admin != who; + } + + function same(address a, address b) public pure returns (bool) { + return a == b; + } +}"#; + +#[test] +fn address_equal_storage_vs_arg() { + let runtime = build_solidity(SETTER_SRC, |_| {}); + let admin = Address::generate(&runtime.env); + let other = Address::generate(&runtime.env); + let addr = runtime.contracts.first().unwrap().clone(); + + runtime.invoke_contract( + &addr, + "set_admin", + vec![admin.clone().into_val(&runtime.env)], + ); + + let res = runtime.invoke_contract( + &addr, + "is_admin", + vec![admin.clone().into_val(&runtime.env)], + ); + assert!(bool::from_val(&runtime.env, &res)); + + let res = runtime.invoke_contract( + &addr, + "is_admin", + vec![other.clone().into_val(&runtime.env)], + ); + assert!(!bool::from_val(&runtime.env, &res)); +} + +#[test] +fn address_not_equal_storage_vs_arg() { + let runtime = build_solidity(SETTER_SRC, |_| {}); + let admin = Address::generate(&runtime.env); + let other = Address::generate(&runtime.env); + let addr = runtime.contracts.first().unwrap().clone(); + + runtime.invoke_contract( + &addr, + "set_admin", + vec![admin.clone().into_val(&runtime.env)], + ); + + let res = runtime.invoke_contract( + &addr, + "not_admin", + vec![admin.clone().into_val(&runtime.env)], + ); + assert!(!bool::from_val(&runtime.env, &res)); + + let res = runtime.invoke_contract( + &addr, + "not_admin", + vec![other.clone().into_val(&runtime.env)], + ); + assert!(bool::from_val(&runtime.env, &res)); +} + +#[test] +fn address_equal_arg_vs_arg() { + let runtime = build_solidity(SETTER_SRC, |_| {}); + let a = Address::generate(&runtime.env); + let b = Address::generate(&runtime.env); + let addr = runtime.contracts.first().unwrap().clone(); + + let res = runtime.invoke_contract( + &addr, + "same", + vec![ + a.clone().into_val(&runtime.env), + a.clone().into_val(&runtime.env), + ], + ); + assert!(bool::from_val(&runtime.env, &res)); + + let res = runtime.invoke_contract( + &addr, + "same", + vec![ + a.clone().into_val(&runtime.env), + b.clone().into_val(&runtime.env), + ], + ); + assert!(!bool::from_val(&runtime.env, &res)); +} + +const CONSTRUCTOR_SRC: &str = r#"contract addr_cmp_ctor { + address instance admin; + + constructor(address admin_) { + admin = admin_; + } + + function is_admin(address who) public view returns (bool) { + return admin == who; + } +}"#; + +#[test] +fn address_equal_constructor_stored() { + let mut runtime = SorobanEnv::new(); + let admin = Address::generate(&runtime.env); + let other = Address::generate(&runtime.env); + + let addr = runtime.deploy_contract_with_args(CONSTRUCTOR_SRC, (admin.clone(),)); + + let res = runtime.invoke_contract( + &addr, + "is_admin", + vec![admin.clone().into_val(&runtime.env)], + ); + assert!(bool::from_val(&runtime.env, &res)); + + let res = runtime.invoke_contract( + &addr, + "is_admin", + vec![other.clone().into_val(&runtime.env)], + ); + assert!(!bool::from_val(&runtime.env, &res)); +} diff --git a/tests/soroban_testcases/example_mint_lock.rs b/tests/soroban_testcases/example_mint_lock.rs new file mode 100644 index 000000000..e47a3bbaf --- /dev/null +++ b/tests/soroban_testcases/example_mint_lock.rs @@ -0,0 +1,436 @@ +// SPDX-License-Identifier: Apache-2.0 + +use crate::SorobanEnv; +use soroban_sdk::{ + contracttype, testutils::Address as _, testutils::Ledger, Address, FromVal, IntoVal, Val, +}; + +const MINT_LOCK_SRC: &str = r#" +contract mint_lock { + struct MinterConfig { + int128 limit; + uint32 epoch_length; + } + + struct MinterStats { + int128 consumed_limit; + } + + struct MinterInfo { + MinterConfig config; + uint32 epoch; + MinterStats minter_stats; + } + + address instance admin; + + mapping(address => mapping(address => MinterConfig)) minters; + mapping(address => mapping(address => bool)) minter_exists; + + mapping(address => + mapping(address => + mapping(uint32 => + mapping(uint32 => MinterStats)))) stats; + + constructor(address admin_) { + admin = admin_; + } + + function set_admin(address new_admin) public { + admin.requireAuth(); + admin = new_admin; + } + + function get_admin() public view returns (address) { + return admin; + } + + function set_minter( + address contract_, + address minter_, + MinterConfig memory config + ) public { + admin.requireAuth(); + minters[contract_][minter_] = config; + minter_exists[contract_][minter_] = true; + } + + function minter(address contract_, address minter_) + public + view + returns (MinterInfo memory) + { + require(minter_exists[contract_][minter_], "not authorized minter"); + MinterConfig memory config = minters[contract_][minter_]; + uint32 epoch = uint32(block.number / uint64(config.epoch_length)); + MinterStats memory minter_stats = stats[contract_][minter_][config.epoch_length][epoch]; + return MinterInfo({config: config, epoch: epoch, minter_stats: minter_stats}); + } + + function mint( + address contract_, + address minter_, + address to, + int128 amount + ) public { + minter_.requireAuth(); + + require(amount >= 0, "negative amount"); + + // The admin can always mint; everyone else is rate-limited per epoch. + if (admin != minter_) { + require(minter_exists[contract_][minter_], "not authorized minter"); + MinterConfig memory config = minters[contract_][minter_]; + + uint32 epoch = uint32(block.number / uint64(config.epoch_length)); + MinterStats memory minter_stats = stats[contract_][minter_][config.epoch_length][epoch]; + int128 new_consumed = minter_stats.consumed_limit + amount; + require(new_consumed <= config.limit, "daily limit insufficient"); + minter_stats.consumed_limit = new_consumed; + stats[contract_][minter_][config.epoch_length][epoch] = minter_stats; + } + + // Dispatch the actual mint into the wrapped token contract. + bytes memory payload = abi.encode("mint", to, amount); + (bool success, bytes memory returndata) = contract_.call(payload); + } +} +"#; + +const MINT_TARGET_SRC: &str = r#" +contract mint_target { + mapping(address => int128) balances; + + function mint(address to, int128 amount) public { + balances[to] = balances[to] + amount; + } + + function balance_of(address who) public view returns (int128) { + return balances[who]; + } +} +"#; + +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MinterConfig { + pub limit: i128, + pub epoch_length: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MinterStats { + pub consumed_limit: i128, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MinterInfo { + pub config: MinterConfig, + pub epoch: u32, + pub minter_stats: MinterStats, +} + +const EPOCH_LENGTH: u32 = 10_000; +const LEDGER_EPOCH: u32 = 150; +const MID_EPOCH_SEQUENCE: u32 = EPOCH_LENGTH * LEDGER_EPOCH + EPOCH_LENGTH / 2; + +fn deploy_lock(runtime: &mut SorobanEnv, admin: &Address) -> Address { + runtime.deploy_contract_with_args(MINT_LOCK_SRC, (admin.clone(),)) +} + +fn set_minter( + runtime: &SorobanEnv, + lock: &Address, + token: &Address, + minter: &Address, + config: &MinterConfig, +) { + runtime.invoke_contract( + lock, + "set_minter", + vec![ + token.clone().into_val(&runtime.env), + minter.clone().into_val(&runtime.env), + config.clone().into_val(&runtime.env), + ], + ); +} + +fn mint_args( + runtime: &SorobanEnv, + token: &Address, + minter: &Address, + to: &Address, + amount: i128, +) -> Vec { + vec![ + token.clone().into_val(&runtime.env), + minter.clone().into_val(&runtime.env), + to.clone().into_val(&runtime.env), + amount.into_val(&runtime.env), + ] +} + +fn mint( + runtime: &SorobanEnv, + lock: &Address, + token: &Address, + minter: &Address, + to: &Address, + amount: i128, +) { + runtime.invoke_contract(lock, "mint", mint_args(runtime, token, minter, to, amount)); +} + +fn try_mint( + runtime: &SorobanEnv, + lock: &Address, + token: &Address, + minter: &Address, + to: &Address, + amount: i128, +) -> Vec { + runtime.invoke_contract_expect_error( + lock, + "mint", + mint_args(runtime, token, minter, to, amount), + ) +} + +fn balance_of(runtime: &SorobanEnv, token: &Address, who: &Address) -> i128 { + let val = runtime.invoke_contract( + token, + "balance_of", + vec![who.clone().into_val(&runtime.env)], + ); + i128::from_val(&runtime.env, &val) +} + +fn minter_info( + runtime: &SorobanEnv, + lock: &Address, + token: &Address, + minter: &Address, +) -> MinterInfo { + MinterInfo::from_val( + &runtime.env, + &runtime.invoke_contract( + lock, + "minter", + vec![ + token.clone().into_val(&runtime.env), + minter.clone().into_val(&runtime.env), + ], + ), + ) +} + +#[test] +fn mint_lock_admin_accessor() { + let mut runtime = SorobanEnv::new(); + let admin = Address::generate(&runtime.env); + let addr = deploy_lock(&mut runtime, &admin); + + let got = Address::from_val( + &runtime.env, + &runtime.invoke_contract(&addr, "get_admin", vec![]), + ); + assert_eq!(got, admin); +} + +#[test] +fn mint_lock_set_admin_rotates() { + let mut runtime = SorobanEnv::new(); + let admin = Address::generate(&runtime.env); + let new_admin = Address::generate(&runtime.env); + let addr = deploy_lock(&mut runtime, &admin); + + runtime.env.mock_all_auths(); + runtime.invoke_contract( + &addr, + "set_admin", + vec![new_admin.clone().into_val(&runtime.env)], + ); + + let got = Address::from_val( + &runtime.env, + &runtime.invoke_contract(&addr, "get_admin", vec![]), + ); + assert_eq!(got, new_admin); +} + +#[test] +fn mint_lock_admin_can_always_mint() { + let mut runtime = SorobanEnv::new(); + let admin = Address::generate(&runtime.env); + let user = Address::generate(&runtime.env); + + let token = runtime.deploy_contract(MINT_TARGET_SRC); + let lock = deploy_lock(&mut runtime, &admin); + + runtime.env.mock_all_auths(); + + mint(&runtime, &lock, &token, &admin, &user, 123); + assert_eq!(balance_of(&runtime, &token, &user), 123); +} + +#[test] +fn mint_lock_authorized_minter_mints_and_tracks() { + let mut runtime = SorobanEnv::new(); + let admin = Address::generate(&runtime.env); + let minter = Address::generate(&runtime.env); + let user = Address::generate(&runtime.env); + + let token = runtime.deploy_contract(MINT_TARGET_SRC); + let lock = deploy_lock(&mut runtime, &admin); + + runtime.env.mock_all_auths(); + runtime.env.ledger().set_sequence_number(MID_EPOCH_SEQUENCE); + + let config = MinterConfig { + limit: 100, + epoch_length: EPOCH_LENGTH, + }; + set_minter(&runtime, &lock, &token, &minter, &config); + + mint(&runtime, &lock, &token, &minter, &user, 97); + assert_eq!(balance_of(&runtime, &token, &user), 97); + + let info = minter_info(&runtime, &lock, &token, &minter); + assert_eq!(info.config, config); + assert_eq!(info.epoch, LEDGER_EPOCH); + assert_eq!(info.minter_stats.consumed_limit, 97); +} + +#[test] +fn mint_lock_enforces_daily_limit() { + let mut runtime = SorobanEnv::new(); + let admin = Address::generate(&runtime.env); + let minter = Address::generate(&runtime.env); + let user = Address::generate(&runtime.env); + + let token = runtime.deploy_contract(MINT_TARGET_SRC); + let lock = deploy_lock(&mut runtime, &admin); + + runtime.env.mock_all_auths(); + runtime.env.ledger().set_sequence_number(MID_EPOCH_SEQUENCE); + + set_minter( + &runtime, + &lock, + &token, + &minter, + &MinterConfig { + limit: 100, + epoch_length: EPOCH_LENGTH, + }, + ); + + // Spend 60, then 40, exactly exhausting the limit of 100. + mint(&runtime, &lock, &token, &minter, &user, 60); + mint(&runtime, &lock, &token, &minter, &user, 40); + assert_eq!(balance_of(&runtime, &token, &user), 100); + assert_eq!( + minter_info(&runtime, &lock, &token, &minter) + .minter_stats + .consumed_limit, + 100 + ); + + // One more unit would exceed the limit — rejected, and nothing changes. + let logs = try_mint(&runtime, &lock, &token, &minter, &user, 1); + assert!(logs.iter().any(|e| e.contains("require condition failed"))); + assert_eq!(balance_of(&runtime, &token, &user), 100); + assert_eq!( + minter_info(&runtime, &lock, &token, &minter) + .minter_stats + .consumed_limit, + 100 + ); +} + +#[test] +fn mint_lock_rejects_unauthorized_minter() { + let mut runtime = SorobanEnv::new(); + let admin = Address::generate(&runtime.env); + let stranger = Address::generate(&runtime.env); + let user = Address::generate(&runtime.env); + + let token = runtime.deploy_contract(MINT_TARGET_SRC); + let lock = deploy_lock(&mut runtime, &admin); + + runtime.env.mock_all_auths(); + + let logs = try_mint(&runtime, &lock, &token, &stranger, &user, 1); + assert!(logs.iter().any(|e| e.contains("require condition failed"))); + assert_eq!(balance_of(&runtime, &token, &user), 0); +} + +#[test] +fn mint_lock_disallows_negative_amount_from_admin() { + let mut runtime = SorobanEnv::new(); + let admin = Address::generate(&runtime.env); + let user = Address::generate(&runtime.env); + + let token = runtime.deploy_contract(MINT_TARGET_SRC); + let lock = deploy_lock(&mut runtime, &admin); + + runtime.env.mock_all_auths(); + + let logs = try_mint(&runtime, &lock, &token, &admin, &user, -123); + assert!(logs.iter().any(|e| e.contains("require condition failed"))); + assert_eq!(balance_of(&runtime, &token, &user), 0); +} + +#[test] +fn mint_lock_disallows_negative_amount_from_minter() { + let mut runtime = SorobanEnv::new(); + let admin = Address::generate(&runtime.env); + let minter = Address::generate(&runtime.env); + let user = Address::generate(&runtime.env); + + let token = runtime.deploy_contract(MINT_TARGET_SRC); + let lock = deploy_lock(&mut runtime, &admin); + + runtime.env.mock_all_auths(); + runtime.env.ledger().set_sequence_number(MID_EPOCH_SEQUENCE); + + let config = MinterConfig { + limit: 100, + epoch_length: EPOCH_LENGTH, + }; + set_minter(&runtime, &lock, &token, &minter, &config); + + let logs = try_mint(&runtime, &lock, &token, &minter, &user, -1000); + assert!(logs.iter().any(|e| e.contains("require condition failed"))); + assert_eq!(balance_of(&runtime, &token, &user), 0); + + // The rejected mint left the epoch's stats at zero. + let info = minter_info(&runtime, &lock, &token, &minter); + assert_eq!(info.config, config); + assert_eq!(info.epoch, LEDGER_EPOCH); + assert_eq!(info.minter_stats.consumed_limit, 0); +} + +#[test] +fn mint_lock_set_and_read_minter() { + let mut runtime = SorobanEnv::new(); + let admin = Address::generate(&runtime.env); + let token = Address::generate(&runtime.env); + let minter = Address::generate(&runtime.env); + let lock = deploy_lock(&mut runtime, &admin); + + runtime.env.mock_all_auths(); + + let config = MinterConfig { + limit: 1000, + epoch_length: 100, + }; + set_minter(&runtime, &lock, &token, &minter, &config); + + let info = minter_info(&runtime, &lock, &token, &minter); + assert_eq!(info.config, config); + assert_eq!(info.minter_stats.consumed_limit, 0); +} diff --git a/tests/soroban_testcases/mod.rs b/tests/soroban_testcases/mod.rs index 9659023ad..6a938cadb 100644 --- a/tests/soroban_testcases/mod.rs +++ b/tests/soroban_testcases/mod.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 mod abi_encode; +mod address_compare; mod alloc; mod array_args; mod array_bytes_strings; @@ -28,6 +29,7 @@ mod example_events; mod example_hello_world; mod example_increment_with_pause; mod example_merkle_distribution; +mod example_mint_lock; mod example_other_custom_types; mod example_pause; mod example_single_offer; From 4d6ae2516d411a1255d79982edd0e8ccf42b3064 Mon Sep 17 00:00:00 2001 From: Islam-Imad Date: Thu, 3 Sep 2026 11:30:36 +0300 Subject: [PATCH 2/2] support require-auth-for-args Signed-off-by: Islam-Imad --- docs/examples/soroban/mint_lock.sol | 4 +- docs/targets/soroban_examples_coverage.rst | 6 +- docs/targets/soroban_support_matrix.rst | 2 +- examples/soroban/mint_lock.sol | 4 +- src/codegen/mod.rs | 2 + src/codegen/targets/soroban/mod.rs | 71 ++++++++++++ src/emit/soroban/mod.rs | 5 + src/sema/ast.rs | 1 + src/sema/builtin.rs | 28 +++++ tests/soroban_testcases/example_mint_lock.rs | 107 ++++++++++++++++++- 10 files changed, 222 insertions(+), 8 deletions(-) diff --git a/docs/examples/soroban/mint_lock.sol b/docs/examples/soroban/mint_lock.sol index ebc4c66a9..628217ec7 100644 --- a/docs/examples/soroban/mint_lock.sol +++ b/docs/examples/soroban/mint_lock.sol @@ -69,10 +69,11 @@ contract mint_lock { address to, int128 amount ) public { - minter_.requireAuth(); + minter_.requireAuthForArgs(contract_, to, amount); require(amount >= 0, "negative amount"); + // The admin can always mint; everyone else is rate-limited per epoch. if (admin != minter_) { require(minter_exists[contract_][minter_], "not authorized minter"); MinterConfig memory config = minters[contract_][minter_]; @@ -85,6 +86,7 @@ contract mint_lock { stats[contract_][minter_][config.epoch_length][epoch] = minter_stats; } + // Dispatch the actual mint into the wrapped token contract. bytes memory payload = abi.encode("mint", to, amount); (bool success, bytes memory returndata) = contract_.call(payload); } diff --git a/docs/targets/soroban_examples_coverage.rst b/docs/targets/soroban_examples_coverage.rst index ecf99c694..ecaf986c8 100644 --- a/docs/targets/soroban_examples_coverage.rst +++ b/docs/targets/soroban_examples_coverage.rst @@ -63,7 +63,7 @@ Documented Counterparts - Merkle-proof airdrop: recipients claim tokens by supplying a Merkle proof against a stored root, and verified claims trigger a cross-contract token transfer. Demonstrates ``sha256`` hashing, the ``to_xdr`` builtin for canonical XDR leaf serialization, ``bytes.concat`` for a real 64-byte inner-node preimage, a ``bytes32[]`` proof parameter, a ``mapping(uint32 => bool)`` claimed-set, and cross-contract ``call`` via ``abi.encode``. The leaf preimage is ``sha256(to_xdr(Receiver{index, recipient, amount}))``, mirroring upstream's ``sha256(Receiver.to_xdr())``; it differs only in the field name ``recipient`` (Solidity reserves ``address``), which changes the struct's ScMap keys, so off-chain tree builders must serialize with this contract's exact struct. Tested via ``merkle_distribution_*`` test cases. * - `mint-lock `_ - `docs/examples/soroban/mint_lock.sol `_ and `tests/soroban_testcases/example_mint_lock.rs `_ - - Admin-controlled minting proxy: an admin authorizes per-contract minters, each with a per-epoch limit, and every mint is dispatched as a cross-contract ``call`` into the wrapped token. Demonstrates deeply nested mappings of structs (``minters`` and the four-level ``stats``), ``block.number`` as the epoch source, struct storage and struct ABI return (``MinterInfo``), ``requireAuth()`` on both admin and minter, address ``==`` comparison, and ``require``-based guards. The per-epoch ``stats`` use ``persistent`` rather than upstream's ``temporary`` storage: a temporary entry would be evicted once the ledger sequence advances past its (un-extended) TTL, which the tests trigger by jumping to mid-epoch. Tested via ``mint_lock_*`` test cases. + - Admin-controlled minting proxy: an admin authorizes per-contract minters, each with a per-epoch limit, and every mint is dispatched as a cross-contract ``call`` into the wrapped token. Demonstrates deeply nested mappings of structs (``minters`` and the four-level ``stats``), ``block.number`` as the epoch source, struct storage and struct ABI return (``MinterInfo``), ``requireAuth()`` on the admin and ``requireAuthForArgs(contract_, to, amount)`` scoping the minter's authorization to the exact arguments it signs (mirroring upstream's ``require_auth_for_args``), address ``==`` comparison, and ``require``-based guards. The per-epoch ``stats`` use ``persistent`` rather than upstream's ``temporary`` storage: a temporary entry would be evicted once the ledger sequence advances past its (un-extended) TTL, which the tests trigger by jumping to mid-epoch. Tested via ``mint_lock_*`` test cases. * - `timelock `_ - `docs/examples/soroban/timelock `_ - Timelock-style example using enums, mappings, authorization, and ``block.timestamp``. @@ -657,7 +657,7 @@ Upstream Soroban example: `mint-lock `_ -An admin-controlled minting proxy. The admin authorizes minters against a wrapped token, each with a per-epoch limit; the admin can always mint, an authorized minter can mint until it exhausts the epoch's limit, and anyone else is rejected. The upstream composite storage keys become nested mappings (``minters[contract_][minter_]`` and the four-level ``stats[contract_][minter_][epoch_length][epoch]``), the epoch is derived from ``block.number`` (Soroban's ledger sequence), and each mint is dispatched with ``contract_.call(abi.encode("mint", to, amount))``. The per-epoch ``stats`` are declared ``persistent`` rather than ``temporary``: a temporary entry would be evicted once the ledger sequence advances past its un-extended TTL, and the tests jump the sequence to mid-epoch. +An admin-controlled minting proxy. The admin authorizes minters against a wrapped token, each with a per-epoch limit; the admin can always mint, an authorized minter can mint until it exhausts the epoch's limit, and anyone else is rejected. The upstream composite storage keys become nested mappings (``minters[contract_][minter_]`` and the four-level ``stats[contract_][minter_][epoch_length][epoch]``), the epoch is derived from ``block.number`` (Soroban's ledger sequence), and each mint is dispatched with ``contract_.call(abi.encode("mint", to, amount))``. The minter authorizes the mint with ``requireAuthForArgs(contract_, to, amount)`` — the ``require_auth_for_args`` mapping that scopes the signed authorization to those exact arguments instead of the full call argument list that ``requireAuth()`` would demand — while ``set_admin``/``set_minter`` gate on the admin's ``requireAuth()``. The per-epoch ``stats`` are declared ``persistent`` rather than ``temporary``: a temporary entry would be evicted once the ledger sequence advances past its un-extended TTL, and the tests jump the sequence to mid-epoch. .. code-block:: solidity @@ -728,7 +728,7 @@ An admin-controlled minting proxy. The admin authorizes minters against a wrappe address to, int128 amount ) public { - minter_.requireAuth(); + minter_.requireAuthForArgs(contract_, to, amount); require(amount >= 0, "negative amount"); diff --git a/docs/targets/soroban_support_matrix.rst b/docs/targets/soroban_support_matrix.rst index 4ab67345d..d1d45a3d4 100644 --- a/docs/targets/soroban_support_matrix.rst +++ b/docs/targets/soroban_support_matrix.rst @@ -67,7 +67,7 @@ Soroban Features - Details and examples * - Authorization - Supported - - ``address.requireAuth()`` and ``auth.authAsCurrContract(...)``. Examples: `auth.sol `_, `token.sol `_, `timelock.sol `_, and `deep_auth `_. + - ``address.requireAuth()``, ``address.requireAuthForArgs(...)`` (scopes the authorization to an explicit argument list, mapping to the host's ``require_auth_for_args``), and ``auth.authAsCurrContract(...)``. Examples: `auth.sol `_, `token.sol `_, `timelock.sol `_, `mint_lock.sol `_, and `deep_auth `_. * - Cross-contract calls - Supported - ``address.call(...)`` and the documented ABI encode/decode flows around it. Examples: `deep_auth `_ and `cross_contract.spec.js `_. diff --git a/examples/soroban/mint_lock.sol b/examples/soroban/mint_lock.sol index ebc4c66a9..628217ec7 100644 --- a/examples/soroban/mint_lock.sol +++ b/examples/soroban/mint_lock.sol @@ -69,10 +69,11 @@ contract mint_lock { address to, int128 amount ) public { - minter_.requireAuth(); + minter_.requireAuthForArgs(contract_, to, amount); require(amount >= 0, "negative amount"); + // The admin can always mint; everyone else is rate-limited per epoch. if (admin != minter_) { require(minter_exists[contract_][minter_], "not authorized minter"); MinterConfig memory config = minters[contract_][minter_]; @@ -85,6 +86,7 @@ contract mint_lock { stats[contract_][minter_][config.epoch_length][epoch] = minter_stats; } + // Dispatch the actual mint into the wrapped token contract. bytes memory payload = abi.encode("mint", to, amount); (bool success, bytes memory returndata) = contract_.call(payload); } diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index 4e46b0419..a2275e909 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -130,6 +130,7 @@ pub enum HostFunctions { ObjToI256HiHi, ObjFromI256Pieces, RequireAuth, + RequireAuthForArgs, AuthAsCurrContract, UpdateCurrentContractWasm, MapNew, @@ -200,6 +201,7 @@ impl HostFunctions { HostFunctions::ObjToI256HiHi => "i.j", HostFunctions::ObjFromI256Pieces => "i.g", HostFunctions::RequireAuth => "a.0", + HostFunctions::RequireAuthForArgs => "a._", HostFunctions::AuthAsCurrContract => "a.3", HostFunctions::UpdateCurrentContractWasm => "l.6", HostFunctions::MapNewFromLinearMemory => "m.9", diff --git a/src/codegen/targets/soroban/mod.rs b/src/codegen/targets/soroban/mod.rs index 6023c0d5d..269100095 100644 --- a/src/codegen/targets/soroban/mod.rs +++ b/src/codegen/targets/soroban/mod.rs @@ -466,6 +466,77 @@ impl TargetCodegen for SorobanTarget { Some(var) } + + ast::Builtin::RequireAuthForArgs => { + // args[0] is the address; handle a storage address like requireAuth. + let addr = if let Type::StorageRef(_, _) = args[0].ty() { + soroban_storage_load( + loc, + &args[0], + &Type::Address(false), + cfg, + contract_no, + func, + ns, + vartab, + opt, + self, + ) + } else { + expression(&args[0], cfg, contract_no, func, ns, vartab, opt, self) + }; + + let mut args_vec = soroban_host_call( + loc, + "auth_args_vec", + HostFunctions::VectorNew, + &Type::Uint(64), + vec![], + cfg, + vartab, + ); + + for arg in args.iter().skip(1) { + let arg = expression(arg, cfg, contract_no, func, ns, vartab, opt, self); + let encoded = soroban_encode_arg(arg, cfg, vartab, ns); + args_vec = soroban_host_call( + loc, + "auth_args_vec", + HostFunctions::VecPushBack, + &Type::Uint(64), + vec![args_vec, encoded], + cfg, + vartab, + ); + } + + let var_temp = vartab.temp( + &pt::Identifier { + name: "auth".to_owned(), + loc: *loc, + }, + &Type::Bool, + ); + let var = Expression::Variable { + loc: *loc, + ty: Type::Address(false), + var_no: var_temp, + }; + + cfg.add( + vartab, + Instr::Call { + res: vec![var_temp], + return_tys: vec![Type::Void], + call: InternalCallTy::HostFunction { + name: HostFunctions::RequireAuthForArgs.name().to_string(), + }, + args: vec![addr, args_vec], + }, + ); + + Some(var) + } // This is the trickiest host function to implement. The reason is takes `InvokerContractAuthEntry` enum as an argument. // let x = SubContractInvocation { // context: ContractContext { diff --git a/src/emit/soroban/mod.rs b/src/emit/soroban/mod.rs index 67ede1e7a..22eab8e0e 100644 --- a/src/emit/soroban/mod.rs +++ b/src/emit/soroban/mod.rs @@ -94,6 +94,10 @@ impl HostFunctions { HostFunctions::ObjToI64 => bin.context.i64_type().fn_type(&[ty.into()], false), HostFunctions::ObjFromI64 => bin.context.i64_type().fn_type(&[ty.into()], false), HostFunctions::RequireAuth => bin.context.i64_type().fn_type(&[ty.into()], false), + HostFunctions::RequireAuthForArgs => bin + .context + .i64_type() + .fn_type(&[ty.into(), ty.into()], false), HostFunctions::AuthAsCurrContract => { bin.context.i64_type().fn_type(&[ty.into()], false) } @@ -554,6 +558,7 @@ impl SorobanTarget { HostFunctions::ObjToI256HiHi, HostFunctions::ObjFromI256Pieces, HostFunctions::RequireAuth, + HostFunctions::RequireAuthForArgs, HostFunctions::AuthAsCurrContract, HostFunctions::UpdateCurrentContractWasm, HostFunctions::MapNewFromLinearMemory, diff --git a/src/sema/ast.rs b/src/sema/ast.rs index 278990f2c..85fdd7361 100644 --- a/src/sema/ast.rs +++ b/src/sema/ast.rs @@ -1839,6 +1839,7 @@ pub enum Builtin { TypeRuntimeCode, TypeCreatorCode, RequireAuth, + RequireAuthForArgs, AuthAsCurrContract, UpdateCurrentContractWasm, ExtendTtl, diff --git a/src/sema/builtin.rs b/src/sema/builtin.rs index b713e79e6..52847b532 100644 --- a/src/sema/builtin.rs +++ b/src/sema/builtin.rs @@ -1500,6 +1500,34 @@ pub(super) fn resolve_method_call( ) -> Result, ()> { let expr_ty = expr.ty(); let deref_ty = expr_ty.deref_memory(); + + if id.name == "requireAuthForArgs" && ns.target == Target::Soroban { + let is_address = matches!(deref_ty, Type::Address(_)) + || matches!(&expr_ty, Type::StorageRef(_, inner) if matches!(**inner, Type::Address(_))); + + if is_address { + let mut resolved_args = vec![expr.cast(&id.loc, deref_ty, true, ns, diagnostics)?]; + + for arg in args { + resolved_args.push(expression( + arg, + context, + ns, + symtable, + diagnostics, + ResolveTo::Unknown, + )?); + } + + return Ok(Some(Expression::Builtin { + loc: id.loc, + tys: vec![Type::Void], + kind: Builtin::RequireAuthForArgs, + args: resolved_args, + })); + } + } + let funcs: Vec<_> = BUILTIN_METHODS .iter() .filter(|func| func.name == id.name && func.method.contains(deref_ty)) diff --git a/tests/soroban_testcases/example_mint_lock.rs b/tests/soroban_testcases/example_mint_lock.rs index e47a3bbaf..a96882d91 100644 --- a/tests/soroban_testcases/example_mint_lock.rs +++ b/tests/soroban_testcases/example_mint_lock.rs @@ -2,7 +2,9 @@ use crate::SorobanEnv; use soroban_sdk::{ - contracttype, testutils::Address as _, testutils::Ledger, Address, FromVal, IntoVal, Val, + contracttype, + testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke}, + Address, FromVal, IntoVal, Val, }; const MINT_LOCK_SRC: &str = r#" @@ -73,7 +75,7 @@ contract mint_lock { address to, int128 amount ) public { - minter_.requireAuth(); + minter_.requireAuthForArgs(contract_, to, amount); require(amount >= 0, "negative amount"); @@ -414,6 +416,107 @@ fn mint_lock_disallows_negative_amount_from_minter() { assert_eq!(info.minter_stats.consumed_limit, 0); } +#[test] +fn mint_lock_require_auth_for_args_scopes_args() { + let mut runtime = SorobanEnv::new(); + let admin = Address::generate(&runtime.env); + let minter = Address::generate(&runtime.env); + let user = Address::generate(&runtime.env); + + let token = runtime.deploy_contract(MINT_TARGET_SRC); + let lock = deploy_lock(&mut runtime, &admin); + + runtime.env.ledger().set_sequence_number(MID_EPOCH_SEQUENCE); + + let config = MinterConfig { + limit: 100, + epoch_length: EPOCH_LENGTH, + }; + runtime.env.mock_auths(&[MockAuth { + address: &admin, + invoke: &MockAuthInvoke { + contract: &lock, + fn_name: "set_minter", + args: (token.clone(), minter.clone(), config.clone()).into_val(&runtime.env), + sub_invokes: &[], + }, + }]); + set_minter(&runtime, &lock, &token, &minter, &config); + + runtime.env.mock_auths(&[MockAuth { + address: &minter, + invoke: &MockAuthInvoke { + contract: &lock, + fn_name: "mint", + args: (token.clone(), user.clone(), 40i128).into_val(&runtime.env), + sub_invokes: &[], + }, + }]); + mint(&runtime, &lock, &token, &minter, &user, 40); + + assert_eq!(balance_of(&runtime, &token, &user), 40); + assert_eq!( + minter_info(&runtime, &lock, &token, &minter) + .minter_stats + .consumed_limit, + 40 + ); +} + +#[test] +fn mint_lock_require_auth_for_args_rejects_mismatched_args() { + let mut runtime = SorobanEnv::new(); + let admin = Address::generate(&runtime.env); + let minter = Address::generate(&runtime.env); + let user = Address::generate(&runtime.env); + + let token = runtime.deploy_contract(MINT_TARGET_SRC); + let lock = deploy_lock(&mut runtime, &admin); + + runtime.env.ledger().set_sequence_number(MID_EPOCH_SEQUENCE); + + let config = MinterConfig { + limit: 100, + epoch_length: EPOCH_LENGTH, + }; + runtime.env.mock_auths(&[MockAuth { + address: &admin, + invoke: &MockAuthInvoke { + contract: &lock, + fn_name: "set_minter", + args: (token.clone(), minter.clone(), config.clone()).into_val(&runtime.env), + sub_invokes: &[], + }, + }]); + set_minter(&runtime, &lock, &token, &minter, &config); + + // The minter authorizes minting 40 to `user`... + runtime.env.mock_auths(&[MockAuth { + address: &minter, + invoke: &MockAuthInvoke { + contract: &lock, + fn_name: "mint", + args: (token.clone(), user.clone(), 40i128).into_val(&runtime.env), + sub_invokes: &[], + }, + }]); + + let logs = try_mint(&runtime, &lock, &token, &minter, &user, 41); + assert!( + logs.iter().any(|e| e.to_lowercase().contains("auth")), + "expected an authorization failure, got logs: {logs:?}" + ); + + // Nothing was minted and the epoch's consumed limit stayed at zero. + assert_eq!(balance_of(&runtime, &token, &user), 0); + assert_eq!( + minter_info(&runtime, &lock, &token, &minter) + .minter_stats + .consumed_limit, + 0 + ); +} + #[test] fn mint_lock_set_and_read_minter() { let mut runtime = SorobanEnv::new();