Skip to content

feat(migration): return the prepared plan with the insufficient-gas 409 - #940

Merged
alpinskiy-top merged 4 commits into
masterfrom
malpinskiy/migration
Aug 5, 2026
Merged

alpinskiy-top merged 4 commits into
masterfrom
malpinskiy/migration

Conversation

@alpinskiy-top

Copy link
Copy Markdown
Collaborator

PrepareMigration threw away everything it had built when the source wallet could not cover the transfer gas, so a client that only lacked TON had to re-request prepare after topping up. The transactions are buildable in that case: emulation already runs against a synthetically over-funded balance.

Both shortfall checks — the static plan minimum and the post-emulation spend — now fold into one observation instead of returning early, the loop builds and emulates every batch, and the shortfall is reported at the end on a body that also carries from/to/wallet_version/transactions. worseShortfall keeps the larger of the two requirements so the client is not told to top up twice.

The 409 body is a new MigrationPrepareConflict schema (allOf of MigrationPrepareResponse plus the error properties); the shared error response is untouched. Two status codes cannot share one schema: ogen dedups response types by name over a Go map, so the encoder would emit a single case whose status code flips between regenerations. This makes prepareMigration the first operation with two concrete responses, hence the PrepareMigrationRes sum type and the client signature change.

Three cases the deferral has to respect:

  • the pre-sweep deduction is skipped on a shortfall, because the wallet has by then spent into the over-funding and deductGramBalance would fail its balance check, turning the 409 into a 500;
  • the sweep batch's spend delta is not measured, since it spends the over-funding too and would report a wildly inflated required;
  • an uninitialized source cannot be over-funded, so it keeps the early return, and a later failure degrades to the bare shortfall rather than masking it or handing out a truncated, out-of-order plan.

Mikhail Alpinskiy and others added 4 commits August 5, 2026 07:10
PrepareMigration threw away everything it had built when the source wallet
could not cover the transfer gas, so a client that only lacked TON had to
re-request prepare after topping up. The transactions are buildable in that
case: emulation already runs against a synthetically over-funded balance.

Both shortfall checks — the static plan minimum and the post-emulation spend —
now fold into one observation instead of returning early, the loop builds and
emulates every batch, and the shortfall is reported at the end on a body that
also carries from/to/wallet_version/transactions. worseShortfall keeps the
larger of the two requirements so the client is not told to top up twice.

The 409 body is a new MigrationPrepareConflict schema (allOf of
MigrationPrepareResponse plus the error properties); the shared error response
is untouched. Two status codes cannot share one schema: ogen dedups response
types by name over a Go map, so the encoder would emit a single case whose
status code flips between regenerations. This makes prepareMigration the first
operation with two concrete responses, hence the PrepareMigrationRes sum type
and the client signature change.

Three cases the deferral has to respect:
- the pre-sweep deduction is skipped on a shortfall, because the wallet has by
  then spent into the over-funding and deductGramBalance would fail its
  balance check, turning the 409 into a 500;
- the sweep batch's spend delta is not measured, since it spends the
  over-funding too and would report a wildly inflated `required`;
- an uninitialized source cannot be over-funded, so it keeps the early return,
  and a later failure degrades to the bare shortfall rather than masking it or
  handing out a truncated, out-of-order plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each MigrationTransaction now carries gas_spent: the TON burned as network
fees by its whole emulated trace, in nanotons. Clients could previously only
reassemble this from emulation.trace by reimplementing the
total_fees + total_fwd_fees - total_action_fees arithmetic the backend already
uses in core.convertTransaction and bath.fromTrace.

The field is required and rides on MigrationPrepareResponse, so it also lands
on the 409 MigrationPrepareConflict body via its allOf inheritance. Fees are
balance-independent (gas is instruction count, forward fees are cell/bit count,
storage is cells x time), so the value is correct on the 409 path too, where
the sweep is emulated against an over-funded balance.

Adding a required response field is a breaking change for the generated client
and the downstream tonapi-go SDK (make update-sdk), as with the prior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
prepareNFTTransfers built a transfer() message for every whitelisted NFT with
no check for whether the item can actually be transferred. TEP-85 soulbound
tokens don't implement transfer() at all, so including one made that batch's
emulation fail and aborted the whole PrepareMigration call for a wallet that
may otherwise have had perfectly migratable jettons and NFTs.

core.NftItem.Transferable already exists for exactly this ("this item cannot
presently be transferred" - bath/auctions.go sets it false for an
auction-locked item) but migration never consulted it. Skip on it in both
prepareNFTTransfers and GetMigrationWallets's NFT-counting loop, so the
wallet listing's nft_count stays consistent with what prepare can actually
migrate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PrepareMigration computed one valid_until for the whole plan while the
emulation clock advanced per batch, so long plans expired partway down.

v3/v4 wallets carry 4 messages per transaction against v5's 255, so a
source with >50 assets becomes ~14 batches instead of 2. The emulator
moves its own clock forward ~32s per batch (8s inter-shard delay times
the rounds a jetton transfer takes to settle), which outruns the fixed
now+5min deadline around batch 10. Both v3 and v4 open recv_external
with throw_if(36, valid_until <= now()), which throws before
accept_message, so the external message is rejected outright and the
migration fails in the middle of an otherwise fine plan.

Anchor each batch's deadline to the clock that batch starts on. Since
the emulation clock advances by roughly the chain time each batch takes,
every transaction gets the full lifetime counted from the point its
predecessors have landed - which is what the client needs too, as it
signs and broadcasts the batches in order, waiting for each to confirm.

Also take the genuine maximum LastPaid in emulateWalletMessage: the
previous last-writer-wins comparison against startTime made the drift
depend on map iteration order, so the batch it died on varied between
identical runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alpinskiy-top
alpinskiy-top merged commit 4371364 into master Aug 5, 2026
1 check passed
@alpinskiy-top
alpinskiy-top deleted the malpinskiy/migration branch August 5, 2026 09:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant