From 28ce2d7f9dcd528db76cd173bddbb946799de44f Mon Sep 17 00:00:00 2001 From: Behrang Norouzinia Date: Tue, 4 Aug 2026 19:03:13 +0330 Subject: [PATCH 1/2] bath: recognize Hipo liquid staking 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. --- api/openapi.json | 3 +- api/openapi.yml | 1 + pkg/bath/bath_test.go | 59 ++++ pkg/bath/hipo.go | 334 +++++++++++++++++++ pkg/bath/hipo_test.go | 66 ++++ pkg/bath/straws.go | 8 + pkg/bath/testdata/hipo-deferred-stake.json | 67 ++++ pkg/bath/testdata/hipo-deferred-unstake.json | 67 ++++ pkg/bath/testdata/hipo-instant-unstake.json | 48 +++ pkg/core/staking.go | 1 + pkg/oas/oas_json_gen.go | 2 + pkg/oas/oas_schemas_gen.go | 7 + pkg/oas/oas_validators_gen.go | 2 + pkg/references/hipo.go | 18 + 14 files changed, 682 insertions(+), 1 deletion(-) create mode 100644 pkg/bath/hipo.go create mode 100644 pkg/bath/hipo_test.go create mode 100644 pkg/bath/testdata/hipo-deferred-stake.json create mode 100644 pkg/bath/testdata/hipo-deferred-unstake.json create mode 100644 pkg/bath/testdata/hipo-instant-unstake.json create mode 100644 pkg/references/hipo.go diff --git a/api/openapi.json b/api/openapi.json index ec7573455..c9d3773d6 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -6135,7 +6135,8 @@ "whales", "tf", "liquidTF", - "ffvault" + "ffvault", + "hipo" ], "type": "string" }, diff --git a/api/openapi.yml b/api/openapi.yml index f73593ac9..746b7b545 100644 --- a/api/openapi.yml +++ b/api/openapi.yml @@ -8968,6 +8968,7 @@ components: - tf - liquidTF - ffvault + - hipo TokenRates: type: object properties: diff --git a/pkg/bath/bath_test.go b/pkg/bath/bath_test.go index e980d8d0d..4dd303230 100644 --- a/pkg/bath/bath_test.go +++ b/pkg/bath/bath_test.go @@ -460,6 +460,21 @@ func TestFindActions(t *testing.T) { "a349ed88485f06f15410ddd0add8aa384f1dafc8e131a3732149d8b6d407eca4": { tongo.MustParseBlockID("(0,8000000000000000,92385315)"), }, + "19b6fc7ca736a65411b7578e9db17f965eeaa60ccb91ce64712f6127d407ad38": { + tongo.MustParseBlockID("(0,8000000000000000,85114134)"), + }, + "d4e8cd268769f830ceb6e58a1bf7f3fb85b13757e80fd2a9a31cce94e4914936": { + tongo.MustParseBlockID("(0,8000000000000000,88409051)"), + }, + "472085c2ca9e2dc8f93dad9dfa2b656f8953d92cfd108504de48a78857562123": { + tongo.MustParseBlockID("(0,c000000000000000,88431279)"), + }, + "9236ab916e92293a822e1bb701c970de97eac3df18f1a18bfca3a4d3fd049229": { + tongo.MustParseBlockID("(0,c000000000000000,88394637)"), + }, + "ba0d8ea61d2caf1d870919362b8a9178726e8f3d71e45c0c866ae7fc450e6aff": { + tongo.MustParseBlockID("(0,8000000000000000,62680498)"), + }, } type Case struct { @@ -1242,6 +1257,50 @@ func TestFindActions(t *testing.T) { filenamePrefix: "stonfi-v2-swap-failed-payout", hash: "a349ed88485f06f15410ddd0add8aa384f1dafc8e131a3732149d8b6d407eca4", }, + { + // Hipo mints hGRAM immediately only while no validation round is running, so + // this shape is rare on mainnet: this is the only occurrence in the last + // month of treasury history. Its block is already past the retention window + // of the public liteservers, so the golden file still has to be recorded + // against an archive liteserver (LITE_SERVERS). + skip: true, + name: "hipo instant stake", + filenamePrefix: "hipo-instant-stake", + hash: "19b6fc7ca736a65411b7578e9db17f965eeaa60ccb91ce64712f6127d407ad38", + }, + { + // Stake placed during a running round: coins are saved on the wallet and an + // SBT is minted, hGRAM follows at round end. + name: "hipo deferred stake", + filenamePrefix: "hipo-deferred-stake", + hash: "d4e8cd268769f830ceb6e58a1bf7f3fb85b13757e80fd2a9a31cce94e4914936", + }, + { + // Treasury had liquid GRAM, so the hGRAM burn is paid out in the same trace. + name: "hipo instant unstake", + filenamePrefix: "hipo-instant-unstake", + hash: "472085c2ca9e2dc8f93dad9dfa2b656f8953d92cfd108504de48a78857562123", + }, + { + // Unstake during a running round: an SBT is minted for the round-end payout, + // so this stays a withdraw request. + name: "hipo deferred unstake", + filenamePrefix: "hipo-deferred-unstake", + hash: "9236ab916e92293a822e1bb701c970de97eac3df18f1a18bfca3a4d3fd049229", + }, + { + // Negative case: the treasury refused the unstake and returned the hGRAM with + // proxy_rollback_unstake. It must not be reported as a withdrawal or a + // withdraw request - the expected output is a plain jetton burn. Rollbacks + // are rare, and this block is past the retention window of the public + // liteservers, so the golden file still has to be recorded against an archive + // liteserver (LITE_SERVERS). TestHipoUnstakeNotRolledBack covers the guard + // itself without network access. + skip: true, + name: "hipo rollback unstake", + filenamePrefix: "hipo-rollback-unstake", + hash: "ba0d8ea61d2caf1d870919362b8a9178726e8f3d71e45c0c866ae7fc450e6aff", + }, } var sharedStorage *litestorage.LiteStorage diff --git a/pkg/bath/hipo.go b/pkg/bath/hipo.go new file mode 100644 index 000000000..cbb6579fc --- /dev/null +++ b/pkg/bath/hipo.go @@ -0,0 +1,334 @@ +package bath + +import ( + "math/big" + + "github.com/tonkeeper/opentonapi/pkg/core" + "github.com/tonkeeper/opentonapi/pkg/references" + "github.com/tonkeeper/tongo/abi" + "github.com/tonkeeper/tongo/ton" +) + +// Hipo (https://hipo.finance) is a liquid-staking protocol for the native coin. A staker +// sends GRAM to the treasury and receives hGRAM jettons; the pooled GRAM is lent to +// validators for one validation round at a time. Structurally it is the same shape as +// Tonstakers - native coin in, jetton receipt out - which is why its straws build the +// pool-shaped BubbleDepositStake / BubbleWithdrawStakeRequest / BubbleWithdrawStake +// bubbles rather than the jetton-vault-shaped BubbleDepositTokenStake. +// +// Each user-facing flow has an instant and a deferred variant. Which one runs depends on +// whether the treasury currently holds enough liquid GRAM: instant settles inside the same +// trace, deferred mints an SBT ("bill") that is redeemed when the round is finalized. +// +// The op-codes below are Hipo's, taken from contracts/schema.tlb in +// github.com/HipoFinance/contract. Only deposit_coins, proxy_tokens_minted and +// tokens_minted are declared in the tongo release this module is pinned to, so everything +// else is matched by raw opcode with HasOpcode. Once tongo ships the full +// abi/schemas/hipo_finance.xml these can be swapped for abi.HipoFinance*MsgOp names and +// the bodies of withdrawal_notification / mint_bill can be decoded for exact amounts. +const ( + hipoProxySaveCoinsMsgOpCode = 0x47daa10f // treasury -> parent + hipoSaveCoinsMsgOpCode = 0x4cce0e74 // parent -> hGRAM wallet + hipoMintBillMsgOpCode = 0x4b2d7871 // treasury -> round collection + hipoAssignBillMsgOpCode = 0x3275dfc2 // collection -> bill (SBT) + hipoProxyReserveTokensMsgOpCode = 0x688b0213 // hGRAM wallet -> parent + hipoReserveTokensMsgOpCode = 0x386a358b // parent -> treasury + hipoProxyTokensBurnedMsgOpCode = 0x4476fde0 // treasury -> parent + hipoTokensBurnedMsgOpCode = 0x5b512e25 // parent -> hGRAM wallet + hipoWithdrawalNotificationMsgOpCode = 0xf0fa223b // hGRAM wallet -> staker, carries the GRAM + hipoProxyRollbackUnstakeMsgOpCode = 0x32b67194 // treasury -> parent, unstake refused +) + +// hipoUnstakeNotRolledBack rejects the treasury's reserve_tokens transaction when it +// answered with proxy_rollback_unstake instead of releasing coins or minting a bill. +// +// The treasury rolls an unstake back when the request came from a wallet whose parent is +// no longer the current one, or when there is neither enough liquid GRAM for an instant +// unstake nor an open round to defer the payout to. It does so by sending +// proxy_rollback_unstake and then `throw(0)`, which keeps the outgoing message but leaves +// the treasury's storage untouched - so the transaction reports success and the hGRAM is +// credited straight back to the staker's wallet. Without this check the trace would look +// exactly like a deferred unstake and would be reported as a withdrawal request that never +// happened. +func hipoUnstakeNotRolledBack(bubble *Bubble) bool { + for _, child := range bubble.Children { + tx, ok := child.Info.(BubbleTx) + if !ok { + continue + } + if tx.opCode != nil && *tx.opCode == hipoProxyRollbackUnstakeMsgOpCode { + return false + } + } + return true +} + +// hipoBillAssigned matches the transaction of a bill - the SBT that records a deferred +// stake or unstake until the round is finalized - in whichever shape it has by the time +// the Hipo straws run. +// +// A bill is deployed by assign_bill and immediately notifies its owner with the standard +// TEP-62 ownership_assigned, so NftTransferNotifyStraw, which runs much earlier, usually +// merges it into a BubbleNftTransfer and the assign_bill opcode is no longer visible. It +// stays a plain transaction when that straw cannot claim it: when the indexer does not +// recognize the bill as an NFT item, or when the staker asked for no notification by +// setting ownership_assigned_amount to zero. +func hipoBillAssigned(bubble *Bubble) bool { + if _, ok := bubble.Info.(BubbleNftTransfer); ok { + return true + } + tx, ok := bubble.Info.(BubbleTx) + return ok && tx.opCode != nil && *tx.opCode == hipoAssignBillMsgOpCode +} + +// hipoBillChildren are the leftovers hanging off a bill transaction: the owner +// notification and the excess refund when the bill is still a plain transaction, and the +// deploy bubble of the bill itself, which fromTrace appends for every freshly deployed +// account. All are optional because which of them exist depends on the shape above. +func hipoBillChildren[T actioner]() []Straw[T] { + return []Straw[T]{ + { + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.NftOwnershipAssignedMsgOp)}, + Optional: true, + }, + { + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.ExcessMsgOp)}, + Optional: true, + }, + { + CheckFuncs: []bubbleCheck{Is(BubbleContractDeploy{})}, + Optional: true, + }, + } +} + +// hipoDepositBuilder fills in everything that can be read from the deposit_coins message +// itself. Both deposit straws share it; the instant one then overwrites Amount with the +// exact figure from tokens_minted. +func hipoDepositBuilder(newAction *BubbleDepositStake, bubble *Bubble) error { + tx := bubble.Info.(BubbleTx) + newAction.Pool = tx.account.Address + newAction.Success = tx.success + newAction.Implementation = core.StakingImplementationHipo + if tx.inputFrom != nil { + newAction.Staker = tx.inputFrom.Address + } + body, ok := tx.decodedBody.Value.(abi.HipoFinanceDepositCoinsMsgBody) + if !ok { + newAction.Amount = core.PriceNanoGram(tx.inputAmount) + return nil + } + // owner is addr_none for ordinary wallets, in which case the treasury credits the + // sender. Protocols depositing on behalf of a user set it explicitly. + if owner, err := ton.AccountIDFromTlb(body.Owner); err == nil && owner != nil { + newAction.Staker = *owner + } + // coins is what the staker asked to stake. It may be zero, meaning "everything that + // is left after the gas prepayment", so fall back to the attached value; that + // overstates the stake by the unused part of the ~0.009 GRAM prepayment, which the + // treasury refunds separately. + amount := big.Int(body.Coins) + if amount.Sign() == 0 { + newAction.Amount = core.PriceNanoGram(tx.inputAmount) + return nil + } + newAction.Amount = core.PriceNanoGram(amount.Int64()) + return nil +} + +// DepositHipoStakeStraw recognizes an instant stake, which is what happens when the +// treasury is between rounds and can mint hGRAM right away: +// +// deposit_coins -> treasury -> proxy_tokens_minted -> parent -> tokens_minted -> +// hGRAM wallet -> transfer_notification -> staker +// +// It must be registered before DepositHipoStakeDeferredStraw: the deferred straw would not +// match this shape, but keeping the specific case first documents the intent. +var DepositHipoStakeStraw = Straw[BubbleDepositStake]{ + CheckFuncs: []bubbleCheck{IsTx, IsAccount(references.HipoTreasury), HasOperation(abi.HipoFinanceDepositCoinsMsgOp)}, + Builder: hipoDepositBuilder, + SingleChild: &Straw[BubbleDepositStake]{ + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.HipoFinanceProxyTokensMintedMsgOp)}, + SingleChild: &Straw[BubbleDepositStake]{ + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.HipoFinanceTokensMintedMsgOp)}, + Builder: func(newAction *BubbleDepositStake, bubble *Bubble) error { + tx := bubble.Info.(BubbleTx) + newAction.Success = tx.success + body, ok := tx.decodedBody.Value.(abi.HipoFinanceTokensMintedMsgBody) + if !ok { + return nil + } + // tokens_minted carries the GRAM the treasury actually accepted, so it + // is preferred over the deposit_coins estimate. + coins := big.Int(body.Coins) + newAction.Amount = core.PriceNanoGram(coins.Int64()) + if owner, err := ton.AccountIDFromTlb(body.Owner); err == nil && owner != nil { + newAction.Staker = *owner + } + return nil + }, + Children: []Straw[BubbleDepositStake]{ + { + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.JettonNotifyMsgOp)}, + Optional: true, + }, + { + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.ExcessMsgOp)}, + Optional: true, + }, + { + // The staker's hGRAM wallet on their very first stake. + CheckFuncs: []bubbleCheck{Is(BubbleContractDeploy{})}, + Optional: true, + }, + }, + }, + }, +} + +// DepositHipoStakeDeferredStraw recognizes a stake made while a round is running. The +// treasury cannot mint hGRAM yet because the exchange rate is not final, so it records the +// coins on the staker's wallet and mints an SBT that is redeemed for hGRAM when the round +// is finalized: +// +// deposit_coins -> treasury -> proxy_save_coins -> parent -> save_coins -> hGRAM wallet +// -> mint_bill -> collection -> assign_bill -> bill -> +// ownership_assigned -> staker +// +// This still reports a DepositStake: the GRAM has left the staker and the stake is +// irrevocable, only the hGRAM arrives later (in the round-finalization trace, which is +// shared by every staker of that round and is not classified here). +var DepositHipoStakeDeferredStraw = Straw[BubbleDepositStake]{ + CheckFuncs: []bubbleCheck{IsTx, IsAccount(references.HipoTreasury), HasOperation(abi.HipoFinanceDepositCoinsMsgOp)}, + Builder: hipoDepositBuilder, + Children: []Straw[BubbleDepositStake]{ + { + CheckFuncs: []bubbleCheck{IsTx, HasOpcode(hipoProxySaveCoinsMsgOpCode)}, + SingleChild: &Straw[BubbleDepositStake]{ + CheckFuncs: []bubbleCheck{IsTx, HasOpcode(hipoSaveCoinsMsgOpCode)}, + Builder: func(newAction *BubbleDepositStake, bubble *Bubble) error { + newAction.Success = bubble.Info.(BubbleTx).success + return nil + }, + SingleChild: &Straw[BubbleDepositStake]{ + // The staker's hGRAM wallet on their very first stake. + CheckFuncs: []bubbleCheck{Is(BubbleContractDeploy{})}, + Optional: true, + }, + }, + }, + { + CheckFuncs: []bubbleCheck{IsTx, HasOpcode(hipoMintBillMsgOpCode)}, + SingleChild: &Straw[BubbleDepositStake]{ + CheckFuncs: []bubbleCheck{hipoBillAssigned}, + Children: hipoBillChildren[BubbleDepositStake](), + }, + }, + }, +} + +// WithdrawHipoStakeRequestStraw recognizes the head that both unstake variants share. An +// unstake starts as an ordinary TEP-74 burn of hGRAM, so by the time this straw runs +// JettonBurnStraw has already merged it into a BubbleJettonBurn - the same entry point +// Tonstakers uses in PendingWithdrawRequestLiquidStraw. This straw must therefore be +// registered after JettonBurnStraw, and it claims the trace so it is reported as an +// unstake instead of a bare jetton burn. +// +// burn -> hGRAM wallet -> proxy_reserve_tokens -> parent -> reserve_tokens -> treasury +// +// From the treasury onwards the two variants diverge. The deferred one mints a bill for +// the round-end payout and is matched here (as an optional child), leaving the action as +// WithdrawStakeRequest. The instant one continues with proxy_tokens_burned, which is left +// unmerged for WithdrawHipoStakeStraw to pick up and upgrade to a completed WithdrawStake. +var WithdrawHipoStakeRequestStraw = Straw[BubbleWithdrawStakeRequest]{ + CheckFuncs: []bubbleCheck{Is(BubbleJettonBurn{})}, + Builder: func(newAction *BubbleWithdrawStakeRequest, bubble *Bubble) error { + burn := bubble.Info.(BubbleJettonBurn) + newAction.Staker = burn.sender.Address + newAction.Implementation = core.StakingImplementationHipo + master := burn.master + newAction.Amount = &core.Price{ + Currency: core.Currency{ + Type: core.CurrencyJetton, + Jetton: &master, + }, + Amount: big.Int(burn.amount), + } + return nil + }, + SingleChild: &Straw[BubbleWithdrawStakeRequest]{ + CheckFuncs: []bubbleCheck{IsTx, HasOpcode(hipoProxyReserveTokensMsgOpCode)}, + Builder: func(newAction *BubbleWithdrawStakeRequest, bubble *Bubble) error { + // The gas the staker prepaid with the burn. Hipo returns whatever is left of + // it together with the GRAM payout, so WithdrawHipoStakeStraw nets it out. + newAction.attachedAmount = bubble.Info.(BubbleTx).inputAmount + return nil + }, + SingleChild: &Straw[BubbleWithdrawStakeRequest]{ + CheckFuncs: []bubbleCheck{ + IsTx, + IsAccount(references.HipoTreasury), + HasOpcode(hipoReserveTokensMsgOpCode), + hipoUnstakeNotRolledBack, + }, + Builder: func(newAction *BubbleWithdrawStakeRequest, bubble *Bubble) error { + tx := bubble.Info.(BubbleTx) + newAction.Pool = tx.account.Address + newAction.Success = tx.success + return nil + }, + Children: []Straw[BubbleWithdrawStakeRequest]{ + { + // Deferred unstake only; absent in the instant flow. + CheckFuncs: []bubbleCheck{IsTx, HasOpcode(hipoMintBillMsgOpCode)}, + Optional: true, + SingleChild: &Straw[BubbleWithdrawStakeRequest]{ + CheckFuncs: []bubbleCheck{hipoBillAssigned}, + Children: hipoBillChildren[BubbleWithdrawStakeRequest](), + }, + }, + }, + }, + }, +} + +// WithdrawHipoStakeStraw upgrades an instant unstake from a request to a completed +// withdrawal by consuming the payout leg that WithdrawHipoStakeRequestStraw left behind: +// +// treasury -> proxy_tokens_burned -> parent -> tokens_burned -> hGRAM wallet -> +// withdrawal_notification -> staker, carrying the GRAM +// +// It must be registered after WithdrawHipoStakeRequestStraw. A deferred unstake has no +// such leg and stays a WithdrawStakeRequest. +// +// The amount is the value of withdrawal_notification minus the gas the staker prepaid, +// mirroring how WithdrawLiquidStake nets out attachedAmount: the message carries the +// withdrawn coins plus the unused part of the prepayment. withdrawal_notification also +// states the exact figure in its body, which can be read once tongo decodes op 0xf0fa223b. +var WithdrawHipoStakeStraw = Straw[BubbleWithdrawStake]{ + CheckFuncs: []bubbleCheck{Is(BubbleWithdrawStakeRequest{}), func(bubble *Bubble) bool { + request, ok := bubble.Info.(BubbleWithdrawStakeRequest) + return ok && request.Implementation == core.StakingImplementationHipo + }}, + Builder: func(newAction *BubbleWithdrawStake, bubble *Bubble) error { + request := bubble.Info.(BubbleWithdrawStakeRequest) + newAction.Pool = request.Pool + newAction.Staker = request.Staker + newAction.Implementation = request.Implementation + newAction.Amount -= request.attachedAmount + return nil + }, + SingleChild: &Straw[BubbleWithdrawStake]{ + CheckFuncs: []bubbleCheck{IsTx, HasOpcode(hipoProxyTokensBurnedMsgOpCode)}, + SingleChild: &Straw[BubbleWithdrawStake]{ + CheckFuncs: []bubbleCheck{IsTx, HasOpcode(hipoTokensBurnedMsgOpCode)}, + SingleChild: &Straw[BubbleWithdrawStake]{ + CheckFuncs: []bubbleCheck{IsTx, HasOpcode(hipoWithdrawalNotificationMsgOpCode)}, + Builder: func(newAction *BubbleWithdrawStake, bubble *Bubble) error { + newAction.Amount += bubble.Info.(BubbleTx).inputAmount + return nil + }, + }, + }, + }, +} diff --git a/pkg/bath/hipo_test.go b/pkg/bath/hipo_test.go new file mode 100644 index 000000000..a00251ef1 --- /dev/null +++ b/pkg/bath/hipo_test.go @@ -0,0 +1,66 @@ +package bath + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func hipoOpBubble(opCode uint32) *Bubble { + op := opCode + return &Bubble{Info: BubbleTx{opCode: &op}} +} + +// TestHipoUnstakeNotRolledBack guards the negative case of the unstake straws without +// needing a trace: a rolled-back unstake looks exactly like a deferred one except for the +// proxy_rollback_unstake answer, and must not be reported as a withdraw request. +func TestHipoUnstakeNotRolledBack(t *testing.T) { + tests := []struct { + name string + children []*Bubble + want bool + }{ + { + name: "instant unstake pays out", + children: []*Bubble{hipoOpBubble(hipoProxyTokensBurnedMsgOpCode)}, + want: true, + }, + { + name: "deferred unstake mints a bill", + children: []*Bubble{hipoOpBubble(hipoMintBillMsgOpCode)}, + want: true, + }, + { + name: "rolled back unstake", + children: []*Bubble{hipoOpBubble(hipoProxyRollbackUnstakeMsgOpCode)}, + want: false, + }, + { + name: "no answer at all", + children: nil, + want: true, + }, + { + name: "non-transaction children are ignored", + children: []*Bubble{{Info: BubbleContractDeploy{}}}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bubble := &Bubble{Info: BubbleTx{}, Children: tt.children} + require.Equal(t, tt.want, hipoUnstakeNotRolledBack(bubble)) + }) + } +} + +// TestHipoBillAssigned covers both shapes a bill transaction can have by the time the Hipo +// straws run: already merged into an NFT transfer by NftTransferNotifyStraw, or still a +// plain assign_bill transaction. +func TestHipoBillAssigned(t *testing.T) { + require.True(t, hipoBillAssigned(&Bubble{Info: BubbleNftTransfer{}})) + require.True(t, hipoBillAssigned(hipoOpBubble(hipoAssignBillMsgOpCode))) + require.False(t, hipoBillAssigned(hipoOpBubble(hipoMintBillMsgOpCode))) + require.False(t, hipoBillAssigned(&Bubble{Info: BubbleTx{}})) + require.False(t, hipoBillAssigned(&Bubble{Info: BubbleJettonBurn{}})) +} diff --git a/pkg/bath/straws.go b/pkg/bath/straws.go index 9898b1c06..17dd0cc66 100644 --- a/pkg/bath/straws.go +++ b/pkg/bath/straws.go @@ -121,6 +121,14 @@ func DefaultStraws(book AddressBook, infoSource core.InformationSource) []Merger WithdrawalRequestFFVaultStraw, XTRDepositAction, XTRBuyAction, + // 70 + // Hipo. The unstake straws need JettonBurnStraw (13) to have merged the hGRAM + // burn first, and WithdrawHipoStakeStraw upgrades what + // WithdrawHipoStakeRequestStraw produced, so this relative order is required. + DepositHipoStakeStraw, + DepositHipoStakeDeferredStraw, + WithdrawHipoStakeRequestStraw, + WithdrawHipoStakeStraw, } } diff --git a/pkg/bath/testdata/hipo-deferred-stake.json b/pkg/bath/testdata/hipo-deferred-stake.json new file mode 100644 index 000000000..dc30c040d --- /dev/null +++ b/pkg/bath/testdata/hipo-deferred-stake.json @@ -0,0 +1,67 @@ +{ + "Actions": [ + { + "DepositStake": { + "Staker": "0:3bd5479721beaf48dc0c7f777690c8d7275e173f6f61e01b5ed027b7a3c09000", + "Amount": { + "Currency": { + "Type": "native", + "Jetton": null, + "CurrencyID": null + }, + "Amount": 5000039599 + }, + "Pool": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Implementation": "hipo" + }, + "Success": true, + "Type": "DepositStake", + "BaseTransactions": [ + "4085595d995bb2083b2ebb2be3ebbaa2e3790cc2f5f219392ae0021fd8d47626", + "a3115a98940947329c3e8b924c148efd19a0989f33398781a5f4f491542b4127", + "30c721019e303562db0bed0ee66ed773cad6b4a5539bb3fb13758fa5cf550b70", + "9e056045d4c09eb3d6ea8c8ef4f7b101f79cab6ec4d0a673575d9f327f7be0ba", + "7277bfc1bf83fdb5482567198f37804fc7dd88951115d331f04d35afaff28bb2", + "c138e8e8ea5cd1e329b7e5c2921eca174f8039ca9b465d038b4e6b7ecc100b47" + ] + } + ], + "Accounts": [ + { + "Account": "0:3bd5479721beaf48dc0c7f777690c8d7275e173f6f61e01b5ed027b7a3c09000", + "Ton": -5100683410, + "Fee": 643812, + "Jettons": null + }, + { + "Account": "0:4fc5a9cb92adce9aad7fbb3ebc8599f54107c9511202c553534bca97089a4190", + "Ton": 0, + "Fee": 684677, + "Jettons": null + }, + { + "Account": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Ton": 5000039262, + "Fee": 1502140, + "Jettons": null + }, + { + "Account": "0:b6d4d107cf7ab228536c0d305a8530096ce869e55e7aa42ffbfbebc226c84a6a", + "Ton": 95593094, + "Fee": 456668, + "Jettons": null + }, + { + "Account": "0:cf76af318c0872b58a9f1925fc29c156211782b9fb01f56760d292e56123bf87", + "Ton": -84, + "Fee": 588486, + "Jettons": null + }, + { + "Account": "0:e860fc2bd284f930d86a778e77d02e3b85f3b891976bb0a6fb8050e9e9f21463", + "Ton": 890621, + "Fee": 284734, + "Jettons": null + } + ] + } \ No newline at end of file diff --git a/pkg/bath/testdata/hipo-deferred-unstake.json b/pkg/bath/testdata/hipo-deferred-unstake.json new file mode 100644 index 000000000..ccdf23283 --- /dev/null +++ b/pkg/bath/testdata/hipo-deferred-unstake.json @@ -0,0 +1,67 @@ +{ + "Actions": [ + { + "WithdrawStakeRequest": { + "Staker": "0:f1f1ad0f4fff9cf79d8af45381bcf98ae6afe1175ede0aa714495da92ca3f078", + "Amount": { + "Currency": { + "Type": "jetton", + "Jetton": "0:cf76af318c0872b58a9f1925fc29c156211782b9fb01f56760d292e56123bf87", + "CurrencyID": null + }, + "Amount": 3200000000 + }, + "Pool": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Implementation": "hipo" + }, + "Success": true, + "Type": "WithdrawStakeRequest", + "BaseTransactions": [ + "36bb127bc36cc80f49830a6a1b9d59d74abfb21fe5a5b70cda4aa1246d383fa3", + "d86e7f7d770a8825bd62d1790afd3e29fa37164217f5797a6bdc04ddeead44f5", + "3f425784700d216bb3e989406252a8c88bcd935cf5d748f7b24c861b181ac599", + "5b2def7909e4cacfe343ea0afc51bf44f1adf5a41644aca56858adccb67de07e", + "1badc50e386ab9de9571ea0c8657fdedc3254800c40dad49673b5575f20364f7", + "bc3fb478135067f67681f76e5f3a142805c139d8ae84edbabeafb3f29efb8c5f" + ] + } + ], + "Accounts": [ + { + "Account": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Ton": -7140, + "Fee": 1059275, + "Jettons": null + }, + { + "Account": "0:bbfb86b18b81883668e7f44bcf0c5a8c63db4ce85c144961991bd4206f630641", + "Ton": 0, + "Fee": 684537, + "Jettons": null + }, + { + "Account": "0:c831f186cb35f27772bb3fc012eb619c0f42d1da2a64a052daf2e51250bbf534", + "Ton": 96576121, + "Fee": 456668, + "Jettons": null + }, + { + "Account": "0:c922154be9e3213f0368e4b1b3e6792c23e5e02fc8b28b32ad6c16a0f447b5a6", + "Ton": -505, + "Fee": 694174, + "Jettons": null + }, + { + "Account": "0:cf76af318c0872b58a9f1925fc29c156211782b9fb01f56760d292e56123bf87", + "Ton": -1851, + "Fee": 538720, + "Jettons": null + }, + { + "Account": "0:f1f1ad0f4fff9cf79d8af45381bcf98ae6afe1175ede0aa714495da92ca3f078", + "Ton": -100609275, + "Fee": 609276, + "Jettons": null + } + ] + } \ No newline at end of file diff --git a/pkg/bath/testdata/hipo-instant-unstake.json b/pkg/bath/testdata/hipo-instant-unstake.json new file mode 100644 index 000000000..7428541db --- /dev/null +++ b/pkg/bath/testdata/hipo-instant-unstake.json @@ -0,0 +1,48 @@ +{ + "Actions": [ + { + "WithdrawStake": { + "Staker": "0:b4ceba28b455def1f3e6e283602d3c28a92800f0eb49dd226edba80aa23664f6", + "Amount": 46490056492704, + "Pool": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Implementation": "hipo" + }, + "Success": true, + "Type": "WithdrawStake", + "BaseTransactions": [ + "8c8f6342fece363765fece2d8ddb9a26301f39b44122ad98ca5c643f50213fa9", + "a2ef0efa961fd6429d352ebd59245e79721b425b638a4fc9e6abeaf6223c7540", + "1041c247b5123e7e08f4ae062856f4a7aff5fb24559f7e147b086a1281500f77", + "358e097fe85526856be12fd94e9516cc9d552f402cfdcbba9ad13dda7c2a7f02", + "5a84f78c791569bcbb85aa965aa3073e6a0d9ecc628782cf817c4f41f591d88a", + "cfe1ffce281a2be08283cb7a5c78d62424640af4cbcf8d9d1c38e60e7e507395" + ] + } + ], + "Accounts": [ + { + "Account": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Ton": -46490058860692, + "Fee": 730881, + "Jettons": null + }, + { + "Account": "0:b4ceba28b455def1f3e6e283602d3c28a92800f0eb49dd226edba80aa23664f6", + "Ton": 46490055200972, + "Fee": 596996, + "Jettons": null + }, + { + "Account": "0:cb3e16dd41033bc880efb44a96add2914b1c61bed90133281fc6046b0f69f3ff", + "Ton": 0, + "Fee": 1240106, + "Jettons": null + }, + { + "Account": "0:cf76af318c0872b58a9f1925fc29c156211782b9fb01f56760d292e56123bf87", + "Ton": -168, + "Fee": 1091905, + "Jettons": null + } + ] + } \ No newline at end of file diff --git a/pkg/core/staking.go b/pkg/core/staking.go index 927224421..cefae09b2 100644 --- a/pkg/core/staking.go +++ b/pkg/core/staking.go @@ -51,6 +51,7 @@ const ( StakingImplementationLiquidTF StakingImplementation = "liquidTF" StakingImplementationWhales StakingImplementation = "whales" StakingImplementationFfVault StakingImplementation = "ffvault" + StakingImplementationHipo StakingImplementation = "hipo" ) // CalculateAPY computes the annualised net APY of a liquid-staking pool from diff --git a/pkg/oas/oas_json_gen.go b/pkg/oas/oas_json_gen.go index 7f3cd9a75..3ae32c569 100644 --- a/pkg/oas/oas_json_gen.go +++ b/pkg/oas/oas_json_gen.go @@ -40465,6 +40465,8 @@ func (s *PoolImplementationType) Decode(d *jx.Decoder) error { *s = PoolImplementationTypeLiquidTF case PoolImplementationTypeFfvault: *s = PoolImplementationTypeFfvault + case PoolImplementationTypeHipo: + *s = PoolImplementationTypeHipo default: *s = PoolImplementationType(v) } diff --git a/pkg/oas/oas_schemas_gen.go b/pkg/oas/oas_schemas_gen.go index c3f27b3cb..9eb4070ce 100644 --- a/pkg/oas/oas_schemas_gen.go +++ b/pkg/oas/oas_schemas_gen.go @@ -18904,6 +18904,7 @@ const ( PoolImplementationTypeTf PoolImplementationType = "tf" PoolImplementationTypeLiquidTF PoolImplementationType = "liquidTF" PoolImplementationTypeFfvault PoolImplementationType = "ffvault" + PoolImplementationTypeHipo PoolImplementationType = "hipo" ) // AllValues returns all PoolImplementationType values. @@ -18913,6 +18914,7 @@ func (PoolImplementationType) AllValues() []PoolImplementationType { PoolImplementationTypeTf, PoolImplementationTypeLiquidTF, PoolImplementationTypeFfvault, + PoolImplementationTypeHipo, } } @@ -18927,6 +18929,8 @@ func (s PoolImplementationType) MarshalText() ([]byte, error) { return []byte(s), nil case PoolImplementationTypeFfvault: return []byte(s), nil + case PoolImplementationTypeHipo: + return []byte(s), nil default: return nil, errors.Errorf("invalid value: %q", s) } @@ -18947,6 +18951,9 @@ func (s *PoolImplementationType) UnmarshalText(data []byte) error { case PoolImplementationTypeFfvault: *s = PoolImplementationTypeFfvault return nil + case PoolImplementationTypeHipo: + *s = PoolImplementationTypeHipo + return nil default: return errors.Errorf("invalid value: %q", data) } diff --git a/pkg/oas/oas_validators_gen.go b/pkg/oas/oas_validators_gen.go index f55f12602..c28d852d7 100644 --- a/pkg/oas/oas_validators_gen.go +++ b/pkg/oas/oas_validators_gen.go @@ -5555,6 +5555,8 @@ func (s PoolImplementationType) Validate() error { return nil case "ffvault": return nil + case "hipo": + return nil default: return errors.Errorf("invalid value: %v", s) } diff --git a/pkg/references/hipo.go b/pkg/references/hipo.go new file mode 100644 index 000000000..885c6b7cb --- /dev/null +++ b/pkg/references/hipo.go @@ -0,0 +1,18 @@ +package references + +import "github.com/tonkeeper/tongo/ton" + +const HipoImplementationsName = "Hipo" +const HipoImplementationsURL = "https://hipo.finance/" + +var ( + // HipoTreasury is Hipo's liquid-staking pool. It holds the staked GRAM, lends it to + // validators once per validation round and owns the hGRAM exchange rate. Its address + // is the stable anchor of every Hipo trace: the code is upgradable in place, so the + // treasury address never changes, while HipoParent can be replaced by an upgrade + // (superseded parents are remembered by the treasury in its `old_parents` set). + HipoTreasury = ton.MustParseAccountID("0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf") + // HipoParent is the jetton master of hGRAM (formerly hTON). It proxies every message + // between the treasury and a staker's hGRAM wallet. + HipoParent = ton.MustParseAccountID("0:cf76af318c0872b58a9f1925fc29c156211782b9fb01f56760d292e56123bf87") +) From cefc0c92a1fb38fc16e7153fc285345dce4aad83 Mon Sep 17 00:00:00 2001 From: Behrang Norouzinia Date: Wed, 5 Aug 2026 20:13:28 +0330 Subject: [PATCH 2/2] bath: record the hipo instant-stake golden from a fresh trace 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 --- pkg/bath/bath_test.go | 14 +++--- pkg/bath/testdata/hipo-instant-stake.json | 53 +++++++++++++++++++++++ 2 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 pkg/bath/testdata/hipo-instant-stake.json diff --git a/pkg/bath/bath_test.go b/pkg/bath/bath_test.go index 4dd303230..4f357cfce 100644 --- a/pkg/bath/bath_test.go +++ b/pkg/bath/bath_test.go @@ -460,8 +460,8 @@ func TestFindActions(t *testing.T) { "a349ed88485f06f15410ddd0add8aa384f1dafc8e131a3732149d8b6d407eca4": { tongo.MustParseBlockID("(0,8000000000000000,92385315)"), }, - "19b6fc7ca736a65411b7578e9db17f965eeaa60ccb91ce64712f6127d407ad38": { - tongo.MustParseBlockID("(0,8000000000000000,85114134)"), + "28bd278900a7b0f8febf02784b5dc4c3b4b693ad237ccdd4b212fe271b3507c8": { + tongo.MustParseBlockID("(0,8000000000000000,88655705)"), }, "d4e8cd268769f830ceb6e58a1bf7f3fb85b13757e80fd2a9a31cce94e4914936": { tongo.MustParseBlockID("(0,8000000000000000,88409051)"), @@ -1258,15 +1258,11 @@ func TestFindActions(t *testing.T) { hash: "a349ed88485f06f15410ddd0add8aa384f1dafc8e131a3732149d8b6d407eca4", }, { - // Hipo mints hGRAM immediately only while no validation round is running, so - // this shape is rare on mainnet: this is the only occurrence in the last - // month of treasury history. Its block is already past the retention window - // of the public liteservers, so the golden file still has to be recorded - // against an archive liteserver (LITE_SERVERS). - skip: true, + // Instant minting is enabled: the treasury mints hGRAM in the deposit trace + // itself instead of deferring it to the round end. name: "hipo instant stake", filenamePrefix: "hipo-instant-stake", - hash: "19b6fc7ca736a65411b7578e9db17f965eeaa60ccb91ce64712f6127d407ad38", + hash: "28bd278900a7b0f8febf02784b5dc4c3b4b693ad237ccdd4b212fe271b3507c8", }, { // Stake placed during a running round: coins are saved on the wallet and an diff --git a/pkg/bath/testdata/hipo-instant-stake.json b/pkg/bath/testdata/hipo-instant-stake.json new file mode 100644 index 000000000..af0051973 --- /dev/null +++ b/pkg/bath/testdata/hipo-instant-stake.json @@ -0,0 +1,53 @@ +{ + "Actions": [ + { + "DepositStake": { + "Staker": "0:7f2beb264ebbd6a81f8bb96737394f380a3b75f0c20eb550dc4c1312dd60e0cc", + "Amount": { + "Currency": { + "Type": "native", + "Jetton": null, + "CurrencyID": null + }, + "Amount": 100000000 + }, + "Pool": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Implementation": "hipo" + }, + "Success": true, + "Type": "DepositStake", + "BaseTransactions": [ + "8a7e859868cb186829978521fcbed99f08bdc7e753a9ef5920435007d0a8157d", + "099ff1431c1e4a74f699f150013f0941d4114b5518ea9b7a0ec1618fe5016057", + "7f86c22ae88b4db1c0aea7e915012d075734d20b5e73a6c8f7facb60634eb81b", + "b14833d1e822e3e2d6619f7101d157b5f4283f09e44e8bef9d257a6b9a876516" + ] + } + ], + "Accounts": [ + { + "Account": "0:708755c60ec1289e08a0c348b3facfb7a6491e32ba482a2a042e36e6181f410c", + "Ton": 0, + "Fee": 556595, + "Jettons": null + }, + { + "Account": "0:7f2beb264ebbd6a81f8bb96737394f380a3b75f0c20eb550dc4c1312dd60e0cc", + "Ton": -102356662, + "Fee": 385729, + "Jettons": null + }, + { + "Account": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Ton": 99999865, + "Fee": 779804, + "Jettons": null + }, + { + "Account": "0:cf76af318c0872b58a9f1925fc29c156211782b9fb01f56760d292e56123bf87", + "Ton": -35, + "Fee": 634704, + "Jettons": null + } + ] + } \ No newline at end of file