Skip to content

abi: complete the Hipo schema for the loan and round lifecycle - #504

Closed
behrang wants to merge 10 commits into
tonkeeper:masterfrom
behrang:hipo-loan-round-ops
Closed

behrang wants to merge 10 commits into
tonkeeper:masterfrom
behrang:hipo-loan-round-ops

Conversation

@behrang

@behrang behrang commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #499, which covered Hipo's staking messages. This adds the loan and round-lifecycle ops, the later request_loan layouts, and the unstake-all chain. It is additive: nothing released in #499 changes.

Ops added

  • Round lifecycle: participate_in_election, vset_changed and finish_participation (ext_in, which is how they are sent), decide_loan_requests, process_loan_requests, recover_stakes, proxy_new_stake, proxy_recover_stake, request_rejected, take_profit, take_borrower_fee. A round-end trace is mostly made of these, and today it reads as unnamed op codes.
  • Unstake-all chain: send_unstake_all, proxy_unstake_all, unstake_all.
  • withdraw_surplus: one op and layout in the wallet, parent, treasury and librarian.

request_loan layouts

Following the bidask_swap_fallback / _v2 precedent, the released name keeps its body and later layouts are versioned:

name after min_payment in force
hipo_finance_request_loan borrower_reward_share:uint8 until 2026-09-05
hipo_finance_request_loan_v2 borrower_reward_share:uint16 2026-09-05 to 2026-09-21
hipo_finance_request_loan_v3 nothing 2026-09-21 to 2026-09-26
hipo_finance_request_loan_v4 max_stake:(VarUInteger 16) since 2026-09-26

Each layout consumes the body exactly only for its own era, so multipleMsgsDecoder picks the right one. Before this, every request since 2026-09-05 decoded the share as its high byte (1799 read as 7) or fell back to raw hex.

Left out on purpose

top_up, withdraw_jettons and the quote ops: most mainnet messages with their op codes belong to other protocols (97 of 105 top_up, all 154 withdraw_jettons), so declaring them would mislabel that traffic.

Tests

abi/hipo_finance_test.go decodes a real mainnet message for every op (except send_unstake_all, never sent, so built), and asserts the decode read the whole body. That check is what catches a wrong field width, which otherwise decodes to a plausible number. Layouts were checked against the FunC sources.

go-generate and abi pass. The unit-tests failures are outside abi: TestConvertBlockchainConfig fails the same way on master (run 34974885155), TestGeneratedMethod3 fails on master locally too, and TestParseAddress is liteserver flakiness.

Eleven op-codes Hipo's treasury and loan contracts use every round were missing
from hipo_finance.xml, so they reach callers as bare hex: 0x574a297b, 0x6a31d344,
0x071d07cc, 0x2f0b5b3b, 0x23274435, 0x4f173d3e, 0x089cd4d0, 0x407cb243,
0xcd0f2116, 0x8b556813 and 0x5e2d81f4. A round-end trace is mostly made of them.

Three are declared as ext_in rather than internal. treasury.fc routes
participate_in_election, vset_changed and finish_participation through
route_external_message, which accepts those three and nothing else; no internal
handler takes them.

finish_participation carries a uint32 query_id where every other op in this file
uses uint64. Declared as uint64 it reads 32 bits of query id and 32 bits of
round_since as one number, and the round the message is about is gone.

Also fixes borrower_reward_share in request_loan, which has been wrong since this
file was added. The contract widened it from 8 bits out of 255 to 16 out of 65535
(contracts/schema.tlb, and store_uint(.., 16) in treasury.fc). Declared as uint8
it still decodes, because the field is followed by a reference rather than more
bits - it just yields the high byte. One borrower's share of 1799 has been
reported as 7 in every request_loan on mainnet.

Tests decode one real mainnet message per op, and pin the two field widths that
are easy to get wrong and impossible to notice: with the old uint8 the new test
reports "borrower_reward_share = 7, want 1799".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@behrang

behrang commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

On the red unit-tests: none of it is this PR, and I did not want to leave you to work that out.

  • TestConvertBlockchainConfig (3 subtests) already fails on master — run 34974885155 on d12106f9, which is the commit this branch is based on.
  • TestParseAddress and TestGeneratedMethod3 are public-liteserver flakiness: they fail locally on an unmodified checkout too, and a different one of the two failed on the base tree in the same run.

go-generate passes, which is the check that matters for a schema change, and go test ./abi/... is green including the twelve new cases.

Happy to split the borrower_reward_share width fix into its own commit or PR if you would rather review it separately from the new declarations.

behrang added a commit to HipoFinance/contract that referenced this pull request Sep 18, 2026
Eleven op-codes were listed and ten were counted. The tongo half is now open as
tonkeeper/tongo#504, with the per-op decode tests the plan asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
behrang and others added 4 commits September 18, 2026 10:34
I declared it uint32 on the strength of contracts/schema.tlb, which says uint32.
The contract does not: treasury.fc reads load_uint(64), then load_uint(32), then
end_parse(). schema.tlb is the file that is wrong, and I should have checked the
FunC rather than the documentation of it.

The message is 128 bits. Read as uint64/uint32 it yields query_id 1788202855 and
round_since 1788104456 with nothing left over. Read as uint32/uint32 it yields
query_id 0 and round_since 1788202855 - the low half of the query id, presented
as the round - and leaves 32 bits unread. Both are plausible timestamps, so
nothing about the output looks wrong.

