diff --git a/docs/examples/soroban/atomic_swap/atomic_swap.sol b/docs/examples/soroban/atomic_swap/atomic_swap.sol new file mode 100644 index 000000000..6a473b81f --- /dev/null +++ b/docs/examples/soroban/atomic_swap/atomic_swap.sol @@ -0,0 +1,44 @@ +/// SPDX-License-Identifier: Apache-2.0 + +contract atomic_swap { + function swap( + address a, + address b, + address token_a, + address token_b, + uint64 amount_a, + uint64 min_b_for_a, + uint64 amount_b, + uint64 min_a_for_b + ) public { + require(amount_b >= min_b_for_a, "not enough token B for token A"); + require(amount_a >= min_a_for_b, "not enough token A for token B"); + + move_token(token_a, a, b, amount_a, min_a_for_b); + move_token(token_b, b, a, amount_b, min_b_for_a); + } + + function move_token( + address token, + address from, + address to, + uint64 max_spend_amount, + uint64 transfer_amount + ) internal { + address contract_address = address(this); + + bytes payload = abi.encode("transfer", from, contract_address, max_spend_amount); + (bool success, bytes returndata) = token.call(payload); + + payload = abi.encode("transfer", contract_address, to, transfer_amount); + (success, returndata) = token.call(payload); + + payload = abi.encode( + "transfer", + contract_address, + from, + max_spend_amount - transfer_amount + ); + (success, returndata) = token.call(payload); + } +} diff --git a/docs/examples/soroban/atomic_swap/atomic_swap_token_a.sol b/docs/examples/soroban/atomic_swap/atomic_swap_token_a.sol new file mode 100644 index 000000000..1c1531bd7 --- /dev/null +++ b/docs/examples/soroban/atomic_swap/atomic_swap_token_a.sol @@ -0,0 +1,19 @@ +/// SPDX-License-Identifier: Apache-2.0 + +contract atomic_swap_token_a { + mapping(address => uint64) public balances; + + function mint(address to, uint64 amount) public { + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, uint64 amount) public { + require(balances[from] >= amount, "Insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address addr) public view returns (uint64) { + return balances[addr]; + } +} diff --git a/docs/examples/soroban/atomic_swap/atomic_swap_token_b.sol b/docs/examples/soroban/atomic_swap/atomic_swap_token_b.sol new file mode 100644 index 000000000..3760a4f68 --- /dev/null +++ b/docs/examples/soroban/atomic_swap/atomic_swap_token_b.sol @@ -0,0 +1,19 @@ +/// SPDX-License-Identifier: Apache-2.0 + +contract atomic_swap_token_b { + mapping(address => uint64) public balances; + + function mint(address to, uint64 amount) public { + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, uint64 amount) public { + require(balances[from] >= amount, "Insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address addr) public view returns (uint64) { + return balances[addr]; + } +} diff --git a/docs/examples/soroban/liquidity_pool/liquidity_pool.sol b/docs/examples/soroban/liquidity_pool/liquidity_pool.sol new file mode 100644 index 000000000..0b5f08ab1 --- /dev/null +++ b/docs/examples/soroban/liquidity_pool/liquidity_pool.sol @@ -0,0 +1,174 @@ +/// SPDX-License-Identifier: Apache-2.0 + +contract liquidity_pool { + uint64 public total_shares; + uint64 public reserve_a; + uint64 public reserve_b; + mapping(address => uint64) public shares; + + function balance_shares(address user) public view returns (uint64) { + return shares[user]; + } + + function deposit( + address to, + address token_a, + address token_b, + uint64 desired_a, + uint64 min_a, + uint64 desired_b, + uint64 min_b + ) public { + to.requireAuth(); + + uint64 amount_a = desired_a; + uint64 amount_b = desired_b; + + if (!(reserve_a == 0 && reserve_b == 0)) { + uint64 optimal_b = (desired_a * reserve_b) / reserve_a; + if (optimal_b <= desired_b) { + require(optimal_b >= min_b, "amount_b less than min"); + amount_b = optimal_b; + } else { + uint64 optimal_a = (desired_b * reserve_a) / reserve_b; + require(optimal_a <= desired_a && optimal_a >= min_a, "amount_a invalid"); + amount_a = optimal_a; + } + } + + require(amount_a > 0 && amount_b > 0, "both amounts must be > 0"); + + token_transfer(token_a, to, address(this), amount_a); + token_transfer(token_b, to, address(this), amount_b); + + uint64 balance_a = token_balance(token_a, address(this)); + uint64 balance_b = token_balance(token_b, address(this)); + + uint64 new_total_shares = total_shares; + if (reserve_a > 0 && reserve_b > 0) { + uint64 shares_a = (balance_a * total_shares) / reserve_a; + uint64 shares_b = (balance_b * total_shares) / reserve_b; + new_total_shares = shares_a < shares_b ? shares_a : shares_b; + } else { + new_total_shares = isqrt(balance_a * balance_b); + } + + require(new_total_shares >= total_shares, "invalid share growth"); + + uint64 minted = new_total_shares - total_shares; + shares[to] = shares[to] + minted; + total_shares = total_shares + minted; + reserve_a = balance_a; + reserve_b = balance_b; + } + + function swap_buy_a( + address to, + address token_a, + address token_b, + uint64 out, + uint64 in_max + ) public { + swap_internal(to, token_a, token_b, true, out, in_max); + } + + function swap_buy_b( + address to, + address token_a, + address token_b, + uint64 out, + uint64 in_max + ) public { + swap_internal(to, token_a, token_b, false, out, in_max); + } + + function swap_internal( + address to, + address token_a, + address token_b, + bool buy_a, + uint64 out, + uint64 in_max + ) internal { + to.requireAuth(); + + uint64 sell_reserve = buy_a ? reserve_b : reserve_a; + uint64 buy_reserve = buy_a ? reserve_a : reserve_b; + + require(buy_reserve > out, "not enough token to buy"); + + uint64 n = sell_reserve * out * 1000; + uint64 d = (buy_reserve - out) * 997; + uint64 sell_amount = (n / d) + 1; + require(sell_amount <= in_max, "in amount is over max"); + + address sell_token = buy_a ? token_b : token_a; + address buy_token = buy_a ? token_a : token_b; + + token_transfer(sell_token, to, address(this), sell_amount); + token_transfer(buy_token, address(this), to, out); + + reserve_a = token_balance(token_a, address(this)); + reserve_b = token_balance(token_b, address(this)); + require(reserve_a > 0 && reserve_b > 0, "new reserves must be > 0"); + } + + function withdraw( + address to, + address token_a, + address token_b, + uint64 share_amount, + uint64 min_a, + uint64 min_b + ) public { + to.requireAuth(); + require(shares[to] >= share_amount, "insufficient shares"); + require(total_shares > 0, "no total shares"); + + uint64 balance_a = token_balance(token_a, address(this)); + uint64 balance_b = token_balance(token_b, address(this)); + + uint64 out_a = (balance_a * share_amount) / total_shares; + uint64 out_b = (balance_b * share_amount) / total_shares; + + require(out_a >= min_a && out_b >= min_b, "min not satisfied"); + + shares[to] = shares[to] - share_amount; + total_shares = total_shares - share_amount; + + token_transfer(token_a, address(this), to, out_a); + token_transfer(token_b, address(this), to, out_b); + + reserve_a = token_balance(token_a, address(this)); + reserve_b = token_balance(token_b, address(this)); + } + + function token_transfer(address token, address from, address to, uint64 amount) internal { + bytes payload = abi.encode("transfer", from, to, amount); + (bool success, bytes memory returndata) = token.call(payload); + success; + returndata; + } + + function token_balance(address token, address owner) internal returns (uint64) { + bytes payload = abi.encode("balance", owner); + (bool success, bytes memory returndata) = token.call(payload); + success; + return abi.decode(returndata, (uint64)); + } + + function isqrt(uint64 x) internal pure returns (uint64) { + if (x == 0) { + return 0; + } + + uint64 y = x; + uint64 z = (x + 1) / 2; + while (z < y) { + y = z; + z = (x / z + z) / 2; + } + + return y; + } +} diff --git a/docs/examples/soroban/liquidity_pool/liquidity_pool_token_a.sol b/docs/examples/soroban/liquidity_pool/liquidity_pool_token_a.sol new file mode 100644 index 000000000..a4d2d0e22 --- /dev/null +++ b/docs/examples/soroban/liquidity_pool/liquidity_pool_token_a.sol @@ -0,0 +1,19 @@ +/// SPDX-License-Identifier: Apache-2.0 + +contract liquidity_pool_token_a { + mapping(address => uint64) public balances; + + function mint(address to, uint64 amount) public { + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, uint64 amount) public { + require(balances[from] >= amount, "Insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address owner) public view returns (uint64) { + return balances[owner]; + } +} diff --git a/docs/examples/soroban/liquidity_pool/liquidity_pool_token_b.sol b/docs/examples/soroban/liquidity_pool/liquidity_pool_token_b.sol new file mode 100644 index 000000000..4281f6234 --- /dev/null +++ b/docs/examples/soroban/liquidity_pool/liquidity_pool_token_b.sol @@ -0,0 +1,19 @@ +/// SPDX-License-Identifier: Apache-2.0 + +contract liquidity_pool_token_b { + mapping(address => uint64) public balances; + + function mint(address to, uint64 amount) public { + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, uint64 amount) public { + require(balances[from] >= amount, "Insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address owner) public view returns (uint64) { + return balances[owner]; + } +} diff --git a/docs/examples/soroban/timelock/timelock.sol b/docs/examples/soroban/timelock/timelock.sol new file mode 100644 index 000000000..91d77cf37 --- /dev/null +++ b/docs/examples/soroban/timelock/timelock.sol @@ -0,0 +1,67 @@ +/// SPDX-License-Identifier: Apache-2.0 + +contract timelock { + enum TimeBoundKind { + Before, + After + } + + enum BalanceState { + Uninitialized, + Funded, + Claimed + } + + BalanceState public state; + TimeBoundKind mode; + uint64 public amount; + uint64 public bound_timestamp; + + function deposit( + address from, + address token_, + uint64 amount_, + TimeBoundKind mode_, + uint64 bound_timestamp_ + ) public { + require( + state == BalanceState.Uninitialized, + "contract has been already initialized" + ); + + from.requireAuth(); + + amount = amount_; + mode = mode_; + bound_timestamp = bound_timestamp_; + + bytes payload = abi.encode("transfer", from, address(this), amount_); + token_.call(payload); + + state = BalanceState.Funded; + } + + function claim(address token_, address claimant) public { + claimant.requireAuth(); + + require(state == BalanceState.Funded, "balance is not claimable"); + require(check_time_bound(), "time predicate is not fulfilled"); + + state = BalanceState.Claimed; + + bytes memory payload = abi.encode("transfer", address(this), claimant, amount); + token_.call(payload); + } + + function now_ts() public view returns (uint64) { + return block.timestamp; + } + + function check_time_bound() internal view returns (bool) { + if (mode == TimeBoundKind.After) { + return block.timestamp >= bound_timestamp; + } + + return block.timestamp <= bound_timestamp; + } +} diff --git a/docs/examples/soroban/timelock/timelock_token.sol b/docs/examples/soroban/timelock/timelock_token.sol new file mode 100644 index 000000000..5d2efb3e8 --- /dev/null +++ b/docs/examples/soroban/timelock/timelock_token.sol @@ -0,0 +1,19 @@ +/// SPDX-License-Identifier: Apache-2.0 + +contract timelock_token { + mapping(address => uint64) public balances; + + function mint(address to, uint64 amount) public { + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, uint64 amount) public { + require(balances[from] >= amount, "insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address owner) public view returns (uint64) { + return balances[owner]; + } +} diff --git a/examples/soroban/atomic_swap/atomic_swap.sol b/examples/soroban/atomic_swap/atomic_swap.sol new file mode 100644 index 000000000..6a473b81f --- /dev/null +++ b/examples/soroban/atomic_swap/atomic_swap.sol @@ -0,0 +1,44 @@ +/// SPDX-License-Identifier: Apache-2.0 + +contract atomic_swap { + function swap( + address a, + address b, + address token_a, + address token_b, + uint64 amount_a, + uint64 min_b_for_a, + uint64 amount_b, + uint64 min_a_for_b + ) public { + require(amount_b >= min_b_for_a, "not enough token B for token A"); + require(amount_a >= min_a_for_b, "not enough token A for token B"); + + move_token(token_a, a, b, amount_a, min_a_for_b); + move_token(token_b, b, a, amount_b, min_b_for_a); + } + + function move_token( + address token, + address from, + address to, + uint64 max_spend_amount, + uint64 transfer_amount + ) internal { + address contract_address = address(this); + + bytes payload = abi.encode("transfer", from, contract_address, max_spend_amount); + (bool success, bytes returndata) = token.call(payload); + + payload = abi.encode("transfer", contract_address, to, transfer_amount); + (success, returndata) = token.call(payload); + + payload = abi.encode( + "transfer", + contract_address, + from, + max_spend_amount - transfer_amount + ); + (success, returndata) = token.call(payload); + } +} diff --git a/examples/soroban/atomic_swap/atomic_swap_token_a.sol b/examples/soroban/atomic_swap/atomic_swap_token_a.sol new file mode 100644 index 000000000..1c1531bd7 --- /dev/null +++ b/examples/soroban/atomic_swap/atomic_swap_token_a.sol @@ -0,0 +1,19 @@ +/// SPDX-License-Identifier: Apache-2.0 + +contract atomic_swap_token_a { + mapping(address => uint64) public balances; + + function mint(address to, uint64 amount) public { + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, uint64 amount) public { + require(balances[from] >= amount, "Insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address addr) public view returns (uint64) { + return balances[addr]; + } +} diff --git a/examples/soroban/atomic_swap/atomic_swap_token_b.sol b/examples/soroban/atomic_swap/atomic_swap_token_b.sol new file mode 100644 index 000000000..3760a4f68 --- /dev/null +++ b/examples/soroban/atomic_swap/atomic_swap_token_b.sol @@ -0,0 +1,19 @@ +/// SPDX-License-Identifier: Apache-2.0 + +contract atomic_swap_token_b { + mapping(address => uint64) public balances; + + function mint(address to, uint64 amount) public { + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, uint64 amount) public { + require(balances[from] >= amount, "Insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address addr) public view returns (uint64) { + return balances[addr]; + } +} diff --git a/examples/soroban/liquidity_pool/liquidity_pool.sol b/examples/soroban/liquidity_pool/liquidity_pool.sol new file mode 100644 index 000000000..0b5f08ab1 --- /dev/null +++ b/examples/soroban/liquidity_pool/liquidity_pool.sol @@ -0,0 +1,174 @@ +/// SPDX-License-Identifier: Apache-2.0 + +contract liquidity_pool { + uint64 public total_shares; + uint64 public reserve_a; + uint64 public reserve_b; + mapping(address => uint64) public shares; + + function balance_shares(address user) public view returns (uint64) { + return shares[user]; + } + + function deposit( + address to, + address token_a, + address token_b, + uint64 desired_a, + uint64 min_a, + uint64 desired_b, + uint64 min_b + ) public { + to.requireAuth(); + + uint64 amount_a = desired_a; + uint64 amount_b = desired_b; + + if (!(reserve_a == 0 && reserve_b == 0)) { + uint64 optimal_b = (desired_a * reserve_b) / reserve_a; + if (optimal_b <= desired_b) { + require(optimal_b >= min_b, "amount_b less than min"); + amount_b = optimal_b; + } else { + uint64 optimal_a = (desired_b * reserve_a) / reserve_b; + require(optimal_a <= desired_a && optimal_a >= min_a, "amount_a invalid"); + amount_a = optimal_a; + } + } + + require(amount_a > 0 && amount_b > 0, "both amounts must be > 0"); + + token_transfer(token_a, to, address(this), amount_a); + token_transfer(token_b, to, address(this), amount_b); + + uint64 balance_a = token_balance(token_a, address(this)); + uint64 balance_b = token_balance(token_b, address(this)); + + uint64 new_total_shares = total_shares; + if (reserve_a > 0 && reserve_b > 0) { + uint64 shares_a = (balance_a * total_shares) / reserve_a; + uint64 shares_b = (balance_b * total_shares) / reserve_b; + new_total_shares = shares_a < shares_b ? shares_a : shares_b; + } else { + new_total_shares = isqrt(balance_a * balance_b); + } + + require(new_total_shares >= total_shares, "invalid share growth"); + + uint64 minted = new_total_shares - total_shares; + shares[to] = shares[to] + minted; + total_shares = total_shares + minted; + reserve_a = balance_a; + reserve_b = balance_b; + } + + function swap_buy_a( + address to, + address token_a, + address token_b, + uint64 out, + uint64 in_max + ) public { + swap_internal(to, token_a, token_b, true, out, in_max); + } + + function swap_buy_b( + address to, + address token_a, + address token_b, + uint64 out, + uint64 in_max + ) public { + swap_internal(to, token_a, token_b, false, out, in_max); + } + + function swap_internal( + address to, + address token_a, + address token_b, + bool buy_a, + uint64 out, + uint64 in_max + ) internal { + to.requireAuth(); + + uint64 sell_reserve = buy_a ? reserve_b : reserve_a; + uint64 buy_reserve = buy_a ? reserve_a : reserve_b; + + require(buy_reserve > out, "not enough token to buy"); + + uint64 n = sell_reserve * out * 1000; + uint64 d = (buy_reserve - out) * 997; + uint64 sell_amount = (n / d) + 1; + require(sell_amount <= in_max, "in amount is over max"); + + address sell_token = buy_a ? token_b : token_a; + address buy_token = buy_a ? token_a : token_b; + + token_transfer(sell_token, to, address(this), sell_amount); + token_transfer(buy_token, address(this), to, out); + + reserve_a = token_balance(token_a, address(this)); + reserve_b = token_balance(token_b, address(this)); + require(reserve_a > 0 && reserve_b > 0, "new reserves must be > 0"); + } + + function withdraw( + address to, + address token_a, + address token_b, + uint64 share_amount, + uint64 min_a, + uint64 min_b + ) public { + to.requireAuth(); + require(shares[to] >= share_amount, "insufficient shares"); + require(total_shares > 0, "no total shares"); + + uint64 balance_a = token_balance(token_a, address(this)); + uint64 balance_b = token_balance(token_b, address(this)); + + uint64 out_a = (balance_a * share_amount) / total_shares; + uint64 out_b = (balance_b * share_amount) / total_shares; + + require(out_a >= min_a && out_b >= min_b, "min not satisfied"); + + shares[to] = shares[to] - share_amount; + total_shares = total_shares - share_amount; + + token_transfer(token_a, address(this), to, out_a); + token_transfer(token_b, address(this), to, out_b); + + reserve_a = token_balance(token_a, address(this)); + reserve_b = token_balance(token_b, address(this)); + } + + function token_transfer(address token, address from, address to, uint64 amount) internal { + bytes payload = abi.encode("transfer", from, to, amount); + (bool success, bytes memory returndata) = token.call(payload); + success; + returndata; + } + + function token_balance(address token, address owner) internal returns (uint64) { + bytes payload = abi.encode("balance", owner); + (bool success, bytes memory returndata) = token.call(payload); + success; + return abi.decode(returndata, (uint64)); + } + + function isqrt(uint64 x) internal pure returns (uint64) { + if (x == 0) { + return 0; + } + + uint64 y = x; + uint64 z = (x + 1) / 2; + while (z < y) { + y = z; + z = (x / z + z) / 2; + } + + return y; + } +} diff --git a/examples/soroban/liquidity_pool/liquidity_pool_token_a.sol b/examples/soroban/liquidity_pool/liquidity_pool_token_a.sol new file mode 100644 index 000000000..a4d2d0e22 --- /dev/null +++ b/examples/soroban/liquidity_pool/liquidity_pool_token_a.sol @@ -0,0 +1,19 @@ +/// SPDX-License-Identifier: Apache-2.0 + +contract liquidity_pool_token_a { + mapping(address => uint64) public balances; + + function mint(address to, uint64 amount) public { + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, uint64 amount) public { + require(balances[from] >= amount, "Insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address owner) public view returns (uint64) { + return balances[owner]; + } +} diff --git a/examples/soroban/liquidity_pool/liquidity_pool_token_b.sol b/examples/soroban/liquidity_pool/liquidity_pool_token_b.sol new file mode 100644 index 000000000..4281f6234 --- /dev/null +++ b/examples/soroban/liquidity_pool/liquidity_pool_token_b.sol @@ -0,0 +1,19 @@ +/// SPDX-License-Identifier: Apache-2.0 + +contract liquidity_pool_token_b { + mapping(address => uint64) public balances; + + function mint(address to, uint64 amount) public { + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, uint64 amount) public { + require(balances[from] >= amount, "Insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address owner) public view returns (uint64) { + return balances[owner]; + } +} diff --git a/examples/soroban/timelock/timelock.sol b/examples/soroban/timelock/timelock.sol new file mode 100644 index 000000000..91d77cf37 --- /dev/null +++ b/examples/soroban/timelock/timelock.sol @@ -0,0 +1,67 @@ +/// SPDX-License-Identifier: Apache-2.0 + +contract timelock { + enum TimeBoundKind { + Before, + After + } + + enum BalanceState { + Uninitialized, + Funded, + Claimed + } + + BalanceState public state; + TimeBoundKind mode; + uint64 public amount; + uint64 public bound_timestamp; + + function deposit( + address from, + address token_, + uint64 amount_, + TimeBoundKind mode_, + uint64 bound_timestamp_ + ) public { + require( + state == BalanceState.Uninitialized, + "contract has been already initialized" + ); + + from.requireAuth(); + + amount = amount_; + mode = mode_; + bound_timestamp = bound_timestamp_; + + bytes payload = abi.encode("transfer", from, address(this), amount_); + token_.call(payload); + + state = BalanceState.Funded; + } + + function claim(address token_, address claimant) public { + claimant.requireAuth(); + + require(state == BalanceState.Funded, "balance is not claimable"); + require(check_time_bound(), "time predicate is not fulfilled"); + + state = BalanceState.Claimed; + + bytes memory payload = abi.encode("transfer", address(this), claimant, amount); + token_.call(payload); + } + + function now_ts() public view returns (uint64) { + return block.timestamp; + } + + function check_time_bound() internal view returns (bool) { + if (mode == TimeBoundKind.After) { + return block.timestamp >= bound_timestamp; + } + + return block.timestamp <= bound_timestamp; + } +} diff --git a/examples/soroban/timelock/timelock_token.sol b/examples/soroban/timelock/timelock_token.sol new file mode 100644 index 000000000..5d2efb3e8 --- /dev/null +++ b/examples/soroban/timelock/timelock_token.sol @@ -0,0 +1,19 @@ +/// SPDX-License-Identifier: Apache-2.0 + +contract timelock_token { + mapping(address => uint64) public balances; + + function mint(address to, uint64 amount) public { + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, uint64 amount) public { + require(balances[from] >= amount, "insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address owner) public view returns (uint64) { + return balances[owner]; + } +} diff --git a/integration/soroban/atomic_swap.sol b/integration/soroban/atomic_swap.sol new file mode 100644 index 000000000..293133708 --- /dev/null +++ b/integration/soroban/atomic_swap.sol @@ -0,0 +1,42 @@ +contract atomic_swap { + function swap( + address a, + address b, + address token_a, + address token_b, + uint64 amount_a, + uint64 min_b_for_a, + uint64 amount_b, + uint64 min_a_for_b + ) public { + require(amount_b >= min_b_for_a, "not enough token B for token A"); + require(amount_a >= min_a_for_b, "not enough token A for token B"); + + move_token(token_a, a, b, amount_a, min_a_for_b); + move_token(token_b, b, a, amount_b, min_b_for_a); + } + + function move_token( + address token, + address from, + address to, + uint64 max_spend_amount, + uint64 transfer_amount + ) internal { + address contract_address = address(this); + + bytes payload = abi.encode("transfer", from, contract_address, max_spend_amount); + (bool success, bytes returndata) = token.call(payload); + + payload = abi.encode("transfer", contract_address, to, transfer_amount); + (success, returndata) = token.call(payload); + + payload = abi.encode( + "transfer", + contract_address, + from, + max_spend_amount - transfer_amount + ); + (success, returndata) = token.call(payload); + } +} diff --git a/integration/soroban/atomic_swap.spec.js b/integration/soroban/atomic_swap.spec.js new file mode 100644 index 000000000..7c4562c7b --- /dev/null +++ b/integration/soroban/atomic_swap.spec.js @@ -0,0 +1,126 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; +import { readFileSync } from 'fs'; +import { expect } from 'chai'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { call_contract_function, call_contract_view, toSafeJson } from './test_helpers.js'; +import { Server } from '@stellar/stellar-sdk/rpc'; + +const __filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(__filename); +const server = new Server('https://soroban-testnet.stellar.org'); + +function readContractAddress(filename) { + return readFileSync(path.join(dirname, '.stellar', 'contract-ids', filename), 'utf8').trim(); +} + +function u64(value) { + return StellarSdk.xdr.ScVal.scvU64(new StellarSdk.xdr.Uint64(BigInt(value))); +} + +describe('Atomic Swap', () => { + let keypair; + let swap; + let tokenA; + let tokenB; + + before(async () => { + keypair = StellarSdk.Keypair.fromSecret(readFileSync('alice.txt', 'utf8').trim()); + swap = new StellarSdk.Contract(readContractAddress('atomic_swap.txt')); + tokenA = new StellarSdk.Contract(readContractAddress('atomic_swap_token_a.txt')); + tokenB = new StellarSdk.Contract(readContractAddress('atomic_swap_token_b.txt')); + }); + + async function mint(contract, owner, amount) { + const res = await call_contract_function( + 'mint', + server, + keypair, + contract, + owner, + u64(amount), + ); + + expect( + res.status, + `mint failed for ${contract.address().toString()}: ${toSafeJson(res)}`, + ).to.equal('SUCCESS'); + } + + async function balance(contract, owner) { + const res = await call_contract_view('balance', server, keypair, contract, owner); + expect( + res.status, + `balance failed for ${contract.address().toString()}: ${toSafeJson(res)}`, + ).to.equal('SUCCESS'); + return res.returnValue; + } + + it('settles swap and refunds remainder to each party', async () => { + const partyA = new StellarSdk.Address(StellarSdk.Keypair.random().publicKey()).toScVal(); + const partyB = new StellarSdk.Address(StellarSdk.Keypair.random().publicKey()).toScVal(); + const swapAddress = swap.address().toScVal(); + + await mint(tokenA, partyA, 100); + await mint(tokenB, partyB, 80); + + const res = await call_contract_function( + 'swap', + server, + keypair, + swap, + partyA, + partyB, + tokenA.address().toScVal(), + tokenB.address().toScVal(), + u64(40), + u64(30), + u64(50), + u64(35), + ); + + expect(res.status, `swap failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + + expect(await balance(tokenA, partyA)).to.equal(65n); + expect(await balance(tokenA, partyB)).to.equal(35n); + expect(await balance(tokenA, swapAddress)).to.equal(0n); + + expect(await balance(tokenB, partyA)).to.equal(30n); + expect(await balance(tokenB, partyB)).to.equal(50n); + expect(await balance(tokenB, swapAddress)).to.equal(0n); + }); + + it('reverts when minimum requested price is not met', async () => { + const partyA = new StellarSdk.Address(StellarSdk.Keypair.random().publicKey()).toScVal(); + const partyB = new StellarSdk.Address(StellarSdk.Keypair.random().publicKey()).toScVal(); + const swapAddress = swap.address().toScVal(); + + await mint(tokenA, partyA, 100); + await mint(tokenB, partyB, 80); + + const res = await call_contract_function( + 'swap', + server, + keypair, + swap, + partyA, + partyB, + tokenA.address().toScVal(), + tokenB.address().toScVal(), + u64(40), + u64(60), + u64(50), + u64(35), + ); + + expect(res.status, `swap unexpectedly succeeded: ${toSafeJson(res)}`).to.not.equal('SUCCESS'); + + expect(await balance(tokenA, partyA)).to.equal(100n); + expect(await balance(tokenA, partyB)).to.equal(0n); + expect(await balance(tokenA, swapAddress)).to.equal(0n); + + expect(await balance(tokenB, partyA)).to.equal(0n); + expect(await balance(tokenB, partyB)).to.equal(80n); + expect(await balance(tokenB, swapAddress)).to.equal(0n); + }); +}); diff --git a/integration/soroban/atomic_swap_token_a.sol b/integration/soroban/atomic_swap_token_a.sol new file mode 100644 index 000000000..5bb51ac8a --- /dev/null +++ b/integration/soroban/atomic_swap_token_a.sol @@ -0,0 +1,17 @@ +contract atomic_swap_token_a { + mapping(address => uint64) public balances; + + function mint(address to, uint64 amount) public { + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, uint64 amount) public { + require(balances[from] >= amount, "Insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address addr) public view returns (uint64) { + return balances[addr]; + } +} diff --git a/integration/soroban/atomic_swap_token_b.sol b/integration/soroban/atomic_swap_token_b.sol new file mode 100644 index 000000000..4619761f6 --- /dev/null +++ b/integration/soroban/atomic_swap_token_b.sol @@ -0,0 +1,17 @@ +contract atomic_swap_token_b { + mapping(address => uint64) public balances; + + function mint(address to, uint64 amount) public { + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, uint64 amount) public { + require(balances[from] >= amount, "Insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address addr) public view returns (uint64) { + return balances[addr]; + } +} diff --git a/integration/soroban/counter.spec.js b/integration/soroban/counter.spec.js index fb89c8650..3595dfa3a 100644 --- a/integration/soroban/counter.spec.js +++ b/integration/soroban/counter.spec.js @@ -3,7 +3,7 @@ import { readFileSync } from 'fs'; import { expect } from 'chai'; import path from 'path'; import { fileURLToPath } from 'url'; -import { call_contract_function, toSafeJson } from './test_helpers.js'; +import { call_contract_function, call_contract_view, toSafeJson } from './test_helpers.js'; import { Server } from '@stellar/stellar-sdk/rpc'; const __filename = fileURLToPath(import.meta.url); @@ -28,7 +28,7 @@ describe('Counter', () => { }); it('get correct initial counter', async () => { - let res = await call_contract_function("count", server, keypair, contract); + let res = await call_contract_view("count", server, keypair, contract); expect(res.status, `Counter 'count' call failed: ${toSafeJson(res)}`).to.equal("SUCCESS"); expect(res.returnValue, `Unexpected counter value: ${toSafeJson(res)}`).to.equal(10n); @@ -40,7 +40,7 @@ describe('Counter', () => { expect(incRes.status, `Counter 'increment' call failed: ${toSafeJson(incRes)}`).to.equal("SUCCESS"); // get the count again - let res = await call_contract_function("count", server, keypair, contract); + let res = await call_contract_view("count", server, keypair, contract); expect(res.status, `Counter 'count' after increment failed: ${toSafeJson(res)}`).to.equal("SUCCESS"); expect(res.returnValue, `Unexpected counter value after increment: ${toSafeJson(res)}`).to.equal(11n); }); diff --git a/integration/soroban/liquidity_pool.sol b/integration/soroban/liquidity_pool.sol new file mode 100644 index 000000000..1c8d88ec8 --- /dev/null +++ b/integration/soroban/liquidity_pool.sol @@ -0,0 +1,172 @@ +contract liquidity_pool { + uint64 public total_shares; + uint64 public reserve_a; + uint64 public reserve_b; + mapping(address => uint64) public shares; + + function balance_shares(address user) public view returns (uint64) { + return shares[user]; + } + + function deposit( + address to, + address token_a, + address token_b, + uint64 desired_a, + uint64 min_a, + uint64 desired_b, + uint64 min_b + ) public { + to.requireAuth(); + + uint64 amount_a = desired_a; + uint64 amount_b = desired_b; + + if (!(reserve_a == 0 && reserve_b == 0)) { + uint64 optimal_b = (desired_a * reserve_b) / reserve_a; + if (optimal_b <= desired_b) { + require(optimal_b >= min_b, "amount_b less than min"); + amount_b = optimal_b; + } else { + uint64 optimal_a = (desired_b * reserve_a) / reserve_b; + require(optimal_a <= desired_a && optimal_a >= min_a, "amount_a invalid"); + amount_a = optimal_a; + } + } + + require(amount_a > 0 && amount_b > 0, "both amounts must be > 0"); + + token_transfer(token_a, to, address(this), amount_a); + token_transfer(token_b, to, address(this), amount_b); + + uint64 balance_a = token_balance(token_a, address(this)); + uint64 balance_b = token_balance(token_b, address(this)); + + uint64 new_total_shares = total_shares; + if (reserve_a > 0 && reserve_b > 0) { + uint64 shares_a = (balance_a * total_shares) / reserve_a; + uint64 shares_b = (balance_b * total_shares) / reserve_b; + new_total_shares = shares_a < shares_b ? shares_a : shares_b; + } else { + new_total_shares = isqrt(balance_a * balance_b); + } + + require(new_total_shares >= total_shares, "invalid share growth"); + + uint64 minted = new_total_shares - total_shares; + shares[to] = shares[to] + minted; + total_shares = total_shares + minted; + reserve_a = balance_a; + reserve_b = balance_b; + } + + function swap_buy_a( + address to, + address token_a, + address token_b, + uint64 out, + uint64 in_max + ) public { + swap_internal(to, token_a, token_b, true, out, in_max); + } + + function swap_buy_b( + address to, + address token_a, + address token_b, + uint64 out, + uint64 in_max + ) public { + swap_internal(to, token_a, token_b, false, out, in_max); + } + + function swap_internal( + address to, + address token_a, + address token_b, + bool buy_a, + uint64 out, + uint64 in_max + ) internal { + to.requireAuth(); + + uint64 sell_reserve = buy_a ? reserve_b : reserve_a; + uint64 buy_reserve = buy_a ? reserve_a : reserve_b; + + require(buy_reserve > out, "not enough token to buy"); + + uint64 n = sell_reserve * out * 1000; + uint64 d = (buy_reserve - out) * 997; + uint64 sell_amount = (n / d) + 1; + require(sell_amount <= in_max, "in amount is over max"); + + address sell_token = buy_a ? token_b : token_a; + address buy_token = buy_a ? token_a : token_b; + + token_transfer(sell_token, to, address(this), sell_amount); + token_transfer(buy_token, address(this), to, out); + + reserve_a = token_balance(token_a, address(this)); + reserve_b = token_balance(token_b, address(this)); + require(reserve_a > 0 && reserve_b > 0, "new reserves must be > 0"); + } + + function withdraw( + address to, + address token_a, + address token_b, + uint64 share_amount, + uint64 min_a, + uint64 min_b + ) public { + to.requireAuth(); + require(shares[to] >= share_amount, "insufficient shares"); + require(total_shares > 0, "no total shares"); + + uint64 balance_a = token_balance(token_a, address(this)); + uint64 balance_b = token_balance(token_b, address(this)); + + uint64 out_a = (balance_a * share_amount) / total_shares; + uint64 out_b = (balance_b * share_amount) / total_shares; + + require(out_a >= min_a && out_b >= min_b, "min not satisfied"); + + shares[to] = shares[to] - share_amount; + total_shares = total_shares - share_amount; + + token_transfer(token_a, address(this), to, out_a); + token_transfer(token_b, address(this), to, out_b); + + reserve_a = token_balance(token_a, address(this)); + reserve_b = token_balance(token_b, address(this)); + } + + function token_transfer(address token, address from, address to, uint64 amount) internal { + bytes payload = abi.encode("transfer", from, to, amount); + (bool success, bytes memory returndata) = token.call(payload); + success; + returndata; + } + + function token_balance(address token, address owner) internal returns (uint64) { + bytes payload = abi.encode("balance", owner); + (bool success, bytes memory returndata) = token.call(payload); + success; + return abi.decode(returndata, (uint64)); + } + + function isqrt(uint64 x) internal pure returns (uint64) { + if (x == 0) { + return 0; + } + + uint64 y = x; + uint64 z = (x + 1) / 2; + while (z < y) { + y = z; + z = (x / z + z) / 2; + } + + return y; + } +} diff --git a/integration/soroban/liquidity_pool.spec.js b/integration/soroban/liquidity_pool.spec.js new file mode 100644 index 000000000..591eae224 --- /dev/null +++ b/integration/soroban/liquidity_pool.spec.js @@ -0,0 +1,118 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; +import { readFileSync } from 'fs'; +import { expect } from 'chai'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { call_contract_function, call_contract_view, toSafeJson } from './test_helpers.js'; +import { Server } from '@stellar/stellar-sdk/rpc'; + +const __filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(__filename); +const server = new Server('https://soroban-testnet.stellar.org'); + +function readContractAddress(filename) { + return readFileSync(path.join(dirname, '.stellar', 'contract-ids', filename), 'utf8').trim(); +} + +function u64(value) { + return StellarSdk.xdr.ScVal.scvU64(new StellarSdk.xdr.Uint64(BigInt(value))); +} + +describe('Liquidity Pool', () => { + let keypair; + let owner; + let pool; + let tokenA; + let tokenB; + + before(async () => { + keypair = StellarSdk.Keypair.fromSecret(readFileSync('alice.txt', 'utf8').trim()); + owner = new StellarSdk.Address(keypair.publicKey()).toScVal(); + + pool = new StellarSdk.Contract(readContractAddress('liquidity_pool.txt')); + tokenA = new StellarSdk.Contract(readContractAddress('liquidity_pool_token_a.txt')); + tokenB = new StellarSdk.Contract(readContractAddress('liquidity_pool_token_b.txt')); + }); + + it('supports deposit, swap, and withdraw', async () => { + let res = await call_contract_function('mint', server, keypair, tokenA, owner, u64(100_000)); + expect(res.status, `mint token A failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + + res = await call_contract_function('mint', server, keypair, tokenB, owner, u64(100_000)); + expect(res.status, `mint token B failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + + res = await call_contract_function( + 'deposit', + server, + keypair, + pool, + owner, + tokenA.address().toScVal(), + tokenB.address().toScVal(), + u64(10_000), + u64(9_000), + u64(20_000), + u64(18_000), + ); + expect(res.status, `deposit failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + + res = await call_contract_view('reserve_a', server, keypair, pool); + expect(res.status, `reserve_a failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + expect(res.returnValue).to.equal(10_000n); + + res = await call_contract_view('reserve_b', server, keypair, pool); + expect(res.status, `reserve_b failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + expect(res.returnValue).to.equal(20_000n); + + res = await call_contract_view('balance_shares', server, keypair, pool, owner); + expect(res.status, `balance_shares failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + expect(res.returnValue).to.equal(14_142n); + + res = await call_contract_function( + 'swap_buy_a', + server, + keypair, + pool, + owner, + tokenA.address().toScVal(), + tokenB.address().toScVal(), + u64(1_000), + u64(3_000), + ); + expect(res.status, `swap_buy_a failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + + res = await call_contract_view('reserve_a', server, keypair, pool); + expect(res.status, `reserve_a after swap failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + expect(res.returnValue).to.equal(9_000n); + + res = await call_contract_view('reserve_b', server, keypair, pool); + expect(res.status, `reserve_b after swap failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + expect(res.returnValue).to.equal(22_229n); + + res = await call_contract_function( + 'withdraw', + server, + keypair, + pool, + owner, + tokenA.address().toScVal(), + tokenB.address().toScVal(), + u64(7_071), + u64(0), + u64(0), + ); + expect(res.status, `withdraw failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + + res = await call_contract_view('balance_shares', server, keypair, pool, owner); + expect(res.status, `balance_shares after withdraw failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + expect(res.returnValue).to.equal(7_071n); + + res = await call_contract_view('reserve_a', server, keypair, pool); + expect(res.status, `reserve_a after withdraw failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + expect(res.returnValue).to.equal(4_500n); + + res = await call_contract_view('reserve_b', server, keypair, pool); + expect(res.status, `reserve_b after withdraw failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + expect(res.returnValue).to.equal(11_115n); + }); +}); diff --git a/integration/soroban/liquidity_pool_token_a.sol b/integration/soroban/liquidity_pool_token_a.sol new file mode 100644 index 000000000..a69127091 --- /dev/null +++ b/integration/soroban/liquidity_pool_token_a.sol @@ -0,0 +1,17 @@ +contract liquidity_pool_token_a { + mapping(address => uint64) public balances; + + function mint(address to, uint64 amount) public { + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, uint64 amount) public { + require(balances[from] >= amount, "Insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address owner) public view returns (uint64) { + return balances[owner]; + } +} diff --git a/integration/soroban/liquidity_pool_token_b.sol b/integration/soroban/liquidity_pool_token_b.sol new file mode 100644 index 000000000..4a5975a69 --- /dev/null +++ b/integration/soroban/liquidity_pool_token_b.sol @@ -0,0 +1,17 @@ +contract liquidity_pool_token_b { + mapping(address => uint64) public balances; + + function mint(address to, uint64 amount) public { + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, uint64 amount) public { + require(balances[from] >= amount, "Insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address owner) public view returns (uint64) { + return balances[owner]; + } +} diff --git a/integration/soroban/setup.js b/integration/soroban/setup.js index 593d4602b..fea2b5d04 100644 --- a/integration/soroban/setup.js +++ b/integration/soroban/setup.js @@ -27,7 +27,11 @@ const NETWORK_PASSPHRASE = Networks.TESTNET; // --- Paths --- const CONTRACT_IDS_DIR = path.join(dirname, '.stellar', 'contract-ids'); -const ALICE_FILE = path.join(dirname, 'alice.txt'); // tests expect seed-only here +const SIGNER_FILES = { + alice: path.join(dirname, 'alice.txt'), + bob: path.join(dirname, 'bob.txt'), + charlie: path.join(dirname, 'charlie.txt'), +}; // --- SDK server --- const server = new rpc.Server(RPC_URL); @@ -58,33 +62,42 @@ function extractSeed(raw) { return null; } -// Save alice in the legacy format expected by your tests: ONLY the secret seed. -function saveAliceTxtSeedOnly(kp) { - writeFileSync(ALICE_FILE, kp.secret().trim() + '\n'); +// Save signer seed in the legacy format expected by tests. +function saveSignerSeedOnly(signerName, kp) { + const signerFile = SIGNER_FILES[signerName]; + if (!signerFile) { + throw new Error(`unknown signer '${signerName}'`); + } + writeFileSync(signerFile, kp.secret().trim() + '\n'); } -// create/fund or reuse an account named "alice" -async function getAlice() { - // prefer env override if you want to reuse a key (optional) - const envRaw = process.env.ALICE_SECRET?.trim(); +// create/fund or reuse signer account +async function getSigner(signerName) { + const signerFile = SIGNER_FILES[signerName]; + if (!signerFile) { + throw new Error(`unknown signer '${signerName}'`); + } + + const envVarName = `${signerName.toUpperCase()}_SECRET`; + const envRaw = process.env[envVarName]?.trim(); if (envRaw) { const seed = extractSeed(envRaw); - if (!seed) throw new Error('ALICE_SECRET is set but not a valid S… seed'); + if (!seed) throw new Error(`${envVarName} is set but not a valid S… seed`); const kp = Keypair.fromSecret(seed); await server.requestAirdrop(kp.publicKey()).catch(() => {}); // no-op if already funded - saveAliceTxtSeedOnly(kp); // normalize file for tests + saveSignerSeedOnly(signerName, kp); // normalize file for tests return kp; } - // if we already wrote alice.txt, parse/normalize it (supports multi-line legacy) - if (existsSync(ALICE_FILE)) { - const raw = readFileSync(ALICE_FILE, 'utf8'); + // if signer file exists, parse/normalize it (supports multi-line legacy) + if (existsSync(signerFile)) { + const raw = readFileSync(signerFile, 'utf8'); const seed = extractSeed(raw); if (seed) { const kp = Keypair.fromSecret(seed); await server.requestAirdrop(kp.publicKey()).catch(() => {}); // normalize file to seed-only so future runs & tests are stable - saveAliceTxtSeedOnly(kp); + saveSignerSeedOnly(signerName, kp); return kp; } // fall through if file was malformed @@ -92,9 +105,9 @@ async function getAlice() { // otherwise generate & fund const kp = Keypair.random(); - logStep(`Funding ${kp.publicKey()} via Friendbot`); + logStep(`Funding ${signerName} (${kp.publicKey()}) via Friendbot`); await server.requestAirdrop(kp.publicKey()); - saveAliceTxtSeedOnly(kp); + saveSignerSeedOnly(signerName, kp); return kp; } @@ -189,26 +202,29 @@ async function createContract(sourceAccount, signer, wasmHash) { return contractId; } -async function deployOne(wasmPath, signer) { +async function deployOne(wasmPath, signerName, signer) { const name = filenameNoExtension(wasmPath); const outFile = path.join(CONTRACT_IDS_DIR, `${name}.txt`); const wasmBytes = readFileSync(wasmPath); - logStep(`Uploading WASM: ${wasmPath}`); + logStep(`[${signerName}] Uploading WASM: ${wasmPath}`); let account = await loadSourceAccount(signer.publicKey()); const wasmHash = await uploadWasm(account, signer, wasmBytes); - logStep(`Creating contract for: ${name}`); + logStep(`[${signerName}] Creating contract for: ${name}`); account = await loadSourceAccount(signer.publicKey()); // refresh sequence const contractId = await createContract(account, signer, wasmHash); mkdirSync(CONTRACT_IDS_DIR, { recursive: true }); writeFileSync(outFile, contractId + '\n'); - console.log(`✔ Wrote contract id -> ${outFile}`); + console.log(`✔ [${signerName}] Wrote contract id -> ${outFile}`); } async function deployAll() { - const signer = await getAlice(); + const signerNames = ['alice', 'bob', 'charlie']; + const signers = await Promise.all( + signerNames.map(async (name) => ({ name, keypair: await getSigner(name) })) + ); const files = readdirSync(dirname).filter((f) => f.endsWith('.wasm')); // include your Rust artifact, same path you used before @@ -221,10 +237,28 @@ async function deployAll() { ); if (!files.includes(rustWasm)) files.push(rustWasm); + files.sort(); console.log('Found WASM files:', files); - for (const f of files) { - const full = path.join(dirname, f); - await deployOne(full, signer); + + // Shard files round-robin across signers; each signer runs sequentially to + // keep account sequence numbers valid, while signer groups run in parallel. + const shardMap = signers.map((s) => ({ signer: s, files: [] })); + files.forEach((file, index) => { + shardMap[index % shardMap.length].files.push(file); + }); + + await Promise.all( + shardMap.map(async ({ signer, files: signerFiles }) => { + for (const f of signerFiles) { + const full = path.join(dirname, f); + await deployOne(full, signer.name, signer.keypair); + } + }) + ); + + console.log('Deployment shard summary:'); + for (const { signer, files: signerFiles } of shardMap) { + console.log(` ${signer.name}: ${signerFiles.length} contracts`); } } diff --git a/integration/soroban/storage_types.spec.js b/integration/soroban/storage_types.spec.js index 3ffd10746..b0bd2af32 100644 --- a/integration/soroban/storage_types.spec.js +++ b/integration/soroban/storage_types.spec.js @@ -3,7 +3,7 @@ import { readFileSync } from 'fs'; import { expect } from 'chai'; import path from 'path'; import { fileURLToPath } from 'url'; -import { call_contract_function, toSafeJson } from './test_helpers.js'; +import { call_contract_function, call_contract_view, toSafeJson } from './test_helpers.js'; import { Server } from '@stellar/stellar-sdk/rpc'; const __filename = fileURLToPath(import.meta.url); @@ -26,19 +26,19 @@ describe('StorageTypes', () => { }); it('check initial values', async () => { - let res = await call_contract_function("sesa", server, keypair, contract); + let res = await call_contract_view("sesa", server, keypair, contract); expect(res.status, `sesa() call failed: ${toSafeJson(res)}`).to.equal("SUCCESS"); expect(res.returnValue, `unexpected sesa: ${toSafeJson(res)}`).to.equal(1n); - res = await call_contract_function("sesa1", server, keypair, contract); + res = await call_contract_view("sesa1", server, keypair, contract); expect(res.status, `sesa1() call failed: ${toSafeJson(res)}`).to.equal("SUCCESS"); expect(res.returnValue, `unexpected sesa1: ${toSafeJson(res)}`).to.equal(1n); - res = await call_contract_function("sesa2", server, keypair, contract); + res = await call_contract_view("sesa2", server, keypair, contract); expect(res.status, `sesa2() call failed: ${toSafeJson(res)}`).to.equal("SUCCESS"); expect(res.returnValue, `unexpected sesa2: ${toSafeJson(res)}`).to.equal(2n); - res = await call_contract_function("sesa3", server, keypair, contract); + res = await call_contract_view("sesa3", server, keypair, contract); expect(res.status, `sesa3() call failed: ${toSafeJson(res)}`).to.equal("SUCCESS"); expect(res.returnValue, `unexpected sesa3: ${toSafeJson(res)}`).to.equal(2n); }); @@ -47,16 +47,16 @@ describe('StorageTypes', () => { let incRes = await call_contract_function("inc", server, keypair, contract); expect(incRes.status, `inc() call failed: ${toSafeJson(incRes)}`).to.equal("SUCCESS"); - let res = await call_contract_function("sesa", server, keypair, contract); + let res = await call_contract_view("sesa", server, keypair, contract); expect(res.returnValue).to.equal(2n); - res = await call_contract_function("sesa1", server, keypair, contract); + res = await call_contract_view("sesa1", server, keypair, contract); expect(res.returnValue).to.equal(2n); - res = await call_contract_function("sesa2", server, keypair, contract); + res = await call_contract_view("sesa2", server, keypair, contract); expect(res.returnValue).to.equal(3n); - res = await call_contract_function("sesa3", server, keypair, contract); + res = await call_contract_view("sesa3", server, keypair, contract); expect(res.returnValue).to.equal(3n); }); @@ -64,16 +64,16 @@ describe('StorageTypes', () => { let decRes = await call_contract_function("dec", server, keypair, contract); expect(decRes.status, `dec() call failed: ${toSafeJson(decRes)}`).to.equal("SUCCESS"); - let res = await call_contract_function("sesa", server, keypair, contract); + let res = await call_contract_view("sesa", server, keypair, contract); expect(res.returnValue).to.equal(1n); - res = await call_contract_function("sesa1", server, keypair, contract); + res = await call_contract_view("sesa1", server, keypair, contract); expect(res.returnValue).to.equal(1n); - res = await call_contract_function("sesa2", server, keypair, contract); + res = await call_contract_view("sesa2", server, keypair, contract); expect(res.returnValue).to.equal(2n); - res = await call_contract_function("sesa3", server, keypair, contract); + res = await call_contract_view("sesa3", server, keypair, contract); expect(res.returnValue).to.equal(2n); }); }); diff --git a/integration/soroban/test_helpers.js b/integration/soroban/test_helpers.js index 45da0f5c8..aa9592f27 100644 --- a/integration/soroban/test_helpers.js +++ b/integration/soroban/test_helpers.js @@ -10,8 +10,73 @@ function decodeReturnValue(scval) { return scval; } +async function buildContractCallTransaction(server, keypair, contract, method, params) { + return new StellarSdk.TransactionBuilder(await server.getAccount(keypair.publicKey()), { + fee: StellarSdk.BASE_FEE, + networkPassphrase: StellarSdk.Networks.TESTNET, + }) + .addOperation(contract.call(method, ...params)) + .setTimeout(30) + .build(); +} + +function extractSimulationRetval(simulation) { + const candidate = simulation?.result?.retval ?? simulation?.results?.[0]?.retval; + if (!candidate) return null; + + if (candidate && typeof candidate.switch === 'function') return candidate; + if (typeof candidate === 'string') { + return StellarSdk.xdr.ScVal.fromXDR(candidate, 'base64'); + } + if (candidate && typeof candidate.toXDR === 'function') return candidate; + return null; +} + +export async function call_contract_view(method, server, keypair, contract, ...params) { + const result = { + status: null, + returnValue: null, + error: null, + raw: null, + }; + + try { + const builtTransaction = await buildContractCallTransaction( + server, + keypair, + contract, + method, + params + ); + const simulation = await server.simulateTransaction(builtTransaction); + result.raw = simulation; + + const simulationError = + simulation?.error ?? + simulation?.result?.error ?? + simulation?.results?.[0]?.error; + + if (simulationError) { + result.status = 'ERROR'; + result.error = `Simulation failed: ${JSON.stringify(simulationError)}`; + return result; + } + + const retval = extractSimulationRetval(simulation); + result.status = 'SUCCESS'; + if (retval) { + result.returnValue = decodeReturnValue(retval); + } + } catch (err) { + result.status = 'ERROR'; + result.error = `Exception: ${err.toString()}`; + } + + return result; +} + export async function call_contract_function(method, server, keypair, contract, ...params) { - let result = { + const result = { status: null, returnValue: null, error: null, @@ -19,41 +84,34 @@ export async function call_contract_function(method, server, keypair, contract, }; try { - let builtTransaction = new StellarSdk.TransactionBuilder(await server.getAccount(keypair.publicKey()), { - fee: StellarSdk.BASE_FEE, - networkPassphrase: StellarSdk.Networks.TESTNET, - }) - .addOperation(contract.call(method, ...params)) - .setTimeout(30) - .build(); - - let preparedTransaction = await server.prepareTransaction(builtTransaction); + const builtTransaction = await buildContractCallTransaction( + server, + keypair, + contract, + method, + params + ); + const preparedTransaction = await server.prepareTransaction(builtTransaction); preparedTransaction.sign(keypair); - let sendResponse = await server.sendTransaction(preparedTransaction); + const sendResponse = await server.sendTransaction(preparedTransaction); if (sendResponse.status === "PENDING") { - let getResponse = await server.getTransaction(sendResponse.hash); - while (getResponse.status === "NOT_FOUND") { - console.log("Waiting for transaction confirmation..."); - await new Promise((resolve) => setTimeout(resolve, 1000)); - getResponse = await server.getTransaction(sendResponse.hash); - } - - result.raw = getResponse; + const finalResponse = await server.pollTransaction(sendResponse.hash); + result.raw = finalResponse; - if (getResponse.status === "SUCCESS") { + if (finalResponse.status === "SUCCESS") { result.status = "SUCCESS"; - if (getResponse.returnValue) { + if (finalResponse.returnValue) { try { - result.returnValue = decodeReturnValue(getResponse.returnValue); + result.returnValue = decodeReturnValue(finalResponse.returnValue); } catch (e) { result.error = "Failed to decode returnValue: " + e.toString(); } } } else { result.status = "ERROR"; - result.error = "Transaction failed: " + (getResponse.resultXdr || JSON.stringify(getResponse)); + result.error = "Transaction failed: " + (finalResponse.resultXdr || JSON.stringify(finalResponse)); } } else if (sendResponse.status === "FAILED") { result.status = "ERROR"; diff --git a/integration/soroban/timelock.sol b/integration/soroban/timelock.sol new file mode 100644 index 000000000..7d77e7a7e --- /dev/null +++ b/integration/soroban/timelock.sol @@ -0,0 +1,65 @@ +contract timelock { + enum TimeBoundKind { + Before, + After + } + + enum BalanceState { + Uninitialized, + Funded, + Claimed + } + + BalanceState public state; + TimeBoundKind mode; + uint64 public amount; + uint64 public bound_timestamp; + + function deposit( + address from, + address token_, + uint64 amount_, + TimeBoundKind mode_, + uint64 bound_timestamp_ + ) public { + require( + state == BalanceState.Uninitialized, + "contract has been already initialized" + ); + + from.requireAuth(); + + amount = amount_; + mode = mode_; + bound_timestamp = bound_timestamp_; + + bytes payload = abi.encode("transfer", from, address(this), amount_); + token_.call(payload); + + state = BalanceState.Funded; + } + + function claim(address token_, address claimant) public { + claimant.requireAuth(); + + require(state == BalanceState.Funded, "balance is not claimable"); + require(check_time_bound(), "time predicate is not fulfilled"); + + state = BalanceState.Claimed; + + bytes memory payload = abi.encode("transfer", address(this), claimant, amount); + token_.call(payload); + } + + function now_ts() public view returns (uint64) { + return block.timestamp; + } + + function check_time_bound() internal view returns (bool) { + if (mode == TimeBoundKind.After) { + return block.timestamp >= bound_timestamp; + } + + return block.timestamp <= bound_timestamp; + } +} diff --git a/integration/soroban/timelock.spec.js b/integration/soroban/timelock.spec.js new file mode 100644 index 000000000..bf327ae57 --- /dev/null +++ b/integration/soroban/timelock.spec.js @@ -0,0 +1,102 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; +import { readFileSync } from 'fs'; +import { expect } from 'chai'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { call_contract_function, call_contract_view, toSafeJson } from './test_helpers.js'; +import { Server } from '@stellar/stellar-sdk/rpc'; + +const __filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(__filename); +const server = new Server('https://soroban-testnet.stellar.org'); + +function readContractAddress(filename) { + return readFileSync(path.join(dirname, '.stellar', 'contract-ids', filename), 'utf8').trim(); +} + +function u32(value) { + return StellarSdk.xdr.ScVal.scvU32(value); +} + +function u64(value) { + return StellarSdk.xdr.ScVal.scvU64(new StellarSdk.xdr.Uint64(BigInt(value))); +} + +describe('Timelock', () => { + let keypair; + let owner; + let timelock; + let token; + + before(async () => { + keypair = StellarSdk.Keypair.fromSecret(readFileSync('alice.txt', 'utf8').trim()); + owner = new StellarSdk.Address(keypair.publicKey()).toScVal(); + + timelock = new StellarSdk.Contract(readContractAddress('timelock.txt')); + token = new StellarSdk.Contract(readContractAddress('timelock_token.txt')); + }); + + async function mint(contract, to, amount) { + const res = await call_contract_function('mint', server, keypair, contract, to, u64(amount)); + expect(res.status, `mint failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + } + + async function balance(contract, ownerAddress) { + const res = await call_contract_view('balance', server, keypair, contract, ownerAddress); + expect(res.status, `balance failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + return res.returnValue; + } + + async function currentTimestamp() { + const res = await call_contract_view('now_ts', server, keypair, timelock); + expect(res.status, `now_ts failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + return BigInt(res.returnValue); + } + + async function timelockState() { + const res = await call_contract_view('state', server, keypair, timelock); + expect(res.status, `state failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + return Number(res.returnValue); + } + + it('rejects claim before bound for TimeBoundKind.After', async () => { + const timelockAddress = timelock.address().toScVal(); + const initialTimestamp = await currentTimestamp(); + const boundTimestamp = initialTimestamp + 3_600n; + + await mint(token, owner, 500); + + let res = await call_contract_function( + 'deposit', + server, + keypair, + timelock, + owner, + token.address().toScVal(), + u64(200), + u32(1), + u64(boundTimestamp), + ); + expect(res.status, `deposit failed: ${toSafeJson(res)}`).to.equal('SUCCESS'); + + expect(await timelockState()).to.equal(1); + expect(await balance(token, owner)).to.equal(300n); + expect(await balance(token, timelockAddress)).to.equal(200n); + + res = await call_contract_function( + 'claim', + server, + keypair, + timelock, + token.address().toScVal(), + owner, + ); + expect(res.status, `early claim unexpectedly succeeded: ${toSafeJson(res)}`).to.not.equal( + 'SUCCESS', + ); + + expect(await timelockState()).to.equal(1); + expect(await balance(token, owner)).to.equal(300n); + expect(await balance(token, timelockAddress)).to.equal(200n); + }); +}); diff --git a/integration/soroban/timelock_token.sol b/integration/soroban/timelock_token.sol new file mode 100644 index 000000000..4e00952c8 --- /dev/null +++ b/integration/soroban/timelock_token.sol @@ -0,0 +1,17 @@ +contract timelock_token { + mapping(address => uint64) public balances; + + function mint(address to, uint64 amount) public { + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, uint64 amount) public { + require(balances[from] >= amount, "insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address owner) public view returns (uint64) { + return balances[owner]; + } +} diff --git a/src/codegen/encoding/soroban_encoding.rs b/src/codegen/encoding/soroban_encoding.rs index 2f33a484e..e3a210692 100644 --- a/src/codegen/encoding/soroban_encoding.rs +++ b/src/codegen/encoding/soroban_encoding.rs @@ -140,20 +140,15 @@ pub fn soroban_decode_arg( value: 0u64.into(), }), }, - Type::Uint(64) => Expression::ShiftRight { - loc: Loc::Codegen, - ty: Type::Uint(64), - left: arg.into(), - right: Box::new(Expression::NumberLiteral { - loc: Loc::Codegen, - ty: Type::Uint(64), - value: BigInt::from(8_u64), - }), - signed: false, - }, + Type::Uint(64) => decode_u64(wrapper_cfg, vartab, arg), Type::Address(_) | Type::String => arg.clone(), + Type::Enum(enum_no) => { + let decoded = soroban_decode_arg(arg, wrapper_cfg, vartab, ns, Some(Type::Uint(32))); + decoded.cast(&Type::Enum(enum_no), ns) + } + Type::Int(128) | Type::Uint(128) => decode_i128(wrapper_cfg, vartab, arg), Type::Int(256) | Type::Uint(256) => decode_i256(wrapper_cfg, vartab, arg), @@ -406,7 +401,49 @@ pub fn soroban_encode_arg( }, } } - Type::Uint(64) | Type::Int(64) => { + Type::Enum(_) => { + let widened = Expression::ZeroExt { + loc: item.loc(), + ty: Type::Uint(64), + expr: Box::new(item.cast(&Type::Uint(32), ns)), + }; + + let shifted = Expression::ShiftLeft { + loc: item.loc(), + ty: Type::Uint(64), + left: Box::new(widened), + right: Box::new(Expression::NumberLiteral { + loc: item.loc(), + ty: Type::Uint(64), + value: 32u64.into(), + }), + }; + + Instr::Set { + loc: item.loc(), + res: obj, + expr: Expression::Add { + loc: item.loc(), + ty: Type::Uint(64), + left: Box::new(shifted), + right: Box::new(Expression::NumberLiteral { + loc: item.loc(), + ty: Type::Uint(64), + value: 4u64.into(), + }), + overflowing: false, + }, + } + } + Type::Uint(64) => { + let encoded = encode_u64(cfg, vartab, item.clone()); + Instr::Set { + loc: item.loc(), + res: obj, + expr: encoded, + } + } + Type::Int(64) => { let shift_left = Expression::ShiftLeft { loc: item.loc(), ty: Type::Uint(64), @@ -418,11 +455,7 @@ pub fn soroban_encode_arg( }), }; - let tag = match item.ty() { - Type::Uint(64) => 6, - Type::Int(64) => 7, - _ => unreachable!(), - }; + let tag = 7; let added = Expression::Add { loc: item.loc(), @@ -443,45 +476,48 @@ pub fn soroban_encode_arg( } } Type::Address(_) => { - let instr = if let Expression::Cast { loc, ty: _, expr } = item { - let address_literal = expr; - - let pointer = Expression::VectorData { - pointer: address_literal.clone(), - }; + let instr = if let Expression::Cast { + loc: _, + ty: _, + expr, + } = item.clone() + { + if let Expression::BytesLiteral { loc, ty: _, value } = *expr.clone() { + let address_literal = expr; - let pointer_extend = Expression::ZeroExt { - loc, - ty: Type::Uint(64), - expr: Box::new(pointer), - }; + let pointer = Expression::VectorData { + pointer: address_literal.clone(), + }; - let encoded = Expression::ShiftLeft { - loc, - ty: Uint(64), - left: Box::new(pointer_extend), - right: Box::new(Expression::NumberLiteral { + let pointer_extend = Expression::ZeroExt { loc, ty: Type::Uint(64), - value: BigInt::from(32), - }), - }; + expr: Box::new(pointer), + }; - let encoded = Expression::Add { - loc, - ty: Type::Uint(64), - overflowing: true, - left: Box::new(encoded), - right: Box::new(Expression::NumberLiteral { + let encoded = Expression::ShiftLeft { + loc, + ty: Uint(64), + left: Box::new(pointer_extend), + right: Box::new(Expression::NumberLiteral { + loc, + ty: Type::Uint(64), + value: BigInt::from(32), + }), + }; + + let encoded = Expression::Add { loc, ty: Type::Uint(64), - value: BigInt::from(4), - }), - }; + overflowing: true, + left: Box::new(encoded), + right: Box::new(Expression::NumberLiteral { + loc, + ty: Type::Uint(64), + value: BigInt::from(4), + }), + }; - let len = if let Expression::BytesLiteral { loc, ty: _, value } = - *address_literal.clone() - { let len = Expression::NumberLiteral { loc, ty: Type::Uint(64), @@ -499,7 +535,7 @@ pub fn soroban_encode_arg( }), }; - Expression::Add { + let len = Expression::Add { loc, ty: Type::Uint(64), left: Box::new(len), @@ -509,39 +545,41 @@ pub fn soroban_encode_arg( value: BigInt::from(4), }), overflowing: false, - } - } else { - todo!() - }; - - let str_key_temp = vartab.temp_name("str_key", &Type::Uint(64)); - let str_key_var = Expression::Variable { - loc, - ty: Type::Uint(64), - var_no: str_key_temp, - }; + }; - let soroban_str_key = Instr::Call { - res: vec![str_key_temp], - return_tys: vec![Type::Uint(64)], - call: crate::codegen::cfg::InternalCallTy::HostFunction { - name: HostFunctions::StringNewFromLinearMemory.name().to_string(), - }, - args: vec![encoded.clone(), len.clone()], - }; + let str_key_temp = vartab.temp_name("str_key", &Type::Uint(64)); + let str_key_var = Expression::Variable { + loc, + ty: Type::Uint(64), + var_no: str_key_temp, + }; - cfg.add(vartab, soroban_str_key); + let soroban_str_key = Instr::Call { + res: vec![str_key_temp], + return_tys: vec![Type::Uint(64)], + call: crate::codegen::cfg::InternalCallTy::HostFunction { + name: HostFunctions::StringNewFromLinearMemory.name().to_string(), + }, + args: vec![encoded.clone(), len.clone()], + }; - let address_object = Instr::Call { - res: vec![obj], - return_tys: vec![Type::Uint(64)], - call: crate::codegen::cfg::InternalCallTy::HostFunction { - name: HostFunctions::StrKeyToAddr.name().to_string(), - }, - args: vec![str_key_var], - }; + cfg.add(vartab, soroban_str_key); - address_object + Instr::Call { + res: vec![obj], + return_tys: vec![Type::Uint(64)], + call: crate::codegen::cfg::InternalCallTy::HostFunction { + name: HostFunctions::StrKeyToAddr.name().to_string(), + }, + args: vec![str_key_var], + } + } else { + Instr::Set { + loc: Loc::Codegen, + res: obj, + expr: item.clone(), + } + } } else { Instr::Set { loc: Loc::Codegen, @@ -831,6 +869,123 @@ fn encode_i128( ret } +fn encode_u64(cfg: &mut ControlFlowGraph, vartab: &mut Vartable, value: Expression) -> Expression { + let ret_var = vartab.temp_anonymous(&Type::Uint(64)); + + let ret = Expression::Variable { + loc: pt::Loc::Codegen, + ty: Type::Uint(64), + var_no: ret_var, + }; + + vartab.new_dirty_tracker(); + + let fits_in_56_bits = cfg.new_basic_block("u64_fits_in_56_bits".to_string()); + let should_be_in_host = cfg.new_basic_block("u64_should_be_in_host".to_string()); + let return_block = cfg.new_basic_block("u64_finish".to_string()); + + let high_8_bits = Expression::ShiftRight { + loc: pt::Loc::Codegen, + ty: Type::Uint(64), + left: value.clone().into(), + right: Expression::NumberLiteral { + loc: pt::Loc::Codegen, + ty: Type::Uint(64), + value: BigInt::from(56_u64), + } + .into(), + signed: false, + }; + + let cond = Expression::Equal { + loc: pt::Loc::Codegen, + left: high_8_bits.into(), + right: Expression::NumberLiteral { + loc: pt::Loc::Codegen, + ty: Type::Uint(64), + value: BigInt::from(0_u64), + } + .into(), + }; + + cfg.add( + vartab, + Instr::BranchCond { + cond, + true_block: fits_in_56_bits, + false_block: should_be_in_host, + }, + ); + + cfg.set_basic_block(fits_in_56_bits); + + let small_value = Expression::ShiftLeft { + loc: Loc::Codegen, + ty: Type::Uint(64), + left: Box::new(value.clone()), + right: Box::new(Expression::NumberLiteral { + loc: Loc::Codegen, + ty: Type::Uint(64), + value: BigInt::from(8_u64), + }), + }; + + let small_value = Expression::Add { + loc: Loc::Codegen, + ty: Type::Uint(64), + left: small_value.into(), + right: Expression::NumberLiteral { + loc: Loc::Codegen, + ty: Type::Uint(64), + value: BigInt::from(6_u64), + } + .into(), + overflowing: false, + }; + + cfg.add( + vartab, + Instr::Set { + loc: pt::Loc::Codegen, + res: ret_var, + expr: small_value, + }, + ); + + cfg.add( + vartab, + Instr::Branch { + block: return_block, + }, + ); + + cfg.set_basic_block(should_be_in_host); + + cfg.add( + vartab, + Instr::Call { + res: vec![ret_var], + return_tys: vec![Type::Uint(64)], + call: InternalCallTy::HostFunction { + name: HostFunctions::ObjFromU64.name().to_string(), + }, + args: vec![value], + }, + ); + + cfg.add( + vartab, + Instr::Branch { + block: return_block, + }, + ); + + cfg.set_basic_block(return_block); + cfg.set_phis(return_block, vartab.pop_dirty_tracker()); + + ret +} + /// Encodes a 256-bit integer (signed or unsigned) into a Soroban ScVal. /// This function handles both Int256 and Uint256 types by splitting them into /// four 64-bit pieces and using the appropriate host functions. @@ -1315,6 +1470,164 @@ fn decode_i256(cfg: &mut ControlFlowGraph, vartab: &mut Vartable, arg: Expressio ret } +fn decode_u64(cfg: &mut ControlFlowGraph, vartab: &mut Vartable, arg: Expression) -> Expression { + let ty = match arg.ty() { + Type::Ref(inner_ty) => *inner_ty.clone(), + Type::SorobanHandle(inner_ty) => *inner_ty.clone(), + _ => arg.ty(), + }; + + let ret_var = vartab.temp_anonymous(&ty); + + let ret = Expression::Variable { + loc: pt::Loc::Codegen, + ty: ty.clone(), + var_no: ret_var, + }; + + vartab.new_dirty_tracker(); + + let tag = extract_tag(arg.clone()); + + let val_is_u64_small = cfg.new_basic_block("u64_val_is_u64_small".to_string()); + let val_is_u32_small = cfg.new_basic_block("u64_val_is_u32_small".to_string()); + let val_in_host = cfg.new_basic_block("u64_val_is_host".to_string()); + let val_not_u64_small = cfg.new_basic_block("u64_val_not_u64_small".to_string()); + let return_block = cfg.new_basic_block("u64_finish".to_string()); + + let is_u64_small = Expression::Equal { + loc: pt::Loc::Codegen, + left: tag.clone().into(), + right: Expression::NumberLiteral { + loc: pt::Loc::Codegen, + ty: Type::Uint(64), + value: BigInt::from(6_u64), + } + .into(), + }; + + cfg.add( + vartab, + Instr::BranchCond { + cond: is_u64_small, + true_block: val_is_u64_small, + false_block: val_not_u64_small, + }, + ); + + cfg.set_basic_block(val_is_u64_small); + + let u64_small_value = Expression::ShiftRight { + loc: pt::Loc::Codegen, + ty: Type::Uint(64), + left: arg.clone().into(), + right: Expression::NumberLiteral { + loc: pt::Loc::Codegen, + ty: Type::Uint(64), + value: BigInt::from(8_u64), + } + .into(), + signed: false, + }; + + cfg.add( + vartab, + Instr::Set { + loc: pt::Loc::Codegen, + res: ret_var, + expr: u64_small_value, + }, + ); + + cfg.add( + vartab, + Instr::Branch { + block: return_block, + }, + ); + + cfg.set_basic_block(val_not_u64_small); + + // Some host paths (for example VecLen) produce U32Val. Allow widening it + // when decoding to uint64. + let is_u32_small = Expression::Equal { + loc: pt::Loc::Codegen, + left: tag.into(), + right: Expression::NumberLiteral { + loc: pt::Loc::Codegen, + ty: Type::Uint(64), + value: BigInt::from(4_u64), + } + .into(), + }; + + cfg.add( + vartab, + Instr::BranchCond { + cond: is_u32_small, + true_block: val_is_u32_small, + false_block: val_in_host, + }, + ); + + cfg.set_basic_block(val_is_u32_small); + + let u32_small_value = Expression::ShiftRight { + loc: pt::Loc::Codegen, + ty: Type::Uint(64), + left: arg.clone().into(), + right: Expression::NumberLiteral { + loc: pt::Loc::Codegen, + ty: Type::Uint(64), + value: BigInt::from(32_u64), + } + .into(), + signed: false, + }; + + cfg.add( + vartab, + Instr::Set { + loc: pt::Loc::Codegen, + res: ret_var, + expr: u32_small_value, + }, + ); + + cfg.add( + vartab, + Instr::Branch { + block: return_block, + }, + ); + + cfg.set_basic_block(val_in_host); + + cfg.add( + vartab, + Instr::Call { + res: vec![ret_var], + return_tys: vec![Type::Uint(64)], + call: InternalCallTy::HostFunction { + name: HostFunctions::ObjToU64.name().to_string(), + }, + args: vec![arg], + }, + ); + + cfg.add( + vartab, + Instr::Branch { + block: return_block, + }, + ); + + cfg.set_basic_block(return_block); + cfg.set_phis(return_block, vartab.pop_dirty_tracker()); + + ret +} + fn extract_tag(arg: Expression) -> Expression { let bit_mask = Expression::NumberLiteral { loc: pt::Loc::Codegen, diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index 2192348f8..f87fd8ce9 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -34,7 +34,7 @@ use self::{ vartable::Vartable, }; use crate::sema::ast::{ - FormatArg, Function, Layout, Namespace, RetrieveType, StringLocation, Type, + ArrayLength, FormatArg, Function, Layout, Namespace, RetrieveType, StringLocation, Type, }; use crate::{sema::ast, Target}; use std::cmp::Ordering; @@ -139,6 +139,7 @@ pub enum HostFunctions { VecPut, StringNewFromLinearMemory, StrKeyToAddr, + GetLedgerTimestamp, GetCurrentContractAddress, BytesNewFromLinearMemory, BytesLen, @@ -186,6 +187,7 @@ impl HostFunctions { HostFunctions::VecPushBack => "v.6", HostFunctions::StringNewFromLinearMemory => "b.i", HostFunctions::StrKeyToAddr => "a.1", + HostFunctions::GetLedgerTimestamp => "x.4", HostFunctions::GetCurrentContractAddress => "x.7", HostFunctions::BytesNewFromLinearMemory => "b.3", HostFunctions::BytesLen => "b.8", @@ -373,9 +375,18 @@ fn storage_initializer(contract_no: usize, ns: &mut Namespace, opt: &Options) -> for layout in &ns.contracts[contract_no].layout { let var = &ns.contracts[layout.contract_no].variables[layout.var_no]; + let soroban_init_with_vec = ns.target == Target::Soroban + && match &var.ty { + Type::String | Type::DynamicBytes | Type::Slice(_) => true, + Type::Array(elem_ty, dims) if dims.last() == Some(&ArrayLength::Dynamic) => { + !elem_ty.is_reference_type(ns) + } + _ => false, + }; + let mut value = if let Some(init) = &var.initializer { expression(init, &mut cfg, contract_no, None, ns, &mut vartab, opt) - } else if ns.target == Target::Soroban && var.ty.is_dynamic_memory() { + } else if soroban_init_with_vec { soroban::soroban_vec_new(&var.loc, &var.ty, &mut cfg, &mut vartab) } else { continue; diff --git a/src/emit/soroban/mod.rs b/src/emit/soroban/mod.rs index 97375a893..542a2626c 100644 --- a/src/emit/soroban/mod.rs +++ b/src/emit/soroban/mod.rs @@ -117,6 +117,7 @@ impl HostFunctions { .i64_type() .fn_type(&[ty.into(), ty.into()], false), HostFunctions::StrKeyToAddr => bin.context.i64_type().fn_type(&[ty.into()], false), + HostFunctions::GetLedgerTimestamp => bin.context.i64_type().fn_type(&[], false), HostFunctions::GetCurrentContractAddress => bin.context.i64_type().fn_type(&[], false), HostFunctions::ObjToI128Lo64 => bin.context.i64_type().fn_type(&[ty.into()], false), HostFunctions::ObjToI128Hi64 => bin.context.i64_type().fn_type(&[ty.into()], false), @@ -172,6 +173,7 @@ impl SorobanTarget { } ast::Type::Uint(32) => ScSpecTypeDef::U32, ast::Type::Int(32) => ScSpecTypeDef::I32, + ast::Type::Enum(_) => ScSpecTypeDef::U32, ast::Type::Uint(64) => ScSpecTypeDef::U64, ast::Type::Int(64) => ScSpecTypeDef::I64, ast::Type::Int(128) => ScSpecTypeDef::I128, @@ -327,6 +329,7 @@ impl SorobanTarget { match ty { ast::Type::Uint(32) => ScSpecTypeDef::U32, ast::Type::Int(32) => ScSpecTypeDef::I32, + ast::Type::Enum(_) => ScSpecTypeDef::U32, ast::Type::Uint(64) => ScSpecTypeDef::U64, &ast::Type::Int(64) => ScSpecTypeDef::I64, ast::Type::Int(128) => ScSpecTypeDef::I128, @@ -364,6 +367,7 @@ impl SorobanTarget { match ty { ast::Type::Uint(32) => ScSpecTypeDef::U32, ast::Type::Int(32) => ScSpecTypeDef::I32, + ast::Type::Enum(_) => ScSpecTypeDef::U32, ast::Type::Uint(64) => ScSpecTypeDef::U64, ast::Type::Int(64) => ScSpecTypeDef::I64, ast::Type::Int(128) => ScSpecTypeDef::I128, @@ -458,6 +462,7 @@ impl SorobanTarget { HostFunctions::VecPut, HostFunctions::StringNewFromLinearMemory, HostFunctions::StrKeyToAddr, + HostFunctions::GetLedgerTimestamp, HostFunctions::GetCurrentContractAddress, HostFunctions::BytesNewFromLinearMemory, HostFunctions::BytesCopyToLinearMemory, diff --git a/src/emit/soroban/target.rs b/src/emit/soroban/target.rs index a8838574e..eb30f29f9 100644 --- a/src/emit/soroban/target.rs +++ b/src/emit/soroban/target.rs @@ -735,6 +735,111 @@ impl<'a> TargetRuntime<'a> for SorobanTarget { emit_context!(bin); match expr { + Expression::Builtin { + kind: Builtin::Timestamp, + args, + .. + } => { + assert_eq!(args.len(), 0, "timestamp expects no arguments"); + + let function_name = HostFunctions::GetLedgerTimestamp.name(); + let function_value = bin.module.get_function(function_name).unwrap(); + let timestamp_val = bin + .builder + .build_call(function_value, &[], function_name) + .unwrap() + .try_as_basic_value() + .left() + .unwrap() + .into_int_value(); + + // Decode U64Val: U64Small values are immediate, otherwise decode U64Object via ObjToU64. + let tag = bin + .builder + .build_and( + timestamp_val, + bin.context.i64_type().const_int(0xff, false), + "timestamp_tag", + ) + .unwrap(); + let is_u64_small = bin + .builder + .build_int_compare( + inkwell::IntPredicate::EQ, + tag, + bin.context.i64_type().const_int(6, false), // Tag::U64Small + "is_u64_small", + ) + .unwrap(); + + let value_is_small = bin + .context + .append_basic_block(function, "timestamp_value_is_small"); + let value_is_object = bin + .context + .append_basic_block(function, "timestamp_value_is_object"); + let value_decoded = bin + .context + .append_basic_block(function, "timestamp_value_decoded"); + + bin.builder + .build_conditional_branch(is_u64_small, value_is_small, value_is_object) + .unwrap(); + + bin.builder.position_at_end(value_is_small); + + let small_value = bin + .builder + .build_right_shift( + timestamp_val, + bin.context.i64_type().const_int(8, false), + false, + "timestamp_small_value", + ) + .unwrap(); + + bin.builder + .build_unconditional_branch(value_decoded) + .unwrap(); + + let small_value_block = bin.builder.get_insert_block().unwrap(); + + bin.builder.position_at_end(value_is_object); + + let decode_function_name = HostFunctions::ObjToU64.name(); + let decode_function_value = bin.module.get_function(decode_function_name).unwrap(); + let object_value = bin + .builder + .build_call( + decode_function_value, + &[timestamp_val.into()], + decode_function_name, + ) + .unwrap() + .try_as_basic_value() + .left() + .unwrap() + .into_int_value(); + + bin.builder + .build_unconditional_branch(value_decoded) + .unwrap(); + + let object_value_block = bin.builder.get_insert_block().unwrap(); + + bin.builder.position_at_end(value_decoded); + + let timestamp = bin + .builder + .build_phi(bin.context.i64_type(), "timestamp") + .unwrap(); + timestamp.add_incoming(&[ + (&small_value, small_value_block), + (&object_value, object_value_block), + ]); + + timestamp.as_basic_value() + } Expression::Builtin { kind: Builtin::ExtendTtl, args, @@ -1142,6 +1247,7 @@ pub fn type_to_tagged_zero_val<'ctx>(bin: &Binary<'ctx>, ty: &Type) -> IntValue< Type::Bool => 0, // Tag::False Type::Uint(32) => 4, // Tag::U32Val Type::Int(32) => 5, // Tag::I32Val + Type::Enum(_) => 4, // Tag::U32Val Type::Uint(64) => 6, // Tag::U64Small Type::Int(64) => 7, // Tag::I64Small Type::Uint(128) => 10, // Tag::U128Small diff --git a/tests/soroban_testcases/atomic_swap.rs b/tests/soroban_testcases/atomic_swap.rs new file mode 100644 index 000000000..8312c15b8 --- /dev/null +++ b/tests/soroban_testcases/atomic_swap.rs @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 + +use crate::SorobanEnv; +use soroban_sdk::{testutils::Address as _, Address, IntoVal, Val}; + +const TOKEN_SRC: &str = r#" +contract token { + address public admin; + uint32 public decimals; + string public name; + string public symbol; + + constructor(address _admin, string memory _name, string memory _symbol, uint32 _decimals) { + admin = _admin; + name = _name; + symbol = _symbol; + decimals = _decimals; + } + + mapping(address => int128) public balances; + + function mint(address to, int128 amount) public { + require(amount >= 0, "Amount must be non-negative"); + admin.requireAuth(); + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, int128 amount) public { + require(amount >= 0, "Amount must be non-negative"); + from.requireAuth(); + require(balances[from] >= amount, "Insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address addr) public view returns (int128) { + return balances[addr]; + } +} +"#; + +const ATOMIC_SWAP_SRC: &str = r#" +contract atomic_swap { + function swap( + address a, + address b, + address token_a, + address token_b, + int128 amount_a, + int128 min_b_for_a, + int128 amount_b, + int128 min_a_for_b + ) public { + require(amount_b >= min_b_for_a, "not enough token B for token A"); + require(amount_a >= min_a_for_b, "not enough token A for token B"); + + a.requireAuth(); + b.requireAuth(); + + move_token(token_a, a, b, amount_a, min_a_for_b); + move_token(token_b, b, a, amount_b, min_b_for_a); + } + + function move_token( + address token, + address from, + address to, + int128 max_spend_amount, + int128 transfer_amount + ) internal { + address contract_address = address(this); + + bytes payload = abi.encode("transfer", from, contract_address, max_spend_amount); + (bool success, bytes returndata) = token.call(payload); + + payload = abi.encode("transfer", contract_address, to, transfer_amount); + (success, returndata) = token.call(payload); + + payload = abi.encode( + "transfer", + contract_address, + from, + max_spend_amount - transfer_amount + ); + (success, returndata) = token.call(payload); + } +} +"#; + +fn deploy_token(runtime: &mut SorobanEnv, name: &str, symbol: &str) -> Address { + let admin = Address::generate(&runtime.env); + let decimals: Val = 18_u32.into_val(&runtime.env); + let name = soroban_sdk::String::from_str(&runtime.env, name); + let symbol = soroban_sdk::String::from_str(&runtime.env, symbol); + + runtime.deploy_contract_with_args(TOKEN_SRC, (admin, name, symbol, decimals)) +} + +fn mint(runtime: &SorobanEnv, token: &Address, to: &Address, amount: i128) { + runtime.invoke_contract( + token, + "mint", + vec![ + to.clone().into_val(&runtime.env), + amount.into_val(&runtime.env), + ], + ); +} + +fn assert_balance(runtime: &SorobanEnv, token: &Address, owner: &Address, expected: i128) { + let balance = + runtime.invoke_contract(token, "balance", vec![owner.clone().into_val(&runtime.env)]); + let expected: Val = expected.into_val(&runtime.env); + + assert!(expected.shallow_eq(&balance)); +} + +#[test] +fn atomic_swap_end_to_end_test() { + let mut runtime = SorobanEnv::new(); + + let token_a = deploy_token(&mut runtime, "Token A", "TKA"); + let token_b = deploy_token(&mut runtime, "Token B", "TKB"); + let swap = runtime.deploy_contract(ATOMIC_SWAP_SRC); + + runtime.env.mock_all_auths(); + + let a = Address::generate(&runtime.env); + let b = Address::generate(&runtime.env); + + mint(&runtime, &token_a, &a, 100); + mint(&runtime, &token_b, &b, 80); + + runtime.invoke_contract( + &swap, + "swap", + vec![ + a.clone().into_val(&runtime.env), + b.clone().into_val(&runtime.env), + token_a.clone().into_val(&runtime.env), + token_b.clone().into_val(&runtime.env), + 40_i128.into_val(&runtime.env), + 30_i128.into_val(&runtime.env), + 50_i128.into_val(&runtime.env), + 35_i128.into_val(&runtime.env), + ], + ); + + assert_balance(&runtime, &token_a, &a, 65); + assert_balance(&runtime, &token_a, &b, 35); + assert_balance(&runtime, &token_a, &swap, 0); + + assert_balance(&runtime, &token_b, &a, 30); + assert_balance(&runtime, &token_b, &b, 50); + assert_balance(&runtime, &token_b, &swap, 0); +} + +#[test] +fn atomic_swap_rejects_when_min_price_not_met() { + let mut runtime = SorobanEnv::new(); + + let token_a = deploy_token(&mut runtime, "Token A", "TKA"); + let token_b = deploy_token(&mut runtime, "Token B", "TKB"); + let swap = runtime.deploy_contract(ATOMIC_SWAP_SRC); + + runtime.env.mock_all_auths(); + + let a = Address::generate(&runtime.env); + let b = Address::generate(&runtime.env); + + mint(&runtime, &token_a, &a, 100); + mint(&runtime, &token_b, &b, 80); + + let logs = runtime.invoke_contract_expect_error( + &swap, + "swap", + vec![ + a.clone().into_val(&runtime.env), + b.clone().into_val(&runtime.env), + token_a.clone().into_val(&runtime.env), + token_b.clone().into_val(&runtime.env), + 40_i128.into_val(&runtime.env), + 60_i128.into_val(&runtime.env), + 50_i128.into_val(&runtime.env), + 35_i128.into_val(&runtime.env), + ], + ); + + assert!(logs + .iter() + .any(|entry| entry.contains("require condition failed"))); + + assert_balance(&runtime, &token_a, &a, 100); + assert_balance(&runtime, &token_a, &b, 0); + assert_balance(&runtime, &token_a, &swap, 0); + + assert_balance(&runtime, &token_b, &a, 0); + assert_balance(&runtime, &token_b, &b, 80); + assert_balance(&runtime, &token_b, &swap, 0); +} diff --git a/tests/soroban_testcases/liquidity_pool.rs b/tests/soroban_testcases/liquidity_pool.rs new file mode 100644 index 000000000..e5cbc472d --- /dev/null +++ b/tests/soroban_testcases/liquidity_pool.rs @@ -0,0 +1,349 @@ +// SPDX-License-Identifier: Apache-2.0 + +use crate::SorobanEnv; +use soroban_sdk::{testutils::Address as _, Address, IntoVal, Val}; + +const TOKEN_SRC: &str = r#" +contract token { + mapping(address => uint64) public balances; + + function mint(address to, uint64 amount) public { + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, uint64 amount) public { + require(balances[from] >= amount, "Insufficient balance"); + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address owner) public view returns (uint64) { + return balances[owner]; + } +} +"#; + +const POOL_SRC: &str = r#" +contract liquidity_pool { + uint64 public total_shares; + uint64 public reserve_a; + uint64 public reserve_b; + mapping(address => uint64) public shares; + + function balance_shares(address user) public view returns (uint64) { + return shares[user]; + } + + function deposit( + address to, + address token_a, + address token_b, + uint64 desired_a, + uint64 min_a, + uint64 desired_b, + uint64 min_b + ) public { + to.requireAuth(); + + uint64 amount_a = desired_a; + uint64 amount_b = desired_b; + + if (!(reserve_a == 0 && reserve_b == 0)) { + uint64 optimal_b = (desired_a * reserve_b) / reserve_a; + if (optimal_b <= desired_b) { + require(optimal_b >= min_b, "amount_b less than min"); + amount_b = optimal_b; + } else { + uint64 optimal_a = (desired_b * reserve_a) / reserve_b; + require(optimal_a <= desired_a && optimal_a >= min_a, "amount_a invalid"); + amount_a = optimal_a; + } + } + + require(amount_a > 0 && amount_b > 0, "both amounts must be > 0"); + + token_transfer(token_a, to, address(this), amount_a); + token_transfer(token_b, to, address(this), amount_b); + + uint64 balance_a = token_balance(token_a, address(this)); + uint64 balance_b = token_balance(token_b, address(this)); + + uint64 new_total_shares = total_shares; + if (reserve_a > 0 && reserve_b > 0) { + uint64 shares_a = (balance_a * total_shares) / reserve_a; + uint64 shares_b = (balance_b * total_shares) / reserve_b; + new_total_shares = shares_a < shares_b ? shares_a : shares_b; + } else { + new_total_shares = isqrt(balance_a * balance_b); + } + + require(new_total_shares >= total_shares, "invalid share growth"); + + uint64 minted = new_total_shares - total_shares; + shares[to] = shares[to] + minted; + total_shares = total_shares + minted; + reserve_a = balance_a; + reserve_b = balance_b; + } + + function swap_buy_a( + address to, + address token_a, + address token_b, + uint64 out, + uint64 in_max + ) public { + swap_internal(to, token_a, token_b, true, out, in_max); + } + + function swap_internal( + address to, + address token_a, + address token_b, + bool buy_a, + uint64 out, + uint64 in_max + ) internal { + to.requireAuth(); + + uint64 sell_reserve = buy_a ? reserve_b : reserve_a; + uint64 buy_reserve = buy_a ? reserve_a : reserve_b; + + require(buy_reserve > out, "not enough token to buy"); + + uint64 n = sell_reserve * out * 1000; + uint64 d = (buy_reserve - out) * 997; + uint64 sell_amount = (n / d) + 1; + require(sell_amount <= in_max, "in amount is over max"); + + address sell_token = buy_a ? token_b : token_a; + address buy_token = buy_a ? token_a : token_b; + + token_transfer(sell_token, to, address(this), sell_amount); + token_transfer(buy_token, address(this), to, out); + + reserve_a = token_balance(token_a, address(this)); + reserve_b = token_balance(token_b, address(this)); + require(reserve_a > 0 && reserve_b > 0, "new reserves must be > 0"); + } + + function withdraw( + address to, + address token_a, + address token_b, + uint64 share_amount, + uint64 min_a, + uint64 min_b + ) public { + to.requireAuth(); + require(shares[to] >= share_amount, "insufficient shares"); + require(total_shares > 0, "no total shares"); + + uint64 balance_a = token_balance(token_a, address(this)); + uint64 balance_b = token_balance(token_b, address(this)); + + uint64 out_a = (balance_a * share_amount) / total_shares; + uint64 out_b = (balance_b * share_amount) / total_shares; + + require(out_a >= min_a && out_b >= min_b, "min not satisfied"); + + shares[to] = shares[to] - share_amount; + total_shares = total_shares - share_amount; + + token_transfer(token_a, address(this), to, out_a); + token_transfer(token_b, address(this), to, out_b); + + reserve_a = token_balance(token_a, address(this)); + reserve_b = token_balance(token_b, address(this)); + } + + function token_transfer(address token, address from, address to, uint64 amount) internal { + bytes payload = abi.encode("transfer", from, to, amount); + (bool success, bytes memory returndata) = token.call(payload); + success; + returndata; + } + + function token_balance(address token, address owner) internal returns (uint64) { + bytes payload = abi.encode("balance", owner); + (bool success, bytes memory returndata) = token.call(payload); + success; + return abi.decode(returndata, (uint64)); + } + + function isqrt(uint64 x) internal pure returns (uint64) { + if (x == 0) { + return 0; + } + + uint64 y = x; + uint64 z = (x + 1) / 2; + while (z < y) { + y = z; + z = (x / z + z) / 2; + } + + return y; + } +} +"#; + +fn mint(runtime: &SorobanEnv, token: &Address, to: &Address, amount: u64) { + runtime.invoke_contract( + token, + "mint", + vec![ + to.clone().into_val(&runtime.env), + amount.into_val(&runtime.env), + ], + ); +} + +fn assert_u64(runtime: &SorobanEnv, actual: Val, expected: u64) { + let expected: Val = expected.into_val(&runtime.env); + assert!(expected.shallow_eq(&actual)); +} + +fn assert_token_balance(runtime: &SorobanEnv, token: &Address, owner: &Address, expected: u64) { + let res = runtime.invoke_contract(token, "balance", vec![owner.clone().into_val(&runtime.env)]); + assert_u64(runtime, res, expected); +} + +#[test] +fn liquidity_pool_deposit_swap_withdraw() { + let mut runtime = SorobanEnv::new(); + let token_a = runtime.deploy_contract(TOKEN_SRC); + let token_b = runtime.deploy_contract(TOKEN_SRC); + let pool = runtime.deploy_contract(POOL_SRC); + + runtime.env.mock_all_auths(); + + let owner = Address::generate(&runtime.env); + + mint(&runtime, &token_a, &owner, 100_000); + mint(&runtime, &token_b, &owner, 100_000); + + runtime.invoke_contract( + &pool, + "deposit", + vec![ + owner.clone().into_val(&runtime.env), + token_a.clone().into_val(&runtime.env), + token_b.clone().into_val(&runtime.env), + 10_000_u64.into_val(&runtime.env), + 9_000_u64.into_val(&runtime.env), + 20_000_u64.into_val(&runtime.env), + 18_000_u64.into_val(&runtime.env), + ], + ); + + let reserve_a = runtime.invoke_contract(&pool, "reserve_a", vec![]); + let reserve_b = runtime.invoke_contract(&pool, "reserve_b", vec![]); + let shares = runtime.invoke_contract( + &pool, + "balance_shares", + vec![owner.clone().into_val(&runtime.env)], + ); + + assert_u64(&runtime, reserve_a, 10_000); + assert_u64(&runtime, reserve_b, 20_000); + assert_u64(&runtime, shares, 14_142); + + runtime.invoke_contract( + &pool, + "swap_buy_a", + vec![ + owner.clone().into_val(&runtime.env), + token_a.clone().into_val(&runtime.env), + token_b.clone().into_val(&runtime.env), + 1_000_u64.into_val(&runtime.env), + 3_000_u64.into_val(&runtime.env), + ], + ); + + let reserve_a = runtime.invoke_contract(&pool, "reserve_a", vec![]); + let reserve_b = runtime.invoke_contract(&pool, "reserve_b", vec![]); + assert_u64(&runtime, reserve_a, 9_000); + assert_u64(&runtime, reserve_b, 22_229); + + runtime.invoke_contract( + &pool, + "withdraw", + vec![ + owner.clone().into_val(&runtime.env), + token_a.clone().into_val(&runtime.env), + token_b.clone().into_val(&runtime.env), + 7_071_u64.into_val(&runtime.env), + 0_u64.into_val(&runtime.env), + 0_u64.into_val(&runtime.env), + ], + ); + + let reserve_a = runtime.invoke_contract(&pool, "reserve_a", vec![]); + let reserve_b = runtime.invoke_contract(&pool, "reserve_b", vec![]); + let shares = runtime.invoke_contract( + &pool, + "balance_shares", + vec![owner.clone().into_val(&runtime.env)], + ); + + assert_u64(&runtime, reserve_a, 4_500); + assert_u64(&runtime, reserve_b, 11_115); + assert_u64(&runtime, shares, 7_071); + + assert_token_balance(&runtime, &token_a, &owner, 95_500); + assert_token_balance(&runtime, &token_b, &owner, 88_885); +} + +#[test] +fn liquidity_pool_swap_respects_in_max() { + let mut runtime = SorobanEnv::new(); + let token_a = runtime.deploy_contract(TOKEN_SRC); + let token_b = runtime.deploy_contract(TOKEN_SRC); + let pool = runtime.deploy_contract(POOL_SRC); + + runtime.env.mock_all_auths(); + + let owner = Address::generate(&runtime.env); + + mint(&runtime, &token_a, &owner, 100_000); + mint(&runtime, &token_b, &owner, 100_000); + + runtime.invoke_contract( + &pool, + "deposit", + vec![ + owner.clone().into_val(&runtime.env), + token_a.clone().into_val(&runtime.env), + token_b.clone().into_val(&runtime.env), + 10_000_u64.into_val(&runtime.env), + 9_000_u64.into_val(&runtime.env), + 20_000_u64.into_val(&runtime.env), + 18_000_u64.into_val(&runtime.env), + ], + ); + + let logs = runtime.invoke_contract_expect_error( + &pool, + "swap_buy_a", + vec![ + owner.clone().into_val(&runtime.env), + token_a.clone().into_val(&runtime.env), + token_b.clone().into_val(&runtime.env), + 1_000_u64.into_val(&runtime.env), + 1_000_u64.into_val(&runtime.env), + ], + ); + + assert!(logs + .iter() + .any(|entry| entry.contains("require condition failed"))); + + let reserve_a = runtime.invoke_contract(&pool, "reserve_a", vec![]); + let reserve_b = runtime.invoke_contract(&pool, "reserve_b", vec![]); + assert_u64(&runtime, reserve_a, 10_000); + assert_u64(&runtime, reserve_b, 20_000); + + assert_token_balance(&runtime, &token_a, &owner, 90_000); + assert_token_balance(&runtime, &token_b, &owner, 80_000); +} diff --git a/tests/soroban_testcases/mod.rs b/tests/soroban_testcases/mod.rs index 348301489..cdd404e6f 100644 --- a/tests/soroban_testcases/mod.rs +++ b/tests/soroban_testcases/mod.rs @@ -1,17 +1,21 @@ // SPDX-License-Identifier: Apache-2.0 mod alloc; mod array_args; +mod atomic_swap; mod auth; mod constructor; mod cross_contract_calls; mod i256_u256; mod integer_width_rounding; mod integer_width_warnings; +mod liquidity_pool; mod mappings; mod math; mod print; mod storage; mod storage_array; mod structs; +mod timelock; +mod timestamp; mod token; mod ttl; diff --git a/tests/soroban_testcases/timelock.rs b/tests/soroban_testcases/timelock.rs new file mode 100644 index 000000000..e2e516388 --- /dev/null +++ b/tests/soroban_testcases/timelock.rs @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: Apache-2.0 + +use crate::SorobanEnv; +use soroban_sdk::{testutils::Address as _, testutils::Ledger, Address, IntoVal, Val}; + +const TOKEN_SRC: &str = r#" +contract token { + mapping(address => int128) public balances; + + function mint(address to, int128 amount) public { + require(amount >= 0, "amount must be non-negative"); + balances[to] = balances[to] + amount; + } + + function transfer(address from, address to, int128 amount) public { + require(amount >= 0, "amount must be non-negative"); + require(balances[from] >= amount, "insufficient balance"); + + balances[from] = balances[from] - amount; + balances[to] = balances[to] + amount; + } + + function balance(address owner) public view returns (int128) { + return balances[owner]; + } +} +"#; + +const TIMELOCK_SRC: &str = r#" +contract claimable_balance { + enum TimeBoundKind { + Before, + After + } + + enum BalanceState { + Uninitialized, + Funded, + Claimed + } + + BalanceState public state; + TimeBoundKind mode; + int128 public amount; + uint64 public bound_timestamp; + + function deposit( + address from, + address token_, + int128 amount_, + TimeBoundKind mode_, + uint64 bound_timestamp_ + ) public { + require( + state == BalanceState.Uninitialized, + "contract has been already initialized" + ); + + from.requireAuth(); + + amount = amount_; + mode = mode_; + bound_timestamp = bound_timestamp_; + + address contract_address = address(this); + bytes payload = abi.encode("transfer", from, contract_address, amount_); + (bool success, bytes memory returndata) = token_.call(payload); + success; + returndata; + + state = BalanceState.Funded; + } + + function claim(address token_, address claimant) public { + claimant.requireAuth(); + + require(state == BalanceState.Funded, "balance is not claimable"); + require(check_time_bound(), "time predicate is not fulfilled"); + + state = BalanceState.Claimed; + + address contract_address = address(this); + bytes memory payload = abi.encode("transfer", contract_address, claimant, amount); + (bool success, bytes memory returndata) = token_.call(payload); + success; + returndata; + } + + function check_time_bound() internal view returns (bool) { + if (mode == TimeBoundKind.After) { + return block.timestamp >= bound_timestamp; + } + + return block.timestamp <= bound_timestamp; + } +} +"#; + +fn mint(runtime: &SorobanEnv, token: &Address, to: &Address, amount: i128) { + runtime.invoke_contract( + token, + "mint", + vec![ + to.clone().into_val(&runtime.env), + amount.into_val(&runtime.env), + ], + ); +} + +fn assert_balance(runtime: &SorobanEnv, token: &Address, owner: &Address, expected: i128) { + let actual = + runtime.invoke_contract(token, "balance", vec![owner.clone().into_val(&runtime.env)]); + let expected: Val = expected.into_val(&runtime.env); + assert!(expected.shallow_eq(&actual)); +} + +#[test] +fn timelock_after_rejects_early_and_allows_at_boundary() { + let mut runtime = SorobanEnv::new(); + runtime.env.mock_all_auths(); + runtime.env.ledger().set_timestamp(900); + + let token = runtime.deploy_contract(TOKEN_SRC); + let timelock = runtime.deploy_contract(TIMELOCK_SRC); + + let from = Address::generate(&runtime.env); + let claimant = Address::generate(&runtime.env); + + mint(&runtime, &token, &from, 1_000); + + runtime.invoke_contract( + &timelock, + "deposit", + vec![ + from.clone().into_val(&runtime.env), + token.clone().into_val(&runtime.env), + 300_i128.into_val(&runtime.env), + 1_u32.into_val(&runtime.env), + 1_000_u64.into_val(&runtime.env), + ], + ); + + assert_balance(&runtime, &token, &from, 700); + assert_balance(&runtime, &token, &timelock, 300); + + runtime.env.ledger().set_timestamp(999); + let logs = runtime.invoke_contract_expect_error( + &timelock, + "claim", + vec![ + token.clone().into_val(&runtime.env), + claimant.clone().into_val(&runtime.env), + ], + ); + + assert!(logs + .iter() + .any(|entry| entry.contains("require condition failed"))); + + assert_balance(&runtime, &token, &claimant, 0); + assert_balance(&runtime, &token, &timelock, 300); + + runtime.env.ledger().set_timestamp(1_000); + runtime.invoke_contract( + &timelock, + "claim", + vec![ + token.clone().into_val(&runtime.env), + claimant.clone().into_val(&runtime.env), + ], + ); + + assert_balance(&runtime, &token, &claimant, 300); + assert_balance(&runtime, &token, &timelock, 0); +} + +#[test] +fn timelock_before_rejects_once_expired() { + let mut runtime = SorobanEnv::new(); + runtime.env.mock_all_auths(); + runtime.env.ledger().set_timestamp(1_200); + + let token = runtime.deploy_contract(TOKEN_SRC); + let timelock = runtime.deploy_contract(TIMELOCK_SRC); + + let from = Address::generate(&runtime.env); + let claimant = Address::generate(&runtime.env); + + mint(&runtime, &token, &from, 500); + + runtime.invoke_contract( + &timelock, + "deposit", + vec![ + from.clone().into_val(&runtime.env), + token.clone().into_val(&runtime.env), + 200_i128.into_val(&runtime.env), + 0_u32.into_val(&runtime.env), + 1_250_u64.into_val(&runtime.env), + ], + ); + + runtime.env.ledger().set_timestamp(1_251); + let logs = runtime.invoke_contract_expect_error( + &timelock, + "claim", + vec![ + token.clone().into_val(&runtime.env), + claimant.clone().into_val(&runtime.env), + ], + ); + + assert!(logs + .iter() + .any(|entry| entry.contains("require condition failed"))); + + assert_balance(&runtime, &token, &from, 300); + assert_balance(&runtime, &token, &claimant, 0); + assert_balance(&runtime, &token, &timelock, 200); +} diff --git a/tests/soroban_testcases/timestamp.rs b/tests/soroban_testcases/timestamp.rs new file mode 100644 index 000000000..86070d426 --- /dev/null +++ b/tests/soroban_testcases/timestamp.rs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +use crate::build_solidity; +use soroban_sdk::testutils::Ledger; +use soroban_sdk::{Address, FromVal, Val}; + +fn now_ts(runtime: &crate::SorobanEnv, addr: &Address) -> u64 { + let out: Val = runtime.invoke_contract(addr, "now_ts", vec![]); + FromVal::from_val(&runtime.env, &out) +} + +#[test] +fn block_timestamp_tracks_ledger_across_u64_encodings() { + let runtime = build_solidity( + r#"contract timestamp_test { + function now_ts() public view returns (uint64) { + return block.timestamp; + } + }"#, + |env| { + env.env.ledger().set_timestamp(0); + }, + ); + + let addr = runtime.contracts.last().unwrap(); + // Cover both Soroban u64 representations: values below 2^56 use the small + // immediate form, while values at/above 2^56 use the object form. + let samples = [ + 0_u64, + 42_u64, + (1_u64 << 56) - 1, + 1_u64 << 56, + (1_u64 << 60) + 1234, + (1_u64 << 56) + 7, + ]; + + for ts in samples { + runtime.env.ledger().set_timestamp(ts); + assert_eq!(now_ts(&runtime, addr), ts, "timestamp mismatch for {ts}"); + } +}