TokenTransfer onramp flow skeleton#754
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces the initial “token transfer onramp” flow for CCIP-on-TON: token transfers are initiated via Jetton transfer_notification, routed through Router -> OnRamp -> CCIPSendExecutor, and resolved via a new TokenRegistry that returns the token’s configured pool for lock/burn.
Changes:
- Add token-transfer message plumbing (
Common_JettonTransferNotification,Router_LockOrBurn,OnRamp_ExecutorRequestsLockOrBurn) and update CCIPSendExecutor state machine to include token-registry lookup + lock/burn confirmation. - Add
TokenRegistrycontract + wrapper, plus aMockTokenPooltest contract/wrapper to simulate lock/burn. - Update FeeQuoter to accept token transfers (currently priced like token-less messages) and add/adjust tests including a new end-to-end token-transfer test.
Reviewed changes
Copilot reviewed 33 out of 33 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| contracts/wrappers/gen/ccip/Router.ts | Regenerated wrapper: adds structs/messages for jetton transfer notification and lock/burn flow + new error IDs. |
| contracts/wrappers/gen/ccip/OnRamp.ts | Regenerated wrapper: adds Router_LockOrBurn, OnRamp_ExecutorRequestsLockOrBurn, storage field tokenRegistry, and tokenAmounts snake typing. |
| contracts/wrappers/gen/ccip/FeeQuoter.ts | Regenerated wrapper: updated CodeCell; error mapping adjusted after token-transfer acceptance. |
| contracts/wrappers/gen/ccip/CCIPSendExecutor.ts | Regenerated wrapper: adds TokenRegistry and MockTokenPool message types, new executor states, and new errors. |
| contracts/wrappers/ccip/TokenRegistry.ts | New TS wrapper for TokenRegistry deployment/config. |
| contracts/wrappers/ccip/Router.ts | Adds lockOrBurn opcode constant for Router wrapper. |
| contracts/wrappers/ccip/OnRamp.ts | Adds optional tokenRegistry to storage encoding/decoding and new opcode constant. |
| contracts/wrappers/ccip/MockTokenPool.ts | New TS wrapper for the mock token pool used in tests. |
| contracts/wrappers/ccip/CCIPSendExecutor.ts | Updates Addresses/Config to carry tokenRegistry and adds inbound opcodes for new messages. |
| contracts/wrappers/ccip.TokenRegistry.compile.ts | New blueprint compile config for TokenRegistry. |
| contracts/wrappers/ccip.test.mockTokenPool.compile.ts | New blueprint compile config for mock token pool. |
| contracts/tests/Logs.ts | Decodes tokenAmounts snake cell into arrays for log matching. |
| contracts/tests/ccip/router/Router.Setup.ts | Allows injecting TokenRegistry address into OnRamp deployment during test setup. |
| contracts/tests/ccip/feequoter/FeeQuoter.getValidatedFee.spec.ts | Updates test expectations: token transfers now accepted and priced like token-less messages. |
| contracts/tests/ccip/e2e/CCIPSendWithTokenTransfer.spec.ts | New E2E test for jetton-initiated CCIP send with token transfer path. |
| contracts/contracts/ccip/token_registry/types.tolk | New TokenRegistry types. |
| contracts/contracts/ccip/token_registry/storage.tolk | New TokenRegistry storage definition/load/store. |
| contracts/contracts/ccip/token_registry/messages.tolk | New TokenRegistry request/response message schema. |
| contracts/contracts/ccip/token_registry/contract.tolk | New TokenRegistry contract implementation. |
| contracts/contracts/ccip/test/tokenPool/messages.tolk | New mock token pool message schema. |
| contracts/contracts/ccip/test/tokenPool/contract.tolk | New mock token pool contract implementation. |
| contracts/contracts/ccip/router/messages.tolk | Router now accepts Router_LockOrBurn and Common_JettonTransferNotification. |
| contracts/contracts/ccip/router/errors.tolk | Adds TokenTransferNotThroughNotification router error. |
| contracts/contracts/ccip/router/contract.tolk | Implements token-transfer notification path and lock/burn forwarding. |
| contracts/contracts/ccip/onramp/types.tolk | Changes TVM ramp message body tokenAmounts to SnakedCell<TokenAmount>. |
| contracts/contracts/ccip/onramp/storage.tolk | Adds tokenRegistry: address? to OnRamp storage. |
| contracts/contracts/ccip/onramp/messages.tolk | Adds OnRamp_ExecutorRequestsLockOrBurn message. |
| contracts/contracts/ccip/onramp/contract.tolk | Computes tokenRegistry for token sends and forwards executor lock/burn requests to Router. |
| contracts/contracts/ccip/fee_quoter/contract.tolk | Removes “no tokens supported” asserts; token transfers now allowed (extra fee ignored for now). |
| contracts/contracts/ccip/ccipsend_executor/types.tolk | Adds tokenRegistry address, new executor states for token flow. |
| contracts/contracts/ccip/ccipsend_executor/messages.tolk | Adds inbound messages for TokenRegistry + MockTokenPool callbacks. |
| contracts/contracts/ccip/ccipsend_executor/errors.tolk | Adds TokenNotEnabled executor error. |
| contracts/contracts/ccip/ccipsend_executor/contract.tolk | Implements token registry query + lock/burn request/confirmation path. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
patricios-space
left a comment
There was a problem hiding this comment.
I like this structure! I've left some general comments. I've only reviewed the contracts for now.
About the validation of the wallet, we should be forwarding that address from the Router through the OnRamp and to the SendExecutor so it can do the validation. We should name this fields untrustedWalletAddress or something similar, to remember that they must be verified
|
|
||
| fun CCIPSendExecutor<CCIPSendExecutor_State_TokenRegistryAccess>.onTokenInfoReceived(mutate self, msg: TokenRegistry_ReturnTokenInfo) { | ||
| //TODO validate sender wallet address by querying the jettonMinter | ||
| assert(msg.tokenInfo.enabled) throw CCIPSendExecutor_Error.TokenNotEnabled; |
There was a problem hiding this comment.
We can use the type system to represent this. Maybe the registry should return a different message if the token is not enabled OR it returns an optional TokenInfo. In this way, we don't even have the tokenPool address here, preventing us from trying to lock anyway
There was a problem hiding this comment.
I think I prefer having just one request and response instead of two different response types, but I can do the two messages if you think it's important to leave that tokenPool field out when it won't be used.
Maybe we should also handle bounced messages from Executor->Registry which would indicate that the token has not been enabled either.
I think handling this could be part of the scope of the task to handle the failure path on the CCIPSend TokenTransfer flow. I'll add a TODO
There was a problem hiding this comment.
Sure, I don't think you need to different opcodes. The msg struct could be:
struct (0xddccddb5) TokenRegistry_ReturnTokenInfo {
minterAddress: address
tokenPool: address?
}
In this way, msg.tokenPool == null means that it is disabled
| bounce: true, | ||
| value: 0, | ||
| dest: tokenRegistry, | ||
| body: TokenRegistry_GetTokenInfo { |
There was a problem hiding this comment.
Why don't we do this earlier in onExecute? We can send to FQ and TR at the same time.
There was a problem hiding this comment.
Hm, maybe we could do that, it makes the state machine more complex though. You would have to wait for two separate messages, you don't know which response will arrive first. You'd have to block on which ever comes first and then wait for the second one to do the same handling.
Besides, it's the same amount of messages, two outgoing one incoming, yeah it's sequential instead of parallel, but I don't think parallelizing this would be a meaningful optimization.
There was a problem hiding this comment.
I agree with @vicentevieytes here. I don't think the extra complexity is worth the small gains. With 0.6 seconds per TX, we would be shaving 1.2 seconds from total latency.
There was a problem hiding this comment.
I agree that 1.2s is a lot :)
I also think that keeping state and counting to 2 in a sharded contract should be straightforward and the default thing to do here.
There was a problem hiding this comment.
I'll add a TODO to keep it as an optimization to consider
| nackMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE); | ||
| } | ||
|
|
||
| //TODO Should be a jetton transfer to the TokenPool wallet, and the LockOrBurn message should go in the forwardPayload of that jetton transfer |
There was a problem hiding this comment.
Think I would move this to the withdraw patter - instead of sending a Jetton, we'd send TokenPool_LockOrBurn to the TP and allow it to withdraw.
The thing is at this point we still didn't go through all the preflight checks - (1) gas check for TP processing - unique per TP/Jetton, (2) rate limiter check, (3) advanced hooks check
We don't want to move funds then return if any of those fail, but ideally we do all the checks and withdraw funds sequentially.
There was a problem hiding this comment.
I think the withdraw path should go through the executor - so the TP needs to withdraw -> executor, which can verify the token amounts and will withdraw -> on-ramp, which can verify the executor. Finally the on-ramp withdraws from the Router - the TP accepts only transfers from the Router JettonWallet.
TokenPool_LockOrBurn msg needs to have an address of an executor to use for withdrawals.
There was a problem hiding this comment.
That makes sense, the token pool first does all the pre-flight checks then starts the withdraw flow which ends up with a transfer from the Router to the TP.
For now I'll just update the TODO and we can integrate this flow when we replace the mockTokenPool with the real one
| import "types" | ||
|
|
||
| struct TokenRegistry_Storage { | ||
| info: TokenRegistry_TokenInfo |
There was a problem hiding this comment.
Yes, we need to follow the EVM implementation closely.
Deployment of the TokenRegistry by the Router through
Administration of the token through the Router
Not sure I understand these requirements to deploy and admin TR through the Router, why is that needed?
|
I think we can use this as a base. Can you address some of the latest comments before merging this? |
… vv/tt/onramp-flow-skeleton
| // Option 1: split the initialization in two messages to maintain backwards compatibility. | ||
| // 1. CCIPSendExecutor_Execute stays the same as in 1.6.1 (that means not changing Config) | ||
| // 2. CCIPSendExecutor_InitTokenTransfer passes the token registry address and other necessary configs | ||
| // That way we can upgrade the SendExecutor code in the OnRamp and then upgrade the OnRamp itself, if an executor is deployed by the OnRamp between upgrades the new SendExecutor will still be able to handle the initialization message. |
There was a problem hiding this comment.
We should be able to do this atomically if we use Acton to build the payload. It allows us to hardcode a constant in the code that can be used in the migrate function to update the value
| val minValue = Router_Costs.CCIPSend(); | ||
| assert (in.valueCoins >= minValue) throw Router_Error.InsufficientFee; | ||
| // Token transfer messages can only come in through jetton transfer notifications | ||
| assert(msg.tokenAmounts.empty()) throw Router_Error.TokenTransferNotThroughNotification; //TBD shuold this be sendMessageRejected instead of throw? |
There was a problem hiding this comment.
Yep. We could wrap these in a try-catch and send the error back instad of calling sendMessageRejected on every line
No description provided.