Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added

- Sandbox UI support

## [0.38.0] - 2025-10-15

### Added
Expand Down
10 changes: 10 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
const base = require('@ton/toolchain');
const globals = require('globals');

module.exports = [
...base,
{ ignores: ['src/executor/emulator-emscripten*', 'src/config/defaultConfig.ts', 'src/config/slimConfig.ts'] },
{
files: ['src/jest/**/*.ts', 'src/jest/**/*.tsx'],
languageOptions: {
globals: {
...globals.jest,
...globals.node,
},
},
},
];
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@types/jest": "^30.0.0",
"@types/node": "^24.1.0",
"eslint": "^9.28.0",
"globals": "^16.4.0",
"jest": "^30.0.5",
"jest-config": "^30.0.5",
"jest-environment-node": "^30.0.5",
Expand Down
56 changes: 51 additions & 5 deletions src/blockchain/Blockchain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ import { MessageQueueManager } from './MessageQueueManager';
import { AsyncLock } from '../utils/AsyncLock';
import { BlockchainSnapshot } from './BlockchainSnapshot';
import { requireOptional } from '../utils/require';
import { noop } from '../utils/noop';
import { getOptionalEnv } from '../utils/environment';
import { IUIConnector } from '../ui/connection/UIConnector';
import { WebSocketConnectionOptions } from '../ui/connection/websocket/types';
import { OneTimeWebSocketConnector } from '../ui/connection/websocket/OneTimeWebSocketConnector';
import { IUIManager, UIManager } from '../ui/UIManager';

const CREATE_WALLETS_PREFIX = 'CREATE_WALLETS';

Expand Down Expand Up @@ -130,6 +136,7 @@ export function toSandboxContract<T>(contract: OpenedContract<T>): SandboxContra
export type PendingMessage = (
| ({
type: 'message';
callStack?: string;
mode?: number;
} & Message)
| {
Expand Down Expand Up @@ -174,6 +181,8 @@ export type SendMessageIterParams = MessageParams & {
allowParallel?: boolean;
};

export type UIOptions = { enabled?: boolean; connector?: IUIConnector } & WebSocketConnectionOptions;

export class Blockchain {
protected lock = new AsyncLock();

Expand All @@ -199,6 +208,7 @@ export class Blockchain {
protected transactions: BlockchainTransaction[] = [];

protected defaultQueueManager: MessageQueueManager;
protected uiManager: IUIManager;

protected collectCoverage: boolean = false;
protected readonly coverageTransactions: BlockchainTransaction[][] = [];
Expand Down Expand Up @@ -323,6 +333,7 @@ export class Blockchain {
storage: BlockchainStorage;
meta?: ContractsMeta;
autoDeployLibs?: boolean;
uiOptions?: UIOptions;
}) {
this.networkConfig = blockchainConfigToBase64(opts.config);
this.executor = opts.executor;
Expand All @@ -331,6 +342,21 @@ export class Blockchain {
this.autoDeployLibs = opts.autoDeployLibs ?? false;

this.defaultQueueManager = this.createQueueManager();
this.uiManager = this.createUiManager(opts.uiOptions);
}

protected createUiManager(opts?: UIOptions): IUIManager {
if (!opts?.enabled) {
// noop implementation
return { publishTransactions: noop };
}

const connector = opts.connector ?? new OneTimeWebSocketConnector(opts);

return new UIManager(connector, {
getMeta: (address) => this.meta?.get(address),
knownContracts: () => this.storage.knownContracts(),
});
}

protected createQueueManager(): MessageQueueManager {
Expand All @@ -341,8 +367,11 @@ export class Blockchain {
getLibs: () => this.libs,
setLibs: (value: Cell | undefined) => (this.libs = value),
getAutoDeployLibs: () => this.autoDeployLibs,
registerTxsForCoverage: (txs) => this.registerTxsForCoverage(txs),
addTransaction: (transaction: BlockchainTransaction) => this.transactions.push(transaction),
onTransactions: (txs) => {
this.registerTxsForCoverage(txs);
this.uiManager.publishTransactions(txs);
},
onTransaction: (transaction: BlockchainTransaction) => this.transactions.push(transaction),
});
}

Expand Down Expand Up @@ -655,11 +684,12 @@ export class Blockchain {
* Opens contract. Returns proxy that substitutes the blockchain Provider in methods starting with get and set.
*
* @param contract Contract to open.
* @param name Name of the contract.
*
* @example
* const contract = blockchain.openContract(new Contract(address));
*/
openContract<T extends Contract>(contract: T) {
openContract<T extends Contract>(contract: T, name?: string) {
let address: Address;
let init: StateInit | undefined = undefined;

Expand All @@ -677,7 +707,7 @@ export class Blockchain {
init = contract.init;
}

this.meta?.upsert(address, { wrapperName: contract?.constructor?.name, abi: contract.abi });
this.meta?.upsert(address, { wrapperName: name ?? contract?.constructor?.name, abi: contract.abi });

const provider = this.provider(address, init);

Expand Down Expand Up @@ -808,6 +838,7 @@ export class Blockchain {
*/
public enableCoverage(enable: boolean = true) {
this.collectCoverage = enable;
this.verbosity.print = false;
this.verbosity.vmLogs = 'vm_logs_verbose';
}

Expand Down Expand Up @@ -896,6 +927,7 @@ export class Blockchain {
* @param [opts.storage] Contracts storage used for blockchain. If omitted {@link LocalBlockchainStorage} is used.
* @param [opts.meta] Optional contracts metadata provider. If not provided, {@link @ton/test-utils.contractsMeta} will be used to accumulate contracts metadata.
* @param [opts.autoDeployLibs] Optional flag. If set to true, libraries will be collected automatically
* @param [opts.useWebsocket] Send data to websocket using `opts.connectionOptions` options.
* @example
* const blockchain = await Blockchain.create({ config: 'slim' });
*
Expand All @@ -914,12 +946,26 @@ export class Blockchain {
storage?: BlockchainStorage;
meta?: ContractsMeta;
autoDeployLibs?: boolean;
uiOptions?: UIOptions;
}) {
return new Blockchain({
const uiEnabled = opts?.uiOptions?.enabled ?? getOptionalEnv('SANDBOX_UI_ENABLED', 'boolean');

const blockchain = new Blockchain({
executor: opts?.executor ?? (await Executor.create()),
storage: opts?.storage ?? new LocalBlockchainStorage(),
meta: opts?.meta ?? requireOptional('@ton/test-utils')?.contractsMeta,
...opts,
uiOptions: {
enabled: uiEnabled,
...opts?.uiOptions,
},
});

if (uiEnabled) {
blockchain.verbosity.print = false;
blockchain.verbosity.vmLogs = 'vm_logs_verbose';
}

return blockchain;
}
}
14 changes: 9 additions & 5 deletions src/blockchain/MessageQueueManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ export class MessageQueueManager {
getLibs(): Cell | undefined;
setLibs(libs: Cell | undefined): void;
getAutoDeployLibs(): boolean;
registerTxsForCoverage(txs: BlockchainTransaction[]): void;
addTransaction(transaction: BlockchainTransaction): void;
onTransactions(txs: BlockchainTransaction[]): void;
onTransaction(transaction: BlockchainTransaction): void;
},
) {}

Expand Down Expand Up @@ -99,7 +99,8 @@ export class MessageQueueManager {

return result;
});
this.blockchain.registerTxsForCoverage(results);

this.blockchain.onTransactions(results);
return results;
}

Expand All @@ -109,17 +110,19 @@ export class MessageQueueManager {
while (!done) {
const message = this.messageQueue.shift()!;

let callStack: string | undefined;
let tx: SmartContractTransaction;
let smartContract: SmartContract;
if (message.type === 'message') {
callStack = message.callStack;
if (message.info.type === 'external-out') {
done = this.messageQueue.length == 0;
continue;
}

this.blockchain.increaseLt();
smartContract = await this.blockchain.getContract(message.info.dest);
tx = await smartContract.receiveMessage(message, params);
tx = await smartContract.receiveMessage(message, params, callStack);
} else {
this.blockchain.increaseLt();
smartContract = await this.blockchain.getContract(message.on);
Expand All @@ -136,7 +139,7 @@ export class MessageQueueManager {
};
transaction.parent?.children.push(transaction);

this.blockchain.addTransaction(transaction);
this.blockchain.onTransaction(transaction);
result = transaction;
done = true;

Expand All @@ -163,6 +166,7 @@ export class MessageQueueManager {
type: 'message',
parentTransaction: transaction,
mode: sendMsgActions[index]?.mode,
callStack,
...message,
});

Expand Down
13 changes: 9 additions & 4 deletions src/blockchain/SmartContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ export type SmartContractTransaction = Transaction & {
blockchainLogs: string;
vmLogs: string;
debugLogs: string;
callStack?: string;
oldStorage?: Cell;
newStorage?: Cell;
outActions?: OutActionExtended[];
Expand Down Expand Up @@ -414,7 +415,7 @@ export class SmartContract {
};
}

async receiveMessage(message: Message, params?: MessageParams) {
async receiveMessage(message: Message, params?: MessageParams, callStack?: string) {
const args: RunTransactionArgs = {
...this.createCommonArgs(params),
message: beginCell().store(storeMessage(message)).endCell(),
Expand All @@ -425,14 +426,14 @@ export class SmartContract {
const { uninitialized, debugInfo } = debugContext.getDebugInfo(this.account);
if (debugInfo !== undefined) {
const executor = await this.blockchain.getDebuggerExecutor();
return await this.runCommon(() => debugContext.debugTransaction(executor, args, debugInfo));
return await this.runCommon(() => debugContext.debugTransaction(executor, args, debugInfo), callStack);
} else if (uninitialized) {
// eslint-disable-next-line no-console
console.log('Debugging uninitialized accounts is unsupported in debugger beta');
}
}

return await this.runCommon(() => this.blockchain.executor.runTransaction(args));
return await this.runCommon(() => this.blockchain.executor.runTransaction(args), callStack);
}

async runTickTock(which: TickOrTock, params?: MessageParams) {
Expand All @@ -444,7 +445,10 @@ export class SmartContract {
);
}

protected async runCommon(run: () => Promise<EmulationResult>): Promise<SmartContractTransaction> {
protected async runCommon(
run: () => Promise<EmulationResult>,
callStack?: string,
): Promise<SmartContractTransaction> {
let oldStorage: Cell | undefined = undefined;
if (this.blockchain.recordStorage && this.account.account?.storage.state.type === 'active') {
oldStorage = this.account.account?.storage.state.state.data ?? undefined;
Expand Down Expand Up @@ -498,6 +502,7 @@ export class SmartContract {
blockchainLogs: res.logs,
vmLogs: res.result.vmLog,
debugLogs: res.debugLogs,
callStack,
oldStorage,
newStorage,
outActions,
Expand Down
46 changes: 46 additions & 0 deletions src/jest/uiSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { randomUUID } from 'crypto';

import { Blockchain } from '../blockchain/Blockchain';
import { ManagedWebSocketConnector } from '../ui/connection/websocket/ManagedWebSocketConnector';

let websocketConnector: ManagedWebSocketConnector | undefined;

beforeAll(() => {
const originalCreate = Blockchain.create.bind(Blockchain);

Blockchain.create = async (...args): Promise<Blockchain> => {
let [opts, ...otherArgs] = args;

if (!opts?.uiOptions?.connector) {
if (!websocketConnector) {
websocketConnector = new ManagedWebSocketConnector(opts?.uiOptions);
// eslint-disable-next-line no-console
console.log('Connecting to websocket connector...');
await websocketConnector.connect();
}

opts = {
...opts,
uiOptions: {
...opts?.uiOptions,
enabled: true,
connector: websocketConnector,
},
};
}

return await originalCreate(opts, ...otherArgs);
};
});

beforeEach(() => {
expect.setState({ testId: randomUUID() });
});

afterEach(() => {
expect.setState({ testId: undefined });
});

afterAll(() => {
websocketConnector?.disconnect();
});
51 changes: 51 additions & 0 deletions src/ui/UIManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Address } from '@ton/core';

import { BlockchainTransaction } from '../blockchain/Blockchain';
import { ContractMeta } from '../meta/ContractsMeta';
import { SmartContract } from '../blockchain/SmartContract';
import { serializeTransactions, serializeContracts, TestInfo, MessageTestData } from './protocol';
import { IUIConnector } from './connection/UIConnector';

// eslint-disable-next-line no-undef
declare const expect: jest.Expect | undefined;

export interface IUIManager {
publishTransactions(transactions: BlockchainTransaction[]): void;
}

export class UIManager implements IUIManager {
constructor(
private readonly connector: IUIConnector,
private readonly blockchain: {
getMeta(address: Address): ContractMeta | undefined;
knownContracts(): SmartContract[];
},
) {}

publishTransactions(txs: BlockchainTransaction[]) {
let testInfo = this.getTestInfo();
const transactions = serializeTransactions(txs);
const knownContracts = this.blockchain.knownContracts();
const contracts = serializeContracts(
knownContracts.map((contract) => ({ contract, meta: this.blockchain.getMeta(contract.address) })),
);

this.connector.send(
JSON.stringify({ type: 'test-data', testInfo, transactions, contracts } satisfies MessageTestData),
);
}

private getTestInfo(): TestInfo | undefined {
if (expect === undefined) {
return;
}

const expectState = expect.getState();

return {
id: expectState.testId,
name: expectState.currentTestName,
path: expectState.testPath,
};
}
}
Loading