Skip to content

bath: report both sides of a Hipo stake and unstake - #971

Open
behrang wants to merge 4 commits into
tonkeeper:masterfrom
behrang:hipo-show-both-sides
Open

behrang wants to merge 4 commits into
tonkeeper:masterfrom
behrang:hipo-show-both-sides

Conversation

@behrang

@behrang behrang commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

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_notification into the DepositStake bubble, which carries a single currency. Tonstakers escapes this only because its mint is a TEP-74 internal_transfer that JettonMintFromMasterStraw turns into its own JettonMint, and DepositLiquidStakeStraw deliberately 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:

before after
instant stake DepositStake 2 GRAM DepositStake 2 GRAM + JettonMint 1.710509667 hGRAM
instant unstake WithdrawStake 46490.056 GRAM JettonBurn 40518.686 hGRAM + WithdrawStake 46490.056 GRAM
deferred unstake hGRAM shown, no jetton flow same, and the flow is booked
  • JettonMintHipoStraw matches tokens_minted wherever it appears, so it also covers the round-end settlement of a deferred stake. It is registered in JettonTransfersBurnsMints too, since that is the list /accounts/{id}/jettons/{jetton}/history uses and hGRAM mints are missing from it today.
  • The deposit straws stop at the message the treasury proxies onwards, which is where the resolved amount lives — deposit_coins.coins is zero for "stake everything after fees".
  • WithdrawHipoStakeRequestStraw starts at proxy_reserve_tokens instead 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's unstake_all path.

Four flows that produced no staking action at all

  • Round-end payout of a deferred unstake (WithdrawHipoStakeSettledStraw). A request was followed, rounds later, by an unexplained GRAM transfer. One real round-end trace yields three of these.
  • A bill the treasury cannot fund, which it re-mints against the next round (WithdrawHipoStakePostponedStraw), so the unstake does not disappear in between.
  • Rollback (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.
  • Comment-based staking. treasury.fc routes a plain GRAM transfer carrying the comment d to 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_tokens in particular does reach the treasury from ordinary wallets on mainnet (4de4a490e0d9b523…) and is answered with a rollback. TestHipoStrawsRejectForgeries covers the three shapes a forged credit or debit would take.

Testing

go build ./..., go vet ./pkg/bath/... and go test ./pkg/bath/ (minus TestFindActions) are clean.

TestFindActions cannot run here — public liteservers no longer serve the pinned blocks, and every case fails identically with not found tx, including ones this PR does not touch. So the goldens were regenerated by rebuilding core.Trace from the raw transaction BOCs that tonapi's /v2/traces returns and replaying them through FindActions. 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-tests was 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 read secrets.LITE_SERVERS, so the variable arrives set but empty, and liteapi.FromEnvsOrMainnet reads that as a configured server list with no servers in it rather than falling back to the public ones — TestLiteStorage_TrimmedConfigBase64 then fails with server 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.yaml and entirely separable from the rest — happy to split it into its own PR if you would rather.

behrang and others added 4 commits September 17, 2026 18:39
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>
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