Skip to content
Merged
Show file tree
Hide file tree
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
154 changes: 154 additions & 0 deletions extensions/tn_settlement/settlement_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ func TestSettlementIntegration(t *testing.T) {
testLoadSettlementConfig(t),
testSkipMarketWithoutAttestation(t),
testMultipleMarketsProcessing(t),
testSettleUsesTheEarliestCapture(t),
},
}, testutils.GetTestOptionsWithCache())
}
Expand Down Expand Up @@ -759,3 +760,156 @@ func createStreamAndAttestation(

return queryComponents
}

// =============================================================================
// Test: a market with two captures settles on the earlier one
// =============================================================================

// A market can end up with two captures: request_attestation freezes the query
// result in the block it lands in, a validator signs it in a later one, and a
// poll between those two blocks sees nothing signed and asks for another. The
// two read the query against different chain state, so which one resolves the
// market decides the outcome.
//
// It must be the earliest — the one taken closest to settle_time, which is the
// state the market was defined to resolve against. Ordering by signing order
// made it a function of when a validator got to each row.
func testSettleUsesTheEarliestCapture(t *testing.T) func(context.Context, *kwilTesting.Platform) error {
return func(ctx context.Context, platform *kwilTesting.Platform) error {
lastTrufBalancePoint = nil

deployer := util.Unsafe_NewEthereumAddressFromString("0x8888888888888888888888888888888888888888")
platform.Deployer = deployer.Bytes()

helper := attestationTests.NewAttestationTestHelper(t, ctx, platform)
require.NoError(t, setup.CreateDataProvider(ctx, platform, deployer.Address()))
require.NoError(t, erc20bridge.ForTestingInitializeExtension(ctx, platform))

// create_stream (100) + insert_records (1 each) + two captures (40 each).
require.NoError(t, giveTrufBalance(ctx, platform, deployer.Address(), "300000000000000000000"))

streamID := "stearliestcapture0000000000000000"[:32]
dataProvider := deployer.Address()

engineCtx := helper.NewEngineContext()
_, err := platform.Engine.Call(engineCtx, platform.DB, "", "create_stream",
[]any{streamID, "primitive"}, nil)
require.NoError(t, err)

// insert_records drops value=0 unless the stream opts in (046-allow-zeros-config.sql),
// and the second capture below has to read a zero for the two captures
// to disagree. Without this the zero is silently discarded, both captures
// read the same value and the test proves nothing.
zerosRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "set_allow_zeros",
[]any{dataProvider, streamID, true}, nil)
require.NoError(t, err)
require.NoError(t, zerosRes.Error, "set_allow_zeros failed")

// The two captures must disagree, so the stream has to move between
// them. parse_attestation_boolean reads a numeric action as value > 0,
// so non-zero then zero gives TRUE then FALSE.
insert := func(eventTime int64, value string) {
dec, err := kwilTypes.ParseDecimalExplicit(value, 36, 18)
require.NoError(t, err)
res, err := platform.Engine.Call(engineCtx, platform.DB, "", "insert_records",
[]any{[]string{dataProvider}, []string{streamID}, []int64{eventTime}, []*kwilTypes.Decimal{dec}}, nil)
require.NoError(t, err)
require.NoError(t, res.Error)
}

// Identical args for both captures, so both land on one attestation hash
// and one market — which is what makes them two captures of the same
// question rather than two different questions.
argsBytes, err := tn_utils.EncodeActionArgs([]any{
dataProvider, streamID, int64(1500), nil, false,
})
require.NoError(t, err)

// One engine context throughout: a capture has to see the records inserted
// above, and a fresh context cannot. Height and TxID are moved on it
// instead — height is what request_attestation stores as created_height,
// and the transaction id is the attestations primary key, so the two
// captures need different ones.
capture := func(height int64) []byte {
ec := engineCtx
ec.TxContext.BlockContext.Height = height
ec.TxContext.TxID = platform.Txid()
var requestTxID string
var hash []byte
res, err := platform.Engine.Call(ec, platform.DB, "", "request_attestation",
[]any{dataProvider, streamID, "get_last_record", argsBytes, false, nil},
func(row *common.Row) error {
requestTxID = row.Values[0].(string)
hash = append([]byte(nil), row.Values[1].([]byte)...)
return nil
})
require.NoError(t, err)
require.NoError(t, res.Error, "request_attestation failed")
helper.SignAttestation(requestTxID)
return hash
}

insert(1000, "2.000000000000000000")
firstHash := capture(10) // sees 2 -> TRUE

insert(1200, "0.000000000000000000")
secondHash := capture(20) // sees 0 -> FALSE

require.Equal(t, firstHash, secondHash,
"identical args must give one hash, so this is one market with two captures")

// Both are signed and they disagree, which is the whole point.
var captures int
engineCtx = helper.NewEngineContext()
require.NoError(t, platform.Engine.Execute(engineCtx, platform.DB,
`SELECT count(*) FROM attestations WHERE attestation_hash = $h AND signature IS NOT NULL`,
map[string]any{"h": firstHash},
func(row *common.Row) error {
captures = int(row.Values[0].(int64))
return nil
}))
require.Equal(t, 2, captures, "the test needs two signed captures to choose between")

queryComponents, err := encodeQueryComponents(dataProvider, streamID, "get_last_record", argsBytes)
require.NoError(t, err)

engineCtx = helper.NewEngineContext()
engineCtx.TxContext.BlockContext.Timestamp = 50
var queryID int
createRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "create_market",
[]any{testExtensionName, queryComponents, int64(100), int64(5), int64(1)},
func(row *common.Row) error {
queryID = int(row.Values[0].(int64))
return nil
})
require.NoError(t, err)
require.Nil(t, createRes.Error)

