Skip to content

bath: recognize Hipo liquid staking - #939

Open
behrang wants to merge 2 commits into
tonkeeper:masterfrom
behrang:hipo-staking-actions
Open

bath: recognize Hipo liquid staking#939
behrang wants to merge 2 commits into
tonkeeper:masterfrom
behrang:hipo-staking-actions

Conversation

@behrang

@behrang behrang commented Aug 5, 2026

Copy link
Copy Markdown

What

Adds pkg/bath straws for Hipo, the liquid staking protocol on TON (treasury EQCLyZHP4Xe8fpchQz76O-_RmUhaVc_9BAoGyJrwJrcbz2eZ, jetton hGRAM, formerly hTON). Today a Hipo unstake renders as a plain jetton Burn and a deposit as generic contract calls; with this PR the four user-facing patterns collapse into staking actions, the same way Tonstakers (liquidTF) traces do. I'm from the Hipo team — I understand straws are usually written by you, so treat this as a proposal; happy to rework it however you prefer (tracking issue with full context: #938).

New straws in pkg/bath/hipo.go, registered at the end of DefaultStraws:

Straw Trace Action
DepositHipoStakeStraw deposit_coinsproxy_tokens_mintedtokens_minted DepositStake
DepositHipoStakeDeferredStraw deposit_coinsproxy_save_coins + bill SBT mint DepositStake (settles at round end)
WithdrawHipoStakeRequestStraw hGRAM burn → reserve_tokens → bill SBT mint WithdrawStakeRequest
WithdrawHipoStakeStraw hGRAM burn → reserve_tokensproxy_tokens_burnedwithdrawal_notification WithdrawStake

Message schemas: contracts/schema.tlb; trace diagrams: graphs/.