Every case now also asserts the decode consumed the whole body, which is the only
thing that distinguishes the two. That check is the point of the test rather than
a detail: a field declared too narrow decodes fine and reports a wrong number,
and only the leftover bits give it away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
borrower_reward_share widened from 8 bits to 16 in Hipo's 2026-09-05 release, so
a request sent before it no longer matches the current declaration at all - it
underflows and falls back to raw hex. Explorers reclassify history, so that would
quietly un-decode every loan request the protocol has ever had up to that date.

Declaring the old layout alongside restores them. The two cannot be confused:
multipleMsgsDecoder prefers whichever reading consumes the body exactly, and each
layout does that only for its own era - the old body underflows the uint16
reading, and the new body leaves 8 bits over in the uint8 one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The older layout was pinned with a body built in the test, because the corpus I
had to hand only went back as far as the release that changed it. Querying
toncenter by op-code turned up a real one, sent on 2026-09-04, the day before.

It carries a reward share of 8 on the old scale out of 255 - the same bid the
contract's migration note uses as its example of what becomes 2056 out of 65535.
No constructed cells remain in this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hipo's treasury upgraded on 2026-09-21 and borrower_reward_share left the
message: the treasury now sets one share for every loan in a round and
refuses a request that names its own, throwing at end_parse. Requests from
tomorrow's round onward carry the shorter body, so without this they decode
as raw hex.

The unversioned name keeps meaning the layout in force, as it did when the
8-bit era became _v1, so the era that named a 16-bit share is now _v2. All
three are distinguished the same way as before, by which reading consumes
the body exactly, and the decoder already prefers a complete read.

The body for the new layout is built by this package from the real one above
it rather than taken from a block: the upgrade landed at 14:19:35 UTC, after
the last request of the open round, so no message in this shape exists on
chain yet. It should be replaced with a real one once it does.
@behrang

behrang commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 08a854e: Hipo's treasury upgraded on mainnet on 2026-09-21 at 14:19:35 UTC, and borrower_reward_share left request_loan. The treasury now sets one share for every loan in a round and refuses a request that names its own, so the body is 16 bits shorter from the next round onward. Without this commit those requests decode as raw hex.

The unversioned name keeps meaning the layout in force, which is what it meant when the 8-bit era became _v1, so the era that named a 16-bit share is now _v2. Nothing else about the decoding changes: the three are told apart by which reading consumes the body exactly, and multipleMsgsDecoder already prefers a complete read over a partial one.

One thing to know about the test: the body for the new layout is built by the package from the real 16-bit message above it, rather than taken from a block. The upgrade landed after the last request of the open round, so no message in this shape exists on chain yet — the first will be in the next round. I'll swap in a real one once it does; flag it if you would rather the commit wait for that.

No rush on review, but the PR has been open since the 18th, so a ping in case it slipped off the list. The unit-tests failure is still the pre-existing one described above, untouched by any of this.

behrang and others added 5 commits September 26, 2026 07:51
Hipo's next treasury release makes max_stake, a coins field, a required part of
request_loan, right after min_payment: the most the loan stakes in total, and 0
for no cap. Once it lands, every request carries it, so without this they would
decode as raw hex.

The unversioned name keeps meaning the newest layout, as before, so the one that
names nothing after min_payment is now _v3. The four are still told apart by
which reading consumes the body exactly: max_stake is 4 + 8n bits, never the 0,
8 or 16 bits the older layouts leave, so no body reads completely under two of
them.

The _v3 test message is now a real one (sent 2026-09-26), replacing the one this
package built before any existed. The two max_stake bodies are that message with
the field added, built with @ton/core: the release is not deployed yet. They
should be replaced with real ones once it is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Hipo's stake-cap release is deployed (2026-09-26 08:49 UTC), and requests carrying max_stake are now
on chain. The capped fixture is one of them, sent 09:39 UTC; the one with max_stake 0 stays built by
hand until a request with no cap is sent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A request with max_stake 0 has now been sent (2026-09-26 14:37 UTC, round 1790463752), so the
last hand-built fixture is replaced by it. Every request_loan fixture is now a real message.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
send_unstake_all, proxy_unstake_all and unstake_all carry the unstake-all flow from the
treasury through the parent to the holder's wallet; withdraw_surplus is one op and layout in
the wallet, parent, treasury and librarian. Each is checked against the FunC, and the real
ones against every mainnet message carrying them (6 withdraw_surplus, 4 of each unstake-all
op, all consumed exactly). send_unstake_all has never been sent, so its test builds one.

top_up, withdraw_jettons and the quote ops stay undeclared: declarations decode globally,
and most mainnet messages with those op codes go to contracts that are not Hipo's. The
round-lifecycle comment now says what treasury.fc does (the three ops are also routed
internally, though only ever sent as externals), and take_borrower_fee moves under the
burner it is sent to.
…youts

hipo_finance_request_loan shipped in v1.23.4 with the uint8 share, and this branch had
repurposed the name for the newest layout. Following the precedent in bidask.xml, the
released name keeps its body and the later layouts are added as _v2 (uint16 share), _v3
(no share) and _v4 (max_stake), so the change is purely additive for API consumers.
Comments are cut down to the style of the other schemas.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant