Skip to content

Add withdrawable fee jettons via JettonWithdrawable - #833

Open
krebernisak wants to merge 43 commits into
mainfrom
feat/fee-withdraw
Open

Add withdrawable fee jettons via JettonWithdrawable#833
krebernisak wants to merge 43 commits into
mainfrom
feat/fee-withdraw

Conversation

@krebernisak

@krebernisak krebernisak commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds withdrawal of accrued fee jettons to the TON TokenPools via a new shared, stateless handler, JettonWithdrawable (lib/funding/jetton_withdrawable.tolk), mirroring EVM FeeTokenHandler.WithdrawFeeTokens.

This replaces the per-pool manual fee sweep with a generalized, msg-driven flow that all three pool families can share.

Highlights

Shared handler (JettonWithdrawable)

  • onWithdraw(msgValue, sender, msg, maxAmount, allowedRecipients) -> coins relays caller-supplied AskToTransfer pairs to the specified jetton wallets.
  • Enforces, in a single loop:
    • inbound-value coverage: msgValue >= running sum(transfer.value) + MIN_RESERVE,
    • per-transfer maxAmount bound,
    • optional allowedRecipients allowlist (throws ZeroAddressNotAllowed for zero/none dest regardless), and a no-customPayload guard.
  • Each relayed ask is tagged with a WithdrawContext (opcode + initiator) via customPayload, so a bounce is recognized and the initiator is notified with a WithdrawFailed reply (onWithdrawBounced).

Pool wiring

  • burn/mint & lockbox: use the base unbounded relay, and onBouncedMessage is extended to discriminate a fee-withdraw AskToTransfer bounce from the existing pool-op asks.
  • lock/release (no-lockbox demo): keeps the extra accruedFees ledger. Fees accrue on confirmed lock settle (onLockOrBurnTransferContinueWithFeeSettlement); withdrawals are bounded by the ledger and decrement it by the total actually moved (onWithdrawFeeTokensBounded, maxAmount = st.accruedFees). A bounced withdraw ask re-credits the ledger.
  • Adds JettonWithdrawable_Withdraw as a TokenPool_InMessage, dispatched on onInternalMessage to onWithdrawFeeTokensDispatch.

Tests

  • Shared TokenPool.withdrawFeeTokens.behavior.ts runs in all three pool specs (LockRelease, BurnMint, Lockbox): accrues-on-lock, withdraw-as-owner, withdraw-as-feeAdmin, reject-non-owner.
  • Bounded-ledger overdraw-guard test (lock/release only).
  • New bounce-discrimination coverage for the withdraw ask in each family.

Notes

  • Auth (owner / fee-admin) is enforced by the caller before invoking the handler; the handler itself only enforces the per-transfer allowlist/limit/value guards.
  • Follow-up (see onramp/messages.tolk TODO): migrate the OnRamp's native-TON WithdrawFeeTokens to this shared handler so it can also sweep fee jettons.

@krebernisak
krebernisak requested a review from a team as a code owner August 18, 2026 08:39

@duck-types duck-types left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Left some comments


val taggedAsk = AskToTransfer {
queryId: ask.queryId,
jettonAmount: ask.jettonAmount,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion should be done outside the loop. It will revert all enqueued AskToTransfer anyway

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, if I recal correctly, the emit also consumes value. Double check that you are not loosing balance when the received value is just enough to pay for the transfer values

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Take a look at contracts/tests/utils/sendInternalMessage.ts and how it's used to check for balance difference

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the value check could be improved.

I'll fix by using reserveToncoinsOnBalance with (1) original balance, + (2) rent due, + (3) rent reserve, or fail

val relay = createMessage({
bounce: BounceMode.RichBounce,
value: transfer.value,
dest: transfer.wallet,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beware that this field is ment for custom Jetton implementations (see jetton specification).

custom_payload - optional custom data (which is used by either sender or receiver jetton wallet for inner logic).

Although this is pretty smart, it's a hack. We should maybe document that this library does not support Jettons that make use of customPayload, and use a more descriptive error here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworked here: f5cb23c

Now using fwd payload wrap protocol (new) Jetton_ForwardPayloadWrap instead of taking over the customPayload field.

};

// Relay the transfer with the caller's value, paying forwarding fees out of it
// (SEND_MODE_REGULAR). Rich bounce returns the full original ask (with the context) on failure.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be checked at the end against totalWithdrawn

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, thanks for raising. There were gaps in logic here supporting N transfers but only a single limit with maxAmount. I've replaced it with maxAmount: map<address, coins>?, and now keep track of totalWithdrawn per wallet which should cover gaps.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed here: ef9ebb0

// TODO: generalized — this was added before TON token support and only supports withdrawing native TON.
// Migrate to the shared `JettonWithdrawable_WithdrawFeeTokens` (lib/funding/jetton_withdrawable.tolk)
// so the OnRamp can also withdraw accrued fee jettons, like the TokenPool does.
struct (0x7052dc75) OnRamp_WithdrawFeeTokens {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this can be just migrated. JettonWithdrawable_WithdrawFeeTokens doesn't support withdrawing native TON from the balance.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can reword, but the point is I think eventually we will want to handle fees in fee tokens (e.g., EVM ramp doesn't keep fees in ETH).

At that point we want to use JettonWithdrawable_WithdrawFeeTokens. We might still keep the native TON withdraw, used for rent.

Comment thread pkg/ton/tlbe/array.go
//
// Elements are decoded via the shared tlbe codec so both scalars and structs
// (including ones carrying ^ refs) are supported.
type Array[T any] []T

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

xssnick/tonutils-go v1.17 added support for array tags. I spent some time in July working on this branch bumping tontuils-go to latest, but never finished the work

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will scope out a follow up for this if we already don't have a ticket

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a ticket I've updated here: https://smartcontract-it.atlassian.net/browse/NONEVM-4680

Base automatically changed from feat/deposit-acc to main August 27, 2026 18:59
@nicolasgnr nicolasgnr added the Flag label Sep 1, 2026
duck-types
duck-types previously approved these changes Sep 2, 2026
vicentevieytes
vicentevieytes previously approved these changes Sep 3, 2026

@duck-types duck-types left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not 100% sold on the opcode manual parsing

/// Parses a `Jetton_ForwardPayloadWrap` from a `forwardPayload`, or `null`
@inline
fun Jetton_ForwardPayloadWrap.from(forwardPayload: ForwardPayloadRemainder): Jetton_ForwardPayloadWrap? {
val wrapCell = loadForwardPayloadAsCell(forwardPayload);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are potentially allocating an extra cell unecessarily.

Suggested change
val wrapCell = loadForwardPayloadAsCell(forwardPayload);
val wrapSlice = loadForwardPayloadAsSlice(forwardPayload);

Comment on lines +33 to +36
var s = wrapCell.beginParse();
if (s.loadUint(32) != Jetton_ForwardPayloadWrap_OPCODE) {
return null;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't you use builtin decoder?

Suggested change
var s = wrapCell.beginParse();
if (s.loadUint(32) != Jetton_ForwardPayloadWrap_OPCODE) {
return null;
}
val UnpackException = 0xff
try {
return Jetton_ForwardPayloadWrap.fromCell(wrapCell, UnpackOptions { throwIfOpcodeDoesNotMatch: UnpackException})
} catch (e) {
if (e is UnpackException) {
return null;
}
throw e;
}

Alternativelly, you can use reflect.serializationPrefixOf to get the opcode instead of having a constant

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can also use a lazy loading + match


struct (0x2d61600c) Jetton_ForwardPayloadWrap {
/// The address the tagged operation is reported back to on failure.
initiator: address

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this go inside the context, too? It's only used when the message bounces

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants