Skip to content

Commit deb34e0

Browse files
committed
test(integration): cover multi-input transactions
1 parent a11d350 commit deb34e0

4 files changed

Lines changed: 296 additions & 0 deletions

File tree

test/integration/same_block_inputs_test.go

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,32 @@
66
package integration
77

88
import (
9+
"bufio"
10+
"bytes"
911
"context"
12+
_ "embed"
13+
"encoding/json"
1014
"fmt"
15+
"io"
1116
"math/big"
17+
"net/http"
18+
"os"
19+
"strings"
1220
"testing"
1321
"time"
1422

1523
"github.com/cartesi/rollups-node/internal/config"
24+
"github.com/cartesi/rollups-node/internal/jsonrpc/api"
1625
"github.com/cartesi/rollups-node/internal/model"
1726
"github.com/cartesi/rollups-node/internal/repository/factory"
1827
"github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus"
1928
"github.com/cartesi/rollups-node/pkg/contracts/iinputbox"
29+
"github.com/ethereum/go-ethereum/accounts/abi"
2030
"github.com/ethereum/go-ethereum/accounts/abi/bind"
2131
"github.com/ethereum/go-ethereum/common"
2232
"github.com/ethereum/go-ethereum/core/types"
2333
"github.com/ethereum/go-ethereum/ethclient"
34+
"github.com/stretchr/testify/require"
2435
"github.com/stretchr/testify/suite"
2536
)
2637

@@ -40,6 +51,19 @@ func TestSameBlockInputs(t *testing.T) {
4051
suite.Run(t, new(SameBlockInputsSuite))
4152
}
4253

54+
// Generated from testdata/Spambox.sol with solc 0.8.30, no optimizer:
55+
//
56+
// solc --abi --bin testdata/Spambox.sol
57+
//
58+
// Kept as static testdata so this integration test does not require forge or
59+
// solc at runtime. Regenerate both files together whenever Spambox.sol changes.
60+
//
61+
//go:embed testdata/spambox_abi.json
62+
var spamboxABIJSON string
63+
64+
//go:embed testdata/spambox_bytecode.hex
65+
var spamboxBytecode string
66+
4367
func (s *SameBlockInputsSuite) SetupSuite() {
4468
s.ctx, s.cancel = context.WithTimeout(context.Background(), 15*time.Minute)
4569
s.client = newIntegrationEthClient(s.ctx, s.T())
@@ -80,6 +104,111 @@ func (s *SameBlockInputsSuite) TestMultipleInputsOneBlockPRT() {
80104
s.runMultipleInputsOneBlock([]string{"--prt"})
81105
}
82106

107+
// TestMultipleInputsOneTransactionAuthority reproduces F29's exact trigger:
108+
// one L1 transaction emits several InputAdded logs for the same application.
109+
func (s *SameBlockInputsSuite) TestMultipleInputsOneTransactionAuthority() {
110+
r := s.Require()
111+
s.appName = uniqueAppName("same-tx-inputs-authority")
112+
113+
dappPath := envOrDefault("CARTESI_TEST_DAPP_PATH", "applications/echo-dapp")
114+
appAddrStr, err := deployApplication(s.ctx, s.appName, dappPath, "--salt", uniqueSalt())
115+
r.NoError(err, "deploy app")
116+
appAddr := common.HexToAddress(appAddrStr)
117+
118+
inputBoxAddr := inputBoxAddress(s.T())
119+
inputBox, err := iinputbox.NewIInputBox(inputBoxAddr, s.client)
120+
r.NoError(err, "bind input box")
121+
122+
startIndex := inputBoxInputCount(s.ctx, s.T(), s.client, inputBoxAddr, appAddr)
123+
controlTx, err := inputBox.AddInput(transactorForMnemonicIndex(s.ctx, s.T(), s.client, 2),
124+
appAddr, []byte("CONTROL-SINGLE"))
125+
r.NoError(err, "submit control input")
126+
receiptCtx, receiptCancel := context.WithTimeout(s.ctx, 30*time.Second)
127+
controlReceipt := waitReceipt(receiptCtx, s.T(), s.client, controlTx)
128+
receiptCancel()
129+
r.Equal(uint64(1), controlReceipt.Status, "control input transaction must succeed")
130+
131+
controlCtx, controlCancel := context.WithTimeout(s.ctx, inputProcessingTimeout)
132+
controlInput, err := waitForInputProcessed(controlCtx, s.T(), s.appName, startIndex)
133+
controlCancel()
134+
r.NoError(err, "wait for control input")
135+
r.Equal(controlReceipt.TxHash, controlInput.TransactionHash)
136+
137+
spambox := deploySpambox(s.ctx, s.T(), s.client, inputBoxAddr)
138+
139+
const spamCount = 5
140+
spamOpts := transactorForMnemonicIndex(s.ctx, s.T(), s.client, 3)
141+
spamOpts.GasLimit = 3_000_000 //nolint:mnd
142+
spamTx, err := spambox.Transact(spamOpts, "spam", appAddr, big.NewInt(spamCount))
143+
r.NoError(err, "submit spam transaction")
144+
receiptCtx, receiptCancel = context.WithTimeout(s.ctx, 30*time.Second)
145+
spamReceipt := waitReceipt(receiptCtx, s.T(), s.client, spamTx)
146+
receiptCancel()
147+
r.Equal(uint64(1), spamReceipt.Status, "spam transaction must succeed")
148+
149+
logIndexByInputIndex := make(map[uint64]uint64, spamCount)
150+
seenLogIndexes := make(map[uint64]struct{}, spamCount)
151+
for _, rawLog := range spamReceipt.Logs {
152+
event, err := inputBox.ParseInputAdded(*rawLog)
153+
if err != nil || event.AppContract != appAddr {
154+
continue
155+
}
156+
r.Equal(spamReceipt.TxHash, event.Raw.TxHash)
157+
inputIndex := event.Index.Uint64()
158+
logIndex := uint64(event.Raw.Index)
159+
logIndexByInputIndex[inputIndex] = logIndex
160+
seenLogIndexes[logIndex] = struct{}{}
161+
}
162+
r.Len(logIndexByInputIndex, spamCount, "spam tx must emit InputAdded logs for this app")
163+
r.Len(seenLogIndexes, spamCount, "spam logs must have distinct log indexes")
164+
165+
for i := uint64(0); i < spamCount; i++ {
166+
idx := startIndex + 1 + i
167+
inputCtx, inputCancel := context.WithTimeout(s.ctx, inputProcessingTimeout)
168+
input, err := waitForInputProcessed(inputCtx, s.T(), s.appName, idx)
169+
inputCancel()
170+
r.NoError(err, "wait for spam input %d", idx)
171+
r.Equal(spamReceipt.TxHash, input.TransactionHash)
172+
r.Equal(logIndexByInputIndex[idx], input.LogIndex)
173+
}
174+
175+
s.waitForInputCursorPast(spamReceipt.BlockNumber.Uint64())
176+
177+
// The F29 wedge showed up as unique-violation retry noise: assert the spam
178+
// transaction produced none, on top of the cursor-advance check above.
179+
s.assertNoNodeLogLineContains("SQLSTATE 23505")
180+
181+
allInputs, err := listInputsByRPC(s.ctx, s.appName, nil)
182+
r.NoError(err, "list all inputs through JSON-RPC")
183+
r.Equal(startIndex+spamCount+1, uint64(len(allInputs.Data)))
184+
for i, input := range allInputs.Data {
185+
r.Equal(startIndex+uint64(i), input.Index)
186+
}
187+
188+
txHash := spamReceipt.TxHash.Hex()
189+
filtered, err := listInputsByRPC(s.ctx, s.appName, &txHash)
190+
r.NoError(err, "list inputs by spam transaction hash through JSON-RPC")
191+
r.Len(filtered.Data, spamCount)
192+
for i, input := range filtered.Data {
193+
expectedIndex := startIndex + 1 + uint64(i)
194+
r.Equal(expectedIndex, input.Index)
195+
r.Equal(spamReceipt.TxHash, input.TransactionHash)
196+
r.Equal(logIndexByInputIndex[expectedIndex], input.LogIndex)
197+
}
198+
199+
out, err := runCLI(s.ctx, "read", "inputs", s.appName, "--transaction-hash", spamReceipt.TxHash.Hex())
200+
r.NoError(err, "list inputs by spam transaction hash")
201+
var cliFiltered api.ListResponse[model.Input]
202+
r.NoError(json.Unmarshal([]byte(out), &cliFiltered), "parse filtered inputs")
203+
r.Len(cliFiltered.Data, spamCount)
204+
for i, input := range cliFiltered.Data {
205+
expectedIndex := startIndex + 1 + uint64(i)
206+
r.Equal(expectedIndex, input.Index)
207+
r.Equal(spamReceipt.TxHash, input.TransactionHash)
208+
r.Equal(logIndexByInputIndex[expectedIndex], input.LogIndex)
209+
}
210+
}
211+
83212
// runMultipleInputsOneBlock deploys an application, batches three inputs into a
84213
// single L1 block, and verifies the node ingests them with contiguous indices,
85214
// processes each through the machine, and assigns all of them to the same epoch.
@@ -364,3 +493,143 @@ func (s *SameBlockInputsSuite) readEpochSettlementData(
364493
r.NoError(err, "wait for epoch %d settlement data (outputs merkle root)", epochIndex)
365494
return root, proof, consensusAddr
366495
}
496+
497+
// deploySpambox deploys the Spambox helper contract and fails the test on any
498+
// error, matching the waitReceipt style it already depends on.
499+
func deploySpambox(
500+
ctx context.Context,
501+
t testing.TB,
502+
client *ethclient.Client,
503+
inputBoxAddr common.Address,
504+
) *bind.BoundContract {
505+
t.Helper()
506+
r := require.New(t)
507+
parsed, err := abi.JSON(strings.NewReader(spamboxABIJSON))
508+
r.NoError(err, "parse Spambox ABI")
509+
opts := transactorForMnemonicIndex(ctx, t, client, 5)
510+
opts.GasLimit = 3_000_000 //nolint:mnd
511+
_, tx, contract, err := bind.DeployContract(
512+
opts, parsed, common.FromHex(strings.TrimSpace(spamboxBytecode)), client, inputBoxAddr)
513+
r.NoError(err, "deploy Spambox")
514+
receiptCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
515+
defer cancel()
516+
receipt := waitReceipt(receiptCtx, t, client, tx)
517+
r.Equal(types.ReceiptStatusSuccessful, receipt.Status,
518+
"Spambox deployment reverted in tx %s", tx.Hash())
519+
return contract
520+
}
521+
522+
func listInputsByRPC(
523+
ctx context.Context,
524+
appName string,
525+
transactionHash *string,
526+
) (*api.ListResponse[model.Input], error) {
527+
req := struct {
528+
JSONRPC string `json:"jsonrpc"`
529+
Method string `json:"method"`
530+
Params any `json:"params"`
531+
ID int `json:"id"`
532+
}{
533+
JSONRPC: "2.0",
534+
Method: "cartesi_listInputs",
535+
Params: api.ListInputsParams{
536+
Application: appName,
537+
TransactionHash: transactionHash,
538+
Limit: 50,
539+
},
540+
ID: 1,
541+
}
542+
body, err := json.Marshal(req)
543+
if err != nil {
544+
return nil, err
545+
}
546+
url := envOrDefault("CARTESI_JSONRPC_API_URL", "http://localhost:10011/rpc")
547+
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
548+
if err != nil {
549+
return nil, err
550+
}
551+
httpReq.Header.Set("Content-Type", "application/json")
552+
resp, err := anvilHTTPClient.Do(httpReq)
553+
if err != nil {
554+
return nil, err
555+
}
556+
defer resp.Body.Close()
557+
if resp.StatusCode != http.StatusOK {
558+
payload, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) //nolint:errcheck
559+
return nil, fmt.Errorf("cartesi_listInputs HTTP %d: %s", resp.StatusCode, string(payload))
560+
}
561+
562+
var rpcResp struct {
563+
Result *api.ListResponse[model.Input] `json:"result"`
564+
Error *struct {
565+
Code int `json:"code"`
566+
Message string `json:"message"`
567+
} `json:"error"`
568+
}
569+
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
570+
return nil, err
571+
}
572+
if rpcResp.Error != nil {
573+
return nil, fmt.Errorf("cartesi_listInputs error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
574+
}
575+
if rpcResp.Result == nil {
576+
return nil, fmt.Errorf("cartesi_listInputs returned no result")
577+
}
578+
return rpcResp.Result, nil
579+
}
580+
581+
func (s *SameBlockInputsSuite) waitForInputCursorPast(blockNumber uint64) {
582+
r := s.Require()
583+
dsn, err := config.GetDatabaseConnection()
584+
r.NoError(err, "get database connection")
585+
repo, err := factory.NewRepositoryFromConnectionString(s.ctx, dsn.Raw())
586+
r.NoError(err, "open repository")
587+
defer repo.Close()
588+
589+
ctx, cancel := context.WithTimeout(s.ctx, inputProcessingTimeout)
590+
defer cancel()
591+
err = pollUntil(ctx, 2*time.Second, func() (bool, error) {
592+
app, err := repo.GetApplication(ctx, s.appName)
593+
if err != nil {
594+
// Retry transient DB errors until the poll deadline, matching the
595+
// other wait helpers; a persistent failure still times out loudly.
596+
s.T().Logf("retrying GetApplication while waiting for input cursor: %v", err)
597+
return false, nil
598+
}
599+
if app == nil {
600+
return false, nil
601+
}
602+
return app.LastInputCheckBlock >= blockNumber, nil
603+
})
604+
r.NoError(err, "input cursor did not advance past block %d", blockNumber)
605+
}
606+
607+
// assertNoNodeLogLineContains fails the test if any node log line emitted
608+
// since StartLogCapture contains the given substring, regardless of level.
609+
// Unlike CheckLogs, which only flags unexpected ERR lines, this pins the
610+
// absence of a specific marker (e.g. SQLSTATE 23505 retry noise).
611+
func (s *SameBlockInputsSuite) assertNoNodeLogLineContains(substr string) {
612+
logFile := os.Getenv("CARTESI_TEST_NODE_LOG_FILE")
613+
if logFile == "" {
614+
s.T().Log("CARTESI_TEST_NODE_LOG_FILE not set, skipping node log substring scan")
615+
return
616+
}
617+
f, err := os.Open(logFile)
618+
s.Require().NoError(err, "open node log file")
619+
defer f.Close()
620+
621+
var hits []string
622+
scanner := bufio.NewScanner(f)
623+
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // handle long lines (stack traces, JSON)
624+
for scanner.Scan() {
625+
line := stripANSI(scanner.Text())
626+
if ts, ok := parseLogTimestamp(line); ok && ts.Before(s.logStart) {
627+
continue
628+
}
629+
if strings.Contains(line, substr) {
630+
hits = append(hits, line)
631+
}
632+
}
633+
s.Require().NoError(scanner.Err(), "read node log file")
634+
s.Require().Empty(hits, "node logs must not contain %q", substr)
635+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// (c) Cartesi and individual authors (see AUTHORS)
2+
// SPDX-License-Identifier: Apache-2.0 (see LICENSE)
3+
pragma solidity ^0.8.30;
4+
5+
interface IInputBoxForSpambox {
6+
function addInput(address app, bytes calldata payload) external returns (bytes32);
7+
}
8+
9+
contract Spambox {
10+
IInputBoxForSpambox public immutable inputBox;
11+
12+
constructor(address inputBox_) {
13+
inputBox = IInputBoxForSpambox(inputBox_);
14+
}
15+
16+
function spam(address app, uint256 count) external {
17+
for (uint256 i = 0; i < count; i++) {
18+
inputBox.addInput(app, abi.encodePacked("SPAM-", i));
19+
}
20+
}
21+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[
2+
{"type":"constructor","inputs":[{"name":"inputBox_","type":"address","internalType":"address"}],"stateMutability":"nonpayable"},
3+
{"type":"function","name":"inputBox","inputs":[],"outputs":[{"name":"","type":"address","internalType":"contract IInputBox"}],"stateMutability":"view"},
4+
{"type":"function","name":"spam","inputs":[{"name":"app","type":"address","internalType":"address"},{"name":"count","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"}
5+
]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
0x60a060405234801561000f575f5ffd5b5060405161059f38038061059f833981810160405281019061003191906100c9565b8073ffffffffffffffffffffffffffffffffffffffff1660808173ffffffffffffffffffffffffffffffffffffffff1681525050506100f4565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6100988261006f565b9050919050565b6100a88161008e565b81146100b2575f5ffd5b50565b5f815190506100c38161009f565b92915050565b5f602082840312156100de576100dd61006b565b5b5f6100eb848285016100b5565b91505092915050565b60805161048d6101125f395f81816081015261014e015261048d5ff3fe608060405234801561000f575f5ffd5b5060043610610034575f3560e01c80631fe5eabb1461003857806344e9061114610054575b5f5ffd5b610052600480360381019061004d9190610201565b610072565b005b61005c61014c565b604051610069919061029a565b60405180910390f35b5f5f90505b81811015610147577f000000000000000000000000000000000000000000000000000000000000000073ffffffffffffffffffffffffffffffffffffffff16631789cd6384836040516020016100cd9190610327565b6040516020818303038152906040526040518363ffffffff1660e01b81526004016100f99291906103cb565b6020604051808303815f875af1158015610115573d5f5f3e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610139919061042c565b508080600101915050610077565b505050565b7f000000000000000000000000000000000000000000000000000000000000000081565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f61019d82610174565b9050919050565b6101ad81610193565b81146101b7575f5ffd5b50565b5f813590506101c8816101a4565b92915050565b5f819050919050565b6101e0816101ce565b81146101ea575f5ffd5b50565b5f813590506101fb816101d7565b92915050565b5f5f6040838503121561021757610216610170565b5b5f610224858286016101ba565b9250506020610235858286016101ed565b9150509250929050565b5f819050919050565b5f61026261025d61025884610174565b61023f565b610174565b9050919050565b5f61027382610248565b9050919050565b5f61028482610269565b9050919050565b6102948161027a565b82525050565b5f6020820190506102ad5f83018461028b565b92915050565b5f81905092915050565b7f5350414d2d0000000000000000000000000000000000000000000000000000005f82015250565b5f6102f16005836102b3565b91506102fc826102bd565b600582019050919050565b5f819050919050565b61032161031c826101ce565b610307565b82525050565b5f610331826102e5565b915061033d8284610310565b60208201915081905092915050565b61035581610193565b82525050565b5f81519050919050565b5f82825260208201905092915050565b8281835e5f83830152505050565b5f601f19601f8301169050919050565b5f61039d8261035b565b6103a78185610365565b93506103b7818560208601610375565b6103c081610383565b840191505092915050565b5f6040820190506103de5f83018561034c565b81810360208301526103f08184610393565b90509392505050565b5f819050919050565b61040b816103f9565b8114610415575f5ffd5b50565b5f8151905061042681610402565b92915050565b5f6020828403121561044157610440610170565b5b5f61044e84828501610418565b9150509291505056fea2646970667358221220f1560bbf1400e24c5dcf94afa8c9cfa75aeb9a60d194e3ebf8ec8b82854a7f7c64736f6c634300081e0033

0 commit comments

Comments
 (0)