From 5d7b24334812663d37bd187d24b218a322dfe2a7 Mon Sep 17 00:00:00 2001 From: Behrang Norouzinia Date: Tue, 4 Aug 2026 19:03:13 +0330 Subject: [PATCH 1/3] 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 7fdab142d..4e70b5a36 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -6159,7 +6159,8 @@ "whales", "tf", "liquidTF", - "ffvault" + "ffvault", + "hipo" ], "type": "string" }, diff --git a/api/openapi.yml b/api/openapi.yml index 82627baa1..dd9568e8e 100644 --- a/api/openapi.yml +++ b/api/openapi.yml @@ -8985,6 +8985,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 e05ae7165..0430e2806 100644 --- a/pkg/bath/bath_test.go +++ b/pkg/bath/bath_test.go @@ -464,6 +464,21 @@ func TestFindActions(t *testing.T) { tongo.MustParseBlockID("(0,8000000000000000,85794512)"), tongo.MustParseBlockID("(0,8000000000000000,85794513)"), }, + "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 { @@ -1251,6 +1266,50 @@ func TestFindActions(t *testing.T) { filenamePrefix: "gas-relay-usdt", hash: "8cd9d960e66fab8cebf45a679f46426cdc8bbd5411d31a89c1c2163a195de386", }, + { + // 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 92b449c1b..714839a45 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 0c382d493..af79dd51f 100644 --- a/pkg/oas/oas_json_gen.go +++ b/pkg/oas/oas_json_gen.go @@ -40643,6 +40643,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 40818e052..0b999e902 100644 --- a/pkg/oas/oas_schemas_gen.go +++ b/pkg/oas/oas_schemas_gen.go @@ -18999,6 +18999,7 @@ const ( PoolImplementationTypeTf PoolImplementationType = "tf" PoolImplementationTypeLiquidTF PoolImplementationType = "liquidTF" PoolImplementationTypeFfvault PoolImplementationType = "ffvault" + PoolImplementationTypeHipo PoolImplementationType = "hipo" ) // AllValues returns all PoolImplementationType values. @@ -19008,6 +19009,7 @@ func (PoolImplementationType) AllValues() []PoolImplementationType { PoolImplementationTypeTf, PoolImplementationTypeLiquidTF, PoolImplementationTypeFfvault, + PoolImplementationTypeHipo, } } @@ -19022,6 +19024,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) } @@ -19042,6 +19046,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 42895d770..366296a2d 100644 --- a/pkg/oas/oas_validators_gen.go +++ b/pkg/oas/oas_validators_gen.go @@ -5626,6 +5626,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 1dc194b05ebc991eacf01ca088cb060ab976b442 Mon Sep 17 00:00:00 2001 From: Behrang Norouzinia Date: Wed, 5 Aug 2026 20:13:28 +0330 Subject: [PATCH 2/3] 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 0430e2806..1629db578 100644 --- a/pkg/bath/bath_test.go +++ b/pkg/bath/bath_test.go @@ -464,8 +464,8 @@ func TestFindActions(t *testing.T) { tongo.MustParseBlockID("(0,8000000000000000,85794512)"), tongo.MustParseBlockID("(0,8000000000000000,85794513)"), }, - "19b6fc7ca736a65411b7578e9db17f965eeaa60ccb91ce64712f6127d407ad38": { - tongo.MustParseBlockID("(0,8000000000000000,85114134)"), + "28bd278900a7b0f8febf02784b5dc4c3b4b693ad237ccdd4b212fe271b3507c8": { + tongo.MustParseBlockID("(0,8000000000000000,88655705)"), }, "d4e8cd268769f830ceb6e58a1bf7f3fb85b13757e80fd2a9a31cce94e4914936": { tongo.MustParseBlockID("(0,8000000000000000,88409051)"), @@ -1267,15 +1267,11 @@ func TestFindActions(t *testing.T) { hash: "8cd9d960e66fab8cebf45a679f46426cdc8bbd5411d31a89c1c2163a195de386", }, { - // 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 From e3bf301c807066ca132ac64efa97e44923da94e1 Mon Sep 17 00:00:00 2001 From: Behrang Norouzinia Date: Mon, 14 Sep 2026 14:55:28 +0000 Subject: [PATCH 3/3] bath: address review of the Hipo straws - Bump tongo to v1.23.4, which ships the full hipo_finance.xml, and match every Hipo message by abi.HipoFinance*MsgOp name instead of raw opcode. All Hipo messages in the four golden traces decode by name with it. - Only claim burns of hGRAM: WithdrawHipoStakeRequestStraw now checks the burn's master is references.HipoParent. - Replace hipoUnstakeNotRolledBack with Not(HasChild(IsTx, HasOperation(abi.HipoFinanceProxyRollbackUnstakeMsgOp))). The rollback is an outgoing message of the treasury's reserve_tokens transaction, i.e. a child bubble, so a plain Not(HasOpcode(...)) on that transaction would always pass; Not and HasChild are added next to Or. - Drop the skipped rollback golden. Its block is past public-liteserver retention; TestWithdrawHipoStakeRequestStraw covers the rollback and the foreign-jetton cases on synthetic bubble trees instead. - Rename hipoBillChildren to optionalNotifyExcessAndDeploy and trim the comments, linking the flow documentation instead. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0138QyniXKEFCxZFGNubhgFP --- go.mod | 2 +- go.sum | 4 +- pkg/bath/bath_test.go | 16 ----- pkg/bath/hipo.go | 162 ++++++++++-------------------------------- pkg/bath/hipo_test.go | 92 +++++++++++++----------- pkg/bath/matcher.go | 25 +++++++ 6 files changed, 118 insertions(+), 183 deletions(-) diff --git a/go.mod b/go.mod index e0589a00c..aa75a621d 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/sourcegraph/conc v0.3.0 github.com/stretchr/testify v1.11.1 github.com/tonkeeper/scam_backoffice_rules v0.0.12 - github.com/tonkeeper/tongo v1.23.1 + github.com/tonkeeper/tongo v1.23.4 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/metric v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 diff --git a/go.sum b/go.sum index 0d1add84a..bf23e4cfe 100644 --- a/go.sum +++ b/go.sum @@ -97,8 +97,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tonkeeper/scam_backoffice_rules v0.0.12 h1:vCxE77SEddtPTWCaTH1E3tZ9SsZoa+QJ9q6vCWVqdIM= github.com/tonkeeper/scam_backoffice_rules v0.0.12/go.mod h1:SqZXYO9vbID8ku+xnnaKXeNGmehxigODGrk5V1KqbRA= -github.com/tonkeeper/tongo v1.23.1 h1:KyZN9MMO3ae8Bantl+uQDXsipv590qk3xYCUx+R/czc= -github.com/tonkeeper/tongo v1.23.1/go.mod h1:nHmdEXPfT0/EvkEaBzPiY599/0OYjQSW4dWR7aX+OII= +github.com/tonkeeper/tongo v1.23.4 h1:zTSdHtz5n9rkizeEwiSS/uVuXa+w3Mush2byLBt10uo= +github.com/tonkeeper/tongo v1.23.4/go.mod h1:nHmdEXPfT0/EvkEaBzPiY599/0OYjQSW4dWR7aX+OII= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= diff --git a/pkg/bath/bath_test.go b/pkg/bath/bath_test.go index 1629db578..2c73df0b8 100644 --- a/pkg/bath/bath_test.go +++ b/pkg/bath/bath_test.go @@ -476,9 +476,6 @@ func TestFindActions(t *testing.T) { "9236ab916e92293a822e1bb701c970de97eac3df18f1a18bfca3a4d3fd049229": { tongo.MustParseBlockID("(0,c000000000000000,88394637)"), }, - "ba0d8ea61d2caf1d870919362b8a9178726e8f3d71e45c0c866ae7fc450e6aff": { - tongo.MustParseBlockID("(0,8000000000000000,62680498)"), - }, } type Case struct { @@ -1293,19 +1290,6 @@ func TestFindActions(t *testing.T) { 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 index cbb6579fc..42844b15f 100644 --- a/pkg/bath/hipo.go +++ b/pkg/bath/hipo.go @@ -9,83 +9,23 @@ import ( "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 -} +// Hipo (https://hipo.finance) is a liquid-staking protocol for the native coin: GRAM in, +// hGRAM jettons out, the same shape as Tonstakers. Each flow has an instant and a deferred +// variant; the deferred one mints an SBT ("bill") that is redeemed when the round ends. +// The message flows these straws follow are documented at +// https://github.com/HipoFinance/contract/blob/main/docs/integration.md#explorer-actions -// 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. +// hipoBillAssigned matches a bill transaction either as a plain assign_bill or already +// merged into a BubbleNftTransfer by NftTransferNotifyStraw, which usually claims it first. 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 + return ok && tx.operation(abi.HipoFinanceAssignBillMsgOp) } -// 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] { +func optionalNotifyExcessAndDeploy[T actioner]() []Straw[T] { return []Straw[T]{ { CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.NftOwnershipAssignedMsgOp)}, @@ -123,10 +63,8 @@ func hipoDepositBuilder(newAction *BubbleDepositStake, bubble *Bubble) error { 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. + // coins may be zero, meaning "everything left after the gas prepayment", so fall back + // to the attached value. amount := big.Int(body.Coins) if amount.Sign() == 0 { newAction.Amount = core.PriceNanoGram(tx.inputAmount) @@ -136,14 +74,10 @@ func hipoDepositBuilder(newAction *BubbleDepositStake, bubble *Bubble) error { return nil } -// DepositHipoStakeStraw recognizes an instant stake, which is what happens when the -// treasury is between rounds and can mint hGRAM right away: +// DepositHipoStakeStraw recognizes an instant stake: // // 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, @@ -158,8 +92,7 @@ var DepositHipoStakeStraw = Straw[BubbleDepositStake]{ if !ok { return nil } - // tokens_minted carries the GRAM the treasury actually accepted, so it - // is preferred over the deposit_coins estimate. + // tokens_minted carries the GRAM the treasury actually accepted. coins := big.Int(body.Coins) newAction.Amount = core.PriceNanoGram(coins.Int64()) if owner, err := ton.AccountIDFromTlb(body.Owner); err == nil && owner != nil { @@ -177,7 +110,6 @@ var DepositHipoStakeStraw = Straw[BubbleDepositStake]{ Optional: true, }, { - // The staker's hGRAM wallet on their very first stake. CheckFuncs: []bubbleCheck{Is(BubbleContractDeploy{})}, Optional: true, }, @@ -186,62 +118,52 @@ var DepositHipoStakeStraw = Straw[BubbleDepositStake]{ }, } -// 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: +// DepositHipoStakeDeferredStraw recognizes a stake made while a round is running. It is +// still a DepositStake: the GRAM has left the staker, only the hGRAM arrives at round end. // // 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)}, + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.HipoFinanceProxySaveCoinsMsgOp)}, SingleChild: &Straw[BubbleDepositStake]{ - CheckFuncs: []bubbleCheck{IsTx, HasOpcode(hipoSaveCoinsMsgOpCode)}, + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.HipoFinanceSaveCoinsMsgOp)}, 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)}, + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.HipoFinanceMintBillMsgOp)}, SingleChild: &Straw[BubbleDepositStake]{ CheckFuncs: []bubbleCheck{hipoBillAssigned}, - Children: hipoBillChildren[BubbleDepositStake](), + Children: optionalNotifyExcessAndDeploy[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. +// WithdrawHipoStakeRequestStraw recognizes the head both unstake variants share, starting +// from the hGRAM burn that JettonBurnStraw has already merged: // // 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. +// The deferred variant's mint_bill is matched here and the action stays a request. The +// instant variant's proxy_tokens_burned is left for WithdrawHipoStakeStraw. A treasury that +// answers with proxy_rollback_unstake refused the unstake, so it is not matched at all. var WithdrawHipoStakeRequestStraw = Straw[BubbleWithdrawStakeRequest]{ - CheckFuncs: []bubbleCheck{Is(BubbleJettonBurn{})}, + CheckFuncs: []bubbleCheck{Is(BubbleJettonBurn{}), func(bubble *Bubble) bool { + return bubble.Info.(BubbleJettonBurn).master == references.HipoParent + }}, Builder: func(newAction *BubbleWithdrawStakeRequest, bubble *Bubble) error { burn := bubble.Info.(BubbleJettonBurn) newAction.Staker = burn.sender.Address @@ -257,10 +179,9 @@ var WithdrawHipoStakeRequestStraw = Straw[BubbleWithdrawStakeRequest]{ return nil }, SingleChild: &Straw[BubbleWithdrawStakeRequest]{ - CheckFuncs: []bubbleCheck{IsTx, HasOpcode(hipoProxyReserveTokensMsgOpCode)}, + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.HipoFinanceProxyReserveTokensMsgOp)}, 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. + // Hipo refunds what is left of this gas prepayment together with the payout. newAction.attachedAmount = bubble.Info.(BubbleTx).inputAmount return nil }, @@ -268,8 +189,8 @@ var WithdrawHipoStakeRequestStraw = Straw[BubbleWithdrawStakeRequest]{ CheckFuncs: []bubbleCheck{ IsTx, IsAccount(references.HipoTreasury), - HasOpcode(hipoReserveTokensMsgOpCode), - hipoUnstakeNotRolledBack, + HasOperation(abi.HipoFinanceReserveTokensMsgOp), + Not(HasChild(IsTx, HasOperation(abi.HipoFinanceProxyRollbackUnstakeMsgOp))), }, Builder: func(newAction *BubbleWithdrawStakeRequest, bubble *Bubble) error { tx := bubble.Info.(BubbleTx) @@ -279,12 +200,11 @@ var WithdrawHipoStakeRequestStraw = Straw[BubbleWithdrawStakeRequest]{ }, Children: []Straw[BubbleWithdrawStakeRequest]{ { - // Deferred unstake only; absent in the instant flow. - CheckFuncs: []bubbleCheck{IsTx, HasOpcode(hipoMintBillMsgOpCode)}, + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.HipoFinanceMintBillMsgOp)}, Optional: true, SingleChild: &Straw[BubbleWithdrawStakeRequest]{ CheckFuncs: []bubbleCheck{hipoBillAssigned}, - Children: hipoBillChildren[BubbleWithdrawStakeRequest](), + Children: optionalNotifyExcessAndDeploy[BubbleWithdrawStakeRequest](), }, }, }, @@ -293,18 +213,12 @@ var WithdrawHipoStakeRequestStraw = Straw[BubbleWithdrawStakeRequest]{ } // WithdrawHipoStakeStraw upgrades an instant unstake from a request to a completed -// withdrawal by consuming the payout leg that WithdrawHipoStakeRequestStraw left behind: +// withdrawal by consuming the payout leg 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. +// Like WithdrawLiquidStake, the amount nets out the gas the staker prepaid. var WithdrawHipoStakeStraw = Straw[BubbleWithdrawStake]{ CheckFuncs: []bubbleCheck{Is(BubbleWithdrawStakeRequest{}), func(bubble *Bubble) bool { request, ok := bubble.Info.(BubbleWithdrawStakeRequest) @@ -319,11 +233,11 @@ var WithdrawHipoStakeStraw = Straw[BubbleWithdrawStake]{ return nil }, SingleChild: &Straw[BubbleWithdrawStake]{ - CheckFuncs: []bubbleCheck{IsTx, HasOpcode(hipoProxyTokensBurnedMsgOpCode)}, + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.HipoFinanceProxyTokensBurnedMsgOp)}, SingleChild: &Straw[BubbleWithdrawStake]{ - CheckFuncs: []bubbleCheck{IsTx, HasOpcode(hipoTokensBurnedMsgOpCode)}, + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.HipoFinanceTokensBurnedMsgOp)}, SingleChild: &Straw[BubbleWithdrawStake]{ - CheckFuncs: []bubbleCheck{IsTx, HasOpcode(hipoWithdrawalNotificationMsgOpCode)}, + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.HipoFinanceWithdrawalNotificationMsgOp)}, 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 index a00251ef1..477629451 100644 --- a/pkg/bath/hipo_test.go +++ b/pkg/bath/hipo_test.go @@ -4,63 +4,75 @@ import ( "testing" "github.com/stretchr/testify/require" + "github.com/tonkeeper/opentonapi/pkg/core" + "github.com/tonkeeper/opentonapi/pkg/references" + "github.com/tonkeeper/tongo" + "github.com/tonkeeper/tongo/abi" ) -func hipoOpBubble(opCode uint32) *Bubble { - op := opCode - return &Bubble{Info: BubbleTx{opCode: &op}} +func hipoTx(account tongo.AccountID, operation string, children ...*Bubble) *Bubble { + return &Bubble{ + Info: BubbleTx{ + success: true, + account: Account{Address: account}, + decodedBody: &core.DecodedMessageBody{Operation: operation}, + }, + Children: children, + ValueFlow: newValueFlow(), + } +} + +// hipoUnstake builds an hGRAM burn whose reserve_tokens transaction on the treasury answers +// with the given children. +func hipoUnstake(master tongo.AccountID, treasuryAnswer ...*Bubble) *Bubble { + return &Bubble{ + Info: BubbleJettonBurn{master: master}, + ValueFlow: newValueFlow(), + Children: []*Bubble{ + hipoTx(references.HipoParent, abi.HipoFinanceProxyReserveTokensMsgOp, + hipoTx(references.HipoTreasury, abi.HipoFinanceReserveTokensMsgOp, treasuryAnswer...), + ), + }, + } } -// 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) { +// The rollback case has no golden trace: the last one on mainnet is older than public +// liteservers keep, so the straw is exercised on a synthetic bubble tree instead. +func TestWithdrawHipoStakeRequestStraw(t *testing.T) { + var someone tongo.AccountID tests := []struct { - name string - children []*Bubble - want bool + name string + bubble *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: "deferred unstake mints a bill", + bubble: hipoUnstake(references.HipoParent, + hipoTx(someone, abi.HipoFinanceMintBillMsgOp, + hipoTx(someone, abi.HipoFinanceAssignBillMsgOp), + ), + ), + want: true, }, { - name: "rolled back unstake", - children: []*Bubble{hipoOpBubble(hipoProxyRollbackUnstakeMsgOpCode)}, - want: false, + name: "instant unstake pays out", + bubble: hipoUnstake(references.HipoParent, hipoTx(references.HipoParent, abi.HipoFinanceProxyTokensBurnedMsgOp)), + want: true, }, { - name: "no answer at all", - children: nil, - want: true, + name: "rolled back unstake", + bubble: hipoUnstake(references.HipoParent, hipoTx(references.HipoParent, abi.HipoFinanceProxyRollbackUnstakeMsgOp)), + want: false, }, { - name: "non-transaction children are ignored", - children: []*Bubble{{Info: BubbleContractDeploy{}}}, - want: true, + name: "burn of another jetton", + bubble: hipoUnstake(someone, hipoTx(references.HipoParent, abi.HipoFinanceProxyTokensBurnedMsgOp)), + want: false, }, } 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)) + require.Equal(t, tt.want, WithdrawHipoStakeRequestStraw.Merge(tt.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/matcher.go b/pkg/bath/matcher.go index cbf46cbf8..9ffa017a9 100644 --- a/pkg/bath/matcher.go +++ b/pkg/bath/matcher.go @@ -213,6 +213,31 @@ func Or(check1, check2 bubbleCheck) bubbleCheck { } } +func Not(check bubbleCheck) bubbleCheck { + return func(bubble *Bubble) bool { + return !check(bubble) + } +} + +// HasChild reports whether any child passes all checks, evaluated in order like CheckFuncs. +func HasChild(checks ...bubbleCheck) bubbleCheck { + return func(bubble *Bubble) bool { + for _, child := range bubble.Children { + matched := true + for _, check := range checks { + if !check(child) { + matched = false + break + } + } + if matched { + return true + } + } + return false + } +} + func HasOpcode(op uint32) bubbleCheck { return func(b *Bubble) bool { opCode := b.Info.(BubbleTx).opCode