Skip to content

Hipo show both sides - #986

Merged
alpinskiy-top merged 9 commits into
masterfrom
hipo-show-both-sides
Oct 14, 2026
Merged

alpinskiy-top merged 9 commits into
masterfrom
hipo-show-both-sides

Conversation

@alpinskiy-top

Copy link
Copy Markdown
Collaborator

No description provided.

behrang and others added 9 commits October 13, 2026 12:58
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>
A wallet's signing preview and an account's history run FindActions with
ForAccount, which keeps an action only when its bubble lists the account. Most of
a Hipo unstake runs on Hipo's own contracts, so whenever the owner had no
transaction in the matched legs their actions vanished from their own view: an
unstake-all by the comment "w" previewed as a bare 0.05 GRAM transfer, a deferred
unstake sent without ownership_assigned showed only the burn, a rollback lost its
failed request, and a postponed bill vanished. Every Hipo straw now adds the owner
it reads, always from a message only Hipo can have sent, the same anchoring that
already guards the jetton flow.

The unstake-all burn also named the wallet as the one burning, because the wallet
burns to itself there. JettonBurnHipoUnstakeAllStraw runs before JettonBurnStraw
and names the owner from the proxy_reserve_tokens the jetton master accepted,
which it only does for the owner's own wallet.

The goldens run unfiltered, which is how this went unnoticed; the new test runs
each flow both ways.
JettonBurnHipoUnstakeAllStraw names whoever proxy_reserve_tokens names, so a
contract of an attacker's could burn to itself and name a stranger. The straw only
trusts a proxy_reserve_tokens the jetton master accepted, which parent.fc does only
for the owner's own wallet; these cases pin that down.
The flows and the rules behind them are documented at the linked integration guide,
so the comments keep only the flow diagrams and the one-line reasons a reader needs at
the call site. The list marker also goes back to every fifth entry.
Rebased onto d16025f (API-236), which gives every jetton burn an Owner and reads a
wallet's master through JettonWalletsInfo. The owner test's information source moves
to that method, and the two unstake goldens gain the Owner line, which equals Sender
because the holder sends the burn. Both goldens were regenerated from the same
mainnet traces; nothing else in them changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alpinskiy-top
alpinskiy-top marked this pull request as ready for review October 14, 2026 08:57
@alpinskiy-top
alpinskiy-top merged commit ab5abd2 into master Oct 14, 2026
1 check passed
@alpinskiy-top
alpinskiy-top deleted the hipo-show-both-sides branch October 14, 2026 09:48
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.

2 participants