66package integration
77
88import (
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+
4367func (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+ }
0 commit comments