engineCtx = helper.NewEngineContext()
engineCtx.TxContext.BlockContext.Timestamp = 200
settleRes, err := platform.Engine.Call(engineCtx, platform.DB, "", "settle_market",
[]any{queryID}, nil)
require.NoError(t, err)
require.Nil(t, settleRes.Error)

var winning *bool
engineCtx = helper.NewEngineContext()
require.NoError(t, platform.Engine.Execute(engineCtx, platform.DB,
`SELECT winning_outcome FROM ob_queries WHERE id = $id`,
map[string]any{"id": queryID},
func(row *common.Row) error {
if row.Values[0] != nil {
v := row.Values[0].(bool)
winning = &v
}
return nil
}))

require.NotNil(t, winning, "market should be settled")
require.True(t, *winning,
"settlement must resolve on the capture taken at height 10 (value 2 -> TRUE), not the later one at height 20 (value 0 -> FALSE)")

t.Logf("✅ settlement resolved on the earliest signed capture")
return nil
}
}
40 changes: 31 additions & 9 deletions internal/migrations/032-order-book-actions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2615,27 +2615,49 @@ CREATE OR REPLACE ACTION settle_market(
-- Query attestation by hash
-- Note: market.hash should match attestation.attestation_hash
-- This links the market to the cryptographic attestation of the query result
--
-- A market can carry more than one capture. request_attestation freezes the
-- query result in the block it lands in and a validator signs it in a later
-- one, so a poll that falls between those two blocks sees nothing signed and
-- asks for another. The two captures read the query against different chain
-- state and are not interchangeable.
--
-- Resolve on the EARLIEST signed capture: it is the one taken closest to
-- settle_time, which is the state the market was defined to resolve against.
-- Ordering by signed_height instead made the outcome a function of when a
-- validator got to each row, and when two were signed in the same block that
-- ordering was a tie decided by whichever row the database returned first.
$result_canonical BYTEA;
$signature BYTEA;
$capture_height INT8;
$attestation_found BOOL := false;

for $row in SELECT result_canonical, signature
for $row in SELECT result_canonical, created_height
FROM attestations
WHERE attestation_hash = $market_hash
ORDER BY signed_height DESC NULLS LAST
AND signature IS NOT NULL
ORDER BY created_height ASC
LIMIT 1 {
$result_canonical := $row.result_canonical;
$signature := $row.signature;
$capture_height := $row.created_height;
$attestation_found := true;
}

if NOT $attestation_found {
ERROR('Attestation not found for market hash. Market cannot be settled without attestation.');
}
-- Nothing signed. Say which of the two reasons it is, because they need
-- different responses: a capture that exists is waiting on a validator,
-- and one that does not has to be requested.
$capture_exists BOOL := false;
for $row in SELECT 1 AS present
FROM attestations
WHERE attestation_hash = $market_hash
LIMIT 1 {
$capture_exists := true;
}

-- Verify attestation has been signed
if $signature IS NULL {
ERROR('Attestation not yet signed by validator. Please wait for signing to complete.');
if $capture_exists {
ERROR('Attestation not yet signed by validator. Please wait for signing to complete.');
}
ERROR('Attestation not found for market hash. Market cannot be settled without attestation.');
}

-- ==========================================================================
Expand Down
Loading