Skip to content

specs(exact): propose TON exact scheme for x402 v2 (spec-only)#1455

Open
ohld wants to merge 30 commits intox402-foundation:mainfrom
ohld:feat/scheme-exact-ton
Open

specs(exact): propose TON exact scheme for x402 v2 (spec-only)#1455
ohld wants to merge 30 commits intox402-foundation:mainfrom
ohld:feat/scheme-exact-ton

Conversation

@ohld
Copy link
Copy Markdown

@ohld ohld commented Mar 5, 2026

Description

Adds formal specification for the exact payment scheme on TON blockchain, following the same structure as existing network-specific scheme documents (EVM, SVM, Stellar, Aptos).

  • Adds specs/schemes/exact/scheme_exact_ton.md -- full spec for TON exact scheme
  • Updates specs/schemes/exact/scheme_exact.md -- adds TON validation rules to the index
  • No SDK/runtime implementation changes
  • Implementation PR follows after spec approval

Tests

No code affected.

Checklist

  • My commits are signed (required for merge)

Why TON

  • 950M+ Telegram users with built-in TON wallets
  • USDT on TON: 60B+ total transfer volume
  • Sub-second finality, <0.01 USD transaction fees
  • W5 wallet standard enables native gasless transactions -- architecturally equivalent to EIP-3009
  • Natural fit for AI agent payments in Telegram ecosystem

Working proof

Key design decisions

  1. W5+ wallet internal_signed -- TON equivalent of EIP-3009. Client signs, relay submits. Facilitator cannot modify destination or amount.
  2. Gasless relay sponsors gas -- client never pays TON for fees, only the token amount. Non-sponsored flow also documented.
  3. Relay-agnostic design -- TONAPI is a reference implementation, not a requirement. Any entity can act as relay.
  4. TEP-74 Jetton transfers only -- stablecoins first (USDT), expandable to any TEP-74 token.
  5. CAIP-2 identifiers -- uses official TVM namespace: tvm:-239 (mainnet), tvm:-3 (testnet) per https://namespaces.chainagnostic.org/tvm/caip2

v2 alignment

  • Uses v2 headers: PAYMENT-REQUIRED, PAYMENT-SIGNATURE, PAYMENT-RESPONSE
  • Uses x402Version: 2 and PaymentRequirements.amount
  • Includes explicit facilitator safety/relay sponsorship rules
  • /settle performs full verification independently (does not trust prior /verify)
  • Full JSON examples matching x402 style

Review request

Please review the scheme design, payload shape, and verification/settlement safety constraints. Once aligned, I will open a separate implementation PR in TypeScript.

@cb-heimdall
Copy link
Copy Markdown

cb-heimdall commented Mar 5, 2026

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 5, 2026

@ohld is attempting to deploy a commit to the Coinbase Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions bot added the specs Spec changes or additions label Mar 5, 2026
@ohld ohld force-pushed the feat/scheme-exact-ton branch from fbf520a to 86c1427 Compare March 5, 2026 07:40
@arjun215-eng
Copy link
Copy Markdown

arjun215-eng commented Mar 5, 2026

Really excited to see TON getting a proper x402 spec — the W5/internal_signed <=> EIP-3009 mapping is elegant. A few things I noticed that might be worth addressing before the implementation PR:

1. >= vs == and the exact scheme semantics
scheme_exact.md requires amount MUST equal requirements.amount exactly, but the TON addition uses >= in both the index and the spec body (verification rules §1 and §3). I'm guessing this is because the relay commission is bundled into the same jetton_transfer, pushing the gross transfer slightly above requirements.amount — but if so, it probably needs to be called out explicitly, and some bound on overpayment defined. Otherwise >= requirements.amount could pass for any amount, which feels at odds with the scheme name.

2. stateInit (seqno == 0) — worth adding a code hash check
Step 7 mentions including stateInit for new wallet deployments, but there's no rule telling facilitators to verify the deployed contract is actually a W5 wallet. A malicious client could potentially submit a stateInit for an arbitrary contract. Might be worth adding: facilitator MUST verify the code hash matches the canonical W5 v5r1 hash before accepting a seqno-0 payload.

3. Relay commission amount is undefined
Verification rule 4 says the W5 message "MUST NOT contain additional unrelated actions beyond the payment transfer and relay commission" — but the commission amount isn't communicated anywhere in PaymentRequirements or PaymentPayload. Without it, how does a facilitator distinguish a valid commission from an inflated one? Maybe extra.relayCommission in PaymentRequirements, or a specified max ratio?

@ohld
Copy link
Copy Markdown
Author

ohld commented Mar 6, 2026

Thanks for the thorough review, these are all great catches.

1. >=== (exact amount)