Design notes

  • Pool-shaped actions + a hipo StakingImplementation rather than DepositTokenStake/WithdrawTokenStakeRequest: Hipo stakes the native coin with a jetton receipt, structurally like Tonstakers. The token-stake actions carry no pool account (SubjectAccounts() would never attribute the treasury), convertWithdrawTokenStakeRequest hardcodes Value: "ALL" (wrong for partial unstakes), and there is no completed WithdrawTokenStake, so an instant unstake couldn't render at all. The oas diff is generated: go generate from the unmodified spec is a byte-for-byte no-op, so the enum addition is exactly the 11 generated lines.
  • Straws anchor on the treasury (pkg/references/hipo.go), not the jetton master — the master can be replaced by a protocol upgrade (old_parents mechanism), the treasury cannot. New ops are matched by opcode constants; only ops already present in the pinned tongo release use abi.*MsgOp (abi: complete Hipo Finance message schemas tongo#499 completes the ABI separately, no dependency between the PRs).
  • NftTransferNotifyStraw runs first and merges the bill's assign_bill/ownership_assigned into a BubbleNftTransfer, so the bill matcher accepts both shapes.
  • Rollback guard: when an instant-mode unstake can't be served, the treasury sends proxy_rollback_unstake and then throw(0) — the tx reports success and the trace looks exactly like a deferred unstake. hipoUnstakeNotRolledBack rejects it so it stays a plain jetton Burn (covered by a no-network unit test in hipo_test.go).

Testing

  • Golden cases in bath_test.go for deferred stake, deferred unstake, and instant unstake, pinned to real mainnet traces; amounts verified against the on-chain values.
  • Two more cases (hipo-instant-stake, block 85114134, and hipo-rollback-unstake, block 62680498) are committed with real trace hashes but skip: true: both blocks are past public-liteserver retention, and no newer occurrence of either shape exists (instant stake only happens between validation rounds; scanned ~1000 recent treasury txs). If you can point LITE_SERVERS at an archive node, un-skipping and copying the .output.json records the goldens.
  • go build ./... clean; full pkg/bath suite shows no golden regressions (the only failures are the pre-existing environmental not found tx ones that also affect existing cases on master).

Not in scope here: round-end settlement legs (they can't misclassify — verified) and listing Hipo in /v2/staking/pools, which needs pool-info plumbing; can follow up on either if you want them.

🤖 Generated with Claude Code

@behrang

behrang commented Aug 5, 2026

Copy link
Copy Markdown
Author

Update: Hipo re-enabled instant minting today, so a fresh instant-stake trace landed within public-liteserver retention. Re-pinned the hipo-instant-stake case to it (tx 28bd278900a7b0f8febf02784b5dc4c3b4b693ad237ccdd4b212fe271b3507c8, block 88655705) and recorded the golden against public liteservers — no archive node needed for it anymore. Only hipo-rollback-unstake remains skip: true (no rollback has occurred on mainnet since March; the guard itself is covered by the no-network TestHipoUnstakeNotRolledBack). All four live hipo golden cases are green.

@behrang

behrang commented Aug 24, 2026

Copy link
Copy Markdown
Author

Thanks for approving the workflow run! The CI failure is unrelated to this PR — it's the fork-secrets limitation:

test.yaml injects LITE_SERVERS: ${{ secrets.LITE_SERVERS }}, and GitHub doesn't expose repository secrets to workflow runs for pull requests from forks. So TestLiteStorage_TrimmedConfigBase64 builds its client from an empty list and fails with server list empty — deterministically, on both attempts, and it would do the same for any external-fork PR. This PR doesn't touch pkg/litestorage; every other test in the run is green (the pkg/bath golden tests skip themselves under TEST_CI=1 as designed).

If it helps, I can add a t.Skip guard to that test when LITE_SERVERS is unset — in this PR or as a tiny separate one. And for the code this PR does change: the full pkg/bath suite including the recorded golden cases runs green locally against public liteservers (details in the comment above from Aug 5).

@hsslbchtop

Copy link
Copy Markdown
Collaborator

Thank you for the PR! I keep in mind it, but it may take few more days for me to understand all changes

behrang and others added 2 commits August 26, 2026 09:51
Hipo (https://hipo.finance) is a liquid-staking protocol for the native
coin: a staker sends GRAM to the treasury
(0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf) and
receives hGRAM jettons from the parent
(0:cf76af318c0872b58a9f1925fc29c156211782b9fb01f56760d292e56123bf87);
the pooled GRAM is lent to validators one validation round at a time.

Today its traces are shown as a chain of unclassified smart-contract
calls, and an unstake shows up as a bare jetton burn followed by
unrelated-looking bill transactions. This adds straws for the four
user-facing shapes:

  instant stake     deposit_coins -> treasury -> proxy_tokens_minted ->
                    parent -> tokens_minted -> transfer_notification
  deferred stake    deposit_coins -> treasury -> {proxy_save_coins ->
                    save_coins} + {mint_bill -> assign_bill ->
                    ownership_assigned}
  instant unstake   burn -> proxy_reserve_tokens -> reserve_tokens ->
                    proxy_tokens_burned -> tokens_burned ->
                    withdrawal_notification (carries the GRAM)
  deferred unstake  same head, then mint_bill -> assign_bill ->
                    ownership_assigned

Instant vs deferred depends on whether the treasury holds enough liquid
GRAM: instant settles in the same trace, deferred mints an SBT ("bill")
that is redeemed when the round is finalized.

Action shape: these build the pool-shaped DepositStake /
WithdrawStakeRequest / WithdrawStake and add a "hipo"
PoolImplementationType rather than reusing DepositTokenStake with a
free-form core.Protocol. The token-stake shape was introduced for
jetton-in vaults (Ethena, Affluent, FFVault) and does not fit here:

  - Hipo stakes the native coin and returns a jetton receipt, which is
    structurally the same as Tonstakers/liquidTF.
  - DepositStakeAction renders as "Deposit N GRAM to staking pool" and
    carries a nanoGram amount; DepositTokenStakeAction renders as
    "Staked with Hipo protocol" with no amount in the sentence, and
    WithdrawTokenStakeRequestAction hardcodes its preview value to "ALL",
    which is wrong for a partial unstake.
  - The token-stake actions have no Pool field, so SubjectAccounts()
    returns only the staker and the action would never be attributed to
    the treasury in account-scoped event queries.
  - There is no completed WithdrawTokenStake action, so an instant
    unstake - which pays the GRAM out in the same trace - could not be
    reported as a finished withdrawal.

The enum change is one line in api/openapi.yml plus regenerated pkg/oas
(verified reproducible: regenerating from the unmodified spec is a
no-op). Clients pinned to the old spec will reject "hipo" in
PoolImplementationType.Validate() until they regenerate, the same
one-time cost "ffvault" already imposed. Hipo pools are not served by
/v2/staking/pools yet; that would be a separate change.

A rolled-back unstake is the trap here. When the treasury cannot honour
an unstake it answers proxy_rollback_unstake and then throw(0), which
keeps the outgoing message but leaves storage untouched, so the
transaction succeeds and the trace is shaped exactly like a deferred
unstake. hipoUnstakeNotRolledBack rejects it so it stays a plain jetton
burn instead of becoming a withdraw request that never happened.

Only deposit_coins, proxy_tokens_minted and tokens_minted are declared
in the pinned tongo release, so the other ops are matched by raw opcode
with HasOpcode. Once tongo ships the full abi/schemas/hipo_finance.xml
these can use abi.HipoFinance*MsgOp names, and the exact payout can be
read from withdrawal_notification instead of being derived from the
message value minus the staker's gas prepayment (the same netting
WithdrawLiquidStake does for Tonstakers).

Tests: golden traces for deferred stake, instant unstake and deferred
unstake. The instant-stake and rollback cases carry real mainnet hashes
and block ids but are skipped: both shapes are rare - one occurrence
each in the last month of treasury history - and their blocks are past
the retention window of the public liteservers, so their golden files
have to be recorded against an archive liteserver. TestHipoUnstakeNotRolledBack
and TestHipoBillAssigned cover the two tricky predicates without network
access. No existing golden output changed.
Hipo re-enabled instant minting on 2026-08-05, so a recent instant-stake
trace is now available within public-liteserver retention. Re-pin the
previously skipped case to it and record the golden the usual way; no
archive liteserver needed anymore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@behrang
behrang force-pushed the hipo-staking-actions branch from 027a05b to cefc0c9 Compare August 26, 2026 09:56
@behrang

behrang commented Aug 26, 2026

Copy link
Copy Markdown
Author

Thanks — no rush at all.

Heads up that the branch had gone conflicting against master, so I've rebased it onto 7e279be and force-pushed. The conflicts were only in pkg/bath/bath_test.go, where the stonfi v2 swap with failed payout case landed at the same spot in the block-id map and the case table as the Hipo cases; both sides are kept. The interface refactor in 9c6429c merged cleanly (the test now passes core.LiteAPIClient(cli)).

The diff against master is unchanged at +731/-1 across 15 files, so nothing was lost or duplicated in the rebase. go build ./... and go vet ./pkg/bath/... are clean and the two offline tests (TestHipoUnstakeNotRolledBack, TestHipoBillAssigned) pass; I can't run the golden traces locally without liteserver access.

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