diff --git a/contracts/common/crypto.fc b/contracts/common/crypto.fc index 368f4e3..0f2dbb2 100644 --- a/contracts/common/crypto.fc +++ b/contracts/common/crypto.fc @@ -3,7 +3,7 @@ #include "../imports/stdlib.fc"; ;; Returs keccak256 hash of the data as uint256 -int hash_keccak256(builder b, int n) asm "HASHEXT_KECCAK256"; +int hash_keccak256(builder b) asm "1 INT HASHEXT_KECCAK256"; ;; Recovers public key from ECDSA signature. Costs 1526 gas. ;; @@ -55,7 +55,7 @@ int normalize_ecdsa_recovery_id(int v) inline { int pub_key_hash = begin_cell() .store_uint(x1, 256) .store_uint(x2, 256) - .hash_keccak256(1); + .hash_keccak256(); slice actual_evm_address = begin_cell() .store_uint(pub_key_hash, 256) diff --git a/contracts/common/errors.fc b/contracts/common/errors.fc index 06a47c1..57f1151 100644 --- a/contracts/common/errors.fc +++ b/contracts/common/errors.fc @@ -8,11 +8,10 @@ const error::no_intent = 101; ;; Unknown opcode const error::unknown_op = 102; -;; No memoe provided -const error::memo_is_empty = 103; +;; Invalid EVM recipient (should be 20 bytes slice) +const error::invalid_evm_recipient = 103; -;; Memo is too short (less than 20 bytes) -const error::memo_is_too_short = 104; +const error::invalid_call_data = 104; ;; We only want to support workchain 0 aka "basechain" const error::wrong_workchain = 105; diff --git a/contracts/common/state.fc b/contracts/common/state.fc index 2b01cac..3bbefbf 100644 --- a/contracts/common/state.fc +++ b/contracts/common/state.fc @@ -2,7 +2,7 @@ const size::deposits_enabled = 1; const size::seqno = 32; -const size::tss_address = 160; ;; 20 bytes +const size::evm_address = 160; ;; 20 bytes ;; Boolean flag to enable/disable deposits global int state::deposits_enabled; @@ -28,7 +28,7 @@ global slice state::tss_address; state::fees = cs~load_coins(); state::seqno = cs~load_uint(size::seqno); - state::tss_address = cs~load_bits(size::tss_address); + state::tss_address = cs~load_bits(size::evm_address); } () mutate_state() impure inline_ref { diff --git a/contracts/gateway.fc b/contracts/gateway.fc index b404d63..5ec4839 100644 --- a/contracts/gateway.fc +++ b/contracts/gateway.fc @@ -9,13 +9,14 @@ ;; Sizes =========================================== const size::op_code_size = 32; const size::query_id_size = 64; -const size::memo_min = 20 * 8; const size::signature_size = 65 * 8; const size::hash_size = 32 * 8; +const size::call_data::max = 2048 * 8; ;; 2 Kilobytes of call data ;; OP CODES ======================================== -const op::internal::deposit = 100; -const op::internal::donate = 101; +const op::internal::donate = 100; +const op::internal::deposit = 101; +const op::internal::deposit_and_call = 102; const op::external::withdraw = 200; const op::external::set_deposits_enabled = 201; @@ -30,11 +31,11 @@ const deposit_gas_fee = 10000000; ;; 0.01 TON ;; parses the query from the message body ;; https://docs.ton.org/develop/smart-contracts/guidelines/internal-messages#internal-message-body -(int, int) parse_intent(slice message_body) impure inline { +(slice, (int, int)) ~parse_intent(slice message_body) impure inline { int op = message_body~load_uint(size::op_code_size); int query_id = message_body~load_uint(size::query_id_size); - return (op, query_id); + return (message_body, (op, query_id)); } ;; parses full message and returns (int flags, slice sender, slice rest_of_the_message) @@ -55,36 +56,82 @@ const deposit_gas_fee = 10000000; ;; 0.01 TON ;; INTERNAL MESSAGES =============================== -;; deposit TON to the gateway -() handle_deposit(slice sender, int amount, cell memo_cell) impure inline { - ;; Validate memo - slice memo = memo_cell.begin_parse(); +;; Checks that deposits are enabled +() guard_deposits() impure inline_ref { + throw_if(error::deposits_disabled, state::deposits_enabled == 0); +} + +;; 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; + + ;; note that if count(child cells) > max_size_cells, TVM will exit with code 7; + (_, int data_bits, _) = compute_data_size(data, max_size_cells); + + if (data_bits > max_size_bits) { + ~strdump("cell size is too big: [got, want]"); + data_bits~dump(); + max_size_bits~dump(); + + throw(throw_error); + } +} - ;; EVM address is 20 bytes, so the memo should be at least 20 bytes long - throw_if(error::memo_is_too_short, memo.slice_bits() < size::memo_min); - ;; todo check if memo is too long. We don't want to have DDOS due to long memos (gas > 0.01 TON) +;; deposit TON to the gateway and specify the EVM recipient on ZetaChain +() handle_deposit(slice sender, int amount, int evm_recipient) impure inline { + load_state(); + guard_deposits(); - ;; Update state - var deposit_amount = amount - deposit_gas_fee; + int deposit_amount = amount - deposit_gas_fee; state::total_locked += deposit_amount; state::fees += deposit_gas_fee; - ;; Update the state mutate_state(); - ;; Logs `$sender deposited $deposit_amount TON with $memo` + ;; Logs `$sender deposited $deposit_amount TON to $evm_recipient` 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_ref(memo_cell) + .store_uint(evm_recipient, size::evm_address) .end_cell(); send_log_message(log); } +() handle_deposit_and_call(slice sender, int amount, int evm_recipient, cell call_data) impure inline { + load_state(); + guard_deposits(); + guard_cell_size(call_data, size::call_data::max, error::invalid_call_data); + + int deposit_amount = amount - deposit_gas_fee; + + state::total_locked += deposit_amount; + state::fees += deposit_gas_fee; + + 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` + 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) + .end_cell(); + + send_log_message(log); +} + +;; 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(); @@ -103,27 +150,33 @@ const deposit_gas_fee = 10000000; ;; 0.01 TON throw_if(error::no_intent, in_msg_body.slice_bits() < (size::op_code_size + size::query_id_size)); ;; query_id is not used for now. - (int op, _) = in_msg_body.parse_intent(); - - load_state(); + (int op, _) = in_msg_body~parse_intent(); ;; Just accept the incoming value if (op == op::internal::donate) { - throw_if(error::insufficient_value, msg_value < deposit_gas_fee); - return (); } if (op == op::internal::deposit) { - throw_if(error::deposits_disabled, state::deposits_enabled == 0); 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); + + return handle_deposit(sender, msg_value, evm_recipient); + } + + 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); - ;; expect a ref with a memo - throw_unless(error::memo_is_empty, in_msg_body.slice_refs()); + throw_if(error::invalid_call_data, in_msg_body.slice_refs_empty?()); - cell memo = in_msg_body~load_ref(); + cell call_data = in_msg_body~load_ref(); - return handle_deposit(sender, msg_value, memo); + return handle_deposit_and_call(sender, msg_value, evm_recipient, call_data); } throw(error::unknown_op); @@ -131,6 +184,7 @@ const deposit_gas_fee = 10000000; ;; 0.01 TON ;; EXTERNAL MESSAGES =============================== +;; Check ECDSA (!) signature of the external message (cell) authenticate_external_message(slice message) impure inline_ref { ;; 1: Parse external message slice signature = message~load_bits(size::signature_size); @@ -214,7 +268,7 @@ const deposit_gas_fee = 10000000; ;; 0.01 TON slice payload = authenticate_external_message(message).begin_parse(); - slice new_tss_address = payload~load_bits(size::tss_address); + 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)); diff --git a/tests/Gateway.spec.ts b/tests/Gateway.spec.ts index 8820bad..43bef4d 100644 --- a/tests/Gateway.spec.ts +++ b/tests/Gateway.spec.ts @@ -1,18 +1,14 @@ import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox'; import { beginCell, Cell, toNano, Transaction } from '@ton/core'; -import { - AdminCommand, - Gateway, - GatewayConfig, - GatewayError, - GatewayOp, - parseDepositLog, -} from '../wrappers/Gateway'; import '@ton/test-utils'; 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 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'); @@ -35,17 +31,17 @@ describe('Gateway', () => { let blockchain: Blockchain; let deployer: SandboxContract; - let gateway: SandboxContract; + let gateway: SandboxContract; beforeEach(async () => { blockchain = await Blockchain.create(); - const deployConfig: GatewayConfig = { + const deployConfig: gw.GatewayConfig = { depositsEnabled: true, tssAddress: tssWallet.address, }; - gateway = blockchain.openContract(Gateway.createFromConfig(deployConfig, code)); + gateway = blockchain.openContract(gw.Gateway.createFromConfig(deployConfig, code)); deployer = await blockchain.treasury('deployer'); @@ -98,7 +94,7 @@ describe('Gateway', () => { from: sender.address, to: gateway.address, success: false, - exitCode: GatewayError.NoIntent, + exitCode: gw.GatewayError.NoIntent, }); // Make sure that balance is decreased by gas fee ... @@ -120,13 +116,12 @@ describe('Gateway', () => { // Given memo with EVM address (20 bytes) const evmAddress = '0x92215391d24c75eb005eb4b7c8c55bf0036604a5'; - const memo = utils.evmAddressToSlice(evmAddress); // Given amount to deposit const amount = toNano('1'); // ACT - const result = await gateway.sendDeposit(sender.getSender(), amount, memo); + const result = await gateway.sendDeposit(sender.getSender(), amount, evmAddress); // ASSERT // Check that tx failed with expected status code @@ -153,17 +148,54 @@ describe('Gateway', () => { expect(tx.outMessagesCount).toEqual(1); // Check for data in the log message - const depositLog = parseDepositLog(tx.outMessages.get(0)!.body); + const depositLog = gw.parseDepositLog(tx.outMessages.get(0)!.body); - expect(depositLog.op).toEqual(GatewayOp.Deposit); + 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); + }); + + it('should deposit and call', async () => { + // ARRANGE + // Given a sender + const sender = await blockchain.treasury('sender1'); + + // Given zevm address + const recipient = '0x95222290dd7278aa3ddd389cc1e1d165cc4bafe5'; + + // Given quite a long call data + const longText = readFixture('long-call-data.txt'); + const callData = stringToCell(longText); + + // ACT + const amount = toNano('10'); + const result = await gateway.sendDepositAndCall( + sender.getSender(), + amount, + recipient, + callData, + ); + + // ASSERT + const tx = expectTX(result.transactions, { + from: sender.address, + to: gateway.address, + success: true, + }); - // Check that memo logged properly - const memoAddress = utils.loadHexStringFromSlice(depositLog.memo.asSlice(), 20); + utils.logGasUsage(expect, tx); + + // Check log + const log = gw.parseDepositAndCallLog(tx.outMessages.get(0)!.body); - expect(memoAddress).toEqual(evmAddress); + 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); }); it('should perform a donation', async () => { @@ -210,7 +242,7 @@ describe('Gateway', () => { await gateway.sendDeposit( sender.getSender(), toNano('10') + gasFee, - utils.evmAddressToSlice('0x95222290dd7278aa3ddd389cc1e1d165cc4bafe5'), + '0x95222290dd7278aa3ddd389cc1e1d165cc4bafe5', ); let [_, valueLocked] = await gateway.getQueryState(); @@ -232,8 +264,8 @@ describe('Gateway', () => { const signature = utils.signCellECDSA(tssWallet, payload); // Given an admin command to withdraw TON - const cmd: AdminCommand = { - op: GatewayOp.Withdraw, + const cmd: gw.AdminCommand = { + op: gw.GatewayOp.Withdraw, signature, payload: payload, }; @@ -280,7 +312,7 @@ describe('Gateway', () => { await gateway.sendDeposit( sender.getSender(), toNano('10') + gasFee, - utils.evmAddressToSlice(someRandomEvmWallet.address), + someRandomEvmWallet.address, ); // Given a withdrawal payload ... @@ -296,7 +328,7 @@ describe('Gateway', () => { const signature = utils.signCellECDSA(someRandomEvmWallet, payload); // Given an admin command to withdraw TON - const cmd: AdminCommand = { op: GatewayOp.Withdraw, signature, payload }; + const cmd: gw.AdminCommand = { op: gw.GatewayOp.Withdraw, signature, payload }; // ACT & ASSERT // Withdraw TON and expect an error @@ -304,7 +336,7 @@ describe('Gateway', () => { const result = await gateway.sendAdminCommand(cmd); } catch (e: any) { const exitCode = e?.exitCode as number; - expect(exitCode).toEqual(GatewayError.InvalidSignature); + expect(exitCode).toEqual(gw.GatewayError.InvalidSignature); } }); @@ -333,7 +365,7 @@ describe('Gateway', () => { // ACT 2 // Send sample deposit - const result2 = await gateway.sendDeposit(sender.getSender(), toNano('1'), null); + const result2 = await gateway.sendDeposit(sender.getSender(), toNano('1'), 123n); // ASSERT 2 // It should fail @@ -341,7 +373,7 @@ describe('Gateway', () => { from: sender.address, to: gateway.address, success: false, - exitCode: GatewayError.DepositsDisabled, + exitCode: gw.GatewayError.DepositsDisabled, }); // ACT 3 @@ -358,7 +390,7 @@ describe('Gateway', () => { const result4 = await gateway.sendDeposit( sender.getSender(), toNano('1'), - utils.evmAddressToSlice('0x23f4569002a5a07f0ecf688142eeb6bcd883eef8'), + '0x23f4569002a5a07f0ecf688142eeb6bcd883eef8', ); // ASSERT 4 @@ -392,7 +424,7 @@ describe('Gateway', () => { expectTX(result1.transactions, { from: undefined, to: gateway.address, - op: GatewayOp.UpdateTSS, + op: gw.GatewayOp.UpdateTSS, }); // Check that tss was updated @@ -406,7 +438,7 @@ describe('Gateway', () => { await gateway.sendUpdateTSS(tssWallet, newTss.address); } catch (e: any) { const exitCode = e?.exitCode as number; - expect(exitCode).toEqual(GatewayError.InvalidSignature); + expect(exitCode).toEqual(gw.GatewayError.InvalidSignature); } // ACT 3 @@ -445,7 +477,7 @@ describe('Gateway', () => { expectTX(result.transactions, { from: undefined, to: gateway.address, - op: GatewayOp.UpdateCode, + op: gw.GatewayOp.UpdateCode, }); // Try to query this new "ping" method @@ -456,7 +488,7 @@ describe('Gateway', () => { // Try to trigger some TSS command // It should fail because external_message is not implemented anymore! :troll: try { - const result3 = await gateway.sendEnableDeposits(tssWallet, true); + await gateway.sendEnableDeposits(tssWallet, true); } catch (e: any) { // https://docs.ton.org/learn/tvm-instructions/tvm-exit-codes const exitCode = e?.exitCode as number; @@ -464,14 +496,11 @@ describe('Gateway', () => { } }); - // todo deposits: arbitrary long memo - // todo deposits: should fail w/o memo - // todo deposits: should fail w/ value too small - // todo deposits: should fail w/ invalid memo (too short) + // todo deposit_and_call: missing memo + // todo deposit_and_call: memo is too long + // todo deposits: should fail because the value is too small // todo deposits: check that gas costs are always less than 0.01 for long memos - - // todo withdrawals: invalid nonce - // todo withdrawals: amount is more than locked + // todo withdrawals: amount is more than locked (should not be possible, but still worth checking) }); export function expectTX(transactions: Transaction[], cmp: FlatTransactionComparable): Transaction { @@ -482,3 +511,10 @@ export function expectTX(transactions: Transaction[], cmp: FlatTransactionCompar return tx!; } + +function readFixture(fixturePath: string): string { + const filePath = path.resolve(__dirname, '../tests/fixtures/', fixturePath); + const buf = fs.readFileSync(filePath, 'utf-8'); + + return buf.toString(); +} diff --git a/tests/fixtures/long-call-data.txt b/tests/fixtures/long-call-data.txt new file mode 100644 index 0000000..d66c4d1 --- /dev/null +++ b/tests/fixtures/long-call-data.txt @@ -0,0 +1,28 @@ +What is Lorem Ipsum? (approx 2 Kilobytes) + +Lorem Ipsum is simply dummy text of the printing and typesetting industry. +Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took +a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, +but also the leap into electronic typesetting, remaining essentially unchanged. + +It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, +and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. + +Why do we use it? + +It is a long-established fact that a reader will be distracted by the readable content of a page when +looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, +as opposed to using 'Content here, content here', making it look like readable English. Many desktop publishing +packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' +will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes +by accident, sometimes on purpose (injected humour and the like). + +Where does it come from? + +Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical +Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor +at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, +from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered +the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" +(The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, +very popular during the Renaissance. \ No newline at end of file diff --git a/wrappers/Gateway.ts b/wrappers/Gateway.ts index 83454b1..0de461e 100644 --- a/wrappers/Gateway.ts +++ b/wrappers/Gateway.ts @@ -10,12 +10,13 @@ import { Slice, } from '@ton/core'; import { evmAddressToSlice, loadHexStringFromBuffer, signCellECDSA } from '../tests/utils'; -import { Wallet } from 'ethers'; +import { Wallet } from 'ethers'; // copied from `gateway.fc` // copied from `gateway.fc` export enum GatewayOp { - Deposit = 100, - Donate = 101, + Donate = 100, + Deposit = 101, + DepositAndCall = 102, Withdraw = 200, SetDepositsEnabled = 201, @@ -78,20 +79,50 @@ export class Gateway implements Contract { }); } - async sendDeposit(provider: ContractProvider, via: Sender, value: bigint, memo: Slice | null) { - let body = beginCell() + async sendDeposit( + provider: ContractProvider, + via: Sender, + value: bigint, + zevmRecipient: string | bigint, + ) { + // accept bigInt or hex string + if (typeof zevmRecipient === 'string') { + zevmRecipient = BigInt(zevmRecipient); + } + + const body = beginCell() .storeUint(GatewayOp.Deposit, 32) // op code - .storeUint(0, 64); // query id + .storeUint(0, 64) // query id + .storeUint(zevmRecipient, 160) // 20 bytes + .endCell(); - if (memo) { - body = body.storeRef(beginCell().storeSlice(memo).endCell()); + const sendMode = SendMode.PAY_GAS_SEPARATELY; + + await provider.internal(via, { value, sendMode, body }); + } + + async sendDepositAndCall( + provider: ContractProvider, + via: Sender, + value: bigint, + zevmRecipient: string | bigint, + callData: Cell, + ) { + // accept bigInt or hex string + if (typeof zevmRecipient === 'string') { + zevmRecipient = BigInt(zevmRecipient); } - await provider.internal(via, { - value, - sendMode: SendMode.PAY_GAS_SEPARATELY, - body: body.endCell(), - }); + const body = beginCell() + .storeUint(GatewayOp.DepositAndCall, 32) // op code + .storeUint(0, 64) // query id + .storeUint(zevmRecipient, 160) // 20 bytes + .storeRef(callData) + .endCell(); + + const sendMode = SendMode.PAY_GAS_SEPARATELY; + + await provider.internal(via, { value, sendMode, body }); } async sendDonation(provider: ContractProvider, via: Sender, value: bigint) { @@ -206,13 +237,17 @@ export class Gateway implements Contract { } } -export type DepositLog = { +export interface DepositLog { op: number; queryId: number; sender: Address; amount: bigint; - memo: Cell; -}; + recipient: string; +} + +export interface DepositAndCallLog extends DepositLog { + callData: string; +} export function parseDepositLog(body: Cell): DepositLog { const cs = body.beginParse(); @@ -221,7 +256,20 @@ export function parseDepositLog(body: Cell): DepositLog { const queryId = cs.loadUint(64); const sender = cs.loadAddress(); const amount = cs.loadCoins(); - const memo = cs.loadRef(); + const recipient = loadHexStringFromBuffer(cs.loadBuffer(20)); + + return { op, queryId, sender, amount, recipient }; +} + +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, memo }; + return { op, queryId, sender, amount, recipient, callData }; }