You're right — >= was wrong here. The relay commission on TON is a separate jetton_transfer action in the W5 batch, not bundled into the payment transfer amount. So the payment to the recipient is exactly requirements.amount, and the commission goes to extra.relayAddress independently. Fixed both the spec and the index to use strict equality, matching SVM/Stellar/EVM.

2. stateInit code hash check

Good call. I didn't want to hardcode a specific hash in the spec since W5 has revisions (v5r1, potentially v5r2) and a hardcoded hash would become outdated when TON Foundation ships a new version. Instead I added: facilitator MUST verify that the contract code in stateInit matches a known W5 wallet contract, and SHOULD maintain an allowlist of accepted wallet code hashes. This follows how SVM handles program IDs — the validation logic is in the facilitator implementation, not frozen in the spec.

3. Relay commission bounds

Added extra.maxRelayCommission (optional) to PaymentRequirements. When the resource server sets it, the facilitator rejects W5 batches where the commission transfer exceeds that cap. When absent, the facilitator applies its own policy. This gives resource servers explicit control without hardcoding a ratio that would break as gas prices change.

Also tightened rule §4: the W5 message must contain exactly two actions (payment transfer + optional commission transfer), anything else is rejected.

All changes in the latest commit + updated the demo repo to enforce exact amount matching (30/30 tests passing).

ohld added 2 commits March 6, 2026 15:25
Adds scheme_exact_ton.md defining the exact payment scheme for TON blockchain.
Uses W5 wallet internal_signed messages for gasless Jetton (TEP-74) transfers.
Updates scheme_exact.md with TON-specific validation rules.
… bounds, stateInit verification

- Change amount verification from >= to == (exact match), aligning with
  all other chain specs (SVM, Stellar, EVM). The relay commission is a
  separate jetton_transfer in the W5 batch, not bundled into the payment.
- Add extra.maxRelayCommission field to PaymentRequirements, allowing
  resource servers to cap the relay commission amount.
- Add stateInit verification rule: when seqno == 0, facilitator must
  verify contract code against a known W5 wallet code hash allowlist.
- Tighten action count: W5 message must contain exactly the payment
  transfer and optionally a relay commission transfer, nothing else.
@phdargen
Copy link
Copy Markdown
Contributor

Thanks a lot for putting this together @ohld!

Notified the core team to review. Please be aware that we currently have a lot of pending/incoming requests, so it might take a bit until we can get back to you, thanks for your patience 🙏

@ohld
Copy link
Copy Markdown
Author

ohld commented Mar 12, 2026

While we wait for the spec review, I created a facilitator for TON

https://github.com/ohld/x402-ton-facilitator

And our community also implemented one: https://github.com/TONresistor/x402-ton

We'll start implementing all SDKs as well.

ohld added a commit to ohld/x402 that referenced this pull request Mar 12, 2026
Add @x402/tvm (TypeScript) and x402[tvm] (Python) mechanism packages
implementing the exact payment scheme for TON blockchain.

Python (mechanisms/tvm/):
- Full gasless USDT payment flow via TONAPI relay
- Ed25519 signature verification for W5R1 wallets
- BoC parser for external messages, jetton transfers
- 6-rule payment verification (protocol, signature, intent, replay,
  relay safety, simulation)
- Idempotent settlement with state machine
- 72 unit tests

TypeScript (@x402/tvm):
- SchemeNetworkClient/Server/Facilitator implementations
- W5R1 wallet signing with @ton/ton SDK
- Gasless estimate + settlement via TONAPI
- CAIP-2 network IDs: tvm:-239 (mainnet), tvm:-3 (testnet)
- 48 unit tests

Refs: spec PR coinbase#1455, live facilitator at ton-facilitator.okhlopkov.com
@TONresistor
Copy link
Copy Markdown

