I maintain a non-custodial escrow protocol that runs on two blockchains: Base, an Ethereum L2, and TON, the chain behind Telegram. Same product, same core logic on paper: lock funds, deliver work, release on approval, handle disputes.
I expected the second implementation to be a port. It wasn't. The two chains disagree so fundamentally about how a contract should be structured that writing the same feature twice forced me to rethink what "the same" even means. This post is about those differences, the design decisions each model pushed me toward, and what I'd tell anyone about to make the same jump.
Light on code, heavy on the reasoning. The interesting part was never the syntax.
The single most important difference is not the language. It's the execution model underneath.
On Base, a contract is an object with shared state. You write Solidity, and it behaves like a class instance sitting in memory that everyone calls into. A user calls a function, the function reads and writes contract storage synchronously, and either the whole thing succeeds or it reverts atomically. All my escrows live in one contract, in one big mapping, and every call reaches straight into that shared state.
On TON, a contract is an actor that receives messages. You write Tact, and it behaves like an isolated process with a mailbox. You don't call a function; you send a message, and the contract handles it in its own turn. There is no synchronous cross-contract call in the EVM sense. Interaction between contracts is asynchronous message passing, and you design around that or you fight it the whole way.
The shape of the entry point tells the whole story. On Base, an external caller invokes a named function:
function approveWork(uint256 _contractId)
external validContract(_contractId) nonReentrant whenNotd
{
// reads and writes shared contract storage, synchronously
}
On TON, nothing "calls" the contract. A message arrives, and a handler consumes it in the actor's own turn:
receive(msg: ClaimTimeout) {
let escrow: EscrowData = self.requireEscrow(msg.escrowId);
require(context().sender == escrow.provider, "Only provider can claim timeout");
// ... handled to completion, no synchronous caller waiting on the stack
}
Everything downstream flows from this one distinction.
On Base, the first thing I reached for was ReentrancyGuard. Every state-changing function that moves money carries the guard:
contract NovaCont is ReentrancyGuard, Ownable2Step, Pausable {
// every fund-moving function is marked nonReentrant:
// ... external payable whenNotd nonReentrant
// ... external validContract(id) nonReentrant whenNotd
}
This is not optional hygiene in the EVM world; the synchronous call model is exactly what makes reentrancy possible. When your contract calls out to transfer funds, control can re-enter before your state settles. The entire discipline of "checks-effects-interactions," pull-payment patterns, and reentrancy guards exists to tame that one property.
On TON, I went looking for the equivalent guard and slowly realised I didn't need one. The actor model doesn't have the synchronous re-entry surface in the same shape. A message is handled to completion in its turn; an outbound transfer is itself just another message queued for later, not a synchronous callout that hands control to attacker code mid-execution. The reentrancy attack that dominates EVM security thinking simply doesn't map onto the model the same way.
That was the first moment the "port" mentality broke. A whole category of defensive code on one chain was answering a question the other chain doesn't ask.
This difference is subtle and it shaped both designs.
On Base I use a pull-payment pattern. When someone is owed money, I don't push it to them inside the function. I credit a balance they withdraw later, in a separate transaction:
// settlement doesn't send; it credits a balance
pendingWithdrawals[token][recipient] += amount;
// the recipient later calls withdraw() themselves
This is deliberate. Pushing funds to an arbitrary address inside your logic hands control to that address, which reintroduces exactly the reentrancy and gas-griefing risks you're trying to avoid. Pull-payment isolates the value transfer from the state change. It's more steps for the user, but it's the safe EVM idiom, and withdrawal even stays available while the contract is d, because a should stop new agreements, not trap funds people already earned.
On TON, sending is just emitting a message, so the calculus changes. A payout is dispatched inline as part of settling, and because that send is itself a queued message rather than a synchronous callout, it doesn't hand control to the recipient's code mid-execution:
send(SendParameters{
to: escrow.provider,
value: providerNet,
mode: SendPayGasSeparately | SendBounceIfActionFail
});
The pressure that makes push-payment dangerous on the EVM is largely absent. The design leans into sending directly, with the gas budget for those outbound messages accounted for explicitly at creation time rather than assumed.
Same goal, "get funds to the right party safely," and the safe path is shaped differently on each chain because the danger is shaped differently.
Here's where the models pull hardest in opposite directions.
Base rewards centralisation. Because state is shared and calls are synchronous, putting every escrow in one contract with one mapping is natural and cheap to reason about. One deployment, one address, one place for the dispute logic to reach into. The cost, and it is a real cost I carry, is that all state accumulates in a single contract, and per-transaction cost and storage rent grow with it. But the model makes the centralised design the path of least resistance.
TON rewards the opposite. The idiomatic TON design is a contract per item: a factory that spawns a child contract for each escrow, each an actor with its own storage and its own lifecycle. This is not a stylistic preference; it's how the chain wants to scale, because isolated actors parallelise where shared state serialises. I did not start there, and the single-contract approach is the sharpest architectural debt in the TON build precisely because it runs against the grain of the platform.
The lesson: the "obvious" structure on one chain is the anti-pattern on the other. Centralised is comfortable on Base and a scaling ceiling on TON.
One decision did survive the crossing, and it's worth calling out because it's chain-independent.
On Base, dispute resolution lives in a separate contract, an independent arbitration system that the escrow contract talks to across a tightly controlled interface, rather than being welded into the escrow logic itself. The reasoning is isolation: a bug or an exploit in the dispute machinery shouldn't be able to reach into and compromise the custody of funds in escrow. Two contracts, two blast radii.
That principle, keep the thing that holds the money simpler than the thing that makes judgments about it, held up on both chains, even though the two express "another contract" so differently (a synchronous interface on one, message passing on the other). When a design principle survives a paradigm shift intact, that's usually a sign it was a real principle and not just a local convention.
Don't port. Re-derive. The temptation is to translate your Solidity into Tact line by line. Resist it. Start from the execution model and ask what the right structure is on the target chain. Half of what you wrote on the first chain is answering questions the second chain doesn't have.
Let the danger model define the safe pattern. Reentrancy guards, pull-payments, and reentrancy-driven ordering are EVM answers to EVM threats. On a different model, some of that is dead weight, and a different set of concerns (message ordering, gas accounting for outbound messages, storage rent) takes its place. Learn the new threat model before you copy the old defenses.
Keep custody boundaries simple, everywhere. The one thing worth holding constant across chains is that the code holding funds should be as small and as boring as possible, with the complex logic pushed to the side. That travels.
Two chains, two paradigms, one product. The parts I thought would transfer (the defensive patterns) mostly didn't, and the part I almost undervalued (a clean separation between custody and judgment) turned out to be the only thing that was truly portable.
I build NovaCont, a non-custodial escrow protocol on Base and TON. Contract addresses and security notes are public. [Docs](https://novacont.gitbook.io/nova-docs).