From 7ad1c0cef263747de3591d2f4d24edbc69da9c13 Mon Sep 17 00:00:00 2001 From: Mikhail Alpinskiy Date: Mon, 19 Oct 2026 15:21:04 +0300 Subject: [PATCH] bath: show Hipo's loans and validation rounds Hipo's treasury lends its stakers' coins to validators one round at a time, but none of that showed up as actions: a loan request, the stake sent to the elector, the repayment and its reward split, and the round's own stages all read as bare GRAM transfers between Hipo's contracts. Four generic actions now cover the lending side of a staking pool, like DepositStake does for staking, each naming the pool implementation: - LoanRequest: a validator asks for a loan for the next round. - LoanIssue: the pool lends and the loan contract stakes it. - LoanRepayment: the loan comes back with its reward, split between the stakers, the protocol fee, the HPO burn and the borrower's payout. - PoolRoundTransition: a round moves through election, began, validated, ended and settled; a settled round carries its totals, and its preview shows the round's net result, negative for a losing round. The treasury reports amounts only in its logs, which it writes by topic rather than by op-code. core now decodes the external-out logs of the treasury and of the burner, and a BubbleTx keeps its input message's lt and its sender's logs so a straw can read the log written next to the message it matches. The loan straws are anchored on such a log, since anyone may send these ops but only the treasury can write its logs. A round only absorbs the steps that succeeded; a failed one stays beside it as its own transaction. The coins of every transfer an action absorbs count towards that action rather than as an account's unexplained extra. Unstake-all requests (send_unstake_all or the comment "w") also become a WithdrawStakeRequest, and Straw.Merge no longer adds the head's accounts and transactions twice when a straw extends an action it built before. API: LoanRequest, LoanIssue, LoanRepayment and PoolRoundTransition actions. A loan request's amount and min_payment are optional, absent when too large to represent; round_since is 0 when the round's start is unknown, and the preview then leaves the date out rather than showing 1970. Co-Authored-By: Claude Opus 5.5 --- api/openapi.json | 275 +++++ api/openapi.yml | 227 ++++ pkg/api/event_converters.go | 8 + pkg/api/i18n/translations/active.en.toml | 11 + pkg/api/i18n/translations/active.ru.toml | 44 + pkg/api/loan_converters.go | 173 +++ pkg/api/loan_converters_test.go | 81 ++ pkg/bath/actions.go | 20 + pkg/bath/bath_test.go | 52 + pkg/bath/bubble.go | 6 + pkg/bath/bubble_tx.go | 6 + pkg/bath/hipo.go | 34 + pkg/bath/hipo_loans.go | 422 +++++++ pkg/bath/hipo_loans_test.go | 288 +++++ pkg/bath/hipo_owner_test.go | 5 +- pkg/bath/loans.go | 162 +++ pkg/bath/matcher.go | 20 +- pkg/bath/straws.go | 44 +- pkg/bath/testdata/hipo-loan-request.json | 34 + pkg/bath/testdata/hipo-round-began.json | 27 + pkg/bath/testdata/hipo-round-election.json | 72 ++ pkg/bath/testdata/hipo-round-end.json | 464 ++++++++ pkg/bath/testdata/hipo-round-validated.json | 27 + pkg/core/converters.go | 22 +- pkg/core/hipo_logs.go | 371 ++++++ pkg/core/hipo_logs_test.go | 243 ++++ pkg/oas/oas_json_gen.go | 1151 ++++++++++++++++++- pkg/oas/oas_schemas_gen.go | 754 ++++++++++++ pkg/oas/oas_validators_gen.go | 200 ++++ pkg/references/hipo.go | 3 + 30 files changed, 5218 insertions(+), 28 deletions(-) create mode 100644 pkg/api/loan_converters.go create mode 100644 pkg/api/loan_converters_test.go create mode 100644 pkg/bath/hipo_loans.go create mode 100644 pkg/bath/hipo_loans_test.go create mode 100644 pkg/bath/loans.go create mode 100644 pkg/bath/testdata/hipo-loan-request.json create mode 100644 pkg/bath/testdata/hipo-round-began.json create mode 100644 pkg/bath/testdata/hipo-round-election.json create mode 100644 pkg/bath/testdata/hipo-round-end.json create mode 100644 pkg/bath/testdata/hipo-round-validated.json create mode 100644 pkg/core/hipo_logs.go create mode 100644 pkg/core/hipo_logs_test.go diff --git a/api/openapi.json b/api/openapi.json index a3a21c62d..a3228e2d5 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1223,6 +1223,15 @@ "LiquidityDeposit": { "$ref": "#/components/schemas/LiquidityDepositAction" }, + "LoanIssue": { + "$ref": "#/components/schemas/LoanIssueAction" + }, + "LoanRepayment": { + "$ref": "#/components/schemas/LoanRepaymentAction" + }, + "LoanRequest": { + "$ref": "#/components/schemas/LoanRequestAction" + }, "NftItemTransfer": { "$ref": "#/components/schemas/NftItemTransferAction" }, @@ -1232,6 +1241,9 @@ "OracleRequest": { "$ref": "#/components/schemas/OracleRequestAction" }, + "PoolRoundTransition": { + "$ref": "#/components/schemas/PoolRoundTransitionAction" + }, "Purchase": { "$ref": "#/components/schemas/PurchaseAction" }, @@ -1318,6 +1330,10 @@ "BuyXTR", "DepositXTR", "WithdrawXTR", + "LoanRequest", + "LoanIssue", + "LoanRepayment", + "PoolRoundTransition", "Unknown" ], "example": "TonTransfer", @@ -4891,6 +4907,213 @@ ], "type": "object" }, + "LoanIssueAction": { + "description": "a staking pool lending to a validator, which stakes the loan with the elector", + "properties": { + "accrued": { + "description": "pool coins added to the loan beyond what was asked for, in nanograms", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + }, + "amount": { + "description": "loan granted, in nanograms", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + }, + "borrower": { + "$ref": "#/components/schemas/AccountAddress" + }, + "collateral": { + "description": "the borrower's own coins staked alongside the loan, in nanograms", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + }, + "implementation": { + "$ref": "#/components/schemas/PoolImplementationType" + }, + "loan": { + "$ref": "#/components/schemas/AccountAddress", + "description": "contract that holds the loan and stakes it" + }, + "min_payment": { + "description": "least the borrower pays the pool from the round's reward, in nanograms", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + }, + "pool": { + "$ref": "#/components/schemas/AccountAddress" + }, + "round_since": { + "description": "start of the validation round the loan is for, as its unix time", + "example": 1792102152, + "format": "int64", + "type": "integer" + } + }, + "required": [ + "borrower", + "pool", + "loan", + "round_since", + "amount", + "accrued", + "collateral", + "min_payment", + "implementation" + ], + "type": "object" + }, + "LoanRepaymentAction": { + "description": "a loan coming back from the elector and its reward being split", + "properties": { + "accrued": { + "description": "pool coins added to the loan beyond what was asked for, in nanograms", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + }, + "amount": { + "description": "loan that was granted, in nanograms", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + }, + "borrower": { + "$ref": "#/components/schemas/AccountAddress" + }, + "borrower_payout": { + "description": "returned to the borrower: collateral and the borrower's reward, in nanograms", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + }, + "burn_amount": { + "description": "taken from the borrower's reward to be burned, in nanograms", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + }, + "implementation": { + "$ref": "#/components/schemas/PoolImplementationType" + }, + "loan": { + "$ref": "#/components/schemas/AccountAddress" + }, + "pool": { + "$ref": "#/components/schemas/AccountAddress" + }, + "pool_reward": { + "description": "what the pool's stakers earned, in nanograms", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + }, + "protocol_fee": { + "description": "the pool operator's share of the pool's reward, in nanograms; the operator may also receive what is left over from fees", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + }, + "recovered": { + "description": "what came back to the pool: the stake and the reward, less what the loan contract spent to forward it, in nanograms", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + }, + "round_since": { + "description": "start of the validation round the loan is for, as its unix time", + "example": 1792102152, + "format": "int64", + "type": "integer" + }, + "shortfall": { + "description": "part of the loan the elector did not return and the collateral could not cover, in nanograms", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + } + }, + "required": [ + "borrower", + "pool", + "loan", + "round_since", + "amount", + "accrued", + "recovered", + "pool_reward", + "protocol_fee", + "borrower_payout", + "burn_amount", + "implementation" + ], + "type": "object" + }, + "LoanRequestAction": { + "description": "a validator asking a staking pool for a loan to stake in the next validation round", + "properties": { + "amount": { + "description": "loan asked for, in nanograms; absent if too large to represent", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + }, + "borrower": { + "$ref": "#/components/schemas/AccountAddress" + }, + "implementation": { + "$ref": "#/components/schemas/PoolImplementationType" + }, + "min_payment": { + "description": "least the borrower promises to pay the pool from the round's reward, in nanograms; absent if too large to represent", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + }, + "pool": { + "$ref": "#/components/schemas/AccountAddress" + }, + "round_since": { + "description": "start of the validation round the loan is for, as its unix time; 0 if the round is unknown", + "example": 1792102152, + "format": "int64", + "type": "integer" + }, + "ton_attached": { + "description": "sent with the request: the borrower's collateral and the pool's fees, in nanograms", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + } + }, + "required": [ + "borrower", + "pool", + "round_since", + "ton_attached", + "implementation" + ], + "type": "object" + }, "MarketTonRates": { "properties": { "last_date_update": { @@ -6273,6 +6496,58 @@ ], "type": "object" }, + "PoolRoundTransition": { + "description": "election: the pool decided which loans to make and staked them with the elector; began: the validation round started; validated: the round's validators finished validating and the stakes are held until the elector releases them; ended: the round's stakes were released and the pool is recovering them; settled: every loan was recovered and the round's deposits and withdrawals were settled\n", + "enum": [ + "election", + "began", + "validated", + "ended", + "settled" + ], + "type": "string" + }, + "PoolRoundTransitionAction": { + "description": "a staking pool moving one of its validation rounds to its next stage", + "properties": { + "implementation": { + "$ref": "#/components/schemas/PoolImplementationType" + }, + "pool": { + "$ref": "#/components/schemas/AccountAddress" + }, + "round_since": { + "description": "start of the validation round, as its unix time; 0 if the round is unknown", + "example": 1792102152, + "format": "int64", + "type": "integer" + }, + "total_recovered": { + "description": "settled only: recovered from the round, in nanograms", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + }, + "total_staked": { + "description": "settled only: lent out for the round, in nanograms", + "example": 1660050553, + "format": "int64", + "type": "integer", + "x-js-format": "bigint" + }, + "transition": { + "$ref": "#/components/schemas/PoolRoundTransition" + } + }, + "required": [ + "pool", + "round_since", + "transition", + "implementation" + ], + "type": "object" + }, "Price": { "properties": { "currency_type": { diff --git a/api/openapi.yml b/api/openapi.yml index 40593b4cd..57eb04902 100644 --- a/api/openapi.yml +++ b/api/openapi.yml @@ -6872,6 +6872,10 @@ components: - BuyXTR - DepositXTR - WithdrawXTR + - LoanRequest + - LoanIssue + - LoanRepayment + - PoolRoundTransition - Unknown status: type: string @@ -6943,6 +6947,14 @@ components: $ref: '#/components/schemas/DepositXTRAction' BuyXTR: $ref: '#/components/schemas/BuyXTRAction' + LoanRequest: + $ref: '#/components/schemas/LoanRequestAction' + LoanIssue: + $ref: '#/components/schemas/LoanIssueAction' + LoanRepayment: + $ref: '#/components/schemas/LoanRepaymentAction' + PoolRoundTransition: + $ref: '#/components/schemas/PoolRoundTransitionAction' simple_preview: $ref: '#/components/schemas/ActionSimplePreview' base_transactions: @@ -7528,6 +7540,221 @@ components: stake_meta: description: "If present, should be used instead of amount" $ref: '#/components/schemas/Price' + LoanRequestAction: + description: "a validator asking a staking pool for a loan to stake in the next validation round" + type: object + required: + - borrower + - pool + - round_since + - ton_attached + - implementation + properties: + borrower: + $ref: '#/components/schemas/AccountAddress' + pool: + $ref: '#/components/schemas/AccountAddress' + round_since: + description: "start of the validation round the loan is for, as its unix time; 0 if the round is unknown" + type: integer + format: int64 + example: 1792102152 + amount: + description: "loan asked for, in nanograms; absent if too large to represent" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + min_payment: + description: "least the borrower promises to pay the pool from the round's reward, in nanograms; absent if too large to represent" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + ton_attached: + description: "sent with the request: the borrower's collateral and the pool's fees, in nanograms" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + implementation: + $ref: '#/components/schemas/PoolImplementationType' + LoanIssueAction: + description: "a staking pool lending to a validator, which stakes the loan with the elector" + type: object + required: + - borrower + - pool + - loan + - round_since + - amount + - accrued + - collateral + - min_payment + - implementation + properties: + borrower: + $ref: '#/components/schemas/AccountAddress' + pool: + $ref: '#/components/schemas/AccountAddress' + loan: + description: "contract that holds the loan and stakes it" + $ref: '#/components/schemas/AccountAddress' + round_since: + description: "start of the validation round the loan is for, as its unix time" + type: integer + format: int64 + example: 1792102152 + amount: + description: "loan granted, in nanograms" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + accrued: + description: "pool coins added to the loan beyond what was asked for, in nanograms" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + collateral: + description: "the borrower's own coins staked alongside the loan, in nanograms" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + min_payment: + description: "least the borrower pays the pool from the round's reward, in nanograms" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + implementation: + $ref: '#/components/schemas/PoolImplementationType' + LoanRepaymentAction: + description: "a loan coming back from the elector and its reward being split" + type: object + required: + - borrower + - pool + - loan + - round_since + - amount + - accrued + - recovered + - pool_reward + - protocol_fee + - borrower_payout + - burn_amount + - implementation + properties: + borrower: + $ref: '#/components/schemas/AccountAddress' + pool: + $ref: '#/components/schemas/AccountAddress' + loan: + $ref: '#/components/schemas/AccountAddress' + round_since: + description: "start of the validation round the loan is for, as its unix time" + type: integer + format: int64 + example: 1792102152 + amount: + description: "loan that was granted, in nanograms" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + accrued: + description: "pool coins added to the loan beyond what was asked for, in nanograms" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + recovered: + description: "what came back to the pool: the stake and the reward, less what the loan contract spent to forward it, in nanograms" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + pool_reward: + description: "what the pool's stakers earned, in nanograms" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + protocol_fee: + description: "the pool operator's share of the pool's reward, in nanograms; the operator may also receive what is left over from fees" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + borrower_payout: + description: "returned to the borrower: collateral and the borrower's reward, in nanograms" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + burn_amount: + description: "taken from the borrower's reward to be burned, in nanograms" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + shortfall: + description: "part of the loan the elector did not return and the collateral could not cover, in nanograms" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + implementation: + $ref: '#/components/schemas/PoolImplementationType' + PoolRoundTransition: + type: string + description: > + election: the pool decided which loans to make and staked them with the elector; + began: the validation round started; + validated: the round's validators finished validating and the stakes are held until the elector releases them; + ended: the round's stakes were released and the pool is recovering them; + settled: every loan was recovered and the round's deposits and withdrawals were settled + enum: + - election + - began + - validated + - ended + - settled + PoolRoundTransitionAction: + description: "a staking pool moving one of its validation rounds to its next stage" + type: object + required: + - pool + - round_since + - transition + - implementation + properties: + pool: + $ref: '#/components/schemas/AccountAddress' + round_since: + description: "start of the validation round, as its unix time; 0 if the round is unknown" + type: integer + format: int64 + example: 1792102152 + transition: + $ref: '#/components/schemas/PoolRoundTransition' + total_staked: + description: "settled only: lent out for the round, in nanograms" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + total_recovered: + description: "settled only: recovered from the round, in nanograms" + type: integer + format: int64 + x-js-format: bigint + example: 1660050553 + implementation: + $ref: '#/components/schemas/PoolImplementationType' ElectionsRecoverStakeAction: type: object required: diff --git a/pkg/api/event_converters.go b/pkg/api/event_converters.go index 1f94409cf..822fd3e08 100644 --- a/pkg/api/event_converters.go +++ b/pkg/api/event_converters.go @@ -1217,6 +1217,14 @@ func (h *Handler) convertAction(ctx context.Context, viewer *tongo.AccountID, a action.DepositXTR, action.SimplePreview = h.convertDepositXTRAction(ctx, a.DepositXTR, acceptLanguage.Value, viewer) case bath.WithdrawXTR: action.WithdrawXTR, action.SimplePreview = h.convertWithdrawXTRAction(ctx, a.WithdrawXTR, acceptLanguage.Value, viewer) + case bath.LoanRequest: + action.LoanRequest, action.SimplePreview = h.convertLoanRequestAction(a.LoanRequest, acceptLanguage.Value, viewer) + case bath.LoanIssue: + action.LoanIssue, action.SimplePreview = h.convertLoanIssueAction(a.LoanIssue, acceptLanguage.Value, viewer) + case bath.LoanRepayment: + action.LoanRepayment, action.SimplePreview = h.convertLoanRepaymentAction(a.LoanRepayment, acceptLanguage.Value, viewer) + case bath.PoolRoundTransition: + action.PoolRoundTransition, action.SimplePreview = h.convertPoolRoundTransitionAction(a.PoolRoundTransition, acceptLanguage.Value, viewer) } return action, nil } diff --git a/pkg/api/i18n/translations/active.en.toml b/pkg/api/i18n/translations/active.en.toml index ad1d2ef4a..e2c79fe8b 100644 --- a/pkg/api/i18n/translations/active.en.toml +++ b/pkg/api/i18n/translations/active.en.toml @@ -20,3 +20,14 @@ gramTransferAction = "Transferring {{.Value}}" withdrawStakeAction = "Withdraw {{.Value}} from staking pool" withdrawStakeRequestAction = "Request to withdraw {{.Value}} from staking pool" purchaseAction = "Payment for invoice #{{.InvoiceID}}" +loanRequestAction = "Requesting a loan of {{.Amount}} for the validation round starting {{.Round}}" +loanRequestActionNoAmount = "Requesting a loan for the validation round starting {{.Round}}" +loanRequestActionNoRound = "Requesting a loan of {{.Amount}}" +loanRequestActionNoAmountNoRound = "Requesting a loan" +loanIssueAction = "Lending {{.Amount}} to a validator for the round starting {{.Round}}" +loanRepaymentAction = "Loan of {{.Amount}} repaid, stakers earned {{.Reward}}" +poolRoundElection = "Loans decided for the validation round starting {{.Round}}" +poolRoundBegan = "Validation round starting {{.Round}} began" +poolRoundValidated = "Validation round starting {{.Round}} finished, its stakes are held" +poolRoundEnded = "Stakes of the validation round starting {{.Round}} released" +poolRoundSettled = "Validation round starting {{.Round}} settled" diff --git a/pkg/api/i18n/translations/active.ru.toml b/pkg/api/i18n/translations/active.ru.toml index 29a5b12c4..a48179bd9 100644 --- a/pkg/api/i18n/translations/active.ru.toml +++ b/pkg/api/i18n/translations/active.ru.toml @@ -85,3 +85,47 @@ other = "Продление домена {{.Value}}" [purchaseAction] hash = "sha1-980bac6469ccaf2a7cb30f4969696164587599aa" other = "Оплата инвойса #{{.InvoiceID}}" + +[loanRequestAction] +hash = "sha1-0219cb6c8a822e50f1a9851363d22381cc5bac36" +other = "Запрос займа {{.Amount}} на раунд валидации с {{.Round}}" + +[loanRequestActionNoAmount] +hash = "sha1-76f7a8ce80a985773b4d91f276d88fdb75035eff" +other = "Запрос займа на раунд валидации с {{.Round}}" + +[loanRequestActionNoRound] +hash = "sha1-3d60b7a1b7bc3cbe8be69e6e714cfbd7b7d537f9" +other = "Запрос займа {{.Amount}}" + +[loanRequestActionNoAmountNoRound] +hash = "sha1-e4d6e764b0b1db536309320874a24b4c4959338b" +other = "Запрос займа" + +[loanIssueAction] +hash = "sha1-3d379be019c3b56497df2dec3d745ad0e5d380a8" +other = "Заём {{.Amount}} валидатору на раунд с {{.Round}}" + +[loanRepaymentAction] +hash = "sha1-c302addb6b3820552191fac6d50d3487f86589e2" +other = "Заём {{.Amount}} погашен, стейкеры заработали {{.Reward}}" + +[poolRoundElection] +hash = "sha1-359508a03cbb4e6fd22da276d0c5971044448257" +other = "Займы на раунд валидации с {{.Round}} распределены" + +[poolRoundBegan] +hash = "sha1-8040e6a6ad559ff686898e199df98b0aa6bd01b3" +other = "Раунд валидации с {{.Round}} начался" + +[poolRoundValidated] +hash = "sha1-65d7ffdec8e38d6d18ab67ef7bb6c36f1a5d781d" +other = "Раунд валидации с {{.Round}} завершён, стейки удерживаются" + +[poolRoundEnded] +hash = "sha1-4bd25ed40dd559c31248ac93fa76fad1a255e265" +other = "Стейки раунда валидации с {{.Round}} освобождены" + +[poolRoundSettled] +hash = "sha1-99e721d3c3f554f301b73e2816712baf3690c388" +other = "Раунд валидации с {{.Round}} рассчитан" diff --git a/pkg/api/loan_converters.go b/pkg/api/loan_converters.go new file mode 100644 index 000000000..cb97ff2d3 --- /dev/null +++ b/pkg/api/loan_converters.go @@ -0,0 +1,173 @@ +package api + +import ( + "time" + + "github.com/tonkeeper/opentonapi/pkg/api/i18n" + "github.com/tonkeeper/opentonapi/pkg/bath" + "github.com/tonkeeper/opentonapi/pkg/oas" + "github.com/tonkeeper/tongo" +) + +var poolRoundTransitionMessages = map[bath.PoolRoundTransitionType]i18n.M{ + bath.PoolRoundElection: {ID: "poolRoundElection", Other: "Loans decided for the validation round starting {{.Round}}"}, + bath.PoolRoundBegan: {ID: "poolRoundBegan", Other: "Validation round starting {{.Round}} began"}, + bath.PoolRoundValidated: {ID: "poolRoundValidated", Other: "Validation round starting {{.Round}} finished, its stakes are held"}, + bath.PoolRoundEnded: {ID: "poolRoundEnded", Other: "Stakes of the validation round starting {{.Round}} released"}, + bath.PoolRoundSettled: {ID: "poolRoundSettled", Other: "Validation round starting {{.Round}} settled"}, +} + +func (h *Handler) convertLoanRequestAction(a *bath.LoanRequestAction, acceptLanguage string, viewer *tongo.AccountID) (oas.OptLoanRequestAction, oas.ActionSimplePreview) { + request := oas.LoanRequestAction{ + Borrower: convertAccountAddress(a.Borrower, h.addressBook), + Pool: convertAccountAddress(a.Pool, h.addressBook), + RoundSince: int64(a.RoundSince), + TonAttached: a.TonAttached, + Implementation: oas.PoolImplementationType(a.Implementation), + } + if a.Amount != nil { + request.Amount = oas.NewOptInt64(*a.Amount) + } + if a.MinPayment != nil { + request.MinPayment = oas.NewOptInt64(*a.MinPayment) + } + var action oas.OptLoanRequestAction + action.SetTo(request) + preview := oas.ActionSimplePreview{ + Name: "Loan Request", + Accounts: distinctAccounts(viewer, h.addressBook, &a.Borrower, &a.Pool), + } + data := i18n.Template{} + if a.Amount != nil { + value := i18n.FormatGrams(*a.Amount) + data["Amount"] = value + preview.Value = oas.NewOptString(value) + } + // a round of unknown start is left out rather than dated 1970 + if a.RoundSince != 0 { + data["Round"] = roundStart(a.RoundSince) + } + var message i18n.M + switch { + case a.Amount == nil && a.RoundSince == 0: + message = i18n.M{ID: "loanRequestActionNoAmountNoRound", Other: "Requesting a loan"} + case a.Amount == nil: + message = i18n.M{ID: "loanRequestActionNoAmount", Other: "Requesting a loan for the validation round starting {{.Round}}"} + case a.RoundSince == 0: + message = i18n.M{ID: "loanRequestActionNoRound", Other: "Requesting a loan of {{.Amount}}"} + default: + message = i18n.M{ID: "loanRequestAction", Other: "Requesting a loan of {{.Amount}} for the validation round starting {{.Round}}"} + } + preview.Description = i18n.T(acceptLanguage, i18n.C{ + DefaultMessage: &message, + TemplateData: data, + }) + return action, preview +} + +func (h *Handler) convertLoanIssueAction(a *bath.LoanIssueAction, acceptLanguage string, viewer *tongo.AccountID) (oas.OptLoanIssueAction, oas.ActionSimplePreview) { + var action oas.OptLoanIssueAction + action.SetTo(oas.LoanIssueAction{ + Borrower: convertAccountAddress(a.Borrower, h.addressBook), + Pool: convertAccountAddress(a.Pool, h.addressBook), + Loan: convertAccountAddress(a.Loan, h.addressBook), + RoundSince: int64(a.RoundSince), + Amount: a.Amount, + Accrued: a.Accrued, + Collateral: a.Collateral, + MinPayment: a.MinPayment, + Implementation: oas.PoolImplementationType(a.Implementation), + }) + value := i18n.FormatGrams(a.Amount + a.Accrued) + return action, oas.ActionSimplePreview{ + Name: "Loan Issue", + Description: i18n.T(acceptLanguage, i18n.C{ + DefaultMessage: &i18n.M{ + ID: "loanIssueAction", + Other: "Lending {{.Amount}} to a validator for the round starting {{.Round}}", + }, + TemplateData: i18n.Template{"Amount": value, "Round": roundStart(a.RoundSince)}, + }), + Value: oas.NewOptString(value), + Accounts: distinctAccounts(viewer, h.addressBook, &a.Pool, &a.Borrower, &a.Loan), + } +} + +func (h *Handler) convertLoanRepaymentAction(a *bath.LoanRepaymentAction, acceptLanguage string, viewer *tongo.AccountID) (oas.OptLoanRepaymentAction, oas.ActionSimplePreview) { + repayment := oas.LoanRepaymentAction{ + Borrower: convertAccountAddress(a.Borrower, h.addressBook), + Pool: convertAccountAddress(a.Pool, h.addressBook), + Loan: convertAccountAddress(a.Loan, h.addressBook), + RoundSince: int64(a.RoundSince), + Amount: a.Amount, + Accrued: a.Accrued, + Recovered: a.Recovered, + PoolReward: a.PoolReward, + ProtocolFee: a.ProtocolFee, + BorrowerPayout: a.BorrowerPayout, + BurnAmount: a.BurnAmount, + Implementation: oas.PoolImplementationType(a.Implementation), + } + if a.Shortfall != nil { + repayment.Shortfall = oas.NewOptInt64(*a.Shortfall) + } + var action oas.OptLoanRepaymentAction + action.SetTo(repayment) + loan := i18n.FormatGrams(a.Amount + a.Accrued) + reward := i18n.FormatGrams(a.PoolReward) + return action, oas.ActionSimplePreview{ + Name: "Loan Repayment", + Description: i18n.T(acceptLanguage, i18n.C{ + DefaultMessage: &i18n.M{ + ID: "loanRepaymentAction", + Other: "Loan of {{.Amount}} repaid, stakers earned {{.Reward}}", + }, + TemplateData: i18n.Template{"Amount": loan, "Reward": reward}, + }), + Value: oas.NewOptString(reward), + Accounts: distinctAccounts(viewer, h.addressBook, &a.Pool, &a.Borrower, &a.Loan), + } +} + +func (h *Handler) convertPoolRoundTransitionAction(a *bath.PoolRoundTransitionAction, acceptLanguage string, viewer *tongo.AccountID) (oas.OptPoolRoundTransitionAction, oas.ActionSimplePreview) { + transition := oas.PoolRoundTransitionAction{ + Pool: convertAccountAddress(a.Pool, h.addressBook), + RoundSince: int64(a.RoundSince), + Transition: oas.PoolRoundTransition(a.Transition), + Implementation: oas.PoolImplementationType(a.Implementation), + } + if a.TotalStaked != nil { + transition.TotalStaked = oas.NewOptInt64(*a.TotalStaked) + } + if a.TotalRecovered != nil { + transition.TotalRecovered = oas.NewOptInt64(*a.TotalRecovered) + } + var action oas.OptPoolRoundTransitionAction + action.SetTo(transition) + preview := oas.ActionSimplePreview{ + Name: "Validation Round", + Accounts: distinctAccounts(viewer, h.addressBook, &a.Pool), + } + // a round of unknown start is left undescribed rather than dated 1970 + if message, ok := poolRoundTransitionMessages[a.Transition]; ok && a.RoundSince != 0 { + preview.Description = i18n.T(acceptLanguage, i18n.C{ + DefaultMessage: &message, + TemplateData: i18n.Template{"Round": roundStart(a.RoundSince)}, + }) + } + if a.TotalStaked != nil && a.TotalRecovered != nil { + preview.Value = oas.NewOptString(formatSignedGrams(*a.TotalRecovered - *a.TotalStaked)) + } + return action, preview +} + +func roundStart(roundSince uint32) string { + return time.Unix(int64(roundSince), 0).UTC().Format("2006-01-02 15:04 UTC") +} + +func formatSignedGrams(amount int64) string { + if amount < 0 { + return "-" + i18n.FormatGrams(-amount) + } + return i18n.FormatGrams(amount) +} diff --git a/pkg/api/loan_converters_test.go b/pkg/api/loan_converters_test.go new file mode 100644 index 000000000..bce184e4f --- /dev/null +++ b/pkg/api/loan_converters_test.go @@ -0,0 +1,81 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/tonkeeper/opentonapi/pkg/addressbook" + "github.com/tonkeeper/opentonapi/pkg/bath" + "github.com/tonkeeper/tongo" + "github.com/tonkeeper/tongo/ton" +) + +func TestConvertPoolRoundTransitionActionValue(t *testing.T) { + h := &Handler{ + addressBook: mockAddressBook{ + OnGetAddressInfoByAddress: func(a tongo.AccountID) (addressbook.KnownAddress, bool) { + return addressbook.KnownAddress{}, false + }, + }, + } + tests := []struct { + name string + staked int64 + recovered int64 + want string + }{ + {name: "gain", staked: 1_000_000_000_000, recovered: 1_234_567_890_123, want: "234 Gram"}, + {name: "loss", staked: 1_234_567_890_123, recovered: 0, want: "-1 234 Gram"}, + {name: "fractional loss", staked: 1_000_000_000, recovered: 500_000_000, want: "-0.5 Gram"}, + {name: "even", staked: 1_000_000_000, recovered: 1_000_000_000, want: "0 Gram"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := &bath.PoolRoundTransitionAction{ + Pool: ton.MustParseAccountID("0:3996a30dd8d4728d84d8596730bff7d47e2c8ef88de888bf2f6c5aef8df134dc"), + Transition: bath.PoolRoundSettled, + TotalStaked: &tt.staked, + TotalRecovered: &tt.recovered, + } + _, preview := h.convertPoolRoundTransitionAction(a, "en", nil) + require.Equal(t, tt.want, preview.Value.Value) + }) + } +} + +func TestConvertLoanRequestActionDescription(t *testing.T) { + h := &Handler{ + addressBook: mockAddressBook{ + OnGetAddressInfoByAddress: func(a tongo.AccountID) (addressbook.KnownAddress, bool) { + return addressbook.KnownAddress{}, false + }, + }, + } + amount := int64(1_000_000_000_000) + tests := []struct { + name string + amount *int64 + roundSince uint32 + lang string + want string + }{ + {name: "amount and round", amount: &amount, roundSince: 1_700_000_000, lang: "en", want: "Requesting a loan of 1 000 Gram for the validation round starting 2023-11-14 22:13 UTC"}, + {name: "round only", roundSince: 1_700_000_000, lang: "en", want: "Requesting a loan for the validation round starting 2023-11-14 22:13 UTC"}, + {name: "amount only", amount: &amount, lang: "en", want: "Requesting a loan of 1 000 Gram"}, + {name: "neither", lang: "en", want: "Requesting a loan"}, + {name: "amount only ru", amount: &amount, lang: "ru", want: "Запрос займа 1 000 Gram"}, + {name: "neither ru", lang: "ru", want: "Запрос займа"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := &bath.LoanRequestAction{ + Borrower: ton.MustParseAccountID("0:3996a30dd8d4728d84d8596730bff7d47e2c8ef88de888bf2f6c5aef8df134dc"), + Pool: ton.MustParseAccountID("0:3996a30dd8d4728d84d8596730bff7d47e2c8ef88de888bf2f6c5aef8df134dc"), + RoundSince: tt.roundSince, + Amount: tt.amount, + } + _, preview := h.convertLoanRequestAction(a, tt.lang, nil) + require.Equal(t, tt.want, preview.Description) + }) + } +} diff --git a/pkg/bath/actions.go b/pkg/bath/actions.go index 9529cc462..c04020078 100644 --- a/pkg/bath/actions.go +++ b/pkg/bath/actions.go @@ -54,6 +54,10 @@ const ( BuyXTR ActionType = "BuyXTR" DepositXTR ActionType = "DepositXTR" WithdrawXTR ActionType = "WithdrawXTR" + LoanRequest ActionType = "LoanRequest" + LoanIssue ActionType = "LoanIssue" + LoanRepayment ActionType = "LoanRepayment" + PoolRoundTransition ActionType = "PoolRoundTransition" Unknown ActionType = "Unknown" ) @@ -112,6 +116,10 @@ type ( BuyXTR *BuyXTRAction `json:",omitempty"` DepositXTR *DepositXTRAction `json:",omitempty"` WithdrawXTR *WithdrawXTRAction `json:",omitempty"` + LoanRequest *LoanRequestAction `json:",omitempty"` + LoanIssue *LoanIssueAction `json:",omitempty"` + LoanRepayment *LoanRepaymentAction `json:",omitempty"` + PoolRoundTransition *PoolRoundTransitionAction `json:",omitempty"` Success bool Type ActionType Error *string `json:",omitempty"` @@ -444,6 +452,14 @@ func (a Action) ContributeToExtra(account tongo.AccountID) int64 { return 0 case WithdrawStake: return detectDirection(account, a.WithdrawStake.Pool, a.WithdrawStake.Staker, a.WithdrawStake.Amount) + case LoanRequest: + return a.LoanRequest.contributeToExtra(account) + case LoanIssue: + return a.LoanIssue.contributeToExtra(account) + case LoanRepayment: + return a.LoanRepayment.contributeToExtra(account) + case PoolRoundTransition: + return a.PoolRoundTransition.contributeToExtra(account) case LiquidityDeposit: extra := int64(0) for _, token := range a.LiquidityDepositAction.Tokens { @@ -488,6 +504,10 @@ func (a Action) IsSubject(account tongo.AccountID) bool { a.RemoveExtension, a.SetSignatureAllowed, a.LiquidityDepositAction, + a.LoanRequest, + a.LoanIssue, + a.LoanRepayment, + a.PoolRoundTransition, } { if i != nil && !reflect.ValueOf(i).IsNil() { return slices.Contains(i.SubjectAccounts(), account) diff --git a/pkg/bath/bath_test.go b/pkg/bath/bath_test.go index 1d6333e48..8fc62e1c6 100644 --- a/pkg/bath/bath_test.go +++ b/pkg/bath/bath_test.go @@ -484,6 +484,24 @@ func TestFindActions(t *testing.T) { "9236ab916e92293a822e1bb701c970de97eac3df18f1a18bfca3a4d3fd049229": { tongo.MustParseBlockID("(0,c000000000000000,88394637)"), }, + "421fa03279386d307ad993e1fc131d2e5de9da85478755b35beb98d037f90f8e": { + tongo.MustParseBlockID("(0,8000000000000000,103773211)"), + }, + "046a4f3214343224ea7406f15767ef4fdbb50ec75e7b8976fc9f0bcbba093a90": { + tongo.MustParseBlockID("(0,8000000000000000,95501781)"), + tongo.MustParseBlockID("(-1,8000000000000000,91096683)"), + }, + "57074455cf497e7cded8d790b8de2d6c7e5d6148252565de1d5eeee25020fc5d": { + tongo.MustParseBlockID("(0,8000000000000000,103852891)"), + }, + "87a89f582a6d372beece6a29aa5d306240065fdfe00897263fd5925d0e248e60": { + tongo.MustParseBlockID("(0,8000000000000000,103852891)"), + }, + "d8d505bceb48b1916f7211faa604168d42f10932ab939f831515c84a2b5d2de6": { + tongo.MustParseBlockID("(0,8000000000000000,103771382)"), + tongo.MustParseBlockID("(-1,8000000000000000,99187813)"), + tongo.MustParseBlockID("(0,8000000000000000,103771385)"), + }, } type Case struct { @@ -1298,6 +1316,40 @@ func TestFindActions(t *testing.T) { filenamePrefix: "hipo-deferred-unstake", hash: "9236ab916e92293a822e1bb701c970de97eac3df18f1a18bfca3a4d3fd049229", }, + { + // A validator asks the treasury for a loan for the next round. + name: "hipo loan request", + filenamePrefix: "hipo-loan-request", + hash: "421fa03279386d307ad993e1fc131d2e5de9da85478755b35beb98d037f90f8e", + }, + { + // Bidding closes: the treasury decides the round's loans and stakes each one, and + // its logs say which borrower each loan is for. A round with one loan: two loans + // staked in one masterchain block send new_stake at the same lt, and the test + // storage then resolves both to the first elector transaction. + name: "hipo round election", + filenamePrefix: "hipo-round-election", + hash: "046a4f3214343224ea7406f15767ef4fdbb50ec75e7b8976fc9f0bcbba093a90", + }, + { + // The validator set changes: the round before stops validating ... + name: "hipo round validated", + filenamePrefix: "hipo-round-validated", + hash: "57074455cf497e7cded8d790b8de2d6c7e5d6148252565de1d5eeee25020fc5d", + }, + { + // ... and, in a second message, the new round begins. + name: "hipo round began", + filenamePrefix: "hipo-round-began", + hash: "87a89f582a6d372beece6a29aa5d306240065fdfe00897263fd5925d0e248e60", + }, + { + // The stakes are released: each loan is repaid and split by its log, the borrower + // fee goes on to the burner, and the round's bills are burned. + name: "hipo round end", + filenamePrefix: "hipo-round-end", + hash: "d8d505bceb48b1916f7211faa604168d42f10932ab939f831515c84a2b5d2de6", + }, } var sharedStorage *litestorage.LiteStorage diff --git a/pkg/bath/bubble.go b/pkg/bath/bubble.go index 0c665a39a..801c045d9 100644 --- a/pkg/bath/bubble.go +++ b/pkg/bath/bubble.go @@ -61,6 +61,7 @@ func fromTrace(trace *core.Trace, parent *core.Trace) *Bubble { external: trace.InMsg == nil || trace.InMsg.IsExternal(), accountWasActiveAtComputingTime: trace.Type != core.OrdinaryTx || trace.ComputePhase == nil || trace.ComputePhase.SkipReason != tlb.ComputeSkipReasonNoState, additionalInfo: trace.AdditionalInfo(), + utime: trace.Utime, } accounts := []tongo.AccountID{trace.Account} @@ -87,6 +88,7 @@ func fromTrace(trace *core.Trace, parent *core.Trace) *Bubble { btx.decodedBody = msg.DecodedBody btx.inputFrom = source btx.init = msg.Init + btx.inputLt = msg.CreatedLt initInterfaces = msg.InitInterfaces } var inputAmount int64 @@ -139,6 +141,10 @@ func fromTrace(trace *core.Trace, parent *core.Trace) *Bubble { } b.Children[i] = fromTrace(c, trace) + if child, ok := b.Children[i].Info.(BubbleTx); ok && len(btx.externalOut) > 0 { + child.senderExternalOut = btx.externalOut + b.Children[i].Info = child + } } if actionPhase := trace.Transaction.ActionPhase; actionPhase != nil { aggregatedFee += int64(actionPhase.FwdFees) diff --git a/pkg/bath/bubble_tx.go b/pkg/bath/bubble_tx.go index b8741e33a..bc91d7922 100644 --- a/pkg/bath/bubble_tx.go +++ b/pkg/bath/bubble_tx.go @@ -21,6 +21,12 @@ type BubbleTx struct { decodedBody *core.DecodedMessageBody init []byte externalOut []core.Message + utime int64 + // inputLt and senderExternalOut let a straw read what the sender logged about this + // message: contracts that log write the log next to the message it describes, in the + // sender's transaction. + inputLt uint64 + senderExternalOut []core.Message additionalInfo *core.TraceAdditionalInfo accountWasActiveAtComputingTime bool diff --git a/pkg/bath/hipo.go b/pkg/bath/hipo.go index 5075c7098..3e072bfd4 100644 --- a/pkg/bath/hipo.go +++ b/pkg/bath/hipo.go @@ -170,6 +170,10 @@ func hipoDepositComment(bubble *Bubble) bool { return HasTextComment("d")(bubble) || HasTextComment("D")(bubble) } +func hipoUnstakeAllComment(bubble *Bubble) bool { + return HasTextComment("w")(bubble) || HasTextComment("W")(bubble) +} + func hipoDepositBuilder(newAction *BubbleDepositStake, bubble *Bubble) error { tx := bubble.Info.(BubbleTx) newAction.Pool = tx.account.Address @@ -269,6 +273,36 @@ var DepositHipoStakeDeferredStraw = Straw[BubbleDepositStake]{ }, } +// WithdrawHipoStakeAllRequestStraw is the request to unstake a whole balance, sent as +// send_unstake_all or as the text comment "w". It names no amount: the wallet burns whatever it +// holds, and that burn and its payout are actions of their own below it. +// +// send_unstake_all -> treasury -> proxy_unstake_all -> parent -> unstake_all -> hGRAM wallet +var WithdrawHipoStakeAllRequestStraw = Straw[BubbleWithdrawStakeRequest]{ + CheckFuncs: []bubbleCheck{IsTx, IsAccount(references.HipoTreasury), Or( + HasOperation(abi.HipoFinanceSendUnstakeAllMsgOp), + hipoUnstakeAllComment, + )}, + Builder: func(newAction *BubbleWithdrawStakeRequest, 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 + } + return nil + }, + SingleChild: &Straw[BubbleWithdrawStakeRequest]{ + CheckFuncs: []bubbleCheck{IsTx, IsAccount(references.HipoParent), HasOperation(abi.HipoFinanceProxyUnstakeAllMsgOp)}, + Optional: true, + SingleChild: &Straw[BubbleWithdrawStakeRequest]{ + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.HipoFinanceUnstakeAllMsgOp)}, + Optional: true, + }, + }, +} + func hipoRolledBack(bubble *Bubble) bool { return HasChild(IsTx, HasOperation(abi.HipoFinanceProxyRollbackUnstakeMsgOp))(bubble) } diff --git a/pkg/bath/hipo_loans.go b/pkg/bath/hipo_loans.go new file mode 100644 index 000000000..6bf4e8b7c --- /dev/null +++ b/pkg/bath/hipo_loans.go @@ -0,0 +1,422 @@ +package bath + +import ( + "math/big" + "slices" + + "github.com/tonkeeper/opentonapi/pkg/core" + "github.com/tonkeeper/opentonapi/pkg/references" + "github.com/tonkeeper/tongo" + "github.com/tonkeeper/tongo/abi" + "github.com/tonkeeper/tongo/tlb" +) + +// Hipo's loans and rounds. The treasury lends to validators each round and reports the +// amounts only in its logs (see hipo_logs.go), so the loan straws are anchored on a log the +// treasury wrote in its own transaction: anyone can send these ops, but only the treasury can +// write its logs. + +const hipoRoundStartedWithin = 65536 / 2 + +// PoolRoundElectionHipoStraw: bidding has closed and the treasury decides the round's loans. +// +// participate_in_election (external) -> treasury -> decide_loan_requests -> treasury -> +// process_loan_requests -> treasury +var PoolRoundElectionHipoStraw = Straw[BubblePoolRoundTransition]{ + CheckFuncs: hipoTreasuryExternal(abi.HipoFinanceParticipateInElectionExtInMsgOp), + Builder: hipoRoundBuilder(PoolRoundElection), +} + +// PoolRoundBeganHipoStraw: the validator set changed. The treasury is told once for each +// round it takes part in: the round that has just begun, and the one before it, whose +// validators have just stopped. Which one this is shows in how long ago the round started. +var PoolRoundBeganHipoStraw = Straw[BubblePoolRoundTransition]{ + CheckFuncs: hipoTreasuryExternal(abi.HipoFinanceVsetChangedExtInMsgOp), + Builder: func(newAction *BubblePoolRoundTransition, bubble *Bubble) error { + if err := hipoRoundBuilder(PoolRoundBegan)(newAction, bubble); err != nil { + return err + } + // The change comes moments after the new round starts and a whole round after the + // old one did, so half a round tells them apart even if the message is hours late. + if newAction.RoundSince != 0 && bubble.Info.(BubbleTx).utime-int64(newAction.RoundSince) > hipoRoundStartedWithin { + newAction.Transition = PoolRoundValidated + } + return nil + }, +} + +// PoolRoundEndedHipoStraw: the stakes are released and the treasury asks for them back. +// +// finish_participation (external) -> treasury -> recover_stakes -> treasury -> +// proxy_recover_stake -> each loan +var PoolRoundEndedHipoStraw = Straw[BubblePoolRoundTransition]{ + CheckFuncs: hipoTreasuryExternal(abi.HipoFinanceFinishParticipationExtInMsgOp), + Builder: hipoRoundBuilder(PoolRoundEnded), +} + +// PoolRoundSettledHipoStraw: every loan is back, and the round's bills are burned. The bills' +// own settlements below stay DepositStake, JettonMint and WithdrawStake actions; the round +// folds in the burning machinery and, from log::finish, the round's totals. +// +// treasury -> burn_all -> collection -> revoke -> bill -> bill_burned -> collection -> +// ... -> last_bill_burned -> treasury +var PoolRoundSettledHipoStraw = Straw[BubblePoolRoundTransition]{ + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.HipoFinanceBurnAllMsgOp), hipoFromTreasury}, + Builder: func(newAction *BubblePoolRoundTransition, bubble *Bubble) error { + tx := bubble.Info.(BubbleTx) + newAction.Pool = references.HipoTreasury + newAction.Transition = PoolRoundSettled + newAction.Implementation = core.StakingImplementationHipo + newAction.Success = tx.success + newAction.add(bubble) + // When burn_all is sent on recovering the round's last loan, that loan's repayment log + // names the round. last_bill_burned names it too, but may never come. + for _, msg := range tx.senderExternalOut { + if _, value, ok := core.DecodeHipoLog(msg); ok { + if log, ok := value.(core.HipoRepaymentLog); ok { + newAction.RoundSince = log.RoundSince + } + } + } + return nil + }, + SingleChild: hipoOptionalLastBill(), +} + +// LoanRequestHipoStraw: a validator asks for a loan. The treasury accepts any of the request +// layouts it has used, and keeps the coins sent with it as collateral. +var LoanRequestHipoStraw = Straw[BubbleLoanRequest]{ + CheckFuncs: []bubbleCheck{IsTx, IsAccount(references.HipoTreasury), HasAnyOperation( + abi.HipoFinanceRequestLoanMsgOp, abi.HipoFinanceRequestLoanV2MsgOp, + abi.HipoFinanceRequestLoanV3MsgOp, abi.HipoFinanceRequestLoanV4MsgOp, + )}, + Builder: func(newAction *BubbleLoanRequest, bubble *Bubble) error { + tx := bubble.Info.(BubbleTx) + newAction.Pool = tx.account.Address + newAction.Implementation = core.StakingImplementationHipo + newAction.TonAttached = tx.inputAmount + newAction.Success = tx.success + newAction.add(bubble) + if tx.inputFrom != nil { + newAction.Borrower = tx.inputFrom.Address + } + switch body := tx.decodedBody.Value.(type) { + case abi.HipoFinanceRequestLoanMsgBody: + newAction.RoundSince, newAction.Amount, newAction.MinPayment = body.RoundSince, hipoVarCoins(body.LoanAmount), hipoVarCoins(body.MinPayment) + case abi.HipoFinanceRequestLoanV2MsgBody: + newAction.RoundSince, newAction.Amount, newAction.MinPayment = body.RoundSince, hipoVarCoins(body.LoanAmount), hipoVarCoins(body.MinPayment) + case abi.HipoFinanceRequestLoanV3MsgBody: + newAction.RoundSince, newAction.Amount, newAction.MinPayment = body.RoundSince, hipoVarCoins(body.LoanAmount), hipoVarCoins(body.MinPayment) + case abi.HipoFinanceRequestLoanV4MsgBody: + newAction.RoundSince, newAction.Amount, newAction.MinPayment = body.RoundSince, hipoVarCoins(body.LoanAmount), hipoVarCoins(body.MinPayment) + } + return nil + }, + SingleChild: &Straw[BubbleLoanRequest]{ + CheckFuncs: []bubbleCheck{IsTx, IsBounced}, + Builder: addTransfer[BubbleLoanRequest], + Optional: true, + }, +} + +// LoanIssueHipoStraw: the treasury lends, and the loan contract stakes it. The elector leg +// below stays an ElectionsDepositStake. +// +// treasury -> proxy_new_stake -> loan -> new_stake -> elector +var LoanIssueHipoStraw = Straw[BubbleLoanIssue]{ + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.HipoFinanceProxyNewStakeMsgOp), func(bubble *Bubble) bool { + _, _, ok := hipoIssueLog(bubble.Info.(BubbleTx)) + return ok + }}, + Builder: func(newAction *BubbleLoanIssue, bubble *Bubble) error { + tx := bubble.Info.(BubbleTx) + log, borrower, _ := hipoIssueLog(tx) + newAction.Borrower = borrower + newAction.Pool = references.HipoTreasury + newAction.Loan = tx.account.Address + newAction.Implementation = core.StakingImplementationHipo + newAction.Success = tx.success + newAction.add(bubble) + newAction.RoundSince = log.RoundSince + newAction.Amount = int64(log.LoanAmount) + newAction.Accrued = int64(log.AccrueAmount) + newAction.Collateral = int64(log.StakeAmount) + newAction.MinPayment = int64(log.MinPayment) + hipoCredit(bubble, borrower) + return nil + }, + SingleChild: &Straw[BubbleLoanIssue]{ + CheckFuncs: []bubbleCheck{Is(BubbleContractDeploy{})}, + Optional: true, + }, +} + +// LoanRepaymentHipoStraw: a loan is back from the elector and the treasury splits it, by +// log::repayment, between the stakers, the governor, the burner and the borrower. +// +// loan -> recover_stake_result -> treasury -> loan_result -> borrower +// -> take_borrower_fee -> burner +// -> take_profit -> governor +var LoanRepaymentHipoStraw = Straw[BubbleLoanRepayment]{ + CheckFuncs: []bubbleCheck{IsTx, IsAccount(references.HipoTreasury), HasOperation(abi.HipoFinanceRecoverStakeResultMsgOp), func(bubble *Bubble) bool { + _, _, ok := hipoRepaymentLog(hipoLogs(bubble)) + return ok + }}, + Builder: func(newAction *BubbleLoanRepayment, bubble *Bubble) error { + tx := bubble.Info.(BubbleTx) + logs := hipoLogs(bubble) + log, borrower, _ := hipoRepaymentLog(logs) + newAction.Borrower = borrower + newAction.Pool = tx.account.Address + newAction.Implementation = core.StakingImplementationHipo + newAction.Success = tx.success + newAction.add(bubble) + if tx.inputFrom != nil { + newAction.Loan = tx.inputFrom.Address + } + newAction.RoundSince = log.RoundSince + newAction.Amount = int64(log.LoanAmount) + newAction.Accrued = int64(log.AccrueAmount) + newAction.Recovered = int64(log.RepaymentAmount) + newAction.PoolReward = int64(log.StakersShare) + newAction.ProtocolFee = int64(log.GovernorShare) + newAction.BorrowerPayout = int64(log.BorrowerShare) + newAction.BurnAmount = int64(log.BurnShare) + hipoCredit(bubble, borrower) + for _, value := range logs[core.HipoLogDeficit] { + if deficit, ok := value.(core.HipoDeficitLog); ok { + shortfall := int64(deficit.Shortfall) + newAction.Shortfall = &shortfall + } + } + return nil + }, + Children: []Straw[BubbleLoanRepayment]{ + { + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.HipoFinanceLoanResultMsgOp), hipoFromTreasury}, + Builder: addTransfer[BubbleLoanRepayment], + Optional: true, + }, + { + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.HipoFinanceTakeBorrowerFeeMsgOp), hipoFromTreasury}, + Builder: addTransfer[BubbleLoanRepayment], + Optional: true, + }, + { + CheckFuncs: []bubbleCheck{IsTx, HasOperation(abi.HipoFinanceTakeProfitMsgOp), hipoFromTreasury}, + Builder: addTransfer[BubbleLoanRepayment], + Optional: true, + }, + }, +} + +// hipoRoundElectionStepStraw folds the treasury's messages to itself into the election round, +// one per pass: there are as many as the round's loans need, since each one stops at a gas +// budget and sends the next. +var hipoRoundElectionStepStraw = Straw[BubblePoolRoundTransition]{ + CheckFuncs: []bubbleCheck{hipoRoundIs(PoolRoundElection)}, + Builder: hipoKeepRound, + SingleChild: &Straw[BubblePoolRoundTransition]{ + CheckFuncs: []bubbleCheck{IsTx, IsSuccess, IsAccount(references.HipoTreasury), hipoFromTreasury, HasAnyOperation(abi.HipoFinanceDecideLoanRequestsMsgOp, abi.HipoFinanceProcessLoanRequestsMsgOp)}, + Builder: addTransfer[BubblePoolRoundTransition], + }, +} + +// hipoRoundEndedStepStraw folds the treasury's recover_stakes messages to itself into the ended +// round, one per pass, the same way. +var hipoRoundEndedStepStraw = Straw[BubblePoolRoundTransition]{ + CheckFuncs: []bubbleCheck{hipoRoundIs(PoolRoundEnded)}, + Builder: hipoKeepRound, + SingleChild: &Straw[BubblePoolRoundTransition]{ + CheckFuncs: []bubbleCheck{IsTx, IsSuccess, IsAccount(references.HipoTreasury), hipoFromTreasury, HasOperation(abi.HipoFinanceRecoverStakesMsgOp)}, + Builder: addTransfer[BubblePoolRoundTransition], + }, +} + +// hipoRoundRecoverStraw folds each loan's proxy_recover_stake into the round that asked for +// it; the elector leg below it stays an ElectionsRecoverStake. +var hipoRoundRecoverStraw = Straw[BubblePoolRoundTransition]{ + CheckFuncs: []bubbleCheck{hipoRoundIs(PoolRoundEnded)}, + Builder: hipoKeepRound, + SingleChild: &Straw[BubblePoolRoundTransition]{ + CheckFuncs: []bubbleCheck{IsTx, IsSuccess, HasOperation(abi.HipoFinanceProxyRecoverStakeMsgOp), hipoFromTreasury}, + Builder: addTransfer[BubblePoolRoundTransition], + }, +} + +// hipoRoundBillStraw folds one burned bill into the settled round, one per pass. +var hipoRoundBillStraw = Straw[BubblePoolRoundTransition]{ + CheckFuncs: []bubbleCheck{hipoRoundIs(PoolRoundSettled)}, + Builder: hipoKeepRound, + SingleChild: &Straw[BubblePoolRoundTransition]{ + CheckFuncs: []bubbleCheck{IsTx, IsSuccess, HasOperation(abi.SbtRevokeMsgOp)}, + Builder: addTransfer[BubblePoolRoundTransition], + SingleChild: &Straw[BubblePoolRoundTransition]{ + CheckFuncs: []bubbleCheck{IsTx, IsSuccess, HasOperation(abi.HipoFinanceBillBurnedMsgOp)}, + Builder: addTransfer[BubblePoolRoundTransition], + SingleChild: hipoOptionalLastBill(), + }, + }, +} + +// hipoRoundMintStraw folds the treasury's half of a deposit bill's settlement into the settled +// round, one per pass. The hGRAM it mints stays a JettonMint, from JettonMintHipoStraw. +// +// bill_burned -> collection -> mint_tokens -> treasury -> proxy_tokens_minted -> parent -> +// tokens_minted -> hGRAM wallet +var hipoRoundMintStraw = Straw[BubblePoolRoundTransition]{ + CheckFuncs: []bubbleCheck{hipoRoundIs(PoolRoundSettled)}, + Builder: hipoKeepRound, + SingleChild: &Straw[BubblePoolRoundTransition]{ + CheckFuncs: []bubbleCheck{IsTx, IsSuccess, IsAccount(references.HipoTreasury), HasOperation(abi.HipoFinanceMintTokensMsgOp)}, + Builder: addTransfer[BubblePoolRoundTransition], + SingleChild: &Straw[BubblePoolRoundTransition]{ + CheckFuncs: []bubbleCheck{IsTx, IsSuccess, IsAccount(references.HipoParent), HasOperation(abi.HipoFinanceProxyTokensMintedMsgOp)}, + Builder: addTransfer[BubblePoolRoundTransition], + }, + }, +} + +var hipoLastBillBurned = Straw[BubblePoolRoundTransition]{ + CheckFuncs: []bubbleCheck{IsTx, IsAccount(references.HipoTreasury), HasOperation(abi.HipoFinanceLastBillBurnedMsgOp)}, + Builder: func(newAction *BubblePoolRoundTransition, bubble *Bubble) error { + tx := bubble.Info.(BubbleTx) + newAction.add(bubble) + if body, ok := tx.decodedBody.Value.(abi.HipoFinanceLastBillBurnedMsgBody); ok { + newAction.RoundSince = body.RoundSince + } + for _, value := range hipoLogs(bubble)[core.HipoLogFinish] { + if log, ok := value.(core.HipoFinishLog); ok { + staked, recovered := int64(log.TotalStaked), int64(log.TotalRecovered) + newAction.RoundSince = log.RoundSince + newAction.TotalStaked = &staked + newAction.TotalRecovered = &recovered + } + } + return nil + }, +} + +func hipoTreasuryExternal(op abi.MsgOpName) []bubbleCheck { + return []bubbleCheck{IsTx, IsAccount(references.HipoTreasury), HasOperation(op), IsExternal} +} + +func hipoRoundBuilder(transition PoolRoundTransitionType) func(*BubblePoolRoundTransition, *Bubble) error { + return func(newAction *BubblePoolRoundTransition, bubble *Bubble) error { + tx := bubble.Info.(BubbleTx) + newAction.Pool = tx.account.Address + newAction.Transition = transition + newAction.Implementation = core.StakingImplementationHipo + newAction.Success = tx.success + switch body := tx.decodedBody.Value.(type) { + case abi.HipoFinanceParticipateInElectionExtInMsgBody: + newAction.RoundSince = body.RoundSince + case abi.HipoFinanceVsetChangedExtInMsgBody: + newAction.RoundSince = body.RoundSince + case abi.HipoFinanceFinishParticipationExtInMsgBody: + newAction.RoundSince = body.RoundSince + } + return nil + } +} + +func hipoRoundIs(transitions ...PoolRoundTransitionType) bubbleCheck { + return func(bubble *Bubble) bool { + round, ok := bubble.Info.(BubblePoolRoundTransition) + if !ok || round.Implementation != core.StakingImplementationHipo { + return false + } + return slices.Contains(transitions, round.Transition) + } +} + +func hipoKeepRound(newAction *BubblePoolRoundTransition, bubble *Bubble) error { + *newAction = bubble.Info.(BubblePoolRoundTransition) + // the transfers folded in next must not write into the kept round's backing array + newAction.transfers = slices.Clip(newAction.transfers) + return nil +} + +func hipoOptionalLastBill() *Straw[BubblePoolRoundTransition] { + s := Optional(hipoLastBillBurned) + return &s +} + +func hipoIssueLog(tx BubbleTx) (core.HipoLoanLog, tongo.AccountID, bool) { + // The treasury writes the loan's log::loan straight after sending its proxy_new_stake, in + // the same transaction. The log names the borrower and the amounts; the message names + // neither. + if tx.inputFrom == nil || tx.inputFrom.Address != references.HipoTreasury { + return core.HipoLoanLog{}, tongo.AccountID{}, false + } + for _, msg := range tx.senderExternalOut { + if msg.CreatedLt != tx.inputLt+1 { + continue + } + topic, value, ok := core.DecodeHipoLog(msg) + if !ok || topic != core.HipoLogLoan { + continue + } + log, ok := value.(core.HipoLoanLog) + if !ok { + return core.HipoLoanLog{}, tongo.AccountID{}, false + } + borrower, ok := hipoOwner(log.Borrower) + return log, borrower, ok + } + return core.HipoLoanLog{}, tongo.AccountID{}, false +} + +func hipoLogs(bubble *Bubble) map[core.HipoLogTopic][]any { + // Only the treasury's own transaction is read: the topic is an address, so any contract + // may emit a message that looks like one of these, and a log is only Hipo's if Hipo's + // treasury sent it. + tx, ok := bubble.Info.(BubbleTx) + if !ok || tx.account.Address != references.HipoTreasury { + return nil + } + var found map[core.HipoLogTopic][]any + for _, msg := range tx.externalOut { + topic, value, ok := core.DecodeHipoLog(msg) + if !ok { + continue + } + if found == nil { + found = make(map[core.HipoLogTopic][]any, 2) + } + found[topic] = append(found[topic], value) + } + return found +} + +func hipoRepaymentLog(logs map[core.HipoLogTopic][]any) (core.HipoRepaymentLog, tongo.AccountID, bool) { + repayments := logs[core.HipoLogRepayment] + if len(repayments) != 1 { + return core.HipoRepaymentLog{}, tongo.AccountID{}, false + } + log, ok := repayments[0].(core.HipoRepaymentLog) + if !ok { + return core.HipoRepaymentLog{}, tongo.AccountID{}, false + } + borrower, ok := hipoOwner(log.Borrower) + return log, borrower, ok +} + +func hipoVarCoins(c tlb.VarUInteger16) *int64 { + // A request may claim any 120-bit number; one past int64 is reported as unknown rather + // than wrapped, and the treasury refuses it anyway. + v := big.Int(c) + if !v.IsInt64() { + return nil + } + n := v.Int64() + return &n +} + +func addTransfer[T any, P interface { + *T + add(*Bubble) +}](newAction P, bubble *Bubble) error { + newAction.add(bubble) + return nil +} diff --git a/pkg/bath/hipo_loans_test.go b/pkg/bath/hipo_loans_test.go new file mode 100644 index 000000000..8f4f308ae --- /dev/null +++ b/pkg/bath/hipo_loans_test.go @@ -0,0 +1,288 @@ +package bath + +import ( + "encoding/hex" + "math/big" + "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" + "github.com/tonkeeper/tongo/boc" + "github.com/tonkeeper/tongo/tlb" +) + +// Real logs, the same ones pkg/core's parser tests read. +const ( + hipoLoanLogBody = "b5ee9c720101010100460000876a9e4f0850ba43b740007077038d7ea4c68000708f0db1157feef523aff0d8e07fff801a6d1cefedb5ca6bcc88f0a7300745ee3e0fbaf722aa9040af454ee47dc2d130d0" + hipoRepaymentLogBody = "b5ee9c7201010101004b0000916a944f08707268034320633703328b944c4000703f288f2fa449a601375194ce1c05344a2f57fd8007b591d71001a927c6fc6b00e107ffea04e4a60f955f4f6c6475b5d6c02703bcb0" + hipoFinishLogBody = "b5ee9c720101010100240000446a944f0870e4a290e8d093470e4c97b1b6a56c71c940a9e703cb07189eaf16dfb68a" + // when each was written: the loan after the 2026-09-05 release, the repayment before it + hipoLoanLogAt = 1788761000 + hipoRepaymentLogAt = 1788200000 +) + +func TestHipoLoanStraws(t *testing.T) { + loan := tongo.MustParseAccountID("-1:1e530983ee3f8be8529cc6c03199953f9fe9b46f7845219f78e888968e9c54ea") + t.Run("a loan is issued from the log written next to its message", func(t *testing.T) { + issue := hipoSent(hipoFrom(hipoTx(loan, abi.HipoFinanceProxyNewStakeMsgOp), references.HipoTreasury), 100, + hipoLogMsg(t, core.HipoLogLoan, 99, hipoLoanLogBody), + hipoLogMsg(t, core.HipoLogLoan, 101, hipoLoanLogBody), + ) + require.True(t, LoanIssueHipoStraw.Merge(issue)) + issued := issue.Info.(BubbleLoanIssue) + want := hipoDecodedLog(t, core.HipoLogLoan, hipoLoanLogBody).(core.HipoLoanLog) + require.Equal(t, int64(want.LoanAmount), issued.Amount) + require.Equal(t, loan, issued.Loan) + require.Equal(t, references.HipoTreasury, issued.Pool) + require.Contains(t, issue.Accounts, issued.Borrower, "the borrower sees the loan in their history") + }) + t.Run("a loan with no log of its own is not issued", func(t *testing.T) { + issue := hipoSent(hipoFrom(hipoTx(loan, abi.HipoFinanceProxyNewStakeMsgOp), references.HipoTreasury), 100, + hipoLogMsg(t, core.HipoLogLoan, 99, hipoLoanLogBody), + ) + require.False(t, LoanIssueHipoStraw.Merge(issue)) + }) + t.Run("a repayment is split by its log", func(t *testing.T) { + result := hipoLogged(hipoFrom(hipoTx(references.HipoTreasury, abi.HipoFinanceRecoverStakeResultMsgOp, + hipoFrom(hipoTx(loan, abi.HipoFinanceLoanResultMsgOp), references.HipoTreasury), + ), loan), hipoLogMsg(t, core.HipoLogRepayment, 1, hipoRepaymentLogBody)) + require.True(t, LoanRepaymentHipoStraw.Merge(result)) + repaid := result.Info.(BubbleLoanRepayment) + want := hipoDecodedLog(t, core.HipoLogRepayment, hipoRepaymentLogBody).(core.HipoRepaymentLog) + require.Equal(t, int64(want.StakersShare), repaid.PoolReward) + require.Equal(t, int64(want.BorrowerShare), repaid.BorrowerPayout) + require.Equal(t, loan, repaid.Loan) + require.Empty(t, result.Children, "loan_result is part of the repayment") + }) + t.Run("the settled round carries the round's totals", func(t *testing.T) { + collection := tongo.MustParseAccountID("0:7e40f97d00000000000000000000000000000000000000000000000000000000") + burn := hipoFrom(hipoTx(collection, abi.HipoFinanceBurnAllMsgOp, + hipoLogged(hipoTx(references.HipoTreasury, abi.HipoFinanceLastBillBurnedMsgOp), + hipoLogMsg(t, core.HipoLogFinish, 1, hipoFinishLogBody)), + ), references.HipoTreasury) + require.True(t, PoolRoundSettledHipoStraw.Merge(burn)) + settled := burn.Info.(BubblePoolRoundTransition) + want := hipoDecodedLog(t, core.HipoLogFinish, hipoFinishLogBody).(core.HipoFinishLog) + require.Equal(t, PoolRoundSettled, settled.Transition) + require.Equal(t, want.RoundSince, settled.RoundSince) + require.Equal(t, int64(want.TotalRecovered), *settled.TotalRecovered) + }) + t.Run("the round folds in the treasury's own steps", func(t *testing.T) { + round := hipoExternal(hipoTx(references.HipoTreasury, abi.HipoFinanceParticipateInElectionExtInMsgOp, + hipoFrom(hipoTx(references.HipoTreasury, abi.HipoFinanceDecideLoanRequestsMsgOp, + hipoFrom(hipoTx(references.HipoTreasury, abi.HipoFinanceProcessLoanRequestsMsgOp, + hipoFrom(hipoTx(loan, abi.HipoFinanceProxyNewStakeMsgOp), references.HipoTreasury), + ), references.HipoTreasury), + ), references.HipoTreasury), + ), abi.HipoFinanceParticipateInElectionExtInMsgBody{RoundSince: 1792102152}) + MergeAllBubbles(round, []Merger{PoolRoundElectionHipoStraw, hipoRoundElectionStepStraw}) + election := round.Info.(BubblePoolRoundTransition) + require.Equal(t, PoolRoundElection, election.Transition) + require.Equal(t, uint32(1792102152), election.RoundSince) + require.Len(t, round.Children, 1, "only the loan is left beside the round") + }) + t.Run("a failed step is not folded into the round", func(t *testing.T) { + failed := hipoFrom(hipoTx(references.HipoTreasury, abi.HipoFinanceProcessLoanRequestsMsgOp), references.HipoTreasury) + tx := failed.Info.(BubbleTx) + tx.success = false + failed.Info = tx + round := hipoExternal(hipoTx(references.HipoTreasury, abi.HipoFinanceParticipateInElectionExtInMsgOp, + hipoFrom(hipoTx(references.HipoTreasury, abi.HipoFinanceDecideLoanRequestsMsgOp, failed), references.HipoTreasury), + ), abi.HipoFinanceParticipateInElectionExtInMsgBody{RoundSince: 1792102152}) + MergeAllBubbles(round, []Merger{PoolRoundElectionHipoStraw, hipoRoundElectionStepStraw}) + require.True(t, round.Info.(BubblePoolRoundTransition).Success) + require.Len(t, round.Children, 1, "the failed step stays beside the round") + require.Equal(t, failed, round.Children[0]) + }) + t.Run("a vset change with an unread body is not taken for the old round", func(t *testing.T) { + round := hipoExternal(hipoTx(references.HipoTreasury, abi.HipoFinanceVsetChangedExtInMsgOp), nil) + tx := round.Info.(BubbleTx) + tx.utime = 1792102152 + round.Info = tx + require.True(t, PoolRoundBeganHipoStraw.Merge(round)) + require.Equal(t, PoolRoundBegan, round.Info.(BubblePoolRoundTransition).Transition) + }) +} + +func TestHipoLoanStrawsRejectForgeries(t *testing.T) { + // Every loan action names a borrower and an amount, so each is anchored on something only + // the treasury can produce: its own logs, or its own transaction. + attacker := tongo.MustParseAccountID("0:1111111111111111111111111111111111111111111111111111111111111111") + t.Run("a log next to a message the treasury did not send", func(t *testing.T) { + forged := hipoSent(hipoFrom(hipoTx(attacker, abi.HipoFinanceProxyNewStakeMsgOp), attacker), 100, + hipoLogMsg(t, core.HipoLogLoan, 101, hipoLoanLogBody)) + require.False(t, LoanIssueHipoStraw.Merge(forged)) + }) + t.Run("a repayment log from another contract", func(t *testing.T) { + forged := hipoLogged(hipoFrom(hipoTx(attacker, abi.HipoFinanceRecoverStakeResultMsgOp), attacker), + hipoLogMsg(t, core.HipoLogRepayment, 1, hipoRepaymentLogBody)) + require.False(t, LoanRepaymentHipoStraw.Merge(forged)) + }) + t.Run("a recover_stake_result the treasury did not log", func(t *testing.T) { + forged := hipoFrom(hipoTx(references.HipoTreasury, abi.HipoFinanceRecoverStakeResultMsgOp), attacker) + require.False(t, LoanRepaymentHipoStraw.Merge(forged)) + }) + t.Run("a round trigger at another contract", func(t *testing.T) { + forged := hipoExternal(hipoTx(attacker, abi.HipoFinanceFinishParticipationExtInMsgOp), + abi.HipoFinanceFinishParticipationExtInMsgBody{}) + require.False(t, PoolRoundEndedHipoStraw.Merge(forged)) + }) + t.Run("a burn_all the treasury did not send", func(t *testing.T) { + forged := hipoFrom(hipoTx(attacker, abi.HipoFinanceBurnAllMsgOp), attacker) + require.False(t, PoolRoundSettledHipoStraw.Merge(forged)) + }) + t.Run("a loan request at another contract", func(t *testing.T) { + forged := hipoFrom(hipoTx(attacker, abi.HipoFinanceRequestLoanV4MsgOp), attacker) + require.False(t, LoanRequestHipoStraw.Merge(forged)) + }) +} + +func TestHipoLoanActionsAccountForTheirTransfers(t *testing.T) { + loan := tongo.MustParseAccountID("-1:1e530983ee3f8be8529cc6c03199953f9fe9b46f7845219f78e888968e9c54ea") + borrower := tongo.MustParseAccountID("0:2222222222222222222222222222222222222222222222222222222222222222") + governor := tongo.MustParseAccountID("0:3333333333333333333333333333333333333333333333333333333333333333") + collection := tongo.MustParseAccountID("0:4444444444444444444444444444444444444444444444444444444444444444") + t.Run("a repayment counts every leg it absorbs", func(t *testing.T) { + result := hipoLogged(hipoPaid(hipoFrom(hipoTx(references.HipoTreasury, abi.HipoFinanceRecoverStakeResultMsgOp, + hipoPaid(hipoFrom(hipoTx(borrower, abi.HipoFinanceLoanResultMsgOp), references.HipoTreasury), 30), + hipoPaid(hipoFrom(hipoTx(references.HipoBurner, abi.HipoFinanceTakeBorrowerFeeMsgOp), references.HipoTreasury), 5), + hipoPaid(hipoFrom(hipoTx(governor, abi.HipoFinanceTakeProfitMsgOp), references.HipoTreasury), 7), + ), loan), 1000), hipoLogMsg(t, core.HipoLogRepayment, 1, hipoRepaymentLogBody)) + require.True(t, LoanRepaymentHipoStraw.Merge(result)) + action := result.Info.(BubbleLoanRepayment).ToAction() + require.Equal(t, int64(-1000), action.ContributeToExtra(loan)) + require.Equal(t, int64(1000-30-5-7), action.ContributeToExtra(references.HipoTreasury)) + require.Equal(t, int64(30), action.ContributeToExtra(borrower)) + require.Equal(t, int64(5), action.ContributeToExtra(references.HipoBurner)) + require.Equal(t, int64(7), action.ContributeToExtra(governor)) + }) + t.Run("a refused request counts its refund", func(t *testing.T) { + request := hipoPaid(hipoFrom(hipoTx(references.HipoTreasury, abi.HipoFinanceRequestLoanV4MsgOp, + hipoBounced(hipoPaid(hipoFrom(hipoTx(borrower, abi.HipoFinanceRequestLoanV4MsgOp), references.HipoTreasury), 999)), + ), borrower), 1000) + require.True(t, LoanRequestHipoStraw.Merge(request)) + action := request.Info.(BubbleLoanRequest).ToAction() + require.Equal(t, int64(-1), action.ContributeToExtra(borrower)) + require.Equal(t, int64(1), action.ContributeToExtra(references.HipoTreasury)) + }) + t.Run("a round counts the stakes it asks back and the bills it burns", func(t *testing.T) { + round := hipoExternal(hipoTx(references.HipoTreasury, abi.HipoFinanceFinishParticipationExtInMsgOp, + hipoFrom(hipoTx(references.HipoTreasury, abi.HipoFinanceRecoverStakesMsgOp, + hipoPaid(hipoFrom(hipoTx(loan, abi.HipoFinanceProxyRecoverStakeMsgOp), references.HipoTreasury), 2), + ), references.HipoTreasury), + ), abi.HipoFinanceFinishParticipationExtInMsgBody{RoundSince: 1792102152}) + MergeAllBubbles(round, []Merger{PoolRoundEndedHipoStraw, hipoRoundEndedStepStraw, hipoRoundRecoverStraw}) + action := round.Info.(BubblePoolRoundTransition).ToAction() + require.Equal(t, int64(-2), action.ContributeToExtra(references.HipoTreasury)) + require.Equal(t, int64(2), action.ContributeToExtra(loan)) + burn := hipoPaid(hipoFrom(hipoTx(collection, abi.HipoFinanceBurnAllMsgOp, + hipoPaid(hipoFrom(hipoTx(references.HipoTreasury, abi.HipoFinanceLastBillBurnedMsgOp), collection), 3), + ), references.HipoTreasury), 4) + require.True(t, PoolRoundSettledHipoStraw.Merge(burn)) + action = burn.Info.(BubblePoolRoundTransition).ToAction() + require.Equal(t, int64(-4+3), action.ContributeToExtra(references.HipoTreasury)) + require.Equal(t, int64(4-3), action.ContributeToExtra(collection)) + }) +} + +func TestHipoLoanStrawsSkipUnreadableLogs(t *testing.T) { + loan := tongo.MustParseAccountID("-1:1e530983ee3f8be8529cc6c03199953f9fe9b46f7845219f78e888968e9c54ea") + t.Run("a repayment log that does not fit its layout", func(t *testing.T) { + log := hipoLogMsg(t, core.HipoLogRepayment, 1, hipoRepaymentLogBody) + log.CreatedAt = 0 + result := hipoLogged(hipoFrom(hipoTx(references.HipoTreasury, abi.HipoFinanceRecoverStakeResultMsgOp), loan), log) + require.False(t, LoanRepaymentHipoStraw.Merge(result)) + }) + t.Run("a loan log that names no borrower", func(t *testing.T) { + cell := boc.NewCell() + require.NoError(t, tlb.Marshal(cell, core.HipoLoanLog{RoundSince: 1, Borrower: tlb.MsgAddress{SumType: "AddrNone"}})) + body, err := cell.ToBoc() + require.NoError(t, err) + log := hipoLogMsg(t, core.HipoLogLoan, 101, hipoLoanLogBody) + log.Body = body + issue := hipoSent(hipoFrom(hipoTx(loan, abi.HipoFinanceProxyNewStakeMsgOp), references.HipoTreasury), 100, log) + require.False(t, LoanIssueHipoStraw.Merge(issue)) + }) +} + +func TestHipoSettledRoundIsNamedByTheLastRepayment(t *testing.T) { + collection := tongo.MustParseAccountID("0:4444444444444444444444444444444444444444444444444444444444444444") + burn := hipoSent(hipoFrom(hipoTx(collection, abi.HipoFinanceBurnAllMsgOp), references.HipoTreasury), 10, + hipoLogMsg(t, core.HipoLogRepayment, 11, hipoRepaymentLogBody)) + require.True(t, PoolRoundSettledHipoStraw.Merge(burn)) + want := hipoDecodedLog(t, core.HipoLogRepayment, hipoRepaymentLogBody).(core.HipoRepaymentLog) + require.Equal(t, want.RoundSince, burn.Info.(BubblePoolRoundTransition).RoundSince) +} + +func hipoLogMsg(t *testing.T, topic core.HipoLogTopic, lt uint64, body string) core.Message { + // built the way emit_log does: the topic in a 256-bit external destination + at := uint32(hipoLoanLogAt) + if topic == core.HipoLogRepayment { + at = hipoRepaymentLogAt + } + t.Helper() + dest := boc.NewBitString(256) + require.NoError(t, dest.WriteBigUint(new(big.Int).SetUint64(uint64(topic)), 256)) + return core.Message{ + MessageID: core.MessageID{CreatedLt: lt}, + CreatedAt: at, + MsgType: core.ExtOutMsg, + DestinationExtern: &dest, + Body: mustHex(t, body), + } +} + +func hipoDecodedLog(t *testing.T, topic core.HipoLogTopic, body string) any { + t.Helper() + decoded, value, ok := core.DecodeHipoLog(hipoLogMsg(t, topic, 0, body)) + require.True(t, ok) + require.Equal(t, topic, decoded) + return value +} + +func hipoLogged(b *Bubble, logs ...core.Message) *Bubble { + tx := b.Info.(BubbleTx) + tx.externalOut = logs + b.Info = tx + return b +} + +func hipoSent(b *Bubble, lt uint64, logs ...core.Message) *Bubble { + tx := b.Info.(BubbleTx) + tx.inputLt = lt + tx.senderExternalOut = logs + b.Info = tx + return b +} + +func hipoExternal(b *Bubble, body any) *Bubble { + tx := b.Info.(BubbleTx) + tx.external = true + tx.decodedBody.Value = body + b.Info = tx + return b +} + +func hipoPaid(b *Bubble, amount int64) *Bubble { + tx := b.Info.(BubbleTx) + tx.inputAmount = amount + b.Info = tx + return b +} + +func hipoBounced(b *Bubble) *Bubble { + tx := b.Info.(BubbleTx) + tx.bounced = true + b.Info = tx + return b +} + +func mustHex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + require.NoError(t, err) + return b +} diff --git a/pkg/bath/hipo_owner_test.go b/pkg/bath/hipo_owner_test.go index ad5db2400..3e30dac79 100644 --- a/pkg/bath/hipo_owner_test.go +++ b/pkg/bath/hipo_owner_test.go @@ -243,13 +243,14 @@ func TestHipoOwnerSeesTheirOwnFlow(t *testing.T) { { name: "unstake-all by comment, paid now", trace: hipoUnstakeAll(hipoPayout()), - want: []string{"JettonBurn", "WithdrawStake"}, + // the request names no amount, the burn and the payout below it do + want: []string{"WithdrawStakeRequest", "JettonBurn", "WithdrawStake"}, hgram: -hipoTestTokens, }, { name: "unstake-all by comment, deferred", trace: hipoUnstakeAll(hipoBill(references.HipoTreasury, 0)), - want: []string{"JettonBurn", "WithdrawStakeRequest"}, + want: []string{"WithdrawStakeRequest", "JettonBurn", "WithdrawStakeRequest"}, hgram: -hipoTestTokens, }, { diff --git a/pkg/bath/loans.go b/pkg/bath/loans.go new file mode 100644 index 000000000..0184d00fd --- /dev/null +++ b/pkg/bath/loans.go @@ -0,0 +1,162 @@ +package bath + +import ( + "github.com/tonkeeper/opentonapi/pkg/core" + "github.com/tonkeeper/tongo" +) + +// A lending pool lends its stakers' coins to validators, one validation round at a time. These +// actions show that side of a pool: a validator asks for a loan, the pool lends and the loan is +// staked, the elector returns it with the round's reward and the pool splits that reward, and the +// round moves through its stages. They are generic, like DepositStake, and say which pool +// implementation produced them. + +const ( + // PoolRoundElection: the pool decided which loans to make and staked them with the elector. + PoolRoundElection PoolRoundTransitionType = "election" + // PoolRoundBegan: the validation round started. + PoolRoundBegan PoolRoundTransitionType = "began" + // PoolRoundValidated: the round's validators finished validating, and the stakes are held + // for a while before the elector releases them. + PoolRoundValidated PoolRoundTransitionType = "validated" + // PoolRoundEnded: the round's stakes were released and the pool is recovering them. + PoolRoundEnded PoolRoundTransitionType = "ended" + // PoolRoundSettled: every loan was recovered and the round's deposits and withdrawals + // were settled. + PoolRoundSettled PoolRoundTransitionType = "settled" +) + +type PoolRoundTransitionType string + +type ( + LoanRequestAction struct { + Borrower tongo.AccountID + Pool tongo.AccountID + RoundSince uint32 + Amount *int64 + MinPayment *int64 + TonAttached int64 + Implementation core.StakingImplementation + transfers + } + LoanIssueAction struct { + Borrower tongo.AccountID + Pool tongo.AccountID + Loan tongo.AccountID + RoundSince uint32 + Amount int64 + Accrued int64 + Collateral int64 + MinPayment int64 + Implementation core.StakingImplementation + transfers + } + LoanRepaymentAction struct { + Borrower tongo.AccountID + Pool tongo.AccountID + Loan tongo.AccountID + RoundSince uint32 + Amount int64 + Accrued int64 + Recovered int64 + PoolReward int64 + ProtocolFee int64 + BorrowerPayout int64 + BurnAmount int64 + Shortfall *int64 + Implementation core.StakingImplementation + transfers + } + PoolRoundTransitionAction struct { + Pool tongo.AccountID + RoundSince uint32 + Transition PoolRoundTransitionType + TotalStaked *int64 + TotalRecovered *int64 + Implementation core.StakingImplementation + transfers + } +) + +type BubbleLoanRequest struct { + LoanRequestAction + Success bool +} + +type BubbleLoanIssue struct { + LoanIssueAction + Success bool +} + +type BubbleLoanRepayment struct { + LoanRepaymentAction + Success bool +} + +type BubblePoolRoundTransition struct { + PoolRoundTransitionAction + Success bool +} + +type ( + // transfers are the coins carried by the messages an action absorbed, so that they count + // towards the action rather than as an account's unexplained extra + transfers []transfer + transfer struct { + from tongo.AccountID + to tongo.AccountID + amount int64 + } +) + +func (a *LoanRequestAction) SubjectAccounts() []tongo.AccountID { + return []tongo.AccountID{a.Borrower, a.Pool} +} + +func (a *LoanIssueAction) SubjectAccounts() []tongo.AccountID { + return []tongo.AccountID{a.Borrower, a.Pool, a.Loan} +} + +func (a *LoanRepaymentAction) SubjectAccounts() []tongo.AccountID { + return []tongo.AccountID{a.Borrower, a.Pool, a.Loan} +} + +func (a *PoolRoundTransitionAction) SubjectAccounts() []tongo.AccountID { + return []tongo.AccountID{a.Pool} +} + +func (b BubbleLoanRequest) ToAction() *Action { + a := b.LoanRequestAction + return &Action{LoanRequest: &a, Success: b.Success, Type: LoanRequest} +} + +func (b BubbleLoanIssue) ToAction() *Action { + a := b.LoanIssueAction + return &Action{LoanIssue: &a, Success: b.Success, Type: LoanIssue} +} + +func (b BubbleLoanRepayment) ToAction() *Action { + a := b.LoanRepaymentAction + return &Action{LoanRepayment: &a, Success: b.Success, Type: LoanRepayment} +} + +func (b BubblePoolRoundTransition) ToAction() *Action { + a := b.PoolRoundTransitionAction + return &Action{PoolRoundTransition: &a, Success: b.Success, Type: PoolRoundTransition} +} + +func (t *transfers) add(bubble *Bubble) { + tx := bubble.Info.(BubbleTx) + if tx.inputFrom == nil { + return + } + *t = append(*t, transfer{from: tx.inputFrom.Address, to: tx.account.Address, amount: tx.inputAmount}) +} + +func (t transfers) contributeToExtra(account tongo.AccountID) int64 { + var extra int64 + for _, tr := range t { + extra += detectDirection(account, tr.from, tr.to, tr.amount) + } + return extra +} diff --git a/pkg/bath/matcher.go b/pkg/bath/matcher.go index 9ffa017a9..c239f88d8 100644 --- a/pkg/bath/matcher.go +++ b/pkg/bath/matcher.go @@ -1,6 +1,7 @@ package bath import ( + "slices" "unsafe" "github.com/tonkeeper/opentonapi/pkg/sentry" @@ -85,12 +86,11 @@ func (s Straw[newBubbleT]) Merge(bubble *Bubble) bool { var newBubble newBubbleT var newChildren []*Bubble var potentialChildren []*Bubble + // The head's accounts and transactions are added with the rest of the mapping below, after + // its builder has run. Starting from them as well would double them each time a straw + // extends an action it built before. var newAccounts []tongo.AccountID var newTransaction []ton.Bits256 - if !s.NotMergeBubble { - newAccounts = bubble.Accounts - newTransaction = bubble.Transaction - } nvf := newValueFlow() var finalizer func(newAction *newBubbleT, flow *ValueFlow) merged := make(map[*Bubble]struct{}, len(mapping)) @@ -177,6 +177,11 @@ func IsTx(b *Bubble) bool { return ok } +func IsSuccess(b *Bubble) bool { + tx, ok := b.Info.(BubbleTx) + return ok && tx.success +} + type interfaceType struct { typ unsafe.Pointer word unsafe.Pointer @@ -254,6 +259,13 @@ func HasOperation(name abi.MsgOpName) bubbleCheck { return b.Info.(BubbleTx).operation(name) } } + +func HasAnyOperation(names ...abi.MsgOpName) bubbleCheck { + return func(b *Bubble) bool { + return slices.ContainsFunc(names, b.Info.(BubbleTx).operation) + } +} + func IsAccount(id tongo.AccountID) bubbleCheck { return func(bubble *Bubble) bool { return bubble.Info.(BubbleTx).account.Address == id diff --git a/pkg/bath/straws.go b/pkg/bath/straws.go index fcadbfb93..72ec172f4 100644 --- a/pkg/bath/straws.go +++ b/pkg/bath/straws.go @@ -60,86 +60,104 @@ func DefaultStraws(book AddressBook, infoSource core.InformationSource) []Merger // Before JettonBurnStraw, which names no owner for it without additional info. JettonBurnHipoUnstakeAllStraw, JettonBurnStraw, + // 15 WtonMintStraw, NftPurchaseStraw, - // 15 StonfiSwapStraw, UniversalStonfiStraw{}, UniversalDedustStraw{}, + // 20 TgAuctionV1InitialBidStraw, StrawAuctionBigGetgems, - // 20 StrawAuctionBuyGetgems, StrawAuctionBuyFragments, JettonMintFromMasterStraw, + // 25 JettonMintStrawGovernance, InvoicePaymentStrawNative, - // 25 InvoicePaymentStrawJetton, MegatonFiJettonSwap, UnSubscriptionBySubscriberStraw, + // 30 UnSubscriptionByBeneficiaryOrExpiredStraw, SubscriptionDeployStraw, - // 30 SubscriptionPaymentStraw, SubscriptionPaymentWithRequestFundsStraw, DepositLiquidStakeStraw, + // 35 PendingWithdrawRequestLiquidStraw, ElectionsDepositStakeStraw, - // 35 ElectionsRecoverStakeStraw, DepositTFStakeStraw, WithdrawTFStakeRequestStraw, + // 40 WithdrawStakeImmediatelyStraw, WithdrawLiquidStake, - // 40 DNSRenewStraw, BidaskLiquidityDepositBothNativeStraw, BidaskLiquidityDepositBothJettonStraw, + // 45 BidaskLiquidityDepositJettonStraw, StonfiLiquidityDepositSingle, - // 45 StonfiLiquidityDepositBoth, DepositEthenaStakeStraw, WithdrawEthenaStakeRequestStraw, + // 50 BidaskSwapStraw, BidaskSwapStrawReverse, - // 50 BidaskJettonSwapStraw, MooncxSwapStraw, MooncxSwapStrawReverse, + // 55 MoocxLiquidityDepositJettonStraw, MoocxLiquidityDepositNativeStraw, - // 55 MoocxLiquidityDepositBothStraw, ToncoSwapStraw, ToncoDepositLiquiditySingleStraw, + // 60 ToncoDepositLiquidityBothStraw, ToncoDepositLiquidityWithRefundStraw, - // 60 DepositAffluentEarnStraw, DepositAffluentEarnWithOraclesStraw, WithdrawAffluentEarnRequestStraw, + // 65 InstantWithdrawAffluentEarnStraw, InstantWithdrawAffluentEarnWithOraclesStraw, - // 65 PythOraclePriceUpdateStraw(infoSource), DepositFFVaultStakeStraw, WithdrawalRequestFFVaultStraw, + // 70 XTRDepositAction, XTRBuyAction, - // 70 // Hipo, in this order: the deposit straws stop above the mint, and // WithdrawHipoStakeStraw upgrades the request. JettonMintHipoStraw, DepositHipoStakeStraw, DepositHipoStakeDeferredStraw, + // 75 WithdrawHipoStakeRequestStraw, WithdrawHipoStakeStraw, - // 75 WithdrawHipoStakeSettledStraw, WithdrawHipoStakePostponedStraw, JettonMintHipoRollbackStraw, + // 80 + WithdrawHipoStakeAllRequestStraw, + // Hipo's loans and rounds, after the staking straws: a settled round folds in the + // bills' burning but leaves their settlements to the straws above. + LoanRequestHipoStraw, + LoanIssueHipoStraw, + LoanRepaymentHipoStraw, + PoolRoundElectionHipoStraw, + // 85 + PoolRoundBeganHipoStraw, + PoolRoundEndedHipoStraw, + PoolRoundSettledHipoStraw, + hipoRoundElectionStepStraw, + hipoRoundEndedStepStraw, + // 90 + hipoRoundRecoverStraw, + hipoRoundBillStraw, + hipoRoundMintStraw, } } diff --git a/pkg/bath/testdata/hipo-loan-request.json b/pkg/bath/testdata/hipo-loan-request.json new file mode 100644 index 000000000..8f52b203c --- /dev/null +++ b/pkg/bath/testdata/hipo-loan-request.json @@ -0,0 +1,34 @@ +{ + "Actions": [ + { + "LoanRequest": { + "Borrower": "0:9c9b4ce7de3773c2a5f905f6f72c90392f0d79e149e8886df0aaa95bbe2b0fff", + "Pool": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "RoundSince": 1792102152, + "Amount": 1925374834094334, + "MinPayment": 1219770712064, + "TonAttached": 1322568227845, + "Implementation": "hipo" + }, + "Success": true, + "Type": "LoanRequest", + "BaseTransactions": [ + "9f04f4bab2f587c110da66dbee299f33a185f3041bcc3c2b350474d71de2dce3" + ] + } + ], + "Accounts": [ + { + "Account": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Ton": 1322566434388, + "Fee": 1793457, + "Jettons": null + }, + { + "Account": "0:9c9b4ce7de3773c2a5f905f6f72c90392f0d79e149e8886df0aaa95bbe2b0fff", + "Ton": -1322568811481, + "Fee": 583636, + "Jettons": null + } + ] + } \ No newline at end of file diff --git a/pkg/bath/testdata/hipo-round-began.json b/pkg/bath/testdata/hipo-round-began.json new file mode 100644 index 000000000..4d7090c1a --- /dev/null +++ b/pkg/bath/testdata/hipo-round-began.json @@ -0,0 +1,27 @@ +{ + "Actions": [ + { + "PoolRoundTransition": { + "Pool": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "RoundSince": 1792102152, + "Transition": "began", + "TotalStaked": null, + "TotalRecovered": null, + "Implementation": "hipo" + }, + "Success": true, + "Type": "PoolRoundTransition", + "BaseTransactions": [ + "87a89f582a6d372beece6a29aa5d306240065fdfe00897263fd5925d0e248e60" + ] + } + ], + "Accounts": [ + { + "Account": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Ton": -618761, + "Fee": 618761, + "Jettons": null + } + ] + } \ No newline at end of file diff --git a/pkg/bath/testdata/hipo-round-election.json b/pkg/bath/testdata/hipo-round-election.json new file mode 100644 index 000000000..40514e813 --- /dev/null +++ b/pkg/bath/testdata/hipo-round-election.json @@ -0,0 +1,72 @@ +{ + "Actions": [ + { + "PoolRoundTransition": { + "Pool": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "RoundSince": 1788759816, + "Transition": "election", + "TotalStaked": null, + "TotalRecovered": null, + "Implementation": "hipo" + }, + "Success": true, + "Type": "PoolRoundTransition", + "BaseTransactions": [ + "046a4f3214343224ea7406f15767ef4fdbb50ec75e7b8976fc9f0bcbba093a90", + "42b2852b100ba5a8de60fae82c50cb5f7b13fafdeee5926def6355ade9b137b0", + "93a7eb39d4e5efe539c99484c073d24ceecd61d69f6e623c8673e00831e2356c" + ] + }, + { + "LoanIssue": { + "Borrower": "0:d368e77f6dae535e64478539803a2f71f07dd7b9155482057a2a7723ee168986", + "Pool": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Loan": "-1:d608dbd87ebf6ad63b1fe270d97613f11300eff4e36ff7e4ab96e838fdf92694", + "RoundSince": 1788759816, + "Amount": 1000000000000000, + "Accrued": 2516623493168879, + "Collateral": 153275652320, + "MinPayment": 50000000000, + "Implementation": "hipo" + }, + "Success": true, + "Type": "LoanIssue", + "BaseTransactions": [ + "6da5b3396e705cf241543ba3aade78b287ddb06ddaeefaabd9dbe565db7e9ed2" + ] + }, + { + "ElectionsDepositStake": { + "Amount": 3516777095561199, + "Elector": "-1:3333333333333333333333333333333333333333333333333333333333333333", + "Staker": "-1:d608dbd87ebf6ad63b1fe270d97613f11300eff4e36ff7e4ab96e838fdf92694" + }, + "Success": true, + "Type": "ElectionsDepositStake", + "BaseTransactions": [ + "30a14fb8fa189e5741dde534d3c0d410f855afa814df632e0d442585537ee46f", + "6adb33d627660ff1871ee1dea9a0222472e92f85398a4b71291f063a241af235" + ] + } + ], + "Accounts": [ + { + "Account": "-1:3333333333333333333333333333333333333333333333333333333333333333", + "Ton": 3516776095561199, + "Fee": 0, + "Jettons": null + }, + { + "Account": "-1:d608dbd87ebf6ad63b1fe270d97613f11300eff4e36ff7e4ab96e838fdf92694", + "Ton": 983080000, + "Fee": 107360000, + "Jettons": null + }, + { + "Account": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Ton": -3516777223992553, + "Fee": 37991354, + "Jettons": null + } + ] + } \ No newline at end of file diff --git a/pkg/bath/testdata/hipo-round-end.json b/pkg/bath/testdata/hipo-round-end.json new file mode 100644 index 000000000..93475988a --- /dev/null +++ b/pkg/bath/testdata/hipo-round-end.json @@ -0,0 +1,464 @@ +{ + "Actions": [ + { + "PoolRoundTransition": { + "Pool": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "RoundSince": 1791971080, + "Transition": "ended", + "TotalStaked": null, + "TotalRecovered": null, + "Implementation": "hipo" + }, + "Success": true, + "Type": "PoolRoundTransition", + "BaseTransactions": [ + "3239221a9ea152065260d6410eec8cda9ea8eb852f664f40d116ff79c6ee9c2f", + "a0b6b9fdedceee19929e1247c72e4b34e40ee6647fa193e9f4d3e2281ab4ce63", + "ac57cbe3d868263d99ca2f2e398129b65267d8d0d2f97ed889627c672088518a", + "d8d505bceb48b1916f7211faa604168d42f10932ab939f831515c84a2b5d2de6" + ] + }, + { + "ElectionsRecoverStake": { + "Amount": 2039594349848138, + "Elector": "-1:3333333333333333333333333333333333333333333333333333333333333333", + "Staker": "-1:ee9ab316f8ea953833267a1efc9bf973bc9346d02781f2c6ae2601eb59923ea7" + }, + "Success": true, + "Type": "ElectionsRecoverStake", + "BaseTransactions": [ + "2912e6201746e4157298b59a527126c126245d18dfb664cd7d8a5ec27922edfe", + "bd8a6b2bce5d27cb2e40551ace7efecd47062ec7b6cd4d371951c2fc90bf6e90" + ] + }, + { + "LoanRepayment": { + "Borrower": "0:95b13f0e84671d294ae84bf70a7e59f1c5963c485ed1eed7b2e1fe8d583bbcab", + "Pool": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Loan": "-1:ee9ab316f8ea953833267a1efc9bf973bc9346d02781f2c6ae2601eb59923ea7", + "RoundSince": 1791971080, + "Amount": 1870000000000000, + "Accrued": 166933201032707, + "Recovered": 2039594284238138, + "PoolReward": 1352872211594, + "ProtocolFee": 0, + "BorrowerPayout": 1289864469718, + "BurnAmount": 18058665452, + "Shortfall": null, + "Implementation": "hipo" + }, + "Success": true, + "Type": "LoanRepayment", + "BaseTransactions": [ + "b5627daaaab38e1629e610008f27285d87a34dd84df4d9962897382502d6c924", + "f3d0977269d85a1e9939b7b6011375a7c5977fe9ad7dad5b21f3f77064ffc254", + "6bfb66cd77796599ccc953a319491c6f029fc5a72037387f5f59f2c11971a82e", + "c0bfd20a949523d6ba8b91e8b7efcbb9348fc12f9495ed2a8f000fc87dbc5f89" + ] + }, + { + "DepositStake": { + "Staker": "0:dc8d90d6be8b68544d17e0749db76a51ddbdac059e178a4cd255274f8aa75030", + "Amount": { + "Currency": { + "Type": "native", + "Jetton": null, + "CurrencyID": null + }, + "Amount": 18529644780 + }, + "Pool": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Implementation": "hipo" + }, + "Success": true, + "Type": "DepositStake", + "BaseTransactions": [ + "ae97030635ac673eec19c04631b203a7570c1fbf248a31ae135dbffca37f346b", + "80e85ade708dff76972dc4f7e4b97757d985f769c2aa45ab2915846c5e23d767" + ] + }, + { + "JettonMint": { + "Jetton": "0:cf76af318c0872b58a9f1925fc29c156211782b9fb01f56760d292e56123bf87", + "Recipient": "0:dc8d90d6be8b68544d17e0749db76a51ddbdac059e178a4cd255274f8aa75030", + "RecipientsWallet": "0:08a58b6cf90f80e4248fd7c21033c4e3e70b1e6ee0b5c12642343880f1e53404", + "Amount": "15647183422" + }, + "Success": true, + "Type": "JettonMint", + "BaseTransactions": [ + "5aaa0f09b134c3595fed419ef79ba7e37ba9fbdd3fef4f5873752a2e97632155", + "725af49be291269a270d0a33a9d1a94f16011154b73db50ec0b5577ddc3a0e12" + ] + }, + { + "JettonSwap": { + "Dex": "dedust", + "UserWallet": "0:dc8d90d6be8b68544d17e0749db76a51ddbdac059e178a4cd255274f8aa75030", + "Router": "0:9726eef35014082ddcedd67233cff3610df559f85c8476771c1436033d1680a9", + "In": { + "Amount": 15647183422, + "IsTon": false, + "JettonMaster": "0:cf76af318c0872b58a9f1925fc29c156211782b9fb01f56760d292e56123bf87", + "JettonWallet": "0:08a58b6cf90f80e4248fd7c21033c4e3e70b1e6ee0b5c12642343880f1e53404" + }, + "Out": { + "Amount": 6277889905227, + "IsTon": false, + "JettonMaster": "0:d0114af42cf8bc9ba0fa1745abbeea24eed65b01caf4ca7d98ee12b6f7530ccc", + "JettonWallet": "0:a16cdaf289f76a01ad6f168ac6f6220f48db6406a11938d218674768d1f10bb2" + } + }, + "Success": true, + "Type": "JettonSwap", + "BaseTransactions": [ + "fa079572ed1d54079203752f15b6e5f91820f273d554e4d0a3c4250c3c4033f9", + "03d31ffed6f64318e1b354301c3b47db8b09018a0c81be18c3dbd92336655f22", + "714688ae627baf1acd0f5878a5071cda72dca60a95927e9ca63d9d25f5b058c1", + "d648bb50b9f8b8295dd35878c228b6a4f5fea03e404665278bb6b64c9cb5985d", + "038212fa7f422c41ea790785ac5911a9a15688e9d9978a05de28534bc59c98af", + "8f2278987439b95bcb57b4b4eec2912e8f0c69de1d9b0682736ad6070f62a13c", + "889de8c500191f80b52cc36ebfe4296f77d8a8acd12a9144afbd29dba007f694", + "252b26d9e926358a6d6eae7b03b7789e5c1d01352cf7dd21d3a0ad4192bf32e5", + "728af9ff181a37c1031b62f38a984075e5600d752aa3159b55f696c7e4206afe", + "300dc78bf195cde26251847d48f2609f11e43ec2d7a0525544dedc375151d5aa" + ] + }, + { + "JettonBurn": { + "Jetton": "0:d0114af42cf8bc9ba0fa1745abbeea24eed65b01caf4ca7d98ee12b6f7530ccc", + "Sender": "0:dc8d90d6be8b68544d17e0749db76a51ddbdac059e178a4cd255274f8aa75030", + "SendersWallet": "0:cc67b804fa5ee26421a1dbc8da5810a4447bf4cc697bb3da858b80684bcd323a", + "Amount": "6277889905227", + "Owner": "0:dc8d90d6be8b68544d17e0749db76a51ddbdac059e178a4cd255274f8aa75030" + }, + "Success": true, + "Type": "JettonBurn", + "BaseTransactions": [ + "191b2932e5542c597d761e6c100566865b158d6668554b0c6c6613afbeb27945", + "3bf83f3ff2788fce5af7142d0e98eeb2b5ab9b19fb18a22946df3d26d2eacf8d" + ] + }, + { + "SmartContractExec": { + "TonAttached": 98985330, + "Executor": "0:d0114af42cf8bc9ba0fa1745abbeea24eed65b01caf4ca7d98ee12b6f7530ccc", + "Contract": "0:dc8d90d6be8b68544d17e0749db76a51ddbdac059e178a4cd255274f8aa75030", + "Operation": "Excess", + "Payload": "QueryId: 391\n" + }, + "Success": true, + "Type": "SmartContractExec", + "BaseTransactions": [ + "e262432a9ec642fc5ea24b2f58391f7b04e43dd356847d34ac6be4d19827cc9c" + ] + }, + { + "ElectionsRecoverStake": { + "Amount": 2039594349848138, + "Elector": "-1:3333333333333333333333333333333333333333333333333333333333333333", + "Staker": "-1:658046730ded813ea99f58128a179b7058f53c21c289ff3fc809cc5279f30900" + }, + "Success": true, + "Type": "ElectionsRecoverStake", + "BaseTransactions": [ + "e9cc9589a9832c3dd850e5d35d39eba47584e8b03ed4106e7fdbad81cca19203", + "e974f27f1b1583d6fa5ed882854412b4df4acb442f945abc70ca7a2b628f967a" + ] + }, + { + "LoanRepayment": { + "Borrower": "0:d368e77f6dae535e64478539803a2f71f07dd7b9155482057a2a7723ee168986", + "Pool": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Loan": "-1:658046730ded813ea99f58128a179b7058f53c21c289ff3fc809cc5279f30900", + "RoundSince": 1791971080, + "Amount": 1870000000000000, + "Accrued": 166933201032707, + "Recovered": 2039594284238138, + "PoolReward": 1352872211594, + "ProtocolFee": 0, + "BorrowerPayout": 1289864469718, + "BurnAmount": 18058665452, + "Shortfall": null, + "Implementation": "hipo" + }, + "Success": true, + "Type": "LoanRepayment", + "BaseTransactions": [ + "b630711dfceb7619bb74cb46888f0d9a07f51af3c4d1b5af1e3474feb804d79c", + "f1c1a46cb29a21d0e35ffee14d990fc166fa94733f8442e7bbfb3d8b260926af", + "d1250a4d5cf6edd645177bcf7af9e2b86ef5a9714953a498ccc354b346322a03", + "f53ca868519e2af8cf42f7789a1c9bbfd0193c4fc59814dd9ccaa665bfbb3246" + ] + }, + { + "PoolRoundTransition": { + "Pool": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "RoundSince": 1791971080, + "Transition": "settled", + "TotalStaked": 4073866402065414, + "TotalRecovered": 4076572146488602, + "Implementation": "hipo" + }, + "Success": true, + "Type": "PoolRoundTransition", + "BaseTransactions": [ + "28e5c11b8b448614fb4f83e84ebf1c61205ccb35b599e18ff0b1caa45534aa50", + "1f47d8ccd8d4281bdd30bd387645b6c75de69e9160ebb4f9a74d20d005239522", + "7560b2a733326198cc2265d59a80470f4b8f5ea65d56046654625eb8d75a99db", + "7af0c6183171ead098cc83cc80f466cbed54a5bb336e926b4c323d7761a06e43" + ] + }, + { + "WithdrawStake": { + "Staker": "0:a732d044d31f709b6174024d7ae1898110f672c38443f2d5ae853a0ac474c893", + "Amount": 3755037590918, + "Pool": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Implementation": "hipo" + }, + "Success": true, + "Type": "WithdrawStake", + "BaseTransactions": [ + "437f00c428d903329ed633c0596efee698ec7c1577ede82095a8f2b8c64d4ff4", + "215cf2cff3b0723e0ca246d58b1aaa2601fd85ecf72053657e2424a90f8d9349", + "64ac2c7ea0c82c6bf68c193df45ade72375bfe3596585fb1f37ba0c7ef6a1111", + "d2bddd58b3ff96dd49188082dd1ab1191260070e3ba993759aef2b1a65261b5d" + ] + }, + { + "DepositStake": { + "Staker": "0:dc8d90d6be8b68544d17e0749db76a51ddbdac059e178a4cd255274f8aa75030", + "Amount": { + "Currency": { + "Type": "native", + "Jetton": null, + "CurrencyID": null + }, + "Amount": 17557118581 + }, + "Pool": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Implementation": "hipo" + }, + "Success": true, + "Type": "DepositStake", + "BaseTransactions": [ + "59b63ad2c86d5d3f553df2f4b3e72a359f504ca95f261d233f2600e0a641fc58", + "e492243f5ace43707557fd1b8023d8ca97c26f8d097d1d37bb5f25b270744e01" + ] + }, + { + "JettonMint": { + "Jetton": "0:cf76af318c0872b58a9f1925fc29c156211782b9fb01f56760d292e56123bf87", + "Recipient": "0:dc8d90d6be8b68544d17e0749db76a51ddbdac059e178a4cd255274f8aa75030", + "RecipientsWallet": "0:08a58b6cf90f80e4248fd7c21033c4e3e70b1e6ee0b5c12642343880f1e53404", + "Amount": "14825942864" + }, + "Success": true, + "Type": "JettonMint", + "BaseTransactions": [ + "c1e37508ccf4bb90522a42d8fd5a3e09f779f4fc3a81cadfa27606e486908fc2", + "b74e582a710b94e879691b8d871bd4afd2fa29d690d5535fd00946185d8e9de4" + ] + }, + { + "JettonSwap": { + "Dex": "dedust", + "UserWallet": "0:dc8d90d6be8b68544d17e0749db76a51ddbdac059e178a4cd255274f8aa75030", + "Router": "0:9726eef35014082ddcedd67233cff3610df559f85c8476771c1436033d1680a9", + "In": { + "Amount": 14825942864, + "IsTon": false, + "JettonMaster": "0:cf76af318c0872b58a9f1925fc29c156211782b9fb01f56760d292e56123bf87", + "JettonWallet": "0:08a58b6cf90f80e4248fd7c21033c4e3e70b1e6ee0b5c12642343880f1e53404" + }, + "Out": { + "Amount": 5833803953619, + "IsTon": false, + "JettonMaster": "0:d0114af42cf8bc9ba0fa1745abbeea24eed65b01caf4ca7d98ee12b6f7530ccc", + "JettonWallet": "0:a16cdaf289f76a01ad6f168ac6f6220f48db6406a11938d218674768d1f10bb2" + } + }, + "Success": true, + "Type": "JettonSwap", + "BaseTransactions": [ + "b7658829c1534f1a89510aaa3a0d11bb7acb85c47301417bbd35b01d922d93f8", + "56c295d3d31e342a69d169bc93d7d43a2910a5f90fcb8df5d611efadfdcb8446", + "8bb71ebc21bf2c52bf6fa73163213f9fe1d4994f08e535e16d654b906be5e91a", + "ac4b43ac477e5e57cde659d46701b3b94057f8c69b73524f3928ac4aa417c75d", + "b7e0bd6877eab27276af832e0776a4784f551da50f1150b7cbda00fa4c89e753", + "00dc232836ab9585fe0fa458101764f32bd46f09046ba315a776e8ace3c7230f", + "87b32017dd569690b16304de9626859dfc218200f190bbdc08c200e647b317bd", + "d2c793dc4213369278ccf4b7b02d38d8efdd7dfcdf33f3924f3b51afdf284078", + "0457d08918e0d49dfb893c35b45a49e1c6b05c739401fdde48919f969379e533", + "59ad7d89b7be0eb999be78143628aa8628d77d44b995bc0d1532b4dd489e8485" + ] + }, + { + "JettonBurn": { + "Jetton": "0:d0114af42cf8bc9ba0fa1745abbeea24eed65b01caf4ca7d98ee12b6f7530ccc", + "Sender": "0:dc8d90d6be8b68544d17e0749db76a51ddbdac059e178a4cd255274f8aa75030", + "SendersWallet": "0:cc67b804fa5ee26421a1dbc8da5810a4447bf4cc697bb3da858b80684bcd323a", + "Amount": "5833803953619", + "Owner": "0:dc8d90d6be8b68544d17e0749db76a51ddbdac059e178a4cd255274f8aa75030" + }, + "Success": true, + "Type": "JettonBurn", + "BaseTransactions": [ + "df06ba14dfcf8232f6abadf3920a1df9d6ac1ce6c0393c2ad46a587249c867b1", + "c513b710a64de3accb7ac8e9d5792a80cebb880bb6abc8b28e5efc4f2a0d543c" + ] + }, + { + "SmartContractExec": { + "TonAttached": 98985330, + "Executor": "0:d0114af42cf8bc9ba0fa1745abbeea24eed65b01caf4ca7d98ee12b6f7530ccc", + "Contract": "0:dc8d90d6be8b68544d17e0749db76a51ddbdac059e178a4cd255274f8aa75030", + "Operation": "Excess", + "Payload": "QueryId: 392\n" + }, + "Success": true, + "Type": "SmartContractExec", + "BaseTransactions": [ + "d25e65cc247a99792e7e375852e197bb91b613b252c9750fecbef57ed4bbf7ac" + ] + } + ], + "Accounts": [ + { + "Account": "-1:3333333333333333333333333333333333333333333333333333333333333333", + "Ton": -4079186293286326, + "Fee": 0, + "Jettons": null + }, + { + "Account": "-1:658046730ded813ea99f58128a179b7058f53c21c289ff3fc809cc5279f30900", + "Ton": -983080000, + "Fee": 123343692, + "Jettons": null + }, + { + "Account": "-1:ee9ab316f8ea953833267a1efc9bf973bc9346d02781f2c6ae2601eb59923ea7", + "Ton": -983080000, + "Fee": 123343692, + "Jettons": null + }, + { + "Account": "0:08a58b6cf90f80e4248fd7c21033c4e3e70b1e6ee0b5c12642343880f1e53404", + "Ton": 0, + "Fee": 2970278, + "Jettons": null + }, + { + "Account": "0:50e74913216cb06936b4d69fc99c78646a212e93076dd563ef74722f3f557948", + "Ton": 9734, + "Fee": 1471471, + "Jettons": [ + { + "Address": "0:d0114af42cf8bc9ba0fa1745abbeea24eed65b01caf4ca7d98ee12b6f7530ccc", + "Quantity": -12111693858846 + } + ] + }, + { + "Account": "0:7e40f97d20d6eea148846608cd6e0163e1325f5812392cdee307b5c96508c68c", + "Ton": 0, + "Fee": 1419298, + "Jettons": null + }, + { + "Account": "0:8b5fe4adec8f730419485effa12b22010f31fb02d7c14f6ec32c8a7c0cd935e7", + "Ton": -96528855, + "Fee": 553073, + "Jettons": null + }, + { + "Account": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Ton": 4072852711234669, + "Fee": 32039647, + "Jettons": null + }, + { + "Account": "0:918c82e6243d190fbcba3ce17289c0b1db43e8ca9904c0727ae00309eefc245c", + "Ton": 0, + "Fee": 1442271, + "Jettons": [ + { + "Address": "0:cf76af318c0872b58a9f1925fc29c156211782b9fb01f56760d292e56123bf87", + "Quantity": 30473126286 + } + ] + }, + { + "Account": "0:95b13f0e84671d294ae84bf70a7e59f1c5963c485ed1eed7b2e1fe8d583bbcab", + "Ton": 1289864334012, + "Fee": 69039, + "Jettons": null + }, + { + "Account": "0:9726eef35014082ddcedd67233cff3610df559f85c8476771c1436033d1680a9", + "Ton": 0, + "Fee": 3009609, + "Jettons": null + }, + { + "Account": "0:a16cdaf289f76a01ad6f168ac6f6220f48db6406a11938d218674768d1f10bb2", + "Ton": -1, + "Fee": 1562005, + "Jettons": null + }, + { + "Account": "0:a62c2600bb53e19646f771420a0c5c03a417cfdaf5c30a31425268298a6091ef", + "Ton": 567157681, + "Fee": 390178, + "Jettons": null + }, + { + "Account": "0:a732d044d31f709b6174024d7ae1898110f672c38443f2d5ae853a0ac474c893", + "Ton": 3755037524336, + "Fee": 66582, + "Jettons": null + }, + { + "Account": "0:cc67b804fa5ee26421a1dbc8da5810a4447bf4cc697bb3da858b80684bcd323a", + "Ton": 0, + "Fee": 2679348, + "Jettons": null + }, + { + "Account": "0:cf76af318c0872b58a9f1925fc29c156211782b9fb01f56760d292e56123bf87", + "Ton": -171, + "Fee": 1825444, + "Jettons": null + }, + { + "Account": "0:d0114af42cf8bc9ba0fa1745abbeea24eed65b01caf4ca7d98ee12b6f7530ccc", + "Ton": -3646, + "Fee": 1037916, + "Jettons": null + }, + { + "Account": "0:d368e77f6dae535e64478539803a2f71f07dd7b9155482057a2a7723ee168986", + "Ton": 1289864334012, + "Fee": 69039, + "Jettons": null + }, + { + "Account": "0:dc8d90d6be8b68544d17e0749db76a51ddbdac059e178a4cd255274f8aa75030", + "Ton": 3379, + "Fee": 11608364, + "Jettons": null + }, + { + "Account": "0:e5524d594d0bf39cd7e812aebe73b8e0f36701b76aed35bfa00a26b3b30f7309", + "Ton": 17085, + "Fee": 537672, + "Jettons": null + }, + { + "Account": "0:faaa4eab4b5d12f26010a042f88337d79b8d70aec42c24ae1fdce934cefc0216", + "Ton": 0, + "Fee": 1925473, + "Jettons": null + } + ] + } \ No newline at end of file diff --git a/pkg/bath/testdata/hipo-round-validated.json b/pkg/bath/testdata/hipo-round-validated.json new file mode 100644 index 000000000..c2b2c0e21 --- /dev/null +++ b/pkg/bath/testdata/hipo-round-validated.json @@ -0,0 +1,27 @@ +{ + "Actions": [ + { + "PoolRoundTransition": { + "Pool": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "RoundSince": 1792036616, + "Transition": "validated", + "TotalStaked": null, + "TotalRecovered": null, + "Implementation": "hipo" + }, + "Success": true, + "Type": "PoolRoundTransition", + "BaseTransactions": [ + "57074455cf497e7cded8d790b8de2d6c7e5d6148252565de1d5eeee25020fc5d" + ] + } + ], + "Accounts": [ + { + "Account": "0:8bc991cfe177bc7e9721433efa3befd199485a55cffd040a06c89af026b71bcf", + "Ton": -621201, + "Fee": 621201, + "Jettons": null + } + ] + } \ No newline at end of file diff --git a/pkg/core/converters.go b/pkg/core/converters.go index 207450c48..bac654f6a 100644 --- a/pkg/core/converters.go +++ b/pkg/core/converters.go @@ -12,6 +12,7 @@ import ( "github.com/tonkeeper/tongo/ton" "github.com/tonkeeper/opentonapi/internal/g" + "github.com/tonkeeper/opentonapi/pkg/references" ) func ConvertToBlockHeader(id tongo.BlockIDExt, block *tlb.Block) (*BlockHeader, error) { @@ -367,6 +368,10 @@ func ConvertMessage(message tlb.Message, txLT uint64, cd *abi.ContractDescriptio case "ExtOutMsgInfo": var decodedBody *DecodedMessageBody info := message.Info.ExtOutMsgInfo + source, err := ton.AccountIDFromTlb(info.Src) + if err != nil { + return Message{}, err + } tag, op, value, err := abi.ExtOutMessageDecoder(&cell, ci, info.Dest) if err == nil && op != nil { decodedBody = &DecodedMessageBody{ @@ -374,9 +379,20 @@ func ConvertMessage(message tlb.Message, txLT uint64, cd *abi.ContractDescriptio Value: value, } } - source, err := ton.AccountIDFromTlb(info.Src) - if err != nil { - return Message{}, err + // Hipo's treasury and burner log by topic, which abi cannot read, and the first 32 + // bits of the body are not an op-code. + // body cannot be reused: convertBodyCell keeps some bodies as text, so the log is + // serialized from the message cell. + if source != nil && (*source == references.HipoTreasury || *source == references.HipoBurner) { + logCell := boc.Cell(message.Body.Value) + if logBody, err := logCell.ToBoc(); err == nil { + dest := externalAddressFromTlb(info.Dest) + if log, ok := decodeHipoLog(source, dest, logBody, info.CreatedAt); ok { + tag, decodedBody = nil, log + } else if log, ok := decodeHipoBurnerLog(source, dest, logBody); ok { + tag, decodedBody = nil, log + } + } } return Message{ MessageID: MessageID{ diff --git a/pkg/core/hipo_logs.go b/pkg/core/hipo_logs.go new file mode 100644 index 000000000..29e41d6d7 --- /dev/null +++ b/pkg/core/hipo_logs.go @@ -0,0 +1,371 @@ +package core + +import ( + "fmt" + "reflect" + + "github.com/tonkeeper/opentonapi/pkg/references" + "github.com/tonkeeper/tongo/boc" + "github.com/tonkeeper/tongo/tlb" + "github.com/tonkeeper/tongo/ton" +) + +// Hipo's treasury reports what a round did in external-out messages rather than in the +// messages it sends, and the two carry different things. reserve_tokens, loan_result and +// recover_stake_result carry a query id and an address; how much a loan repaid, and how +// that repayment was split between stakers, the governor, the borrower and the burner, +// exists only here. +// +// The shape is unusual enough to be worth stating. emit_log puts the topic in the message's +// destination - a 256-bit external address holding a small integer - and starts the body at +// round_since with no op-code at all. Decoders that key external-out messages on a body +// op-code therefore read round_since as one, and report a different op-code for the same +// log every round. These are parsed by topic instead. +// +// Layouts follow log_loan, log_repayment and log_finish in contracts/imports/utils.fc, and +// log_deficit in treasury.fc. Amounts are Coins; borrower_reward_share and borrower_fee are +// rates out of 65535, not amounts. + +const ( + HipoLogLoan HipoLogTopic = 1 + HipoLogRepayment HipoLogTopic = 2 + HipoLogFinish HipoLogTopic = 3 + HipoLogFailedBurningTokens HipoLogTopic = 4 + HipoLogDeficit HipoLogTopic = 5 +) + +// Two of these logs changed shape with Hipo's 2026-09-05 release, which introduced the +// borrower fee. Both layouts are parsed, because actions are recomputed from history and a +// round settled before that release would otherwise be unreadable. The layout is chosen by +// when the log was written, not by which one fits: an old body can fit the new layout +// exactly and read as nonsense, and 8 of the 542 real logs around the release fit both. +// hipoBorrowerFeeRelease is the treasury's upgrade transaction (9722cf28d0be9a08...) on +// 2026-09-05 at 17:29:33 UTC; every log written after it is in the new layout. +const hipoBorrowerFeeRelease = 1788629373 + +// each log is named the way a decoded message is named +var hipoLogOperations = map[HipoLogTopic]string{ + HipoLogLoan: "HipoFinanceLogLoan", + HipoLogRepayment: "HipoFinanceLogRepayment", + HipoLogFinish: "HipoFinanceLogFinish", + HipoLogFailedBurningTokens: "HipoFinanceLogFailedBurningTokens", + HipoLogDeficit: "HipoFinanceLogDeficit", +} + +var hipoBurnerLogs = map[uint64]struct { + operation string + layout func() any +}{ + 1: {"HipoBurnerLogReceived", func() any { return &HipoBurnerReceivedLog{} }}, + 2: {"HipoBurnerLogDeposit", func() any { return &HipoBurnerDepositLog{} }}, + 3: {"HipoBurnerLogSwap", func() any { return &HipoBurnerSwapLog{} }}, + 4: {"HipoBurnerLogBurn", func() any { return &HipoBurnerBurnLog{} }}, + 5: {"HipoBurnerLogDiscovery", func() any { return &HipoBurnerDiscoveryLog{} }}, + 6: {"HipoBurnerLogUpgrade", func() any { return &HipoBurnerUpgradeLog{} }}, +} + +type HipoLogTopic uint64 + +// HipoLoanLog is emitted once per loan the treasury hands out, as the round is distributed. +type HipoLoanLog struct { + RoundSince uint32 + MinPayment tlb.Grams + // BorrowerRewardShare is always scaled to 65535 here. Before 2026-09-05 the field was 8 + // bits out of 255, and is converted on the way out by the factor the contract's own + // migration note gives: 255 * 257 = 65535. + BorrowerRewardShare uint16 + LoanAmount tlb.Grams + AccrueAmount tlb.Grams + StakeAmount tlb.Grams + // BorrowerFee is a rate out of 65535, snapshotted from the extension when the request + // was made so that set_borrower_fee cannot reprice a committed loan. Zero for a loan + // made before the fee existed, which is accurate rather than missing. + BorrowerFee uint16 + Borrower tlb.MsgAddress +} + +// HipoRepaymentLog is emitted once per loan recovered, and is the only place the split of a +// repayment appears. StakersShare is what reaches the exchange rate. +type HipoRepaymentLog struct { + RoundSince uint32 + RepaymentAmount tlb.Grams + LoanAmount tlb.Grams + AccrueAmount tlb.Grams + StakersShare tlb.Grams + GovernorShare tlb.Grams + BorrowerShare tlb.Grams + // BurnShare is zero for a loan recovered before 2026-09-05, when no share was burned. + BurnShare tlb.Grams + Borrower tlb.MsgAddress +} + +// HipoFinishLog is emitted once per round, when its last loan has been recovered. +type HipoFinishLog struct { + RoundSince uint32 + TotalStaked tlb.Grams + TotalRecovered tlb.Grams + TotalCoins tlb.Grams + TotalTokens tlb.Grams +} + +// HipoFailedBurningLog is emitted when a bill burns that the treasury cannot settle. +type HipoFailedBurningLog struct { + RoundSince uint32 + TotalCoins tlb.Grams + TotalTokens tlb.Grams + Coins tlb.Grams + Tokens tlb.Grams + Owner tlb.MsgAddress +} + +// HipoDeficitLog is emitted alongside a repayment that did not cover what it owed. +type HipoDeficitLog struct { + RoundSince uint32 + LoanAmount tlb.Grams + AccrueAmount tlb.Grams + IncomingTon tlb.Grams + Shortfall tlb.Grams + Deficit tlb.Grams + Borrower tlb.MsgAddress +} + +// Hipo's burner logs the same way, with topics of its own (contracts/burner.fc in +// HipoFinance/burner). Each log is read only from the burner. +type HipoBurnerReceivedLog struct { + Op uint32 + Value tlb.Grams + TotalReceived tlb.Grams + Source tlb.MsgAddress +} + +type HipoBurnerDepositLog struct { + QueryId uint64 + Stake tlb.Grams +} + +type HipoBurnerSwapLog struct { + QueryId uint64 + Amount tlb.Grams + TotalSwapped tlb.Grams +} + +type HipoBurnerBurnLog struct { + QueryId uint64 + Amount tlb.Grams + TotalBurned tlb.Grams +} + +type HipoBurnerDiscoveryLog struct { + QueryId uint64 +} + +type HipoBurnerUpgradeLog struct { + QueryId uint64 + Source tlb.MsgAddress +} + +// the pre-2026-09-05 shapes, kept only so that history still parses +type hipoLoanLogV1 struct { + RoundSince uint32 + MinPayment tlb.Grams + BorrowerRewardShare uint8 + LoanAmount tlb.Grams + AccrueAmount tlb.Grams + StakeAmount tlb.Grams + Borrower tlb.MsgAddress +} + +type hipoRepaymentLogV1 struct { + RoundSince uint32 + RepaymentAmount tlb.Grams + LoanAmount tlb.Grams + AccrueAmount tlb.Grams + StakersShare tlb.Grams + GovernorShare tlb.Grams + BorrowerShare tlb.Grams + Borrower tlb.MsgAddress +} + +func DecodeHipoLog(msg Message) (HipoLogTopic, any, bool) { + topic, ok := hipoLogTopicOf(msg) + if !ok { + return 0, nil, false + } + // ConvertMessage has already decoded the logs of the treasury it saw; a store may not have. + if msg.DecodedBody != nil && msg.DecodedBody.Operation == hipoLogOperations[topic] { + switch msg.DecodedBody.Value.(type) { + case HipoLoanLog, HipoRepaymentLog, HipoFinishLog, HipoFailedBurningLog, HipoDeficitLog: + return topic, msg.DecodedBody.Value, true + } + } + value, err := parseHipoLog(topic, msg.Body, msg.CreatedAt) + if err != nil { + return 0, nil, false + } + return topic, value, true +} + +func hipoLogTopicOf(msg Message) (HipoLogTopic, bool) { + // The topic is just an address any contract can write to: callers must check that the + // treasury wrote the message. + if msg.MsgType != ExtOutMsg { + return 0, false + } + return hipoTopic(msg.DestinationExtern) +} + +func decodeHipoLog(source *ton.AccountID, dest *ExternalAddress, body []byte, createdAt uint32) (*DecodedMessageBody, bool) { + // abi cannot decode these: it dispatches on a body op-code, and these bodies start at + // round_since. Only the treasury's own messages are read. + if source == nil || *source != references.HipoTreasury { + return nil, false + } + topic, ok := hipoTopic(dest) + if !ok { + return nil, false + } + value, err := parseHipoLog(topic, body, createdAt) + if err != nil { + return nil, false + } + return &DecodedMessageBody{Operation: hipoLogOperations[topic], Value: value}, true +} + +func decodeHipoBurnerLog(source *ton.AccountID, dest *ExternalAddress, body []byte) (*DecodedMessageBody, bool) { + if source == nil || *source != references.HipoBurner { + return nil, false + } + topic, ok := hipoTopicValue(dest) + if !ok { + return nil, false + } + log, ok := hipoBurnerLogs[topic] + if !ok { + return nil, false + } + value := log.layout() + if unmarshalHipoLog(body, value) != nil { + return nil, false + } + return &DecodedMessageBody{Operation: log.operation, Value: reflect.ValueOf(value).Elem().Interface()}, true +} + +func parseHipoLog(topic HipoLogTopic, body []byte, createdAt uint32) (any, error) { + switch topic { + case HipoLogLoan: + return parseHipoLoanLog(body, createdAt) + case HipoLogRepayment: + return parseHipoRepaymentLog(body, createdAt) + case HipoLogFinish: + var log HipoFinishLog + err := unmarshalHipoLog(body, &log) + return log, err + case HipoLogFailedBurningTokens: + var log HipoFailedBurningLog + err := unmarshalHipoLog(body, &log) + return log, err + case HipoLogDeficit: + var log HipoDeficitLog + err := unmarshalHipoLog(body, &log) + return log, err + } + return nil, fmt.Errorf("unknown hipo log topic %d", topic) +} + +func parseHipoLoanLog(body []byte, createdAt uint32) (HipoLoanLog, error) { + if hipoCurrentLayout(createdAt) { + var log HipoLoanLog + err := unmarshalHipoLog(body, &log) + return log, err + } + var old hipoLoanLogV1 + if err := unmarshalHipoLog(body, &old); err != nil { + return HipoLoanLog{}, err + } + return HipoLoanLog{ + RoundSince: old.RoundSince, + MinPayment: old.MinPayment, + // 255 -> 65535, the conversion the contract's own note prescribes. + BorrowerRewardShare: uint16(old.BorrowerRewardShare) * 257, + LoanAmount: old.LoanAmount, + AccrueAmount: old.AccrueAmount, + StakeAmount: old.StakeAmount, + Borrower: old.Borrower, + }, nil +} + +func parseHipoRepaymentLog(body []byte, createdAt uint32) (HipoRepaymentLog, error) { + if hipoCurrentLayout(createdAt) { + var log HipoRepaymentLog + err := unmarshalHipoLog(body, &log) + return log, err + } + var old hipoRepaymentLogV1 + if err := unmarshalHipoLog(body, &old); err != nil { + return HipoRepaymentLog{}, err + } + return HipoRepaymentLog{ + RoundSince: old.RoundSince, + RepaymentAmount: old.RepaymentAmount, + LoanAmount: old.LoanAmount, + AccrueAmount: old.AccrueAmount, + StakersShare: old.StakersShare, + GovernorShare: old.GovernorShare, + BorrowerShare: old.BorrowerShare, + Borrower: old.Borrower, + }, nil +} + +func hipoCurrentLayout(createdAt uint32) bool { + // A log of unknown time, 0, is taken as current: a store that does not keep the time of + // external-out messages would otherwise misread every new log. + return createdAt == 0 || createdAt >= hipoBorrowerFeeRelease +} + +func hipoTopic(dest *ExternalAddress) (HipoLogTopic, bool) { + value, ok := hipoTopicValue(dest) + if !ok { + return 0, false + } + switch topic := HipoLogTopic(value); topic { + case HipoLogLoan, HipoLogRepayment, HipoLogFinish, HipoLogFailedBurningTokens, HipoLogDeficit: + return topic, true + } + return 0, false +} + +func hipoTopicValue(dest *ExternalAddress) (uint64, bool) { + if dest == nil || dest.BitsAvailableForRead() != 256 { + return 0, false + } + copied := dest.Copy() + // ReadUint stops at 64 bits. + value, err := copied.ReadBigUint(256) + if err != nil || !value.IsUint64() { + return 0, false + } + return value.Uint64(), true +} + +func unmarshalHipoLog(body []byte, dst any) error { + if len(body) == 0 { + return fmt.Errorf("empty body") + } + cells, err := boc.DeserializeBoc(body) + if err != nil { + return err + } + if len(cells) != 1 { + return fmt.Errorf("expected one root cell, got %d", len(cells)) + } + if err := tlb.Unmarshal(cells[0], dst); err != nil { + return err + } + // The body must be consumed exactly. That is the only defence against a layout that has + // drifted from the contract: a field read at the wrong width still decodes and still + // yields a plausible number, and nothing but the leftover bits says otherwise. + if left := cells[0].BitsAvailableForRead(); left != 0 { + return fmt.Errorf("%d bits left unread: the layout does not match the contract", left) + } + return nil +} diff --git a/pkg/core/hipo_logs_test.go b/pkg/core/hipo_logs_test.go new file mode 100644 index 000000000..d62a71826 --- /dev/null +++ b/pkg/core/hipo_logs_test.go @@ -0,0 +1,243 @@ +package core + +import ( + "encoding/hex" + "encoding/json" + "math/big" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tonkeeper/opentonapi/pkg/references" + "github.com/tonkeeper/tongo/boc" + "github.com/tonkeeper/tongo/tlb" + "github.com/tonkeeper/tongo/ton" +) + +// Real logs, taken from the treasury's own transactions on mainnet and identified by the +// topic in their destination address. Each is checked field by field against the log_* +// functions in the Hipo contracts, and unmarshalHipoLog additionally insists the body was +// consumed exactly - which is what would catch a layout drifting from the contract. +const ( + hipoLoanLogBody = "b5ee9c720101010100460000876a9e4f0850ba43b740007077038d7ea4c68000708f0db1157feef523aff0d8e07fff801a6d1cefedb5ca6bcc88f0a7300745ee3e0fbaf722aa9040af454ee47dc2d130d0" + hipoRepaymentLogBody = "b5ee9c7201010101004b0000916a944f08707268034320633703328b944c4000703f288f2fa449a601375194ce1c05344a2f57fd8007b591d71001a927c6fc6b00e107ffea04e4a60f955f4f6c6475b5d6c02703bcb0" + hipoFinishLogBody = "b5ee9c720101010100240000446a944f0870e4a290e8d093470e4c97b1b6a56c71c940a9e703cb07189eaf16dfb68a" +) + +func TestHipoParseLoanLog(t *testing.T) { + log, err := parseHipoLoanLog(mustHex(t, hipoLoanLogBody), hipoBorrowerFeeRelease+1) + require.NoError(t, err) + require.Equal(t, uint32(1788759816), log.RoundSince) + // A rate out of 65535, not an amount: 1799 is the share this borrower bid. + require.Equal(t, uint16(1799), log.BorrowerRewardShare) + require.EqualValues(t, "AddrStd", log.Borrower.SumType) + // The loan is the pool's money; the stake is the borrower's own, posted alongside it. + require.Greater(t, uint64(log.LoanAmount), uint64(0)) + require.Greater(t, uint64(log.StakeAmount), uint64(0)) + t.Logf("loan log: round=%d min_payment=%d share=%d loan=%d accrue=%d stake=%d fee=%d", + log.RoundSince, log.MinPayment, log.BorrowerRewardShare, log.LoanAmount, + log.AccrueAmount, log.StakeAmount, log.BorrowerFee) +} + +func TestHipoParseRepaymentLog(t *testing.T) { + // Recovered before 2026-09-05, so it is in the older six-amount layout and BurnShare + // is legitimately zero. + log, err := parseHipoRepaymentLog(mustHex(t, hipoRepaymentLogBody), hipoBorrowerFeeRelease-1) + require.NoError(t, err) + require.Zero(t, uint64(log.BurnShare)) + require.Equal(t, uint32(1788104456), log.RoundSince) + require.EqualValues(t, "AddrStd", log.Borrower.SumType) + // This is the whole reason the logs are read: the split exists in no message. + require.Greater(t, uint64(log.RepaymentAmount), uint64(0)) + require.Greater(t, uint64(log.StakersShare), uint64(0)) + // A repayment covers the loan and then some, and what it pays out cannot exceed it. + require.GreaterOrEqual(t, uint64(log.RepaymentAmount), uint64(log.LoanAmount)) + split := uint64(log.StakersShare) + uint64(log.GovernorShare) + + uint64(log.BorrowerShare) + uint64(log.BurnShare) + require.LessOrEqual(t, split, uint64(log.RepaymentAmount)) + t.Logf("repayment log: round=%d repaid=%d loan=%d accrue=%d stakers=%d governor=%d borrower=%d burn=%d", + log.RoundSince, log.RepaymentAmount, log.LoanAmount, log.AccrueAmount, + log.StakersShare, log.GovernorShare, log.BorrowerShare, log.BurnShare) +} + +func TestHipoParseFinishLog(t *testing.T) { + var log HipoFinishLog + require.NoError(t, unmarshalHipoLog(mustHex(t, hipoFinishLogBody), &log)) + require.Equal(t, uint32(1788104456), log.RoundSince) + // total_coins and total_tokens are the exchange rate at the moment the round finished, + // so both have to be there for the rate to be derivable. + require.Greater(t, uint64(log.TotalCoins), uint64(0)) + require.Greater(t, uint64(log.TotalTokens), uint64(0)) + // A round that was lent out and recovered more than it staked is the normal case. + require.GreaterOrEqual(t, uint64(log.TotalRecovered), uint64(log.TotalStaked)) + t.Logf("finish log: round=%d staked=%d recovered=%d total_coins=%d total_tokens=%d rate=%.6f", + log.RoundSince, log.TotalStaked, log.TotalRecovered, log.TotalCoins, log.TotalTokens, + float64(log.TotalCoins)/float64(log.TotalTokens)) +} + +func TestHipoParseLogRejectsShortBody(t *testing.T) { + // a body that is one field short of its layout must be refused rather than half-read + var log HipoFinishLog + require.Error(t, unmarshalHipoLog(mustHex(t, hipoRepaymentLogBody), &log), + "a repayment body read as a finish log leaves bits over and must not be accepted") +} + +func TestHipoParseAllLogsInCorpus(t *testing.T) { + // No body anywhere may be left half-read, since a half-read body is exactly what a + // layout that has drifted from the contract looks like. Both logs changed shape with the + // 2026-09-05 borrower-fee release, and the corpus spans it, so this also covers the + // fallback to the older layouts. + dir := os.Getenv("REPLAY_DIR") + if dir == "" { + t.Skip("set REPLAY_DIR to the directory holding log_samples.json") + } + raw, err := os.ReadFile(filepath.Join(dir, "log_samples.json")) + if err != nil { + t.Skipf("no corpus: %v", err) + } + byTopic := map[string][]string{} + require.NoError(t, json.Unmarshal(raw, &byTopic)) + counts := map[string]int{} + for topic, bodies := range byTopic { + for _, b := range bodies { + body := mustHex(t, b) + switch topic[len(topic)-1] { + case '1': + // the samples carry no time, so either era may be the right one + log, err := parseHipoLoanLog(body, hipoBorrowerFeeRelease) + if err != nil { + log, err = parseHipoLoanLog(body, 1) + } + require.NoErrorf(t, err, "loan log %s", b) + require.NotZero(t, log.RoundSince) + require.LessOrEqual(t, uint64(log.BorrowerRewardShare), uint64(65535)) + case '2': + log, err := parseHipoRepaymentLog(body, hipoBorrowerFeeRelease) + if err != nil { + log, err = parseHipoRepaymentLog(body, 1) + } + require.NoErrorf(t, err, "repayment log %s", b) + require.NotZero(t, log.RoundSince) + split := uint64(log.StakersShare) + uint64(log.GovernorShare) + + uint64(log.BorrowerShare) + uint64(log.BurnShare) + require.LessOrEqualf(t, split, uint64(log.RepaymentAmount), + "a repayment cannot pay out more than it brought in: %s", b) + case '3': + var log HipoFinishLog + require.NoErrorf(t, unmarshalHipoLog(body, &log), "finish log %s", b) + require.NotZero(t, log.TotalTokens) + default: + t.Fatalf("unexpected topic %s", topic) + } + counts[topic]++ + } + } + total := 0 + for topic, n := range counts { + t.Logf("topic ...%s: %d logs parsed, none left half-read", topic[len(topic)-4:], n) + total += n + } + require.Greater(t, total, 100, "corpus should not have shrunk") +} + +func TestHipoLogsDecodeOnlyFromTheTreasury(t *testing.T) { + // a topic is an address any contract can write to, so only the treasury's logs are decoded + topic := boc.NewBitString(256) + require.NoError(t, topic.WriteBigUint(big.NewInt(int64(HipoLogRepayment)), 256)) + body := mustHex(t, hipoRepaymentLogBody) + treasury := references.HipoTreasury + decoded, ok := decodeHipoLog(&treasury, &topic, body, hipoBorrowerFeeRelease-1) + require.True(t, ok) + require.Equal(t, "HipoFinanceLogRepayment", decoded.Operation) + require.IsType(t, HipoRepaymentLog{}, decoded.Value) + other := ton.MustParseAccountID("0:1111111111111111111111111111111111111111111111111111111111111111") + _, ok = decodeHipoLog(&other, &topic, body, hipoBorrowerFeeRelease-1) + require.False(t, ok) + // A body of another topic's layout is left undecoded rather than half-read. + finish := boc.NewBitString(256) + require.NoError(t, finish.WriteBigUint(big.NewInt(int64(HipoLogFinish)), 256)) + _, ok = decodeHipoLog(&treasury, &finish, body, hipoBorrowerFeeRelease-1) + require.False(t, ok) +} + +func TestHipoLoanLogEraIsByTime(t *testing.T) { + // An old loan log can fit the new layout exactly and read as nonsense, so the layout is + // chosen by when the log was written. The figures are such + // a log, one whose old fields also parse cleanly as the new layout. + borrower := ton.MustParseAccountID("0:7a228a801e58bdf435a39c93c8efb5a3736b58455381c04c7322cda3512b5bd4") + old := hipoLoanLogV1{ + RoundSince: 1788000000, + MinPayment: 544807109759, + BorrowerRewardShare: 17, + LoanAmount: 1662881220370992, + AccrueAmount: 124844006369212, + StakeAmount: 1486722724167, + Borrower: borrower.ToMsgAddress(), + } + cell := boc.NewCell() + require.NoError(t, tlb.Marshal(cell, old)) + body, err := cell.ToBoc() + require.NoError(t, err) + var misread HipoLoanLog + require.NoError(t, unmarshalHipoLog(body, &misread), "the old body fits the new layout too") + require.NotEqual(t, old.LoanAmount, misread.LoanAmount) + log, err := parseHipoLoanLog(body, hipoBorrowerFeeRelease-1) + require.NoError(t, err) + require.Equal(t, old.LoanAmount, log.LoanAmount) + require.Equal(t, old.AccrueAmount, log.AccrueAmount) + require.Equal(t, uint16(17*257), log.BorrowerRewardShare) + // a log of unknown time is taken as current + unknown, err := parseHipoLoanLog(body, 0) + require.NoError(t, err) + require.Equal(t, misread, unknown) +} + +func TestHipoBurnerLogsDecodeOnlyFromTheBurner(t *testing.T) { + topic := boc.NewBitString(256) + require.NoError(t, topic.WriteBigUint(big.NewInt(3), 256)) + // a real swap log, written when the burner sold 15.647 hGRAM + body := mustHex(t, "b5ee9c720101010100160000280000000000000185503a4a5123e60154ee22f17f") + burner := references.HipoBurner + decoded, ok := decodeHipoBurnerLog(&burner, &topic, body) + require.True(t, ok) + require.Equal(t, "HipoBurnerLogSwap", decoded.Operation) + require.Equal(t, HipoBurnerSwapLog{QueryId: 389, Amount: 15647183422, TotalSwapped: 1464284148095}, decoded.Value) + treasury := references.HipoTreasury + _, ok = decodeHipoBurnerLog(&treasury, &topic, body) + require.False(t, ok) +} + +func TestHipoDecodeLog(t *testing.T) { + topic := boc.NewBitString(256) + require.NoError(t, topic.WriteBigUint(big.NewInt(int64(HipoLogRepayment)), 256)) + msg := Message{MsgType: ExtOutMsg, DestinationExtern: &topic, Body: mustHex(t, hipoRepaymentLogBody), CreatedAt: hipoBorrowerFeeRelease - 1} + t.Run("parses the body when nothing decoded it", func(t *testing.T) { + got, value, ok := DecodeHipoLog(msg) + require.True(t, ok) + require.Equal(t, HipoLogRepayment, got) + require.IsType(t, HipoRepaymentLog{}, value) + }) + t.Run("reuses the decoded body", func(t *testing.T) { + decoded := HipoRepaymentLog{RoundSince: 42} + reused := msg + reused.Body = []byte{1, 2, 3} + reused.DecodedBody = &DecodedMessageBody{Operation: "HipoFinanceLogRepayment", Value: decoded} + _, value, ok := DecodeHipoLog(reused) + require.True(t, ok) + require.Equal(t, decoded, value) + }) + t.Run("a body that does not fit is refused", func(t *testing.T) { + unknownTime := msg + unknownTime.CreatedAt = 0 + _, _, ok := DecodeHipoLog(unknownTime) + require.False(t, ok) + }) +} + +func mustHex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + require.NoError(t, err) + return b +} diff --git a/pkg/oas/oas_json_gen.go b/pkg/oas/oas_json_gen.go index c5cc73469..3f2c4c25c 100644 --- a/pkg/oas/oas_json_gen.go +++ b/pkg/oas/oas_json_gen.go @@ -2038,6 +2038,30 @@ func (s *Action) encodeFields(e *jx.Encoder) { s.BuyXTR.Encode(e) } } + { + if s.LoanRequest.Set { + e.FieldStart("LoanRequest") + s.LoanRequest.Encode(e) + } + } + { + if s.LoanIssue.Set { + e.FieldStart("LoanIssue") + s.LoanIssue.Encode(e) + } + } + { + if s.LoanRepayment.Set { + e.FieldStart("LoanRepayment") + s.LoanRepayment.Encode(e) + } + } + { + if s.PoolRoundTransition.Set { + e.FieldStart("PoolRoundTransition") + s.PoolRoundTransition.Encode(e) + } + } { e.FieldStart("simple_preview") s.SimplePreview.Encode(e) @@ -2052,7 +2076,7 @@ func (s *Action) encodeFields(e *jx.Encoder) { } } -var jsonFieldsNameOfAction = [36]string{ +var jsonFieldsNameOfAction = [40]string{ 0: "type", 1: "status", 2: "TonTransfer", @@ -2087,8 +2111,12 @@ var jsonFieldsNameOfAction = [36]string{ 31: "WithdrawXTR", 32: "DepositXTR", 33: "BuyXTR", - 34: "simple_preview", - 35: "base_transactions", + 34: "LoanRequest", + 35: "LoanIssue", + 36: "LoanRepayment", + 37: "PoolRoundTransition", + 38: "simple_preview", + 39: "base_transactions", } // Decode decodes Action from json. @@ -2440,8 +2468,48 @@ func (s *Action) Decode(d *jx.Decoder) error { }(); err != nil { return errors.Wrap(err, "decode field \"BuyXTR\"") } + case "LoanRequest": + if err := func() error { + s.LoanRequest.Reset() + if err := s.LoanRequest.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"LoanRequest\"") + } + case "LoanIssue": + if err := func() error { + s.LoanIssue.Reset() + if err := s.LoanIssue.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"LoanIssue\"") + } + case "LoanRepayment": + if err := func() error { + s.LoanRepayment.Reset() + if err := s.LoanRepayment.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"LoanRepayment\"") + } + case "PoolRoundTransition": + if err := func() error { + s.PoolRoundTransition.Reset() + if err := s.PoolRoundTransition.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"PoolRoundTransition\"") + } case "simple_preview": - requiredBitSet[4] |= 1 << 2 + requiredBitSet[4] |= 1 << 6 if err := func() error { if err := s.SimplePreview.Decode(d); err != nil { return err @@ -2451,7 +2519,7 @@ func (s *Action) Decode(d *jx.Decoder) error { return errors.Wrap(err, "decode field \"simple_preview\"") } case "base_transactions": - requiredBitSet[4] |= 1 << 3 + requiredBitSet[4] |= 1 << 7 if err := func() error { s.BaseTransactions = make([]string, 0) if err := d.Arr(func(d *jx.Decoder) error { @@ -2484,7 +2552,7 @@ func (s *Action) Decode(d *jx.Decoder) error { 0b00000000, 0b00000000, 0b00000000, - 0b00001100, + 0b11000000, } { if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { // Mask only required fields and check equality to mask using XOR. @@ -3056,6 +3124,14 @@ func (s *ActionType) Decode(d *jx.Decoder) error { *s = ActionTypeDepositXTR case ActionTypeWithdrawXTR: *s = ActionTypeWithdrawXTR + case ActionTypeLoanRequest: + *s = ActionTypeLoanRequest + case ActionTypeLoanIssue: + *s = ActionTypeLoanIssue + case ActionTypeLoanRepayment: + *s = ActionTypeLoanRepayment + case ActionTypePoolRoundTransition: + *s = ActionTypePoolRoundTransition case ActionTypeUnknown: *s = ActionTypeUnknown default: @@ -29491,6 +29567,716 @@ func (s *LiquidityDepositAction) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode implements json.Marshaler. +func (s *LoanIssueAction) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *LoanIssueAction) encodeFields(e *jx.Encoder) { + { + e.FieldStart("borrower") + s.Borrower.Encode(e) + } + { + e.FieldStart("pool") + s.Pool.Encode(e) + } + { + e.FieldStart("loan") + s.Loan.Encode(e) + } + { + e.FieldStart("round_since") + e.Int64(s.RoundSince) + } + { + e.FieldStart("amount") + e.Int64(s.Amount) + } + { + e.FieldStart("accrued") + e.Int64(s.Accrued) + } + { + e.FieldStart("collateral") + e.Int64(s.Collateral) + } + { + e.FieldStart("min_payment") + e.Int64(s.MinPayment) + } + { + e.FieldStart("implementation") + s.Implementation.Encode(e) + } +} + +var jsonFieldsNameOfLoanIssueAction = [9]string{ + 0: "borrower", + 1: "pool", + 2: "loan", + 3: "round_since", + 4: "amount", + 5: "accrued", + 6: "collateral", + 7: "min_payment", + 8: "implementation", +} + +// Decode decodes LoanIssueAction from json. +func (s *LoanIssueAction) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode LoanIssueAction to nil") + } + var requiredBitSet [2]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "borrower": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + if err := s.Borrower.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"borrower\"") + } + case "pool": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + if err := s.Pool.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"pool\"") + } + case "loan": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + if err := s.Loan.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"loan\"") + } + case "round_since": + requiredBitSet[0] |= 1 << 3 + if err := func() error { + v, err := d.Int64() + s.RoundSince = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"round_since\"") + } + case "amount": + requiredBitSet[0] |= 1 << 4 + if err := func() error { + v, err := d.Int64() + s.Amount = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"amount\"") + } + case "accrued": + requiredBitSet[0] |= 1 << 5 + if err := func() error { + v, err := d.Int64() + s.Accrued = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"accrued\"") + } + case "collateral": + requiredBitSet[0] |= 1 << 6 + if err := func() error { + v, err := d.Int64() + s.Collateral = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"collateral\"") + } + case "min_payment": + requiredBitSet[0] |= 1 << 7 + if err := func() error { + v, err := d.Int64() + s.MinPayment = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"min_payment\"") + } + case "implementation": + requiredBitSet[1] |= 1 << 0 + if err := func() error { + if err := s.Implementation.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"implementation\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode LoanIssueAction") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [2]uint8{ + 0b11111111, + 0b00000001, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfLoanIssueAction) { + name = jsonFieldsNameOfLoanIssueAction[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *LoanIssueAction) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *LoanIssueAction) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *LoanRepaymentAction) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *LoanRepaymentAction) encodeFields(e *jx.Encoder) { + { + e.FieldStart("borrower") + s.Borrower.Encode(e) + } + { + e.FieldStart("pool") + s.Pool.Encode(e) + } + { + e.FieldStart("loan") + s.Loan.Encode(e) + } + { + e.FieldStart("round_since") + e.Int64(s.RoundSince) + } + { + e.FieldStart("amount") + e.Int64(s.Amount) + } + { + e.FieldStart("accrued") + e.Int64(s.Accrued) + } + { + e.FieldStart("recovered") + e.Int64(s.Recovered) + } + { + e.FieldStart("pool_reward") + e.Int64(s.PoolReward) + } + { + e.FieldStart("protocol_fee") + e.Int64(s.ProtocolFee) + } + { + e.FieldStart("borrower_payout") + e.Int64(s.BorrowerPayout) + } + { + e.FieldStart("burn_amount") + e.Int64(s.BurnAmount) + } + { + if s.Shortfall.Set { + e.FieldStart("shortfall") + s.Shortfall.Encode(e) + } + } + { + e.FieldStart("implementation") + s.Implementation.Encode(e) + } +} + +var jsonFieldsNameOfLoanRepaymentAction = [13]string{ + 0: "borrower", + 1: "pool", + 2: "loan", + 3: "round_since", + 4: "amount", + 5: "accrued", + 6: "recovered", + 7: "pool_reward", + 8: "protocol_fee", + 9: "borrower_payout", + 10: "burn_amount", + 11: "shortfall", + 12: "implementation", +} + +// Decode decodes LoanRepaymentAction from json. +func (s *LoanRepaymentAction) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode LoanRepaymentAction to nil") + } + var requiredBitSet [2]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "borrower": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + if err := s.Borrower.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"borrower\"") + } + case "pool": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + if err := s.Pool.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"pool\"") + } + case "loan": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + if err := s.Loan.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"loan\"") + } + case "round_since": + requiredBitSet[0] |= 1 << 3 + if err := func() error { + v, err := d.Int64() + s.RoundSince = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"round_since\"") + } + case "amount": + requiredBitSet[0] |= 1 << 4 + if err := func() error { + v, err := d.Int64() + s.Amount = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"amount\"") + } + case "accrued": + requiredBitSet[0] |= 1 << 5 + if err := func() error { + v, err := d.Int64() + s.Accrued = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"accrued\"") + } + case "recovered": + requiredBitSet[0] |= 1 << 6 + if err := func() error { + v, err := d.Int64() + s.Recovered = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"recovered\"") + } + case "pool_reward": + requiredBitSet[0] |= 1 << 7 + if err := func() error { + v, err := d.Int64() + s.PoolReward = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"pool_reward\"") + } + case "protocol_fee": + requiredBitSet[1] |= 1 << 0 + if err := func() error { + v, err := d.Int64() + s.ProtocolFee = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"protocol_fee\"") + } + case "borrower_payout": + requiredBitSet[1] |= 1 << 1 + if err := func() error { + v, err := d.Int64() + s.BorrowerPayout = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"borrower_payout\"") + } + case "burn_amount": + requiredBitSet[1] |= 1 << 2 + if err := func() error { + v, err := d.Int64() + s.BurnAmount = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"burn_amount\"") + } + case "shortfall": + if err := func() error { + s.Shortfall.Reset() + if err := s.Shortfall.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"shortfall\"") + } + case "implementation": + requiredBitSet[1] |= 1 << 4 + if err := func() error { + if err := s.Implementation.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"implementation\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode LoanRepaymentAction") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [2]uint8{ + 0b11111111, + 0b00010111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfLoanRepaymentAction) { + name = jsonFieldsNameOfLoanRepaymentAction[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *LoanRepaymentAction) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *LoanRepaymentAction) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *LoanRequestAction) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *LoanRequestAction) encodeFields(e *jx.Encoder) { + { + e.FieldStart("borrower") + s.Borrower.Encode(e) + } + { + e.FieldStart("pool") + s.Pool.Encode(e) + } + { + e.FieldStart("round_since") + e.Int64(s.RoundSince) + } + { + if s.Amount.Set { + e.FieldStart("amount") + s.Amount.Encode(e) + } + } + { + if s.MinPayment.Set { + e.FieldStart("min_payment") + s.MinPayment.Encode(e) + } + } + { + e.FieldStart("ton_attached") + e.Int64(s.TonAttached) + } + { + e.FieldStart("implementation") + s.Implementation.Encode(e) + } +} + +var jsonFieldsNameOfLoanRequestAction = [7]string{ + 0: "borrower", + 1: "pool", + 2: "round_since", + 3: "amount", + 4: "min_payment", + 5: "ton_attached", + 6: "implementation", +} + +// Decode decodes LoanRequestAction from json. +func (s *LoanRequestAction) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode LoanRequestAction to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "borrower": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + if err := s.Borrower.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"borrower\"") + } + case "pool": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + if err := s.Pool.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"pool\"") + } + case "round_since": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := d.Int64() + s.RoundSince = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"round_since\"") + } + case "amount": + if err := func() error { + s.Amount.Reset() + if err := s.Amount.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"amount\"") + } + case "min_payment": + if err := func() error { + s.MinPayment.Reset() + if err := s.MinPayment.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"min_payment\"") + } + case "ton_attached": + requiredBitSet[0] |= 1 << 5 + if err := func() error { + v, err := d.Int64() + s.TonAttached = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"ton_attached\"") + } + case "implementation": + requiredBitSet[0] |= 1 << 6 + if err := func() error { + if err := s.Implementation.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"implementation\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode LoanRequestAction") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b01100111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfLoanRequestAction) { + name = jsonFieldsNameOfLoanRequestAction[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *LoanRequestAction) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *LoanRequestAction) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode implements json.Marshaler. func (s *MarketTonRates) Encode(e *jx.Encoder) { e.ObjStart() @@ -38260,6 +39046,105 @@ func (s *OptLiquidityDepositAction) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes LoanIssueAction as json. +func (o OptLoanIssueAction) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes LoanIssueAction from json. +func (o *OptLoanIssueAction) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptLoanIssueAction to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptLoanIssueAction) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptLoanIssueAction) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes LoanRepaymentAction as json. +func (o OptLoanRepaymentAction) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes LoanRepaymentAction from json. +func (o *OptLoanRepaymentAction) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptLoanRepaymentAction to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptLoanRepaymentAction) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptLoanRepaymentAction) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes LoanRequestAction as json. +func (o OptLoanRequestAction) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes LoanRequestAction from json. +func (o *OptLoanRequestAction) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptLoanRequestAction to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptLoanRequestAction) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptLoanRequestAction) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode encodes Message as json. func (o OptMessage) Encode(e *jx.Encoder) { if !o.Set { @@ -38826,6 +39711,39 @@ func (s *OptPictureDNS) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes PoolRoundTransitionAction as json. +func (o OptPoolRoundTransitionAction) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes PoolRoundTransitionAction from json. +func (o *OptPoolRoundTransitionAction) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptPoolRoundTransitionAction to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptPoolRoundTransitionAction) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptPoolRoundTransitionAction) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode encodes Price as json. func (o OptPrice) Encode(e *jx.Encoder) { if !o.Set { @@ -41034,6 +41952,227 @@ func (s *PoolInfo) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes PoolRoundTransition as json. +func (s PoolRoundTransition) Encode(e *jx.Encoder) { + e.Str(string(s)) +} + +// Decode decodes PoolRoundTransition from json. +func (s *PoolRoundTransition) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PoolRoundTransition to nil") + } + v, err := d.StrBytes() + if err != nil { + return err + } + // Try to use constant string. + switch PoolRoundTransition(v) { + case PoolRoundTransitionElection: + *s = PoolRoundTransitionElection + case PoolRoundTransitionBegan: + *s = PoolRoundTransitionBegan + case PoolRoundTransitionValidated: + *s = PoolRoundTransitionValidated + case PoolRoundTransitionEnded: + *s = PoolRoundTransitionEnded + case PoolRoundTransitionSettled: + *s = PoolRoundTransitionSettled + default: + *s = PoolRoundTransition(v) + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s PoolRoundTransition) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PoolRoundTransition) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *PoolRoundTransitionAction) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *PoolRoundTransitionAction) encodeFields(e *jx.Encoder) { + { + e.FieldStart("pool") + s.Pool.Encode(e) + } + { + e.FieldStart("round_since") + e.Int64(s.RoundSince) + } + { + e.FieldStart("transition") + s.Transition.Encode(e) + } + { + if s.TotalStaked.Set { + e.FieldStart("total_staked") + s.TotalStaked.Encode(e) + } + } + { + if s.TotalRecovered.Set { + e.FieldStart("total_recovered") + s.TotalRecovered.Encode(e) + } + } + { + e.FieldStart("implementation") + s.Implementation.Encode(e) + } +} + +var jsonFieldsNameOfPoolRoundTransitionAction = [6]string{ + 0: "pool", + 1: "round_since", + 2: "transition", + 3: "total_staked", + 4: "total_recovered", + 5: "implementation", +} + +// Decode decodes PoolRoundTransitionAction from json. +func (s *PoolRoundTransitionAction) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PoolRoundTransitionAction to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "pool": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + if err := s.Pool.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"pool\"") + } + case "round_since": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Int64() + s.RoundSince = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"round_since\"") + } + case "transition": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + if err := s.Transition.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"transition\"") + } + case "total_staked": + if err := func() error { + s.TotalStaked.Reset() + if err := s.TotalStaked.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"total_staked\"") + } + case "total_recovered": + if err := func() error { + s.TotalRecovered.Reset() + if err := s.TotalRecovered.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"total_recovered\"") + } + case "implementation": + requiredBitSet[0] |= 1 << 5 + if err := func() error { + if err := s.Implementation.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"implementation\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode PoolRoundTransitionAction") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00100111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfPoolRoundTransitionAction) { + name = jsonFieldsNameOfPoolRoundTransitionAction[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *PoolRoundTransitionAction) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PoolRoundTransitionAction) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode implements json.Marshaler. func (s *Price) Encode(e *jx.Encoder) { e.ObjStart() diff --git a/pkg/oas/oas_schemas_gen.go b/pkg/oas/oas_schemas_gen.go index 7b7fedff9..c0fc7cc99 100644 --- a/pkg/oas/oas_schemas_gen.go +++ b/pkg/oas/oas_schemas_gen.go @@ -743,6 +743,10 @@ type Action struct { WithdrawXTR OptWithdrawXTRAction `json:"WithdrawXTR"` DepositXTR OptDepositXTRAction `json:"DepositXTR"` BuyXTR OptBuyXTRAction `json:"BuyXTR"` + LoanRequest OptLoanRequestAction `json:"LoanRequest"` + LoanIssue OptLoanIssueAction `json:"LoanIssue"` + LoanRepayment OptLoanRepaymentAction `json:"LoanRepayment"` + PoolRoundTransition OptPoolRoundTransitionAction `json:"PoolRoundTransition"` SimplePreview ActionSimplePreview `json:"simple_preview"` BaseTransactions []string `json:"base_transactions"` } @@ -917,6 +921,26 @@ func (s *Action) GetBuyXTR() OptBuyXTRAction { return s.BuyXTR } +// GetLoanRequest returns the value of LoanRequest. +func (s *Action) GetLoanRequest() OptLoanRequestAction { + return s.LoanRequest +} + +// GetLoanIssue returns the value of LoanIssue. +func (s *Action) GetLoanIssue() OptLoanIssueAction { + return s.LoanIssue +} + +// GetLoanRepayment returns the value of LoanRepayment. +func (s *Action) GetLoanRepayment() OptLoanRepaymentAction { + return s.LoanRepayment +} + +// GetPoolRoundTransition returns the value of PoolRoundTransition. +func (s *Action) GetPoolRoundTransition() OptPoolRoundTransitionAction { + return s.PoolRoundTransition +} + // GetSimplePreview returns the value of SimplePreview. func (s *Action) GetSimplePreview() ActionSimplePreview { return s.SimplePreview @@ -1097,6 +1121,26 @@ func (s *Action) SetBuyXTR(val OptBuyXTRAction) { s.BuyXTR = val } +// SetLoanRequest sets the value of LoanRequest. +func (s *Action) SetLoanRequest(val OptLoanRequestAction) { + s.LoanRequest = val +} + +// SetLoanIssue sets the value of LoanIssue. +func (s *Action) SetLoanIssue(val OptLoanIssueAction) { + s.LoanIssue = val +} + +// SetLoanRepayment sets the value of LoanRepayment. +func (s *Action) SetLoanRepayment(val OptLoanRepaymentAction) { + s.LoanRepayment = val +} + +// SetPoolRoundTransition sets the value of PoolRoundTransition. +func (s *Action) SetPoolRoundTransition(val OptPoolRoundTransitionAction) { + s.PoolRoundTransition = val +} + // SetSimplePreview sets the value of SimplePreview. func (s *Action) SetSimplePreview(val ActionSimplePreview) { s.SimplePreview = val @@ -1349,6 +1393,10 @@ const ( ActionTypeBuyXTR ActionType = "BuyXTR" ActionTypeDepositXTR ActionType = "DepositXTR" ActionTypeWithdrawXTR ActionType = "WithdrawXTR" + ActionTypeLoanRequest ActionType = "LoanRequest" + ActionTypeLoanIssue ActionType = "LoanIssue" + ActionTypeLoanRepayment ActionType = "LoanRepayment" + ActionTypePoolRoundTransition ActionType = "PoolRoundTransition" ActionTypeUnknown ActionType = "Unknown" ) @@ -1387,6 +1435,10 @@ func (ActionType) AllValues() []ActionType { ActionTypeBuyXTR, ActionTypeDepositXTR, ActionTypeWithdrawXTR, + ActionTypeLoanRequest, + ActionTypeLoanIssue, + ActionTypeLoanRepayment, + ActionTypePoolRoundTransition, ActionTypeUnknown, } } @@ -1458,6 +1510,14 @@ func (s ActionType) MarshalText() ([]byte, error) { return []byte(s), nil case ActionTypeWithdrawXTR: return []byte(s), nil + case ActionTypeLoanRequest: + return []byte(s), nil + case ActionTypeLoanIssue: + return []byte(s), nil + case ActionTypeLoanRepayment: + return []byte(s), nil + case ActionTypePoolRoundTransition: + return []byte(s), nil case ActionTypeUnknown: return []byte(s), nil default: @@ -1564,6 +1624,18 @@ func (s *ActionType) UnmarshalText(data []byte) error { case ActionTypeWithdrawXTR: *s = ActionTypeWithdrawXTR return nil + case ActionTypeLoanRequest: + *s = ActionTypeLoanRequest + return nil + case ActionTypeLoanIssue: + *s = ActionTypeLoanIssue + return nil + case ActionTypeLoanRepayment: + *s = ActionTypeLoanRepayment + return nil + case ActionTypePoolRoundTransition: + *s = ActionTypePoolRoundTransition + return nil case ActionTypeUnknown: *s = ActionTypeUnknown return nil @@ -10214,6 +10286,362 @@ func (s *LiquidityDepositAction) SetTokens(val []VaultDepositInfo) { s.Tokens = val } +// A staking pool lending to a validator, which stakes the loan with the elector. +// Ref: #/components/schemas/LoanIssueAction +type LoanIssueAction struct { + Borrower AccountAddress `json:"borrower"` + Pool AccountAddress `json:"pool"` + // Contract that holds the loan and stakes it. + Loan AccountAddress `json:"loan"` + // Start of the validation round the loan is for, as its unix time. + RoundSince int64 `json:"round_since"` + // Loan granted, in nanograms. + Amount int64 `json:"amount"` + // Pool coins added to the loan beyond what was asked for, in nanograms. + Accrued int64 `json:"accrued"` + // The borrower's own coins staked alongside the loan, in nanograms. + Collateral int64 `json:"collateral"` + // Least the borrower pays the pool from the round's reward, in nanograms. + MinPayment int64 `json:"min_payment"` + Implementation PoolImplementationType `json:"implementation"` +} + +// GetBorrower returns the value of Borrower. +func (s *LoanIssueAction) GetBorrower() AccountAddress { + return s.Borrower +} + +// GetPool returns the value of Pool. +func (s *LoanIssueAction) GetPool() AccountAddress { + return s.Pool +} + +// GetLoan returns the value of Loan. +func (s *LoanIssueAction) GetLoan() AccountAddress { + return s.Loan +} + +// GetRoundSince returns the value of RoundSince. +func (s *LoanIssueAction) GetRoundSince() int64 { + return s.RoundSince +} + +// GetAmount returns the value of Amount. +func (s *LoanIssueAction) GetAmount() int64 { + return s.Amount +} + +// GetAccrued returns the value of Accrued. +func (s *LoanIssueAction) GetAccrued() int64 { + return s.Accrued +} + +// GetCollateral returns the value of Collateral. +func (s *LoanIssueAction) GetCollateral() int64 { + return s.Collateral +} + +// GetMinPayment returns the value of MinPayment. +func (s *LoanIssueAction) GetMinPayment() int64 { + return s.MinPayment +} + +// GetImplementation returns the value of Implementation. +func (s *LoanIssueAction) GetImplementation() PoolImplementationType { + return s.Implementation +} + +// SetBorrower sets the value of Borrower. +func (s *LoanIssueAction) SetBorrower(val AccountAddress) { + s.Borrower = val +} + +// SetPool sets the value of Pool. +func (s *LoanIssueAction) SetPool(val AccountAddress) { + s.Pool = val +} + +// SetLoan sets the value of Loan. +func (s *LoanIssueAction) SetLoan(val AccountAddress) { + s.Loan = val +} + +// SetRoundSince sets the value of RoundSince. +func (s *LoanIssueAction) SetRoundSince(val int64) { + s.RoundSince = val +} + +// SetAmount sets the value of Amount. +func (s *LoanIssueAction) SetAmount(val int64) { + s.Amount = val +} + +// SetAccrued sets the value of Accrued. +func (s *LoanIssueAction) SetAccrued(val int64) { + s.Accrued = val +} + +// SetCollateral sets the value of Collateral. +func (s *LoanIssueAction) SetCollateral(val int64) { + s.Collateral = val +} + +// SetMinPayment sets the value of MinPayment. +func (s *LoanIssueAction) SetMinPayment(val int64) { + s.MinPayment = val +} + +// SetImplementation sets the value of Implementation. +func (s *LoanIssueAction) SetImplementation(val PoolImplementationType) { + s.Implementation = val +} + +// A loan coming back from the elector and its reward being split. +// Ref: #/components/schemas/LoanRepaymentAction +type LoanRepaymentAction struct { + Borrower AccountAddress `json:"borrower"` + Pool AccountAddress `json:"pool"` + Loan AccountAddress `json:"loan"` + // Start of the validation round the loan is for, as its unix time. + RoundSince int64 `json:"round_since"` + // Loan that was granted, in nanograms. + Amount int64 `json:"amount"` + // Pool coins added to the loan beyond what was asked for, in nanograms. + Accrued int64 `json:"accrued"` + // What came back to the pool: the stake and the reward, less what the loan contract spent to forward + // it, in nanograms. + Recovered int64 `json:"recovered"` + // What the pool's stakers earned, in nanograms. + PoolReward int64 `json:"pool_reward"` + // The pool operator's share of the pool's reward, in nanograms; the operator may also receive what + // is left over from fees. + ProtocolFee int64 `json:"protocol_fee"` + // Returned to the borrower: collateral and the borrower's reward, in nanograms. + BorrowerPayout int64 `json:"borrower_payout"` + // Taken from the borrower's reward to be burned, in nanograms. + BurnAmount int64 `json:"burn_amount"` + // Part of the loan the elector did not return and the collateral could not cover, in nanograms. + Shortfall OptInt64 `json:"shortfall"` + Implementation PoolImplementationType `json:"implementation"` +} + +// GetBorrower returns the value of Borrower. +func (s *LoanRepaymentAction) GetBorrower() AccountAddress { + return s.Borrower +} + +// GetPool returns the value of Pool. +func (s *LoanRepaymentAction) GetPool() AccountAddress { + return s.Pool +} + +// GetLoan returns the value of Loan. +func (s *LoanRepaymentAction) GetLoan() AccountAddress { + return s.Loan +} + +// GetRoundSince returns the value of RoundSince. +func (s *LoanRepaymentAction) GetRoundSince() int64 { + return s.RoundSince +} + +// GetAmount returns the value of Amount. +func (s *LoanRepaymentAction) GetAmount() int64 { + return s.Amount +} + +// GetAccrued returns the value of Accrued. +func (s *LoanRepaymentAction) GetAccrued() int64 { + return s.Accrued +} + +// GetRecovered returns the value of Recovered. +func (s *LoanRepaymentAction) GetRecovered() int64 { + return s.Recovered +} + +// GetPoolReward returns the value of PoolReward. +func (s *LoanRepaymentAction) GetPoolReward() int64 { + return s.PoolReward +} + +// GetProtocolFee returns the value of ProtocolFee. +func (s *LoanRepaymentAction) GetProtocolFee() int64 { + return s.ProtocolFee +} + +// GetBorrowerPayout returns the value of BorrowerPayout. +func (s *LoanRepaymentAction) GetBorrowerPayout() int64 { + return s.BorrowerPayout +} + +// GetBurnAmount returns the value of BurnAmount. +func (s *LoanRepaymentAction) GetBurnAmount() int64 { + return s.BurnAmount +} + +// GetShortfall returns the value of Shortfall. +func (s *LoanRepaymentAction) GetShortfall() OptInt64 { + return s.Shortfall +} + +// GetImplementation returns the value of Implementation. +func (s *LoanRepaymentAction) GetImplementation() PoolImplementationType { + return s.Implementation +} + +// SetBorrower sets the value of Borrower. +func (s *LoanRepaymentAction) SetBorrower(val AccountAddress) { + s.Borrower = val +} + +// SetPool sets the value of Pool. +func (s *LoanRepaymentAction) SetPool(val AccountAddress) { + s.Pool = val +} + +// SetLoan sets the value of Loan. +func (s *LoanRepaymentAction) SetLoan(val AccountAddress) { + s.Loan = val +} + +// SetRoundSince sets the value of RoundSince. +func (s *LoanRepaymentAction) SetRoundSince(val int64) { + s.RoundSince = val +} + +// SetAmount sets the value of Amount. +func (s *LoanRepaymentAction) SetAmount(val int64) { + s.Amount = val +} + +// SetAccrued sets the value of Accrued. +func (s *LoanRepaymentAction) SetAccrued(val int64) { + s.Accrued = val +} + +// SetRecovered sets the value of Recovered. +func (s *LoanRepaymentAction) SetRecovered(val int64) { + s.Recovered = val +} + +// SetPoolReward sets the value of PoolReward. +func (s *LoanRepaymentAction) SetPoolReward(val int64) { + s.PoolReward = val +} + +// SetProtocolFee sets the value of ProtocolFee. +func (s *LoanRepaymentAction) SetProtocolFee(val int64) { + s.ProtocolFee = val +} + +// SetBorrowerPayout sets the value of BorrowerPayout. +func (s *LoanRepaymentAction) SetBorrowerPayout(val int64) { + s.BorrowerPayout = val +} + +// SetBurnAmount sets the value of BurnAmount. +func (s *LoanRepaymentAction) SetBurnAmount(val int64) { + s.BurnAmount = val +} + +// SetShortfall sets the value of Shortfall. +func (s *LoanRepaymentAction) SetShortfall(val OptInt64) { + s.Shortfall = val +} + +// SetImplementation sets the value of Implementation. +func (s *LoanRepaymentAction) SetImplementation(val PoolImplementationType) { + s.Implementation = val +} + +// A validator asking a staking pool for a loan to stake in the next validation round. +// Ref: #/components/schemas/LoanRequestAction +type LoanRequestAction struct { + Borrower AccountAddress `json:"borrower"` + Pool AccountAddress `json:"pool"` + // Start of the validation round the loan is for, as its unix time; 0 if the round is unknown. + RoundSince int64 `json:"round_since"` + // Loan asked for, in nanograms; absent if too large to represent. + Amount OptInt64 `json:"amount"` + // Least the borrower promises to pay the pool from the round's reward, in nanograms; absent if too + // large to represent. + MinPayment OptInt64 `json:"min_payment"` + // Sent with the request: the borrower's collateral and the pool's fees, in nanograms. + TonAttached int64 `json:"ton_attached"` + Implementation PoolImplementationType `json:"implementation"` +} + +// GetBorrower returns the value of Borrower. +func (s *LoanRequestAction) GetBorrower() AccountAddress { + return s.Borrower +} + +// GetPool returns the value of Pool. +func (s *LoanRequestAction) GetPool() AccountAddress { + return s.Pool +} + +// GetRoundSince returns the value of RoundSince. +func (s *LoanRequestAction) GetRoundSince() int64 { + return s.RoundSince +} + +// GetAmount returns the value of Amount. +func (s *LoanRequestAction) GetAmount() OptInt64 { + return s.Amount +} + +// GetMinPayment returns the value of MinPayment. +func (s *LoanRequestAction) GetMinPayment() OptInt64 { + return s.MinPayment +} + +// GetTonAttached returns the value of TonAttached. +func (s *LoanRequestAction) GetTonAttached() int64 { + return s.TonAttached +} + +// GetImplementation returns the value of Implementation. +func (s *LoanRequestAction) GetImplementation() PoolImplementationType { + return s.Implementation +} + +// SetBorrower sets the value of Borrower. +func (s *LoanRequestAction) SetBorrower(val AccountAddress) { + s.Borrower = val +} + +// SetPool sets the value of Pool. +func (s *LoanRequestAction) SetPool(val AccountAddress) { + s.Pool = val +} + +// SetRoundSince sets the value of RoundSince. +func (s *LoanRequestAction) SetRoundSince(val int64) { + s.RoundSince = val +} + +// SetAmount sets the value of Amount. +func (s *LoanRequestAction) SetAmount(val OptInt64) { + s.Amount = val +} + +// SetMinPayment sets the value of MinPayment. +func (s *LoanRequestAction) SetMinPayment(val OptInt64) { + s.MinPayment = val +} + +// SetTonAttached sets the value of TonAttached. +func (s *LoanRequestAction) SetTonAttached(val int64) { + s.TonAttached = val +} + +// SetImplementation sets the value of Implementation. +func (s *LoanRequestAction) SetImplementation(val PoolImplementationType) { + s.Implementation = val +} + // Ref: #/components/schemas/MarketTonRates type MarketTonRates struct { Market string `json:"market"` @@ -16646,6 +17074,144 @@ func (o OptLiquidityDepositAction) Or(d LiquidityDepositAction) LiquidityDeposit return d } +// NewOptLoanIssueAction returns new OptLoanIssueAction with value set to v. +func NewOptLoanIssueAction(v LoanIssueAction) OptLoanIssueAction { + return OptLoanIssueAction{ + Value: v, + Set: true, + } +} + +// OptLoanIssueAction is optional LoanIssueAction. +type OptLoanIssueAction struct { + Value LoanIssueAction + Set bool +} + +// IsSet returns true if OptLoanIssueAction was set. +func (o OptLoanIssueAction) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptLoanIssueAction) Reset() { + var v LoanIssueAction + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptLoanIssueAction) SetTo(v LoanIssueAction) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptLoanIssueAction) Get() (v LoanIssueAction, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptLoanIssueAction) Or(d LoanIssueAction) LoanIssueAction { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// NewOptLoanRepaymentAction returns new OptLoanRepaymentAction with value set to v. +func NewOptLoanRepaymentAction(v LoanRepaymentAction) OptLoanRepaymentAction { + return OptLoanRepaymentAction{ + Value: v, + Set: true, + } +} + +// OptLoanRepaymentAction is optional LoanRepaymentAction. +type OptLoanRepaymentAction struct { + Value LoanRepaymentAction + Set bool +} + +// IsSet returns true if OptLoanRepaymentAction was set. +func (o OptLoanRepaymentAction) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptLoanRepaymentAction) Reset() { + var v LoanRepaymentAction + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptLoanRepaymentAction) SetTo(v LoanRepaymentAction) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptLoanRepaymentAction) Get() (v LoanRepaymentAction, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptLoanRepaymentAction) Or(d LoanRepaymentAction) LoanRepaymentAction { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// NewOptLoanRequestAction returns new OptLoanRequestAction with value set to v. +func NewOptLoanRequestAction(v LoanRequestAction) OptLoanRequestAction { + return OptLoanRequestAction{ + Value: v, + Set: true, + } +} + +// OptLoanRequestAction is optional LoanRequestAction. +type OptLoanRequestAction struct { + Value LoanRequestAction + Set bool +} + +// IsSet returns true if OptLoanRequestAction was set. +func (o OptLoanRequestAction) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptLoanRequestAction) Reset() { + var v LoanRequestAction + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptLoanRequestAction) SetTo(v LoanRequestAction) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptLoanRequestAction) Get() (v LoanRequestAction, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptLoanRequestAction) Or(d LoanRequestAction) LoanRequestAction { + if v, ok := o.Get(); ok { + return v + } + return d +} + // NewOptMessage returns new OptMessage with value set to v. func NewOptMessage(v Message) OptMessage { return OptMessage{ @@ -17416,6 +17982,52 @@ func (o OptPictureDNS) Or(d PictureDNS) PictureDNS { return d } +// NewOptPoolRoundTransitionAction returns new OptPoolRoundTransitionAction with value set to v. +func NewOptPoolRoundTransitionAction(v PoolRoundTransitionAction) OptPoolRoundTransitionAction { + return OptPoolRoundTransitionAction{ + Value: v, + Set: true, + } +} + +// OptPoolRoundTransitionAction is optional PoolRoundTransitionAction. +type OptPoolRoundTransitionAction struct { + Value PoolRoundTransitionAction + Set bool +} + +// IsSet returns true if OptPoolRoundTransitionAction was set. +func (o OptPoolRoundTransitionAction) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptPoolRoundTransitionAction) Reset() { + var v PoolRoundTransitionAction + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptPoolRoundTransitionAction) SetTo(v PoolRoundTransitionAction) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptPoolRoundTransitionAction) Get() (v PoolRoundTransitionAction, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptPoolRoundTransitionAction) Or(d PoolRoundTransitionAction) PoolRoundTransitionAction { + if v, ok := o.Get(); ok { + return v + } + return d +} + // NewOptPrice returns new OptPrice with value set to v. func NewOptPrice(v Price) OptPrice { return OptPrice{ @@ -19267,6 +19879,148 @@ func (s *PoolInfo) SetCycleLength(val OptInt64) { s.CycleLength = val } +// Election: the pool decided which loans to make and staked them with the elector; began: the +// validation round started; validated: the round's validators finished validating and the stakes are +// held until the elector releases them; ended: the round's stakes were released and the pool is +// recovering them; settled: every loan was recovered and the round's deposits and withdrawals were +// settled. +// Ref: #/components/schemas/PoolRoundTransition +type PoolRoundTransition string + +const ( + PoolRoundTransitionElection PoolRoundTransition = "election" + PoolRoundTransitionBegan PoolRoundTransition = "began" + PoolRoundTransitionValidated PoolRoundTransition = "validated" + PoolRoundTransitionEnded PoolRoundTransition = "ended" + PoolRoundTransitionSettled PoolRoundTransition = "settled" +) + +// AllValues returns all PoolRoundTransition values. +func (PoolRoundTransition) AllValues() []PoolRoundTransition { + return []PoolRoundTransition{ + PoolRoundTransitionElection, + PoolRoundTransitionBegan, + PoolRoundTransitionValidated, + PoolRoundTransitionEnded, + PoolRoundTransitionSettled, + } +} + +// MarshalText implements encoding.TextMarshaler. +func (s PoolRoundTransition) MarshalText() ([]byte, error) { + switch s { + case PoolRoundTransitionElection: + return []byte(s), nil + case PoolRoundTransitionBegan: + return []byte(s), nil + case PoolRoundTransitionValidated: + return []byte(s), nil + case PoolRoundTransitionEnded: + return []byte(s), nil + case PoolRoundTransitionSettled: + return []byte(s), nil + default: + return nil, errors.Errorf("invalid value: %q", s) + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (s *PoolRoundTransition) UnmarshalText(data []byte) error { + switch PoolRoundTransition(data) { + case PoolRoundTransitionElection: + *s = PoolRoundTransitionElection + return nil + case PoolRoundTransitionBegan: + *s = PoolRoundTransitionBegan + return nil + case PoolRoundTransitionValidated: + *s = PoolRoundTransitionValidated + return nil + case PoolRoundTransitionEnded: + *s = PoolRoundTransitionEnded + return nil + case PoolRoundTransitionSettled: + *s = PoolRoundTransitionSettled + return nil + default: + return errors.Errorf("invalid value: %q", data) + } +} + +// A staking pool moving one of its validation rounds to its next stage. +// Ref: #/components/schemas/PoolRoundTransitionAction +type PoolRoundTransitionAction struct { + Pool AccountAddress `json:"pool"` + // Start of the validation round, as its unix time; 0 if the round is unknown. + RoundSince int64 `json:"round_since"` + Transition PoolRoundTransition `json:"transition"` + // Settled only: lent out for the round, in nanograms. + TotalStaked OptInt64 `json:"total_staked"` + // Settled only: recovered from the round, in nanograms. + TotalRecovered OptInt64 `json:"total_recovered"` + Implementation PoolImplementationType `json:"implementation"` +} + +// GetPool returns the value of Pool. +func (s *PoolRoundTransitionAction) GetPool() AccountAddress { + return s.Pool +} + +// GetRoundSince returns the value of RoundSince. +func (s *PoolRoundTransitionAction) GetRoundSince() int64 { + return s.RoundSince +} + +// GetTransition returns the value of Transition. +func (s *PoolRoundTransitionAction) GetTransition() PoolRoundTransition { + return s.Transition +} + +// GetTotalStaked returns the value of TotalStaked. +func (s *PoolRoundTransitionAction) GetTotalStaked() OptInt64 { + return s.TotalStaked +} + +// GetTotalRecovered returns the value of TotalRecovered. +func (s *PoolRoundTransitionAction) GetTotalRecovered() OptInt64 { + return s.TotalRecovered +} + +// GetImplementation returns the value of Implementation. +func (s *PoolRoundTransitionAction) GetImplementation() PoolImplementationType { + return s.Implementation +} + +// SetPool sets the value of Pool. +func (s *PoolRoundTransitionAction) SetPool(val AccountAddress) { + s.Pool = val +} + +// SetRoundSince sets the value of RoundSince. +func (s *PoolRoundTransitionAction) SetRoundSince(val int64) { + s.RoundSince = val +} + +// SetTransition sets the value of Transition. +func (s *PoolRoundTransitionAction) SetTransition(val PoolRoundTransition) { + s.Transition = val +} + +// SetTotalStaked sets the value of TotalStaked. +func (s *PoolRoundTransitionAction) SetTotalStaked(val OptInt64) { + s.TotalStaked = val +} + +// SetTotalRecovered sets the value of TotalRecovered. +func (s *PoolRoundTransitionAction) SetTotalRecovered(val OptInt64) { + s.TotalRecovered = val +} + +// SetImplementation sets the value of Implementation. +func (s *PoolRoundTransitionAction) SetImplementation(val PoolImplementationType) { + s.Implementation = val +} + // Ref: #/components/schemas/Price type Price struct { CurrencyType CurrencyType `json:"currency_type"` diff --git a/pkg/oas/oas_validators_gen.go b/pkg/oas/oas_validators_gen.go index cba4c42a5..ecd3879c4 100644 --- a/pkg/oas/oas_validators_gen.go +++ b/pkg/oas/oas_validators_gen.go @@ -681,6 +681,78 @@ func (s *Action) Validate() error { Error: err, }) } + if err := func() error { + if value, ok := s.LoanRequest.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "LoanRequest", + Error: err, + }) + } + if err := func() error { + if value, ok := s.LoanIssue.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "LoanIssue", + Error: err, + }) + } + if err := func() error { + if value, ok := s.LoanRepayment.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "LoanRepayment", + Error: err, + }) + } + if err := func() error { + if value, ok := s.PoolRoundTransition.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "PoolRoundTransition", + Error: err, + }) + } if err := func() error { if err := s.SimplePreview.Validate(); err != nil { return err @@ -809,6 +881,14 @@ func (s ActionType) Validate() error { return nil case "WithdrawXTR": return nil + case "LoanRequest": + return nil + case "LoanIssue": + return nil + case "LoanRepayment": + return nil + case "PoolRoundTransition": + return nil case "Unknown": return nil default: @@ -4481,6 +4561,75 @@ func (s *LiquidityDepositAction) Validate() error { return nil } +func (s *LoanIssueAction) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.Implementation.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "implementation", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *LoanRepaymentAction) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.Implementation.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "implementation", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *LoanRequestAction) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.Implementation.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "implementation", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + func (s *MarketTonRates) Validate() error { if s == nil { return validate.ErrNilPointer @@ -5669,6 +5818,57 @@ func (s *PoolInfo) Validate() error { return nil } +func (s PoolRoundTransition) Validate() error { + switch s { + case "election": + return nil + case "began": + return nil + case "validated": + return nil + case "ended": + return nil + case "settled": + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} + +func (s *PoolRoundTransitionAction) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.Transition.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "transition", + Error: err, + }) + } + if err := func() error { + if err := s.Implementation.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "implementation", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + func (s *Price) Validate() error { if s == nil { return validate.ErrNilPointer diff --git a/pkg/references/hipo.go b/pkg/references/hipo.go index 885c6b7cb..9bad4067b 100644 --- a/pkg/references/hipo.go +++ b/pkg/references/hipo.go @@ -15,4 +15,7 @@ var ( // 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") + // HipoBurner receives the borrower fee the treasury takes from each loan's reward, + // stakes it, swaps the hGRAM for HPO and burns the HPO. + HipoBurner = ton.MustParseAccountID("0:dc8d90d6be8b68544d17e0749db76a51ddbdac059e178a4cd255274f8aa75030") )