Conversation
The Hipo straws reported only the GRAM side of an operation. A stake showed "Deposit 2 GRAM" and nothing about the 1.71 hGRAM it minted, because the deposit straw swallowed proxy_tokens_minted -> tokens_minted -> transfer_notification into the DepositStake bubble, which carries a single currency and no jetton flow. Tonstakers avoids this only because its mint is a TEP-74 internal_transfer that JettonMintFromMasterStraw turns into its own JettonMint action; hGRAM is minted with Hipo's own op, so nothing claimed it. Unstakes lost the burned hGRAM the same way, by consuming the jetton burn they start from. Both now keep the jetton action beside the staking one: Deposit Stake 2 GRAM Burn 40518.685 hGRAM Mint 1.710 hGRAM Withdraw Stake 46490.056 GRAM JettonMintHipoStraw matches tokens_minted wherever it appears, so it also covers the round-end settlement of a deferred stake, and the deposit straws stop at the message the treasury proxies onwards - which is where the resolved amount lives, mattering when deposit_coins.coins is zero for "stake everything after fees". WithdrawHipoStakeRequestStraw starts at proxy_reserve_tokens instead of the burn, so the burn survives and the staker is read from the message rather than from the burn's sender, which is the wallet itself on the unstake_all path. Four flows that produced no staking action at all now do: - The round-end payout of a deferred unstake (WithdrawHipoStakeSettledStraw), so a request is no longer followed by an unexplained GRAM transfer rounds later. - A bill the treasury cannot fund, which it re-mints against the next round (WithdrawHipoStakePostponedStraw), so the unstake does not vanish in between. - A rollback (JettonMintHipoRollbackStraw). Both rollback sites hand the hGRAM straight back, so the burn above was reading as a loss that never happened. - Comment-based staking. treasury.fc routes a plain GRAM transfer with the comment "d" to the same handler, which is what Hipo documents for wallets that cannot attach a payload; one such deposit of 5.7M GRAM showed as a bare transfer. Every straw that moves an hGRAM balance is anchored to the treasury or the jetton master, because these ops may be sent by anyone and name the holder they credit. reserve_tokens in particular does reach the treasury from ordinary wallets on mainnet, and it is answered with a rollback. TestHipoStrawsRejectForgeries covers the three shapes a forged credit or debit would take. The goldens were regenerated by replaying the traces they pin through bath, as TestFindActions cannot run where public liteservers no longer serve those blocks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fork cannot read secrets.LITE_SERVERS, so the variable arrives set to the empty string. liteapi.FromEnvsOrMainnet reads that as a configured server list that happens to have no servers in it, rather than as "not configured", and TestLiteStorage_TrimmedConfigBase64 fails with "server list empty" on every fork pull request - including ones that change nothing it touches. Unsetting the variable when it is empty restores the public-mainnet fallback. The whole suite then passes on a fork. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each straw already requires the leg it cares about as a non-optional child, so the HasChild check in front of it could only ever agree. Hoisting the shared head into a plain slice says the same thing with less to read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rollback straw trusted proxy_rollback_unstake because it came from the treasury. That proves less than it looks. reserve_tokens authenticates nobody: anyone may send the treasury one naming any owner, and the treasury answers by returning proxy_rollback_unstake TO THE SENDER with that owner copied across, so the treasury really does send this message to addresses of a scammer's choosing carrying a victim's address. Two contracts of their own would have been enough to have hGRAM reported as arriving in a stranger's wallet, value flow included. What cannot be faked is where it lands. parent.fc relays a rollback only from the treasury and derives the wallet from the owner itself, and the treasury only sends one to the parent when the parent started the unstake - which it does only for a wallet it has verified. So a proxy_rollback_unstake transaction at the jetton master is genuine, and that is what the straw now requires. The forgery test grew the case that matters: the previous one had the message arriving from the attacker, which the sender check already caught, rather than from the treasury, which it did not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #939, which I got wrong: it reported only the GRAM side of every Hipo operation.
A stake renders as "Deposit 2 GRAM" with nothing about the 1.710509667 hGRAM it minted — no action, no jetton value flow, and nothing in the holder's jetton history. The deposit straw swallowed
proxy_tokens_minted → tokens_minted → transfer_notificationinto theDepositStakebubble, which carries a single currency. Tonstakers escapes this only because its mint is a TEP-74internal_transferthatJettonMintFromMasterStrawturns into its ownJettonMint, andDepositLiquidStakeStrawdeliberately stops at the pool transaction. hGRAM is minted with Hipo's own op, so nothing claimed it. Unstakes lost the burned hGRAM the same way, by consuming the jetton burn they start from.Live example:
37e56da8….What changes
Both sides are now visible, keeping the jetton action beside the staking one rather than changing the shared action types:
DepositStake 2 GRAMDepositStake 2 GRAM+JettonMint 1.710509667 hGRAMWithdrawStake 46490.056 GRAMJettonBurn 40518.686 hGRAM+WithdrawStake 46490.056 GRAMJettonMintHipoStrawmatchestokens_mintedwherever it appears, so it also covers the round-end settlement of a deferred stake. It is registered inJettonTransfersBurnsMintstoo, since that is the list/accounts/{id}/jettons/{jetton}/historyuses and hGRAM mints are missing from it today.deposit_coins.coinsis zero for "stake everything after fees".WithdrawHipoStakeRequestStrawstarts atproxy_reserve_tokensinstead of the burn, so the burn survives as its own action and the staker comes from the message rather than the burn's sender, which is the wallet itself on Hipo'sunstake_allpath.Four flows that produced no staking action at all
WithdrawHipoStakeSettledStraw). A request was followed, rounds later, by an unexplained GRAM transfer. One real round-end trace yields three of these.WithdrawHipoStakePostponedStraw), so the unstake does not disappear in between.JettonMintHipoRollbackStraw). Both rollback sites hand the hGRAM straight back, so the burn above was reading as a loss that never happened. 78 of 489 unstake traces I swept are rollbacks.treasury.fcroutes a plain GRAM transfer carrying the commentdto the same handler — the method Hipo documents for wallets that cannot attach a payload. A 5,717,000 GRAM multisig deposit shows as a bare transfer today.Anchoring
Every straw that moves an hGRAM balance is pinned to the treasury or the jetton master. These ops may be sent by anyone and they name the holder they credit, so an unanchored straw would let a contract have tonviewer report hGRAM arriving in, or leaving, a stranger's wallet.
reserve_tokensin particular does reach the treasury from ordinary wallets on mainnet (4de4a490e0d9b523…) and is answered with a rollback.TestHipoStrawsRejectForgeriescovers the three shapes a forged credit or debit would take.Testing
go build ./...,go vet ./pkg/bath/...andgo test ./pkg/bath/(minusTestFindActions) are clean.TestFindActionscannot run here — public liteservers no longer serve the pinned blocks, and every case fails identically withnot found tx, including ones this PR does not touch. So the goldens were regenerated by rebuildingcore.Tracefrom the raw transaction BOCs that tonapi's/v2/tracesreturns and replaying them throughFindActions. Before trusting it, I checked that harness reproduces the four committed Hipo goldens byte for byte on an unmodified master. I also replayed the new flows against real mainnet traces. Happy to contribute the harness behind a build tag if it would be useful to you — I left it out to keep this diff to Hipo.Second commit: fork CI
unit-testswas failing on this PR before the second commit, and it fails the same way on any pull request from a fork, including ones that touch nothing it covers. A fork cannot readsecrets.LITE_SERVERS, so the variable arrives set but empty, andliteapi.FromEnvsOrMainnetreads that as a configured server list with no servers in it rather than falling back to the public ones —TestLiteStorage_TrimmedConfigBase64then fails withserver list empty. Unsetting the variable when it is empty restores the fallback, and the whole suite passes on a fork. It is one line in.github/workflows/test.yamland entirely separable from the rest — happy to split it into its own PR if you would rather.