From e55b146be809e14194814c3f00bc779cb5e83922 Mon Sep 17 00:00:00 2001 From: Renan Santos Date: Tue, 21 May 2024 18:10:25 -0300 Subject: [PATCH] feat: add rollupsmachine package --- internal/node/model/models.go | 2 + pkg/rollupsmachine/error.go | 42 +++ pkg/rollupsmachine/io.go | 123 ++++++++ pkg/rollupsmachine/machine.go | 425 +++++++++++++++++++++++++ pkg/rollupsmachine/machine_test.go | 486 +++++++++++++++++++++++++++++ pkg/rollupsmachine/server.go | 126 ++++++++ 6 files changed, 1204 insertions(+) create mode 100644 pkg/rollupsmachine/error.go create mode 100644 pkg/rollupsmachine/io.go create mode 100644 pkg/rollupsmachine/machine.go create mode 100644 pkg/rollupsmachine/machine_test.go create mode 100644 pkg/rollupsmachine/server.go diff --git a/internal/node/model/models.go b/internal/node/model/models.go index b70b6b1b2..ae88a8b98 100644 --- a/internal/node/model/models.go +++ b/internal/node/model/models.go @@ -8,6 +8,8 @@ import ( "github.com/ethereum/go-ethereum/common/hexutil" ) +const HashLength = common.HashLength + type ( Hash = common.Hash Address = common.Address diff --git a/pkg/rollupsmachine/error.go b/pkg/rollupsmachine/error.go new file mode 100644 index 000000000..b0c209cd5 --- /dev/null +++ b/pkg/rollupsmachine/error.go @@ -0,0 +1,42 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package rollupsmachine + +import ( + "errors" + "fmt" + + "github.com/cartesi/rollups-node/internal/node/model" +) + +const unreachable = "internal error: entered unreacheable code" + +var ( + ErrCartesiMachine = errors.New("cartesi machine internal error") + + // Misc. + ErrException = errors.New("last request yielded an exception") + ErrHalted = errors.New("machine halted") + ErrProgress = errors.New("machine yielded progress") + ErrSoftYield = errors.New("machine yielded softly") + ErrCycleLimitExceeded = errors.New("cycle limit exceeded") + ErrOutputsLimitExceeded = errors.New("outputs length limit exceeded") + // ErrPayloadLengthLimitExceeded = errors.New("payload length limit exceeded") + + ErrOrphanServer = errors.New("cartesi machine server was left orphan") + + // Load + ErrNotAtManualYield = errors.New("not at manual yield") + + // Advance + ErrHashLength = fmt.Errorf("hash does not have exactly %d bytes", model.HashLength) +) + +func errOrphanServerWithAddress(address string) error { + return fmt.Errorf("%w at address %s", ErrOrphanServer, address) +} + +func errCartesiMachine(err error) error { + return errors.Join(ErrCartesiMachine, err) +} diff --git a/pkg/rollupsmachine/io.go b/pkg/rollupsmachine/io.go new file mode 100644 index 000000000..17d00d9b4 --- /dev/null +++ b/pkg/rollupsmachine/io.go @@ -0,0 +1,123 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package rollupsmachine + +import ( + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" +) + +type Input struct { + ChainId uint64 + AppContract [20]byte + Sender [20]byte + BlockNumber uint64 + BlockTimestamp uint64 + // PrevRandao uint64 + Index uint64 + Data []byte +} + +type Query struct { + Data []byte +} + +type Voucher struct { + Address [20]byte + Value *big.Int + Data []byte +} + +type Notice struct { + Data []byte +} + +func (input Input) Encode() ([]byte, error) { + chainId := new(big.Int).SetUint64(input.ChainId) + appContract := common.BytesToAddress(input.AppContract[:]) + sender := common.BytesToAddress(input.Sender[:]) + blockNumber := new(big.Int).SetUint64(input.BlockNumber) + blockTimestamp := new(big.Int).SetUint64(input.BlockTimestamp) + // prevRandao := new(big.Int).SetUint64(input.PrevRandao) + index := new(big.Int).SetUint64(input.Index) + return ioABI.Pack("EvmAdvance", chainId, appContract, sender, blockNumber, blockTimestamp, + index, input.Data) +} + +func (query Query) Encode() ([]byte, error) { + return query.Data, nil +} + +func decodeArguments(payload []byte) (arguments []any, _ error) { + method, err := ioABI.MethodById(payload) + if err != nil { + return nil, err + } + + return method.Inputs.Unpack(payload[4:]) +} + +func DecodeOutput(payload []byte) (*Voucher, *Notice, error) { + arguments, err := decodeArguments(payload) + if err != nil { + return nil, nil, err + } + + switch length := len(arguments); length { + case 1: + notice := &Notice{Data: arguments[0].([]byte)} + return nil, notice, nil + case 3: //nolint:mnd + voucher := &Voucher{ + Address: [20]byte(arguments[0].(common.Address)), + Value: arguments[1].(*big.Int), + Data: arguments[2].([]byte), + } + return voucher, nil, nil + default: + return nil, nil, fmt.Errorf("not an output: len(arguments) == %d, should be 1 or 3", length) + } +} + +var ioABI abi.ABI + +func init() { + json := `[{ + "type" : "function", + "name" : "EvmAdvance", + "inputs" : [ + { "type" : "uint256" }, + { "type" : "address" }, + { "type" : "address" }, + { "type" : "uint256" }, + { "type" : "uint256" }, + { "type" : "uint256" }, + { "type" : "bytes" } + ] + }, { + "type" : "function", + "name" : "Voucher", + "inputs" : [ + { "type" : "address" }, + { "type" : "uint256" }, + { "type" : "bytes" } + ] + }, { + "type" : "function", + "name" : "Notice", + "inputs" : [ + { "type" : "bytes" } + ] + }]` + + var err error + ioABI, err = abi.JSON(strings.NewReader(json)) + if err != nil { + panic(err) + } +} diff --git a/pkg/rollupsmachine/machine.go b/pkg/rollupsmachine/machine.go new file mode 100644 index 000000000..da764821c --- /dev/null +++ b/pkg/rollupsmachine/machine.go @@ -0,0 +1,425 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// TODO : check old server manager for DefaultInc e DefaultMax. + +package rollupsmachine + +import ( + "errors" + "fmt" + "log/slog" + + "github.com/cartesi/rollups-node/internal/node/model" + "github.com/cartesi/rollups-node/pkg/emulator" +) + +// Convenient type aliases. +type ( + Cycle = uint64 + Output = []byte + Report = []byte +) + +type requestType uint8 + +const ( + DefaultInc = Cycle(10000000) + DefaultMax = Cycle(1000000000) + + advanceStateRequest requestType = 0 + inspectStateRequest requestType = 1 + + maxOutputs = 65536 // 2^16 +) + +// A RollupsMachine wraps an emulator.Machine and provides five basic functions: +// Fork, Destroy, Hash, Advance and Inspect. +type RollupsMachine struct { + // For each request, the machine will run in increments of Inc cycles, + // for no more than Max cycles. + // + // If these fields are left undefined, + // the machine will use the DefaultInc and DefaultMax values. + Inc, Max Cycle + + address string + + inner *emulator.Machine + remote *emulator.RemoteMachineManager +} + +// Load loads the machine stored at path into the remote server from address. +// It then checks if the machine is in a valid state to receive advance and inspect requests. +func Load(path, address string, config *emulator.MachineRuntimeConfig) (*RollupsMachine, error) { + // Creates the machine with default values for Inc and Max. + machine := &RollupsMachine{Inc: DefaultInc, Max: DefaultMax, address: address} + + // Creates the remote machine manager. + remote, err := emulator.NewRemoteMachineManager(address) + if err != nil { + err = fmt.Errorf("could not create the remote machine manager: %w", err) + return nil, errCartesiMachine(err) + } + machine.remote = remote + + // Loads the machine stored at path into the server. + // Creates the inner machine reference. + inner, err := remote.LoadMachine(path, config) + if err != nil { + defer machine.remote.Delete() + err = fmt.Errorf("could not load the machine: %w", err) + return nil, errCartesiMachine(err) + } + machine.inner = inner + + // Ensures that the machine is at a manual yield. + isAtManualYield, err := machine.inner.ReadIFlagsY() + if err != nil { + defer machine.remote.Delete() + err = fmt.Errorf("could not read iflagsY: %w", err) + return nil, errors.Join(errCartesiMachine(err), machine.closeInner()) + } + if !isAtManualYield { + defer machine.remote.Delete() + return nil, errors.Join(ErrNotAtManualYield, machine.closeInner()) + } + + // Ensures that the last request the machine received did not yield and exception. + _, err = machine.lastRequestWasAccepted() + if err != nil { + defer machine.remote.Delete() + return nil, errors.Join(err, machine.closeInner()) + } + + return machine, nil +} + +// Fork forks an existing cartesi machine. +func (machine *RollupsMachine) Fork() (_ *RollupsMachine, address string, _ error) { + // Creates the new machine based on the old machine. + newMachine := &RollupsMachine{Inc: machine.Inc, Max: machine.Max} + + // Forks the remote server's process. + address, err := machine.remote.Fork() + if err != nil { + err = fmt.Errorf("could not fork the machine: %w", err) + return nil, address, errCartesiMachine(err) + } + + // Instantiates the new remote machine manager. + newMachine.remote, err = emulator.NewRemoteMachineManager(address) + if err != nil { + err = fmt.Errorf("could not create the new remote machine manager: %w", err) + errOrphanServer := errOrphanServerWithAddress(address) + return nil, address, errors.Join(errCartesiMachine(err), errOrphanServer) + } + + // Gets the inner machine reference from the remote server. + newMachine.inner, err = newMachine.remote.GetMachine() + if err != nil { + err = fmt.Errorf("could not get the machine from the server: %w", err) + return nil, address, errors.Join(errCartesiMachine(err), newMachine.closeServer()) + } + + return newMachine, address, nil +} + +// Hash returns the machine's merkle tree root hash. +func (machine RollupsMachine) Hash() (model.Hash, error) { + hash, err := machine.inner.GetRootHash() + if err != nil { + err := fmt.Errorf("could not get the machine's root hash: %w", err) + return model.Hash(hash), errCartesiMachine(err) + } + return model.Hash(hash), nil +} + +// Advance sends an input to the cartesi machine. +// It returns a boolean indicating whether or not the request was accepted. +// It also returns the corresponding outputs, reports, and the hash of the outputs. +// +// If the request was not accepted, the function does not return outputs. +func (machine *RollupsMachine) Advance(input []byte) (bool, []Output, []Report, model.Hash, error) { + var outputsHash model.Hash + + accepted, outputs, reports, err := machine.process(input, advanceStateRequest) + if err != nil { + return accepted, outputs, reports, outputsHash, err + } + + if !accepted { + return accepted, nil, reports, model.Hash{}, nil + } else { + hashBytes, err := machine.readMemory() + if err != nil { + err := fmt.Errorf("could not read the outputs' hash from the memory: %w", err) + return accepted, outputs, reports, outputsHash, errCartesiMachine(err) + } + if length := len(hashBytes); length != model.HashLength { + err := fmt.Errorf("%w (it has %d bytes)", ErrHashLength, length) + return accepted, outputs, reports, outputsHash, err + } + copy(outputsHash[:], hashBytes) + + return accepted, outputs, reports, outputsHash, nil + } +} + +// Inspect sends a query to the cartesi machine. +// It returns a boolean indicating whether or not the request was accepted +// It also returns the corresponding reports. +func (machine *RollupsMachine) Inspect(query []byte) (bool, []Report, error) { + accepted, _, reports, err := machine.process(query, inspectStateRequest) + return accepted, reports, err +} + +// Close destroys the inner cartesi machine, deletes its reference, +// shutsdown the server, and deletes the server's reference. +func (machine *RollupsMachine) Close() error { + return errors.Join(machine.closeInner(), machine.closeServer()) +} + +// ------------------------------------------------------------------------------------------------ + +// closeInner destroys the machine and deletes its reference. +func (machine *RollupsMachine) closeInner() error { + defer machine.inner.Delete() + err := machine.inner.Destroy() + if err != nil { + err = fmt.Errorf("could not destroy the machine: %w", err) + err = errCartesiMachine(err) + } + return err +} + +// closeServer shutsdown the server and deletes its reference. +func (machine *RollupsMachine) closeServer() error { + defer machine.remote.Delete() + err := machine.remote.Shutdown() + if err != nil { + err = fmt.Errorf("could not shutdown the server: %w", err) + err = errCartesiMachine(err) + err = errors.Join(err, errOrphanServerWithAddress(machine.address)) + } + return err +} + +// lastRequestWasAccepted returns true if the last request was accepted and false otherwise. +// +// The machine MUST be at a manual yield when calling this function. +func (machine *RollupsMachine) lastRequestWasAccepted() (bool, error) { + yieldReason, err := machine.readYieldReason() + if err != nil { + err := fmt.Errorf("could not read the yield reason: %w", err) + return false, errCartesiMachine(err) + } + switch yieldReason { //nolint:exhaustive + case emulator.ManualYieldReasonAccepted: + return true, nil + case emulator.ManualYieldReasonRejected: + return false, nil + case emulator.ManualYieldReasonException: + return false, ErrException + default: + panic(unreachable) + } +} + +// process processes a request, be it an avance-state or an inspect-state request. +// It returns the accepted state and any collected responses. +// +// It expects the machine to be ready to receive requests before execution, +// and leaves the machine in a state ready to receive requests after an execution with no errors. +func (machine *RollupsMachine) process( + request []byte, + requestType requestType, +) (accepted bool, _ []Output, _ []Report, _ error) { + // Writes the request's data. + err := machine.inner.WriteMemory(emulator.CmioRxBufferStart, request) + if err != nil { + err := fmt.Errorf("could not write the request's data to the memory: %w", err) + return false, nil, nil, errCartesiMachine(err) + } + + // Writes the request's type and length. + fromhost := ((uint64(requestType) << 32) | (uint64(len(request)) & 0xffffffff)) //nolint:mnd + err = machine.inner.WriteHtifFromHostData(fromhost) + if err != nil { + err := fmt.Errorf("could not write HTIF fromhost data: %w", err) + return false, nil, nil, errCartesiMachine(err) + } + + // Green-lights the machine to keep running. + err = machine.inner.ResetIFlagsY() + if err != nil { + err := fmt.Errorf("could not reset iflagsY: %w", err) + return false, nil, nil, errCartesiMachine(err) + } + + outputs, reports, err := machine.runAndCollect() + if err != nil { + return false, outputs, reports, err + } + + accepted, err = machine.lastRequestWasAccepted() + + return accepted, outputs, reports, err +} + +// runAndCollect runs the machine until it manually yields. +// It returns any collected responses. +func (machine *RollupsMachine) runAndCollect() ([]Output, []Report, error) { + startingCycle, err := machine.readMachineCycle() + if err != nil { + err := fmt.Errorf("could not read the machine's cycle: %w", err) + return nil, nil, errCartesiMachine(err) + } + maxCycle := startingCycle + machine.Max + slog.Debug("runAndCollect", + "startingCycle", startingCycle, + "maxCycle", maxCycle, + "leftover", maxCycle-startingCycle) + + outputs := []Output{} + reports := []Report{} + for { + var ( + breakReason emulator.BreakReason + err error + ) + breakReason, startingCycle, err = machine.run(startingCycle, maxCycle) + if err != nil { + return outputs, reports, err + } + + switch breakReason { //nolint:exhaustive + case emulator.BreakReasonYieldedManually: + return outputs, reports, nil // returns with the responses + case emulator.BreakReasonYieldedAutomatically: + break // breaks from the switch to read the outputs/reports + default: + panic(unreachable) + } + + yieldReason, err := machine.readYieldReason() + if err != nil { + err := fmt.Errorf("could not read the yield reason: %w", err) + return outputs, reports, errCartesiMachine(err) + } + + switch yieldReason { //nolint:exhaustive + case emulator.AutomaticYieldReasonProgress: + return outputs, reports, ErrProgress + case emulator.AutomaticYieldReasonOutput: + output, err := machine.readMemory() + if err != nil { + err := fmt.Errorf("could not read the output from the memory: %w", err) + return outputs, reports, errCartesiMachine(err) + } + if len(outputs) == maxOutputs { + return outputs, reports, ErrOutputsLimitExceeded + } + outputs = append(outputs, output) + case emulator.AutomaticYieldReasonReport: + report, err := machine.readMemory() + if err != nil { + err := fmt.Errorf("could not read the report from the memory: %w", err) + return outputs, reports, errCartesiMachine(err) + } + reports = append(reports, report) + default: + panic(unreachable) + } + } +} + +// run runs the machine until it yields. +// +// If there are no errors, it returns one of two possible break reasons: +// - emulator.BreakReasonYieldedManually +// - emulator.BreakReasonYieldedAutomatically +// +// Otherwise, it returns one of four possible errors: +// - ErrCartesiMachine +// - ErrHalted +// - ErrSoftYield +// - ErrCycleLimitExceeded +func (machine *RollupsMachine) run( + startingCycle Cycle, + maxCycle Cycle, +) (emulator.BreakReason, Cycle, error) { + currentCycle := startingCycle + slog.Debug("run", "startingCycle", startingCycle, "leftover", maxCycle-startingCycle) + + for { + // Calculates the increment. + increment := min(machine.Inc, maxCycle-currentCycle) + + // Returns with an error if the next run would exceed Max cycles. + if currentCycle+increment >= maxCycle { + return emulator.BreakReasonReachedTargetMcycle, currentCycle, ErrCycleLimitExceeded + } + + // Runs the machine. + breakReason, err := machine.inner.Run(currentCycle + increment) + if err != nil { + assert(breakReason == emulator.BreakReasonFailed, breakReason.String()) + err := fmt.Errorf("machine run failed: %w", err) + return breakReason, currentCycle, errCartesiMachine(err) + } + + // Gets the current cycle. + currentCycle, err = machine.readMachineCycle() + if err != nil { + err := fmt.Errorf("could not read the machine's cycle: %w", err) + return emulator.BreakReasonFailed, currentCycle, errCartesiMachine(err) + } + slog.Debug("run", "currentCycle", currentCycle, "leftover", maxCycle-currentCycle) + + switch breakReason { + case emulator.BreakReasonFailed: + panic(unreachable) // covered above + case emulator.BreakReasonHalted: + return emulator.BreakReasonHalted, currentCycle, ErrHalted + case emulator.BreakReasonYieldedManually, emulator.BreakReasonYieldedAutomatically: + return breakReason, currentCycle, nil // returns with the break reason + case emulator.BreakReasonYieldedSoftly: + return emulator.BreakReasonYieldedSoftly, currentCycle, ErrSoftYield + case emulator.BreakReasonReachedTargetMcycle: + continue // keeps on running + default: + panic(unreachable) + } + } +} + +// ------------------------------------------------------------------------------------------------ + +// readMemory reads the machine's memory to retrieve the data from emitted outputs/reports. +func (machine *RollupsMachine) readMemory() ([]byte, error) { + tohost, err := machine.inner.ReadHtifToHostData() + if err != nil { + return nil, err + } + length := tohost & 0x00000000ffffffff //nolint:mnd + return machine.inner.ReadMemory(emulator.CmioTxBufferStart, length) +} + +func (machine *RollupsMachine) readYieldReason() (emulator.HtifYieldReason, error) { + value, err := machine.inner.ReadHtifToHostData() + return emulator.HtifYieldReason(value >> 32), err //nolint:mnd +} + +func (machine *RollupsMachine) readMachineCycle() (Cycle, error) { + cycle, err := machine.inner.ReadMCycle() + return Cycle(cycle), err +} + +// ------------------------------------------------------------------------------------------------ + +func assert(condition bool, s string) { + if !condition { + panic("assertion error: " + s) + } +} diff --git a/pkg/rollupsmachine/machine_test.go b/pkg/rollupsmachine/machine_test.go new file mode 100644 index 000000000..b1b4a6212 --- /dev/null +++ b/pkg/rollupsmachine/machine_test.go @@ -0,0 +1,486 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package rollupsmachine + +import ( + "encoding/hex" + "fmt" + "log" + "log/slog" + "os" + "testing" + + "github.com/cartesi/rollups-node/pkg/emulator" + "github.com/cartesi/rollups-node/test/snapshot" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +func init() { + log.SetFlags(log.Ltime) + slog.SetLogLoggerLevel(slog.LevelDebug) +} + +const ( + cycles = uint64(1_000_000_000) + serverVerbosity = ServerVerbosityInfo +) + +func payload(s string) string { + return fmt.Sprintf("echo '{ \"payload\": \"0x%s\" }'", hex.EncodeToString([]byte(s))) +} + +// ------------------------------------------------------------------------------------------------ + +// TestRollupsMachine runs all the tests for the rollupsmachine package. +func TestRollupsMachine(t *testing.T) { + suite.Run(t, new(RollupsMachineSuite)) +} + +type RollupsMachineSuite struct{ suite.Suite } + +func (s *RollupsMachineSuite) TestLoad() { suite.Run(s.T(), new(LoadSuite)) } +func (s *RollupsMachineSuite) TestFork() { suite.Run(s.T(), new(ForkSuite)) } +func (s *RollupsMachineSuite) TestAdvance() { suite.Run(s.T(), new(AdvanceSuite)) } +func (s *RollupsMachineSuite) TestInspect() { suite.Run(s.T(), new(InspectSuite)) } +func (s *RollupsMachineSuite) TestCycles() { suite.Run(s.T(), new(CyclesSuite)) } + +// ------------------------------------------------------------------------------------------------ + +// Missing: +// - "could not create the remote machine manager" +// - "could not read iflagsY" +// - "could not read the yield reason" +// - machine.Close() +type LoadSuite struct { + suite.Suite + address string + + acceptSnapshot *snapshot.Snapshot + rejectSnapshot *snapshot.Snapshot + exceptionSnapshot *snapshot.Snapshot + noticeSnapshot *snapshot.Snapshot +} + +func (s *LoadSuite) SetupSuite() { + var ( + require = s.Require() + script string + err error + ) + + script = "rollup accept" + s.acceptSnapshot, err = snapshot.FromScript(script, cycles) + require.Nil(err) + require.Equal(emulator.BreakReasonYieldedManually, s.acceptSnapshot.BreakReason) + + script = "rollup reject" + s.rejectSnapshot, err = snapshot.FromScript(script, cycles) + require.Nil(err) + require.Equal(emulator.BreakReasonYieldedManually, s.rejectSnapshot.BreakReason) + + script = payload("Paul Atreides") + " | rollup exception" + s.exceptionSnapshot, err = snapshot.FromScript(script, cycles) + require.Nil(err) + require.Equal(emulator.BreakReasonYieldedManually, s.exceptionSnapshot.BreakReason) + + script = payload("Hari Seldon") + " | rollup notice" + s.noticeSnapshot, err = snapshot.FromScript(script, cycles) + require.Nil(err) + require.Equal(emulator.BreakReasonYieldedAutomatically, s.noticeSnapshot.BreakReason) +} + +func (s *LoadSuite) TearDownSuite() { + s.acceptSnapshot.Close() + s.rejectSnapshot.Close() + s.exceptionSnapshot.Close() + s.noticeSnapshot.Close() +} + +func (s *LoadSuite) SetupTest() { + address, err := StartServer(serverVerbosity, 0, os.Stdout, os.Stderr) + s.Require().Nil(err) + s.address = address +} + +func (s *LoadSuite) TearDownTest() { + err := StopServer(s.address) + s.Require().Nil(err) +} + +func (s *LoadSuite) TestOkAccept() { + require := s.Require() + config := &emulator.MachineRuntimeConfig{} + machine, err := Load(s.acceptSnapshot.Dir, s.address, config) + require.Nil(err) + require.NotNil(machine) +} + +func (s *LoadSuite) TestOkReject() { + require := s.Require() + config := &emulator.MachineRuntimeConfig{} + machine, err := Load(s.rejectSnapshot.Dir, s.address, config) + require.Nil(err) + require.NotNil(machine) +} + +func (s *LoadSuite) TestInvalidAddress() { + require := s.Require() + config := &emulator.MachineRuntimeConfig{} + machine, err := Load(s.acceptSnapshot.Dir, "invalid-address", config) + require.ErrorContains(err, "could not load the machine") + require.ErrorIs(err, ErrCartesiMachine) + require.Nil(machine) +} + +func (s *LoadSuite) TestInvalidPath() { + require := s.Require() + config := &emulator.MachineRuntimeConfig{} + machine, err := Load("invalid-path", s.address, config) + require.ErrorContains(err, "could not load the machine") + require.ErrorIs(err, ErrCartesiMachine) + require.Nil(machine) +} + +func (s *LoadSuite) TestNotAtManualYield() { + require := s.Require() + config := &emulator.MachineRuntimeConfig{} + machine, err := Load(s.noticeSnapshot.Dir, s.address, config) + require.NotNil(err) + require.ErrorIs(err, ErrNotAtManualYield) + require.Nil(machine) +} + +func (s *LoadSuite) TestException() { + require := s.Require() + config := &emulator.MachineRuntimeConfig{} + machine, err := Load(s.exceptionSnapshot.Dir, s.address, config) + require.NotNil(err) + require.ErrorIs(err, ErrException) + require.Nil(machine) +} + +// ------------------------------------------------------------------------------------------------ + +type ForkSuite struct{ suite.Suite } + +func (s *ForkSuite) TestOk() { + require := s.Require() + + // Creates the snapshot. + script := "while true; do rollup accept; done" + snapshot, err := snapshot.FromScript(script, cycles) + require.Nil(err) + require.Equal(emulator.BreakReasonYieldedManually, snapshot.BreakReason) + defer func() { require.Nil(snapshot.Close()) }() + + // Starts the server. + address, err := StartServer(serverVerbosity, 0, os.Stdout, os.Stderr) + require.Nil(err) + + // Loads the machine. + machine, err := Load(snapshot.Dir, address, &emulator.MachineRuntimeConfig{}) + require.Nil(err) + require.NotNil(machine) + defer func() { require.Nil(machine.Close()) }() + + // Forks the machine. + forkMachine, forkAddress, err := machine.Fork() + require.Nil(err) + require.NotNil(forkMachine) + require.NotEqual(address, forkAddress) + require.Nil(forkMachine.Close()) +} + +// ------------------------------------------------------------------------------------------------ + +type AdvanceSuite struct { + suite.Suite + snapshotEcho *snapshot.Snapshot + snapshotReject *snapshot.Snapshot + address string +} + +func (s *AdvanceSuite) SetupSuite() { + var ( + require = s.Require() + script string + err error + ) + + script = "ioctl-echo-loop --vouchers=1 --notices=3 --reports=5 --verbose=1" + s.snapshotEcho, err = snapshot.FromScript(script, cycles) + require.Nil(err) + require.Equal(emulator.BreakReasonYieldedManually, s.snapshotEcho.BreakReason) + + script = "while true; do rollup reject; done" + s.snapshotReject, err = snapshot.FromScript(script, cycles) + require.Nil(err) + require.Equal(emulator.BreakReasonYieldedManually, s.snapshotReject.BreakReason) +} + +func (s *AdvanceSuite) TearDownSuite() { + s.snapshotEcho.Close() + s.snapshotReject.Close() +} + +func (s *AdvanceSuite) SetupTest() { + address, err := StartServer(serverVerbosity, 0, os.Stdout, os.Stderr) + s.Require().Nil(err) + s.address = address +} + +func (s *AdvanceSuite) TestEchoLoop() { + require := s.Require() + + // Loads the machine. + machine, err := Load(s.snapshotEcho.Dir, s.address, &emulator.MachineRuntimeConfig{}) + require.Nil(err) + require.NotNil(machine) + defer func() { require.Nil(machine.Close()) }() + + // Encodes the input. + input := Input{Data: []byte("Ender Wiggin")} + encodedInput, err := input.Encode() + require.Nil(err) + + // Sends the advance-state request. + accepted, outputs, reports, outputsHash, err := machine.Advance(encodedInput) + require.Nil(err) + require.True(accepted) + require.Len(outputs, 4) + require.Len(reports, 5) + require.NotEmpty(outputsHash) + + // Checks the responses. + require.Equal(input.Data, expectVoucher(s.T(), outputs[0]).Data) + for i := 1; i < 4; i++ { + require.Equal(input.Data, expectNotice(s.T(), outputs[i]).Data) + } + for _, report := range reports { + require.Equal(input.Data, report) + } +} + +func (s *AdvanceSuite) TestAcceptRejectException() { + require := s.Require() + + // Creates the snapshot. + script := `rollup accept + rollup accept + rollup reject + echo '{"payload": "0x53616e64776f726d" }' | rollup exception` + snapshot, err := snapshot.FromScript(script, cycles) + require.Nil(err) + require.Equal(emulator.BreakReasonYieldedManually, snapshot.BreakReason) + defer func() { require.Nil(snapshot.Close()) }() + + // Loads the machine. + machine, err := Load(snapshot.Dir, s.address, &emulator.MachineRuntimeConfig{}) + require.Nil(err) + require.NotNil(machine) + defer func() { require.Nil(machine.Close()) }() + + // Encodes the input. + input := Input{Data: []byte("Shai-Hulud")} + encodedInput, err := input.Encode() + require.Nil(err) + + { // Accept. + accepted, outputs, reports, outputsHash, err := machine.Advance(encodedInput) + require.Nil(err) + require.True(accepted) + require.Empty(outputs) + require.Empty(reports) + require.NotEmpty(outputsHash) + } + + { // Reject. + accepted, outputs, reports, outputsHash, err := machine.Advance(encodedInput) + require.Nil(err) + require.False(accepted) + require.Nil(outputs) + require.Empty(reports) + require.Empty(outputsHash) + } + + { // Exception + _, _, _, _, err := machine.Advance(encodedInput) + require.Equal(ErrException, err) + } +} + +func (s *AdvanceSuite) TestHalted() { + require := s.Require() + + // Creates the snapshot. + script := `rollup accept; echo "Done"` + snapshot, err := snapshot.FromScript(script, cycles) + require.Nil(err) + require.Equal(emulator.BreakReasonYieldedManually, snapshot.BreakReason) + defer func() { require.Nil(snapshot.Close()) }() + + // Loads the machine. + machine, err := Load(snapshot.Dir, s.address, &emulator.MachineRuntimeConfig{}) + require.Nil(err) + require.NotNil(machine) + defer func() { require.Nil(machine.Close()) }() + + // Encodes the input. + input := Input{Data: []byte("Fremen")} + encodedInput, err := input.Encode() + require.Nil(err) + + _, _, _, _, err = machine.Advance(encodedInput) + require.Equal(ErrHalted, err) +} + +// ------------------------------------------------------------------------------------------------ + +type CyclesSuite struct { + suite.Suite + snapshot *snapshot.Snapshot + address string + machine *RollupsMachine + input []byte +} + +func (s *CyclesSuite) SetupSuite() { + require := s.Require() + script := "ioctl-echo-loop --vouchers=1 --notices=1 --reports=1 --verbose=1" + snapshot, err := snapshot.FromScript(script, cycles) + require.Nil(err) + require.Equal(emulator.BreakReasonYieldedManually, snapshot.BreakReason) + s.snapshot = snapshot + + quote := `"I must not fear. Fear is the mind-killer." -- Dune, Frank Herbert` + input, err := Input{Data: []byte(quote)}.Encode() + require.Nil(err) + s.input = input +} + +func (s *CyclesSuite) TearDownSuite() { + s.snapshot.Close() +} + +func (s *CyclesSuite) SetupSubTest() { + require := s.Require() + var err error + + s.address, err = StartServer(serverVerbosity, 0, os.Stdout, os.Stderr) + require.Nil(err) + + s.machine, err = Load(s.snapshot.Dir, s.address, &emulator.MachineRuntimeConfig{}) + require.Nil(err) + require.NotNil(s.machine) +} + +func (s *CyclesSuite) TearDownSubTest() { + err := s.machine.Close() + s.Require().Nil(err) +} + +// When we send a request to the machine with machine.Max set too low, +// the function call should return the ErrCycleLimitExceeded error. +func (s *CyclesSuite) TestCycleLimitExceeded() { + // Exits before calling machine.Run. + s.Run("Max=0", func() { + require := s.Require() + s.machine.Max = 0 + _, _, _, _, err := s.machine.Advance(s.input) + require.Equal(ErrCycleLimitExceeded, err) + }) + + // Runs for exactly one cycle. + s.Run("Max=1", func() { + require := s.Require() + s.machine.Max = 1 + _, _, _, _, err := s.machine.Advance(s.input) + require.Equal(ErrCycleLimitExceeded, err) + }) + + // Calls machine.Run many times. + s.Run("Max=100000", func() { + require := s.Require() + s.machine.Max = 100000 + s.machine.Inc = 10000 + _, _, _, _, err := s.machine.Advance(s.input) + require.Equal(ErrCycleLimitExceeded, err) + }) +} + +// ------------------------------------------------------------------------------------------------ + +type InspectSuite struct { + suite.Suite + snapshotEcho *snapshot.Snapshot + address string +} + +func (s *InspectSuite) SetupSuite() { + var ( + require = s.Require() + script string + err error + ) + + script = "ioctl-echo-loop --vouchers=3 --notices=5 --reports=7 --verbose=1" + s.snapshotEcho, err = snapshot.FromScript(script, cycles) + require.Nil(err) + require.Equal(emulator.BreakReasonYieldedManually, s.snapshotEcho.BreakReason) +} + +func (s *InspectSuite) TearDownSuite() { + s.snapshotEcho.Close() +} + +func (s *InspectSuite) SetupTest() { + address, err := StartServer(serverVerbosity, 0, os.Stdout, os.Stderr) + s.Require().Nil(err) + s.address = address +} + +func (s *InspectSuite) TestEchoLoop() { + require := s.Require() + + // Loads the machine. + machine, err := Load(s.snapshotEcho.Dir, s.address, &emulator.MachineRuntimeConfig{}) + require.Nil(err) + require.NotNil(machine) + defer func() { require.Nil(machine.Close()) }() + + query := []byte("Bene Gesserit") + + // Sends the inspect-state request. + accepted, reports, err := machine.Inspect(query) + require.Nil(err) + require.True(accepted) + require.Len(reports, 7) + + // Checks the responses. + for _, report := range reports { + require.Equal(query, report) + } +} + +// ------------------------------------------------------------------------------------------------ + +// expectVoucher decodes the output and asserts that it is a voucher. +func expectVoucher(t *testing.T, output Output) *Voucher { + voucher, notice, err := DecodeOutput(output) + require.Nil(t, err) + require.NotNil(t, voucher) + require.Nil(t, notice) + return voucher +} + +// expectNotice decodes the output and asserts that it is a notice. +func expectNotice(t *testing.T, output Output) *Notice { + voucher, notice, err := DecodeOutput(output) + require.Nil(t, err) + require.Nil(t, voucher) + require.NotNil(t, notice) + return notice +} diff --git a/pkg/rollupsmachine/server.go b/pkg/rollupsmachine/server.go new file mode 100644 index 000000000..d4d29ac56 --- /dev/null +++ b/pkg/rollupsmachine/server.go @@ -0,0 +1,126 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package rollupsmachine + +import ( + "fmt" + "io" + "log/slog" + "os/exec" + "regexp" + "strconv" + + "github.com/cartesi/rollups-node/internal/linewriter" + "github.com/cartesi/rollups-node/pkg/emulator" +) + +type ServerVerbosity string + +const ( + ServerVerbosityTrace ServerVerbosity = "trace" + ServerVerbosityDebug ServerVerbosity = "debug" + ServerVerbosityInfo ServerVerbosity = "info" + ServerVerbosityWarn ServerVerbosity = "warn" + ServerVerbosityError ServerVerbosity = "error" + ServerVerbosityFatal ServerVerbosity = "fatal" +) + +// StartServer starts a JSON RPC remote cartesi machine server. +// +// It configures the server's logging verbosity and initializes its address to localhost:port. +// If verbosity is an invalid LogLevel, a default value will be used instead. +// If port is 0, a random valid port will be used instead. +// +// StartServer also redirects the server's stdout and stderr to the provided io.Writers. +// +// It returns the server's address. +func StartServer(verbosity ServerVerbosity, port uint32, stdout, stderr io.Writer) (string, error) { + // Configures the command's arguments. + args := []string{} + if verbosity.valid() { + args = append(args, "--log-level="+string(verbosity)) + } + if port != 0 { + args = append(args, fmt.Sprintf("--server-address=localhost:%d", port)) + } + + // Creates the command. + cmd := exec.Command("jsonrpc-remote-cartesi-machine", args...) + + // Redirects stdout and stderr. + intercepter := portIntercepter{ + inner: stderr, + port: make(chan uint32), + found: new(bool), + } + cmd.Stdout = stdout + cmd.Stderr = linewriter.New(intercepter) + + // Starts the server. + slog.Info("running", "command", cmd.String()) + if err := cmd.Start(); err != nil { + return "", err + } + + // Waits for the intercepter to write the port to the channel. + if actualPort := <-intercepter.port; port == 0 { + port = actualPort + } else if port != actualPort { + panic(fmt.Sprintf("mismatching ports (%d != %d)", port, actualPort)) + } + + return fmt.Sprintf("localhost:%d", port), nil +} + +// StopServer shuts down the JSON RPC remote cartesi machine server hosted at address. +func StopServer(address string) error { + slog.Info("Stopping server at", "address", address) + remote, err := emulator.NewRemoteMachineManager(address) + if err != nil { + return err + } + defer remote.Delete() + return remote.Shutdown() +} + +// ------------------------------------------------------------------------------------------------ + +func (verbosity ServerVerbosity) valid() bool { + return verbosity == ServerVerbosityTrace || + verbosity == ServerVerbosityDebug || + verbosity == ServerVerbosityInfo || + verbosity == ServerVerbosityWarn || + verbosity == ServerVerbosityError || + verbosity == ServerVerbosityFatal +} + +// portIntercepter sends the server's port through the port channel as soon as it reads it. +// It then closes the channel and keeps on writing to the inner writer. +// +// It expects to be wrapped by a linewriter.LineWriter. +type portIntercepter struct { + inner io.Writer + port chan uint32 + found *bool +} + +var regex = regexp.MustCompile("initial server bound to port ([0-9]+)") + +func (writer portIntercepter) Write(p []byte) (n int, err error) { + if *writer.found { + return writer.inner.Write(p) + } else { + matches := regex.FindStringSubmatch(string(p)) + if matches != nil { + port, err := strconv.ParseUint(matches[1], 10, 32) + if err != nil { + return 0, err + } + *writer.found = true + writer.port <- uint32(port) + close(writer.port) + } + return writer.inner.Write(p) + } +}