From 5f108f95c23adff3d1f0526c5351a8df5006cb6d Mon Sep 17 00:00:00 2001 From: Dmitry S <11892559+swift1337@users.noreply.github.com> Date: Thu, 10 Oct 2024 17:45:15 +0200 Subject: [PATCH] feat(gateway): improve Gateway contract (#15) --- .github/CODEOWNERS | 1 + README.md | 78 +++++++++++++- contracts/common/errors.fc | 3 + contracts/common/state.fc | 9 +- contracts/gateway.fc | 207 +++++++++++++++++++------------------ scripts/deployGateway.ts | 14 ++- tests/Gateway.spec.ts | 179 ++++++++++++++++++-------------- wrappers/Gateway.ts | 139 +++++++++++-------------- 8 files changed, 358 insertions(+), 272 deletions(-) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..5cd5482 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @lumtis @fbac @swift1337 @skosito diff --git a/README.md b/README.md index 89f6e99..4050efa 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,80 @@ The project is built using [Blueprint](https://github.com/ton-org/blueprint). ## How to use -- Contract tests: `make test` -- Contract scripts: `make run` -- Contract compilation: `make compile` +- Compile FunC: `make compile` +- Run tests: `make test` +- Run Blueprint [scripts](https://github.com/ton-org/blueprint?tab=readme-ov-file#custom-scripts): `make run` ## How it works -`TBD` \ No newline at end of file +### Deposits + +All deposits are represented as internal messages that have the following structure: + +- uint32 `op_code` - operation code. Standard for TON +- uint64 `query_id` - not used right now. Standard for TON +- ... the rest of the message is the operation-specific data + +#### `deposit` (op 101) + +``` +op_code:uint32 query_id:uint64 evm_recipient:slice (160 bits) +``` + +Deposits funds to the contract (subtracting a small deposit fee to cover the gas costs). +ZetaChain will observe this tx and execute cross-chain deposit to `evm_recipient` on Zeta. + +#### `deposit_and_call` (op 102) + +``` +op_code:uint32 query_id:uint64 evm_recipient:slice (160 bits) call_data:cell +``` + +Deposits funds to the contract (subtracting a small deposit fee to cover the gas costs). +ZetaChain will observe this tx and execute cross-chain deposit to `evm_recipient` on Zeta +AND call the contract with `call_data`. + +Note that `call_data` should be +encoded as [snakeCell](https://docs.ton.org/develop/dapps/asset-processing/metadata#snake-data-encoding) + +#### Authority operations + +These "admin" operations are used to manage the contract. In the future, they will be fully managed by TSS. +Currently, a dedicated authority address is used `state::authority_address` + +- `set_deposits_enabled` - toggle deposits +- `update_tss` - update TSS public key +- `update_code` - upgrade the contract code +- `update_authority` - update authority TON address + +### Withdrawals + +ZetaChain uses MPC (Multi Party Computation) to sign all outbound transactions using TSS (Threshold Signature Scheme). +Due to the technical implementation TSS uses ECDSA cryptography in opposite to EdDSA in TON. Thus, we need to +check ECDSA signatures in the contract on-chain. + +All TSS commands are represented as external messages that have the following structure: + +- `uint32 op_code` - operation code. Standard for TON +- `[65]byte signature` - ECDSA signature of the message hash (v, r, s) +- `[32]byte hash` - hash of the payload +- `ref cell payload` - the actual payload + +By having this structure we can sign arbitrary messages using ECDSA, recover signature, +then ensure sender and proceed with the operation. + +The payload for `op withdrawal (200)` is the following: + +``` +recipient:MsgAddr amount:Coins seqno:uint32 +``` + +#### External message signature flow: + +Let’s simplify the input as `["signature", "payload_hash", "payload_data"]`: + +- With `signature + payload_hash`, we can derive the signer's public key -> check that the message comes from TSS. +- By having `payload_hash + payload_data`, we can check that the payload is **exactly** + the same as the one that was signed. +- Otherwise, the sender could take any valid `signature + payload_hash`, + append an **arbitrary payload**, and execute the contract on behalf of TSS (e.g. "withdraw 1000 TON to address X"). \ No newline at end of file diff --git a/contracts/common/errors.fc b/contracts/common/errors.fc index 57f1151..8661a1f 100644 --- a/contracts/common/errors.fc +++ b/contracts/common/errors.fc @@ -30,3 +30,6 @@ const error::invalid_seqno = 109; ;; Deposits are disabled const error::deposits_disabled = 110; + +;; Authority sender is invalid +const error::invalid_authority = 111; diff --git a/contracts/common/state.fc b/contracts/common/state.fc index 3bbefbf..fbfe70a 100644 --- a/contracts/common/state.fc +++ b/contracts/common/state.fc @@ -16,10 +16,13 @@ global int state::fees; ;; nonce global int state::seqno; -;; TSS address as 20 bytes (160 bits) +;; TSS EVM address as 20 bytes (160 bits) global slice state::tss_address; -() load_state() impure inline_ref { +;; Authority address on TON (tl-b MsgAddress) +global slice state::authority_address; + +() load_state() impure inline { var cs = get_data().begin_parse(); state::deposits_enabled = cs~load_uint(size::deposits_enabled); @@ -29,6 +32,7 @@ global slice state::tss_address; state::seqno = cs~load_uint(size::seqno); state::tss_address = cs~load_bits(size::evm_address); + state::authority_address = cs~load_msg_addr(); } () mutate_state() impure inline_ref { @@ -38,6 +42,7 @@ global slice state::tss_address; .store_coins(state::fees) .store_uint(state::seqno, size::seqno) .store_slice(state::tss_address) + .store_slice(state::authority_address) .end_cell(); set_data(store); diff --git a/contracts/gateway.fc b/contracts/gateway.fc index 5ec4839..7f3d688 100644 --- a/contracts/gateway.fc +++ b/contracts/gateway.fc @@ -19,9 +19,11 @@ const op::internal::deposit = 101; const op::internal::deposit_and_call = 102; const op::external::withdraw = 200; -const op::external::set_deposits_enabled = 201; -const op::external::update_tss = 202; -const op::external::update_code = 203; + +const op::authority::set_deposits_enabled = 201; +const op::authority::update_tss = 202; +const op::authority::update_code = 203; +const op::authority::update_authority = 204; ;; GAS FEE ========================================= ;; Let's use const for now and refine this later @@ -61,6 +63,11 @@ const deposit_gas_fee = 10000000; ;; 0.01 TON throw_if(error::deposits_disabled, state::deposits_enabled == 0); } +;; Checks that sender is the authority +() guard_authority_sender(slice sender) impure inline_ref { + throw_unless(error::invalid_authority, equal_slices(sender, state::authority_address)); +} + ;; Protects gas usage against huge payloads () guard_cell_size(cell data, int max_size_bits, int throw_error) impure inline { int max_size_cells = (max_size_bits / 1023) + 1; @@ -89,13 +96,11 @@ const deposit_gas_fee = 10000000; ;; 0.01 TON mutate_state(); - ;; Logs `$sender deposited $deposit_amount TON to $evm_recipient` + ;; Logs `$deposited, $depositFee`. + ;; The rest we can parse from inbound message on the observer side cell log = begin_cell() - .store_uint(op::internal::deposit, size::op_code_size) - .store_uint(0, size::query_id_size) - .store_slice(sender) .store_coins(deposit_amount) - .store_uint(evm_recipient, size::evm_address) + .store_coins(deposit_gas_fee) .end_cell(); send_log_message(log); @@ -113,24 +118,70 @@ const deposit_gas_fee = 10000000; ;; 0.01 TON mutate_state(); - ;; todo Should we remove the call_data from the log? - ;; todo Removing it reduces gas usage by ~x2.5 (wow!) - - ;; todo Should we completely remove logs and simply parse `in_msg_body`? - - ;; Logs `$sender deposited $deposit_amount TON to $evm_recipient with $call_data` + ;; Logs `$deposited, $depositFee`. + ;; The rest we can parse from inbound message on the observer side cell log = begin_cell() - .store_uint(op::internal::deposit_and_call, size::op_code_size) - .store_uint(0, size::query_id_size) - .store_slice(sender) .store_coins(deposit_amount) - .store_uint(evm_recipient, size::evm_address) - .store_ref(call_data) + .store_coins(deposit_gas_fee) .end_cell(); send_log_message(log); } +;; Enables or disables deposits. +() handle_set_deposits_enabled(slice sender, slice message) impure inline { + load_state(); + + guard_authority_sender(sender); + + state::deposits_enabled = message~load_uint(1); + + mutate_state(); +} + +;; Updates the TSS address. WARNING! Execute with extra caution. +;; Wrong TSS address leads to loss of funds. +() handle_update_tss(slice sender, slice message) impure inline { + load_state(); + + guard_authority_sender(sender); + + state::tss_address = message~load_bits(size::evm_address); + + mutate_state(); +} + +;; Updated the code of the contract +;; handle_code_update (cell new_code) +() handle_update_code(slice sender, slice message) impure inline { + load_state(); + + guard_authority_sender(sender); + + cell new_code = message~load_ref(); + + ;; note that the code will be updated only after the current tx is finished + set_code(new_code); + + mutate_state(); +} + +() handle_update_authority(slice sender, slice message) impure inline { + load_state(); + + guard_authority_sender(sender); + + slice new_authority = message~load_msg_addr(); + + ;; Validate the workchain & address + (int wc, int addr) = parse_std_addr(new_authority); + throw_unless(error::wrong_workchain, wc == 0); + + state::authority_address = new_authority; + + mutate_state(); +} + ;; Input for all internal messages () recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure { (int flags, slice sender, _) = in_msg_full.parse_flags_and_sender(); @@ -157,8 +208,9 @@ const deposit_gas_fee = 10000000; ;; 0.01 TON return (); } + throw_if(error::insufficient_value, msg_value < deposit_gas_fee); + if (op == op::internal::deposit) { - throw_if(error::insufficient_value, msg_value < deposit_gas_fee); throw_if(error::invalid_evm_recipient, in_msg_body.slice_bits() < size::evm_address); int evm_recipient = in_msg_body~load_uint(size::evm_address); @@ -167,7 +219,6 @@ const deposit_gas_fee = 10000000; ;; 0.01 TON } if (op == op::internal::deposit_and_call) { - throw_if(error::insufficient_value, msg_value < deposit_gas_fee); throw_if(error::invalid_evm_recipient, in_msg_body.slice_bits() < size::evm_address); int evm_recipient = in_msg_body~load_uint(size::evm_address); @@ -179,6 +230,25 @@ const deposit_gas_fee = 10000000; ;; 0.01 TON return handle_deposit_and_call(sender, msg_value, evm_recipient, call_data); } + ;; TODO set authority tx fee + ;; https://github.com/zeta-chain/protocol-contracts-ton/issues/9 + + if (op == op::authority::set_deposits_enabled) { + return handle_set_deposits_enabled(sender, in_msg_body); + } + + if (op == op::authority::update_tss) { + return handle_update_tss(sender, in_msg_body); + } + + if (op == op::authority::update_code) { + return handle_update_code(sender, in_msg_body); + } + + if (op == op::authority::update_authority) { + return handle_update_authority(sender, in_msg_body); + } + throw(error::unknown_op); } @@ -238,73 +308,6 @@ const deposit_gas_fee = 10000000; ;; 0.01 TON mutate_state(); } -;; Enables or disables deposits -;; -;; handle_enable_deposits (int enabled, int seqno) -() handle_set_deposits_enabled(slice message) impure inline { - load_state(); - - slice payload = authenticate_external_message(message).begin_parse(); - - int enabled = payload~load_uint(1); - int seqno = payload~load_uint(size::seqno); - - throw_if(error::invalid_seqno, seqno != (state::seqno + 1)); - - accept_message(); - - state::deposits_enabled = enabled; - state::seqno += 1; - - mutate_state(); -} - -;; Updates the TSS address. WARNING! Execute with extra caution. -;; Wrong TSS address leads to funds loss. -;; -;; handle_update_tss (slice tss_address, int32 seqno) -() handle_update_tss(slice message) impure inline { - load_state(); - - slice payload = authenticate_external_message(message).begin_parse(); - - slice new_tss_address = payload~load_bits(size::evm_address); - int seqno = payload~load_uint(size::seqno); - - throw_if(error::invalid_seqno, seqno != (state::seqno + 1)); - - accept_message(); - - state::tss_address = new_tss_address; - state::seqno += 1; - - ~strdump("new_tss_address"); - new_tss_address~dump(); - - mutate_state(); -} - -;; Updated the code of the contract - -;; handle_code_update (cell new_code, int32 seqno) -() handle_update_code(slice message) impure inline { - load_state(); - - slice payload = authenticate_external_message(message).begin_parse(); - - cell new_code = payload~load_ref(); - int seqno = payload~load_uint(size::seqno); - - throw_if(error::invalid_seqno, seqno != (state::seqno + 1)); - - accept_message(); - - ;; note that the code will be updated only after the current tx is finished - set_code(new_code); - - mutate_state(); -} - ;; Input for all external messages () recv_external(slice message) impure { int op = message~load_uint(size::op_code_size); @@ -313,28 +316,26 @@ const deposit_gas_fee = 10000000; ;; 0.01 TON return handle_withdrawal(message); } - if (op == op::external::set_deposits_enabled) { - return handle_set_deposits_enabled(message); - } - - if (op == op::external::update_tss) { - return handle_update_tss(message); - } - - if (op == op::external::update_code) { - return handle_update_code(message); - } - throw(error::unknown_op); } ;; GETTERS ======================================== -;; returns (int1 `deposits enabled`, int128 `total TON locked`, slice[20] `TSS address`) -(int, int, slice) query_state() method_id { +;; returns ( +;; int1 `deposits enabled`, +;; int128 `total TON locked`, +;; slice[20] `TSS address`, +;; MsgAddress `authority address` +;; ) +(int, int, slice, slice) query_state() method_id { load_state(); - return (state::deposits_enabled, state::total_locked, state::tss_address); + return ( + state::deposits_enabled, + state::total_locked, + state::tss_address, + state::authority_address + ); } ;; get nonce (int32) diff --git a/scripts/deployGateway.ts b/scripts/deployGateway.ts index d02e9af..32f48f5 100644 --- a/scripts/deployGateway.ts +++ b/scripts/deployGateway.ts @@ -1,9 +1,8 @@ import { OpenedContract, toNano } from '@ton/core'; import { Gateway, GatewayConfig } from '../wrappers/Gateway'; import { compile, NetworkProvider } from '@ton/blueprint'; -import { evmAddressToSlice, formatCoin } from '../tests/utils'; // https://etherscan.io/address/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +import { formatCoin } from '../tests/utils'; -// https://etherscan.io/address/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 const vitalikDotETH = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; // TEST PURPOSES ONLY @@ -12,7 +11,8 @@ export const sampleTSS = '0x70e967acfcc17c3941e87562161406d41676fd83'; async function open(provider: NetworkProvider): Promise> { const config: GatewayConfig = { depositsEnabled: true, - tssAddress: sampleTSS, + tss: sampleTSS, + authority: provider.sender().address!, }; const code = await compile('Gateway'); @@ -38,12 +38,10 @@ export async function run(provider: NetworkProvider) { } // Deposit 1 TON to Vitalik's address on ZetaChain - const memo = evmAddressToSlice(vitalikDotETH); - - await gateway.sendDeposit(sender, toNano('1'), memo); + await gateway.sendDeposit(sender, toNano('1'), vitalikDotETH); // Query the state. Note that contract will be queried instantly // w/o waiting for the tx to be processed, so expect outdated data - const [_, totalLocked] = await gateway.getQueryState(); - console.log(`Total locked: ${formatCoin(totalLocked)}`); + const { valueLocked } = await gateway.getGatewayState(); + console.log(`Total locked: ${formatCoin(valueLocked)}`); } diff --git a/tests/Gateway.spec.ts b/tests/Gateway.spec.ts index 43bef4d..8e50e28 100644 --- a/tests/Gateway.spec.ts +++ b/tests/Gateway.spec.ts @@ -5,12 +5,11 @@ import { compile } from '@ton/blueprint'; import * as utils from './utils'; import { findTransaction, FlatTransactionComparable } from '@ton/test-utils/dist/test/transaction'; import { ethers } from 'ethers'; -import { stringToCell } from '@ton/core/dist/boc/utils/strings'; +import { readString, stringToCell } from '@ton/core/dist/boc/utils/strings'; import path from 'node:path'; import * as fs from 'node:fs'; import * as gw from '../wrappers/Gateway'; -// copied from `gas.fc` const gasFee = toNano('0.01'); // Sample TSS wallet. In reality there's no single private key @@ -35,16 +34,16 @@ describe('Gateway', () => { beforeEach(async () => { blockchain = await Blockchain.create(); + deployer = await blockchain.treasury('deployer'); const deployConfig: gw.GatewayConfig = { depositsEnabled: true, - tssAddress: tssWallet.address, + tss: tssWallet.address, + authority: deployer.address, }; gateway = blockchain.openContract(gw.Gateway.createFromConfig(deployConfig, code)); - deployer = await blockchain.treasury('deployer'); - const deployResult = await gateway.sendDeploy(deployer.getSender(), toNano('0.05')); expect(deployResult.transactions).toHaveTransaction({ @@ -60,11 +59,12 @@ describe('Gateway', () => { // ASSERT // Check that initial state is queried correctly - const [depositsEnabled, valueLocked, tss] = await gateway.getQueryState(); + const state = await gateway.getGatewayState(); - expect(depositsEnabled).toBe(true); - expect(valueLocked).toBe(0n); - expect(tss).toBe(tssWallet.address.toLowerCase()); + expect(state.depositsEnabled).toBe(true); + expect(state.valueLocked).toBe(0n); + expect(state.tss).toBe(tssWallet.address.toLowerCase()); + expect(state.authority.toRawString()).toBe(deployer.address.toRawString()); // Check that seqno works and is zero const nonce = await gateway.getSeqno(); @@ -140,7 +140,7 @@ describe('Gateway', () => { expect(gatewayBalanceAfter).toBeGreaterThanOrEqual(gatewayBalanceBefore + amount - gasFee); // Check that valueLocked is updated - const [_, valueLocked] = await gateway.getQueryState(); + const { valueLocked } = await gateway.getGatewayState(); expect(valueLocked).toEqual(amount - gasFee); @@ -148,13 +148,10 @@ describe('Gateway', () => { expect(tx.outMessagesCount).toEqual(1); // Check for data in the log message - const depositLog = gw.parseDepositLog(tx.outMessages.get(0)!.body); + const log = gw.parseDepositLog(tx.outMessages.get(0)!.body); - expect(depositLog.op).toEqual(gw.GatewayOp.Deposit); - expect(depositLog.queryId).toEqual(0); - expect(depositLog.sender.toRawString()).toEqual(sender.address.toRawString()); - expect(depositLog.amount).toEqual(amount - gasFee); - expect(depositLog.recipient).toEqual(evmAddress); + expect(log.amount).toEqual(amount - gasFee); + expect(log.depositFee).toEqual(toNano('0.01')); }); it('should deposit and call', async () => { @@ -188,14 +185,19 @@ describe('Gateway', () => { utils.logGasUsage(expect, tx); // Check log - const log = gw.parseDepositAndCallLog(tx.outMessages.get(0)!.body); + const log = gw.parseDepositLog(tx.outMessages.get(0)!.body); - expect(log.op).toEqual(gw.GatewayOp.DepositAndCall); - expect(log.queryId).toEqual(0); - expect(log.sender.toRawString()).toEqual(sender.address.toRawString()); expect(log.amount).toEqual(amount - gasFee); - expect(log.recipient).toEqual(recipient); - expect(log.callData).toEqual(longText); + expect(log.depositFee).toEqual(toNano('0.01')); + + // Parse call data from the internal message + const body = tx.inMessage!.body.beginParse(); + + // skip op + query_id + evm address + const callDataCell = body.skip(64 + 32 + 160).loadRef(); + + const callDataRestored = readString(callDataCell.asSlice()); + expect(callDataRestored).toEqual(longText); }); it('should perform a donation', async () => { @@ -220,7 +222,7 @@ describe('Gateway', () => { utils.logGasUsage(expect, tx); // Check that valueLocked is NOT updated - const [_, valueLocked] = await gateway.getQueryState(); + const { valueLocked } = await gateway.getGatewayState(); // Donation doesn't count as a deposit, so no net-new locked value. expect(valueLocked).toEqual(0n); @@ -245,7 +247,7 @@ describe('Gateway', () => { '0x95222290dd7278aa3ddd389cc1e1d165cc4bafe5', ); - let [_, valueLocked] = await gateway.getQueryState(); + let { valueLocked } = await gateway.getGatewayState(); expect(valueLocked).toEqual(toNano('10')); // Given sender balance BEFORE withdrawal @@ -260,19 +262,9 @@ describe('Gateway', () => { .storeUint(nonce, 32) .endCell(); - // ... Which is signed by TSS - const signature = utils.signCellECDSA(tssWallet, payload); - - // Given an admin command to withdraw TON - const cmd: gw.AdminCommand = { - op: gw.GatewayOp.Withdraw, - signature, - payload: payload, - }; - // ACT // Withdraw TON to the same sender on the behalf of TSS - const result = await gateway.sendAdminCommand(cmd); + const result = await gateway.sendTSSCommand(tssWallet, gw.GatewayOp.Withdraw, payload); // ASSERT // Check that tx is successful and contains expected outbound internal message @@ -286,7 +278,9 @@ describe('Gateway', () => { utils.logGasUsage(expect, tx); // Check that locked funds are updated - [_, valueLocked] = await gateway.getQueryState(); + const gwState = await gateway.getGatewayState(); + valueLocked = gwState.valueLocked; + expect(valueLocked).toEqual(toNano('7')); // Check nonce @@ -324,23 +318,18 @@ describe('Gateway', () => { .storeUint(nonce, 32) .endCell(); - // ... which is signed by a RANDOM EVM wallet - const signature = utils.signCellECDSA(someRandomEvmWallet, payload); - - // Given an admin command to withdraw TON - const cmd: gw.AdminCommand = { op: gw.GatewayOp.Withdraw, signature, payload }; - // ACT & ASSERT // Withdraw TON and expect an error try { - const result = await gateway.sendAdminCommand(cmd); + // Sign with some random wallet + await gateway.sendTSSCommand(someRandomEvmWallet, gw.GatewayOp.Withdraw, payload); } catch (e: any) { const exitCode = e?.exitCode as number; expect(exitCode).toEqual(gw.GatewayError.InvalidSignature); } }); - it('should exercise deposits enablement toggle', async () => { + it('should enable or disable deposits', async () => { // ARRANGE // Given a sender const sender = await blockchain.treasury('sender5'); @@ -350,17 +339,17 @@ describe('Gateway', () => { // ACT 1 // Disable deposits - const result1 = await gateway.sendEnableDeposits(tssWallet, false); + const result1 = await gateway.sendEnableDeposits(deployer.getSender(), false); // ASSERT 1 expectTX(result1.transactions, { - from: undefined, + from: deployer.address, to: gateway.address, success: true, }); // Check that deposits are disabled - const [depositsEnabled] = await gateway.getQueryState(); + const { depositsEnabled } = await gateway.getGatewayState(); expect(depositsEnabled).toBe(false); // ACT 2 @@ -378,9 +367,9 @@ describe('Gateway', () => { // ACT 3 // Enable deposits back - const result3 = await gateway.sendEnableDeposits(tssWallet, true); + const result3 = await gateway.sendEnableDeposits(deployer.getSender(), true); expectTX(result3.transactions, { - from: undefined, + from: deployer.address, to: gateway.address, success: true, }); @@ -400,6 +389,18 @@ describe('Gateway', () => { to: gateway.address, success: true, }); + + // ACT 5 + // Disable deposits, but sender IS NOT an authority + const result5 = await gateway.sendEnableDeposits(sender.getSender(), false); + + // ASSERT 5 + expectTX(result5.transactions, { + from: sender.address, + to: gateway.address, + success: false, + exitCode: gw.GatewayError.InvalidAuthority, + }); }); it('should update tss address', async () => { @@ -417,40 +418,19 @@ describe('Gateway', () => { // ACT 1 // Update TSS address - const result1 = await gateway.sendUpdateTSS(tssWallet, newTss.address); + const result1 = await gateway.sendUpdateTSS(deployer.getSender(), newTss.address); // ASSERT 1 // Check that tx is successful expectTX(result1.transactions, { - from: undefined, + from: deployer.address, to: gateway.address, op: gw.GatewayOp.UpdateTSS, }); // Check that tss was updated - const { 2: tss } = await gateway.getQueryState(); + const { tss } = await gateway.getGatewayState(); expect(tss).toEqual(newTss.address.toLowerCase()); - - // ACT 2 - // Do the same operation - // Obviously, it should fail, because the TSS was already updated - try { - await gateway.sendUpdateTSS(tssWallet, newTss.address); - } catch (e: any) { - const exitCode = e?.exitCode as number; - expect(exitCode).toEqual(gw.GatewayError.InvalidSignature); - } - - // ACT 3 - // Now let's try to invoke an admin command with the new TSS - const result3 = await gateway.sendEnableDeposits(newTss, true); - - // ASSERT 3 - expectTX(result3.transactions, { - from: undefined, - to: gateway.address, - success: true, - }); }); it('should update the code', async () => { @@ -471,11 +451,11 @@ describe('Gateway', () => { // ACT // Update the code - const result = await gateway.sendUpdateCode(tssWallet, code); + const result = await gateway.sendUpdateCode(deployer.getSender(), code); // ASSERT expectTX(result.transactions, { - from: undefined, + from: deployer.address, to: gateway.address, op: gw.GatewayOp.UpdateCode, }); @@ -488,7 +468,7 @@ describe('Gateway', () => { // Try to trigger some TSS command // It should fail because external_message is not implemented anymore! :troll: try { - await gateway.sendEnableDeposits(tssWallet, true); + await gateway.sendEnableDeposits(deployer.getSender(), true); } catch (e: any) { // https://docs.ton.org/learn/tvm-instructions/tvm-exit-codes const exitCode = e?.exitCode as number; @@ -496,6 +476,53 @@ describe('Gateway', () => { } }); + it('should update authority', async () => { + // ARRANGE + // Given some value in the Gateway + await gateway.sendDonation(deployer.getSender(), toNano('10')); + + // Given a new authority + const newAuth = await blockchain.treasury('newAuth'); + + // ACT 1 + // Update authority + const result1 = await gateway.sendUpdateAuthority(deployer.getSender(), newAuth.address); + + // ASSERT 1 + expectTX(result1.transactions, { + from: deployer.address, + to: gateway.address, + op: gw.GatewayOp.UpdateAuthority, + }); + + const state = await gateway.getGatewayState(); + expect(state.authority.toRawString()).toEqual(newAuth.address.toRawString()); + + // ACT 2 + // Try to disable deposits with the new authority + const result2 = await gateway.sendEnableDeposits(newAuth.getSender(), false); + + // ASSERT 2 + expectTX(result2.transactions, { + from: newAuth.address, + to: gateway.address, + op: gw.GatewayOp.SetDepositsEnabled, + success: true, + }); + + // ACT 3 + // And do the same for old authority and fail + const result3 = await gateway.sendEnableDeposits(deployer.getSender(), false); + + // ASSERT 3 + expectTX(result3.transactions, { + from: deployer.address, + to: gateway.address, + exitCode: gw.GatewayError.InvalidAuthority, + success: false, + }); + }); + // todo deposit_and_call: missing memo // todo deposit_and_call: memo is too long // todo deposits: should fail because the value is too small diff --git a/wrappers/Gateway.ts b/wrappers/Gateway.ts index 0de461e..2c75202 100644 --- a/wrappers/Gateway.ts +++ b/wrappers/Gateway.ts @@ -1,13 +1,14 @@ import { Address, beginCell, + Builder, Cell, Contract, contractAddress, ContractProvider, Sender, SendMode, - Slice, + toNano, } from '@ton/core'; import { evmAddressToSlice, loadHexStringFromBuffer, signCellECDSA } from '../tests/utils'; import { Wallet } from 'ethers'; // copied from `gateway.fc` @@ -22,6 +23,7 @@ export enum GatewayOp { SetDepositsEnabled = 201, UpdateTSS = 202, UpdateCode = 203, + UpdateAuthority = 204, } // copied from `errors.fc` @@ -29,22 +31,25 @@ export enum GatewayError { NoIntent = 101, InvalidSignature = 108, DepositsDisabled = 110, + InvalidAuthority = 111, } export type GatewayConfig = { depositsEnabled: boolean; - tssAddress: string; + tss: string; + authority: Address; }; -export type AdminCommand = { - op: number; - signature: Slice; - payload: Cell; +export type GatewayState = { + depositsEnabled: boolean; + valueLocked: bigint; + tss: string; + authority: Address; }; // Initial state of the contract during deployment export function gatewayConfigToCell(config: GatewayConfig): Cell { - const tss = evmAddressToSlice(config.tssAddress); + const tss = evmAddressToSlice(config.tss); return beginCell() .storeUint(config.depositsEnabled ? 1 : 0, 1) // deposits_enabled @@ -52,6 +57,7 @@ export function gatewayConfigToCell(config: GatewayConfig): Cell { .storeCoins(0) // fees .storeUint(0, 32) // seqno .storeSlice(tss) // tss_address + .storeAddress(config.authority) // authority_address .endCell(); } @@ -113,9 +119,7 @@ export class Gateway implements Contract { zevmRecipient = BigInt(zevmRecipient); } - const body = beginCell() - .storeUint(GatewayOp.DepositAndCall, 32) // op code - .storeUint(0, 64) // query id + const body = newIntent(GatewayOp.DepositAndCall) .storeUint(zevmRecipient, 160) // 20 bytes .storeRef(callData) .endCell(); @@ -138,73 +142,62 @@ export class Gateway implements Contract { }); } - async sendEnableDeposits(provider: ContractProvider, signer: Wallet, enabled: boolean) { - const nextSeqno = await this.getNextSeqno(provider); - const payload = beginCell().storeBit(enabled).storeUint(nextSeqno, 32).endCell(); + async sendEnableDeposits(provider: ContractProvider, via: Sender, enabled: boolean) { + const body = newIntent(GatewayOp.SetDepositsEnabled).storeBit(enabled).endCell(); - return await this.signAndSendAdminCommand( - provider, - signer, - GatewayOp.SetDepositsEnabled, - payload, - ); + await this.sendAuthorityCommand(provider, via, body); } - async sendUpdateTSS(provider: ContractProvider, signer: Wallet, newTSS: string) { - const nextSeqno = await this.getNextSeqno(provider); - const payload = beginCell() - .storeSlice(evmAddressToSlice(newTSS)) - .storeUint(nextSeqno, 32) - .endCell(); + async sendUpdateTSS(provider: ContractProvider, via: Sender, newTSS: string) { + const body = newIntent(GatewayOp.UpdateTSS).storeSlice(evmAddressToSlice(newTSS)).endCell(); + + await this.sendAuthorityCommand(provider, via, body); + } + + async sendUpdateCode(provider: ContractProvider, via: Sender, code: Cell) { + const body = newIntent(GatewayOp.UpdateCode).storeRef(code).endCell(); - return await this.signAndSendAdminCommand(provider, signer, GatewayOp.UpdateTSS, payload); + await this.sendAuthorityCommand(provider, via, body); } - async sendUpdateCode(provider: ContractProvider, signer: Wallet, code: Cell) { - const nextSeqno = await this.getNextSeqno(provider); - const payload = beginCell().storeRef(code).storeUint(nextSeqno, 32).endCell(); + async sendUpdateAuthority(provider: ContractProvider, via: Sender, authority: Address) { + const body = newIntent(GatewayOp.UpdateAuthority).storeAddress(authority).endCell(); - return await this.signAndSendAdminCommand(provider, signer, GatewayOp.UpdateCode, payload); + await this.sendAuthorityCommand(provider, via, body); + } + + async sendAuthorityCommand(provider: ContractProvider, via: Sender, body: Cell) { + await provider.internal(via, { + value: toNano('0.01'), + sendMode: SendMode.PAY_GAS_SEPARATELY, + body, + }); } /** - * Sign external message using ECDSA private key and send it to the contract + * Sign external message using ECDSA private TSS key and send it to the contract * * @param provider * @param signer * @param op * @param payload */ - async signAndSendAdminCommand( - provider: ContractProvider, - signer: Wallet, - op: number, - payload: Cell, - ) { + async sendTSSCommand(provider: ContractProvider, signer: Wallet, op: number, payload: Cell) { const signature = signCellECDSA(signer, payload); - return await this.sendAdminCommand(provider, { op, payload, signature }); - } - - /** - * Send an admin command to the contract as an external message - * @param provider - * @param cmd - */ - async sendAdminCommand(provider: ContractProvider, cmd: AdminCommand) { // SHA-256 - const hash = cmd.payload.hash(); + const hash = payload.hash(); if (hash.byteLength != 32) { throw new Error(`Invalid hash length (got ${hash.byteLength}, want 32)`); } const message = beginCell() - .storeUint(cmd.op, 32) - .storeBits(cmd.signature.loadBits(8)) // v - .storeBits(cmd.signature.loadBits(256)) // r - .storeBits(cmd.signature.loadBits(256)) // s + .storeUint(op, 32) + .storeBits(signature.loadBits(8)) // v + .storeBits(signature.loadBits(256)) // r + .storeBits(signature.loadBits(256)) // s .storeBuffer(hash) - .storeRef(cmd.payload) + .storeRef(payload) .endCell(); await provider.external(message); @@ -216,14 +209,20 @@ export class Gateway implements Contract { return state.balance; } - async getQueryState(provider: ContractProvider): Promise<[boolean, bigint, string]> { + async getGatewayState(provider: ContractProvider): Promise { const response = await provider.get('query_state', []); const depositsEnabled = response.stack.readBoolean(); const valueLocked = response.stack.readBigNumber(); const tssAddress = loadHexStringFromBuffer(response.stack.readBuffer()); - - return [depositsEnabled, valueLocked, tssAddress]; + const authorityAddress = response.stack.readAddress(); + + return { + depositsEnabled, + valueLocked, + tss: tssAddress, + authority: authorityAddress, + }; } async getSeqno(provider: ContractProvider): Promise { @@ -238,38 +237,20 @@ export class Gateway implements Contract { } export interface DepositLog { - op: number; - queryId: number; - sender: Address; amount: bigint; - recipient: string; -} - -export interface DepositAndCallLog extends DepositLog { - callData: string; + depositFee: bigint; } export function parseDepositLog(body: Cell): DepositLog { const cs = body.beginParse(); - const op = cs.loadUint(32); - const queryId = cs.loadUint(64); - const sender = cs.loadAddress(); const amount = cs.loadCoins(); - const recipient = loadHexStringFromBuffer(cs.loadBuffer(20)); + const depositFee = cs.loadCoins(); - return { op, queryId, sender, amount, recipient }; + return { amount, depositFee }; } -export function parseDepositAndCallLog(body: Cell): DepositAndCallLog { - const cs = body.beginParse(); - - const op = cs.loadUint(32); - const queryId = cs.loadUint(64); - const sender = cs.loadAddress(); - const amount = cs.loadCoins(); - const recipient = loadHexStringFromBuffer(cs.loadBuffer(20)); - const callData = cs.loadStringTail(); - - return { op, queryId, sender, amount, recipient, callData }; +function newIntent(op: GatewayOp): Builder { + // op code, query id + return beginCell().storeUint(op, 32).storeUint(0, 64); }