diff --git a/contracts/contracts/test/examples/funding/freezer.tolk b/contracts/contracts/test/examples/funding/freezer.tolk new file mode 100644 index 000000000..20ccecb23 --- /dev/null +++ b/contracts/contracts/test/examples/funding/freezer.tolk @@ -0,0 +1,51 @@ +// Small contract to test freezing and unfreezing contracts + +struct Storage { + id: uint32; + value: uint32; +} + +struct (0xed2287f4) SetValue { + queryID: uint64; + value: uint32; +} + +struct (0xfd475c25) Drain { +} + +type InMessages = + | SetValue + | Drain; + +fun onInternalMessage(in: InMessage) { + val msg = lazy InMessages.fromSlice(in.body); + match (msg) { + SetValue => { + var st = lazy Storage.fromCell(contract.getData()); + st.value = msg.value; + contract.setData(st.toCell()); + reply(in.senderAddress).send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE); + } + Drain => { + // send all balance to sender + reply(in.senderAddress).send(SEND_MODE_CARRY_ALL_BALANCE); + } + else => { + assert (in.body.isEmpty()) throw 0xffff; + }, + } +} + +get fun value() { + val st = lazy Storage.fromCell(contract.getData()); + return st.value; +} + +fun reply(dest: address): OutMessage { + return createMessage({ + bounce: BounceMode.NoBounce, + value: 0, + dest, + body: createEmptyCell(), + }); +} \ No newline at end of file diff --git a/contracts/tests/Freeze.spec.ts b/contracts/tests/Freeze.spec.ts new file mode 100644 index 000000000..60179b1eb --- /dev/null +++ b/contracts/tests/Freeze.spec.ts @@ -0,0 +1,298 @@ +import '@ton/test-utils' +import { + Blockchain, + loadConfig, + SandboxContract, + SmartContract, + TreasuryContract, + updateConfig, +} from '@ton/sandbox' +import { beginCell, Cell, StateInit, StorageUsed, toNano } from '@ton/core' + +import * as freezer from '../wrappers/examples/funding/Freezer' +import { + GasLimitsPrices, + GasLimitsPrices_gas_flat_pfx, + GasLimitsPrices_gas_prices, + GasLimitsPrices_gas_prices_ext, +} from '@ton/sandbox/dist/config/config.tlb-gen' +import { generateRandomContractId } from '../src/utils' + +class CompleteStateInit { + constructor( + public code: Cell, + public data: Cell, + ) {} + + static fromStateInit(stateInit: StateInit): CompleteStateInit { + if (!(stateInit.code && stateInit.data)) { + throw new Error('StateInit is missing code or data') + } + return new CompleteStateInit(stateInit.code, stateInit.data) + } +} + +describe('Contract freezing and unfreezing', () => { + let blockchain: Blockchain + let deployer: SandboxContract + let bind: { + freezer: SandboxContract + } + let code: Cell + + const dueLimits = { + freeze_due_limit: 1_000n, + delete_due_limit: 100_000n, + } + + beforeEach(async () => { + blockchain = await Blockchain.create() + blockchain.now = 1 + updateDueLimits(blockchain, dueLimits) + + deployer = await blockchain.treasury('deployer') + code = await freezer.ContractClient.code() + + bind = { + freezer: blockchain.openContract( + freezer.ContractClient.createFromConfig( + { + id: generateRandomContractId(), + value: 0, + }, + code, + ), + ), + } + }) + + it('should freeze and unfreeze', async () => { + // Deploy + const stateInit = await (async (): Promise => { + const result = await bind.freezer.sendDeploy(deployer.getSender(), toNano('1')) + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: bind.freezer.address, + deploy: true, + success: true, + }) + console.log('Deployed') + const state = await getContractState() + logAccountState(state) + if (state.account.account!.storage.state.type !== 'active') { + throw new Error('Contract is not active after deploy!') + } + return CompleteStateInit.fromStateInit(state.account.account!.storage.state.state) + })() + + // Change Status + const currentState = await (async (): Promise => { + const result = await bind.freezer.sendSetValue(deployer.getSender(), { + value: toNano('0.05'), + body: { + queryID: 0n, + value: 42, + }, + }) + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: bind.freezer.address, + success: true, + }) + console.log('Changed state') + const state = await getContractState() + logAccountState(state) + if (state.account.account!.storage.state.type !== 'active') { + throw new Error('Contract is not active after state change!') + } + return CompleteStateInit.fromStateInit(state.account.account!.storage.state.state) + })() + + // Freeze + { + console.log('Freezing') + + // Drain remaining balance to 0 + { + const result = await bind.freezer.sendDrain(deployer.getSender(), toNano('0.05')) + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: bind.freezer.address, + success: true, + }) + expect(result.transactions).toHaveTransaction({ + from: bind.freezer.address, + to: deployer.address, + success: true, + value(x) { + console.log(`Drained value: ${x} nanotons`) + return true + }, + }) + + const state = await getContractState() + logAccountState(state) + expect(state.balance).toBe(0n) + expect(state.account.account!.storageStats.duePayment).toBeNull() + } + + // Accumulate due payments until frozen + { + const probeInterval = 1000 + warpTime(probeInterval) // Advance time by 1000 seconds to accumulate rent fees + await triggerAccountStateUpdate() + const state = await getContractState() + if (!state.accountState) { + throw new Error('Account state is undefined! It probably got deleted.') + } + logAccountState(state) + expect(state.account.account!.storageStats.duePayment).toBeDefined() + const duePayments = state.account.account!.storageStats.duePayment! + expect(duePayments).toBeGreaterThan(0n) + expect(duePayments).toBeLessThan(dueLimits.delete_due_limit) + + const rent = Number(duePayments) / probeInterval + console.log(`Rate: ${rent} nanoton/sec`) + + const expectedDue = (dueLimits.freeze_due_limit + dueLimits.delete_due_limit) / 2n // Must fall between freeze and delete limits + + const timeToFreeze = Math.floor(Number(expectedDue - duePayments + 1n) / rent) + console.log(`Estimated time to freeze: ${timeToFreeze} sec`) + warpTime(timeToFreeze) + await triggerAccountStateUpdate() + + const stateFrozen = await getContractState() + logAccountState(stateFrozen) + + expect(stateFrozen.balance).toBe(0n) + expect(stateFrozen.account.account!.storageStats.duePayment).toBeGreaterThan( + dueLimits.freeze_due_limit, + ) + expect(stateFrozen.account.account!.storageStats.duePayment).toBeLessThan( + dueLimits.delete_due_limit, + ) + + expect(['frozen', 'uninit']).toContain(stateFrozen.accountState!.type) + console.log('Contract is frozen now.') + } + } + + // Unfreeze + { + console.log('Test unfreezing with initial status') + { + const freezerContract = blockchain.openContract( + freezer.ContractClient.createFromFrozen(bind.freezer, stateInit), // Doesn't match current state + ) + const result = await freezerContract.sendDeploy(deployer.getSender(), toNano('0.1')) + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: bind.freezer.address, + success: false, + exitCode: undefined, + }) + } + { + const freezerContract = blockchain.openContract( + freezer.ContractClient.createFromFrozen(bind.freezer, currentState), + ) + const result = await freezerContract.sendDeploy(deployer.getSender(), toNano('0.1')) + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: bind.freezer.address, + success: true, + }) + console.log('Unfrozen by deploy') + const state = await getContractState() + logAccountState(state) + expect(state.account.account!.storage.state.type).toBe('active') + if (!state.account.account!.storageStats.duePayment) { + throw new Error(`Due payment should not have been cleared yet!`) + } + const netBalance = state.balance - state.account.account!.storageStats.duePayment! + + console.log('Trigger account update to pay dues') + await triggerAccountStateUpdate() // Ensure contract is operational + const state2 = await getContractState() + logAccountState(state2) + expect(state2.balance).toBe(netBalance) + + const value = await freezerContract.getValue() + expect(value).toBe(42) + console.log('Contract is unfrozen and operational again.') + } + } + }) + + function warpTime(period: number) { + blockchain.now = blockchain.now!! + period + } + + async function getContractState() { + return await blockchain.getContract(bind.freezer.address) + } + + async function triggerAccountStateUpdate() { + const result = await bind.freezer.sendInternal( + deployer.getSender(), + toNano('1'), + beginCell().storeUint(0xffffffff, 32).asCell(), + ) + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: bind.freezer.address, + success: false, // The call must fail so it bounces the TON back + }) + expect(result.transactions).toHaveTransaction({ + from: bind.freezer.address, + to: deployer.address, + inMessageBounced: true, + }) + } +}) + +function logAccountState(state: SmartContract) { + console.log( + `Balance: ${state.balance} | Due: ${state.account.account!.storageStats.duePayment} | State: ${state.accountState?.type}`, + ) +} + +function updateDueLimits( + blockchain: Blockchain, + dueLimits: { freeze_due_limit: bigint; delete_due_limit: bigint }, +) { + const oldConfig = loadConfig(blockchain.config) + + function fas(prevParam: GasLimitsPrices): GasLimitsPrices { + console.log('Old limits:', prevParam) + switch (prevParam.kind) { + case 'GasLimitsPrices_gas_flat_pfx': + const val: GasLimitsPrices_gas_flat_pfx = { + ...prevParam, + other: fas(prevParam.other), + } + return val + + case 'GasLimitsPrices_gas_prices': { + const val: GasLimitsPrices_gas_prices = { + ...prevParam, + ...dueLimits, + } + return val + } + case 'GasLimitsPrices_gas_prices_ext': { + const val: GasLimitsPrices_gas_prices_ext = { + ...prevParam, + ...dueLimits, + } + return val + } + } + } + const newGasLimit = fas(oldConfig[21].anon0) + const updatedConfig = updateConfig(blockchain.config, { + kind: 'ConfigParam_config_gas_prices', + anon0: newGasLimit, + }) + blockchain.setConfig(updatedConfig) +} diff --git a/contracts/wrappers/examples.funding.Freezer.compile.ts b/contracts/wrappers/examples.funding.Freezer.compile.ts new file mode 100644 index 000000000..e01e53a83 --- /dev/null +++ b/contracts/wrappers/examples.funding.Freezer.compile.ts @@ -0,0 +1,9 @@ +import { CompilerConfig } from '@ton/blueprint' + +export const compile: CompilerConfig = { + lang: 'tolk', + entrypoint: 'contracts/test/examples/funding/freezer.tolk', + withStackComments: true, // Fift output will contain comments, if you wish to debug its output + withSrcLineComments: true, // Fift output will contain .tolk lines as comments + experimentalOptions: '', // you can pass experimental compiler options here +} diff --git a/contracts/wrappers/examples/funding/Freezer.ts b/contracts/wrappers/examples/funding/Freezer.ts new file mode 100644 index 000000000..ef8b537e8 --- /dev/null +++ b/contracts/wrappers/examples/funding/Freezer.ts @@ -0,0 +1,180 @@ +import { SandboxContract } from '@ton/sandbox' +import { + Address, + beginCell, + Builder, + Cell, + Contract, + ContractABI, + contractAddress, + ContractProvider, + Sender, + SendMode, + Slice, +} from '@ton/core' +import { loadContractCode } from '../../codeLoader' + +import { CellCodec } from '../../utils' +import * as ownable2step from '../../libraries/access/Ownable2Step' +import * as typeAndVersion from '../../libraries/versioning/TypeAndVersion' +import { Maybe } from '@ton/core/dist/utils/maybe' + +/// @dev Message to set the contract value. +export type SetValue = { + queryID: bigint + value: number +} + +/// Message to drain the contract balance. +export type Drain = {} + +export const opcodes = { + in: { + SetValue: 0xed2287f4, + drain: 0xfd475c25, + }, + out: {}, +} + +export type ContractData = { + /// ID allows multiple independent instances, since contract address depends on initial state. + id: bigint | number // uint32 + value: number // uint32 +} + +export const builder = { + message: { + in: (() => { + // Creates a new `setValue` message. + const setValue: CellCodec = { + encode: (msg: SetValue): Builder => { + return beginCell() // break line + .storeUint(opcodes.in.SetValue, 32) + .storeUint(msg.queryID, 64) + .storeUint(msg.value, 32) + }, + load: (src: Slice): SetValue => { + src.skip(32) // skip opcode + return { + queryID: src.loadUintBig(64), + value: src.loadUint(32), + } + }, + } + // Creates a new `IncreaseCount` message. + const drain: CellCodec = { + encode: (msg: Drain): Builder => { + return beginCell().storeUint(opcodes.in.drain, 32) + }, + load: (src: Slice): Drain => { + src.skip(32) // skip opcode + return {} + }, + } + + return { setValue, drain } + })(), + }, + data: (() => { + const contractData: CellCodec = { + encode: (data: ContractData): Builder => { + return beginCell().storeUint(data.id, 32).storeUint(data.value, 32) + }, + load: (src: Slice): ContractData => { + return { + id: src.loadUintBig(32), + value: src.loadUint(32), + } + }, + } + + return { + contractData, + } + })(), +} + +export class ContractClient implements Contract, typeAndVersion.Interface { + constructor( + readonly address: Address, + readonly init?: { code: Cell; data: Cell }, + readonly abi?: Maybe, + ) { + this.abi = { + name: 'Freezer', + } + } + + static createFromAddress(address: Address): ContractClient { + return new ContractClient(address) + } + + static createFromConfig(data: ContractData, code: Cell, workchain = 0): ContractClient { + const init = { code, data: builder.data.contractData.encode(data).asCell() } + return new ContractClient(contractAddress(workchain, init), init) + } + + static createFromFrozen( + contract: SandboxContract, + stateInit: { code: Cell; data: Cell }, + ): any { + return new ContractClient(contract.address, stateInit) + } + + async sendInternal(p: ContractProvider, via: Sender, value: bigint, body: Cell) { + await p.internal(via, { + value: value, + sendMode: SendMode.PAY_GAS_SEPARATELY, + body: body, + }) + } + + async sendDeploy(p: ContractProvider, via: Sender, value: bigint): Promise { + const body = Cell.EMPTY + await this.sendInternal(p, via, value, body) + } + + async sendSetValue(p: ContractProvider, via: Sender, opts: { value: bigint; body: SetValue }) { + return this.sendInternal( + p, + via, + opts.value, + builder.message.in.setValue.encode(opts.body).asCell(), + ) + } + + async sendDrain(provider: ContractProvider, via: Sender, value: bigint): Promise { + await provider.internal(via, { + value, + sendMode: SendMode.PAY_GAS_SEPARATELY, + body: builder.message.in.drain.encode({}).asCell(), + }) + } + + static code(): Promise { + return loadContractCode('examples.funding.Freezer') + } + + async getValue(provider: ContractProvider): Promise { + const result = await provider.get('value', []) + return result.stack.readNumber() + } + + async getId(provider: ContractProvider): Promise { + const result = await provider.get('id', []) + return result.stack.readNumber() + } + + // Delegate TypeAndVersion methods + async getTypeAndVersion(provider: ContractProvider): Promise<{ type: string; version: string }> { + return typeAndVersion.getTypeAndVersion(provider) + } + + async getCode(provider: ContractProvider): Promise { + return typeAndVersion.getCode(provider) + } + + async getCodeHash(provider: ContractProvider): Promise { + return typeAndVersion.getCodeHash(provider) + } +} diff --git a/integration-tests/monitor/balance_test.go b/integration-tests/monitor/balance_test.go index a405d5086..8e0b66ede 100644 --- a/integration-tests/monitor/balance_test.go +++ b/integration-tests/monitor/balance_test.go @@ -17,7 +17,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/monitoring/balance" test_utils "github.com/smartcontractkit/chainlink-ton/deployment/utils" - "github.com/smartcontractkit/chainlink-ton/pkg/relay/monitor" + monitor "github.com/smartcontractkit/chainlink-ton/pkg/relay/monitor/balance" relayer_utils "github.com/smartcontractkit/chainlink-ton/pkg/relay/testutils" "github.com/smartcontractkit/chainlink-ton/pkg/ton/tracetracking" "github.com/smartcontractkit/chainlink-ton/pkg/ton/tvm" @@ -126,7 +126,7 @@ func TestBalanceMonitor_Polling(t *testing.T) { keystore.AddKey(tonChain.Wallet.PrivateKey()) require.NotNil(t, keystore) - opts := monitor.BalanceMonitorOpts{ + opts := monitor.MonitorOpts{ ChainInfo: balance.ChainInfo{ ChainFamilyName: "ton", ChainID: string(chainsel.TON_LOCALNET.ChainID), @@ -144,7 +144,7 @@ func TestBalanceMonitor_Polling(t *testing.T) { } // Create and start balance monitor - balanceMonitor, err := monitor.NewBalanceMonitor(opts) + balanceMonitor, err := monitor.NewMonitor(opts) require.NoError(t, err) require.NotNil(t, balanceMonitor) err = balanceMonitor.Start(t.Context()) diff --git a/pkg/relay/chain.go b/pkg/relay/chain.go index 56eff164d..8afadecfa 100644 --- a/pkg/relay/chain.go +++ b/pkg/relay/chain.go @@ -35,7 +35,7 @@ import ( "github.com/smartcontractkit/chainlink-ton/pkg/logpoller" txloader "github.com/smartcontractkit/chainlink-ton/pkg/logpoller/loader" lppgstore "github.com/smartcontractkit/chainlink-ton/pkg/logpoller/store/postgres" - "github.com/smartcontractkit/chainlink-ton/pkg/relay/monitor" + balancemonitor "github.com/smartcontractkit/chainlink-ton/pkg/relay/monitor/balance" tonchain "github.com/smartcontractkit/chainlink-ton/pkg/ton/chain" tonconfig "github.com/smartcontractkit/chainlink-ton/pkg/ton/config" "github.com/smartcontractkit/chainlink-ton/pkg/ton/tracetracking" @@ -161,7 +161,7 @@ func newChain(cfg *config.TOMLConfig, loopKs loop.Keystore, lggr logger.Logger, return nil, fmt.Errorf("failed to get chain info for balance monitor: %w", err) } - ch.bm, err = monitor.NewBalanceMonitor(monitor.BalanceMonitorOpts{ + ch.bm, err = balancemonitor.NewMonitor(balancemonitor.MonitorOpts{ ChainInfo: balance.ChainInfo{ ChainFamilyName: chainInfo.FamilyName, ChainID: chainInfo.ChainID, diff --git a/pkg/relay/monitor/balance.go b/pkg/relay/monitor/balance/balance.go similarity index 92% rename from pkg/relay/monitor/balance.go rename to pkg/relay/monitor/balance/balance.go index 9860c5a94..a54334857 100644 --- a/pkg/relay/monitor/balance.go +++ b/pkg/relay/monitor/balance/balance.go @@ -1,4 +1,4 @@ -package monitor +package balance import ( "context" @@ -19,8 +19,8 @@ import ( tonconfig "github.com/smartcontractkit/chainlink-ton/pkg/ton/config" ) -// BalanceMonitorOpts contains the options for creating a new TON account balance monitor. -type BalanceMonitorOpts struct { +// MonitorOpts contains the options for creating a new TON account balance monitor. +type MonitorOpts struct { ChainInfo balance.ChainInfo Config balance.GenericBalanceConfig @@ -29,8 +29,8 @@ type BalanceMonitorOpts struct { NewClient func(context.Context) (*ton.APIClient, error) } -// NewBalanceMonitor returns a balance monitoring services.Service which reports balance of all Keystore accounts. -func NewBalanceMonitor(opts BalanceMonitorOpts) (services.Service, error) { +// NewMonitor returns a balance monitoring services.Service which reports balance of all Keystore accounts. +func NewMonitor(opts MonitorOpts) (services.Service, error) { return balance.NewGenericBalanceMonitor(balance.GenericBalanceMonitorOpts{ ChainInfo: opts.ChainInfo, ChainNativeCurrency: "TON", diff --git a/pkg/relay/monitor/balance_test.go b/pkg/relay/monitor/balance/balance_test.go similarity index 99% rename from pkg/relay/monitor/balance_test.go rename to pkg/relay/monitor/balance/balance_test.go index 874355416..1d08cd99d 100644 --- a/pkg/relay/monitor/balance_test.go +++ b/pkg/relay/monitor/balance/balance_test.go @@ -1,4 +1,4 @@ -package monitor +package balance import ( "crypto/ed25519" diff --git a/pkg/relay/monitor/state/state.go b/pkg/relay/monitor/state/state.go new file mode 100644 index 000000000..51924e34f --- /dev/null +++ b/pkg/relay/monitor/state/state.go @@ -0,0 +1,73 @@ +package state + +import ( + "context" + + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/ton" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/services" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" + "github.com/smartcontractkit/chainlink-ton/pkg/logpoller" +) + +type ServiceOptions struct { + ContractsToMonitor []address.Address + + LogPoller logpoller.Service + Logger logger.Logger + KeyStore core.Keystore +} + +func NewService(opts ServiceOptions) (services.Service, error) { + return service{ + Service: nil, + eng: &services.Engine{}, + lggr: logger.Sugared(opts.Logger), + clientProvider: func(context.Context) (ton.APIClientWrapped, error) { + panic("TODO") + }, + chainID: "", + s: nil, + }, nil +} + +type service struct { + services.Service + eng *services.Engine // Service engine for lifecycle management + lggr logger.SugaredLogger // Logger instance + clientProvider func(context.Context) (ton.APIClientWrapped, error) // TON blockchain client lazy getter + chainID string // Target chain ID + + s logpoller.Service // Log poller service +} + +// Close implements services.Service. +func (s service) Close() error { + panic("unimplemented") +} + +// HealthReport implements services.Service. +func (s service) HealthReport() map[string]error { + panic("unimplemented") +} + +// Name implements services.Service. +func (s service) Name() string { + return s.lggr.Name() + +} + +// Ready implements services.Service. +func (s service) Ready() error { + panic("unimplemented") +} + +// Start implements services.Service. +func (s service) Start(context.Context) error { + s.Service, s.eng = services.Config{ + Name: "TONLogPoller", + Start: s.start, + }.NewServiceEngine(lggr) +}