Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
399 changes: 399 additions & 0 deletions tests/streams/order_book/index_change_payout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,399 @@
//go:build kwiltest

package order_book

import (
"context"
"math/big"
"testing"
"time"

"github.com/stretchr/testify/require"
"github.com/trufnetwork/kwil-db/common"
kwilTypes "github.com/trufnetwork/kwil-db/core/types"
erc20bridge "github.com/trufnetwork/kwil-db/node/exts/erc20-bridge/erc20"
kwilTesting "github.com/trufnetwork/kwil-db/testing"
"github.com/trufnetwork/node/extensions/tn_utils"
"github.com/trufnetwork/node/internal/migrations"
testutils "github.com/trufnetwork/node/tests/streams/utils"
"github.com/trufnetwork/node/tests/streams/utils/feefund"
"github.com/trufnetwork/node/tests/streams/utils/setup"
"github.com/trufnetwork/sdk-go/core/util"

attestationTests "github.com/trufnetwork/node/tests/streams/attestation"
)

// Money changing hands on a market that settles through index_change_in_range (action id 12).
//
// index_change_settlement_test.go covers how such a market resolves: the YES/NO answer, the
// agreement between the market hash and the attestation hash, and the half-open bucket rule.
// Every market in that file settles against an empty order book, so no position is ever paid.
// Nothing anywhere had put two traders on opposite sides of one and let it settle.
//
// The payout, reward and fee migrations (032, 033, 034) contain no reference to action_id and
// branch on query_id alone, so an index-change market is expected to pay exactly as a value
// market does. That expectation is what this file checks rather than assumes.

const (
// The bucket the 2% move lands inside. Half-open, so 2% belongs to [1%, 3%) and to nothing
// else.
indexChangePayoutMinChange = "1"
indexChangePayoutMaxChange = "3"

// One split order of 100 pairs, with the NO side sold at 40 cents.
indexChangePayoutShares = int64(100)
indexChangePayoutTruePrice = 60
indexChangePayoutNoPrice = 100 - indexChangePayoutTruePrice
)

func TestIndexChangePayout(t *testing.T) {
owner := util.Unsafe_NewEthereumAddressFromString("0x1111111111111111111111111111111111111111")

testutils.RunSchemaTest(t, kwilTesting.SchemaTest{
Name: "ORDER_BOOK_IndexChangePayout",
SeedStatements: migrations.GetSeedScriptStatements(),
Owner: owner.Address(),
FunctionTests: []kwilTesting.TestFunc{
testIndexChangeWinnerIsPaidAndLoserIsNot(t),
},
}, testutils.GetTestOptionsWithCache())
}

// =============================================================================
// A traded index-change market pays the winning side and clears the losing one
// =============================================================================

func testIndexChangeWinnerIsPaidAndLoserIsNot(t *testing.T) func(context.Context, *kwilTesting.Platform) error {
return func(ctx context.Context, platform *kwilTesting.Platform) error {
// The data provider stays out of the trade so neither trader collects its share of the
// fees, which would blur the balance arithmetic below.
dataProvider := util.Unsafe_NewEthereumAddressFromString("0x5555555555555555555555555555555555555555")
winner := util.Unsafe_NewEthereumAddressFromString("0x6666666666666666666666666666666666666666")
loser := util.Unsafe_NewEthereumAddressFromString("0x7777777777777777777777777777777777777777")

// Each function test runs against a fresh container, so the balance chain restarts here.
lastBalancePoint = nil
lastTrufBalancePoint = nil

platform.Deployer = dataProvider.Bytes()
helper := attestationTests.NewAttestationTestHelper(t, ctx, platform)

require.NoError(t, erc20bridge.ForTestingInitializeExtension(ctx, platform))
require.NoError(t, setup.CreateDataProvider(ctx, platform, dataProvider.Address()))

for _, wallet := range []string{
dataProvider.Address(), winner.Address(), loser.Address(),
} {
require.NoError(t, giveBalanceChained(ctx, platform, wallet, "1000000000000000000000"))
}

// create_stream (100 TRUF), insert_records (1 TRUF) and request_attestation (40 TRUF) all
// bill the data provider.
require.NoError(t, feefund.EnsureWalletFunded(
ctx, platform, dataProvider.Address(), "200000000000000000000"))

now := time.Now().Unix()
fx := &indexChangePayoutFixture{
helper: helper,
dataProvider: dataProvider.Address(),
streamID: "stindexchangepayout0000000000000",
attestAt: now,
settleTime: now + 3600,
}
fx.seedStreamMovingTwoPercent(t, platform)

queryID := fx.createMarket(t, platform, now)

winnerStart, err := getUSDCBalance(ctx, platform, winner.Address())
require.NoError(t, err)
loserStart, err := getUSDCBalance(ctx, platform, loser.Address())
require.NoError(t, err)

// The winner mints 100 YES/NO pairs and offers the NO side at 40 cents. The loser lifts
// it, which is a real match between two wallets rather than one wallet holding both
// sides, so settlement has someone to pay and someone to pass over.
require.NoError(t, callPlaceSplitLimitOrder(
ctx, platform, &winner, queryID, indexChangePayoutTruePrice, indexChangePayoutShares))
require.NoError(t, callPlaceBuyOrder(
ctx, platform, &loser, queryID, false, indexChangePayoutNoPrice, indexChangePayoutShares))

// Asserted per wallet, not per side. A match that credited both holdings
// to one trader while still debiting the other would leave every share
// count and every balance below unchanged.
winnerID := participantIDForWallet(t, ctx, platform, &winner)
loserID := participantIDForWallet(t, ctx, platform, &loser)
requireHolding(t, ctx, platform, queryID, winnerID, true, indexChangePayoutShares)
requireHolding(t, ctx, platform, queryID, loserID, false, indexChangePayoutShares)

// Minting cost 100 USDC and selling the NO side returned 40, so the winner is down 60 and
// the loser is down the 40 they paid. Checked before settlement so a wrong payout below
// cannot be mistaken for a wrong trade here.
requireUSDCDelta(t, ctx, platform, winner.Address(), winnerStart, "-60",
"minting 100 pairs costs 100 USDC and selling the NO side returns 40")
requireUSDCDelta(t, ctx, platform, loser.Address(), loserStart, "-40",
"buying 100 NO at 40 cents costs 40 USDC")

fx.attest(t, platform)
outcome := fx.settle(t, platform, queryID)
require.True(t, outcome, "a 2%% change inside [1%%, 3%%) should settle YES")

positions, err := getPositions(ctx, platform, queryID)
require.NoError(t, err)
require.Empty(t, positions, "settlement should clear every position on the market")

// The winner's 100 YES redeem at a dollar less the 2% that funds LP rewards, so 98 back
// against 100 out and 40 in leaves them up 38. The loser is still down their 40: holding
// the losing side pays nothing, and nothing is clawed back either.
requireUSDCDelta(t, ctx, platform, winner.Address(), winnerStart, "38",
"100 YES redeem at 98 USDC after the 2% settlement fee")
requireUSDCDelta(t, ctx, platform, loser.Address(), loserStart, "-40",
"the losing side is paid nothing and charged nothing further")

return nil
}
}

// =============================================================================
// Fixture
// =============================================================================

// indexChangePayoutFixture is the same 2% move index_change_settlement_test.go uses: the stream
// holds 100.00 one interval before the attestation point and 102.00 just before it.
type indexChangePayoutFixture struct {
helper *attestationTests.AttestationTestHelper
dataProvider string
streamID string
attestAt int64
settleTime int64
}

func (fx *indexChangePayoutFixture) seedStreamMovingTwoPercent(
t *testing.T,
platform *kwilTesting.Platform,
) {
t.Helper()

priorValue, err := kwilTypes.ParseDecimalExplicit("100.000000000000000000", 36, 18)
require.NoError(t, err)
currentValue, err := kwilTypes.ParseDecimalExplicit("102.000000000000000000", 36, 18)
require.NoError(t, err)

// create_stream and insert_records share ONE engine context on purpose: the records are not
// visible to a read in another context within the same test.
engineCtx := fx.helper.NewEngineContext()
mustCallAction(t, engineCtx, platform, "create_stream", []any{fx.streamID, "primitive"}, nil)
mustCallAction(t, engineCtx, platform, "insert_records", []any{
[]string{fx.dataProvider, fx.dataProvider},
[]string{fx.streamID, fx.streamID},
[]int64{
fx.attestAt - indexChangeSettlementInterval,
fx.attestAt - indexChangeCurrentAnchorOffset,
},
[]*kwilTypes.Decimal{priorValue, currentValue},
}, nil)
}

func (fx *indexChangePayoutFixture) createMarket(
t *testing.T,
platform *kwilTesting.Platform,
createAt int64,
) int {
t.Helper()

queryComponents, err := encodeQueryComponentsForTests(
fx.dataProvider, fx.streamID, "index_change_in_range", fx.encodeArgs(t))
require.NoError(t, err)

engineCtx := fx.helper.NewEngineContext()
engineCtx.TxContext.BlockContext.Timestamp = createAt

var queryID int
mustCallAction(t, engineCtx, platform, "create_market",
[]any{testExtensionName, queryComponents, fx.settleTime, int64(5), int64(1)},
func(row *common.Row) error {
queryID = int(row.Values[0].(int64))
return nil
})
require.Greater(t, queryID, 0, "queryID should be positive")

return queryID
}

func (fx *indexChangePayoutFixture) attest(t *testing.T, platform *kwilTesting.Platform) {
t.Helper()

engineCtx := fx.helper.NewEngineContext()
engineCtx.TxContext.BlockContext.Timestamp = fx.settleTime + 1

var requestTxID string
mustCallAction(t, engineCtx, platform, "request_attestation",
[]any{
fx.dataProvider,
fx.streamID,
"index_change_in_range",
fx.encodeArgs(t),
false, // encrypt_sig
nil, // max_fee
},
func(row *common.Row) error {
requestTxID = row.Values[0].(string)
return nil
})
require.NotEmpty(t, requestTxID, "request_attestation should return a tx id")

fx.helper.SignAttestation(requestTxID)
}

// settle settles the market and returns the outcome it settled to.
//
// Unlike the settlement tests, this market holds positions, so process_settlement runs and needs
// a proposer to attribute the validator share of the fees to.
func (fx *indexChangePayoutFixture) settle(
t *testing.T,
platform *kwilTesting.Platform,
queryID int,
) bool {
t.Helper()

engineCtx := fx.helper.NewEngineContext()
engineCtx.TxContext.BlockContext.Timestamp = fx.settleTime + 1
engineCtx.TxContext.BlockContext.Proposer = NewTestProposerPub(t)
mustCallAction(t, engineCtx, platform, "settle_market", []any{queryID}, nil)

var settled bool
var winningOutcome *bool
engineCtx = fx.helper.NewEngineContext()
err := platform.Engine.Execute(engineCtx, platform.DB,
`SELECT settled, winning_outcome FROM ob_queries WHERE id = $id`,
map[string]any{"id": queryID},
func(row *common.Row) error {
settled = row.Values[0].(bool)
if row.Values[1] != nil {
outcome := row.Values[1].(bool)
winningOutcome = &outcome
}
return nil
})
require.NoError(t, err)
require.True(t, settled, "market should be settled")
require.NotNil(t, winningOutcome, "winning_outcome should be set")

return *winningOutcome
}

// encodeArgs encodes action 12's arguments in the order migration 055 declares them.
func (fx *indexChangePayoutFixture) encodeArgs(t *testing.T) []byte {
t.Helper()

minChange, err := kwilTypes.ParseDecimalExplicit(indexChangePayoutMinChange, 36, 18)
require.NoError(t, err)
maxChange, err := kwilTypes.ParseDecimalExplicit(indexChangePayoutMaxChange, 36, 18)
require.NoError(t, err)

argsBytes, err := tn_utils.EncodeActionArgs([]any{
fx.dataProvider,
fx.streamID,
fx.attestAt,
nil, // base_time
indexChangeSettlementInterval,
minChange,
maxChange,
nil, // frozen_at
})
require.NoError(t, err)

return argsBytes
}

// =============================================================================
// Assertions
// =============================================================================

// participantIDForWallet resolves the order book's internal id for a wallet, which is what
// ob_positions rows are keyed by.
func participantIDForWallet(
t *testing.T,
ctx context.Context,
platform *kwilTesting.Platform,
wallet *util.EthereumAddress,
) int {
t.Helper()

engineCtx := &common.EngineContext{TxContext: &common.TxContext{
Ctx: ctx,
BlockContext: &common.BlockContext{Height: 1},
TxID: platform.Txid(),
}}

var participantID int
var found bool
err := platform.Engine.Execute(engineCtx, platform.DB,
"SELECT id FROM ob_participants WHERE wallet_address = $wallet",
map[string]any{"$wallet": wallet.Bytes()},
func(row *common.Row) error {
participantID = int(row.Values[0].(int64))
found = true
return nil
})
require.NoError(t, err)
require.True(t, found, "no participant row for %s", wallet.Address())

return participantID
}

// requireHolding asserts that a named trader holds one side of the market outright, which is a
// position at price 0 rather than a resting order. Checking the owner is the point: the share
// counts alone are satisfied by both sides landing in the same wallet.
func requireHolding(
t *testing.T,
ctx context.Context,
platform *kwilTesting.Platform,
queryID int,
participantID int,
outcome bool,
amount int64,
) {
t.Helper()

positions, err := getPositions(ctx, platform, queryID)
require.NoError(t, err)

side := "NO"
if outcome {
side = "YES"
}

for _, position := range positions {
if position.ParticipantID == participantID &&
position.Outcome == outcome &&
position.Price == 0 {
require.Equal(t, amount, position.Amount, "%s holding amount", side)
return
}
}

t.Fatalf("participant %d holds no %s among %d positions on the market",
participantID, side, len(positions))
}

// requireUSDCDelta asserts a wallet's movement against a starting balance, in whole USDC.
func requireUSDCDelta(
t *testing.T,
ctx context.Context,
platform *kwilTesting.Platform,
wallet string,
start *big.Int,
expectedUSDC string,
reason string,
) {
t.Helper()

current, err := getUSDCBalance(ctx, platform, wallet)
require.NoError(t, err)

expected, ok := new(big.Int).SetString(expectedUSDC, 10)
require.True(t, ok, "bad expected amount %q", expectedUSDC)
expected.Mul(expected, big.NewInt(1e18))

require.Equal(t, expected.String(), new(big.Int).Sub(current, start).String(), reason)
}
Loading