We implemented this spec in production with x402-ton (https://github.com/TONresistor/x402-ton).

Two findings from our integration:

Commission discovery at boot: The spec recommends discovering commission via /v2/gasless/estimate, but this endpoint requires a real unsigned BOC. A dummy/sample BOC always returns 400. We ended up using a config fallback (MAX_RELAY_COMMISSION) when the estimate is unavailable.

Native TON support: Our implementation supports both USDT gasless (internal auth via TONAPI) and native TON direct broadcast (external auth). The PR describes the jetton path only. We treat native TON as an additive superset since payers with TON already have gas and don't need the gasless relay.

Live facilitator: https://x402.resistance.dog
npm: x402ton@2.0.0

@phdargen phdargen self-assigned this Mar 16, 2026

- `payload.seqno` MUST match the wallet's current on-chain seqno.
- Duplicate `signedBoc` submissions MUST be rejected.
- The W5 message MUST contain exactly two actions: the payment `jetton_transfer` and (optionally) a relay commission `jetton_transfer`. Any additional actions MUST cause rejection.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
- The W5 message MUST contain exactly two actions: the payment `jetton_transfer` and (optionally) a relay commission `jetton_transfer`. Any additional actions MUST cause rejection.
- The W5 message MUST contain at most two actions: the payment `jetton_transfer` and (optionally) a relay commission `jetton_transfer`. Any additional actions MUST cause rejection.

- `payTo`: Recipient TON address (raw format).
- `amount`: Atomic token amount (6 decimals for USDT, so `10000` = $0.01).
- `extra.relayAddress`: (Optional) Gasless relay address that receives the relay commission. When present, the W5 batch includes a separate `jetton_transfer` to this address as commission for gas sponsorship. When absent, the client handles gas fees directly.
- `extra.maxRelayCommission`: (Optional) Maximum relay commission in atomic token units. When present, facilitator MUST reject W5 batches where the commission transfer exceeds this amount. When absent, the facilitator applies its own commission policy.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Who effectively pays the relayFee in the end? Is the client sending amount + relayFee or is the server receiving amount - relayFee?

- `asset`: [TEP-74] Jetton master contract address (raw format `workchain:hex`).
- `payTo`: Recipient TON address (raw format).
- `amount`: Atomic token amount (6 decimals for USDT, so `10000` = $0.01).
- `extra.relayAddress`: (Optional) Gasless relay address that receives the relay commission. When present, the W5 batch includes a separate `jetton_transfer` to this address as commission for gas sponsorship. When absent, the client handles gas fees directly.
Copy link
Copy Markdown
Contributor

@phdargen phdargen Mar 16, 2026

Choose a reason for hiding this comment

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

Could you please clarify if the facilitator acts as relayer or is the relayer an independent 3rd party actor?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If its a a 3rd party, how does the server know to which relay service the facilitator will submit the tx? Ie how to get relayAddress?


The `exact` scheme on TON transfers a specific amount of a [TEP-74] Jetton from the client to the resource server using a W5 wallet `internal_signed` message.

The facilitator sponsors gas by wrapping the client-signed message in an internal TON message. The client controls payment intent (asset, recipient, amount) through Ed25519 signature. The facilitator cannot modify the destination or amount.
Copy link
Copy Markdown
Contributor

@phdargen phdargen Mar 16, 2026

Choose a reason for hiding this comment

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

From reading through the specs, it is my understanding that the client would sign two batched transfers to payTo and relayAddress. The facilitator wouldn't actually sponsor gas? The facilitator would effectively just pass through the client request to the relayer?

If this is the case, I think phrasing the relayer service as gas sponsorship is also a bit misleading. Its more like a mechanism for the client to pay for gas in a non-native token. But the client still effectively pays for the gas


1. Re-run all verification checks (do not trust prior `/verify` result).
2. Submit `signedBoc` via gasless relay or direct broadcast:
- **Sponsored (gasless):** `POST /v2/gasless/send` with `{ wallet_public_key, boc }` to a relay service. The relay wraps the signed message in an internal message carrying TON for gas.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This sounds like relayer is an independent service

ohld referenced this pull request in ohld/x402-ton-facilitator Mar 20, 2026
- Check payload.to == requirements.payTo (metadata consistency)
- Verify source Jetton wallet via get_wallet_address(from) on master
- Fix dead code: old check used transfer.jetton_wallet which was always empty
- Add negative tests for both new checks

Per @skywardboundd review on coinbase/x402#1455
Per @skywardboundd review:
1. payload.to MUST equal requirements.payTo (explicit metadata check)
2. Source Jetton wallet (W5 internal msg destination) MUST match
   get_wallet_address(from) — prevents substitute source contract
3. jetton_transfer body destination MUST equal requirements.payTo

Tested against deployed facilitator with real on-chain data.
Facilitator commit: ohld/x402-ton-facilitator@6f92320
@ohld
Copy link
Copy Markdown
Author

ohld commented Mar 20, 2026

Good catches, both added in the latest commit:

  1. payload.to MUST equal requirements.payTo — explicit check in section 4.
  2. Source Jetton wallet: the W5 internal message destination MUST match get_wallet_address(payload.from) on the Jetton master. Prevents a malicious BoC from using a substitute source contract.

Also added a third explicit rule: destination inside the jetton_transfer body MUST equal requirements.payTo.

All three checks are implemented and tested in the facilitator against mainnet via TONAPI.

- `asset`: [TEP-74] Jetton master contract address (raw format `workchain:hex`).
- `payTo`: Recipient TON address (raw format).
- `amount`: Atomic token amount (6 decimals for USDT, so `10000` = $0.01).
- `extra.facilitatorUrl`: URL of the facilitator server. The resource server calls `{facilitatorUrl}/verify` and `{facilitatorUrl}/settle`.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

facilitatorUrl is not needed, please remove

1. After verification succeeds, compute a hash of the `settlementBoc`.
2. If the hash is already present in the cache, reject the settlement with a `"duplicate_settlement"` error.
3. If the hash is not present, insert it into the cache and proceed with signing and submission.
4. Evict entries older than `maxTimeoutSeconds` from the corresponding `PaymentRequirements`. After this window, the signed message will have expired and cannot land on-chain regardless.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

maxTimeoutSeconds is a server-side setting that covers time for API execution + tx confirmation.

SVM caches around /settle for a fixed 2mins after which the blockhash will be expired.
Could we also defined a fixed time here after which the seqno should have safely advanced onchain?

- `payload.settlementBoc` MUST decode as a valid TON external message.
- The message body MUST contain a valid W5 (v5r1+) signed transfer with opcode `0x73696e74` (`internal_signed`).
- The Ed25519 signature MUST verify against `payload.walletPublicKey`. The signature is located at the TAIL of the W5 message body (after `walletId`, `validUntil`, `seqno`, and actions).
- If the external message includes `stateInit` (seqno == 0), the facilitator MUST verify the contract code matches a known W5 wallet contract. Implementations SHOULD maintain an allowlist of accepted wallet code hashes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What would be the size of the allowlist currently and how frequently do you anticipate this to change?

We should avoid scenarios where some clients can only use some facilitators based on an opaque allowlist the facilitator keeps internally. Might be worth to define a canonical set of code hashes that MUST be accepted.

For SVM, we want to move to simulation based verification instead of an explicit smart wallet whitelist, see
https://github.com/coinbase/x402/pull/1527. Could a similar approach work here?

I think for now we could live with an allowlist, but something to consider for future updates if feasible

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The allowlist currently has 1 entry: W5R1. I added the canonical code hash inline in the latest commit so implementations don't need to discover it externally.

TON wallet versions ship very rarely. v3 to v4 and v4 to v5 were years apart each, so in practice this list is near-static. When a new version does ship, it would be announced by TON Foundation and implementations would add the new hash.

Regarding simulation-based verification: yes, this works on TON. The emulation API runs the full transaction trace, so if the contract doesn't produce the expected jetton transfer, the output won't match and verification fails. I've noted this in the spec: implementations that simulate MAY skip explicit code hash checks. If SVM moves to simulation-only (PR #1527), we can follow the same direction here.

- `payload.settlementBoc` MUST decode as a valid TON external message.
- The message body MUST contain a valid W5 (v5r1+) signed transfer with opcode `0x73696e74` (`internal_signed`).
- The Ed25519 signature MUST verify against `payload.walletPublicKey`. The signature is located at the TAIL of the W5 message body (after `walletId`, `validUntil`, `seqno`, and actions).
- If the external message includes `stateInit` (seqno == 0), the facilitator MUST verify the contract code matches a known W5 wallet contract. Implementations SHOULD maintain an allowlist of accepted wallet code hashes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The wallet contract check is only done for seqno == 0. Would it be possible to deploy a malicious wallet contract that could grief the facilitator for seqno>0 by eg keeping the TON but not executing the Jetton transfer? I suppose the simulation checks would fail then, just to double-check with you that there is no attack vector

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good question. Short answer: simulation catches this, and without simulation the risk is limited to gas (~$0.04).

The key property of W5R1 is that it has no setCode opcode. Once deployed, the contract code is immutable. So a wallet that was deployed as W5R1 at seqno=0 cannot become malicious later. cc @skywardboundd

The remaining scenario is a non-W5R1 contract that was never deployed via x402 (so no stateInit check happened). If such a contract accepts the facilitator's TON but doesn't execute the jetton transfer, simulation will show that the expected transfer output is missing and verification will fail before settlement.

Without simulation, the facilitator would lose gas (~0.013 TON / $0.04 per attempt), but the payment itself won't execute, so no funds are stolen. Economically irrational for the attacker since they gain nothing.

For extra safety, implementations can also query the on-chain code hash for any wallet address (not just seqno=0). But simulation is the cleaner path forward.

… W5R1 hash

- Remove facilitatorUrl from extra (implementation detail, not protocol field)
- Fix dedup TTL: use fixed 300s instead of per-request maxTimeoutSeconds
- Add canonical W5R1 code hash for stateInit verification
- Note simulation as alternative to code hash checks for seqno>0

Per @phdargen review on coinbase/x402#1455
@ohld ohld requested a review from phdargen March 23, 2026 05:45
Comment on lines +31 to +33
4. **Client** constructs a `jetton_transfer` body ([TEP-74]) and wraps it in a W5 `internal_signed` message.
5. **Client** signs the message with their Ed25519 private key.
6. **Client** wraps the signed body in an external message BOC (with `stateInit` if `seqno == 0`) and base64-encodes it.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

It’s not entirely clear why we wrap internal_body and state_init in an external message. I think it would be better to split settlementBoc into separate fields, such as internalBoc and stateInitBoc, since that would be easier to understand. If we want to keep a single field, then it would make more sense to encode it as an internal message rather than an external one, because the internal message is what the facilitator will actually send to the network.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Agree, I think Internal Message will work in this case. In includes all the useful data required for the future transaction processing

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good point. We considered splitting into separate internalBoc + stateInitBoc fields, but went with encoding as a single internal message since it naturally carries both the body and stateInit in one structure. Updated in the latest commit: settlementBoc is now an internal message (bounceable, dest=client wallet, value=0). The facilitator extracts body + stateInit from it.

- `tokenMaster`: Jetton master contract address in raw format. Must match `requirements.asset`.
- `amount`: Payment amount in atomic token units. Must match `requirements.amount`.
- `validUntil`: Unix timestamp after which the signed message expires.
- `settlementBoc`: Base64-encoded signed W5 external message BOC containing the Jetton transfer with `internal_signed` body and Ed25519 signature.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In exact_scheme.md and in the facilitator reference implementation, support for external_signed messages is mentioned, whereas exact_scheme_ton.md only refers to internal_signed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Right, cleaned this up. The current spec now explicitly scopes to internal_signed only (gasless flow). Added a note at the top that non-gasless flows (external_signed, native TON) are planned for a follow-up extension.


- `payload.settlementBoc` MUST decode as a valid TON external message.
- The message body MUST contain a valid W5 (v5r1+) signed transfer with opcode `0x73696e74` (`internal_signed`).
- The Ed25519 signature MUST verify against `payload.walletPublicKey`. The signature is located at the TAIL of the W5 message body (after `walletId`, `validUntil`, `seqno`, and actions).
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

There should be a check that payload.walletPublicKey matches the public key actually obtained from the blockchain for the address payload.from, or from the wallet’s stateInit. With that approach, passing payload.walletPublicKey separately is generally unnecessary.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed, removed walletPublicKey from the payload entirely. The facilitator now derives the public key from the stateInit data cell (seqno == 0) or via the on-chain get_public_key getter (seqno > 0). Much cleaner.

Comment on lines +106 to +110
"from": "0:1da21a6e33ef22840029ae77900f61ba820b94e813a3b7bef4e3ea471007645f",
"to": "0:92433a576cbe56c4dcc86d94b497a2cf18a9baa9c8283fea28ea43eb3c25cfed",
"tokenMaster": "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe",
"amount": "10000",
"validUntil": 1772689900,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All of these fields can be derived from settlementBoc. If included explicitly, they must be additionally validated, which is a poor developer experience.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Except for the tokenMaster. This field maybe include for the future protocol extension with multiple accepted assets. But let's rename it to "assert" for clarity

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done, renamed to asset in the payload. Matches requirements.asset and leaves room for multi-asset extensions.

Comment on lines +31 to +33
4. **Client** constructs a `jetton_transfer` body ([TEP-74]) and wraps it in a W5 `internal_signed` message.
5. **Client** signs the message with their Ed25519 private key.
6. **Client** wraps the signed body in an external message BOC (with `stateInit` if `seqno == 0`) and base64-encodes it.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Agree, I think Internal Message will work in this case. In includes all the useful data required for the future transaction processing


**Field Definitions:**

- `asset`: [TEP-74] Jetton master contract address (raw format `workchain:hex`).
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Are there future plans for non-gasless TEP-74 transfers and native TON transfers? The spec is built only for gasless payments, with the required facilitator entity. In the future it'll be hard to extend this document with another payments. Maybe it is better to plan them ahead?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, we want to support non-gasless TEP-74 transfers and native TON transfers in follow-up specs. Added a note in the scope section. The current spec is intentionally focused on the gasless flow to keep the first version simple, but the internal message format we chose should make it straightforward to extend.

Comment on lines +162 to +164
### 3. Facilitator safety

- The facilitator's own address MUST NOT appear as the sender (`payload.from`) or as the source of any Jetton transfer. This prevents a malicious payload from tricking the facilitator into spending its own funds.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think that this makes no sense. How the payload would trick the facilitator if he will emulate before sending funds?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fair point. Updated the spec: implementations that simulate MAY skip explicit code hash checks (simulation catches any malicious contract behavior). The code hash check remains as a requirement only for implementations that don't simulate.

1. **Client** requests a protected resource from the **Resource Server**.
2. **Resource Server** responds with HTTP 402 and `PaymentRequired` data. The `accepts` array includes a TON payment option.
3. **Client** queries a TON RPC endpoint to resolve its Jetton wallet address (`get_wallet_address` on the Jetton master contract) and fetches its current wallet seqno.
4. **Client** constructs a `jetton_transfer` body ([TEP-74]) and wraps it in a W5 `internal_signed` message.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Message should be bouncable

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed, the internal message in settlementBoc MUST be bounceable now.

### 5. Replay protection

- `payload.validUntil` MUST NOT be expired and MUST NOT be more than `maxTimeoutSeconds` in the future.
- The wallet's on-chain seqno MUST be checked: the seqno in the BoC MUST NOT be less than the current on-chain seqno.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On TON seqno should be strictly equal to stored. Is there any business requirements behind this (delayed send)? Or just a missprint?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Misprint on our side, thanks. Fixed to strict equality. TON wallets reject anything that doesn't match exactly.


## Settlement Logic

1. Re-run all verification checks (do not trust prior `/verify` result).
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

By this is it meant that Resource Server should do the emulation as well? In this case why /verify is needed?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The /verify step is an x402 protocol-level requirement, not TON-specific. It lets the resource server reject invalid payloads before doing any actual work (API execution, data retrieval, etc). The facilitator then re-verifies independently during /settle.

@phdargen would be great to hear your thoughts on this too. Is there a better way to frame the verify/settle separation in the spec for chains where simulation covers everything?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Facilitators should reverify cheap checks like payload fields in settle/, the may skip checks that require rpc calls like balance checks or simulation if they want to optimize for latency, at the risk of wasting gas for edge cases where tx become invalid between verify/ and settle/.

For EVM, we have a config flag to give facilitators the choice:

export interface EIP3009FacilitatorConfig {
/**
* If enabled, the facilitator will deploy ERC-4337 smart wallets
* via EIP-6492 when encountering undeployed contract signatures.
*
* @default false
*/
deployERC4337WithEIP6492: boolean;
/**
* If enabled, simulates transaction before settling. Defaults to false, ie only simulate during verify.
*
* @default false
*/
simulateInSettle?: boolean;
}

@cb-heimdall
Copy link
Copy Markdown

Review Error for Alejandbel @ 2026-03-24 15:04:22 UTC
User failed mfa authentication, either user does not exist or public email is not set on your github profile. \ see go/mfa-help

Major rework based on TON Core team review:
- settlementBoc is now an internal message (not external)
- Payload reduced to {settlementBoc, asset}, all fields derived from BoC
- Public key derived from stateInit or on-chain get_public_key
- Seqno check changed to strict equality (TON requirement)
- Internal message must be bounceable
- Removed facilitatorUrl, nonce, walletPublicKey
- Clarified simulation in /verify, extensibility for non-gasless flows
- Updated parent scheme_exact.md TON section
@ohld ohld requested review from Alejandbel and ArkadiyStena March 25, 2026 07:30

The facilitator IS the relay. It sponsors gas (~0.013 TON per transaction) by wrapping the client-signed message in an internal TON message from its own funded wallet. The client resolves signing data (seqno, Jetton wallet address) via a TON RPC endpoint, signs locally, and sends the result. The facilitator cannot modify the destination or amount; the client controls payment intent through Ed25519 signature.

There is no relay commission. The facilitator absorbs gas costs as the cost of operating the payment network, analogous to how EVM facilitators pay gas for `transferWithAuthorization`.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Someone has to pay for this.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The facilitator operator funds their own wallet, same model as EVM where the facilitator pays gas for transferWithAuthorization.

7. **Client** sends a second request to the **Resource Server** with the `PaymentPayload`.
8. **Resource Server** forwards the payload and requirements to the **Facilitator's** `/verify` endpoint.
9. **Facilitator** deserializes the internal message BoC, derives the sender address and public key, verifies the Ed25519 signature, validates payment intent (amount, destination, asset), and checks replay protection (seqno, validUntil, BoC hash).
10. **Facilitator** returns a `VerifyResponse`. Verification is **REQUIRED** — it protects the resource server from doing unnecessary work for invalid payloads. This is an x402 protocol-level requirement, not specific to TON.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is an x402 protocol-level requirement, not specific to TON.

Do we really need this sentence?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Removed.


- `asset`: [TEP-74] Jetton master contract address (raw format `workchain:hex`).
- `payTo`: Recipient TON address (raw format).
- `amount`: Atomic token amount (6 decimals for USDT, so `10000` = $0.01).
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

There is no comments how we can get decimal parameter

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added a note that decimals can be queried via get_jetton_data on the Jetton master contract.

The facilitator derives the following from the BoC:
- **Sender address**: the `dest` field of the internal message (the client's wallet).
- **Public key**: from the `stateInit` data cell (if present) or via the on-chain `get_public_key` getter.
- **Amount, destination, validUntil, seqno**: from the W5 signed body and its actions.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

walletId too

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added.

- `payload.accepted.payTo` MUST equal `requirements.payTo`.
- `payload.accepted.amount` MUST equal `requirements.amount` exactly.

### 2. Signature validity
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

It isn't about client signature validity

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Renamed to "Message and signature verification".


- Facilitator SHOULD simulate message execution via emulation during `/verify`. This protects the resource server from doing unnecessary work for invalid payloads.
- Verification SHOULD fail if simulation indicates: insufficient Jetton balance, expired message, or invalid seqno.
- When simulation is performed, it implicitly covers seqno, balance, and code hash checks from sections 2 and 5.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

about what code hash check we are talking?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The W5 wallet code hash check in section 2 (hash 20834b7b...). When you simulate, this is covered implicitly since a non-W5 contract would not execute the expected transfer.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The W5 wallet code hash check in section 2 (hash 20834b7b...). When you simulate, this is covered implicitly since a non-W5 contract would not execute the expected transfer.

No, it does not work like this. You can make fake contract, that won't fail the emulation, but the transaction will not be properly executed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

So how would you write it?

2. Extract the signed body and optional `stateInit` from the internal message BoC.
3. Fetch the facilitator's own wallet seqno.
4. Estimate gas via emulation: build a trial relay message, emulate the trace, and sum all fees across the trace.
5. Build the relay message: wrap the user's signed body in a bounceable internal message from the facilitator's wallet to the user's wallet, attaching the estimated TON for gas. If the user's wallet has a `stateInit` (seqno == 0), include it in the relay message for deployment.
Copy link
Copy Markdown

@skywardboundd skywardboundd Mar 25, 2026

Choose a reason for hiding this comment

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

If the user's wallet has a stateInit (seqno == 0), include it in the relay message for deployment.

We should add stateinit if account is not active
https://docs.ton.org/foundations/status

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. Changed to reference account state (nonexist/uninit) instead of seqno == 0.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

What we should do on frozen accounts?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

no idea, any suggestions?

4. Estimate gas via emulation: build a trial relay message, emulate the trace, and sum all fees across the trace.
5. Build the relay message: wrap the user's signed body in a bounceable internal message from the facilitator's wallet to the user's wallet, attaching the estimated TON for gas. If the user's wallet has a `stateInit` (seqno == 0), include it in the relay message for deployment.
6. Sign and broadcast the facilitator's external message.
7. Wait for transaction confirmation (typically < 5 seconds on TON).
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why we talk about time here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Removed.


A race condition exists in the settlement flow: if the same payment BoC is submitted to the facilitator's `/settle` endpoint multiple times before the first submission is confirmed on-chain, each call may attempt broadcast. Although TON's seqno-based replay protection ensures the transfer only executes once on-chain, a malicious client can exploit the timing window to obtain access to multiple resources while only paying once.

### Recommended Mitigation
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why are we talking about this? If we want to protect ourselves from the vulnerability mentioned above, then either simulation or checking the seqno helps.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This covers a specific race condition: if the same BoC is submitted twice before the first settlement lands on-chain, both /verify calls pass because the seqno hasn't changed yet. The attacker gets 2 resource accesses for 1 payment. Seqno/simulation don't help here because on-chain state is unchanged between the two calls. The cache closes this window. It's RECOMMENDED, not MUST.

|---|---|---|---|---|
| `tvm:-239` | USDT | USD₮ | 6 | `0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe` |

### References
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added, thanks!

@cb-heimdall
Copy link
Copy Markdown

Review Error for skywardboundd @ 2026-03-25 12:25:40 UTC
User failed mfa authentication, either user does not exist or public email is not set on your github profile. \ see go/mfa-help

ohld added 3 commits March 26, 2026 11:48
…tion, simulation chain

- Replace seqno==0 with proper TON account states (nonexist/uninit) for stateInit condition
- Clarify that payload.asset is a JSON field, not a BoC-internal field (TEP-74 has no asset)
- Add full TEP-74 transfer chain verification to simulation section
- Remove non-normative timing note from settlement step 7
- Add W5 gasless transactions reference link
- Narrow wallet scope to W5 v5r1 (not v5r1+), only version that exists today
- Remove redundant "x402 protocol-level requirement" sentence
- Add get_jetton_data hint for decimal resolution
- Add walletId to derived fields list
- Rename section 2 to "Message and signature verification"
- Move balance check from Replay protection to Payment intent
- Remove redundant seqno explanation sentence

- Facilitator SHOULD simulate message execution via emulation during `/verify`. This protects the resource server from doing unnecessary work for invalid payloads.
- Verification SHOULD fail if simulation indicates: insufficient Jetton balance, expired message, or invalid seqno.
- When simulation is performed, it implicitly covers seqno, balance, and code hash checks from sections 2 and 5.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The W5 wallet code hash check in section 2 (hash 20834b7b...). When you simulate, this is covered implicitly since a non-W5 contract would not execute the expected transfer.

No, it does not work like this. You can make fake contract, that won't fail the emulation, but the transaction will not be properly executed.

@cb-heimdall
Copy link
Copy Markdown

Review Error for ArkadiyStena @ 2026-03-26 20:08:11 UTC
User failed mfa authentication, either user does not exist or public email is not set on your github profile. \ see go/mfa-help


### 3. Facilitator safety

- The facilitator's own address MUST NOT appear as the sender (derived from the BoC's `dest` field) or as the source of any Jetton transfer. This prevents a malicious payload from tricking the facilitator into spending its own funds.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This should be removed as it doesn't make any sense. User can't send any transactions that will affect facilitator's wallet.

Refine the TON `exact` scheme spec to better describe how client-signed W5 jetton transfers are verified and relayed by the facilitator.

This update expands the TON payment requirements and verification rules to account for optional jetton transfer parameters exposed through `extra`, and clarifies how those parameters are interpreted when they are omitted.

Changes included:
- add optional `extra.forwardPayload` and `extra.forwardTonAmount` to TON
  `PaymentRequirements` and define their effective defaults as zero-bit payload and `"0"`
- add optional `extra.responseDestination` and define its effective default as `addr_none`
- require facilitators to compare the effective values of `responseDestination`, `forwardPayload`, and `forwardTonAmount` between `accepted.extra` and `requirements.extra`
- extend payment intent validation so the facilitator derives and checks `response_destination`, `forward_payload`, and `forward_ton_amount` from the signed W5 action
- clarify that the facilitator-funded relay must cover both outer relay execution and the value expected by the client-signed inner transfer
- clarify that the client-signed inner message must carry enough TON to fund payer-side execution from the payer jetton wallet to the source jetton wallet
- specify that the outbound internal message from the client W5 wallet to the source jetton wallet must be bounceable
- specify that the `settlementBoc` wrapper itself does not need to be bounceable
- clean up terminology and consistency across the document: `BoC`, `zero-bit`, `forwardPayload`, `forwardTonAmount`, and derived field descriptions

This is a spec clarification/update only. It does not introduce a new TON payment scheme, but makes the existing gas-sponsored W5 relay flow more precise.
@phdargen
Copy link
Copy Markdown
Contributor

phdargen commented Apr 3, 2026

There have been significant changes in the last commit by @ArkadiyStena, could you guys please give a TLDR of what changed and why? In particular, motivating the new extra fields.

There are open questions in the thread from @skywardboundd about:

  • code hash check
  • frozen accounts

Does the last commit address those?

Any other open issues/question from the thread above @ohld?

Will make a first pass on your sdk PR next week, please tag me when it reflects the current state of the specs

@ArkadiyStena
Copy link
Copy Markdown

@phdargen Thanks. TL;DR: the last commit was mostly a spec clarification pass around TON-specific Jetton transfer semantics, not a redesign of the flow.

Main changes:

  • added extra.forwardPayload, extra.forwardTonAmount, and extra.responseDestination
  • defined their effective defaults when omitted
  • required the facilitator to derive/check those values from the signed W5 action
  • clarified relay funding responsibilities between the facilitator-funded outer relay and the client-signed inner message
  • clarified bounceability / wrapping details for settlementBoc

Why the new extra fields:

  • forwardPayload: lets the payment carry app-level metadata (e.g. invoice id), basically TON’s analogue of a memo/reference field
  • forwardTonAmount: needed for recipient-side custom contracts, since on TON they need attached TON to execute any operations on receipt
  • responseDestination: needed because exact TON fees are not known upfront, so unused TON is returned as excesses; this field specifies where those excesses go

On the open questions:

  • code hash check: the spec now requires checking wallet code against an allowlist of accepted W5 hashes.
  • frozen accounts: I’ve addressed this explicitly in a small follow-up commit; the spec now says we only support W5 wallets in active and uninit / nonexist, and frozen is out of scope / rejected

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

Labels

specs Spec changes or additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants