diff --git a/.github/workflows/ci-test.yaml b/.github/workflows/ci-test.yaml index 3a80c6cf2a..192ed2d0a6 100644 --- a/.github/workflows/ci-test.yaml +++ b/.github/workflows/ci-test.yaml @@ -300,6 +300,11 @@ jobs: # NOTE: This name appears in GitHub's Checks API. name: e2e-ts-web-rt runs-on: ubuntu-latest + env: + # Run all E2E tests in mock SGX. + OASIS_UNSAFE_SKIP_AVR_VERIFY: 1 + OASIS_UNSAFE_ALLOW_DEBUG_ENCLAVES: 1 + OASIS_UNSAFE_MOCK_SGX: 1 steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index 3578cffde4..c938247e9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2334,6 +2334,7 @@ name = "oasis-runtime-sdk" version = "0.9.0" dependencies = [ "anyhow", + "async-trait", "base64 0.22.1", "bech32", "blake3", @@ -2355,6 +2356,7 @@ dependencies = [ "once_cell", "p256", "p384", + "rand", "rand_core", "schnorrkel", "sha2 0.10.8", @@ -2370,7 +2372,7 @@ dependencies = [ [[package]] name = "oasis-runtime-sdk-contracts" -version = "0.4.1" +version = "0.5.0" dependencies = [ "anyhow", "blake3", @@ -2395,7 +2397,7 @@ dependencies = [ [[package]] name = "oasis-runtime-sdk-evm" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -3675,6 +3677,26 @@ dependencies = [ "thiserror", ] +[[package]] +name = "test-runtime-components-rofl" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "oasis-runtime-sdk", + "test-runtime-components-ronl", +] + +[[package]] +name = "test-runtime-components-ronl" +version = "0.1.0" +dependencies = [ + "oasis-cbor", + "oasis-runtime-sdk", + "once_cell", + "thiserror", +] + [[package]] name = "test-runtime-simple-consensus" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5e945f20a3..1eff9fec36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ members = [ "tests/runtimes/simple-consensus", "tests/runtimes/simple-evm", "tests/runtimes/simple-contracts", + "tests/runtimes/components-ronl", + "tests/runtimes/components-rofl", ] exclude = [ # Test contracts. diff --git a/client-sdk/go/connection/connection.go b/client-sdk/go/connection/connection.go index b69691d8c7..afa2ebc4d0 100644 --- a/client-sdk/go/connection/connection.go +++ b/client-sdk/go/connection/connection.go @@ -22,6 +22,7 @@ import ( "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/core" "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/evm" "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/rewards" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/rofl" ) // RuntimeClient is a client.RuntimeClient augmented with commonly used modules. @@ -34,6 +35,7 @@ type RuntimeClient struct { ConsensusAccounts consensusaccounts.V1 Contracts contracts.V1 Evm evm.V1 + ROFL rofl.V1 } // Connection is the general node connection interface. @@ -74,6 +76,7 @@ func (c *connection) Runtime(pt *config.ParaTime) RuntimeClient { ConsensusAccounts: consensusaccounts.NewV1(cli), Contracts: contracts.NewV1(cli), Evm: evm.NewV1(cli), + ROFL: rofl.NewV1(cli), } } diff --git a/client-sdk/go/modules/consensusaccounts/consensus_accounts.go b/client-sdk/go/modules/consensusaccounts/consensus_accounts.go index cd5ce302dd..1f858f7ebb 100644 --- a/client-sdk/go/modules/consensusaccounts/consensus_accounts.go +++ b/client-sdk/go/modules/consensusaccounts/consensus_accounts.go @@ -181,9 +181,6 @@ func (a *v1) GetEvents(ctx context.Context, round uint64) ([]*Event, error) { if err != nil { return nil, err } - if ev == nil { - continue - } for _, e := range ev { evs = append(evs, e.(*Event)) } diff --git a/client-sdk/go/modules/core/core.go b/client-sdk/go/modules/core/core.go index 1adb46c418..19095448ad 100644 --- a/client-sdk/go/modules/core/core.go +++ b/client-sdk/go/modules/core/core.go @@ -112,9 +112,6 @@ func (a *v1) GetEvents(ctx context.Context, round uint64) ([]*Event, error) { if err != nil { return nil, err } - if ev == nil { - continue - } for _, e := range ev { evs = append(evs, e.(*Event)) } diff --git a/client-sdk/go/modules/core/types.go b/client-sdk/go/modules/core/types.go index 6373539169..a9a53f6b45 100644 --- a/client-sdk/go/modules/core/types.go +++ b/client-sdk/go/modules/core/types.go @@ -19,7 +19,7 @@ type EstimateGasQuery struct { PropagateFailures bool `json:"propagate_failures,omitempty"` } -// GasCosts are the consensus accounts module gas costs. +// GasCosts are the core module gas costs. type GasCosts struct { TxByte uint64 `json:"tx_byte"` AuthSignature uint64 `json:"auth_signature"` @@ -27,7 +27,7 @@ type GasCosts struct { CallformatX25519Deoxysii uint64 `json:"callformat_x25519_deoxysii"` } -// Parameters are the parameters for the consensus accounts module. +// Parameters are the parameters for the core module. type Parameters struct { MaxBatchGas uint64 `json:"max_batch_gas"` MaxTxSigners uint32 `json:"max_tx_signers"` diff --git a/client-sdk/go/modules/rofl/app_id.go b/client-sdk/go/modules/rofl/app_id.go new file mode 100644 index 0000000000..720030b7d2 --- /dev/null +++ b/client-sdk/go/modules/rofl/app_id.go @@ -0,0 +1,97 @@ +package rofl + +import ( + "encoding" + "encoding/binary" + + "github.com/oasisprotocol/oasis-core/go/common/crypto/address" + "github.com/oasisprotocol/oasis-core/go/common/encoding/bech32" + + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/types" +) + +var ( + // AppIDV0CRIContext is the unique context for v0 creator/round/index application identifiers. + AppIDV0CRIContext = address.NewContext("oasis-sdk/rofl: cri app id", 0) + // AppIDV0GlobalNameContext is the unique context for v0 global name application identifiers. + AppIDV0GlobalNameContext = address.NewContext("oasis-sdk/rofl: global name app id", 0) + // AppIDBech32HRP is the unique human readable part of Bech32 encoded application identifiers. + AppIDBech32HRP = address.NewBech32HRP("rofl") + + _ encoding.BinaryMarshaler = AppID{} + _ encoding.BinaryUnmarshaler = (*AppID)(nil) + _ encoding.TextMarshaler = AppID{} + _ encoding.TextUnmarshaler = (*AppID)(nil) +) + +// AppID is the ROFL application identifier. +type AppID address.Address + +// MarshalBinary encodes an application identifier into binary form. +func (a AppID) MarshalBinary() ([]byte, error) { + return (address.Address)(a).MarshalBinary() +} + +// UnmarshalBinary decodes a binary marshaled application identifier. +func (a *AppID) UnmarshalBinary(data []byte) error { + return (*address.Address)(a).UnmarshalBinary(data) +} + +// MarshalText encodes an application identifier into text form. +func (a AppID) MarshalText() ([]byte, error) { + return (address.Address)(a).MarshalBech32(AppIDBech32HRP) +} + +// UnmarshalText decodes a text marshaled application identifier. +func (a *AppID) UnmarshalText(text []byte) error { + return (*address.Address)(a).UnmarshalBech32(AppIDBech32HRP, text) +} + +// Equal compares vs another application identifier for equality. +func (a AppID) Equal(cmp AppID) bool { + return (address.Address)(a).Equal((address.Address)(cmp)) +} + +// String returns the string representation of an application identifier. +func (a AppID) String() string { + bech32Addr, err := bech32.Encode(AppIDBech32HRP.String(), a[:]) + if err != nil { + return "[malformed]" + } + return bech32Addr +} + +// NewAppIDCreatorRoundIndex creates a new application identifier from the given creator/round/index +// tuple. +func NewAppIDCreatorRoundIndex(creator types.Address, round uint64, index uint32) AppID { + data := make([]byte, address.Size+8+4) + + rawCreator, _ := creator.MarshalBinary() + copy(data[:address.Size], rawCreator) + + binary.BigEndian.PutUint64(data[address.Size:], round) + binary.BigEndian.PutUint32(data[address.Size+8:], index) + + return NewAppIDRaw(AppIDV0CRIContext, data) +} + +// NewAppIDGlobalName creates a new application identifier from the given global name. +func NewAppIDGlobalName(name string) AppID { + return NewAppIDRaw(AppIDV0GlobalNameContext, []byte(name)) +} + +// NewAppIDRaw creates a new application identifier from passed context and data. +func NewAppIDRaw(ctx address.Context, data []byte) AppID { + return (AppID)(address.NewAddress(ctx, data)) +} + +// NewAppIDFromBech32 creates a new application identifier from the given bech-32 encoded string. +// +// Panics in case of errors -- use UnmarshalText if you want to handle errors. +func NewAppIDFromBech32(data string) (a AppID) { + err := a.UnmarshalText([]byte(data)) + if err != nil { + panic(err) + } + return +} diff --git a/client-sdk/go/modules/rofl/app_id_test.go b/client-sdk/go/modules/rofl/app_id_test.go new file mode 100644 index 0000000000..c528104f86 --- /dev/null +++ b/client-sdk/go/modules/rofl/app_id_test.go @@ -0,0 +1,28 @@ +package rofl + +import ( + "testing" + + "github.com/stretchr/testify/require" + + sdkTesting "github.com/oasisprotocol/oasis-sdk/client-sdk/go/testing" +) + +func TestIdentifierV0(t *testing.T) { + require := require.New(t) + + appID := NewAppIDCreatorRoundIndex(sdkTesting.Alice.Address, 42, 0) + require.Equal("rofl1qr98wz5t6q4x8ng6a5l5v7rqlx90j3kcnun5dwht", appID.String()) + + appID = NewAppIDCreatorRoundIndex(sdkTesting.Bob.Address, 42, 0) + require.Equal("rofl1qrd45eaj4tf6l7mjw5prcukz75wdmwg6kggt6pnp", appID.String()) + + appID = NewAppIDCreatorRoundIndex(sdkTesting.Bob.Address, 1, 0) + require.Equal("rofl1qzmuyfwygnmfralgtwrqx8kcm587kwex9y8hf9hf", appID.String()) + + appID = NewAppIDCreatorRoundIndex(sdkTesting.Bob.Address, 42, 1) + require.Equal("rofl1qzmh56f52yd0tcqh757fahzc7ec49s8kaguyylvu", appID.String()) + + appID = NewAppIDGlobalName("test global app") + require.Equal("rofl1qrev5wq76npkmcv5wxkdxxcu4dhmu704yyl30h43", appID.String()) +} diff --git a/client-sdk/go/modules/rofl/policy.go b/client-sdk/go/modules/rofl/policy.go new file mode 100644 index 0000000000..8d8b63cdb6 --- /dev/null +++ b/client-sdk/go/modules/rofl/policy.go @@ -0,0 +1,46 @@ +package rofl + +import ( + beacon "github.com/oasisprotocol/oasis-core/go/beacon/api" + "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" + "github.com/oasisprotocol/oasis-core/go/common/sgx" + "github.com/oasisprotocol/oasis-core/go/common/sgx/quote" +) + +// AppAuthPolicy is the per-application ROFL policy. +type AppAuthPolicy struct { + // Quotes is a quote policy. + Quotes quote.Policy `json:"quotes"` + // Enclaves is the set of allowed enclave identities. + Enclaves []sgx.EnclaveIdentity `json:"enclaves"` + // Endorsements is the set of allowed endorsements. + Endorsements []AllowedEndorsement `json:"endorsements"` + // Fees is the gas fee payment policy. + Fees FeePolicy `json:"fees"` + // MaxExpiration is the maximum number of future epochs for which one can register. + MaxExpiration beacon.EpochTime `json:"max_expiration"` +} + +// AllowedEndorsement is an allowed endorsement policy. +type AllowedEndorsement struct { + // Any specifies that any node can endorse the enclave. + Any *struct{} `json:"any,omitempty"` + // ComputeRole specifies that a compute node can endorse the enclave. + ComputeRole *struct{} `json:"role_compute,omitempty"` + // ObserverRole specifies that an observer node can endorse the enclave. + ObserverRole *struct{} `json:"role_observer,omitempty"` + // Entity specifies that a registered node from a specific entity can endorse the enclave. + Entity *signature.PublicKey `json:"entity,omitempty"` + // Node specifies that a specific node can endorse the enclave. + Node *signature.PublicKey `json:"node,omitempty"` +} + +// FeePolicy is a gas fee payment policy. +type FeePolicy uint8 + +const ( + // FeePolicyAppPays is a fee policy where the application enclave pays the gas fees. + FeePolicyAppPays FeePolicy = 1 + // FeePolicyEndorsingNodePays is a fee policy where the endorsing node pays the gas fees. + FeePolicyEndorsingNodePays FeePolicy = 2 +) diff --git a/client-sdk/go/modules/rofl/rofl.go b/client-sdk/go/modules/rofl/rofl.go new file mode 100644 index 0000000000..f7065c28d2 --- /dev/null +++ b/client-sdk/go/modules/rofl/rofl.go @@ -0,0 +1,189 @@ +package rofl + +import ( + "context" + "fmt" + + "github.com/oasisprotocol/oasis-core/go/common/cbor" + + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/client" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/types" +) + +var ( + // Callable methods. + methodCreate = types.NewMethodName("rofl.Create", Create{}) + methodUpdate = types.NewMethodName("rofl.Update", Update{}) + methodRemove = types.NewMethodName("rofl.Remove", Remove{}) + + // Queries. + methodApp = types.NewMethodName("rofl.App", AppQuery{}) + methodAppInstances = types.NewMethodName("rofl.AppInstances", AppQuery{}) + methodParameters = types.NewMethodName("rofl.Parameters", nil) +) + +// V1 is the v1 rofl module interface. +type V1 interface { + client.EventDecoder + + // Create generates a rofl.Create transaction. + Create(policy AppAuthPolicy) *client.TransactionBuilder + + // Update generates a rofl.Update transaction. + Update(id AppID, policy AppAuthPolicy, admin *types.Address) *client.TransactionBuilder + + // Remove generates a rofl.Remove transaction. + Remove(id AppID) *client.TransactionBuilder + + // App queries the given application configuration. + App(ctx context.Context, round uint64, id AppID) (*AppConfig, error) + + // AppInstances queries the registered instances of the given application. + AppInstances(ctx context.Context, round uint64, id AppID) ([]*Registration, error) + + // Parameters queries the module parameters. + Parameters(ctx context.Context, round uint64) (*Parameters, error) + + // GetEvents returns all rofl events emitted in a given block. + GetEvents(ctx context.Context, round uint64) ([]*Event, error) +} + +type v1 struct { + rc client.RuntimeClient +} + +// Implements V1. +func (a *v1) Create(policy AppAuthPolicy) *client.TransactionBuilder { + return client.NewTransactionBuilder(a.rc, methodCreate, &Create{ + Policy: policy, + }) +} + +// Implements V1. +func (a *v1) Update(id AppID, policy AppAuthPolicy, admin *types.Address) *client.TransactionBuilder { + return client.NewTransactionBuilder(a.rc, methodUpdate, &Update{ + ID: id, + Policy: policy, + Admin: admin, + }) +} + +// Implements V1. +func (a *v1) Remove(id AppID) *client.TransactionBuilder { + return client.NewTransactionBuilder(a.rc, methodRemove, &Remove{ + ID: id, + }) +} + +// Implements V1. +func (a *v1) App(ctx context.Context, round uint64, id AppID) (*AppConfig, error) { + var appCfg AppConfig + err := a.rc.Query(ctx, round, methodApp, AppQuery{ID: id}, &appCfg) + if err != nil { + return nil, err + } + return &appCfg, nil +} + +// Implements V1. +func (a *v1) AppInstances(ctx context.Context, round uint64, id AppID) ([]*Registration, error) { + var instances []*Registration + err := a.rc.Query(ctx, round, methodAppInstances, AppQuery{ID: id}, &instances) + if err != nil { + return nil, err + } + return instances, nil +} + +// Implements V1. +func (a *v1) Parameters(ctx context.Context, round uint64) (*Parameters, error) { + var params Parameters + err := a.rc.Query(ctx, round, methodParameters, nil, ¶ms) + if err != nil { + return nil, err + } + return ¶ms, nil +} + +// Implements V1. +func (a *v1) GetEvents(ctx context.Context, round uint64) ([]*Event, error) { + rawEvs, err := a.rc.GetEventsRaw(ctx, round) + if err != nil { + return nil, err + } + + evs := make([]*Event, 0) + for _, rawEv := range rawEvs { + ev, err := a.DecodeEvent(rawEv) + if err != nil { + return nil, err + } + for _, e := range ev { + evs = append(evs, e.(*Event)) + } + } + + return evs, nil +} + +// Implements client.EventDecoder. +func (a *v1) DecodeEvent(event *types.Event) ([]client.DecodedEvent, error) { + return DecodeEvent(event) +} + +// DecodeEvent decodes a rofl event. +func DecodeEvent(event *types.Event) ([]client.DecodedEvent, error) { + if event.Module != ModuleName { + return nil, nil + } + var events []client.DecodedEvent + switch event.Code { + case AppCreatedEventCode: + var evs []*AppCreatedEvent + if err := cbor.Unmarshal(event.Value, &evs); err != nil { + return nil, fmt.Errorf("decode rofl app created event value: %w", err) + } + for _, ev := range evs { + events = append(events, &Event{AppCreated: ev}) + } + case AppUpdatedEventCode: + var evs []*AppUpdatedEvent + if err := cbor.Unmarshal(event.Value, &evs); err != nil { + return nil, fmt.Errorf("decode rofl app updated event value: %w", err) + } + for _, ev := range evs { + events = append(events, &Event{AppUpdated: ev}) + } + case AppRemovedEventCode: + var evs []*AppRemovedEvent + if err := cbor.Unmarshal(event.Value, &evs); err != nil { + return nil, fmt.Errorf("decode rofl app removed event value: %w", err) + } + for _, ev := range evs { + events = append(events, &Event{AppRemoved: ev}) + } + default: + return nil, fmt.Errorf("invalid rofl event code: %v", event.Code) + } + return events, nil +} + +// NewV1 generates a V1 client helper for the rofl module. +func NewV1(rc client.RuntimeClient) V1 { + return &v1{rc: rc} +} + +// NewCreateTx generates a new rofl.Create transaction. +func NewCreateTx(fee *types.Fee, body *Create) *types.Transaction { + return types.NewTransaction(fee, methodCreate, body) +} + +// NewUpdateTx generates a new rofl.Update transaction. +func NewUpdateTx(fee *types.Fee, body *Update) *types.Transaction { + return types.NewTransaction(fee, methodUpdate, body) +} + +// NewRemoveTx generates a new rofl.Remove transaction. +func NewRemoveTx(fee *types.Fee, body *Remove) *types.Transaction { + return types.NewTransaction(fee, methodRemove, body) +} diff --git a/client-sdk/go/modules/rofl/types.go b/client-sdk/go/modules/rofl/types.go new file mode 100644 index 0000000000..613f7ee278 --- /dev/null +++ b/client-sdk/go/modules/rofl/types.go @@ -0,0 +1,103 @@ +package rofl + +import ( + "github.com/oasisprotocol/curve25519-voi/primitives/x25519" + + beacon "github.com/oasisprotocol/oasis-core/go/beacon/api" + "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" + + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/types" +) + +// Create new ROFL application call. +type Create struct { + // Policy is the application authentication policy. + Policy AppAuthPolicy `json:"policy"` +} + +// Update an existing ROFL application call. +type Update struct { + // ID is the application identifier. + ID AppID `json:"id"` + // Policy is the application authentication policy. + Policy AppAuthPolicy `json:"policy"` + // Admin is the application administrator address. + Admin *types.Address `json:"admin"` +} + +// Remove an existing ROFL application call. +type Remove struct { + // ID is the application identifier. + ID AppID `json:"id"` +} + +// AppQuery is an application-related query. +type AppQuery struct { + // ID is the application identifier. + ID AppID `json:"id"` +} + +// AppConfig is a ROFL application configuration. +type AppConfig struct { + // ID is the application identifier. + ID AppID `json:"id"` + // Policy is the application authentication policy. + Policy AppAuthPolicy `json:"policy"` + // Admin is the application administrator address. + Admin *types.Address `json:"admin"` + // Stake is the staked amount. + Stake types.BaseUnits `json:"stake"` +} + +// Registration is a ROFL enclave registration descriptor. +type Registration struct { + // App is the application this enclave is registered for. + App AppID `json:"app"` + // NodeID is the identifier of the endorsing node. + NodeID signature.PublicKey `json:"node_id"` + // RAK is the Runtime Attestation Key. + RAK signature.PublicKey `json:"rak"` + // REK is the Runtime Encryption Key. + REK x25519.PublicKey `json:"rek"` + // Expiration is the epoch when the ROFL registration expires if not renewed. + Expiration beacon.EpochTime `json:"expiration"` + // ExtraKeys are the extra public keys to endorse (e.g. secp256k1 keys). + ExtraKeys []types.PublicKey `json:"extra_keys"` +} + +// Parameters are the parameters for the rofl module. +type Parameters struct{} + +// ModuleName is the rofl module name. +const ModuleName = "rofl" + +const ( + // AppCreatedEventCode is the event code for the application created event. + AppCreatedEventCode = 1 + // AppUpdatedEventCode is the event code for the application updated event. + AppUpdatedEventCode = 2 + // AppRemovedEventCode is the event code for the application removed event. + AppRemovedEventCode = 3 +) + +// AppCreatedEvent is an application created event. +type AppCreatedEvent struct { + ID AppID `json:"id"` +} + +// AppUpdatedEvent is an application updated event. +type AppUpdatedEvent struct { + ID AppID `json:"id"` +} + +// AppRemovedEvent is an application removed event. +type AppRemovedEvent struct { + ID AppID `json:"id"` +} + +// Event is a rofl module event. +type Event struct { + AppCreated *AppCreatedEvent + AppUpdated *AppUpdatedEvent + AppRemoved *AppRemovedEvent +} diff --git a/client-sdk/ts-web/rt/playground/build-runtime.sh b/client-sdk/ts-web/rt/playground/build-runtime.sh index d8ed122c40..6fd6226846 100755 --- a/client-sdk/ts-web/rt/playground/build-runtime.sh +++ b/client-sdk/ts-web/rt/playground/build-runtime.sh @@ -1,14 +1,15 @@ #!/bin/sh -eux + if [ ! -e ../../../../target/debug/test-runtime-simple-keyvalue ]; then ( cd ../../../.. - cargo build -p test-runtime-simple-keyvalue + cargo build -p test-runtime-simple-keyvalue --features debug-mock-sgx ) fi if [ ! -e ../../../../target/debug/test-runtime-simple-consensus ]; then ( cd ../../../.. - cargo build -p test-runtime-simple-consensus + cargo build -p test-runtime-simple-consensus --features debug-mock-sgx ) fi diff --git a/client-sdk/ts-web/rt/playground/sample-run-network.sh b/client-sdk/ts-web/rt/playground/sample-run-network.sh index 52d2361dc9..164ee0ed4b 100755 --- a/client-sdk/ts-web/rt/playground/sample-run-network.sh +++ b/client-sdk/ts-web/rt/playground/sample-run-network.sh @@ -10,6 +10,7 @@ FIXTURE_FILE="/tmp/oasis-net-runner-sdk-rt/fixture.json" "$TEST_NET_RUNNER" \ dump-fixture \ + --fixture.default.tee_hardware intel-sgx \ --fixture.default.node.binary "$TEST_NODE_BINARY" \ --fixture.default.runtime.id "8000000000000000000000000000000000000000000000000000000000000000" \ --fixture.default.runtime.binary ../../../../target/debug/test-runtime-simple-keyvalue \ @@ -22,6 +23,14 @@ FIXTURE_FILE="/tmp/oasis-net-runner-sdk-rt/fixture.json" --fixture.default.runtime.version 0.1.0 \ --fixture.default.staking_genesis ./staking.json >"$FIXTURE_FILE" +# Use mock SGX. +jq ' + .runtimes[0].deployments[0].components[0].binaries."0" = "'${TEST_KM_BINARY}'" | + .runtimes[1].deployments[0].components[0].binaries."0" = "../../../../target/debug/test-runtime-simple-keyvalue" | + .runtimes[2].deployments[0].components[0].binaries."0" = "../../../../target/debug/test-runtime-simple-consensus" +' "$FIXTURE_FILE" >"$FIXTURE_FILE.tmp" +mv "$FIXTURE_FILE.tmp" "$FIXTURE_FILE" + # Allow expensive gas estimation and expensive queries. jq ' .clients[0].runtime_config."2".estimate_gas_by_simulating_contracts = true | diff --git a/contract-sdk/specs/token/oas20/Cargo.lock b/contract-sdk/specs/token/oas20/Cargo.lock index b250e7f621..44c6118a6c 100644 --- a/contract-sdk/specs/token/oas20/Cargo.lock +++ b/contract-sdk/specs/token/oas20/Cargo.lock @@ -1798,6 +1798,7 @@ name = "oasis-runtime-sdk" version = "0.9.0" dependencies = [ "anyhow", + "async-trait", "base64 0.22.1", "bech32", "byteorder", @@ -1818,6 +1819,7 @@ dependencies = [ "once_cell", "p256", "p384", + "rand", "rand_core", "schnorrkel", "sha2 0.10.8", diff --git a/docs/README.md b/docs/README.md index 16917e0df4..5bb58ac69d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,20 @@ The Runtime SDK handles the _backend_ part, namely the runtime itself. It allows you to build the logic that will be replicated when running alongside an Oasis Core node in Rust. +#### ROFL Applications + +Runtime OFf-chain Logic (ROFL) applications are a mechanism to augment the +deterministic on-chain backend with verifiable off-chain applications. These +applications are stateless, have access to the network and can perform expensive +and/or non-deterministic computation. Consider them the _off-chain backend_ +part. + +ROFL applications run in Trusted Execution Environments (TEEs), similar to the +on-chain confidential runtimes. This enables them to securely authenticate to +the on-chain backend which is handled transparently by the framework. Together +they allow one to implement secure decentralized oracles, bridges, AI agents and +more. + ### Contract SDK The Contract SDK handles the _higher level backend_ part and allows you to diff --git a/docs/runtime/modules.md b/docs/runtime/modules.md index 15244b40ed..2d78c5490c 100644 --- a/docs/runtime/modules.md +++ b/docs/runtime/modules.md @@ -21,6 +21,11 @@ impl sdk::Runtime for Runtime { // Use the crate version from Cargo.toml as the runtime version. const VERSION: Version = sdk::version_from_cargo!(); + // Module that provides the core API. + type Core = modules::core::Module; + // Module that provides the accounts API. + type Accounts = modules::accounts::Module; + // Define the modules that the runtime will be composed of. type Modules = (modules::core::Module, modules::accounts::Module); diff --git a/docs/runtime/prerequisites.md b/docs/runtime/prerequisites.md index dd7b8fdc1a..f016b1658e 100644 --- a/docs/runtime/prerequisites.md +++ b/docs/runtime/prerequisites.md @@ -116,16 +116,16 @@ The SDK requires utilities provided by [Oasis Core] in order to be able to run a local test network for development purposes. The recommended way is to download a pre-built release (at least version -23.0) from the [Oasis Core releases] page. After downloading the binary -release (e.g. into `~/Downloads/oasis_core_23.0_linux_amd64.tar.gz`), unpack +24.0) from the [Oasis Core releases] page. After downloading the binary +release (e.g. into `~/Downloads/oasis_core_24.0_linux_amd64.tar.gz`), unpack it as follows: ```bash cd ~/Downloads -tar xf ~/Downloads/oasis_core_23.0_linux_amd64.tar.gz --strip-components=1 +tar xf ~/Downloads/oasis_core_24.0_linux_amd64.tar.gz --strip-components=1 # This environment variable will be used throughout this guide. -export OASIS_CORE_PATH=~/Downloads/oasis_core_23.0_linux_amd64 +export OASIS_CORE_PATH=~/Downloads/oasis_core_24.0_linux_amd64 ``` [Oasis Core]: https://github.com/oasisprotocol/oasis-core diff --git a/runtime-sdk/Cargo.toml b/runtime-sdk/Cargo.toml index 462038ed72..9c08c66d1a 100644 --- a/runtime-sdk/Cargo.toml +++ b/runtime-sdk/Cargo.toml @@ -12,6 +12,7 @@ oasis-core-keymanager = { git = "https://github.com/oasisprotocol/oasis-core", t oasis-runtime-sdk-macros = { path = "../runtime-sdk-macros", optional = true } # Third party. +async-trait = "0.1.77" byteorder = "1.4.3" curve25519-dalek = "4.1.3" ed25519-dalek = { version = "2.0.0", features = ["digest", "hazmat"] } @@ -33,6 +34,7 @@ num-traits = "0.2.14" impl-trait-for-tuples = "0.2.1" base64 = "0.22.1" once_cell = "1.8.0" +rand = "0.8.5" rand_core = { version = "0.6.4", default-features = false } slog = "2.7.0" tiny-keccak = { version = "2.0", features = ["tuple_hash"] } diff --git a/runtime-sdk/modules/contracts/Cargo.toml b/runtime-sdk/modules/contracts/Cargo.toml index 2b9733e104..b5b87cbe84 100644 --- a/runtime-sdk/modules/contracts/Cargo.toml +++ b/runtime-sdk/modules/contracts/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "oasis-runtime-sdk-contracts" description = "Smart contracts module for the Oasis Runtime SDK." -version = "0.4.1" +version = "0.5.0" authors = ["Oasis Protocol Foundation "] edition = "2021" license = "Apache-2.0" diff --git a/runtime-sdk/modules/contracts/src/abi/oasis/env.rs b/runtime-sdk/modules/contracts/src/abi/oasis/env.rs index 4b7d59e1b0..1a7b0067d7 100644 --- a/runtime-sdk/modules/contracts/src/abi/oasis/env.rs +++ b/runtime-sdk/modules/contracts/src/abi/oasis/env.rs @@ -3,7 +3,7 @@ use oasis_contract_sdk_types::{ env::{AccountsQuery, AccountsResponse, QueryRequest, QueryResponse}, InstanceId, }; -use oasis_runtime_sdk::{context::Context, modules::accounts::API as _}; +use oasis_runtime_sdk::{context::Context, modules::accounts::API as _, Runtime}; use super::{memory::Region, OasisV1}; use crate::{ @@ -42,7 +42,7 @@ impl OasisV1 { )??; // Dispatch query. - let result = dispatch_query::(ec.tx_context, request); + let result = dispatch_query::(ec.tx_context, request); // Create new region by calling `allocate`. // @@ -102,7 +102,7 @@ impl OasisV1 { } /// Perform environment query dispatch. -fn dispatch_query(ctx: &C, query: QueryRequest) -> QueryResponse { +fn dispatch_query(ctx: &C, query: QueryRequest) -> QueryResponse { match query { // Information about the current runtime block. QueryRequest::BlockInfo => QueryResponse::BlockInfo { @@ -112,7 +112,7 @@ fn dispatch_query(ctx: &C, query: QueryRequest) -> Quer }, // Accounts API queries. - QueryRequest::Accounts(query) => dispatch_accounts_query::(ctx, query), + QueryRequest::Accounts(query) => dispatch_accounts_query::(ctx, query), _ => QueryResponse::Error { module: "".to_string(), @@ -123,17 +123,15 @@ fn dispatch_query(ctx: &C, query: QueryRequest) -> Quer } /// Perform accounts API query dispatch. -fn dispatch_accounts_query( - _ctx: &C, - query: AccountsQuery, -) -> QueryResponse { +fn dispatch_accounts_query(_ctx: &C, query: AccountsQuery) -> QueryResponse { match query { AccountsQuery::Balance { address, denomination, } => { let balance = - Cfg::Accounts::get_balance(address.into(), denomination.into()).unwrap_or_default(); + ::Accounts::get_balance(address.into(), denomination.into()) + .unwrap_or_default(); AccountsResponse::Balance { balance }.into() } diff --git a/runtime-sdk/modules/contracts/src/abi/oasis/test.rs b/runtime-sdk/modules/contracts/src/abi/oasis/test.rs index 03a4d3d941..c0fd0b8b2a 100644 --- a/runtime-sdk/modules/contracts/src/abi/oasis/test.rs +++ b/runtime-sdk/modules/contracts/src/abi/oasis/test.rs @@ -3,7 +3,6 @@ use oasis_runtime_sdk::{ context::Context, core::common::crypto::hash::Hash, error::Error as _, - modules, modules::core, testing::mock, types::{address::Address, transaction::CallFormat}, @@ -18,9 +17,7 @@ const HELLO_CONTRACT_CODE: &[u8] = include_bytes!( struct ContractsConfig; -impl Config for ContractsConfig { - type Accounts = modules::accounts::Module; -} +impl Config for ContractsConfig {} struct CoreConfig; diff --git a/runtime-sdk/modules/contracts/src/lib.rs b/runtime-sdk/modules/contracts/src/lib.rs index 65973d298b..2f36534527 100644 --- a/runtime-sdk/modules/contracts/src/lib.rs +++ b/runtime-sdk/modules/contracts/src/lib.rs @@ -354,10 +354,7 @@ pub mod state { } /// Module configuration. -pub trait Config: 'static { - /// Module that is used for accessing accounts. - type Accounts: modules::accounts::API; -} +pub trait Config: 'static {} pub struct Module { _cfg: std::marker::PhantomData, @@ -570,7 +567,7 @@ impl Module { // Transfer any attached tokens. for tokens in &body.tokens { - Cfg::Accounts::transfer(creator, instance_info.address(), tokens) + ::Accounts::transfer(creator, instance_info.address(), tokens) .map_err(|_| Error::InsufficientCallerBalance)? } // Run instantiation function. @@ -615,7 +612,7 @@ impl Module { // Transfer any attached tokens. for tokens in &body.tokens { - Cfg::Accounts::transfer(caller, instance_info.address(), tokens) + ::Accounts::transfer(caller, instance_info.address(), tokens) .map_err(|_| Error::InsufficientCallerBalance)? } // Run call function. @@ -689,7 +686,7 @@ impl Module { // Transfer any attached tokens. for tokens in &body.tokens { - Cfg::Accounts::transfer(caller, instance_info.address(), tokens) + ::Accounts::transfer(caller, instance_info.address(), tokens) .map_err(|_| Error::InsufficientCallerBalance)? } // Run pre-upgrade function on the previous contract. diff --git a/runtime-sdk/modules/contracts/src/test.rs b/runtime-sdk/modules/contracts/src/test.rs index 778a7b93cd..d031cca62d 100644 --- a/runtime-sdk/modules/contracts/src/test.rs +++ b/runtime-sdk/modules/contracts/src/test.rs @@ -27,9 +27,7 @@ static HELLO_CONTRACT_CODE: &[u8] = include_bytes!( struct ContractsConfig; -impl Config for ContractsConfig { - type Accounts = Accounts; -} +impl Config for ContractsConfig {} type Contracts = crate::Module; @@ -58,9 +56,8 @@ fn upload_hello_contract(ctx: &C) -> types::CodeId { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 160_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -104,9 +101,8 @@ fn deploy_hello_contract(ctx: &C, tokens: Vec) -> types:: 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -210,9 +206,8 @@ fn test_hello_contract_call() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -305,9 +300,8 @@ fn test_hello_contract_call() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -457,9 +451,8 @@ fn test_hello_contract_call() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -544,9 +537,8 @@ fn test_hello_contract_call() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -642,9 +634,8 @@ fn test_hello_contract_call() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -678,6 +669,7 @@ impl Runtime for ContractRuntime { const VERSION: Version = Version::new(0, 0, 0); type Core = Core; + type Accounts = Accounts; type Modules = (Core, Accounts, Contracts); @@ -749,9 +741,8 @@ fn test_hello_contract_subcalls_overflow() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 3_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -806,7 +797,7 @@ fn test_hello_contract_subcalls() { fee: transaction::Fee { amount: BaseUnits::new(2_000_000, Denomination::NATIVE), gas: 2_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -872,9 +863,8 @@ fn test_hello_contract_query() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -915,9 +905,8 @@ fn test_hello_contract_query() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -958,9 +947,8 @@ fn test_hello_contract_query() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -1013,9 +1001,8 @@ fn test_hello_contract_upgrade() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 2_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -1057,9 +1044,8 @@ fn test_hello_contract_upgrade_fail_policy() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 2_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -1105,9 +1091,8 @@ fn test_hello_contract_upgrade_fail_pre() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 2_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -1156,9 +1141,8 @@ fn test_hello_contract_upgrade_fail_post() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 2_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -1204,9 +1188,8 @@ fn test_hello_contract_change_upgrade_policy() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 2_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -1245,9 +1228,8 @@ fn test_hello_contract_change_upgrade_policy_fail() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 2_000_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, diff --git a/runtime-sdk/modules/evm/Cargo.toml b/runtime-sdk/modules/evm/Cargo.toml index 697e04c104..742184ee11 100644 --- a/runtime-sdk/modules/evm/Cargo.toml +++ b/runtime-sdk/modules/evm/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "oasis-runtime-sdk-evm" description = "EVM module for the Oasis Runtime SDK." -version = "0.5.0" +version = "0.6.0" authors = ["Oasis Protocol Foundation "] edition = "2021" license = "Apache-2.0" diff --git a/runtime-sdk/modules/evm/src/backend.rs b/runtime-sdk/modules/evm/src/backend.rs index 4527f14385..df0d740aff 100644 --- a/runtime-sdk/modules/evm/src/backend.rs +++ b/runtime-sdk/modules/evm/src/backend.rs @@ -327,7 +327,7 @@ impl<'ctx, 'backend, 'config, C: Context, Cfg: Config> Backend } fn block_base_fee_per_gas(&self) -> U256 { - ::Core::min_gas_price(self.backend.ctx, &Cfg::TOKEN_DENOMINATION) + ::Core::min_gas_price(&Cfg::TOKEN_DENOMINATION) .unwrap_or_default() .into() } @@ -346,8 +346,10 @@ impl<'ctx, 'backend, 'config, C: Context, Cfg: Config> Backend // Derive SDK account address from the Ethereum address. let sdk_address = Cfg::map_address(address); // Fetch balance and nonce from SDK accounts. Note that these can never fail. - let balance = Cfg::Accounts::get_balance(sdk_address, Cfg::TOKEN_DENOMINATION).unwrap(); - let mut nonce = Cfg::Accounts::get_nonce(sdk_address).unwrap(); + let balance = + ::Accounts::get_balance(sdk_address, Cfg::TOKEN_DENOMINATION) + .unwrap(); + let mut nonce = ::Accounts::get_nonce(sdk_address).unwrap(); // If this is the caller's address, the caller nonce has not yet been incremented based on // the EVM semantics and this is not a simulation context, return the nonce decremented by @@ -465,7 +467,7 @@ impl<'ctx, 'backend, 'config, C: Context, Cfg: Config> StackState<'config> } let address = Cfg::map_address(address); - Cfg::Accounts::inc_nonce(address); + ::Accounts::inc_nonce(address); Ok(()) } @@ -525,7 +527,8 @@ impl<'ctx, 'backend, 'config, C: Context, Cfg: Config> StackState<'config> let amount = transfer.value.as_u128(); let amount = token::BaseUnits::new(amount, Cfg::TOKEN_DENOMINATION); - Cfg::Accounts::transfer(from, to, &amount).map_err(|_| ExitError::OutOfFund) + ::Accounts::transfer(from, to, &amount) + .map_err(|_| ExitError::OutOfFund) } fn reset_balance(&mut self, _address: H160) { diff --git a/runtime-sdk/modules/evm/src/lib.rs b/runtime-sdk/modules/evm/src/lib.rs index dbb50095ce..8573ab6393 100644 --- a/runtime-sdk/modules/evm/src/lib.rs +++ b/runtime-sdk/modules/evm/src/lib.rs @@ -26,7 +26,6 @@ use oasis_runtime_sdk::{ handler, migration, module::{self, Module as _}, modules::{ - self, accounts::API as _, core::{Error as CoreError, API as _}, }, @@ -52,9 +51,6 @@ const MODULE_NAME: &str = "evm"; /// Module configuration. pub trait Config: 'static { - /// Module that is used for accessing accounts. - type Accounts: modules::accounts::API; - /// AdditionalPrecompileSet is the type used for the additional precompiles. /// Use `()` if unused. type AdditionalPrecompileSet: evm::executor::stack::PrecompileSet; @@ -371,7 +367,10 @@ impl API for Module { fn get_balance(_ctx: &C, address: H160) -> Result { let address = Cfg::map_address(address.into()); - Ok(Cfg::Accounts::get_balance(address, Cfg::TOKEN_DENOMINATION).unwrap_or_default()) + Ok( + ::Accounts::get_balance(address, Cfg::TOKEN_DENOMINATION) + .unwrap_or_default(), + ) } fn simulate_call( @@ -440,6 +439,7 @@ impl API for Module { ), gas: gas_limit, consensus_messages: 0, + proxy: None, }, ..Default::default() }, @@ -565,7 +565,7 @@ impl Module { }; ::Core::use_tx_gas(gas_used)?; - Cfg::Accounts::set_refund_unused_tx_fee(Cfg::REFUND_UNUSED_FEE); + ::Accounts::set_refund_unused_tx_fee(Cfg::REFUND_UNUSED_FEE); return Err(err); } }; @@ -577,7 +577,7 @@ impl Module { }; ::Core::use_tx_gas(gas_used)?; - Cfg::Accounts::set_refund_unused_tx_fee(Cfg::REFUND_UNUSED_FEE); + ::Accounts::set_refund_unused_tx_fee(Cfg::REFUND_UNUSED_FEE); Ok(exit_value) } @@ -754,14 +754,14 @@ impl Module { impl module::TransactionHandler for Module { fn decode_tx( - ctx: &C, + _ctx: &C, scheme: &str, body: &[u8], ) -> Result, CoreError> { match scheme { "evm.ethereum.v0" => { let min_gas_price = - ::Core::min_gas_price(ctx, &Cfg::TOKEN_DENOMINATION) + ::Core::min_gas_price(&Cfg::TOKEN_DENOMINATION) .unwrap_or_default(); Ok(Some( diff --git a/runtime-sdk/modules/evm/src/precompile/testing.rs b/runtime-sdk/modules/evm/src/precompile/testing.rs index 0e5f1690d3..81a8947cc9 100644 --- a/runtime-sdk/modules/evm/src/precompile/testing.rs +++ b/runtime-sdk/modules/evm/src/precompile/testing.rs @@ -7,7 +7,7 @@ pub use primitive_types::{H160, H256}; use oasis_runtime_sdk::{ context, module::{self}, - modules::{accounts, accounts::Module, core, core::Error}, + modules::{accounts, core, core::Error}, subcall, testing::keys, types::token::{self, Denomination}, @@ -25,8 +25,6 @@ use std::collections::BTreeMap; pub(crate) struct TestConfig; impl crate::Config for TestConfig { - type Accounts = Module; - type AdditionalPrecompileSet = (); const CHAIN_ID: u64 = 0; @@ -210,6 +208,7 @@ pub(crate) struct TestRuntime; impl Runtime for TestRuntime { const VERSION: Version = Version::new(0, 0, 0); type Core = Core; + type Accounts = Accounts; type Modules = (Core, Accounts, Evm); fn genesis_state() -> ::Genesis { diff --git a/runtime-sdk/modules/evm/src/raw_tx.rs b/runtime-sdk/modules/evm/src/raw_tx.rs index a6d91d6b20..763863413d 100644 --- a/runtime-sdk/modules/evm/src/raw_tx.rs +++ b/runtime-sdk/modules/evm/src/raw_tx.rs @@ -191,6 +191,7 @@ pub fn decode( amount: token::BaseUnits::new(resolved_fee_amount, denom.clone()), gas: gas_limit, consensus_messages: 0, // Dynamic number of consensus messages, limited by gas. + proxy: None, }, ..Default::default() }, diff --git a/runtime-sdk/modules/evm/src/signed_call.rs b/runtime-sdk/modules/evm/src/signed_call.rs index 8cb2034d84..40447973a1 100644 --- a/runtime-sdk/modules/evm/src/signed_call.rs +++ b/runtime-sdk/modules/evm/src/signed_call.rs @@ -12,7 +12,7 @@ use oasis_runtime_sdk::{ use crate::{ state, types::{Leash, SimulateCallQuery}, - Config, Error, + Config, Error, Runtime, }; /// Verifies the signature on signed query and whether it is appropriately leashed. @@ -44,7 +44,7 @@ pub(crate) fn verify( // Next, verify the leash. let current_block = ctx.runtime_header().round; let sdk_address = Cfg::map_address(query.caller.into()); - let nonce = Cfg::Accounts::get_nonce(sdk_address).unwrap(); + let nonce = ::Accounts::get_nonce(sdk_address).unwrap(); if nonce > leash.nonce { return Err(Error::InvalidSignedSimulateCall("stale nonce")); } @@ -158,7 +158,7 @@ fn hash_encoded(tokens: &[Token]) -> [u8; 32] { mod test { use super::*; - use oasis_runtime_sdk::testing::mock; + use oasis_runtime_sdk::{modules::accounts, testing::mock}; use crate::{ test::{ConfidentialEVMConfig as C10lCfg, EVMConfig as Cfg}, @@ -166,6 +166,8 @@ mod test { Module as EVMModule, }; + type Accounts = accounts::Module; + /// This was generated using the `@oasislabs/sapphire-paratime` JS lib. const SIGNED_CALL_DATA_PACK: &str = "a36464617461a164626f64794401020304656c65617368a4656e6f6e63651903e76a626c6f636b5f686173685820c92b675c7013e33aa88feaae520eb0ede155e7cacb3c4587e0923cba9953f8bb6b626c6f636b5f72616e6765036c626c6f636b5f6e756d626572182a697369676e6174757265584148bca100e84d13a80b131c62b9b87caf07e4da6542a9e1ea16d8042ba08cc1e31f10ae924d8c137882204e9217423194014ce04fa2130c14f27b148858733c7b1c"; @@ -194,12 +196,12 @@ mod test { fn setup_nonce(caller: &H160, leash: &Leash) { let sdk_address = C10lCfg::map_address((*caller).into()); - ::Accounts::set_nonce(sdk_address, leash.nonce); + Accounts::set_nonce(sdk_address, leash.nonce); } fn setup_stale_nonce(caller: &H160, leash: &Leash) { let sdk_address = C10lCfg::map_address((*caller).into()); - ::Accounts::set_nonce(sdk_address, leash.nonce + 1); + Accounts::set_nonce(sdk_address, leash.nonce + 1); } fn setup_block(leash: &Leash) { diff --git a/runtime-sdk/modules/evm/src/test.rs b/runtime-sdk/modules/evm/src/test.rs index 746099ba17..d32a135b9d 100644 --- a/runtime-sdk/modules/evm/src/test.rs +++ b/runtime-sdk/modules/evm/src/test.rs @@ -41,8 +41,6 @@ static FAUCET_CONTRACT_CODE_HEX: &str = pub(crate) struct EVMConfig; impl Config for EVMConfig { - type Accounts = Accounts; - type AdditionalPrecompileSet = (); const CHAIN_ID: u64 = 0xa515; @@ -53,8 +51,6 @@ impl Config for EVMConfig { pub(crate) struct ConfidentialEVMConfig; impl Config for ConfidentialEVMConfig { - type Accounts = Accounts; - type AdditionalPrecompileSet = (); const CHAIN_ID: u64 = 0x5afe; @@ -209,9 +205,8 @@ fn do_test_evm_calls(force_plain: bool) { 0, )], fee: transaction::Fee { - amount: Default::default(), - gas: 1000000, - consensus_messages: 0, + gas: 1_000_000, + ..Default::default() }, ..Default::default() }, @@ -250,9 +245,8 @@ fn do_test_evm_calls(force_plain: bool) { 1, )], fee: transaction::Fee { - amount: Default::default(), - gas: 25000, - consensus_messages: 0, + gas: 25_000, + ..Default::default() }, ..Default::default() }, @@ -343,9 +337,8 @@ fn test_c10l_evm_balance_transfer() { 0, )], fee: transaction::Fee { - amount: Default::default(), - gas: 1000000, - consensus_messages: 0, + gas: 1_000_000, + ..Default::default() }, ..Default::default() }, @@ -401,6 +394,7 @@ impl Runtime for EVMRuntime { const VERSION: Version = Version::new(0, 0, 0); type Core = Core; + type Accounts = Accounts; type Modules = (Core, Accounts, EVMModule); @@ -512,9 +506,8 @@ fn do_test_evm_runtime() { 0, )], fee: transaction::Fee { - amount: Default::default(), - gas: 1000000, - consensus_messages: 0, + gas: 1_000_000, + ..Default::default() }, ..Default::default() }, @@ -561,9 +554,8 @@ fn do_test_evm_runtime() { 1, )], fee: transaction::Fee { - amount: Default::default(), gas: 10, // Not enough gas. - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -621,9 +613,8 @@ fn do_test_evm_runtime() { 2, )], fee: transaction::Fee { - amount: Default::default(), - gas: 25000, - consensus_messages: 0, + gas: 25_000, + ..Default::default() }, ..Default::default() }, @@ -703,9 +694,8 @@ fn do_test_evm_runtime() { 3, )], fee: transaction::Fee { - amount: Default::default(), - gas: 64000, - consensus_messages: 0, + gas: 64_000, + ..Default::default() }, ..Default::default() }, @@ -755,9 +745,8 @@ fn do_test_evm_runtime() { 4, )], fee: transaction::Fee { - amount: Default::default(), gas: 10, // Not enough gas. - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, diff --git a/runtime-sdk/src/crypto/signature/ed25519.rs b/runtime-sdk/src/crypto/signature/ed25519.rs index 44c1545d0d..4f64c74172 100644 --- a/runtime-sdk/src/crypto/signature/ed25519.rs +++ b/runtime-sdk/src/crypto/signature/ed25519.rs @@ -4,6 +4,7 @@ use std::convert::TryInto; use base64::prelude::*; use curve25519_dalek::{digest::consts::U64, edwards::CompressedEdwardsY}; use ed25519_dalek::Signer as _; +use rand_core::RngCore; use sha2::{Digest as _, Sha512, Sha512_256}; use oasis_core_runtime::common::crypto::signature::{ @@ -13,7 +14,7 @@ use oasis_core_runtime::common::crypto::signature::{ use crate::crypto::signature::{Error, Signature, Signer}; /// An Ed25519 public key. -#[derive(Clone, Debug, PartialEq, Eq, cbor::Encode, cbor::Decode)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, cbor::Encode, cbor::Decode)] #[cbor(transparent, no_default)] pub struct PublicKey(CorePublicKey); @@ -216,6 +217,12 @@ impl MemorySigner { } impl Signer for MemorySigner { + fn random(rng: &mut impl RngCore) -> Result { + let mut seed = [0u8; 32]; + rng.fill_bytes(&mut seed); + Self::new_from_seed(&seed) + } + fn new_from_seed(seed: &[u8]) -> Result { if seed.len() != 32 { return Err(Error::MalformedPublicKey); diff --git a/runtime-sdk/src/crypto/signature/mod.rs b/runtime-sdk/src/crypto/signature/mod.rs index de9755895b..cec4a19057 100644 --- a/runtime-sdk/src/crypto/signature/mod.rs +++ b/runtime-sdk/src/crypto/signature/mod.rs @@ -2,8 +2,11 @@ use std::convert::TryFrom; use digest::{typenum::Unsigned as _, Digest as _}; +use rand_core::RngCore; use thiserror::Error; +use crate::core::common::crypto::signature::{PublicKey as CorePublicKey, Signer as CoreSigner}; + pub mod context; mod digests; pub mod ed25519; @@ -107,7 +110,7 @@ impl TryFrom for SignatureType { } /// A public key used for signing. -#[derive(Clone, Debug, PartialEq, Eq, cbor::Encode, cbor::Decode)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, cbor::Encode, cbor::Decode)] pub enum PublicKey { #[cbor(rename = "ed25519")] Ed25519(ed25519::PublicKey), @@ -145,6 +148,17 @@ pub enum Error { } impl PublicKey { + /// Return the key type as string. + pub fn key_type(&self) -> &str { + match self { + Self::Ed25519(_) => "ed25519", + Self::Secp256k1(_) => "secp256k1", + Self::Secp256r1(_) => "secp256r1", + Self::Secp384r1(_) => "secp384r1", + Self::Sr25519(_) => "sr25519", + } + } + /// Return a byte representation of this public key. pub fn as_bytes(&self) -> &[u8] { match self { @@ -313,6 +327,32 @@ impl AsRef<[u8]> for PublicKey { } } +impl PartialEq for PublicKey { + fn eq(&self, other: &CorePublicKey) -> bool { + match self { + PublicKey::Ed25519(pk) => pk.as_bytes() == other.as_ref(), + _ => false, + } + } +} + +impl TryFrom for CorePublicKey { + type Error = &'static str; + + fn try_from(pk: PublicKey) -> Result { + match pk { + PublicKey::Ed25519(pk) => Ok(pk.into()), + _ => Err("not an Ed25519 public key"), + } + } +} + +impl From for PublicKey { + fn from(pk: CorePublicKey) -> Self { + Self::Ed25519(pk.into()) + } +} + /// Variable-length opaque signature. #[derive(Clone, Debug, Default, PartialEq, Eq, cbor::Encode, cbor::Decode)] #[cbor(transparent)] @@ -337,25 +377,75 @@ impl From for Vec { } /// Common trait for memory signers. -trait Signer { +pub trait Signer: Send + Sync { + /// Create a new random signer. + fn random(rng: &mut impl RngCore) -> Result + where + Self: Sized; + /// Create a new signer from the given seed. fn new_from_seed(seed: &[u8]) -> Result where Self: Sized; + /// Recreate signer from a byte serialization. fn from_bytes(bytes: &[u8]) -> Result where Self: Sized; + /// Serialize the signer into bytes. fn to_bytes(&self) -> Vec; + /// Return the public key counterpart to the signer's secret key. fn public_key(&self) -> PublicKey; + /// Generate a signature over the context and message. fn sign(&self, context: &[u8], message: &[u8]) -> Result; + /// Generate a signature over the message. fn sign_raw(&self, message: &[u8]) -> Result; } +impl Signer for &T { + fn random(_rng: &mut impl RngCore) -> Result + where + Self: Sized, + { + Err(Error::InvalidArgument) + } + + fn new_from_seed(_seed: &[u8]) -> Result + where + Self: Sized, + { + Err(Error::InvalidArgument) + } + + fn from_bytes(_bytes: &[u8]) -> Result + where + Self: Sized, + { + Err(Error::InvalidArgument) + } + + fn to_bytes(&self) -> Vec { + vec![] + } + + fn public_key(&self) -> PublicKey { + PublicKey::Ed25519(self.public().into()) + } + + fn sign(&self, context: &[u8], message: &[u8]) -> Result { + let raw_sig = CoreSigner::sign(*self, context, message).map_err(|_| Error::SigningError)?; + Ok(Signature(raw_sig.as_ref().into())) + } + + fn sign_raw(&self, _message: &[u8]) -> Result { + Err(Error::InvalidArgument) + } +} + /// A memory-backed signer. pub enum MemorySigner { Ed25519(ed25519::MemorySigner), diff --git a/runtime-sdk/src/crypto/signature/secp256k1.rs b/runtime-sdk/src/crypto/signature/secp256k1.rs index ee7fff4328..63a9ed7d99 100644 --- a/runtime-sdk/src/crypto/signature/secp256k1.rs +++ b/runtime-sdk/src/crypto/signature/secp256k1.rs @@ -10,11 +10,12 @@ use k256::{ elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint}, sha2::Sha512_256, }; +use rand_core::RngCore; use crate::crypto::signature::{Error, Signature}; /// A Secp256k1 public key (in compressed form). -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PublicKey(k256::EncodedPoint); impl PublicKey { @@ -121,6 +122,12 @@ impl MemorySigner { } impl super::Signer for MemorySigner { + fn random(rng: &mut impl RngCore) -> Result { + let mut seed = [0u8; 32]; + rng.fill_bytes(&mut seed); + Self::new_from_seed(&seed) + } + fn new_from_seed(seed: &[u8]) -> Result { let sk = ecdsa::SigningKey::from_slice(seed).map_err(|_| Error::InvalidArgument)?; Ok(Self { sk }) diff --git a/runtime-sdk/src/crypto/signature/secp256r1.rs b/runtime-sdk/src/crypto/signature/secp256r1.rs index f4f05b91c4..c7f31f0ae0 100644 --- a/runtime-sdk/src/crypto/signature/secp256r1.rs +++ b/runtime-sdk/src/crypto/signature/secp256r1.rs @@ -9,11 +9,12 @@ use p256::{ signature::{DigestSigner as _, DigestVerifier, Signer as _, Verifier as _}, }, }; +use rand_core::RngCore; use crate::crypto::signature::{Error, Signature}; /// A Secp256r1 public key (in compressed form). -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PublicKey(p256::EncodedPoint); impl PublicKey { @@ -107,6 +108,12 @@ impl MemorySigner { } impl super::Signer for MemorySigner { + fn random(rng: &mut impl RngCore) -> Result { + let mut seed = [0u8; 32]; + rng.fill_bytes(&mut seed); + Self::new_from_seed(&seed) + } + fn new_from_seed(seed: &[u8]) -> Result { let sk = ecdsa::SigningKey::from_slice(seed).map_err(|_| Error::InvalidArgument)?; Ok(Self { sk }) diff --git a/runtime-sdk/src/crypto/signature/secp384r1.rs b/runtime-sdk/src/crypto/signature/secp384r1.rs index 2c51d1bec9..bcbc6f91d6 100644 --- a/runtime-sdk/src/crypto/signature/secp384r1.rs +++ b/runtime-sdk/src/crypto/signature/secp384r1.rs @@ -8,11 +8,12 @@ use p384::{ signature::{DigestSigner as _, DigestVerifier, Signer as _, Verifier as _}, }, }; +use rand_core::RngCore; use crate::crypto::signature::{Error, Signature}; /// A Secp384r1 public key (in compressed form). -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PublicKey(p384::EncodedPoint); impl PublicKey { @@ -106,6 +107,12 @@ impl MemorySigner { } impl super::Signer for MemorySigner { + fn random(rng: &mut impl RngCore) -> Result { + let mut seed = [0u8; 48]; + rng.fill_bytes(&mut seed); + Self::new_from_seed(&seed) + } + fn new_from_seed(seed: &[u8]) -> Result { let sk = ecdsa::SigningKey::from_slice(seed).map_err(|_| Error::InvalidArgument)?; Ok(Self { sk }) diff --git a/runtime-sdk/src/crypto/signature/sr25519.rs b/runtime-sdk/src/crypto/signature/sr25519.rs index 4c5513ec83..2422a58ce3 100644 --- a/runtime-sdk/src/crypto/signature/sr25519.rs +++ b/runtime-sdk/src/crypto/signature/sr25519.rs @@ -6,7 +6,7 @@ use sha2::{Digest, Sha512_256}; use crate::crypto::signature::{Error, Signature}; /// A Sr25519 public key. -#[derive(Clone, Debug, PartialEq, Eq, cbor::Encode, cbor::Decode)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, cbor::Encode, cbor::Decode)] #[cbor(transparent, no_default)] pub struct PublicKey(Vec); diff --git a/runtime-sdk/src/dispatcher.rs b/runtime-sdk/src/dispatcher.rs index e318603556..78ce1f404a 100644 --- a/runtime-sdk/src/dispatcher.rs +++ b/runtime-sdk/src/dispatcher.rs @@ -14,8 +14,9 @@ use oasis_core_runtime::{ self, common::crypto::hash::Hash, consensus::{roothash, verifier::Verifier}, + enclave_rpc::dispatcher::Dispatcher as RpcDispatcher, future::block_on, - protocol::HostInfo, + protocol::{HostInfo, Protocol}, transaction::{ self, dispatcher::{ExecuteBatchResult, ExecuteTxResult}, @@ -28,6 +29,7 @@ use oasis_core_runtime::{ use crate::{ callformat, context::{Context, RuntimeBatchContext}, + enclave_rpc, error::{Error as _, RuntimeError}, event::IntoTags, keymanager::{KeyManagerClient, KeyManagerError}, @@ -127,6 +129,7 @@ pub struct DispatchOptions<'a> { /// The runtime dispatcher. pub struct Dispatcher { host_info: HostInfo, + host: Arc, key_manager: Option>, consensus_verifier: Arc, schedule_control_host: Arc, @@ -139,16 +142,16 @@ impl Dispatcher { /// Note that the dispatcher is fully static and the constructor is only needed so that the /// instance can be used directly with the dispatcher system provided by Oasis Core. pub(super) fn new( - host_info: HostInfo, + host: Arc, key_manager: Option>, consensus_verifier: Arc, - schedule_control_host: Arc, ) -> Self { Self { - host_info, + host_info: host.get_host_info(), key_manager, consensus_verifier, - schedule_control_host, + schedule_control_host: host.clone(), + host, _runtime: PhantomData, } } @@ -586,6 +589,20 @@ impl Dispatcher { }) }) } + + /// Register EnclaveRPC methods. + pub fn register_enclaverpc(&self, rpc: &mut RpcDispatcher) + where + R: Runtime + Send + Sync + 'static, + { + enclave_rpc::Wrapper::::wrap( + rpc, + self.host.clone(), + self.host_info.clone(), + self.key_manager.clone(), + self.consensus_verifier.clone(), + ); + } } impl transaction::dispatcher::Dispatcher for Dispatcher { @@ -917,7 +934,7 @@ mod test { use crate::{ handler, module::Module, - modules::core, + modules::{accounts, core}, sdk_derive, state::{CurrentState, Options}, storage::Store, @@ -930,6 +947,7 @@ mod test { struct CoreConfig; impl core::Config for CoreConfig {} type Core = core::Module; + type Accounts = accounts::Module; #[derive(Error, Debug, oasis_runtime_sdk_macros::Error)] enum AlphabetError { @@ -993,6 +1011,7 @@ mod test { impl Runtime for AlphabetRuntime { const VERSION: Version = Version::new(0, 0, 0); type Core = Core; + type Accounts = Accounts; type Modules = (Core, AlphabetModule); fn genesis_state() -> ::Genesis { @@ -1089,7 +1108,7 @@ mod test { fee: transaction::Fee { amount: token::BaseUnits::new(0, token::Denomination::NATIVE), gas: 1000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -1144,7 +1163,7 @@ mod test { fee: transaction::Fee { amount: token::BaseUnits::new(0, token::Denomination::NATIVE), gas: 1000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, diff --git a/runtime-sdk/src/enclave_rpc.rs b/runtime-sdk/src/enclave_rpc.rs new file mode 100644 index 0000000000..b09ef69960 --- /dev/null +++ b/runtime-sdk/src/enclave_rpc.rs @@ -0,0 +1,180 @@ +//! Exposed EnclaveRPC methods. +use std::{marker::PhantomData, sync::Arc}; + +use anyhow::{anyhow, bail, Result}; + +use crate::{ + context::RuntimeBatchContext, + core::{ + consensus::{ + roothash::Header, + state::{ + beacon::ImmutableState as BeaconState, registry::ImmutableState as RegistryState, + roothash::ImmutableState as RoothashState, + }, + verifier::Verifier, + }, + enclave_rpc::{ + dispatcher::{ + Dispatcher as RpcDispatcher, Method as RpcMethod, + MethodDescriptor as RpcMethodDescriptor, + }, + types::Kind as RpcKind, + Context as RpcContext, + }, + future::block_on, + protocol::{HostInfo, Protocol}, + storage::mkvs, + }, + dispatcher, + keymanager::KeyManagerClient, + module::MethodHandler, + state::{self, CurrentState}, + storage::HostStore, + Runtime, +}; + +/// Name of the `query` method. +pub const METHOD_QUERY: &str = "runtime-sdk/query"; + +/// Arguments for the `query` method. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +pub struct QueryRequest { + pub round: u64, + pub method: String, + pub args: Vec, +} + +/// EnclaveRPC dispatcher wrapper. +pub(crate) struct Wrapper { + host_info: HostInfo, + host: Arc, + key_manager: Option>, + consensus_verifier: Arc, + _runtime: PhantomData, +} + +impl Wrapper +where + R: Runtime + Send + Sync + 'static, +{ + pub(crate) fn wrap( + rpc: &mut RpcDispatcher, + host: Arc, + host_info: HostInfo, + key_manager: Option>, + consensus_verifier: Arc, + ) { + let wrapper = Box::leak(Box::new(Self { + host_info, + host, + key_manager, + consensus_verifier, + _runtime: PhantomData, + })); + rpc.add_methods(wrapper.methods()); + } + + fn methods(&'static self) -> Vec { + vec![RpcMethod::new( + RpcMethodDescriptor { + name: METHOD_QUERY.to_string(), + kind: RpcKind::NoiseSession, + }, + move |ctx: &_, req: &_| self.rpc_query(ctx, req), + )] + } + + fn ensure_session_endorsed(&self, ctx: &RpcContext) -> Result<()> { + let endorsed_by = ctx + .session_info + .as_ref() + .ok_or(anyhow!("not authorized"))? + .endorsed_by + .ok_or(anyhow!("not endorsed by host"))?; + let host_identity = self + .host + .get_identity() + .ok_or(anyhow!("local identity not available"))? + .node_identity() + .ok_or(anyhow!("node identity not available"))?; + if endorsed_by != host_identity { + bail!("not endorsed by host"); + } + Ok(()) + } + + fn rpc_query(&self, ctx: &RpcContext, req: &QueryRequest) -> Result> { + self.ensure_session_endorsed(ctx)?; + + // Determine whether the method is allowed to access confidential state and provide an + // appropriately scoped instance of the key manager client. + let is_confidential_allowed = R::Modules::is_allowed_private_km_query(&req.method) + && R::is_allowed_private_km_query(&req.method); + let key_manager = self.key_manager.as_ref().map(|mgr| { + if is_confidential_allowed { + mgr.with_private_context() + } else { + mgr.with_context() + } + }); + + // Fetch latest consensus layer state. + let state = block_on(self.consensus_verifier.latest_state())?; + let roothash = RoothashState::new(&state); + let roots = roothash + .round_roots(self.host_info.runtime_id, req.round)? + .ok_or(anyhow!("root not found"))?; + let beacon = BeaconState::new(&state); + let epoch = beacon.epoch()?; + let registry = RegistryState::new(&state); + let runtime = registry + .runtime(&self.host_info.runtime_id)? + .ok_or(anyhow!("runtime not found"))?; + + // Prepare dispatch context. + let history = self.consensus_verifier.clone(); + let root = HostStore::new( + self.host.clone(), + mkvs::Root { + namespace: self.host_info.runtime_id, + version: req.round, + root_type: mkvs::RootType::State, + hash: roots.state_root, + }, + ); + // TODO: This is currently limited as we have no nice way of getting a good known header. We + // need to expose more stuff in roothash and then limit the query to latest round. Until + // then any queries requiring access to features like timestamp will fail as we need to + // ensure we use safe values for these arguments. + let header = Header { + namespace: self.host_info.runtime_id, + round: req.round, + io_root: roots.io_root, + state_root: roots.state_root, + ..Default::default() + }; + let round_results = Default::default(); + let max_messages = runtime.executor.max_messages; + + let ctx = RuntimeBatchContext::<'_, R>::new( + &self.host_info, + key_manager, + &header, + &round_results, + &state, + &history, + epoch, + max_messages, + ); + + CurrentState::enter_opts( + state::Options::new() + .with_mode(state::Mode::Check) + .with_rng_local_entropy(), // Mix in local (private) entropy for queries. + root, + || dispatcher::Dispatcher::::dispatch_query(&ctx, &req.method, req.args.clone()), + ) + .map_err(Into::into) + } +} diff --git a/runtime-sdk/src/lib.rs b/runtime-sdk/src/lib.rs index 93e767db28..0a805114c6 100644 --- a/runtime-sdk/src/lib.rs +++ b/runtime-sdk/src/lib.rs @@ -1,5 +1,6 @@ //! Oasis runtime SDK. #![feature(test)] +#![feature(associated_type_defaults)] #![deny(rust_2018_idioms, unreachable_pub)] pub mod callformat; @@ -7,6 +8,7 @@ pub mod config; pub mod context; pub mod crypto; pub mod dispatcher; +pub mod enclave_rpc; pub mod error; pub mod event; pub mod history; diff --git a/runtime-sdk/src/module.rs b/runtime-sdk/src/module.rs index e3f230d3e6..d29142f065 100644 --- a/runtime-sdk/src/module.rs +++ b/runtime-sdk/src/module.rs @@ -17,6 +17,7 @@ use crate::{ storage, storage::Prefix, types::{ + address::Address, message::MessageResult, transaction::{self, AuthInfo, Call, Transaction, UnverifiedTransaction}, }, @@ -86,6 +87,15 @@ impl CallResult { Self::Aborted(e) => panic!("tx aborted with error: {e}"), } } + + #[cfg(any(test, feature = "test"))] + pub fn unwrap_failed(self) -> (String, u32) { + match self { + Self::Ok(_) => panic!("call result indicates success"), + Self::Failed { module, code, .. } => (module, code), + Self::Aborted(e) => panic!("tx aborted with error: {e}"), + } + } } impl From for transaction::CallResult { @@ -322,6 +332,15 @@ impl MethodHandler for Tuple { } } +/// An authentication decision for cases where multiple handlers are available. +#[derive(Clone, Debug)] +pub enum AuthDecision { + /// Authentication passed, continue with the next authentication handler. + Continue, + /// Authentication passed, no further authentication handlers should be called. + Stop, +} + /// Transaction handler. pub trait TransactionHandler { /// Judge if a raw transaction is good enough to undergo decoding. @@ -363,9 +382,9 @@ pub trait TransactionHandler { fn authenticate_tx( _ctx: &C, _tx: &Transaction, - ) -> Result<(), modules::core::Error> { + ) -> Result { // Default implementation accepts all transactions. - Ok(()) + Ok(AuthDecision::Continue) } /// Perform any action after authentication, within the transaction context. @@ -423,9 +442,18 @@ impl TransactionHandler for Tuple { Ok(None) } - fn authenticate_tx(ctx: &C, tx: &Transaction) -> Result<(), modules::core::Error> { - for_tuples!( #( Tuple::authenticate_tx(ctx, tx)?; )* ); - Ok(()) + fn authenticate_tx( + ctx: &C, + tx: &Transaction, + ) -> Result { + for_tuples!( #( + match Tuple::authenticate_tx(ctx, tx)? { + AuthDecision::Stop => return Ok(AuthDecision::Stop), + AuthDecision::Continue => {}, + } + )* ); + + Ok(AuthDecision::Continue) } fn before_handle_call(ctx: &C, call: &Call) -> Result<(), modules::core::Error> { @@ -448,6 +476,32 @@ impl TransactionHandler for Tuple { } } +/// Fee proxy handler. +pub trait FeeProxyHandler { + /// Resolve the proxy payer for the given transaction. If no payer could be resolved, `None` + /// should be returned. + fn resolve_payer( + ctx: &C, + tx: &Transaction, + ) -> Result, modules::core::Error>; +} + +#[impl_for_tuples(30)] +impl FeeProxyHandler for Tuple { + fn resolve_payer( + ctx: &C, + tx: &Transaction, + ) -> Result, modules::core::Error> { + for_tuples!( #( + if let Some(payer) = Tuple::resolve_payer(ctx, tx)? { + return Ok(Some(payer)); + } + )* ); + + Ok(None) + } +} + /// Migration handler. pub trait MigrationHandler { /// Genesis state type. @@ -615,3 +669,76 @@ pub trait Parameters: Debug + Default + cbor::Encode + cbor::Decode { impl Parameters for () { type Error = std::convert::Infallible; } + +#[cfg(test)] +mod test { + use super::*; + use crate::testing::mock; + + /// An authentication handler that always continues. + struct TestAuthContinue; + /// An authentication handler that always stops. + struct TestAuthStop; + /// An authentication handler that always fails. + struct TestAuthFail; + + impl super::TransactionHandler for TestAuthContinue { + fn authenticate_tx( + _ctx: &C, + _tx: &Transaction, + ) -> Result { + Ok(AuthDecision::Continue) + } + } + + impl super::TransactionHandler for TestAuthStop { + fn authenticate_tx( + _ctx: &C, + _tx: &Transaction, + ) -> Result { + Ok(AuthDecision::Stop) + } + } + + impl super::TransactionHandler for TestAuthFail { + fn authenticate_tx( + _ctx: &C, + _tx: &Transaction, + ) -> Result { + Err(modules::core::Error::NotAuthenticated) + } + } + + #[test] + fn test_authenticate_tx() { + let mut mock = mock::Mock::default(); + let ctx = mock.create_ctx(); + let tx = mock::transaction(); + + // Make sure mock authentication handlers behave as expected. + let result = TestAuthContinue::authenticate_tx(&ctx, &tx).unwrap(); + assert!(matches!(result, AuthDecision::Continue)); + let result = TestAuthStop::authenticate_tx(&ctx, &tx).unwrap(); + assert!(matches!(result, AuthDecision::Stop)); + let _ = TestAuthFail::authenticate_tx(&ctx, &tx).unwrap_err(); + + // Make sure that composed variants behave as expected. + type Composed1 = (TestAuthContinue, TestAuthContinue, TestAuthContinue); + let result = Composed1::authenticate_tx(&ctx, &tx).unwrap(); + assert!(matches!(result, AuthDecision::Continue)); + + type Composed2 = (TestAuthContinue, TestAuthStop, TestAuthContinue); + let result = Composed2::authenticate_tx(&ctx, &tx).unwrap(); + assert!(matches!(result, AuthDecision::Stop)); + + type Composed3 = (TestAuthContinue, TestAuthStop, TestAuthFail); + let result = Composed3::authenticate_tx(&ctx, &tx).unwrap(); + assert!(matches!(result, AuthDecision::Stop)); + + type Composed4 = (TestAuthFail, TestAuthStop, TestAuthContinue); + let _ = Composed4::authenticate_tx(&ctx, &tx).unwrap_err(); + + type Composed5 = (TestAuthContinue, TestAuthContinue, TestAuthFail); + let _ = Composed5::authenticate_tx(&ctx, &tx).unwrap_err(); + } +} diff --git a/runtime-sdk/src/modules/accounts/mod.rs b/runtime-sdk/src/modules/accounts/mod.rs index 9ba430ae5f..fa231dfbfb 100644 --- a/runtime-sdk/src/modules/accounts/mod.rs +++ b/runtime-sdk/src/modules/accounts/mod.rs @@ -12,8 +12,8 @@ use thiserror::Error; use crate::{ context::Context, core::common::quantity::Quantity, - handler, migration, module, - module::{Module as _, Parameters as _}, + handler, migration, + module::{self, FeeProxyHandler, Module as _, Parameters as _}, modules, modules::core::{Error as CoreError, API as _}, runtime::Runtime, @@ -870,24 +870,16 @@ impl Module { } impl module::TransactionHandler for Module { - fn authenticate_tx(ctx: &C, tx: &Transaction) -> Result<(), modules::core::Error> { - // Check whether the transaction is currently valid. - let round = ctx.runtime_header().round; - if let Some(not_before) = tx.auth_info.not_before { - if round < not_before { - // Too early. - return Err(modules::core::Error::ExpiredTransaction); - } - } - if let Some(not_after) = tx.auth_info.not_after { - if round > not_after { - // Too late. - return Err(modules::core::Error::ExpiredTransaction); - } - } - + fn authenticate_tx( + ctx: &C, + tx: &Transaction, + ) -> Result { // Check nonces. - let payer = Self::check_signer_nonces(ctx, &tx.auth_info)?; + let default_payer = Self::check_signer_nonces(ctx, &tx.auth_info)?; + + // Attempt to resolve a proxy fee payer if set. + let payer = + ::FeeProxy::resolve_payer(ctx, tx)?.unwrap_or(default_payer); // Charge the specified amount of fees. if !tx.auth_info.fee.amount.amount().is_zero() { @@ -914,7 +906,7 @@ impl module::TransactionHandler for Module { Self::update_signer_nonces(ctx, &tx.auth_info)?; } - Ok(()) + Ok(module::AuthDecision::Continue) } fn after_handle_call( diff --git a/runtime-sdk/src/modules/accounts/test.rs b/runtime-sdk/src/modules/accounts/test.rs index 9f19f56cad..f75b695251 100644 --- a/runtime-sdk/src/modules/accounts/test.rs +++ b/runtime-sdk/src/modules/accounts/test.rs @@ -9,7 +9,10 @@ use anyhow::anyhow; use crate::{ context::Context, handler, - module::{self, BlockHandler, InvariantHandler, MethodHandler, Module, TransactionHandler}, + module::{ + self, BlockHandler, FeeProxyHandler, InvariantHandler, MethodHandler, Module, + TransactionHandler, + }, modules::{ core, core::{Error as CoreError, Module as Core, API as _}, @@ -42,6 +45,8 @@ impl Runtime for TestRuntime { const VERSION: Version = Version::new(0, 0, 0); type Core = Core; + type Accounts = Accounts; + type FeeProxy = TestFeeProxyHandler; type Modules = (Core, Accounts, TestModule); @@ -71,6 +76,31 @@ impl Runtime for TestRuntime { } } +/// A fee proxy handler. +struct TestFeeProxyHandler; + +impl FeeProxyHandler for TestFeeProxyHandler { + fn resolve_payer( + _ctx: &C, + tx: &transaction::Transaction, + ) -> Result, CoreError> { + let proxy = if let Some(ref proxy) = tx.auth_info.fee.proxy { + proxy + } else { + return Ok(None); + }; + + if proxy.module != "test" { + return Ok(None); + } + if proxy.id != b"pleasepaythisalicekthx" { + return Ok(None); + } + + Ok(Some(keys::alice::address())) + } +} + /// A module with multiple no-op methods; intended for testing routing. struct TestModule; @@ -305,9 +335,8 @@ fn test_api_tx_transfer_disabled() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -336,9 +365,8 @@ fn test_prefetch() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }; @@ -490,7 +518,7 @@ fn test_authenticate_tx() { fee: transaction::Fee { amount: BaseUnits::new(1_000, Denomination::NATIVE), gas: 1000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -552,9 +580,8 @@ fn test_tx_transfer() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -630,7 +657,7 @@ fn test_fee_disbursement() { // Use an amount that does not split nicely among the good compute entities. amount: BaseUnits::new(1_001, Denomination::NATIVE), gas: 1000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -1228,61 +1255,6 @@ fn test_query_denomination_info() { .unwrap_err(); } -#[test] -fn test_transaction_expiry() { - let mut mock = mock::Mock::default(); - let ctx = mock.create_ctx(); - - init_accounts(&ctx); - - let mut tx = transaction::Transaction { - version: 1, - call: transaction::Call { - format: transaction::CallFormat::Plain, - method: "accounts.Transfer".to_owned(), - body: cbor::to_value(Transfer { - to: keys::bob::address(), - amount: Default::default(), - }), - ..Default::default() - }, - auth_info: transaction::AuthInfo { - signer_info: vec![transaction::SignerInfo::new_sigspec( - keys::alice::sigspec(), - 0, - )], - fee: transaction::Fee { - // Use an amount that does not split nicely among the good compute entities. - amount: BaseUnits::new(1_001, Denomination::NATIVE), - gas: 1000, - consensus_messages: 0, - }, - not_before: Some(10), - not_after: Some(42), - }, - }; - - // Authenticate transaction, should be expired. - let err = Accounts::authenticate_tx(&ctx, &tx).expect_err("tx should be expired (early)"); - assert!(matches!(err, core::Error::ExpiredTransaction)); - - // Move the round forward. - mock.runtime_header.round = 15; - - // Authenticate transaction, should succeed. - let ctx = mock.create_ctx(); - Accounts::authenticate_tx(&ctx, &tx).expect("tx should be valid"); - - // Move the round forward and also update the transaction nonce. - mock.runtime_header.round = 50; - tx.auth_info.signer_info[0].nonce = 1; - - // Authenticate transaction, should be expired. - let ctx = mock.create_ctx(); - let err = Accounts::authenticate_tx(&ctx, &tx).expect_err("tx should be expired"); - assert!(matches!(err, core::Error::ExpiredTransaction)); -} - #[test] fn test_fee_disbursement_2() { let mut mock = mock::Mock::default(); @@ -1457,6 +1429,89 @@ fn test_fee_refund_subcall() { assert_eq!(events[0].amount, 11_000); } +#[test] +fn test_fee_proxy() { + let mut mock = mock::Mock::default(); + let ctx = mock.create_ctx_for_runtime::(false); + let mut signer = mock::Signer::new(0, keys::bob::sigspec()); + + TestRuntime::migrate(&ctx); + + // Do a simple transfer. Note that ALICE is paying the fees. + let dispatch_result = signer.call_opts( + &ctx, + "accounts.Transfer", + Transfer { + to: keys::bob::address(), + amount: BaseUnits::new(0, Denomination::NATIVE), // Bob has no funds. + }, + mock::CallOptions { + fee: transaction::Fee { + amount: BaseUnits::new(1_500, Denomination::NATIVE), + gas: 1_500, + proxy: Some(transaction::FeeProxy { + module: "test".to_owned(), + id: b"pleasepaythisalicekthx".to_vec(), // Magic words. + }), + ..Default::default() + }, + }, + ); + assert!(dispatch_result.result.is_success(), "call should succeed"); + + // Make sure two events were emitted and are properly formatted. + let tags = &dispatch_result.tags; + assert_eq!(tags.len(), 2, "two events should have been emitted"); + assert_eq!(tags[0].key, b"accounts\x00\x00\x00\x01"); // accounts.Transfer (code = 1) event + assert_eq!(tags[1].key, b"core\x00\x00\x00\x01"); // core.GasUsed (code = 1) event + + #[derive(Debug, Default, cbor::Decode)] + struct TransferEvent { + from: Address, + to: Address, + amount: BaseUnits, + } + + let events: Vec = cbor::from_slice(&tags[0].value).unwrap(); + assert_eq!(events.len(), 1); // One event for fee payment as transfer was of zero tokens. + let event = &events[0]; + assert_eq!(event.from, keys::alice::address()); // Alice is paying via proxy! + assert_eq!(event.to, *ADDRESS_FEE_ACCUMULATOR); + assert_eq!(event.amount, BaseUnits::new(1_500, Denomination::NATIVE)); + + // Make sure only one gas used event was emitted. + #[derive(Debug, Default, cbor::Decode)] + struct GasUsedEvent { + amount: u64, + } + + let events: Vec = cbor::from_slice(&tags[1].value).unwrap(); + assert_eq!(events.len(), 1); // Just one gas used event. + assert_eq!(events[0].amount, 1_000); + + // Proxy payment should fail in case the id is incorrect. + let dispatch_result = signer.call_opts( + &ctx, + "accounts.Transfer", + Transfer { + to: keys::bob::address(), + amount: BaseUnits::new(0, Denomination::NATIVE), // Bob has no funds. + }, + mock::CallOptions { + fee: transaction::Fee { + amount: BaseUnits::new(1_500, Denomination::NATIVE), + gas: 1_500, + proxy: Some(transaction::FeeProxy { + module: "test".to_owned(), + id: b"plzplzplz".to_vec(), // Incorrect id. + }), + ..Default::default() + }, + }, + ); + assert!(!dispatch_result.result.is_success(), "call should fail"); +} + #[test] fn test_pool_addresses() { assert_eq!( diff --git a/runtime-sdk/src/modules/consensus_accounts/mod.rs b/runtime-sdk/src/modules/consensus_accounts/mod.rs index 7ca74b22b0..4954e05114 100644 --- a/runtime-sdk/src/modules/consensus_accounts/mod.rs +++ b/runtime-sdk/src/modules/consensus_accounts/mod.rs @@ -22,7 +22,10 @@ use crate::{ error, migration, module, module::Module as _, modules, - modules::core::{Error as CoreError, API as _}, + modules::{ + accounts::API as _, + core::{Error as CoreError, API as _}, + }, runtime::Runtime, state::CurrentState, storage::Prefix, @@ -224,8 +227,7 @@ pub trait API { ) -> Result<(), Error>; } -pub struct Module { - _accounts: std::marker::PhantomData, +pub struct Module { _consensus: std::marker::PhantomData, } @@ -246,9 +248,7 @@ const CONSENSUS_WITHDRAW_HANDLER: &str = "consensus.WithdrawIntoRuntime"; const CONSENSUS_DELEGATE_HANDLER: &str = "consensus.Delegate"; const CONSENSUS_UNDELEGATE_HANDLER: &str = "consensus.Undelegate"; -impl API - for Module -{ +impl API for Module { fn deposit( ctx: &C, from: Address, @@ -309,7 +309,7 @@ impl API // Transfer the given amount to the module's withdrawal account to make sure the tokens // remain available until actually withdrawn. - Accounts::transfer(from, *ADDRESS_PENDING_WITHDRAWAL, &amount) + ::Accounts::transfer(from, *ADDRESS_PENDING_WITHDRAWAL, &amount) .map_err(|_| Error::InsufficientBalance)?; Ok(()) @@ -345,7 +345,7 @@ impl API // Transfer the given amount to the module's delegation account to make sure the tokens // remain available until actually delegated. - Accounts::transfer(from, *ADDRESS_PENDING_DELEGATION, &amount) + ::Accounts::transfer(from, *ADDRESS_PENDING_DELEGATION, &amount) .map_err(|_| Error::InsufficientBalance)?; Ok(()) @@ -383,9 +383,7 @@ impl API } #[sdk_derive(Module)] -impl - Module -{ +impl Module { const NAME: &'static str = MODULE_NAME; const VERSION: u32 = 1; type Error = Error; @@ -545,7 +543,8 @@ impl args: types::BalanceQuery, ) -> Result { let denomination = Consensus::consensus_denomination()?; - let balances = Accounts::get_balances(args.address).map_err(|_| Error::InvalidArgument)?; + let balances = ::Accounts::get_balances(args.address) + .map_err(|_| Error::InvalidArgument)?; let balance = balances .balances .get(&denomination) @@ -594,7 +593,7 @@ impl ) { if !me.is_success() { // Transfer out failed, refund the balance. - Accounts::transfer( + ::Accounts::transfer( *ADDRESS_PENDING_WITHDRAWAL, context.address, &context.amount, @@ -615,7 +614,7 @@ impl } // Burn the withdrawn tokens. - Accounts::burn(*ADDRESS_PENDING_WITHDRAWAL, &context.amount) + ::Accounts::burn(*ADDRESS_PENDING_WITHDRAWAL, &context.amount) .expect("should have enough balance"); // Emit withdraw successful event. @@ -651,7 +650,7 @@ impl } // Update runtime state. - Accounts::mint(context.address, &context.amount).unwrap(); + ::Accounts::mint(context.address, &context.amount).unwrap(); // Emit deposit successful event. CurrentState::with(|state| { @@ -673,8 +672,12 @@ impl ) { if !me.is_success() { // Delegation failed, refund the balance. - Accounts::transfer(*ADDRESS_PENDING_DELEGATION, context.from, &context.amount) - .expect("should have enough balance"); + ::Accounts::transfer( + *ADDRESS_PENDING_DELEGATION, + context.from, + &context.amount, + ) + .expect("should have enough balance"); // Store receipt if requested. if context.receipt { @@ -703,7 +706,7 @@ impl } // Burn the delegated tokens. - Accounts::burn(*ADDRESS_PENDING_DELEGATION, &context.amount) + ::Accounts::burn(*ADDRESS_PENDING_DELEGATION, &context.amount) .expect("should have enough balance"); // Record delegation. @@ -828,14 +831,9 @@ impl } } -impl - module::TransactionHandler for Module -{ -} +impl module::TransactionHandler for Module {} -impl module::BlockHandler - for Module -{ +impl module::BlockHandler for Module { fn end_block(ctx: &C) { // Only do work in case the epoch has changed since the last processed block. if !::Core::has_epoch_changed() { @@ -914,7 +912,7 @@ impl modul let amount = token::BaseUnits::new(raw_amount, denomination.clone()); // Mint the given number of tokens. - Accounts::mint(ud.to, &amount).unwrap(); + ::Accounts::mint(ud.to, &amount).unwrap(); // Store receipt if requested. if udi.receipt > 0 { @@ -942,9 +940,7 @@ impl modul } } -impl module::InvariantHandler - for Module -{ +impl module::InvariantHandler for Module { /// Check invariants. fn check_invariants(ctx: &C) -> Result<(), CoreError> { // Total supply of the designated consensus layer token denomination @@ -953,9 +949,9 @@ impl modul let den = Consensus::consensus_denomination().unwrap(); #[allow(clippy::or_fun_call)] - let ts = Accounts::get_total_supplies().or(Err(CoreError::InvariantViolation( - "unable to get total supplies".to_string(), - )))?; + let ts = ::Accounts::get_total_supplies().or(Err( + CoreError::InvariantViolation("unable to get total supplies".to_string()), + ))?; let rt_addr = Address::from_runtime_id(ctx.runtime_id()); let rt_acct = Consensus::account(ctx, rt_addr).unwrap_or_default(); diff --git a/runtime-sdk/src/modules/consensus_accounts/test.rs b/runtime-sdk/src/modules/consensus_accounts/test.rs index 5740dbc77c..ec0939a974 100644 --- a/runtime-sdk/src/modules/consensus_accounts/test.rs +++ b/runtime-sdk/src/modules/consensus_accounts/test.rs @@ -68,7 +68,7 @@ fn init_accounts_ex(ctx: &C, address: Address) { ..Default::default() }, ); - Module::::init_or_migrate(ctx, &mut meta, genesis); + Module::::init_or_migrate(ctx, &mut meta, genesis); } fn init_accounts(ctx: &C) { @@ -107,9 +107,9 @@ fn test_api_deposit_invalid_denomination() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, consensus_messages: 1, + ..Default::default() }, ..Default::default() }, @@ -117,9 +117,8 @@ fn test_api_deposit_invalid_denomination() { let call = tx.call.clone(); CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || { - let result = - Module::::tx_deposit(&ctx, cbor::from_value(call.body).unwrap()) - .unwrap_err(); + let result = Module::::tx_deposit(&ctx, cbor::from_value(call.body).unwrap()) + .unwrap_err(); assert!(matches!( result, Error::Consensus(ConsensusError::InvalidDenomination) @@ -152,9 +151,9 @@ fn test_api_deposit_incompatible_signer() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, consensus_messages: 1, + ..Default::default() }, ..Default::default() }, @@ -162,9 +161,8 @@ fn test_api_deposit_incompatible_signer() { let call = tx.call.clone(); CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || { - let result = - Module::::tx_deposit(&ctx, cbor::from_value(call.body).unwrap()) - .unwrap_err(); + let result = Module::::tx_deposit(&ctx, cbor::from_value(call.body).unwrap()) + .unwrap_err(); assert!(matches!( result, Error::Consensus(ConsensusError::ConsensusIncompatibleSigner) @@ -199,9 +197,9 @@ fn test_api_deposit() { nonce, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, consensus_messages: 1, + ..Default::default() }, ..Default::default() }, @@ -209,7 +207,7 @@ fn test_api_deposit() { let call = tx.call.clone(); let hook = CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || { - Module::::tx_deposit(&ctx, cbor::from_value(call.body).unwrap()) + Module::::tx_deposit(&ctx, cbor::from_value(call.body).unwrap()) .expect("deposit tx should succeed"); let mut messages = CurrentState::with(|state| state.take_messages()); @@ -239,11 +237,7 @@ fn test_api_deposit() { // Simulate the message being processed and make sure withdrawal is successfully completed. let me = Default::default(); - Module::::message_result_withdraw( - &ctx, - me, - cbor::from_value(hook.payload).unwrap(), - ); + Module::::message_result_withdraw(&ctx, me, cbor::from_value(hook.payload).unwrap()); // Ensure runtime balance is updated. let balance = Accounts::get_balance(test::keys::bob::address(), denom.clone()).unwrap(); @@ -308,9 +302,9 @@ fn test_api_withdraw_invalid_denomination() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, consensus_messages: 1, + ..Default::default() }, ..Default::default() }, @@ -318,9 +312,8 @@ fn test_api_withdraw_invalid_denomination() { let call = tx.call.clone(); CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || { - let result = - Module::::tx_withdraw(&ctx, cbor::from_value(call.body).unwrap()) - .unwrap_err(); + let result = Module::::tx_withdraw(&ctx, cbor::from_value(call.body).unwrap()) + .unwrap_err(); assert!(matches!( result, Error::Consensus(ConsensusError::InvalidDenomination) @@ -353,9 +346,9 @@ fn test_api_withdraw_insufficient_balance() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, consensus_messages: 1, + ..Default::default() }, ..Default::default() }, @@ -363,9 +356,8 @@ fn test_api_withdraw_insufficient_balance() { let call = tx.call.clone(); CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || { - let result = - Module::::tx_withdraw(&ctx, cbor::from_value(call.body).unwrap()) - .unwrap_err(); + let result = Module::::tx_withdraw(&ctx, cbor::from_value(call.body).unwrap()) + .unwrap_err(); assert!(matches!(result, Error::InsufficientBalance)); }); } @@ -393,9 +385,9 @@ fn test_api_withdraw_incompatible_signer() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, consensus_messages: 1, + ..Default::default() }, ..Default::default() }, @@ -403,9 +395,8 @@ fn test_api_withdraw_incompatible_signer() { let call = tx.call.clone(); CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || { - let result = - Module::::tx_withdraw(&ctx, cbor::from_value(call.body).unwrap()) - .unwrap_err(); + let result = Module::::tx_withdraw(&ctx, cbor::from_value(call.body).unwrap()) + .unwrap_err(); assert!(matches!( result, Error::Consensus(ConsensusError::ConsensusIncompatibleSigner) @@ -438,9 +429,9 @@ fn test_api_withdraw(signer_sigspec: SignatureAddressSpec) { auth_info: transaction::AuthInfo { signer_info: vec![transaction::SignerInfo::new_sigspec(signer_sigspec, nonce)], fee: transaction::Fee { - amount: Default::default(), gas: 1000, consensus_messages: 1, + ..Default::default() }, ..Default::default() }, @@ -448,7 +439,7 @@ fn test_api_withdraw(signer_sigspec: SignatureAddressSpec) { let call = tx.call.clone(); let hook = CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || { - Module::::tx_withdraw(&ctx, cbor::from_value(call.body).unwrap()) + Module::::tx_withdraw(&ctx, cbor::from_value(call.body).unwrap()) .expect("withdraw tx should succeed"); CurrentState::with(|state| state.take_all_events()); // Clear events. @@ -486,11 +477,7 @@ fn test_api_withdraw(signer_sigspec: SignatureAddressSpec) { // Simulate the message being processed and make sure withdrawal is successfully completed. let me = Default::default(); - Module::::message_result_transfer( - &ctx, - me, - cbor::from_value(hook.payload).unwrap(), - ); + Module::::message_result_transfer(&ctx, me, cbor::from_value(hook.payload).unwrap()); // Ensure runtime balance is updated. let balance = Accounts::get_balance(*ADDRESS_PENDING_WITHDRAWAL, denom.clone()).unwrap(); @@ -568,9 +555,9 @@ fn test_api_withdraw_handler_failure() { nonce, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, consensus_messages: 1, + ..Default::default() }, ..Default::default() }, @@ -578,7 +565,7 @@ fn test_api_withdraw_handler_failure() { let call = tx.call.clone(); let hook = CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || { - Module::::tx_withdraw(&ctx, cbor::from_value(call.body).unwrap()) + Module::::tx_withdraw(&ctx, cbor::from_value(call.body).unwrap()) .expect("withdraw tx should succeed"); let mut messages = CurrentState::with(|state| state.take_messages()); @@ -620,11 +607,7 @@ fn test_api_withdraw_handler_failure() { index: 0, result: None, }; - Module::::message_result_transfer( - &ctx, - me, - cbor::from_value(hook.payload).unwrap(), - ); + Module::::message_result_transfer(&ctx, me, cbor::from_value(hook.payload).unwrap()); // Ensure amount is refunded. let balance = Accounts::get_balance(*ADDRESS_PENDING_WITHDRAWAL, denom.clone()).unwrap(); @@ -690,7 +673,7 @@ fn test_consensus_withdraw_handler() { address: keys::alice::address(), amount: BaseUnits::new(1, denom.clone()), }; - Module::::message_result_withdraw(&ctx, me, h_ctx); + Module::::message_result_withdraw(&ctx, me, h_ctx); // Ensure runtime balance is updated. let bals = Accounts::get_balances(keys::alice::address()).unwrap(); @@ -718,9 +701,9 @@ fn perform_delegation(ctx: &C, success: bool) -> u64 { nonce, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, consensus_messages: 1, + ..Default::default() }, ..Default::default() }, @@ -728,7 +711,7 @@ fn perform_delegation(ctx: &C, success: bool) -> u64 { let call = tx.call.clone(); let hook = CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || { - Module::::tx_delegate(ctx, cbor::from_value(call.body).unwrap()) + Module::::tx_delegate(ctx, cbor::from_value(call.body).unwrap()) .expect("delegate tx should succeed"); CurrentState::with(|state| state.take_all_events()); // Clear events. @@ -785,11 +768,7 @@ fn perform_delegation(ctx: &C, success: bool) -> u64 { result: None, } }; - Module::::message_result_delegate( - ctx, - me, - cbor::from_value(hook.payload).unwrap(), - ); + Module::::message_result_delegate(ctx, me, cbor::from_value(hook.payload).unwrap()); nonce } @@ -843,7 +822,7 @@ fn test_api_delegate() { // Test delegation queries. let ctx = mock.create_ctx(); - let di = Module::::query_delegation( + let di = Module::::query_delegation( &ctx, types::DelegationQuery { from: keys::alice::address(), @@ -853,7 +832,7 @@ fn test_api_delegate() { .expect("delegation query should succeed"); assert_eq!(di.shares, 1_000); - let dis = Module::::query_delegations( + let dis = Module::::query_delegations( &ctx, types::DelegationsQuery { from: keys::alice::address(), @@ -889,9 +868,9 @@ fn test_api_delegate_insufficient_balance() { 123, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, consensus_messages: 1, + ..Default::default() }, ..Default::default() }, @@ -899,9 +878,8 @@ fn test_api_delegate_insufficient_balance() { let call = tx.call.clone(); CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || { - let result = - Module::::tx_delegate(&ctx, cbor::from_value(call.body).unwrap()) - .unwrap_err(); + let result = Module::::tx_delegate(&ctx, cbor::from_value(call.body).unwrap()) + .unwrap_err(); assert!(matches!(result, Error::InsufficientBalance)); }); } @@ -965,9 +943,9 @@ fn test_api_delegate_receipt_not_internal() { 123, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, consensus_messages: 1, + ..Default::default() }, ..Default::default() }, @@ -975,9 +953,8 @@ fn test_api_delegate_receipt_not_internal() { let call = tx.call.clone(); CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || { - let result = - Module::::tx_delegate(&ctx, cbor::from_value(call.body).unwrap()) - .unwrap_err(); + let result = Module::::tx_delegate(&ctx, cbor::from_value(call.body).unwrap()) + .unwrap_err(); assert!(matches!(result, Error::InvalidArgument)); }); } @@ -1003,9 +980,9 @@ fn perform_undelegation(ctx: &C, success: Option) -> (u64, Opt nonce, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, consensus_messages: 1, + ..Default::default() }, ..Default::default() }, @@ -1013,7 +990,7 @@ fn perform_undelegation(ctx: &C, success: Option) -> (u64, Opt let call = tx.call.clone(); let hook = CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || { - Module::::tx_undelegate(ctx, cbor::from_value(call.body).unwrap()) + Module::::tx_undelegate(ctx, cbor::from_value(call.body).unwrap()) .expect("undelegate tx should succeed"); CurrentState::with(|state| state.take_all_events()); // Clear events. @@ -1043,7 +1020,7 @@ fn perform_undelegation(ctx: &C, success: Option) -> (u64, Opt }); // Make sure the delegation was updated to remove shares. - let di = Module::::query_delegation( + let di = Module::::query_delegation( ctx, types::DelegationQuery { from: keys::alice::address(), @@ -1079,7 +1056,7 @@ fn perform_undelegation(ctx: &C, success: Option) -> (u64, Opt result: None, }, }; - Module::::message_result_undelegate( + Module::::message_result_undelegate( ctx, me, cbor::from_value(hook.payload).unwrap(), @@ -1194,7 +1171,7 @@ fn test_api_undelegate() { let ctx = mock.create_ctx(); ::Core::begin_block(&ctx); - Module::::end_block(&ctx); + Module::::end_block(&ctx); // Make sure nothing changes. let tags = CurrentState::with(|state| state.take_events().into_tags()); @@ -1217,7 +1194,7 @@ fn test_api_undelegate() { let ctx = mock.create_ctx(); ::Core::begin_block(&ctx); - Module::::end_block(&ctx); + Module::::end_block(&ctx); // Make sure events were emitted. let tags = CurrentState::with(|state| state.take_events().into_tags()); @@ -1274,9 +1251,9 @@ fn test_api_undelegate_insufficient_balance() { 123, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, consensus_messages: 1, + ..Default::default() }, ..Default::default() }, @@ -1284,11 +1261,8 @@ fn test_api_undelegate_insufficient_balance() { let call = tx.call.clone(); CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || { - let result = Module::::tx_undelegate( - &ctx, - cbor::from_value(call.body).unwrap(), - ) - .unwrap_err(); + let result = Module::::tx_undelegate(&ctx, cbor::from_value(call.body).unwrap()) + .unwrap_err(); assert!(matches!(result, Error::InsufficientBalance)); }); } @@ -1345,9 +1319,9 @@ fn test_api_undelegate_receipt_not_internal() { 123, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, consensus_messages: 1, + ..Default::default() }, ..Default::default() }, @@ -1355,11 +1329,8 @@ fn test_api_undelegate_receipt_not_internal() { let call = tx.call.clone(); CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || { - let result = Module::::tx_undelegate( - &ctx, - cbor::from_value(call.body).unwrap(), - ) - .unwrap_err(); + let result = Module::::tx_undelegate(&ctx, cbor::from_value(call.body).unwrap()) + .unwrap_err(); assert!(matches!(result, Error::InvalidArgument)); }); } @@ -1422,7 +1393,7 @@ fn test_api_undelegate_suspension() { debond_end_time: 14, })), }; - Module::::message_result_undelegate( + Module::::message_result_undelegate( &ctx, me, cbor::from_value(hook_payload.unwrap()).unwrap(), @@ -1430,7 +1401,7 @@ fn test_api_undelegate_suspension() { // Process block. ::Core::begin_block(&ctx); - Module::::end_block(&ctx); + Module::::end_block(&ctx); // Make sure events were emitted. let tags = CurrentState::with(|state| state.take_events().into_tags()); @@ -1485,9 +1456,9 @@ fn test_prefetch() { 0, )], fee: transaction::Fee { - amount: Default::default(), gas: 1000, consensus_messages: 1, + ..Default::default() }, ..Default::default() }; @@ -1512,14 +1483,10 @@ fn test_prefetch() { // Withdraw should result in one prefix getting prefetched. CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || { let mut prefixes = BTreeSet::new(); - let result = Module::::prefetch( - &mut prefixes, - &call.method, - call.body, - &auth_info, - ) - .ok_or(anyhow!("dispatch failure")) - .expect("prefetch should succeed"); + let result = + Module::::prefetch(&mut prefixes, &call.method, call.body, &auth_info) + .ok_or(anyhow!("dispatch failure")) + .expect("prefetch should succeed"); assert!(matches!(result, Ok(()))); assert_eq!(prefixes.len(), 1, "there should be 1 prefix to be fetched"); @@ -1545,14 +1512,10 @@ fn test_prefetch() { // Deposit should result in zero prefixes. CurrentState::with_transaction_opts(Options::new().with_tx(tx.into()), || { let mut prefixes = BTreeSet::new(); - let result = Module::::prefetch( - &mut prefixes, - &call.method, - call.body, - &auth_info, - ) - .ok_or(anyhow!("dispatch failure")) - .expect("prefetch should succeed"); + let result = + Module::::prefetch(&mut prefixes, &call.method, call.body, &auth_info) + .ok_or(anyhow!("dispatch failure")) + .expect("prefetch should succeed"); assert!(matches!(result, Ok(()))); assert_eq!( diff --git a/runtime-sdk/src/modules/core/mod.rs b/runtime-sdk/src/modules/core/mod.rs index ca7073920f..52f50a4da2 100644 --- a/runtime-sdk/src/modules/core/mod.rs +++ b/runtime-sdk/src/modules/core/mod.rs @@ -26,7 +26,8 @@ use crate::{ types::{ token::{self, Denomination}, transaction::{ - self, AddressSpec, AuthProof, Call, CallFormat, CallerAddress, UnverifiedTransaction, + self, AddressSpec, AuthProof, Call, CallFormat, CallerAddress, Transaction, + UnverifiedTransaction, }, }, Runtime, @@ -297,6 +298,7 @@ impl module::Parameters for Parameters { } } +/// Interface that can be called from other modules. pub trait API { /// Module configuration. type Config: Config; @@ -327,7 +329,7 @@ pub trait API { fn max_batch_gas() -> u64; /// Configured minimum gas price. - fn min_gas_price(ctx: &C, denom: &token::Denomination) -> Option; + fn min_gas_price(denom: &token::Denomination) -> Option; /// Sets the transaction priority to the provided amount. fn set_priority(priority: u64); @@ -525,8 +527,8 @@ impl API for Module { Self::params().max_batch_gas } - fn min_gas_price(ctx: &C, denom: &token::Denomination) -> Option { - Self::min_gas_prices(ctx).get(denom).copied() + fn min_gas_price(denom: &token::Denomination) -> Option { + Self::min_gas_prices().get(denom).copied() } fn set_priority(priority: u64) { @@ -889,7 +891,7 @@ impl Module { ctx: &C, _args: (), ) -> Result, Error> { - let mut mgp = Self::min_gas_prices(ctx); + let mut mgp = Self::min_gas_prices(); // Generate a combined view with local overrides. for (denom, price) in mgp.iter_mut() { @@ -973,7 +975,7 @@ impl Module { } impl Module { - fn min_gas_prices(_ctx: &C) -> BTreeMap { + fn min_gas_prices() -> BTreeMap { let params = Self::params(); if params.dynamic_min_gas_price.enabled { CurrentState::with_store(|store| { @@ -1015,7 +1017,7 @@ impl Module { let fee = CurrentState::with_env(|env| env.tx_auth_info().fee.clone()); let denom = fee.amount.denomination(); - match Self::min_gas_price(ctx, denom) { + match Self::min_gas_price(denom) { // If the denomination is not among the global set, reject. None => return Err(Error::GasPriceTooLow), @@ -1067,6 +1069,28 @@ impl module::TransactionHandler for Module { Ok(()) } + fn authenticate_tx( + ctx: &C, + tx: &Transaction, + ) -> Result { + // Check whether the transaction is currently valid. + let round = ctx.runtime_header().round; + if let Some(not_before) = tx.auth_info.not_before { + if round < not_before { + // Too early. + return Err(Error::ExpiredTransaction); + } + } + if let Some(not_after) = tx.auth_info.not_after { + if round > not_after { + // Too late. + return Err(Error::ExpiredTransaction); + } + } + + Ok(module::AuthDecision::Continue) + } + fn before_handle_call(ctx: &C, call: &Call) -> Result<(), Error> { // Ensure that specified gas limit is not greater than batch gas limit. let params = Self::params(); @@ -1247,7 +1271,7 @@ impl module::BlockHandler for Module { }); } - fn end_block(ctx: &C) { + fn end_block(_ctx: &C) { let params = Self::params(); if !params.dynamic_min_gas_price.enabled { return; @@ -1266,7 +1290,7 @@ impl module::BlockHandler for Module { ) / 100; // Compute new prices. - let mut mgp = Self::min_gas_prices(ctx); + let mut mgp = Self::min_gas_prices(); mgp.iter_mut().for_each(|(d, price)| { let mut new_min_price = min_gas_price_update( gas_used, diff --git a/runtime-sdk/src/modules/core/test.rs b/runtime-sdk/src/modules/core/test.rs index 8752e313fe..ed5d60feef 100644 --- a/runtime-sdk/src/modules/core/test.rs +++ b/runtime-sdk/src/modules/core/test.rs @@ -142,14 +142,8 @@ fn test_query_min_gas_price() { dynamic_min_gas_price: Default::default(), }); - assert_eq!( - Core::min_gas_price(&ctx, &token::Denomination::NATIVE), - Some(123) - ); - assert_eq!( - Core::min_gas_price(&ctx, &"SMALLER".parse().unwrap()), - Some(1000) - ); + assert_eq!(Core::min_gas_price(&token::Denomination::NATIVE), Some(123)); + assert_eq!(Core::min_gas_price(&"SMALLER".parse().unwrap()), Some(1000)); let mgp = Core::query_min_gas_price(&ctx, ()).expect("query_min_gas_price should succeed"); assert!(mgp.len() == 2); @@ -174,11 +168,11 @@ fn test_query_min_gas_price() { } assert_eq!( - super::Module::::min_gas_price(&ctx, &token::Denomination::NATIVE), + super::Module::::min_gas_price(&token::Denomination::NATIVE), Some(123) ); assert_eq!( - super::Module::::min_gas_price(&ctx, &"SMALLER".parse().unwrap()), + super::Module::::min_gas_price(&"SMALLER".parse().unwrap()), Some(1000) ); @@ -376,6 +370,7 @@ impl Runtime for GasWasterRuntime { const VERSION: Version = Version::new(0, 0, 0); type Core = Core; + type Accounts = crate::modules::accounts::Module; type Modules = (Core, GasWasterModule); @@ -440,7 +435,7 @@ fn test_reject_txs() { fee: transaction::Fee { amount: token::BaseUnits::new(0, token::Denomination::NATIVE), gas: u64::MAX, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -475,7 +470,7 @@ fn test_query_estimate_gas() { fee: transaction::Fee { amount: token::BaseUnits::new(0, token::Denomination::NATIVE), gas: u64::MAX, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -883,6 +878,55 @@ fn test_approve_unverified_tx() { .expect_err("multisig too many signers"); } +#[test] +fn test_transaction_expiry() { + let mut mock = mock::Mock::default(); + let ctx = mock.create_ctx(); + + let tx = transaction::Transaction { + version: 1, + call: transaction::Call { + format: transaction::CallFormat::Plain, + method: "test.Test".to_owned(), + ..Default::default() + }, + auth_info: transaction::AuthInfo { + signer_info: vec![transaction::SignerInfo::new_sigspec( + keys::alice::sigspec(), + 0, + )], + fee: Default::default(), + not_before: Some(10), + not_after: Some(42), + }, + }; + + // Authenticate transaction, should be expired. + let err = Core::authenticate_tx(&ctx, &tx).expect_err("tx should be expired (early)"); + assert!(matches!( + err, + crate::modules::core::Error::ExpiredTransaction + )); + + // Move the round forward. + mock.runtime_header.round = 15; + + // Authenticate transaction, should succeed. + let ctx = mock.create_ctx(); + Core::authenticate_tx(&ctx, &tx).expect("tx should be valid"); + + // Move the round forward again. + mock.runtime_header.round = 50; + + // Authenticate transaction, should be expired. + let ctx = mock.create_ctx(); + let err = Core::authenticate_tx(&ctx, &tx).expect_err("tx should be expired"); + assert!(matches!( + err, + crate::modules::core::Error::ExpiredTransaction + )); +} + #[test] fn test_set_priority() { let _mock = mock::Mock::default(); @@ -967,7 +1011,7 @@ fn test_min_gas_price() { fee: transaction::Fee { amount: token::BaseUnits::new(0, token::Denomination::NATIVE), gas: 100, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, @@ -1203,7 +1247,6 @@ fn test_min_gas_price_update() { #[test] fn test_dynamic_min_gas_price() { let mut mock = mock::Mock::default(); - let ctx = mock.create_ctx_for_runtime::(false); let denom: token::Denomination = "SMALLER".parse().unwrap(); Core::set_params(Parameters { @@ -1255,17 +1298,17 @@ fn test_dynamic_min_gas_price() { fee: transaction::Fee { amount: token::BaseUnits::new(1_000_000_000, token::Denomination::NATIVE), gas: 10_000, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, }; let call = tx.call.clone(); assert_eq!( - Core::min_gas_price(&ctx, &token::Denomination::NATIVE), + Core::min_gas_price(&token::Denomination::NATIVE), Some(1000) ); - assert_eq!(Core::min_gas_price(&ctx, &denom), Some(100)); + assert_eq!(Core::min_gas_price(&denom), Some(100)); // Simulate some full blocks (with max gas usage). for round in 0..=10 { @@ -1292,12 +1335,11 @@ fn test_dynamic_min_gas_price() { }); } - let ctx = mock.create_ctx(); assert_eq!( - Core::min_gas_price(&ctx, &token::Denomination::NATIVE), + Core::min_gas_price(&token::Denomination::NATIVE), Some(3598) // Gas price should increase. ); - assert_eq!(Core::min_gas_price(&ctx, &denom), Some(350)); + assert_eq!(Core::min_gas_price(&denom), Some(350)); // Simulate some empty blocks. for round in 10..=100 { @@ -1308,12 +1350,11 @@ fn test_dynamic_min_gas_price() { Core::end_block(&ctx); } - let ctx = mock.create_ctx(); assert_eq!( - Core::min_gas_price(&ctx, &token::Denomination::NATIVE), + Core::min_gas_price(&token::Denomination::NATIVE), Some(1000) // Gas price should decrease to the configured min gas price. ); - assert_eq!(Core::min_gas_price(&ctx, &denom), Some(100)); + assert_eq!(Core::min_gas_price(&denom), Some(100)); } #[test] @@ -1510,7 +1551,7 @@ fn test_message_gas() { fee: transaction::Fee { amount: token::BaseUnits::new(0, token::Denomination::NATIVE), gas: u64::MAX, - consensus_messages: 0, + ..Default::default() }, ..Default::default() }, diff --git a/runtime-sdk/src/modules/mod.rs b/runtime-sdk/src/modules/mod.rs index a26dd435cb..3624df4770 100644 --- a/runtime-sdk/src/modules/mod.rs +++ b/runtime-sdk/src/modules/mod.rs @@ -5,3 +5,4 @@ pub mod consensus; pub mod consensus_accounts; pub mod core; pub mod rewards; +pub mod rofl; diff --git a/runtime-sdk/src/modules/rewards/mod.rs b/runtime-sdk/src/modules/rewards/mod.rs index b0e320e0a4..69e352c87f 100644 --- a/runtime-sdk/src/modules/rewards/mod.rs +++ b/runtime-sdk/src/modules/rewards/mod.rs @@ -10,7 +10,7 @@ use crate::{ core::consensus::beacon, migration, module::{self, Module as _, Parameters as _}, - modules::{self, core::API as _}, + modules::{self, accounts::API as _, core::API as _}, runtime::Runtime, sdk_derive, state::CurrentState, @@ -83,9 +83,8 @@ pub mod state { pub const REWARDS: &[u8] = &[0x02]; } -pub struct Module { - _accounts: std::marker::PhantomData, -} +/// Rewards module. +pub struct Module; /// Module's address that has the reward pool. /// @@ -94,7 +93,7 @@ pub static ADDRESS_REWARD_POOL: Lazy
= Lazy::new(|| Address::from_module(MODULE_NAME, "reward-pool")); #[sdk_derive(Module)] -impl Module { +impl Module { const NAME: &'static str = MODULE_NAME; const VERSION: u32 = 2; type Error = Error; @@ -123,9 +122,9 @@ impl Module { } } -impl module::TransactionHandler for Module {} +impl module::TransactionHandler for Module {} -impl module::BlockHandler for Module { +impl module::BlockHandler for Module { fn end_block(ctx: &C) { let epoch = ctx.epoch(); @@ -182,7 +181,11 @@ impl module::BlockHandler for Module params.participation_threshold_numerator, params.participation_threshold_denominator, ) { - match Accounts::transfer(*ADDRESS_REWARD_POOL, address, &reward) { + match ::Accounts::transfer( + *ADDRESS_REWARD_POOL, + address, + &reward, + ) { Ok(_) => {} Err(modules::accounts::Error::InsufficientBalance) => { // Since rewards are the same for the whole epoch, if there is not @@ -206,7 +209,7 @@ impl module::BlockHandler for Module } } -impl module::InvariantHandler for Module {} +impl module::InvariantHandler for Module {} /// A trait that exists solely to convert `beacon::EpochTime` to bytes for use as a storage key. trait ToStorageKey { diff --git a/runtime-sdk/src/modules/rewards/test.rs b/runtime-sdk/src/modules/rewards/test.rs index f2981214eb..1a21236512 100644 --- a/runtime-sdk/src/modules/rewards/test.rs +++ b/runtime-sdk/src/modules/rewards/test.rs @@ -18,7 +18,7 @@ use crate::{ use super::{types, Genesis, Parameters, ADDRESS_REWARD_POOL}; -type Rewards = super::Module; +type Rewards = super::Module; fn init_accounts(ctx: &mut C) { Accounts::init_or_migrate( diff --git a/runtime-sdk/src/modules/rofl/app/client.rs b/runtime-sdk/src/modules/rofl/app/client.rs new file mode 100644 index 0000000000..4f4eae1b4e --- /dev/null +++ b/runtime-sdk/src/modules/rofl/app/client.rs @@ -0,0 +1,266 @@ +use std::{collections::HashSet, sync::Arc}; + +use anyhow::{anyhow, Result}; +use tokio::sync::{mpsc, oneshot}; + +use crate::{ + core::{ + consensus::{ + registry::{SGXConstraints, TEEHardware}, + state::{ + beacon::ImmutableState as BeaconState, registry::ImmutableState as RegistryState, + }, + }, + enclave_rpc::{client::RpcClient, session}, + host::{self, Host as _}, + }, + crypto::signature::{PublicKey, Signer}, + enclave_rpc::{QueryRequest, METHOD_QUERY}, + modules::{ + accounts::API as _, + core::{types::EstimateGasQuery, API as _}, + }, + state::CurrentState, + storage::HostStore, + types::{ + address::{Address, SignatureAddressSpec}, + token, + transaction::{self, CallerAddress}, + }, + Runtime, +}; + +use super::{processor, App}; + +/// EnclaveRPC endpoint for communicating with the RONL component. +const ENCLAVE_RPC_ENDPOINT_RONL: &str = "ronl"; + +/// A runtime client meant for use within runtimes. +pub struct Client { + state: Arc>, + cmdq: mpsc::WeakSender, +} + +impl Client +where + A: App, +{ + /// Create a new runtime client. + pub(super) fn new( + state: Arc>, + cmdq: mpsc::WeakSender, + ) -> Self { + Self { state, cmdq } + } + + /// Retrieve the latest known runtime round. + pub async fn latest_round(&self) -> Result { + let cmdq = self + .cmdq + .upgrade() + .ok_or(anyhow!("processor has shut down"))?; + let (tx, rx) = oneshot::channel(); + cmdq.send(processor::Command::GetLatestRound(tx)).await?; + Ok(rx.await?) + } + + /// Retrieve the nonce for the given account. + pub async fn account_nonce(&self, round: u64, address: Address) -> Result { + self.with_store_for_round(round, move || { + Ok(::Accounts::get_nonce(address)?) + }) + .await + } + + /// Retrieve the gas price in the given denomination. + pub async fn gas_price(&self, round: u64, denom: token::Denomination) -> Result { + self.with_store_for_round(round, move || { + ::Core::min_gas_price(&denom) + .ok_or(anyhow!("denomination not supported")) + }) + .await + } + + /// Securely query the on-chain runtime component. + pub async fn query(&self, round: u64, method: &str, args: Rq) -> Result + where + Rq: cbor::Encode, + Rs: cbor::Decode + Send + 'static, + { + // TODO: Consider using PolicyVerifier when it has the needed methods (and is async). + let state = self.state.consensus_verifier.latest_state().await?; + let runtime_id = self.state.host.get_runtime_id(); + let enclaves = tokio::task::spawn_blocking(move || -> Result<_> { + let beacon = BeaconState::new(&state); + let epoch = beacon.epoch()?; + let registry = RegistryState::new(&state); + let runtime = registry + .runtime(&runtime_id)? + .ok_or(anyhow!("runtime not available"))?; + let ad = runtime + .active_deployment(epoch) + .ok_or(anyhow!("active runtime deployment not available"))?; + + match runtime.tee_hardware { + TEEHardware::TEEHardwareIntelSGX => Ok(HashSet::from_iter( + ad.try_decode_tee::()?.enclaves().clone(), + )), + _ => Err(anyhow!("unsupported TEE platform")), + } + }) + .await??; + + let identity = self + .state + .host + .get_identity() + .ok_or(anyhow!("local identity not available"))? + .clone(); + let quote_policy = identity + .quote_policy() + .ok_or(anyhow!("quote policy not available"))?; + let enclave_rpc = RpcClient::new_runtime( + session::Builder::default() + .use_endorsement(true) + .quote_policy(Some(quote_policy)) + .local_identity(identity) + .remote_enclaves(Some(enclaves)), + self.state.host.clone(), + ENCLAVE_RPC_ENDPOINT_RONL, + vec![], + ); + + let response: Vec = enclave_rpc + .secure_call( + METHOD_QUERY, + QueryRequest { + round, + method: method.to_string(), + args: cbor::to_vec(args), + }, + ) + .await + .into_result()?; + + Ok(cbor::from_slice(&response)?) + } + + /// Securely perform gas estimation. + pub async fn estimate_gas(&self, req: EstimateGasQuery) -> Result { + let round = self.latest_round().await?; + self.query(round, "core.EstimateGas", req).await + } + + /// Sign a given transaction and submit it. + /// + /// This method supports multiple transaction signers. + pub async fn multi_sign_and_submit_tx( + &self, + signers: &[&dyn Signer], + mut tx: transaction::Transaction, + ) -> Result { + if signers.is_empty() { + return Err(anyhow!("no signers specified")); + } + + let round = self.latest_round().await?; + + // Resolve account nonces. + for (idx, signer) in signers.iter().enumerate() { + let sigspec = SignatureAddressSpec::try_from_pk(&signer.public_key()) + .ok_or(anyhow!("signature scheme not supported"))?; + let address = Address::from_sigspec(&sigspec); + let nonce = self.account_nonce(round, address).await?; + + tx.append_auth_signature(sigspec, nonce); + + // If gas is not set, perform estimation. + if idx == 0 && tx.fee_gas() == 0 { + let gas = self + .estimate_gas(EstimateGasQuery { + caller: if let PublicKey::Secp256k1(pk) = signer.public_key() { + Some(CallerAddress::EthAddress( + pk.to_eth_address().try_into().unwrap(), + )) + } else { + Some(CallerAddress::Address(address)) + }, + tx: tx.clone(), + propagate_failures: false, + }) + .await?; + tx.set_fee_gas(gas); + } + } + + // Determine gas price. Currently we always use the native denomination. + let mgp = self.gas_price(round, token::Denomination::NATIVE).await?; + let fee = mgp.saturating_mul(tx.fee_gas().into()); + tx.set_fee_amount(token::BaseUnits::new(fee, token::Denomination::NATIVE)); + + // Sign the transaction. + let mut tx = tx.prepare_for_signing(); + for signer in signers { + tx.append_sign(*signer)?; + } + let tx = tx.finalize(); + + // Submit the transaction. + let result = self + .state + .host + .submit_tx( + cbor::to_vec(tx), + host::SubmitTxOpts { + wait: true, + ..Default::default() + }, + ) + .await? + .ok_or(anyhow!("missing result"))?; + cbor::from_slice(&result.output).map_err(|_| anyhow!("malformed result")) + } + + /// Sign a given transaction and submit it. + pub async fn sign_and_submit_tx( + &self, + signer: &dyn Signer, + tx: transaction::Transaction, + ) -> Result { + self.multi_sign_and_submit_tx(&[signer], tx).await + } + + /// Run a closure inside a `CurrentState` context with store for the given round. + async fn with_store_for_round(&self, round: u64, f: F) -> Result + where + F: FnOnce() -> Result + Send + 'static, + R: Send + 'static, + { + let store = self.store_for_round(round).await?; + + tokio::task::spawn_blocking(move || CurrentState::enter(store, f)).await? + } + + /// Return a store corresponding to the given round. + async fn store_for_round(&self, round: u64) -> Result { + HostStore::new_for_round( + self.state.host.clone(), + &self.state.consensus_verifier, + self.state.host.get_runtime_id(), + round, + ) + .await + } +} + +impl Clone for Client +where + A: App, +{ + fn clone(&self) -> Self { + Self { + state: self.state.clone(), + cmdq: self.cmdq.clone(), + } + } +} diff --git a/runtime-sdk/src/modules/rofl/app/env.rs b/runtime-sdk/src/modules/rofl/app/env.rs new file mode 100644 index 0000000000..48554e818e --- /dev/null +++ b/runtime-sdk/src/modules/rofl/app/env.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use tokio::sync::mpsc; + +use crate::crypto::signature::Signer; + +use super::{client, processor, App}; + +/// Application environment. +pub struct Environment { + client: client::Client, + signer: Arc, + cmdq: mpsc::WeakSender, +} + +impl Environment +where + A: App, +{ + /// Create a new environment talking to the given processor. + pub(super) fn new( + state: Arc>, + cmdq: mpsc::WeakSender, + ) -> Self { + Self { + signer: state.signer.clone(), + client: client::Client::new(state, cmdq.clone()), + cmdq, + } + } + + /// Runtime client. + pub fn client(&self) -> &client::Client { + &self.client + } + + /// Transaction signer. + pub fn signer(&self) -> &dyn Signer { + self.signer.as_ref() + } + + /// Send a command to the processor. + pub(super) async fn send_command(&self, cmd: processor::Command) -> Result<()> { + let cmdq = self + .cmdq + .upgrade() + .ok_or(anyhow!("processor has shut down"))?; + cmdq.send(cmd).await?; + Ok(()) + } +} + +impl Clone for Environment +where + A: App, +{ + fn clone(&self) -> Self { + Self { + signer: self.signer.clone(), + client: self.client.clone(), + cmdq: self.cmdq.clone(), + } + } +} diff --git a/runtime-sdk/src/modules/rofl/app/mod.rs b/runtime-sdk/src/modules/rofl/app/mod.rs new file mode 100644 index 0000000000..bddc7eb023 --- /dev/null +++ b/runtime-sdk/src/modules/rofl/app/mod.rs @@ -0,0 +1,130 @@ +//! Wrapper to make development of ROFL components easier. +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use tokio::sync::mpsc; + +use crate::{ + core::{ + config::Config, + consensus::roothash, + dispatcher::{PostInitState, PreInitState}, + rofl, start_runtime, + }, + crypto, + types::transaction, + Runtime, +}; + +mod client; +mod env; +mod notifier; +mod processor; +mod registration; + +pub use crate::modules::rofl::app_id::AppId; +pub use client::Client; +pub use env::Environment; + +/// ROFL component application. +#[allow(unused_variables)] +#[async_trait] +pub trait App: Send + Sync + 'static { + /// Runtime to attach the application to. + /// + /// The runtime must have the ROFL module enabled so that the application can register to it. + type AttachTo: Runtime; + + /// Identifier of the application (used for registrations). + fn id() -> AppId; + + /// Create a new unsigned transaction. + fn new_transaction(&self, method: &str, body: B) -> transaction::Transaction + where + B: cbor::Encode, + { + let mut tx = transaction::Transaction::new(method, body); + // Make the ROFL module resolve the payer for all of our transactions. + tx.set_fee_proxy("rofl", Self::id().as_ref()); + tx + } + + /// Main application processing loop. + async fn run(self: Arc, env: Environment) + where + Self: Sized, + { + // Default implementation does nothing. + } + + /// Logic that runs on each runtime block. Only one of these will run concurrently. + async fn on_runtime_block(self: Arc, env: Environment, round: u64) + where + Self: Sized, + { + // Default implementation does nothing. + } + + /// Start the application. + fn start(self) + where + Self: Sized, + { + start_runtime( + Box::new(|state: PreInitState<'_>| -> PostInitState { + // Fetch host information and configure domain separation context. + let hi = state.protocol.get_host_info(); + crypto::signature::context::set_chain_context( + hi.runtime_id, + &hi.consensus_chain_context, + ); + + PostInitState { + app: Some(Box::new(AppWrapper::new(self, &state))), + ..Default::default() + } + }), + Config { + // Use the same version as the runtime we are attaching to. + version: Self::AttachTo::VERSION, + // Use the same trust root as the runtime we are attaching to. + trust_root: Self::AttachTo::consensus_trust_root(), + ..Default::default() + }, + ); + } +} + +struct AppWrapper { + cmdq: mpsc::Sender, +} + +impl AppWrapper { + fn new(app: A, state: &PreInitState<'_>) -> Self + where + A: App, + { + Self { + cmdq: processor::Processor::start(app, state), + } + } +} + +#[async_trait] +impl rofl::App for AppWrapper { + async fn on_runtime_block(&self, blk: &roothash::AnnotatedBlock) -> Result<()> { + self.cmdq + .send(processor::Command::ProcessRuntimeBlock(blk.clone())) + .await?; + Ok(()) + } + + async fn on_runtime_event( + &self, + _blk: &roothash::AnnotatedBlock, + _tags: &[Vec], + ) -> Result<()> { + Ok(()) + } +} diff --git a/runtime-sdk/src/modules/rofl/app/notifier.rs b/runtime-sdk/src/modules/rofl/app/notifier.rs new file mode 100644 index 0000000000..c20b5f880a --- /dev/null +++ b/runtime-sdk/src/modules/rofl/app/notifier.rs @@ -0,0 +1,125 @@ +use std::sync::Arc; + +use anyhow::Result; +use tokio::sync::mpsc; + +use crate::core::common::logger::get_logger; + +use super::{processor, App, Environment}; + +/// Notification to deliver to the application. +pub(super) enum Notify { + RuntimeBlock(u64), + RuntimeBlockDone, + InitialRegistrationCompleted, +} + +#[derive(Default)] +struct NotifyState { + pending: bool, + running: bool, +} + +/// Application notifier task. +pub(super) struct Task { + imp: Option>, + tx: mpsc::Sender, +} + +impl Task +where + A: App, +{ + /// Create an application notifier task. + pub(super) fn new(state: Arc>, env: Environment) -> Self { + let (tx, rx) = mpsc::channel(16); + + let imp = Impl { + state, + env, + logger: get_logger("modules/rofl/app/notifier"), + notify: rx, + notify_tx: tx.downgrade(), + }; + + Self { imp: Some(imp), tx } + } + + /// Start the application notifier task. + pub(super) fn start(&mut self) { + if let Some(imp) = self.imp.take() { + imp.start(); + } + } + + /// Deliver a notification. + pub(super) async fn notify(&self, notification: Notify) -> Result<()> { + self.tx.send(notification).await?; + Ok(()) + } +} + +struct Impl { + state: Arc>, + env: Environment, + logger: slog::Logger, + + notify: mpsc::Receiver, + notify_tx: mpsc::WeakSender, +} + +impl Impl +where + A: App, +{ + /// Start the application notifier task. + pub(super) fn start(self) { + tokio::task::spawn(self.run()); + } + + /// Run the application notifier task. + async fn run(mut self) { + slog::info!(self.logger, "starting notifier task"); + + // Pending notifications. + let mut registered = false; + let mut block = NotifyState::default(); + let mut last_round = 0; + + while let Some(notification) = self.notify.recv().await { + match notification { + Notify::RuntimeBlock(round) if registered => { + block.pending = true; + last_round = round; + } + Notify::RuntimeBlock(_) => continue, // Skip blocks before registration. + Notify::RuntimeBlockDone => block.running = false, + Notify::InitialRegistrationCompleted => registered = true, + } + + // Don't do anything unless registered. + if !registered { + continue; + } + + // Block notifications. + if block.pending && !block.running { + block.pending = false; + block.running = true; + + let notify_tx = self.notify_tx.clone(); + let app = self.state.app.clone(); + let env = self.env.clone(); + + tokio::spawn(async move { + app.on_runtime_block(env, last_round).await; + if let Some(tx) = notify_tx.upgrade() { + let _ = tx.send(Notify::RuntimeBlockDone).await; + } + }); + } + } + + slog::info!(self.logger, "notifier task stopped"); + } +} diff --git a/runtime-sdk/src/modules/rofl/app/processor.rs b/runtime-sdk/src/modules/rofl/app/processor.rs new file mode 100644 index 0000000000..086d9cdf6c --- /dev/null +++ b/runtime-sdk/src/modules/rofl/app/processor.rs @@ -0,0 +1,193 @@ +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use tokio::sync::{mpsc, oneshot}; + +use crate::{ + core::{ + common::logger::get_logger, + consensus::{roothash, verifier::Verifier}, + dispatcher::PreInitState, + host::{self, Host as _}, + identity::Identity, + protocol::Protocol, + }, + crypto::signature::{secp256k1, Signer}, +}; +use rand::rngs::OsRng; + +use super::{notifier, registration, App, Environment}; + +/// Size of the processor command queue. +const CMDQ_BACKLOG: usize = 32; + +/// Command sent to the processor task. +#[allow(clippy::large_enum_variant)] +#[derive(Debug)] +pub(super) enum Command { + /// Process a notification of a new runtime block. + ProcessRuntimeBlock(roothash::AnnotatedBlock), + /// Retrieve the latest known round. + GetLatestRound(oneshot::Sender), + /// Notification that initial registration has been completed. + InitialRegistrationCompleted, +} + +/// Processor state. +pub(super) struct State { + pub(super) identity: Arc, + pub(super) host: Arc, + pub(super) consensus_verifier: Arc, + pub(super) signer: Arc, + pub(super) app: Arc, +} + +struct Tasks { + registration: registration::Task, + notifier: notifier::Task, +} + +/// Processor. +pub(super) struct Processor { + state: Arc>, + env: Environment, + tasks: Tasks, + cmdq: mpsc::Receiver, + logger: slog::Logger, + + latest_round: u64, +} + +impl Processor +where + A: App, +{ + /// Create and start a new processor. + pub(super) fn start(app: A, state: &PreInitState<'_>) -> mpsc::Sender { + // Create the command channel. + let (tx, rx) = mpsc::channel(CMDQ_BACKLOG); + + // Provision keys. Currently we provision a random key for signing transactions to avoid + // using the RAK directly as the RAK is an Ed25519 key which cannot easily be used for EVM + // calls due to the limitations of the current implementation. + let signer = secp256k1::MemorySigner::random(&mut OsRng).unwrap(); + + // Prepare state. + let state = Arc::new(State { + identity: state.identity.clone(), + host: state.protocol.clone(), + consensus_verifier: state.consensus_verifier.clone(), + signer: Arc::new(signer), + app: Arc::new(app), + }); + + // Prepare application environment. + let env = Environment::new(state.clone(), tx.downgrade()); + + // Create the processor and start it. + let processor = Self { + tasks: Tasks { + registration: registration::Task::new(state.clone(), env.clone()), + notifier: notifier::Task::new(state.clone(), env.clone()), + }, + state, + env, + cmdq: rx, + logger: get_logger("modules/rofl/app"), + latest_round: 0, + }; + tokio::spawn(processor.run()); + + tx + } + + /// Run the processor. + async fn run(mut self) { + slog::info!(self.logger, "starting processor"; + "app_id" => A::id(), + ); + + // Register for notifications. + if let Err(err) = self + .state + .host + .register_notify(host::RegisterNotifyOpts { + runtime_block: true, + runtime_event: vec![], + }) + .await + { + slog::error!(self.logger, "failed to register for notifications"; + "err" => ?err, + ); + } + + // Start the tasks. + self.tasks.registration.start(); + self.tasks.notifier.start(); + + slog::info!(self.logger, "entering processor loop"); + while let Some(cmd) = self.cmdq.recv().await { + if let Err(err) = self.process(cmd).await { + slog::error!(self.logger, "failed to process command"; + "err" => ?err, + ); + } + } + + slog::info!(self.logger, "processor stopped"); + } + + /// Process a command. + async fn process(&mut self, cmd: Command) -> Result<()> { + match cmd { + Command::ProcessRuntimeBlock(blk) => self.cmd_process_runtime_block(blk).await, + Command::GetLatestRound(ch) => self.cmd_get_latest_round(ch).await, + Command::InitialRegistrationCompleted => { + self.cmd_initial_registration_completed().await + } + } + } + + async fn cmd_process_runtime_block(&mut self, blk: roothash::AnnotatedBlock) -> Result<()> { + // Update latest known round. + if blk.block.header.round <= self.latest_round { + return Err(anyhow!("round seems to have moved backwards")); + } + self.latest_round = blk.block.header.round; + + // Notify registration task. + self.tasks.registration.refresh(); + // Notify notifier task. + let _ = self + .tasks + .notifier + .notify(notifier::Notify::RuntimeBlock(self.latest_round)) + .await; + + Ok(()) + } + + async fn cmd_get_latest_round(&self, ch: oneshot::Sender) -> Result<()> { + let _ = ch.send(self.latest_round); + Ok(()) + } + + async fn cmd_initial_registration_completed(&self) -> Result<()> { + slog::info!( + self.logger, + "initial registration completed, starting application" + ); + + // Start application after first registration. + tokio::spawn(self.state.app.clone().run(self.env.clone())); + + // Notify notifier task. + self.tasks + .notifier + .notify(notifier::Notify::InitialRegistrationCompleted) + .await?; + + Ok(()) + } +} diff --git a/runtime-sdk/src/modules/rofl/app/registration.rs b/runtime-sdk/src/modules/rofl/app/registration.rs new file mode 100644 index 0000000000..ba07c05bd1 --- /dev/null +++ b/runtime-sdk/src/modules/rofl/app/registration.rs @@ -0,0 +1,144 @@ +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use tokio::sync::mpsc; + +use crate::{ + core::{ + common::logger::get_logger, + consensus::{ + beacon::EpochTime, state::beacon::ImmutableState as BeaconState, verifier::Verifier, + }, + }, + modules::rofl::types::Register, +}; + +use super::{processor, App, Environment}; + +/// Registration task. +pub(super) struct Task { + imp: Option>, + tx: mpsc::Sender<()>, +} + +impl Task +where + A: App, +{ + /// Create a registration task. + pub(super) fn new(state: Arc>, env: Environment) -> Self { + let (tx, rx) = mpsc::channel(1); + + let imp = Impl { + state, + env, + logger: get_logger("modules/rofl/app/registration"), + notify: rx, + last_registration_epoch: None, + }; + + Self { imp: Some(imp), tx } + } + + /// Start the registration task. + pub(super) fn start(&mut self) { + if let Some(imp) = self.imp.take() { + imp.start(); + } + } + + /// Ask the registration task to refresh the registration. + pub(super) fn refresh(&self) { + let _ = self.tx.try_send(()); + } +} + +struct Impl { + state: Arc>, + env: Environment, + logger: slog::Logger, + + notify: mpsc::Receiver<()>, + last_registration_epoch: Option, +} + +impl Impl +where + A: App, +{ + /// Start the registration task. + pub(super) fn start(self) { + tokio::task::spawn(self.run()); + } + + /// Run the registration task. + async fn run(mut self) { + slog::info!(self.logger, "starting registration task"); + + // TODO: Handle retries etc. + while self.notify.recv().await.is_some() { + if let Err(err) = self.refresh_registration().await { + slog::error!(self.logger, "failed to refresh registration"; + "err" => ?err, + ); + } + } + + slog::info!(self.logger, "registration task stopped"); + } + + /// Perform application registration refresh. + async fn refresh_registration(&mut self) -> Result<()> { + // Determine current epoch. + let state = self.state.consensus_verifier.latest_state().await?; + let epoch = tokio::task::spawn_blocking(move || { + let beacon = BeaconState::new(&state); + beacon.epoch() + }) + .await??; + + // Skip refresh in case epoch has not changed. + if self.last_registration_epoch == Some(epoch) { + return Ok(()); + } + + slog::info!(self.logger, "refreshing registration"; + "last_registration_epoch" => self.last_registration_epoch, + "epoch" => epoch, + ); + + // Refresh registration. + let ect = self + .state + .identity + .endorsed_capability_tee() + .ok_or(anyhow!("endorsed TEE capability not available"))?; + let register = Register { + app: A::id(), + ect, + expiration: epoch + 2, + extra_keys: vec![self.env.signer().public_key()], + }; + + let tx = self.state.app.new_transaction("rofl.Register", register); + let result = self + .env + .client() + .multi_sign_and_submit_tx(&[&self.state.identity.as_ref(), self.env.signer()], tx) + .await? + .ok()?; + + slog::info!(self.logger, "refreshed registration"; "result" => ?result); + + if self.last_registration_epoch.is_none() { + // If this is the first registration, notify processor that initial registration has + // been completed so it can do other stuff. + self.env + .send_command(processor::Command::InitialRegistrationCompleted) + .await?; + } + self.last_registration_epoch = Some(epoch); + + Ok(()) + } +} diff --git a/runtime-sdk/src/modules/rofl/app_id.rs b/runtime-sdk/src/modules/rofl/app_id.rs new file mode 100644 index 0000000000..1b6823b368 --- /dev/null +++ b/runtime-sdk/src/modules/rofl/app_id.rs @@ -0,0 +1,223 @@ +//! ROFL application identifier. +use std::fmt; + +use bech32::{Bech32, Hrp}; + +use crate::{core::common::crypto::hash::Hash, types::address::Address}; + +const APP_ID_VERSION_SIZE: usize = 1; +const APP_ID_DATA_SIZE: usize = 20; +const APP_ID_SIZE: usize = APP_ID_VERSION_SIZE + APP_ID_DATA_SIZE; + +/// V0 identifier version. +const APP_ID_V0_VERSION: u8 = 0; +/// Creator/round/index identifier context. +const APP_ID_CRI_CONTEXT: &[u8] = b"oasis-sdk/rofl: cri app id"; +/// Global name identifier context. +const APP_ID_GLOBAL_NAME_CONTEXT: &[u8] = b"oasis-sdk/rofl: global name app id"; + +/// Human readable part for Bech32-encoded application identifier. +pub const APP_ID_BECH32_HRP: Hrp = Hrp::parse_unchecked("rofl"); + +/// Error. +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("malformed identifier")] + MalformedIdentifier, +} + +/// ROFL application identifier. +/// +/// The application identifier is similar to an address, but using its own separate namespace and +/// derivation scheme as it is not meant to be used as an address. +#[derive(Copy, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AppId([u8; APP_ID_SIZE]); + +impl AppId { + /// Size of an application identifier in bytes. + pub const SIZE: usize = APP_ID_SIZE; + + /// Creates a new application identifier from a context, version and data. + fn new(ctx: &'static [u8], version: u8, data: &[u8]) -> Self { + let h = Hash::digest_bytes_list(&[ctx, &[version], data]); + + let mut a = [0; APP_ID_SIZE]; + a[..APP_ID_VERSION_SIZE].copy_from_slice(&[version]); + a[APP_ID_VERSION_SIZE..].copy_from_slice(h.truncated(APP_ID_DATA_SIZE)); + + AppId(a) + } + + /// Creates a new v0 application identifier from a global name. + pub fn from_global_name(name: &str) -> Self { + Self::new( + APP_ID_GLOBAL_NAME_CONTEXT, + APP_ID_V0_VERSION, + name.as_bytes(), + ) + } + + /// Creates a new v0 application identifier from creator/round/index tuple. + pub fn from_creator_round_index(creator: Address, round: u64, index: u32) -> Self { + Self::new( + APP_ID_CRI_CONTEXT, + APP_ID_V0_VERSION, + &[creator.as_ref(), &round.to_be_bytes(), &index.to_be_bytes()].concat(), + ) + } + + /// Tries to create a new identifier from raw bytes. + pub fn from_bytes(data: &[u8]) -> Result { + if data.len() != APP_ID_SIZE { + return Err(Error::MalformedIdentifier); + } + + let mut a = [0; APP_ID_SIZE]; + a.copy_from_slice(data); + + Ok(AppId(a)) + } + + /// Convert the identifier into raw bytes. + pub fn into_bytes(self) -> [u8; APP_ID_SIZE] { + self.0 + } + + /// Tries to create a new identifier from Bech32-encoded string. + pub fn from_bech32(data: &str) -> Result { + let (hrp, data) = bech32::decode(data).map_err(|_| Error::MalformedIdentifier)?; + if hrp != APP_ID_BECH32_HRP { + return Err(Error::MalformedIdentifier); + } + + Self::from_bytes(&data) + } + + /// Converts an identifier to Bech32 representation. + pub fn to_bech32(self) -> String { + bech32::encode::(APP_ID_BECH32_HRP, &self.0).unwrap() + } +} + +impl AsRef<[u8]> for AppId { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl TryFrom<&[u8]> for AppId { + type Error = Error; + + fn try_from(bytes: &[u8]) -> Result { + Self::from_bytes(bytes) + } +} + +impl From<&'static str> for AppId { + fn from(s: &'static str) -> AppId { + AppId::from_bech32(s).unwrap() + } +} + +impl fmt::LowerHex for AppId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for i in &self.0[..] { + write!(f, "{i:02x}")?; + } + Ok(()) + } +} + +impl fmt::Debug for AppId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_bech32())?; + Ok(()) + } +} + +impl fmt::Display for AppId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_bech32())?; + Ok(()) + } +} + +impl cbor::Encode for AppId { + fn into_cbor_value(self) -> cbor::Value { + cbor::Value::ByteString(self.as_ref().to_vec()) + } +} + +impl cbor::Decode for AppId { + fn try_default() -> Result { + Ok(Default::default()) + } + + fn try_from_cbor_value(value: cbor::Value) -> Result { + match value { + cbor::Value::ByteString(data) => { + Self::from_bytes(&data).map_err(|_| cbor::DecodeError::UnexpectedType) + } + _ => Err(cbor::DecodeError::UnexpectedType), + } + } +} + +impl slog::Value for AppId { + fn serialize( + &self, + _record: &slog::Record<'_>, + key: slog::Key, + serializer: &mut dyn slog::Serializer, + ) -> slog::Result { + serializer.emit_str(key, &self.to_bech32()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::testing::keys; + + #[test] + fn test_identifier_v0() { + let creator = keys::alice::address(); + let app_id = AppId::from_creator_round_index(creator, 42, 0); + + assert_eq!( + app_id.to_bech32(), + "rofl1qr98wz5t6q4x8ng6a5l5v7rqlx90j3kcnun5dwht" + ); + + let creator = keys::bob::address(); + let app_id = AppId::from_creator_round_index(creator, 42, 0); + + assert_eq!( + app_id.to_bech32(), + "rofl1qrd45eaj4tf6l7mjw5prcukz75wdmwg6kggt6pnp" + ); + + let creator = keys::bob::address(); + let app_id = AppId::from_creator_round_index(creator, 1, 0); + + assert_eq!( + app_id.to_bech32(), + "rofl1qzmuyfwygnmfralgtwrqx8kcm587kwex9y8hf9hf" + ); + + let creator = keys::bob::address(); + let app_id = AppId::from_creator_round_index(creator, 42, 1); + + assert_eq!( + app_id.to_bech32(), + "rofl1qzmh56f52yd0tcqh757fahzc7ec49s8kaguyylvu" + ); + + let app_id = AppId::from_global_name("test global app"); + + assert_eq!( + app_id.to_bech32(), + "rofl1qrev5wq76npkmcv5wxkdxxcu4dhmu704yyl30h43" + ); + } +} diff --git a/runtime-sdk/src/modules/rofl/config.rs b/runtime-sdk/src/modules/rofl/config.rs new file mode 100644 index 0000000000..939fd3c652 --- /dev/null +++ b/runtime-sdk/src/modules/rofl/config.rs @@ -0,0 +1,22 @@ +use crate::types::token; + +/// Module configuration. +pub trait Config: 'static { + /// Gas cost of rofl.Create call. + const GAS_COST_CALL_CREATE: u64 = 100_000; + /// Gas cost of rofl.Update call. + const GAS_COST_CALL_UPDATE: u64 = 100_000; + /// Gas cost of rofl.Remove call. + const GAS_COST_CALL_REMOVE: u64 = 10_000; + /// Gas cost of rofl.Register call. + const GAS_COST_CALL_REGISTER: u64 = 100_000; + /// Gas cost of rofl.IsAuthorizedOrigin call. + const GAS_COST_CALL_IS_AUTHORIZED_ORIGIN: u64 = 1000; + + /// Amount of stake required for maintaining an application. + /// + /// The stake is held in escrow and is returned to the administrator when the application is + /// removed. + const STAKE_APP_CREATE: token::BaseUnits = + token::BaseUnits::new(0, token::Denomination::NATIVE); +} diff --git a/runtime-sdk/src/modules/rofl/error.rs b/runtime-sdk/src/modules/rofl/error.rs new file mode 100644 index 0000000000..5c96f52888 --- /dev/null +++ b/runtime-sdk/src/modules/rofl/error.rs @@ -0,0 +1,59 @@ +use crate::modules; + +use super::MODULE_NAME; + +/// Errors emitted by the module. +#[derive(thiserror::Error, Debug, oasis_runtime_sdk_macros::Error)] +pub enum Error { + #[error("invalid argument")] + #[sdk_error(code = 1)] + InvalidArgument, + + #[error("unknown application")] + #[sdk_error(code = 2)] + UnknownApp, + + #[error("tx not signed by RAK")] + #[sdk_error(code = 3)] + NotSignedByRAK, + + #[error("tx not signed by extra key")] + #[sdk_error(code = 4)] + NotSignedByExtraKey, + + #[error("unknown enclave")] + #[sdk_error(code = 5)] + UnknownEnclave, + + #[error("unknown node")] + #[sdk_error(code = 6)] + UnknownNode, + + #[error("endorsement from given node not allowed")] + #[sdk_error(code = 7)] + NodeNotAllowed, + + #[error("registration expired")] + #[sdk_error(code = 8)] + RegistrationExpired, + + #[error("extra key update not allowed")] + #[sdk_error(code = 9)] + ExtraKeyUpdateNotAllowed, + + #[error("application already exists")] + #[sdk_error(code = 10)] + AppAlreadyExists, + + #[error("forbidden")] + #[sdk_error(code = 11)] + Forbidden, + + #[error("core: {0}")] + #[sdk_error(transparent)] + Core(#[from] modules::core::Error), + + #[error("accounts: {0}")] + #[sdk_error(transparent)] + Accounts(#[from] modules::accounts::Error), +} diff --git a/runtime-sdk/src/modules/rofl/event.rs b/runtime-sdk/src/modules/rofl/event.rs new file mode 100644 index 0000000000..64f5d961ad --- /dev/null +++ b/runtime-sdk/src/modules/rofl/event.rs @@ -0,0 +1,15 @@ +use super::{app_id::AppId, MODULE_NAME}; + +/// Events emitted by the ROFL module. +#[derive(Debug, cbor::Encode, oasis_runtime_sdk_macros::Event)] +#[cbor(untagged)] +pub enum Event { + #[sdk_event(code = 1)] + AppCreated { id: AppId }, + + #[sdk_event(code = 2)] + AppUpdated { id: AppId }, + + #[sdk_event(code = 3)] + AppRemoved { id: AppId }, +} diff --git a/runtime-sdk/src/modules/rofl/mod.rs b/runtime-sdk/src/modules/rofl/mod.rs new file mode 100644 index 0000000000..c0528c911c --- /dev/null +++ b/runtime-sdk/src/modules/rofl/mod.rs @@ -0,0 +1,525 @@ +//! On-chain coordination for ROFL components. +use std::collections::BTreeSet; + +use once_cell::sync::Lazy; + +use crate::{ + context::Context, + core::consensus::{ + registry::{Node, RolesMask, VerifiedEndorsedCapabilityTEE}, + state::registry::ImmutableState as RegistryImmutableState, + }, + crypto::signature::PublicKey, + handler, migration, + module::{self, Module as _, Parameters as _}, + modules::{self, accounts::API as _, core::API as _}, + sdk_derive, + state::CurrentState, + types::{address::Address, transaction::Transaction}, + Runtime, +}; + +pub mod app; +pub mod app_id; +mod config; +mod error; +mod event; +pub mod policy; +pub mod state; +#[cfg(test)] +mod test; +pub mod types; + +/// Unique module name. +const MODULE_NAME: &str = "rofl"; + +pub use config::Config; +pub use error::Error; +pub use event::Event; + +/// Parameters for the module. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +pub struct Parameters {} + +/// Errors emitted during parameter validation. +#[derive(thiserror::Error, Debug)] +pub enum ParameterValidationError {} + +impl module::Parameters for Parameters { + type Error = ParameterValidationError; + + fn validate_basic(&self) -> Result<(), Self::Error> { + Ok(()) + } +} + +/// Genesis state for the module. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +pub struct Genesis { + pub parameters: Parameters, + + /// Application configurations. + pub apps: Vec, +} + +/// Interface that can be called from other modules. +pub trait API { + /// Verify whether the origin transaction is signed by an authorized ROFL instance for the given + /// application. + /// + /// # Panics + /// + /// This method will panic if called outside a transaction environment. + fn is_authorized_origin(app: app_id::AppId) -> Result; + + /// Get an application's configuration. + fn get_app(id: app_id::AppId) -> Result; + + /// Get all registered instances for an application. + fn get_instances(id: app_id::AppId) -> Result, Error>; +} + +/// Module's address that has the application stake pool. +/// +/// oasis1qza6sddnalgzexk3ct30gqfvntgth5m4hsyywmff +pub static ADDRESS_APP_STAKE_POOL: Lazy
= + Lazy::new(|| Address::from_module(MODULE_NAME, "app-stake-pool")); + +pub struct Module { + _cfg: std::marker::PhantomData, +} + +impl API for Module { + fn is_authorized_origin(app: app_id::AppId) -> Result { + let caller_pk = CurrentState::with_env_origin(|env| env.tx_caller_public_key()) + .ok_or(Error::InvalidArgument)?; + + // Resolve RAK as the call may be made by an extra key. + let rak = match state::get_endorser(&caller_pk) { + // It may point to a RAK. + Some(state::KeyEndorsementInfo { rak: Some(rak), .. }) => rak, + // Or it points to itself. + Some(_) => caller_pk.try_into().map_err(|_| Error::InvalidArgument)?, + // Or is unknown. + None => return Ok(false), + }; + + // Check whether the the endorsement is for the right application. + Ok(state::get_registration(app, &rak).is_some()) + } + + fn get_app(id: app_id::AppId) -> Result { + state::get_app(id).ok_or(Error::UnknownApp) + } + + fn get_instances(id: app_id::AppId) -> Result, Error> { + Ok(state::get_registrations_for_app(id)) + } +} + +#[sdk_derive(Module)] +impl Module { + const NAME: &'static str = MODULE_NAME; + type Error = Error; + type Event = Event; + type Parameters = Parameters; + type Genesis = Genesis; + + #[migration(init)] + fn init(genesis: Genesis) { + genesis + .parameters + .validate_basic() + .expect("invalid genesis parameters"); + + // Set genesis parameters. + Self::set_params(genesis.parameters); + + // Insert all applications. + for cfg in genesis.apps { + if state::get_app(cfg.id).is_some() { + panic!("duplicate application in genesis: {:?}", cfg.id); + } + + state::set_app(cfg); + } + } + + /// Create a new ROFL application. + #[handler(call = "rofl.Create")] + fn tx_create(ctx: &C, body: types::Create) -> Result { + ::Core::use_tx_gas(Cfg::GAS_COST_CALL_CREATE)?; + + if CurrentState::with_env(|env| env.is_check_only()) { + return Ok(Default::default()); + } + + let (creator, tx_index) = + CurrentState::with_env(|env| (env.tx_caller_address(), env.tx_index())); + let app_id = app_id::AppId::from_creator_round_index( + creator, + ctx.runtime_header().round, + tx_index.try_into().map_err(|_| Error::InvalidArgument)?, + ); + + // Sanity check that the application doesn't already exist. + if state::get_app(app_id).is_some() { + return Err(Error::AppAlreadyExists); + } + + // Transfer stake. + ::Accounts::transfer( + creator, + *ADDRESS_APP_STAKE_POOL, + &Cfg::STAKE_APP_CREATE, + )?; + + // Register the application. + let cfg = types::AppConfig { + id: app_id, + policy: body.policy, + admin: Some(creator), + stake: Cfg::STAKE_APP_CREATE, + }; + state::set_app(cfg); + + CurrentState::with(|state| state.emit_event(Event::AppCreated { id: app_id })); + + Ok(app_id) + } + + /// Ensure caller is the current administrator, return an error otherwise. + fn ensure_caller_is_admin(cfg: &types::AppConfig) -> Result<(), Error> { + let caller = CurrentState::with_env(|env| env.tx_caller_address()); + if cfg.admin != Some(caller) { + return Err(Error::Forbidden); + } + Ok(()) + } + + /// Update a ROFL application. + #[handler(call = "rofl.Update")] + fn tx_update(ctx: &C, body: types::Update) -> Result<(), Error> { + ::Core::use_tx_gas(Cfg::GAS_COST_CALL_UPDATE)?; + + let mut cfg = state::get_app(body.id).ok_or(Error::UnknownApp)?; + + // Ensure caller is the admin and is allowed to update the configuration. + Self::ensure_caller_is_admin(&cfg)?; + + if CurrentState::with_env(|env| env.is_check_only()) { + return Ok(()); + } + + // Return early if nothing has actually changed. + if cfg.policy == body.policy && cfg.admin == body.admin { + return Ok(()); + } + + cfg.policy = body.policy; + cfg.admin = body.admin; + state::set_app(cfg); + + CurrentState::with(|state| state.emit_event(Event::AppUpdated { id: body.id })); + + Ok(()) + } + + /// Remove a ROFL application. + #[handler(call = "rofl.Remove")] + fn tx_remove(ctx: &C, body: types::Remove) -> Result<(), Error> { + ::Core::use_tx_gas(Cfg::GAS_COST_CALL_REMOVE)?; + + let cfg = state::get_app(body.id).ok_or(Error::UnknownApp)?; + + // Ensure caller is the admin and is allowed to update the configuration. + Self::ensure_caller_is_admin(&cfg)?; + + if CurrentState::with_env(|env| env.is_check_only()) { + return Ok(()); + } + + state::remove_app(body.id); + + // Return stake to the administrator account. + if let Some(admin) = cfg.admin { + ::Accounts::transfer( + *ADDRESS_APP_STAKE_POOL, + admin, + &cfg.stake, + )?; + } + + CurrentState::with(|state| state.emit_event(Event::AppRemoved { id: body.id })); + + Ok(()) + } + + /// Register a new ROFL instance. + #[handler(call = "rofl.Register")] + fn tx_register(ctx: &C, body: types::Register) -> Result<(), Error> { + ::Core::use_tx_gas(Cfg::GAS_COST_CALL_REGISTER)?; + + if body.expiration <= ctx.epoch() { + return Err(Error::RegistrationExpired); + } + + let cfg = state::get_app(body.app).ok_or(Error::UnknownApp)?; + + if body.expiration - ctx.epoch() > cfg.policy.max_expiration { + return Err(Error::InvalidArgument); + } + + // Ensure that the transaction is signed by RAK (and co-signed by extra keys). + let signer_pks: BTreeSet = CurrentState::with_env(|env| { + env.tx_auth_info() + .signer_info + .iter() + .filter_map(|si| si.address_spec.public_key()) + .collect() + }); + if !signer_pks.contains(&body.ect.capability_tee.rak.into()) { + return Err(Error::NotSignedByRAK); + } + for extra_pk in &body.extra_keys { + if !signer_pks.contains(extra_pk) { + return Err(Error::NotSignedByExtraKey); + } + } + + if CurrentState::with_env(|env| env.is_check_only()) { + return Ok(()); + } + + // Verify policy. + let verified_ect = body + .ect + .verify(&cfg.policy.quotes) + .map_err(|_| Error::InvalidArgument)?; + + // Verify enclave identity. + if !cfg + .policy + .enclaves + .contains(&verified_ect.verified_attestation.quote.identity) + { + return Err(Error::UnknownEnclave); + } + + // Verify allowed endorsement. + Self::verify_endorsement(ctx, &cfg.policy, &verified_ect)?; + + // Update registration. + let registration = types::Registration { + app: body.app, + node_id: verified_ect.node_id.unwrap(), // Verified above. + rak: body.ect.capability_tee.rak, + rek: body.ect.capability_tee.rek.ok_or(Error::InvalidArgument)?, // REK required. + expiration: body.expiration, + extra_keys: body.extra_keys, + }; + state::update_registration(registration)?; + + Ok(()) + } + + /// Verify whether the given endorsement is allowed by the application policy. + fn verify_endorsement( + ctx: &C, + app_policy: &policy::AppAuthPolicy, + ect: &VerifiedEndorsedCapabilityTEE, + ) -> Result<(), Error> { + use policy::AllowedEndorsement; + + let endorsing_node_id = ect.node_id.ok_or(Error::UnknownNode)?; + + // Attempt to resolve the node that endorsed the enclave. It may be that the node is not + // even registered in the consensus layer which may be acceptable for some policies. + // + // But if the node is registered, it must be registered for this runtime, otherwise it is + // treated as if it is not registered. + let node = || -> Result, Error> { + let registry = RegistryImmutableState::new(ctx.consensus_state()); + let node = registry + .node(&endorsing_node_id) + .map_err(|_| Error::UnknownNode)?; + let node = if let Some(node) = node { + node + } else { + return Ok(None); + }; + // Ensure node is not expired. + if node.expiration < ctx.epoch() { + return Ok(None); + } + // Ensure node is registered for this runtime. + let version = &::VERSION; + if node.get_runtime(ctx.runtime_id(), version).is_none() { + return Ok(None); + } + + Ok(Some(node)) + }()?; + + for allowed in &app_policy.endorsements { + match (allowed, &node) { + (AllowedEndorsement::Any, _) => { + // Any node is allowed. + return Ok(()); + } + (AllowedEndorsement::ComputeRole, Some(node)) => { + if node.has_roles(RolesMask::ROLE_COMPUTE_WORKER) { + return Ok(()); + } + } + (AllowedEndorsement::ObserverRole, Some(node)) => { + if node.has_roles(RolesMask::ROLE_OBSERVER) { + return Ok(()); + } + } + (AllowedEndorsement::Entity(entity_id), Some(node)) => { + if &node.entity_id == entity_id { + return Ok(()); + } + } + (AllowedEndorsement::Node(node_id), _) => { + if endorsing_node_id == *node_id { + return Ok(()); + } + } + _ => continue, + } + } + + // If nothing matched, this node is not allowed to register. + Err(Error::NodeNotAllowed) + } + + /// Verify whether the origin transaction is signed by an authorized ROFL instance for the given + /// application. + #[handler(call = "rofl.IsAuthorizedOrigin", internal)] + fn internal_is_authorized_origin( + _ctx: &C, + app: app_id::AppId, + ) -> Result { + ::Core::use_tx_gas(Cfg::GAS_COST_CALL_IS_AUTHORIZED_ORIGIN)?; + + Self::is_authorized_origin(app) + } + + /// Returns the configuration for the given ROFL application. + #[handler(query = "rofl.App")] + fn query_app(_ctx: &C, args: types::AppQuery) -> Result { + Self::get_app(args.id) + } + + /// Returns a list of all registered instances for the given ROFL application. + #[handler(query = "rofl.AppInstances", expensive)] + fn query_app_instances( + _ctx: &C, + args: types::AppQuery, + ) -> Result, Error> { + Self::get_instances(args.id) + } + + fn resolve_payer_from_tx( + ctx: &C, + tx: &Transaction, + app_policy: &policy::AppAuthPolicy, + ) -> Result, anyhow::Error> { + let caller_pk = tx + .auth_info + .signer_info + .first() + .and_then(|si| si.address_spec.public_key()); + + match tx.call.method.as_str() { + "rofl.Register" => { + // For registration transactions, extract endorsing node. + let body: types::Register = cbor::from_value(tx.call.body.clone())?; + if body.expiration <= ctx.epoch() { + return Err(Error::RegistrationExpired.into()); + } + + // Ensure that the transaction is signed by RAK. + let caller_pk = caller_pk.ok_or(Error::NotSignedByRAK)?; + if caller_pk != body.ect.capability_tee.rak { + return Err(Error::NotSignedByRAK.into()); + } + + body.ect.verify_endorsement()?; + + // Checking other details is not relevant for authorizing fee payments as if the + // node signed a TEE capability then it is authorizing fees to be spent on its + // behalf. + + let node_id = body.ect.node_endorsement.public_key; + let payer = Address::from_consensus_pk(&node_id); + + Ok(Some(payer)) + } + _ => { + // For others, check if caller is one of the endorsed keys. + let caller_pk = match caller_pk { + Some(pk) => pk, + None => return Ok(None), + }; + + Ok(state::get_endorser(&caller_pk) + .map(|ei| Address::from_consensus_pk(&ei.node_id))) + } + } + } +} + +impl module::FeeProxyHandler for Module { + fn resolve_payer( + ctx: &C, + tx: &Transaction, + ) -> Result, modules::core::Error> { + use policy::FeePolicy; + + let proxy = if let Some(ref proxy) = tx.auth_info.fee.proxy { + proxy + } else { + return Ok(None); + }; + + if proxy.module != MODULE_NAME { + return Ok(None); + } + + // Look up the per-ROFL app policy. + let app_id = app_id::AppId::try_from(proxy.id.as_slice()) + .map_err(|err| modules::core::Error::InvalidArgument(err.into()))?; + let app_policy = state::get_app(app_id).map(|cfg| cfg.policy).ok_or( + modules::core::Error::InvalidArgument(Error::UnknownApp.into()), + )?; + + match app_policy.fees { + FeePolicy::AppPays => { + // Application needs to figure out a way to pay, defer to regular handler. + Ok(None) + } + FeePolicy::EndorsingNodePays => Self::resolve_payer_from_tx(ctx, tx, &app_policy) + .map_err(modules::core::Error::InvalidArgument), + } + } +} + +impl module::TransactionHandler for Module {} + +impl module::BlockHandler for Module { + fn end_block(ctx: &C) { + // Only do work in case the epoch has changed since the last processed block. + if !::Core::has_epoch_changed() { + return; + } + + // Process enclave expirations. + // TODO: Consider processing unprocessed in the next block(s) if there are too many. + state::expire_registrations(ctx.epoch(), 128); + } +} + +impl module::InvariantHandler for Module {} diff --git a/runtime-sdk/src/modules/rofl/policy.rs b/runtime-sdk/src/modules/rofl/policy.rs new file mode 100644 index 0000000000..9421951229 --- /dev/null +++ b/runtime-sdk/src/modules/rofl/policy.rs @@ -0,0 +1,54 @@ +use crate::core::{ + common::{ + crypto::signature::PublicKey, + sgx::{EnclaveIdentity, QuotePolicy}, + }, + consensus::beacon::EpochTime, +}; + +/// Per-application ROFL policy. +#[derive(Clone, Debug, PartialEq, Eq, Default, cbor::Encode, cbor::Decode)] +pub struct AppAuthPolicy { + /// Quote policy. + pub quotes: QuotePolicy, + /// The set of allowed enclave identities. + pub enclaves: Vec, + /// The set of allowed endorsements. + pub endorsements: Vec, + /// Gas fee payment policy. + pub fees: FeePolicy, + /// Maximum number of future epochs for which one can register. + pub max_expiration: EpochTime, +} + +/// An allowed endorsement policy. +#[derive(Clone, Debug, PartialEq, Eq, cbor::Encode, cbor::Decode)] +#[cbor(no_default)] +pub enum AllowedEndorsement { + /// Any node can endorse the enclave. + #[cbor(rename = "any", as_struct)] + Any, + /// Compute node can endorse the enclave. + #[cbor(rename = "role_compute", as_struct)] + ComputeRole, + /// Observer node can endorse the enclave. + #[cbor(rename = "role_observer", as_struct)] + ObserverRole, + /// Registered node from a specific entity can endorse the enclave. + #[cbor(rename = "entity")] + Entity(PublicKey), + /// Specific node can endorse the enclave. + #[cbor(rename = "node")] + Node(PublicKey), +} + +/// Gas fee payment policy. +#[derive(Clone, Debug, Default, PartialEq, Eq, cbor::Encode, cbor::Decode)] +#[repr(u8)] +pub enum FeePolicy { + /// Application enclave pays the gas fees. + AppPays = 1, + /// Endorsing node pays the gas fees. + #[default] + EndorsingNodePays = 2, +} diff --git a/runtime-sdk/src/modules/rofl/state.rs b/runtime-sdk/src/modules/rofl/state.rs new file mode 100644 index 0000000000..31a440e03d --- /dev/null +++ b/runtime-sdk/src/modules/rofl/state.rs @@ -0,0 +1,346 @@ +use crate::{ + core::{ + common::crypto::{hash::Hash, signature::PublicKey as CorePublicKey}, + consensus::beacon::EpochTime, + }, + crypto::signature::PublicKey, + state::CurrentState, + storage::{self, Store}, +}; + +use super::{app_id::AppId, types, Error, MODULE_NAME}; + +/// Map of application identifiers to their configs. +const APPS: &[u8] = &[0x01]; +/// Map of (application identifier, H(RAK)) tuples to their registrations. +const REGISTRATIONS: &[u8] = &[0x02]; +/// Map of H(pk)s to KeyEndorsementInfos. This is used when just the public key is needed to avoid +/// fetching entire registrations from storage. +const ENDORSERS: &[u8] = &[0x03]; +/// A queue of registration expirations. +const EXPIRATION_QUEUE: &[u8] = &[0x04]; + +/// Information about an endorsed key. +#[derive(Clone, Debug, Default, PartialEq, Eq, cbor::Encode, cbor::Decode)] +#[cbor(as_array)] +pub struct KeyEndorsementInfo { + /// Identifier of node that endorsed the enclave. + pub node_id: CorePublicKey, + /// RAK of the enclave that endorsed the key. This is only set for endorsements of extra keys. + pub rak: Option, +} + +impl KeyEndorsementInfo { + /// Create a new key endorsement information for RAK endorsed by given node directly. + pub fn for_rak(node_id: CorePublicKey) -> Self { + Self { + node_id, + ..Default::default() + } + } + + /// Create a new key endorsement information for extra key endorsed by RAK. + pub fn for_extra_key(node_id: CorePublicKey, rak: CorePublicKey) -> Self { + Self { + node_id, + rak: Some(rak), + } + } +} + +/// Retrieves an application configuration. +pub fn get_app(app_id: AppId) -> Option { + CurrentState::with_store(|store| { + let store = storage::PrefixStore::new(store, &MODULE_NAME); + let apps = storage::TypedStore::new(storage::PrefixStore::new(store, &APPS)); + apps.get(app_id) + }) +} + +/// Updates an application configuration. +pub fn set_app(cfg: types::AppConfig) { + CurrentState::with_store(|store| { + let store = storage::PrefixStore::new(store, &MODULE_NAME); + let mut apps = storage::TypedStore::new(storage::PrefixStore::new(store, &APPS)); + apps.insert(cfg.id, cfg); + }) +} + +/// Removes an application configuration. +pub fn remove_app(app_id: AppId) { + CurrentState::with_store(|store| { + let store = storage::PrefixStore::new(store, &MODULE_NAME); + let mut apps = storage::TypedStore::new(storage::PrefixStore::new(store, &APPS)); + apps.remove(app_id); + }) +} + +/// Updates registration of the given ROFL enclave. +pub fn update_registration(registration: types::Registration) -> Result<(), Error> { + let hrak = hash_rak(®istration.rak); + + // Update expiration queue. + if let Some(existing) = get_registration_hrak(registration.app, hrak) { + // Disallow modification of extra keys. + if existing.extra_keys != registration.extra_keys { + return Err(Error::ExtraKeyUpdateNotAllowed); + } + + remove_expiration_queue(existing.expiration, registration.app, hrak); + } + insert_expiration_queue(registration.expiration, registration.app, hrak); + + // Update registration. + CurrentState::with_store(|mut root_store| { + let store = storage::PrefixStore::new(&mut root_store, &MODULE_NAME); + let mut endorsers = storage::TypedStore::new(storage::PrefixStore::new(store, &ENDORSERS)); + endorsers.insert(hrak, KeyEndorsementInfo::for_rak(registration.node_id)); + + for pk in ®istration.extra_keys { + endorsers.insert( + hash_pk(pk), + KeyEndorsementInfo::for_extra_key(registration.node_id, registration.rak), + ); + } + + let app_id = registration.app; + let store = storage::PrefixStore::new(&mut root_store, &MODULE_NAME); + let registrations = storage::PrefixStore::new(store, ®ISTRATIONS); + let mut app = storage::TypedStore::new(storage::PrefixStore::new(registrations, app_id)); + app.insert(hrak, registration); + }); + + Ok(()) +} + +fn remove_registration_hrak(app_id: AppId, hrak: Hash) { + let registration = match get_registration_hrak(app_id, hrak) { + Some(registration) => registration, + None => return, + }; + + // Remove from expiration queue if present. + remove_expiration_queue(registration.expiration, registration.app, hrak); + + // Remove registration. + CurrentState::with_store(|mut root_store| { + let store = storage::PrefixStore::new(&mut root_store, &MODULE_NAME); + let mut endorsers = storage::TypedStore::new(storage::PrefixStore::new(store, &ENDORSERS)); + endorsers.remove(hrak); + + for pk in ®istration.extra_keys { + endorsers.remove(hash_pk(pk)); + } + + let store = storage::PrefixStore::new(&mut root_store, &MODULE_NAME); + let registrations = storage::PrefixStore::new(store, ®ISTRATIONS); + let mut app = storage::TypedStore::new(storage::PrefixStore::new(registrations, app_id)); + app.remove(hrak); + }); +} + +/// Removes an existing registration of the given ROFL enclave. +pub fn remove_registration(app_id: AppId, rak: &CorePublicKey) { + remove_registration_hrak(app_id, hash_rak(rak)) +} + +fn get_registration_hrak(app_id: AppId, hrak: Hash) -> Option { + CurrentState::with_store(|store| { + let store = storage::PrefixStore::new(store, &MODULE_NAME); + let registrations = storage::PrefixStore::new(store, ®ISTRATIONS); + let app = storage::TypedStore::new(storage::PrefixStore::new(registrations, app_id)); + app.get(hrak) + }) +} + +/// Retrieves registration of the given ROFL enclave. In case enclave is not registered, returns +/// `None`. +pub fn get_registration(app_id: AppId, rak: &CorePublicKey) -> Option { + get_registration_hrak(app_id, hash_rak(rak)) +} + +/// Retrieves all registrations for the given ROFL application. +pub fn get_registrations_for_app(app_id: AppId) -> Vec { + CurrentState::with_store(|mut root_store| { + let store = storage::PrefixStore::new(&mut root_store, &MODULE_NAME); + let registrations = storage::PrefixStore::new(store, ®ISTRATIONS); + let app = storage::TypedStore::new(storage::PrefixStore::new(registrations, app_id)); + + app.iter() + .map(|(_, registration): (Hash, types::Registration)| registration) + .collect() + }) +} + +/// Retrieves endorser of the given ROFL enclave. In case enclave is not registered, returns `None`. +pub fn get_endorser(pk: &PublicKey) -> Option { + let hpk = hash_pk(pk); + + CurrentState::with_store(|store| { + let store = storage::PrefixStore::new(store, &MODULE_NAME); + let endorsers = storage::TypedStore::new(storage::PrefixStore::new(store, &ENDORSERS)); + endorsers.get(hpk) + }) +} + +fn hash_rak(rak: &CorePublicKey) -> Hash { + hash_pk(&PublicKey::Ed25519(rak.into())) +} + +fn hash_pk(pk: &PublicKey) -> Hash { + Hash::digest_bytes_list(&[pk.key_type().as_bytes(), pk.as_ref()]) +} + +fn queue_entry_key(epoch: EpochTime, app_id: AppId, hrak: Hash) -> Vec { + [&epoch.to_be_bytes(), app_id.as_ref(), hrak.as_ref()].concat() +} + +fn insert_expiration_queue(epoch: EpochTime, app_id: AppId, hrak: Hash) { + CurrentState::with_store(|store| { + let store = storage::PrefixStore::new(store, &MODULE_NAME); + let mut queue = storage::PrefixStore::new(store, &EXPIRATION_QUEUE); + queue.insert(&queue_entry_key(epoch, app_id, hrak), &[]); + }) +} + +fn remove_expiration_queue(epoch: EpochTime, app_id: AppId, hrak: Hash) { + CurrentState::with_store(|store| { + let store = storage::PrefixStore::new(store, &MODULE_NAME); + let mut queue = storage::PrefixStore::new(store, &EXPIRATION_QUEUE); + queue.remove(&queue_entry_key(epoch, app_id, hrak)); + }) +} + +struct ExpirationQueueEntry { + epoch: EpochTime, + app_id: AppId, + hrak: Hash, +} + +impl<'a> TryFrom<&'a [u8]> for ExpirationQueueEntry { + type Error = anyhow::Error; + + fn try_from(value: &'a [u8]) -> Result { + // Decode a storage key of the format (epoch, hrak). + if value.len() != 8 + AppId::SIZE + Hash::len() { + anyhow::bail!("incorrect expiration queue key size"); + } + + Ok(Self { + epoch: EpochTime::from_be_bytes(value[..8].try_into()?), + app_id: value[8..8 + AppId::SIZE].try_into()?, + hrak: value[8 + AppId::SIZE..].into(), + }) + } +} + +/// Removes all expired registrations, e.g. those that expire in epochs earlier than or equal to the +/// passed epoch. +pub fn expire_registrations(epoch: EpochTime, limit: usize) { + let expired: Vec<_> = CurrentState::with_store(|store| { + let store = storage::PrefixStore::new(store, &MODULE_NAME); + let queue = storage::TypedStore::new(storage::PrefixStore::new(store, &EXPIRATION_QUEUE)); + + queue + .iter() + .take_while(|(e, _): &(ExpirationQueueEntry, CorePublicKey)| e.epoch <= epoch) + .map(|(e, _)| (e.app_id, e.hrak)) + .take(limit) + .collect() + }); + + for (app_id, hrak) in expired { + remove_registration_hrak(app_id, hrak); + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::testing::{keys, mock}; + + #[test] + fn test_app_cfg() { + let _mock = mock::Mock::default(); + + let app_id = AppId::from_creator_round_index(keys::alice::address(), 0, 0); + let app = get_app(app_id); + assert!(app.is_none()); + + let cfg = types::AppConfig { + id: app_id, + policy: Default::default(), + admin: Some(keys::alice::address()), + stake: Default::default(), + }; + set_app(cfg.clone()); + let app = get_app(app_id).expect("application config should be created"); + assert_eq!(app, cfg); + + let cfg = types::AppConfig { admin: None, ..cfg }; + set_app(cfg.clone()); + let app = get_app(app_id).expect("application config should be updated"); + assert_eq!(app, cfg); + + remove_app(app_id); + let app = get_app(app_id); + assert!(app.is_none(), "application should have been removed"); + } + + #[test] + fn test_registration() { + let _mock = mock::Mock::default(); + let app_id = Default::default(); + let rak = keys::alice::pk().try_into().unwrap(); // Fake RAK. + let rak_pk = keys::alice::pk(); + + let registration = get_registration(app_id, &rak); + assert!(registration.is_none()); + let endorser = get_endorser(&rak_pk); + assert!(endorser.is_none()); + let endorser = get_endorser(&keys::dave::pk()); + assert!(endorser.is_none()); + + let new_registration = types::Registration { + app: app_id, + rak, + expiration: 42, + extra_keys: vec![ + keys::dave::pk(), // Add dave as an extra endorsed key. + ], + ..Default::default() + }; + update_registration(new_registration.clone()).expect("registration update should work"); + + // Ensure extra endorsed keys cannot be updated later. + let bad_registration = types::Registration { + app: app_id, + extra_keys: vec![], + ..new_registration.clone() + }; + update_registration(bad_registration.clone()) + .expect_err("extra endorsed key update should not be allowed"); + + let registration = get_registration(app_id, &rak).expect("registration should be present"); + assert_eq!(registration, new_registration); + let endorser = get_endorser(&rak_pk).expect("endorser should be present"); + assert_eq!(endorser.node_id, new_registration.node_id); + assert!(endorser.rak.is_none()); + let endorser = get_endorser(&keys::dave::pk()).expect("extra keys should be endorsed"); + assert_eq!(endorser.node_id, new_registration.node_id); + assert_eq!(endorser.rak, Some(rak)); + let registrations = get_registrations_for_app(new_registration.app); + assert_eq!(registrations.len(), 1); + + expire_registrations(42, 128); + + let registration = get_registration(app_id, &rak); + assert!(registration.is_none()); + let endorser = get_endorser(&rak_pk); + assert!(endorser.is_none()); + let endorser = get_endorser(&keys::dave::pk()); + assert!(endorser.is_none()); + let registrations = get_registrations_for_app(new_registration.app); + assert_eq!(registrations.len(), 0); + } +} diff --git a/runtime-sdk/src/modules/rofl/test.rs b/runtime-sdk/src/modules/rofl/test.rs new file mode 100644 index 0000000000..1a59ddfb97 --- /dev/null +++ b/runtime-sdk/src/modules/rofl/test.rs @@ -0,0 +1,208 @@ +use std::collections::BTreeMap; + +use crate::{ + module, + modules::{ + accounts::{self, API as _}, + core, + }, + testing::{keys, mock}, + types::{ + address::Address, + token::{BaseUnits, Denomination}, + }, + Runtime, Version, +}; + +use super::{app_id::AppId, types, Genesis, Module, ADDRESS_APP_STAKE_POOL, API as _}; + +type Accounts = accounts::Module; +type Core = core::Module; + +struct Config; + +impl core::Config for Config {} + +impl super::Config for Config { + const STAKE_APP_CREATE: BaseUnits = BaseUnits::new(1_000, Denomination::NATIVE); +} + +/// Test runtime. +struct TestRuntime; + +impl Runtime for TestRuntime { + const VERSION: Version = Version::new(0, 0, 0); + + type Core = Core; + type Accounts = Accounts; + + type Modules = (Core, Accounts, Module); + + fn genesis_state() -> ::Genesis { + ( + core::Genesis { + parameters: core::Parameters { + max_batch_gas: 10_000_000, + min_gas_price: BTreeMap::from([(Denomination::NATIVE, 0)]), + ..Default::default() + }, + }, + accounts::Genesis { + balances: BTreeMap::from([( + keys::alice::address(), + BTreeMap::from([(Denomination::NATIVE, 1_000_000)]), + )]), + total_supplies: BTreeMap::from([(Denomination::NATIVE, 1_000_000)]), + ..Default::default() + }, + Genesis::default(), + ) + } +} + +#[test] +fn test_app_stake_pool_address() { + // Make sure the application stake pool address doesn't change. + assert_eq!( + ADDRESS_APP_STAKE_POOL.to_bech32(), + "oasis1qza6sddnalgzexk3ct30gqfvntgth5m4hsyywmff" + ); +} + +#[test] +fn test_management_ops() { + let mut mock = mock::Mock::default(); + let ctx = mock.create_ctx_for_runtime::(false); + + TestRuntime::migrate(&ctx); + + let create = types::Create { + policy: Default::default(), + }; + + // Bob attempts to create a new ROFL application, but he doesn't have enough to stake. + let mut signer_bob = mock::Signer::new(0, keys::bob::sigspec()); + let dispatch_result = signer_bob.call(&ctx, "rofl.Create", create.clone()); + assert!(!dispatch_result.result.is_success(), "call should fail"); + + // Alice should be able to create a new ROFL application. + let mut signer_alice = mock::Signer::new(0, keys::alice::sigspec()); + let dispatch_result = signer_alice.call(&ctx, "rofl.Create", create.clone()); + assert!(dispatch_result.result.is_success(), "call should succeed"); + + // Ensure the correct application ID has been created. + let app_id: AppId = cbor::from_value(dispatch_result.result.unwrap()).unwrap(); + assert_eq!( + app_id.to_bech32(), + "rofl1qpa9ydy3qmka3yrqzx0pxuvyfexf9mlh75hker5j" + ); + + // Make sure correct events were emitted. + let tags = &dispatch_result.tags; + assert_eq!(tags.len(), 3, "three event kinds should have been emitted"); + assert_eq!(tags[0].key, b"accounts\x00\x00\x00\x01"); // accounts.Transfer (code = 1) event + assert_eq!(tags[1].key, b"core\x00\x00\x00\x01"); // core.GasUsed (code = 1) event + assert_eq!(tags[2].key, b"rofl\x00\x00\x00\x01"); // rofl.AppCreated (code = 1) event + + // Ensure stake has been escrowed. + #[derive(Debug, Default, cbor::Decode)] + struct TransferEvent { + from: Address, + to: Address, + amount: BaseUnits, + } + + let events: Vec = cbor::from_slice(&tags[0].value).unwrap(); + assert_eq!(events.len(), 1); // Just the escrow event as fee is zero. + let event = &events[0]; + assert_eq!(event.from, keys::alice::address()); + assert_eq!(event.to, *ADDRESS_APP_STAKE_POOL); + assert_eq!(event.amount, BaseUnits::new(1_000, Denomination::NATIVE)); + + // Simulate round advancing as application ID is generated from it. + mock.runtime_header.round += 1; + let ctx = mock.create_ctx_for_runtime::(false); + + // Creating another application should get a different application ID. + let dispatch_result = signer_alice.call(&ctx, "rofl.Create", create.clone()); + let app_id: AppId = cbor::from_value(dispatch_result.result.unwrap()).unwrap(); + assert_eq!( + app_id.to_bech32(), + "rofl1qzxz79xj0jxq07jtd2aysj0yxkxvldcg5vq24pj5" + ); + + // Ensure balances are correct. + let balance = Accounts::get_balance(keys::alice::address(), Denomination::NATIVE).unwrap(); + assert_eq!(balance, 998_000); // Two applications require 2_000 stake in escrow. + let balance = Accounts::get_balance(*ADDRESS_APP_STAKE_POOL, Denomination::NATIVE).unwrap(); + assert_eq!(balance, 2_000); // Two applications require 2_000 stake in escrow. + + // Ensure queries return the right things. + let app_cfg = Module::::get_app(app_id).unwrap(); + assert_eq!( + app_cfg, + types::AppConfig { + id: app_id, + policy: create.policy, + admin: Some(keys::alice::address()), + stake: BaseUnits::new(1_000, Denomination::NATIVE), + } + ); + let instances = Module::::get_instances(app_id).unwrap(); + assert_eq!(instances.len(), 0); + + // Update application. Bob should not be allowed to do it. + let update = types::Update { + id: app_id, + policy: Default::default(), + admin: Some(keys::bob::address()), // Transfer admin to bob. + }; + + let dispatch_result = signer_bob.call(&ctx, "rofl.Update", update.clone()); + assert!(!dispatch_result.result.is_success(), "call should fail"); + let (err_module, err_code) = dispatch_result.result.unwrap_failed(); + assert_eq!(&err_module, "rofl"); + assert_eq!(err_code, 11); // Forbidden. + + // Update application. Alice should be allowed to transfer to Bob. + let dispatch_result = signer_alice.call(&ctx, "rofl.Update", update.clone()); + assert!(dispatch_result.result.is_success(), "call should succeed"); + + // Alice should no longer be allowed to update. + let dispatch_result = signer_alice.call(&ctx, "rofl.Update", update.clone()); + let (err_module, err_code) = dispatch_result.result.unwrap_failed(); + assert_eq!(&err_module, "rofl"); + assert_eq!(err_code, 11); // Forbidden. + + // Ensure queries return the right things. + let app_cfg = Module::::get_app(app_id).unwrap(); + assert_eq!( + app_cfg, + types::AppConfig { + id: app_id, + policy: update.policy, + admin: Some(keys::bob::address()), + stake: BaseUnits::new(1_000, Denomination::NATIVE), + } + ); + + // Remove application. Alice should not be allowed to do it. + let remove = types::Remove { id: app_id }; + + let dispatch_result = signer_alice.call(&ctx, "rofl.Remove", remove.clone()); + let (err_module, err_code) = dispatch_result.result.unwrap_failed(); + assert_eq!(&err_module, "rofl"); + assert_eq!(err_code, 11); // Forbidden. + + // Remove application. Bob should be allowed to do it. + let dispatch_result = signer_bob.call(&ctx, "rofl.Remove", remove.clone()); + assert!(dispatch_result.result.is_success(), "call should succeed"); + + // Ensure balances are correct. + let balance = Accounts::get_balance(keys::alice::address(), Denomination::NATIVE).unwrap(); + assert_eq!(balance, 998_000); // One application requires 1_000 stake in escrow and 1_000 of stake was returned. + let balance = Accounts::get_balance(keys::bob::address(), Denomination::NATIVE).unwrap(); + assert_eq!(balance, 1_000); // Returned stake for one application. + let balance = Accounts::get_balance(*ADDRESS_APP_STAKE_POOL, Denomination::NATIVE).unwrap(); + assert_eq!(balance, 1_000); // One application remains. +} diff --git a/runtime-sdk/src/modules/rofl/types.rs b/runtime-sdk/src/modules/rofl/types.rs new file mode 100644 index 0000000000..73adc3f153 --- /dev/null +++ b/runtime-sdk/src/modules/rofl/types.rs @@ -0,0 +1,88 @@ +use crate::{ + core::{ + common::crypto::{signature, x25519}, + consensus::{beacon::EpochTime, registry}, + }, + crypto::signature::PublicKey, + types::{address::Address, token}, +}; + +use super::{app_id::AppId, policy::AppAuthPolicy}; + +/// Create new ROFL application call. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +pub struct Create { + /// Application authentication policy. + pub policy: AppAuthPolicy, +} + +/// Update an existing ROFL application call. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +pub struct Update { + /// ROFL application identifier. + pub id: AppId, + /// Authentication policy. + pub policy: AppAuthPolicy, + /// Application administrator address. + pub admin: Option
, +} + +/// Remove an existing ROFL application call. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +pub struct Remove { + /// ROFL application identifier. + pub id: AppId, +} + +/// ROFL application configuration. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +#[cfg_attr(test, derive(PartialEq, Eq))] +pub struct AppConfig { + /// ROFL application identifier. + pub id: AppId, + /// Authentication policy. + pub policy: AppAuthPolicy, + /// Application administrator address. + pub admin: Option
, + /// Staked amount. + pub stake: token::BaseUnits, +} + +/// Register ROFL call. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +pub struct Register { + /// ROFL application identifier. + pub app: AppId, + /// Endorsed TEE capability. + pub ect: registry::EndorsedCapabilityTEE, + /// Epoch when the ROFL registration expires if not renewed. + pub expiration: EpochTime, + /// Extra public keys to endorse (e.g. secp256k1 keys). + /// + /// All of these keys need to co-sign the registration transaction to prove ownership. + pub extra_keys: Vec, +} + +/// ROFL registration descriptor. +#[derive(Clone, Debug, Default, PartialEq, Eq, cbor::Encode, cbor::Decode)] +pub struct Registration { + /// Application this enclave is registered for. + pub app: AppId, + /// Identifier of the endorsing node. + pub node_id: signature::PublicKey, + /// Runtime Attestation Key. + pub rak: signature::PublicKey, + /// Runtime Encryption Key. + pub rek: x25519::PublicKey, + /// Epoch when the ROFL registration expires if not renewed. + pub expiration: EpochTime, + /// Extra public keys to endorse (e.g. secp256k1 keys). + pub extra_keys: Vec, +} + +/// Application-related query. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +pub struct AppQuery { + /// ROFL application identifier. + pub id: AppId, +} diff --git a/runtime-sdk/src/runtime.rs b/runtime-sdk/src/runtime.rs index d845a68182..7fe64f884d 100644 --- a/runtime-sdk/src/runtime.rs +++ b/runtime-sdk/src/runtime.rs @@ -16,8 +16,8 @@ use crate::{ crypto, dispatcher, keymanager::{KeyManagerClient, TrustedSigners}, module::{ - BlockHandler, InvariantHandler, MethodHandler, MigrationHandler, ModuleInfoHandler, - TransactionHandler, + BlockHandler, FeeProxyHandler, InvariantHandler, MethodHandler, MigrationHandler, + ModuleInfoHandler, TransactionHandler, }, modules, state::CurrentState, @@ -39,6 +39,10 @@ pub trait Runtime { /// Module that provides the core API. type Core: modules::core::API; + /// Module that provides the accounts API. + type Accounts: modules::accounts::API; + /// Handler for proxy fee payments. + type FeeProxy: FeeProxyHandler = (); /// Supported modules. type Modules: TransactionHandler @@ -174,13 +178,14 @@ pub trait Runtime { )) }); - // Register runtime's methods. + // Create the transaction dispatcher. let dispatcher = dispatcher::Dispatcher::::new( - hi, + state.protocol.clone(), key_manager, state.consensus_verifier.clone(), - state.protocol.clone(), ); + // Register EnclaveRPC methods. + dispatcher.register_enclaverpc(state.rpc_dispatcher); PostInitState { txn_dispatcher: Some(Box::new(dispatcher)), diff --git a/runtime-sdk/src/state.rs b/runtime-sdk/src/state.rs index ce477ee8ac..8648f9a30d 100644 --- a/runtime-sdk/src/state.rs +++ b/runtime-sdk/src/state.rs @@ -11,7 +11,7 @@ use oasis_core_runtime::{common::crypto::hash::Hash, consensus::roothash, storag use crate::{ context::Context, - crypto::random::RootRng, + crypto::{random::RootRng, signature::PublicKey}, event::{Event, EventTag, EventTags}, modules::core::Error, storage::{MKVSStore, NestedStore, OverlayStore, Store}, @@ -174,6 +174,22 @@ impl Environment { .map(|si| si.address_spec.address()) .unwrap_or_default() } + + /// Authenticated caller public key if available. + /// + /// In case there are multiple signers of a transaction, this will return the public key + /// corresponding to the first signer. If there are no signers or if the address specification + /// does not represent a single public key, it returns `None`. + /// + /// # Panics + /// + /// This method will panic if called outside a transaction environment. + pub fn tx_caller_public_key(&self) -> Option { + self.tx_auth_info() + .signer_info + .first() + .and_then(|si| si.address_spec.public_key()) + } } /// Decoded transaction with additional metadata. @@ -627,6 +643,16 @@ impl State { &self.env } + /// Origin environment information. + /// + /// The origin environment is the first non-internal environment in the hierarchy. + pub fn env_origin(&self) -> &Environment { + match self.parent { + Some(ref parent) if self.env.internal => parent.env_origin(), + _ => &self.env, + } + } + /// Returns the nesting level of the current state. pub fn level(&self) -> usize { if let Some(ref parent) = self.parent { @@ -868,6 +894,19 @@ impl CurrentState { Self::with(|state| f(state.env())) } + /// Run a closure with the origin environment of the currently active state. + /// + /// # Panics + /// + /// This method will panic if called outside `CurrentState::enter` or if any transaction methods + /// are called from the closure. + pub fn with_env_origin(f: F) -> R + where + F: FnOnce(&Environment) -> R, + { + Self::with(|state| f(state.env_origin())) + } + /// Start a new transaction by opening a new child state. /// /// # Panics @@ -1407,6 +1446,17 @@ mod test { assert_eq!(env.tx_size(), 888, "environment should be updated"); }); + CurrentState::with_env_origin(|env_origin| { + assert!( + !env_origin.is_check_only(), + "origin environment should be correct" + ); + assert!( + !env_origin.is_transaction(), + "origin environment should be correct" + ); + }); + CurrentState::with_transaction(|| { // Check environment propagation. CurrentState::with_env(|env| { diff --git a/runtime-sdk/src/storage/host.rs b/runtime-sdk/src/storage/host.rs new file mode 100644 index 0000000000..5d255218ae --- /dev/null +++ b/runtime-sdk/src/storage/host.rs @@ -0,0 +1,85 @@ +use std::sync::Arc; + +use anyhow::{anyhow, Result}; + +use crate::{ + core::{ + common::namespace::Namespace, + consensus::{state::roothash::ImmutableState as RoothashState, verifier::Verifier}, + protocol::Protocol, + storage::mkvs, + types::HostStorageEndpoint, + }, + storage, +}; + +/// A store for a specific state root that talks to the runtime host. +pub struct HostStore { + tree: mkvs::Tree, +} + +impl HostStore { + /// Create a new host store for the given host and root. + pub fn new(host: Arc, root: mkvs::Root) -> Self { + let read_syncer = mkvs::sync::HostReadSyncer::new(host, HostStorageEndpoint::Runtime); + let tree = mkvs::Tree::builder() + .with_capacity(10_000, 1024 * 1024) + .with_root(root) + .build(Box::new(read_syncer)); + + Self { tree } + } + + /// Create a new host store for the given host and root at the given round. + /// + /// The corresponding root hash is fetched by looking it up in consensus layer state, verified + /// by the passed verifier to be correct. + pub async fn new_for_round( + host: Arc, + consensus_verifier: &Arc, + id: Namespace, + round: u64, + ) -> Result { + // Fetch latest consensus layer state. + let state = consensus_verifier.latest_state().await?; + // Fetch latest state root for the given namespace. + let roots = tokio::task::spawn_blocking(move || { + let roothash = RoothashState::new(&state); + roothash.round_roots(id, round) + }) + .await?? + .ok_or(anyhow!("root not found"))?; + + Ok(Self::new( + host, + mkvs::Root { + namespace: id, + version: round, + root_type: mkvs::RootType::State, + hash: roots.state_root, + }, + )) + } +} + +impl storage::Store for HostStore { + fn get(&self, key: &[u8]) -> Option> { + self.tree.get(key).unwrap() + } + + fn insert(&mut self, key: &[u8], value: &[u8]) { + self.tree.insert(key, value).unwrap(); + } + + fn remove(&mut self, key: &[u8]) { + self.tree.remove(key).unwrap(); + } + + fn iter(&self) -> Box { + Box::new(self.tree.iter()) + } + + fn prefetch_prefixes(&mut self, prefixes: Vec, limit: u16) { + self.tree.prefetch_prefixes(&prefixes, limit).unwrap(); + } +} diff --git a/runtime-sdk/src/storage/mod.rs b/runtime-sdk/src/storage/mod.rs index ce10dbfa64..0c19a782fc 100644 --- a/runtime-sdk/src/storage/mod.rs +++ b/runtime-sdk/src/storage/mod.rs @@ -3,6 +3,7 @@ use oasis_core_runtime::storage::mkvs::Iterator; pub mod confidential; mod hashed; +mod host; mod mkvs; mod overlay; mod prefix; @@ -92,6 +93,7 @@ impl Store for Box { pub use confidential::{ConfidentialStore, Error as ConfidentialStoreError}; pub use hashed::HashedStore; +pub use host::HostStore; pub use mkvs::MKVSStore; pub use overlay::OverlayStore; pub use prefix::PrefixStore; diff --git a/runtime-sdk/src/subcall.rs b/runtime-sdk/src/subcall.rs index 51e5def30a..94fd42e1df 100644 --- a/runtime-sdk/src/subcall.rs +++ b/runtime-sdk/src/subcall.rs @@ -154,6 +154,7 @@ pub fn call( gas: info.max_gas, // Propagate consensus message limit. consensus_messages: CurrentState::with(|state| state.emitted_messages_max(ctx)), + proxy: None, }, ..Default::default() }, diff --git a/runtime-sdk/src/testing/mock.rs b/runtime-sdk/src/testing/mock.rs index 6c774d0ca8..c35c16e83f 100644 --- a/runtime-sdk/src/testing/mock.rs +++ b/runtime-sdk/src/testing/mock.rs @@ -36,6 +36,8 @@ impl Runtime for EmptyRuntime { type Core = modules::core::Module; + type Accounts = modules::accounts::Module; + type Modules = modules::core::Module; fn genesis_state() -> ::Genesis { @@ -165,6 +167,7 @@ pub fn transaction() -> transaction::Transaction { amount: Default::default(), gas: 1_000_000, consensus_messages: 32, + ..Default::default() }, ..Default::default() }, @@ -185,6 +188,7 @@ impl Default for CallOptions { amount: Default::default(), gas: 1_000_000, consensus_messages: 0, + ..Default::default() }, } } diff --git a/runtime-sdk/src/types/address.rs b/runtime-sdk/src/types/address.rs index baddd7b269..3c66658c04 100644 --- a/runtime-sdk/src/types/address.rs +++ b/runtime-sdk/src/types/address.rs @@ -5,7 +5,10 @@ use bech32::{Bech32, Hrp}; use thiserror::Error; use oasis_core_runtime::{ - common::{crypto::hash::Hash, namespace::Namespace}, + common::{ + crypto::{hash::Hash, signature::PublicKey as ConsensusPublicKey}, + namespace::Namespace, + }, consensus::address::Address as ConsensusAddress, }; @@ -58,6 +61,17 @@ pub enum SignatureAddressSpec { } impl SignatureAddressSpec { + /// Try to construct an authentication/address derivation specification from the given public + /// key. In case the given scheme is not supported, it returns `None`. + pub fn try_from_pk(pk: &PublicKey) -> Option { + match pk { + PublicKey::Ed25519(pk) => Some(Self::Ed25519(pk.clone())), + PublicKey::Secp256k1(pk) => Some(Self::Secp256k1Eth(pk.clone())), + PublicKey::Sr25519(pk) => Some(Self::Sr25519(pk.clone())), + _ => None, + } + } + /// Public key of the authentication/address derivation specification. pub fn public_key(&self) -> PublicKey { match self { @@ -91,7 +105,7 @@ impl Address { a[..ADDRESS_VERSION_SIZE].copy_from_slice(&[version]); a[ADDRESS_VERSION_SIZE..].copy_from_slice(h.truncated(ADDRESS_DATA_SIZE)); - Address(a) + Self(a) } /// Tries to create a new address from raw bytes. @@ -103,7 +117,7 @@ impl Address { let mut a = [0; ADDRESS_SIZE]; a.copy_from_slice(data); - Ok(Address(a)) + Ok(Self(a)) } /// Convert the address into raw bytes. @@ -113,12 +127,12 @@ impl Address { /// Creates a new address for a specific module and kind. pub fn from_module(module: &str, kind: &str) -> Self { - Address::from_module_raw(module, kind.as_bytes()) + Self::from_module_raw(module, kind.as_bytes()) } /// Creates a new address for a specific module and raw kind. pub fn from_module_raw(module: &str, kind: &[u8]) -> Self { - Address::new( + Self::new( ADDRESS_V0_MODULE_CONTEXT, ADDRESS_V0_VERSION, &[module.as_bytes(), b".", kind].concat(), @@ -127,7 +141,7 @@ impl Address { /// Creates a new runtime address. pub fn from_runtime_id(id: &Namespace) -> Self { - Address::new( + Self::new( ADDRESS_RUNTIME_V0_CONTEXT, ADDRESS_RUNTIME_V0_VERSION, id.as_ref(), @@ -137,19 +151,19 @@ impl Address { /// Creates a new address from a public key. pub fn from_sigspec(spec: &SignatureAddressSpec) -> Self { match spec { - SignatureAddressSpec::Ed25519(pk) => Address::new( + SignatureAddressSpec::Ed25519(pk) => Self::new( ADDRESS_V0_ED25519_CONTEXT, ADDRESS_V0_VERSION, pk.as_bytes(), ), - SignatureAddressSpec::Secp256k1Eth(pk) => Address::new( + SignatureAddressSpec::Secp256k1Eth(pk) => Self::new( ADDRESS_V0_SECP256K1ETH_CONTEXT, ADDRESS_V0_VERSION, // Use a scheme such that we can compute Secp256k1 addresses from Ethereum // addresses as this makes things more interoperable. &pk.to_eth_address(), ), - SignatureAddressSpec::Sr25519(pk) => Address::new( + SignatureAddressSpec::Sr25519(pk) => Self::new( ADDRESS_V0_SR25519_CONTEXT, ADDRESS_V0_VERSION, pk.as_bytes(), @@ -160,18 +174,26 @@ impl Address { /// Creates a new address from a multisig configuration. pub fn from_multisig(config: multisig::Config) -> Self { let config_vec = cbor::to_vec(config); - Address::new(ADDRESS_V0_MULTISIG_CONTEXT, ADDRESS_V0_VERSION, &config_vec) + Self::new(ADDRESS_V0_MULTISIG_CONTEXT, ADDRESS_V0_VERSION, &config_vec) } /// Creates a new address from an Ethereum-compatible address. pub fn from_eth(eth_address: &[u8]) -> Self { - Address::new( + Self::new( ADDRESS_V0_SECP256K1ETH_CONTEXT, ADDRESS_V0_VERSION, eth_address, ) } + /// Creates a new address from a consensus-layer Ed25519 public key. + /// + /// This is a convenience wrapper and the same result can be obtained by going via the + /// `from_sigspec` method using the same Ed25519 public key. + pub fn from_consensus_pk(pk: &ConsensusPublicKey) -> Self { + Self::from_bytes(ConsensusAddress::from_pk(pk).as_ref()).unwrap() + } + /// Tries to create a new address from Bech32-encoded string. pub fn from_bech32(data: &str) -> Result { let (hrp, data) = bech32::decode(data).map_err(|_| Error::MalformedAddress)?; @@ -179,7 +201,7 @@ impl Address { return Err(Error::MalformedAddress); } - Address::from_bytes(&data) + Self::from_bytes(&data) } /// Converts an address to Bech32 representation. @@ -203,8 +225,8 @@ impl TryFrom<&[u8]> for Address { } impl From<&'static str> for Address { - fn from(s: &'static str) -> Address { - Address::from_bech32(s).unwrap() + fn from(s: &'static str) -> Self { + Self::from_bech32(s).unwrap() } } @@ -252,6 +274,17 @@ impl cbor::Decode for Address { } } +impl slog::Value for Address { + fn serialize( + &self, + _record: &slog::Record<'_>, + key: slog::Key, + serializer: &mut dyn slog::Serializer, + ) -> slog::Result { + serializer.emit_str(key, &self.to_bech32()) + } +} + impl From
for ConsensusAddress { fn from(addr: Address) -> ConsensusAddress { ConsensusAddress::from(&addr.0) @@ -260,6 +293,7 @@ impl From
for ConsensusAddress { #[cfg(test)] mod test { + use base64::prelude::*; use bech32::Bech32m; use super::*; @@ -391,6 +425,21 @@ mod test { ); } + #[test] + fn test_address_from_consensus_pk() { + // Same test vector as in `test_address_ed25519`. + let pk: ConsensusPublicKey = BASE64_STANDARD + .decode("utrdHlX///////////////////////////////////8=") + .unwrap() + .into(); + + let addr = Address::from_consensus_pk(&pk); + assert_eq!( + addr.to_bech32(), + "oasis1qryqqccycvckcxp453tflalujvlf78xymcdqw4vz" + ); + } + #[test] fn test_address_raw() { let eth_address = hex::decode("dce075e1c39b1ae0b75d554558b6451a226ffe00").unwrap(); diff --git a/runtime-sdk/src/types/token.rs b/runtime-sdk/src/types/token.rs index 7343589ec5..683429ce75 100644 --- a/runtime-sdk/src/types/token.rs +++ b/runtime-sdk/src/types/token.rs @@ -91,7 +91,7 @@ pub struct BaseUnits(pub u128, pub Denomination); impl BaseUnits { /// Creates a new token amount of the given denomination. - pub fn new(amount: u128, denomination: Denomination) -> Self { + pub const fn new(amount: u128, denomination: Denomination) -> Self { BaseUnits(amount, denomination) } diff --git a/runtime-sdk/src/types/transaction.rs b/runtime-sdk/src/types/transaction.rs index 82c10b56be..648cb79dda 100644 --- a/runtime-sdk/src/types/transaction.rs +++ b/runtime-sdk/src/types/transaction.rs @@ -5,7 +5,7 @@ use thiserror::Error; use crate::{ crypto::{ multisig, - signature::{self, PublicKey, Signature}, + signature::{self, PublicKey, Signature, Signer}, }, types::{ address, @@ -26,10 +26,14 @@ pub enum Error { UnsupportedVersion, #[error("malformed transaction: {0}")] MalformedTransaction(anyhow::Error), + #[error("signer not found in transaction")] + SignerNotFound, + #[error("failed to sign: {0}")] + FailedToSign(#[from] signature::Error), } /// A container for data that authenticates a transaction. -#[derive(Clone, Debug, cbor::Encode, cbor::Decode)] +#[derive(Clone, Default, Debug, cbor::Encode, cbor::Decode)] pub enum AuthProof { /// For _signature_ authentication. #[cbor(rename = "signature")] @@ -41,6 +45,11 @@ pub enum AuthProof { /// module must handle. The scheme name must not be empty. #[cbor(rename = "module")] Module(String), + + /// A non-serializable placeholder value. + #[cbor(skip)] + #[default] + Invalid, } /// An unverified signed transaction. @@ -81,6 +90,96 @@ impl UnverifiedTransaction { } } +/// Transaction signer. +pub struct TransactionSigner { + auth_info: AuthInfo, + ut: UnverifiedTransaction, +} + +impl TransactionSigner { + /// Construct a new transaction signer for the given transaction. + pub fn new(tx: Transaction) -> Self { + let mut ts = Self { + auth_info: tx.auth_info.clone(), + ut: UnverifiedTransaction(cbor::to_vec(tx), vec![]), + }; + ts.allocate_proofs(); + + ts + } + + /// Allocate proof structures based on the specified authentication info in the transaction. + fn allocate_proofs(&mut self) { + if !self.ut.1.is_empty() { + return; + } + + // Allocate proof slots. + self.ut + .1 + .resize_with(self.auth_info.signer_info.len(), Default::default); + + for (si, ap) in self.auth_info.signer_info.iter().zip(self.ut.1.iter_mut()) { + match (&si.address_spec, ap) { + (AddressSpec::Multisig(cfg), ap) => { + // Allocate multisig slots. + *ap = AuthProof::Multisig(vec![None; cfg.signers.len()]); + } + _ => continue, + } + } + } + + /// Sign the transaction and append the signature. + /// + /// The signer must be specified in the `auth_info` field. + pub fn append_sign(&mut self, signer: &S) -> Result<(), Error> + where + S: Signer + ?Sized, + { + let ctx = signature::context::get_chain_context_for(SIGNATURE_CONTEXT_BASE); + let signature = signer.sign(&ctx, &self.ut.0)?; + + let mut matched = false; + for (si, ap) in self.auth_info.signer_info.iter().zip(self.ut.1.iter_mut()) { + match (&si.address_spec, ap) { + (AddressSpec::Signature(spec), ap) => { + if spec.public_key() != signer.public_key() { + continue; + } + + matched = true; + *ap = AuthProof::Signature(signature.clone()); + } + (AddressSpec::Multisig(cfg), AuthProof::Multisig(ref mut sigs)) => { + for (i, mss) in cfg.signers.iter().enumerate() { + if mss.public_key != signer.public_key() { + continue; + } + + matched = true; + sigs[i] = Some(signature.clone()); + } + } + _ => { + return Err(Error::MalformedTransaction(anyhow!( + "malformed address_spec" + ))) + } + } + } + if !matched { + return Err(Error::SignerNotFound); + } + Ok(()) + } + + /// Finalize the signing process and return the (signed) unverified transaction. + pub fn finalize(self) -> UnverifiedTransaction { + self.ut + } +} + /// Transaction. #[derive(Clone, Debug, cbor::Encode, cbor::Decode)] #[cbor(no_default)] @@ -95,6 +194,76 @@ pub struct Transaction { } impl Transaction { + /// Create a new (unsigned) transaction. + pub fn new(method: &str, body: B) -> Self + where + B: cbor::Encode, + { + Self { + version: LATEST_TRANSACTION_VERSION, + call: Call { + format: CallFormat::Plain, + method: method.to_string(), + body: cbor::to_value(body), + ..Default::default() + }, + auth_info: Default::default(), + } + } + + /// Prepare this transaction for signing. + pub fn prepare_for_signing(self) -> TransactionSigner { + TransactionSigner::new(self) + } + + /// Maximum amount of gas that the transaction can use. + pub fn fee_gas(&self) -> u64 { + self.auth_info.fee.gas + } + + /// Set maximum amount of gas that the transaction can use. + pub fn set_fee_gas(&mut self, gas: u64) { + self.auth_info.fee.gas = gas; + } + + /// Amount of fee to pay for transaction execution. + pub fn fee_amount(&self) -> &token::BaseUnits { + &self.auth_info.fee.amount + } + + /// Set amount of fee to pay for transaction execution. + pub fn set_fee_amount(&mut self, amount: token::BaseUnits) { + self.auth_info.fee.amount = amount; + } + + /// Set a proxy for paying the transaction fee. + pub fn set_fee_proxy(&mut self, module: &str, id: &[u8]) { + self.auth_info.fee.proxy = Some(FeeProxy { + module: module.to_string(), + id: id.to_vec(), + }); + } + + /// Append a new transaction signer information to the transaction. + pub fn append_signer_info(&mut self, address_spec: AddressSpec, nonce: u64) { + self.auth_info.signer_info.push(SignerInfo { + address_spec, + nonce, + }) + } + + /// Append a new transaction signer information with a signature address specification to the + /// transaction. + pub fn append_auth_signature(&mut self, spec: SignatureAddressSpec, nonce: u64) { + self.append_signer_info(AddressSpec::Signature(spec), nonce); + } + + /// Append a new transaction signer information with a multisig address specification to the + /// transaction. + pub fn append_auth_multisig(&mut self, cfg: multisig::Config, nonce: u64) { + self.append_signer_info(AddressSpec::Multisig(cfg), nonce); + } + /// Perform basic validation on the transaction. pub fn validate_basic(&self) -> Result<(), Error> { if self.version != LATEST_TRANSACTION_VERSION { @@ -179,6 +348,9 @@ pub struct Fee { /// number of per-batch messages can be emitted. #[cbor(optional)] pub consensus_messages: u32, + /// Proxy which has authorized the fees to be paid. + #[cbor(optional)] + pub proxy: Option, } impl Fee { @@ -191,6 +363,15 @@ impl Fee { } } +/// Information about a fee proxy. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +pub struct FeeProxy { + /// Module that will handle the proxy payment. + pub module: String, + /// Module-specific identifier that will handle fee payments for the transaction signer. + pub id: Vec, +} + /// A caller address. #[derive(Clone, Debug, cbor::Encode, cbor::Decode)] pub enum CallerAddress { @@ -238,6 +419,14 @@ pub enum AddressSpec { } impl AddressSpec { + /// Returns the public key when the address spec represents a single public key. + pub fn public_key(&self) -> Option { + match self { + AddressSpec::Signature(spec) => Some(spec.public_key()), + _ => None, + } + } + /// Derives the address. pub fn address(&self) -> Address { match self { @@ -284,6 +473,9 @@ impl AddressSpec { (_, AuthProof::Module(_)) => Err(Error::MalformedTransaction(anyhow!( "module-controlled decoding flag in auth proof list" ))), + (_, AuthProof::Invalid) => Err(Error::MalformedTransaction(anyhow!( + "invalid auth proof in list" + ))), } } } @@ -344,6 +536,21 @@ impl CallResult { pub fn is_success(&self) -> bool { !matches!(self, CallResult::Failed { .. }) } + + /// Transforms `CallResult` into `anyhow::Result`, mapping `Ok(v)` and `Unknown(v)` + /// to `Ok(v)` and `Failed` to `Err`. + pub fn ok(self) -> anyhow::Result { + match self { + Self::Ok(v) | Self::Unknown(v) => Ok(v), + Self::Failed { + module, + code, + message, + } => Err(anyhow!( + "call failed: module={module} code={code}: {message}" + )), + } + } } #[cfg(any(test, feature = "test"))] @@ -359,6 +566,13 @@ impl CallResult { } } + pub fn unwrap_failed(self) -> (String, u32) { + match self { + Self::Ok(_) | Self::Unknown(_) => panic!("call result indicates success"), + Self::Failed { module, code, .. } => (module, code), + } + } + pub fn into_call_result(self) -> Option { Some(match self { Self::Ok(v) => crate::module::CallResult::Ok(v), @@ -384,17 +598,12 @@ mod test { #[test] fn test_fee_gas_price() { - let fee = Fee { - amount: Default::default(), - gas: 0, - consensus_messages: 0, - }; + let fee = Fee::default(); assert_eq!(0, fee.gas_price(), "empty fee - gas price should be zero",); let fee = Fee { - amount: Default::default(), gas: 100, - consensus_messages: 0, + ..Default::default() }; assert_eq!( 0, @@ -405,14 +614,14 @@ mod test { let fee = Fee { amount: BaseUnits::new(1_000, Denomination::NATIVE), gas: 0, - consensus_messages: 0, + ..Default::default() }; assert_eq!(0, fee.gas_price(), "empty fee 0 - gas price should be zero",); let fee = Fee { amount: BaseUnits::new(1_000, Denomination::NATIVE), gas: 10_000, - consensus_messages: 0, + ..Default::default() }; assert_eq!( 0, @@ -423,7 +632,7 @@ mod test { let fee = Fee { amount: BaseUnits::new(1_000, Denomination::NATIVE), gas: 500, - consensus_messages: 0, + ..Default::default() }; assert_eq!(2, fee.gas_price(), "non empty fee - gas price should match"); } diff --git a/tests/contracts/hello/Cargo.lock b/tests/contracts/hello/Cargo.lock index 0bd6970acb..411bee9cc6 100644 --- a/tests/contracts/hello/Cargo.lock +++ b/tests/contracts/hello/Cargo.lock @@ -1808,6 +1808,7 @@ name = "oasis-runtime-sdk" version = "0.9.0" dependencies = [ "anyhow", + "async-trait", "base64 0.22.1", "bech32", "byteorder", @@ -1828,6 +1829,7 @@ dependencies = [ "once_cell", "p256", "p384", + "rand", "rand_core", "schnorrkel", "sha2 0.10.8", diff --git a/tests/download-artifacts.sh b/tests/download-artifacts.sh index 72e41390d5..8dd0542d91 100755 --- a/tests/download-artifacts.sh +++ b/tests/download-artifacts.sh @@ -91,11 +91,13 @@ if [ -n "$BUILD_NUMBER" ]; then cd "$TESTS_DIR/untracked/buildkite-$BUILD_NUMBER" KEY_MANAGER_RUNTIME_JOB_ID=$(jq <"$BUILD_NUMBER.json" -r '.jobs[] | select(.name == "Build runtimes") | .id') KEY_MANAGER_RUNTIME_ARTIFACTS_JSON=$(curl -sf "https://buildkite.com/organizations/$ORGANIZATION/pipelines/$PIPELINE/builds/$BUILD_NUMBER/jobs/$KEY_MANAGER_RUNTIME_JOB_ID/artifacts") - SIMPLE_KEYMANAGER_URL=$(printf '%s' "$KEY_MANAGER_RUNTIME_ARTIFACTS_JSON" | jq -r '.[] | select(.path == "simple-keymanager") | .url') + SIMPLE_KEYMANAGER_URL=$(printf '%s' "$KEY_MANAGER_RUNTIME_ARTIFACTS_JSON" | jq -r '.[] | select(.path == "simple-keymanager.mocksgx") | .url') + SIMPLE_KEYMANAGER_SGXS_URL=$(printf '%s' "$KEY_MANAGER_RUNTIME_ARTIFACTS_JSON" | jq -r '.[] | select(.path == "simple-keymanager.sgxs") | .url') echo "### Downloading simple-keymanager from Buildkite build $BUILD_NUMBER..." curl -fLo simple-keymanager "https://buildkite.com$SIMPLE_KEYMANAGER_URL" chmod +x simple-keymanager + curl -fLo simple-keymanager.sgxs "https://buildkite.com$SIMPLE_KEYMANAGER_SGXS_URL" ) fi fi diff --git a/tests/e2e/evm/fixture.go b/tests/e2e/evm/fixture.go index dc36990234..d6a8cf7bd4 100644 --- a/tests/e2e/evm/fixture.go +++ b/tests/e2e/evm/fixture.go @@ -4,10 +4,12 @@ import ( "github.com/oasisprotocol/oasis-core/go/common/quantity" "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/oasis" staking "github.com/oasisprotocol/oasis-core/go/staking/api" + + "github.com/oasisprotocol/oasis-sdk/tests/e2e/scenario" ) // RuntimeFixture prepares the runtime fixture for the EVM tests. -func RuntimeFixture(ff *oasis.NetworkFixture) { +func RuntimeFixture(_ *scenario.RuntimeScenario, ff *oasis.NetworkFixture) { // The EVM runtime has 110_000 TEST tokens already minted internally. Since we connect it to the // consensus layer (via the consensus module), we should make sure that the runtime's account in // the consensus layer also has a similar amount as otherwise the delegation tests will fail. diff --git a/tests/e2e/rofl/fixture.go b/tests/e2e/rofl/fixture.go new file mode 100644 index 0000000000..b70b6d138c --- /dev/null +++ b/tests/e2e/rofl/fixture.go @@ -0,0 +1,30 @@ +package rofl + +import ( + "github.com/oasisprotocol/oasis-core/go/common/quantity" + "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/oasis" + "github.com/oasisprotocol/oasis-core/go/runtime/bundle/component" + staking "github.com/oasisprotocol/oasis-core/go/staking/api" + + "github.com/oasisprotocol/oasis-sdk/tests/e2e/scenario" +) + +// RuntimeFixture prepares the runtime fixture for ROFL tests. +func RuntimeFixture(sc *scenario.RuntimeScenario, ff *oasis.NetworkFixture) { + // Add ROFL component. + ff.Runtimes[1].Deployments[0].Components = append(ff.Runtimes[1].Deployments[0].Components, oasis.ComponentCfg{ + Kind: component.ROFL, + Binaries: sc.ResolveRuntimeBinaries(roflBinaryName), + }) + + // The runtime has 110_000 TEST tokens already minted internally. Since we connect it to the + // consensus layer (via the consensus module), we should make sure that the runtime's account in + // the consensus layer also has a similar amount. + runtimeAddress := staking.NewRuntimeAddress(ff.Runtimes[1].ID) + _ = ff.Network.StakingGenesis.TotalSupply.Add(quantity.NewFromUint64(110_000)) + ff.Network.StakingGenesis.Ledger[runtimeAddress] = &staking.Account{ + General: staking.GeneralAccount{ + Balance: *quantity.NewFromUint64(110_000), + }, + } +} diff --git a/tests/e2e/rofl/oracle.go b/tests/e2e/rofl/oracle.go new file mode 100644 index 0000000000..88a9c6951a --- /dev/null +++ b/tests/e2e/rofl/oracle.go @@ -0,0 +1,67 @@ +package rofl + +import ( + "context" + "fmt" + + "github.com/oasisprotocol/oasis-core/go/common/cbor" + "github.com/oasisprotocol/oasis-core/go/common/quantity" + + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/client" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/types" + + "github.com/oasisprotocol/oasis-sdk/tests/e2e/scenario" +) + +type oracleEventDecoder struct{} + +type oracleObservation struct { + Value quantity.Quantity `json:"value"` + Timestamp uint64 `json:"ts"` +} + +type oracleEvent struct { + ValueUpdated *oracleObservation +} + +// DecodeEvent implements client.EventDecoder. +func (oed *oracleEventDecoder) DecodeEvent(event *types.Event) ([]client.DecodedEvent, error) { + if event.Module != "oracle" { + return nil, nil + } + var events []client.DecodedEvent + switch event.Code { + case 1: + var evs []*oracleObservation + if err := cbor.Unmarshal(event.Value, &evs); err != nil { + return nil, fmt.Errorf("decode rofl app created event value: %w", err) + } + for _, ev := range evs { + events = append(events, &oracleEvent{ValueUpdated: ev}) + } + default: + return nil, fmt.Errorf("invalid oracle event code: %v", event.Code) + } + return events, nil +} + +// OracleTest tests basic example ROFL application functionality. +func OracleTest(ctx context.Context, env *scenario.Env) error { + env.Logger.Info("waiting for the oracle to publish a value") + + ch, err := env.Client.WatchEvents(ctx, []client.EventDecoder{&oracleEventDecoder{}}, false) + if err != nil { + return err + } + + oe, err := scenario.WaitForRuntimeEventUntil[*oracleEvent](ctx, ch, func(oe *oracleEvent) bool { + return oe.ValueUpdated != nil + }) + if err != nil { + return err + } + + env.Logger.Info("oracle published a value", "value", oe.ValueUpdated) + + return nil +} diff --git a/tests/e2e/rofl/rofl.go b/tests/e2e/rofl/rofl.go new file mode 100644 index 0000000000..5c908d2dcc --- /dev/null +++ b/tests/e2e/rofl/rofl.go @@ -0,0 +1,18 @@ +// Package rofl implements the E2E tests for ROFL. +package rofl + +import ( + "github.com/oasisprotocol/oasis-sdk/tests/e2e/scenario" +) + +const ( + ronlBinaryName = "test-runtime-components-ronl" + roflBinaryName = "test-runtime-components-rofl" +) + +// Runtime is the rofl module test. +var Runtime = scenario.NewRuntimeScenario(ronlBinaryName, []scenario.RunTestFunction{ + OracleTest, + CreateUpdateRemoveTest, + QueryTest, +}, scenario.WithCustomFixture(RuntimeFixture)) diff --git a/tests/e2e/rofl/tests.go b/tests/e2e/rofl/tests.go new file mode 100644 index 0000000000..2f9130c0b1 --- /dev/null +++ b/tests/e2e/rofl/tests.go @@ -0,0 +1,139 @@ +package rofl + +import ( + "context" + "fmt" + + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/client" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/accounts" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/rofl" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/testing" + + "github.com/oasisprotocol/oasis-sdk/tests/e2e/scenario" +) + +// CreateUpdateRemoveTest tests application create, update and remove calls. +func CreateUpdateRemoveTest(ctx context.Context, env *scenario.Env) error { + ac := accounts.NewV1(env.Client) + rf := rofl.NewV1(env.Client) + + // Start watching ROFL events. + ch, err := env.Client.WatchEvents(ctx, []client.EventDecoder{rf}, false) + if err != nil { + return err + } + + // Create an application. + nonce, err := ac.Nonce(ctx, client.RoundLatest, testing.Alice.Address) + if err != nil { + return fmt.Errorf("failed to get nonce: %w", err) + } + + policy := rofl.AppAuthPolicy{ + Fees: rofl.FeePolicyEndorsingNodePays, + } + + tb := rf.Create(policy). + SetFeeGas(110_000). + AppendAuthSignature(testing.Alice.SigSpec, nonce) + _ = tb.AppendSign(ctx, testing.Alice.Signer) + var appID rofl.AppID + if err = tb.SubmitTx(ctx, &appID); err != nil { + return fmt.Errorf("failed to create application: %w", err) + } + + env.Logger.Info("waiting for AppCreated event", "app_id", appID) + + ev, err := scenario.WaitForNextRuntimeEvent[*rofl.Event](ctx, ch) + if err != nil { + return err + } + if ev.AppCreated == nil || ev.AppCreated.ID != appID { + return fmt.Errorf("expected rofl.AppCreated event to be emitted") + } + + // Update an application (change admin). + tb = rf.Update(appID, policy, &testing.Dave.Address). + SetFeeGas(110_000). + AppendAuthSignature(testing.Alice.SigSpec, nonce+1) + _ = tb.AppendSign(ctx, testing.Alice.Signer) + if err = tb.SubmitTx(ctx, nil); err != nil { + return fmt.Errorf("failed to update application: %w", err) + } + + env.Logger.Info("waiting for AppUpdated event", "app_id", appID) + + ev, err = scenario.WaitForNextRuntimeEvent[*rofl.Event](ctx, ch) + if err != nil { + return err + } + if ev.AppUpdated == nil || ev.AppUpdated.ID != appID { + return fmt.Errorf("expected rofl.AppUpdated event to be emitted") + } + + // Remove an application. + nonce, err = ac.Nonce(ctx, client.RoundLatest, testing.Dave.Address) + if err != nil { + return fmt.Errorf("failed to get nonce: %w", err) + } + + tb = rf.Remove(appID). + SetFeeGas(11_000). + AppendAuthSignature(testing.Dave.SigSpec, nonce) + _ = tb.AppendSign(ctx, testing.Dave.Signer) + if err = tb.SubmitTx(ctx, nil); err != nil { + return fmt.Errorf("failed to remove application: %w", err) + } + + env.Logger.Info("waiting for AppRemoved event", "app_id", appID) + + ev, err = scenario.WaitForNextRuntimeEvent[*rofl.Event](ctx, ch) + if err != nil { + return err + } + if ev.AppRemoved == nil || ev.AppRemoved.ID != appID { + return fmt.Errorf("expected rofl.AppRemoved event to be emitted") + } + + return nil +} + +// QueryTest tests that queries work correctly. +func QueryTest(ctx context.Context, env *scenario.Env) error { + rf := rofl.NewV1(env.Client) + + // Derive the AppID for the example oracle ROFL application that is registered in genesis. + exampleAppID := rofl.NewAppIDGlobalName("example") + + appCfg, err := rf.App(ctx, client.RoundLatest, exampleAppID) + if err != nil { + return err + } + + env.Logger.Info("retrieved application config", "app_cfg", appCfg) + + if appCfg.ID != exampleAppID { + return fmt.Errorf("expected app ID '%s', got '%s'", exampleAppID, appCfg.ID) + } + + instances, err := rf.AppInstances(ctx, client.RoundLatest, exampleAppID) + if err != nil { + return err + } + + for _, ins := range instances { + env.Logger.Info("retrieved application instance", + "app", ins.App, + "node_id", ins.NodeID, + "rak", ins.RAK, + "expiration", ins.Expiration, + ) + } + + // There should be 3 instances, one for each compute node. + if expected := 3; len(instances) != expected { + return fmt.Errorf("expected %d application instances, got %d", expected, len(instances)) + } + + return nil +} diff --git a/tests/e2e/scenario/events.go b/tests/e2e/scenario/events.go index be34752620..825b8e4705 100644 --- a/tests/e2e/scenario/events.go +++ b/tests/e2e/scenario/events.go @@ -1,6 +1,7 @@ package scenario import ( + "context" "fmt" "time" @@ -15,6 +16,47 @@ import ( const timeout = 2 * time.Minute +// WaitForNextRuntimeEvent waits for the next event of the given kind and returns it. +// +// All of the other events are discarded. +func WaitForNextRuntimeEvent[T client.DecodedEvent](ctx context.Context, ch <-chan *client.BlockEvents) (T, error) { + return WaitForRuntimeEventUntil[T](ctx, ch, func(T) bool { return true }) +} + +// WaitForRuntimeEventUntil waits for the event of the given kind to satistfy the given condition +// and then returns the event. +// +// All of the other events are discarded. +func WaitForRuntimeEventUntil[T client.DecodedEvent]( + ctx context.Context, + ch <-chan *client.BlockEvents, + condFn func(T) bool, +) (T, error) { + var empty T + for { + select { + case <-ctx.Done(): + return empty, ctx.Err() + case bev := <-ch: + if bev == nil { + return empty, fmt.Errorf("event channel closed") + } + + for _, ev := range bev.Events { + re, ok := ev.(T) + if !ok { + continue + } + + if !condFn(re) { + continue + } + return re, nil + } + } + } +} + func EnsureStakingEvent(log *logging.Logger, ch <-chan *staking.Event, check func(*staking.Event) bool) error { log.Info("waiting for expected staking event...") for { diff --git a/tests/e2e/scenario/scenario.go b/tests/e2e/scenario/scenario.go index 0287a54137..4f7a82d01e 100644 --- a/tests/e2e/scenario/scenario.go +++ b/tests/e2e/scenario/scenario.go @@ -17,6 +17,7 @@ import ( "github.com/oasisprotocol/oasis-core/go/common/logging" "github.com/oasisprotocol/oasis-core/go/common/node" "github.com/oasisprotocol/oasis-core/go/common/quantity" + "github.com/oasisprotocol/oasis-core/go/common/sgx" "github.com/oasisprotocol/oasis-core/go/common/version" consensus "github.com/oasisprotocol/oasis-core/go/consensus/api" "github.com/oasisprotocol/oasis-core/go/keymanager/secrets" @@ -41,9 +42,9 @@ const ( cfgRuntimeBinaryDirDefault = "runtime.binary_dir.default" cfgRuntimeLoader = "runtime.loader" cfgRuntimeProvisioner = "runtime.provisioner" - cfgIasMock = "ias.mock" - cfgKeymanagerBinary = "keymanager.binary" + // keymanagerBinary is the name of the key manager runtime binary. + keymanagerBinary = "simple-keymanager" ) var ( @@ -95,7 +96,7 @@ type RuntimeScenario struct { type Option func(*RuntimeScenario) // FixtureModifierFunc is a function that performs arbitrary modifications to a given fixture. -type FixtureModifierFunc func(*oasis.NetworkFixture) +type FixtureModifierFunc func(*RuntimeScenario, *oasis.NetworkFixture) // WithCustomFixture applies the given fixture modifier function to the runtime scenario fixture. func WithCustomFixture(fm FixtureModifierFunc) Option { @@ -115,8 +116,6 @@ func NewRuntimeScenario(runtimeName string, tests []RunTestFunction, opts ...Opt sc.Flags.String(cfgRuntimeBinaryDirDefault, "../../target/debug", "path to the runtime binaries directory") sc.Flags.String(cfgRuntimeLoader, "../../../oasis-core/target/default/debug/oasis-core-runtime-loader", "path to the runtime loader") - sc.Flags.String(cfgKeymanagerBinary, "", "path to the keymanager binary") - sc.Flags.Bool(cfgIasMock, true, "if mock IAS service should be used") sc.Flags.String(cfgRuntimeProvisioner, "sandboxed", "the runtime provisioner: mock, unconfined, or sandboxed") for _, opt := range opts { @@ -147,20 +146,16 @@ func (sc *RuntimeScenario) Fixture() (*oasis.NetworkFixture, error) { runtimeBinary := sc.RuntimeName runtimeLoader, _ := sc.Flags.GetString(cfgRuntimeLoader) - iasMock, _ := sc.Flags.GetBool(cfgIasMock) runtimeProvisionerRaw, _ := sc.Flags.GetString(cfgRuntimeProvisioner) var runtimeProvisioner runtimeCfg.RuntimeProvisioner if err = runtimeProvisioner.UnmarshalText([]byte(runtimeProvisionerRaw)); err != nil { return nil, err } - keymanagerPath, _ := sc.Flags.GetString(cfgKeymanagerBinary) - usingKeymanager := len(keymanagerPath) > 0 - ff := &oasis.NetworkFixture{ TEE: oasis.TEEFixture{ - Hardware: node.TEEHardwareInvalid, - MrSigner: nil, + Hardware: node.TEEHardwareIntelSGX, // Using mock SGX. + MrSigner: &sgx.FortanixDummyMrSigner, }, Network: oasis.NetworkCfg{ NodeBinary: f.Network.NodeBinary, @@ -171,9 +166,6 @@ func (sc *RuntimeScenario) Fixture() (*oasis.NetworkFixture, error) { Beacon: beacon.ConsensusParameters{ Backend: beacon.BackendInsecure, }, - IAS: oasis.IASCfg{ - Mock: iasMock, - }, StakingGenesis: &api.Genesis{ Parameters: api.ConsensusParameters{ MaxAllowances: 10, @@ -222,10 +214,8 @@ func (sc *RuntimeScenario) Fixture() (*oasis.NetworkFixture, error) { { Components: []oasis.ComponentCfg{ { - Kind: component.RONL, - Binaries: map[node.TEEHardware]string{ - node.TEEHardwareInvalid: keymanagerPath, - }, + Kind: component.RONL, + Binaries: sc.ResolveRuntimeBinaries(keymanagerBinary), }, }, }, @@ -236,7 +226,7 @@ func (sc *RuntimeScenario) Fixture() (*oasis.NetworkFixture, error) { ID: RuntimeID, Kind: registry.KindCompute, Entity: 0, - Keymanager: -1, + Keymanager: 0, Executor: registry.ExecutorParameters{ GroupSize: 2, GroupBackupSize: 1, @@ -273,13 +263,26 @@ func (sc *RuntimeScenario) Fixture() (*oasis.NetworkFixture, error) { Components: []oasis.ComponentCfg{ { Kind: component.RONL, - Binaries: sc.resolveRuntimeBinaries(runtimeBinary), + Binaries: sc.ResolveRuntimeBinaries(runtimeBinary), }, }, }, }, }, }, + KeymanagerPolicies: []oasis.KeymanagerPolicyFixture{ + {Runtime: 0, Serial: 1, MasterSecretRotationInterval: 0}, + }, + Keymanagers: []oasis.KeymanagerFixture{ + { + RuntimeProvisioner: runtimeProvisioner, + Runtime: 0, + Entity: 1, + Policy: 0, + SkipPolicy: false, + PrivatePeerPubKeys: []string{"pr+KLREDcBxpWgQ/80yUrHXbyhDuBDcnxzo3td4JiIo="}, // The deterministic client node pub key. + }, + }, Validators: []oasis.ValidatorFixture{ {Entity: 1, Consensus: oasis.ConsensusFixture{SupplementarySanityInterval: 1}}, {Entity: 1, Consensus: oasis.ConsensusFixture{}}, @@ -302,50 +305,39 @@ func (sc *RuntimeScenario) Fixture() (*oasis.NetworkFixture, error) { }, } - if usingKeymanager { - for i := range ff.Runtimes { - if ff.Runtimes[i].Kind == registry.KindKeyManager { - continue - } - ff.Runtimes[i].Keymanager = 0 - } - ff.KeymanagerPolicies = []oasis.KeymanagerPolicyFixture{ - {Runtime: 0, Serial: 1, MasterSecretRotationInterval: 0}, - } - ff.Keymanagers = []oasis.KeymanagerFixture{ - { - RuntimeProvisioner: runtimeProvisioner, - Runtime: 0, - Entity: 1, - Policy: 0, - SkipPolicy: true, - PrivatePeerPubKeys: []string{"pr+KLREDcBxpWgQ/80yUrHXbyhDuBDcnxzo3td4JiIo="}, // The deterministic client node pub key. - }, - } - } - // Apply fixture modifier function when configured. if sc.fixtureModifier != nil { - sc.fixtureModifier(ff) + sc.fixtureModifier(sc, ff) } return ff, nil } -func (sc *RuntimeScenario) resolveRuntimeBinaries(baseRuntimeBinary string) map[node.TEEHardware]string { +// ResolveRuntimeBinaries expands the given base binary name into per-TEE binary map. +func (sc *RuntimeScenario) ResolveRuntimeBinaries(baseRuntimeBinary string) map[node.TEEHardware]string { binaries := make(map[node.TEEHardware]string) for _, tee := range []node.TEEHardware{ node.TEEHardwareInvalid, node.TEEHardwareIntelSGX, } { - binaries[tee] = sc.resolveRuntimeBinary(baseRuntimeBinary) + binaries[tee] = sc.resolveRuntimeBinary(baseRuntimeBinary, tee) } return binaries } -func (sc *RuntimeScenario) resolveRuntimeBinary(runtimeBinary string) string { +func (sc *RuntimeScenario) resolveRuntimeBinary(runtimeBinary string, tee node.TEEHardware) string { + var runtimeExt string + switch tee { + case node.TEEHardwareInvalid: + runtimeExt = "" + case node.TEEHardwareIntelSGX: + runtimeExt = ".sgxs" + default: + panic(fmt.Errorf("unsupported TEE hardware kind: %s", tee)) + } + path, _ := sc.Flags.GetString(cfgRuntimeBinaryDirDefault) - return filepath.Join(path, runtimeBinary) + return filepath.Join(path, runtimeBinary+runtimeExt) } func (sc *RuntimeScenario) waitNodesSynced(ctx context.Context) error { diff --git a/tests/e2e/scenarios.go b/tests/e2e/scenarios.go index b6ad2e9844..c329980236 100644 --- a/tests/e2e/scenarios.go +++ b/tests/e2e/scenarios.go @@ -8,6 +8,7 @@ import ( "github.com/oasisprotocol/oasis-sdk/tests/e2e/consensusaccounts" "github.com/oasisprotocol/oasis-sdk/tests/e2e/contracts" "github.com/oasisprotocol/oasis-sdk/tests/e2e/evm" + "github.com/oasisprotocol/oasis-sdk/tests/e2e/rofl" sdkScenario "github.com/oasisprotocol/oasis-sdk/tests/e2e/scenario" ) @@ -22,6 +23,7 @@ func RegisterScenarios() error { evm.PlainRuntime, evm.C10lRuntime, contracts.Runtime, + rofl.Runtime, } { if err := cmd.Register(s); err != nil { return err diff --git a/tests/paths.sh b/tests/paths.sh index 88e398df7d..c592d036d8 100644 --- a/tests/paths.sh +++ b/tests/paths.sh @@ -19,4 +19,5 @@ if [ -n "${BUILD_NUMBER:-}" ]; then : "${TEST_NET_RUNNER=$TESTS_DIR/untracked/buildkite-$BUILD_NUMBER/oasis-net-runner}" : "${TEST_RUNTIME_LOADER=$TESTS_DIR/untracked/buildkite-$BUILD_NUMBER/oasis-core-runtime-loader}" : "${TEST_KM_BINARY=$TESTS_DIR/untracked/buildkite-$BUILD_NUMBER/simple-keymanager}" + : "${TEST_KM_SGXS_BINARY=$TESTS_DIR/untracked/buildkite-$BUILD_NUMBER/simple-keymanager.sgxs}" fi diff --git a/tests/run-e2e.sh b/tests/run-e2e.sh index 64e2543bc4..5582224ee6 100755 --- a/tests/run-e2e.sh +++ b/tests/run-e2e.sh @@ -21,7 +21,7 @@ OFF=$'\e[0m' TEST_BASE_DIR=$(mktemp -d -t oasis-sdk-e2e-XXXXXXXXXX) # Kill all dangling processes on exit. -cleanup() { +function cleanup() { printf "${OFF}" pkill -P $$ || true wait || true @@ -45,31 +45,73 @@ if [[ ! -v TEST_NODE_BINARY ]] || [[ ! -v TEST_RUNTIME_LOADER ]]; then "${TESTS_DIR}/download-artifacts.sh" fi +# Run all E2E tests in mock SGX. +export OASIS_UNSAFE_SKIP_AVR_VERIFY=1 +export OASIS_UNSAFE_ALLOW_DEBUG_ENCLAVES=1 +export OASIS_UNSAFE_MOCK_SGX=1 +unset OASIS_UNSAFE_SKIP_KM_POLICY + +# Runtimes. +function build_runtime() { + local name=$1 + shift + local features=("debug-mock-sgx") + local extra_args=() + local output="${name}" + + while [[ $# -gt 0 ]]; do + case $1 in + --output) + output="$2" + shift + shift + ;; + --features) + features+=("$2") + shift + shift + ;; + *) + extra_args+=("$1") + shift + ;; + esac + done + + pushd "${TESTS_DIR}/runtimes/${name}" + local csf=$(IFS=, ; echo "${features[*]}") + cargo build --features ${csf} ${extra_args[@]} + # NOTE: We don't actually need a working SGXS binary as it will never get executed and + # omitting the conversion avoids a dependency on oasis-core-tools. + + cp "${TESTS_DIR}"/../target/debug/test-runtime-${name} "${TEST_BASE_DIR}"/test-runtime-${output} + echo -n ${name} > "${TEST_BASE_DIR}"/test-runtime-${output}.sgxs + # Output deterministic MRENCLAVE. + echo "[${name}] MRENCLAVE: $(echo -n ${name} | sha256sum | cut -d ' ' -f 1)" + popd +} + printf "${CYAN}### Building test simple-keyvalue runtime...${OFF}\n" -cd "${TESTS_DIR}"/runtimes/simple-keyvalue -cargo build -cp "${TESTS_DIR}"/../target/debug/test-runtime-simple-keyvalue "${TEST_BASE_DIR}"/ +build_runtime simple-keyvalue printf "${CYAN}### Building test simple-consensus runtime...${OFF}\n" -cd "${TESTS_DIR}"/runtimes/simple-consensus -cargo build -cp "${TESTS_DIR}"/../target/debug/test-runtime-simple-consensus "${TEST_BASE_DIR}"/ +build_runtime simple-consensus printf "${CYAN}### Building test simple-evm runtime...${OFF}\n" -cd "${TESTS_DIR}"/runtimes/simple-evm -cargo build -cp "${TESTS_DIR}"/../target/debug/test-runtime-simple-evm "${TEST_BASE_DIR}"/ +build_runtime simple-evm printf "${CYAN}### Building test c10l-evm runtime...${OFF}\n" -cd "${TESTS_DIR}"/runtimes/simple-evm -cargo build --features confidential -cp "${TESTS_DIR}"/../target/debug/test-runtime-simple-evm "${TEST_BASE_DIR}"/test-runtime-c10l-evm +build_runtime simple-evm --output c10l-evm --features confidential printf "${CYAN}### Building test simple-contracts runtime...${OFF}\n" -cd "${TESTS_DIR}"/runtimes/simple-contracts -cargo build -cp "${TESTS_DIR}"/../target/debug/test-runtime-simple-contracts "${TEST_BASE_DIR}"/ +build_runtime simple-contracts +printf "${CYAN}### Building test components-ronl runtime...${OFF}\n" +build_runtime components-ronl +printf "${CYAN}### Building test components-rofl runtime...${OFF}\n" +build_runtime components-rofl + +# Test WASM contracts. printf "${CYAN}### Building test hello contract...${OFF}\n" cd "${TESTS_DIR}"/contracts/hello cargo build --target wasm32-unknown-unknown --release @@ -80,16 +122,21 @@ cd "${TESTS_DIR}"/../contract-sdk/specs/token/oas20 cargo build --target wasm32-unknown-unknown --release cp "${TESTS_DIR}"/../contract-sdk/specs/token/oas20/target/wasm32-unknown-unknown/release/oas20.wasm "${TESTS_DIR}"/e2e/contracts/build/ +# Test harness. printf "${CYAN}### Building e2e test harness...${OFF}\n" cd "${TESTS_DIR}"/e2e ${OASIS_GO} build cp "${TESTS_DIR}"/e2e/e2e "${TEST_BASE_DIR}"/ -cd "${TEST_BASE_DIR}" - . "${TESTS_DIR}/consts.sh" . "${TESTS_DIR}/paths.sh" +# Key manager runtime. +cp "${TEST_KM_BINARY}" "${TEST_BASE_DIR}"/ +cp "${TEST_KM_SGXS_BINARY}" "${TEST_BASE_DIR}"/ + +cd "${TEST_BASE_DIR}" + printf "${CYAN}### Running end-to-end tests...${OFF}\n" ./e2e --log.level=debug \ --log.format json \ @@ -97,7 +144,6 @@ printf "${CYAN}### Running end-to-end tests...${OFF}\n" --e2e.node.binary="${TEST_NODE_BINARY}" \ --e2e.runtime.binary_dir.default="${TEST_BASE_DIR}" \ --e2e.runtime.loader="${TEST_RUNTIME_LOADER}" \ - --e2e.keymanager.binary="${TEST_KM_BINARY}" \ --scenario_timeout 30m \ "$@" diff --git a/tests/runtimes/benchmarking/src/lib.rs b/tests/runtimes/benchmarking/src/lib.rs index bb105edb0b..dc88298fed 100644 --- a/tests/runtimes/benchmarking/src/lib.rs +++ b/tests/runtimes/benchmarking/src/lib.rs @@ -14,8 +14,6 @@ pub struct Runtime; impl modules::core::Config for Config {} impl oasis_runtime_sdk_evm::Config for Config { - type Accounts = modules::accounts::Module; - type AdditionalPrecompileSet = (); const CHAIN_ID: u64 = 123456; @@ -27,6 +25,7 @@ impl sdk::Runtime for Runtime { const VERSION: Version = sdk::version_from_cargo!(); type Core = modules::core::Module; + type Accounts = modules::accounts::Module; #[allow(clippy::type_complexity)] type Modules = ( @@ -37,7 +36,7 @@ impl sdk::Runtime for Runtime { // Consensus layer interface. modules::consensus::Module, // Consensus layer accounts. - modules::consensus_accounts::Module, + modules::consensus_accounts::Module, // EVM. oasis_runtime_sdk_evm::Module, // Benchmarks. diff --git a/tests/runtimes/components-rofl/Cargo.toml b/tests/runtimes/components-rofl/Cargo.toml new file mode 100644 index 0000000000..305a21443d --- /dev/null +++ b/tests/runtimes/components-rofl/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "test-runtime-components-rofl" +version = "0.1.0" +authors = ["Oasis Protocol Foundation "] +edition = "2021" +license = "Apache-2.0" + +[dependencies] +oasis-runtime-sdk = { path = "../../../runtime-sdk" } + +components-ronl = { package = "test-runtime-components-ronl", path = "../components-ronl" } + +anyhow = "1.0" +async-trait = "0.1.77" + +[features] +# Enables mock SGX in non-SGX builds. +debug-mock-sgx = ["oasis-runtime-sdk/debug-mock-sgx"] diff --git a/tests/runtimes/components-rofl/src/main.rs b/tests/runtimes/components-rofl/src/main.rs new file mode 100644 index 0000000000..73c7791fd9 --- /dev/null +++ b/tests/runtimes/components-rofl/src/main.rs @@ -0,0 +1,55 @@ +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; + +use oasis_runtime_sdk::modules::rofl::app::{App, AppId, Environment}; + +struct TestApp; + +#[async_trait] +impl App for TestApp { + /// Runtime to attach the application to. + type AttachTo = components_ronl::Runtime; + + /// Identifier of the application (used for registrations). + /// + /// Here we use an application identifier that was set at genesis to make tests simpler. In + /// practice one would use the application identifier assigned when creating a ROFL app via the + /// `rofl.Create` call. + fn id() -> AppId { + *components_ronl::EXAMPLE_APP_ID + } + + async fn run(self: Arc, _env: Environment) { + // We are running now! + println!("Hello ROFL world!"); + } + + async fn on_runtime_block(self: Arc, env: Environment, _round: u64) { + // This gets called for each runtime block. It will not be called again until the previous + // invocation returns and if invocation takes multiple blocks to run, those blocks will be + // skipped. + if let Err(err) = self.run_oracle(env).await { + println!("Failed to submit observation: {:?}", err); + } + } +} + +impl TestApp { + /// Fetch stuff from remote service via HTTPS and publish it on chain. + async fn run_oracle(self: Arc, env: Environment) -> Result<()> { + // TODO: Fetch stuff from remote service. + let observation = components_ronl::oracle::types::Observation { value: 42, ts: 0 }; + let tx = self.new_transaction("oracle.Observe", observation); + + // Submit observation on chain. + env.client().sign_and_submit_tx(env.signer(), tx).await?; + + Ok(()) + } +} + +fn main() { + TestApp.start(); +} diff --git a/tests/runtimes/components-ronl/Cargo.toml b/tests/runtimes/components-ronl/Cargo.toml new file mode 100644 index 0000000000..f148a7e70f --- /dev/null +++ b/tests/runtimes/components-ronl/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "test-runtime-components-ronl" +version = "0.1.0" +authors = ["Oasis Protocol Foundation "] +edition = "2021" +license = "Apache-2.0" + +[dependencies] +cbor = { version = "0.5.1", package = "oasis-cbor" } +oasis-runtime-sdk = { path = "../../../runtime-sdk" } + +# Third party. +once_cell = "1.8.0" +thiserror = "1.0" + +[features] +# Enables mock SGX in non-SGX builds. +debug-mock-sgx = ["oasis-runtime-sdk/debug-mock-sgx"] diff --git a/tests/runtimes/components-ronl/src/lib.rs b/tests/runtimes/components-ronl/src/lib.rs new file mode 100644 index 0000000000..74c58f01f4 --- /dev/null +++ b/tests/runtimes/components-ronl/src/lib.rs @@ -0,0 +1,135 @@ +//! Multi-component runtime, RONL component. +use std::collections::BTreeMap; + +use once_cell::sync::Lazy; + +use oasis_runtime_sdk::{ + self as sdk, config, modules, modules::rofl::app_id::AppId, types::token::Denomination, Version, +}; + +pub mod oracle; + +/// Multi-component runtime. +pub struct Runtime; + +/// Runtime configuration. +pub struct Config; + +/// Example ROFL application identifier. +pub static EXAMPLE_APP_ID: Lazy = Lazy::new(|| AppId::from_global_name("example")); + +impl modules::core::Config for Config {} + +impl modules::rofl::Config for Config {} + +impl oracle::Config for Config { + type Rofl = modules::rofl::Module; + + fn rofl_app_id() -> AppId { + *EXAMPLE_APP_ID + } +} + +impl sdk::Runtime for Runtime { + const VERSION: Version = sdk::version_from_cargo!(); + + const SCHEDULE_CONTROL: config::ScheduleControl = config::ScheduleControl { + initial_batch_size: 50, + batch_size: 50, + min_remaining_gas: 100, + max_tx_count: 1000, + }; + + type Core = modules::core::Module; + type Accounts = modules::accounts::Module; + type FeeProxy = modules::rofl::Module; + + type Modules = ( + modules::accounts::Module, + modules::consensus::Module, + modules::consensus_accounts::Module, + modules::core::Module, + modules::rofl::Module, + oracle::Module, + ); + + fn genesis_state() -> ::Genesis { + use modules::rofl::policy::*; + use sdk::core::common::sgx::{pcs, EnclaveIdentity, QuotePolicy}; + + ( + modules::accounts::Genesis { + parameters: Default::default(), + balances: BTreeMap::from([ + // Alice. + ( + sdk::testing::keys::alice::address(), + BTreeMap::from([(Denomination::NATIVE, 10_000_000)]), + ), + // Dave. + ( + sdk::testing::keys::dave::address(), + BTreeMap::from([(Denomination::NATIVE, 100_000_000)]), + ), + ]), + total_supplies: BTreeMap::from([(Denomination::NATIVE, 110_000_000)]), + ..Default::default() + }, + modules::consensus::Genesis { + parameters: modules::consensus::Parameters { + gas_costs: Default::default(), + consensus_denomination: Denomination::NATIVE, + // Test scaling consensus base units when transferring them into the runtime. + consensus_scaling_factor: 1000, + min_delegate_amount: 2, + }, + }, + modules::consensus_accounts::Genesis::default(), + modules::core::Genesis { + parameters: modules::core::Parameters { + max_batch_gas: 15_000_000, + max_tx_size: 128 * 1024, + max_tx_signers: 8, + max_multisig_signers: 8, + gas_costs: modules::core::GasCosts { + auth_signature: 0, + auth_multisig_signer: 0, + ..Default::default() + }, + min_gas_price: BTreeMap::from([(Denomination::NATIVE, 0)]), + dynamic_min_gas_price: Default::default(), + }, + }, + modules::rofl::Genesis { + parameters: Default::default(), + apps: vec![modules::rofl::types::AppConfig { + id: *EXAMPLE_APP_ID, + policy: AppAuthPolicy { + quotes: QuotePolicy { + ias: None, + pcs: Some(pcs::QuotePolicy { + tcb_validity_period: 30, + min_tcb_evaluation_data_number: 16, + ..Default::default() + }), + }, + enclaves: vec![ + // SHA256("simple-rofl") + EnclaveIdentity::fortanix_test( + "e1b2c5cfacb4e57f6a0892db30d02dd21836b380c75d9d6b9e9deeec4f55d1c5" + .parse() + .unwrap(), + ), + ], + endorsements: vec![AllowedEndorsement::ComputeRole], + fees: FeePolicy::EndorsingNodePays, + max_expiration: 2, + }, + admin: None, + ..Default::default() + }], + }, + oracle::Genesis::default(), + ) + } +} diff --git a/tests/runtimes/components-ronl/src/main.rs b/tests/runtimes/components-ronl/src/main.rs new file mode 100644 index 0000000000..f8e4d859b7 --- /dev/null +++ b/tests/runtimes/components-ronl/src/main.rs @@ -0,0 +1,5 @@ +use oasis_runtime_sdk::Runtime; + +fn main() { + test_runtime_components_ronl::Runtime::start(); +} diff --git a/tests/runtimes/components-ronl/src/oracle/error.rs b/tests/runtimes/components-ronl/src/oracle/error.rs new file mode 100644 index 0000000000..5b0b76f300 --- /dev/null +++ b/tests/runtimes/components-ronl/src/oracle/error.rs @@ -0,0 +1,23 @@ +use oasis_runtime_sdk::modules; + +use super::MODULE_NAME; + +/// Errors emitted by the module. +#[derive(thiserror::Error, Debug, oasis_runtime_sdk::Error)] +pub enum Error { + #[error("invalid argument")] + #[sdk_error(code = 1)] + InvalidArgument, + + #[error("not authorized")] + #[sdk_error(code = 2)] + NotAuthorized, + + #[error("{0}")] + #[sdk_error(transparent)] + Rofl(#[from] modules::rofl::Error), + + #[error("{0}")] + #[sdk_error(transparent)] + Core(#[from] modules::core::Error), +} diff --git a/tests/runtimes/components-ronl/src/oracle/event.rs b/tests/runtimes/components-ronl/src/oracle/event.rs new file mode 100644 index 0000000000..dd13c78fa6 --- /dev/null +++ b/tests/runtimes/components-ronl/src/oracle/event.rs @@ -0,0 +1,9 @@ +use super::{types, MODULE_NAME}; + +/// Events emitted by the oracle module. +#[derive(Debug, cbor::Encode, oasis_runtime_sdk::Event)] +#[cbor(untagged)] +pub enum Event { + #[sdk_event(code = 1)] + ValueUpdated(Option), +} diff --git a/tests/runtimes/components-ronl/src/oracle/mod.rs b/tests/runtimes/components-ronl/src/oracle/mod.rs new file mode 100644 index 0000000000..d5f97e247d --- /dev/null +++ b/tests/runtimes/components-ronl/src/oracle/mod.rs @@ -0,0 +1,134 @@ +//! Example ROFL-based oracle module. +use oasis_runtime_sdk::{ + context::Context, + handler, migration, + module::{self, Module as _, Parameters as _}, + modules::{ + self, + core::API as _, + rofl::{app_id::AppId, API as _}, + }, + sdk_derive, + state::CurrentState, + Runtime, +}; + +mod error; +mod event; +pub mod state; +pub mod types; + +pub use error::Error; +pub use event::Event; + +/// Unique module name. +const MODULE_NAME: &str = "oracle"; + +/// Module configuration. +pub trait Config: 'static { + /// Module implementing the ROFL API. + type Rofl: modules::rofl::API; + + /// Minimum number of required observations to finalize a round. + const MIN_OBSERVATIONS: usize = 2; + + /// Gas cost of oracle.Observe call. + const GAS_COST_CALL_OBSERVE: u64 = 1000; + + /// Identifier of the ROFL application that is allowed to contribute observations. + fn rofl_app_id() -> AppId; + + /// Observation aggregation function. + fn aggregate(mut observations: Vec) -> Option { + if observations.is_empty() || observations.len() < Self::MIN_OBSERVATIONS { + return None; + } + + // Naive median implementation, should work for this example. + observations.sort_by_key(|obs| obs.value); + Some(observations[(observations.len() / 2).saturating_sub(1)].clone()) + } +} + +/// Parameters for the module. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +pub struct Parameters {} + +/// Errors emitted during rewards parameter validation. +#[derive(thiserror::Error, Debug)] +pub enum ParameterValidationError {} + +impl module::Parameters for Parameters { + type Error = ParameterValidationError; + + fn validate_basic(&self) -> Result<(), Self::Error> { + Ok(()) + } +} + +/// Genesis state for the module. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +pub struct Genesis { + pub parameters: Parameters, +} + +pub struct Module { + _cfg: std::marker::PhantomData, +} + +#[sdk_derive(Module)] +impl Module { + const NAME: &'static str = MODULE_NAME; + type Error = Error; + type Event = Event; + type Parameters = Parameters; + type Genesis = Genesis; + + #[migration(init)] + fn init(genesis: Genesis) { + genesis + .parameters + .validate_basic() + .expect("invalid genesis parameters"); + + // Set genesis parameters. + Self::set_params(genesis.parameters); + } + + /// Process an observation by an oracle. + #[handler(call = "oracle.Observe")] + fn tx_observe(ctx: &C, body: types::Observation) -> Result<(), Error> { + let params = Self::params(); + ::Core::use_tx_gas(Cfg::GAS_COST_CALL_OBSERVE)?; + + // Ensure that the observation was processed by the configured ROFL application. + if !Cfg::Rofl::is_authorized_origin(Cfg::rofl_app_id())? { + return Err(Error::NotAuthorized); + } + + // NOTE: This is a naive oracle implementation for ROFL example purposes. A real oracle + // must do additional checks and better aggregation before accepting values. + + // Update the round. + let mut round = state::get_current_round(); + round.observations.push(body); + + // Emit aggregated observation when possible. + if round.observations.len() >= Cfg::MIN_OBSERVATIONS { + let agg = Cfg::aggregate(std::mem::take(&mut round.observations)); + state::set_last_observation(agg.clone()); + + CurrentState::with(|state| state.emit_event(Event::ValueUpdated(agg))); + } + + state::set_current_round(round); + + Ok(()) + } +} + +impl module::TransactionHandler for Module {} + +impl module::BlockHandler for Module {} + +impl module::InvariantHandler for Module {} diff --git a/tests/runtimes/components-ronl/src/oracle/state.rs b/tests/runtimes/components-ronl/src/oracle/state.rs new file mode 100644 index 0000000000..0244305a31 --- /dev/null +++ b/tests/runtimes/components-ronl/src/oracle/state.rs @@ -0,0 +1,44 @@ +use oasis_runtime_sdk::{state::CurrentState, storage}; + +use super::{types, MODULE_NAME}; + +/// Current aggregation round state. +const CURRENT_ROUND: &[u8] = &[0x01]; +/// Last observation. +const LAST_OBSERVATION: &[u8] = &[0x02]; + +/// Retrieves the current aggregation round state. +pub fn get_current_round() -> types::Round { + CurrentState::with_store(|store| { + let store = storage::TypedStore::new(storage::PrefixStore::new(store, &MODULE_NAME)); + store.get(CURRENT_ROUND).unwrap_or_default() + }) +} + +/// Sets the current aggregation round state. +pub fn set_current_round(round: types::Round) { + CurrentState::with_store(|store| { + let mut store = storage::TypedStore::new(storage::PrefixStore::new(store, &MODULE_NAME)); + store.insert(CURRENT_ROUND, round); + }) +} + +/// Retrieves the last observation. +pub fn get_last_observation() -> Option { + CurrentState::with_store(|store| { + let store = storage::TypedStore::new(storage::PrefixStore::new(store, &MODULE_NAME)); + store.get(LAST_OBSERVATION) + }) +} + +/// Sets the last observation. +pub fn set_last_observation(observation: Option) { + CurrentState::with_store(|store| { + let mut store = storage::TypedStore::new(storage::PrefixStore::new(store, &MODULE_NAME)); + if let Some(observation) = observation { + store.insert(LAST_OBSERVATION, observation); + } else { + store.remove(LAST_OBSERVATION); + } + }) +} diff --git a/tests/runtimes/components-ronl/src/oracle/types.rs b/tests/runtimes/components-ronl/src/oracle/types.rs new file mode 100644 index 0000000000..27fb4dc553 --- /dev/null +++ b/tests/runtimes/components-ronl/src/oracle/types.rs @@ -0,0 +1,15 @@ +/// Observation call. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +pub struct Observation { + /// Observation value. + pub value: u128, + /// Observation timestamp. + pub ts: u64, +} + +/// State for an observation round. +#[derive(Clone, Debug, Default, cbor::Encode, cbor::Decode)] +pub struct Round { + /// Observations in this round. + pub observations: Vec, +} diff --git a/tests/runtimes/simple-consensus/Cargo.toml b/tests/runtimes/simple-consensus/Cargo.toml index 4490611d60..6b9df9b9c8 100644 --- a/tests/runtimes/simple-consensus/Cargo.toml +++ b/tests/runtimes/simple-consensus/Cargo.toml @@ -8,3 +8,7 @@ license = "Apache-2.0" [dependencies] oasis-runtime-sdk = { path = "../../../runtime-sdk" } cbor = { version = "0.5.1", package = "oasis-cbor" } + +[features] +# Enables mock SGX in non-SGX builds. +debug-mock-sgx = ["oasis-runtime-sdk/debug-mock-sgx"] diff --git a/tests/runtimes/simple-consensus/src/lib.rs b/tests/runtimes/simple-consensus/src/lib.rs index ec44eec70d..286610909f 100644 --- a/tests/runtimes/simple-consensus/src/lib.rs +++ b/tests/runtimes/simple-consensus/src/lib.rs @@ -21,11 +21,12 @@ impl sdk::Runtime for Runtime { }; type Core = modules::core::Module; + type Accounts = modules::accounts::Module; type Modules = ( modules::accounts::Module, modules::consensus::Module, - modules::consensus_accounts::Module, + modules::consensus_accounts::Module, modules::core::Module, ); diff --git a/tests/runtimes/simple-contracts/Cargo.toml b/tests/runtimes/simple-contracts/Cargo.toml index 4b8a76e939..47119befa1 100644 --- a/tests/runtimes/simple-contracts/Cargo.toml +++ b/tests/runtimes/simple-contracts/Cargo.toml @@ -12,3 +12,7 @@ oasis-runtime-sdk-contracts = { path = "../../../runtime-sdk/modules/contracts" # Third party. thiserror = "1.0" + +[features] +# Enables mock SGX in non-SGX builds. +debug-mock-sgx = ["oasis-runtime-sdk/debug-mock-sgx"] diff --git a/tests/runtimes/simple-contracts/src/lib.rs b/tests/runtimes/simple-contracts/src/lib.rs index ef0e715a48..21f43119bb 100644 --- a/tests/runtimes/simple-contracts/src/lib.rs +++ b/tests/runtimes/simple-contracts/src/lib.rs @@ -15,9 +15,7 @@ impl modules::core::Config for Config { const ALLOW_INTERACTIVE_READ_ONLY_TRANSACTIONS: bool = true; } -impl contracts::Config for Config { - type Accounts = modules::accounts::Module; -} +impl contracts::Config for Config {} impl sdk::Runtime for Runtime { const VERSION: Version = sdk::version_from_cargo!(); @@ -30,6 +28,7 @@ impl sdk::Runtime for Runtime { }; type Core = modules::core::Module; + type Accounts = modules::accounts::Module; type Modules = ( modules::accounts::Module, diff --git a/tests/runtimes/simple-evm/Cargo.toml b/tests/runtimes/simple-evm/Cargo.toml index 391b566437..52c6233dc3 100644 --- a/tests/runtimes/simple-evm/Cargo.toml +++ b/tests/runtimes/simple-evm/Cargo.toml @@ -15,3 +15,5 @@ thiserror = "1.0" [features] confidential = [] +# Enables mock SGX in non-SGX builds. +debug-mock-sgx = ["oasis-runtime-sdk/debug-mock-sgx"] diff --git a/tests/runtimes/simple-evm/src/lib.rs b/tests/runtimes/simple-evm/src/lib.rs index 263e8f11bb..672fc1dbad 100644 --- a/tests/runtimes/simple-evm/src/lib.rs +++ b/tests/runtimes/simple-evm/src/lib.rs @@ -15,8 +15,6 @@ pub struct Config; impl modules::core::Config for Config {} impl evm::Config for Config { - type Accounts = modules::accounts::Module; - type AdditionalPrecompileSet = (); const CHAIN_ID: u64 = 0xa515; @@ -37,11 +35,12 @@ impl sdk::Runtime for Runtime { }; type Core = modules::core::Module; + type Accounts = modules::accounts::Module; type Modules = ( modules::accounts::Module, modules::consensus::Module, - modules::consensus_accounts::Module, + modules::consensus_accounts::Module, modules::core::Module, evm::Module, ); diff --git a/tests/runtimes/simple-keyvalue/Cargo.toml b/tests/runtimes/simple-keyvalue/Cargo.toml index 4a5e6c2cfd..94590c6f62 100644 --- a/tests/runtimes/simple-keyvalue/Cargo.toml +++ b/tests/runtimes/simple-keyvalue/Cargo.toml @@ -13,3 +13,7 @@ cbor = { version = "0.5.1", package = "oasis-cbor" } anyhow = "1.0.86" thiserror = "1.0" futures = "0.3.18" + +[features] +# Enables mock SGX in non-SGX builds. +debug-mock-sgx = ["oasis-runtime-sdk/debug-mock-sgx"] diff --git a/tests/runtimes/simple-keyvalue/src/lib.rs b/tests/runtimes/simple-keyvalue/src/lib.rs index 975cb48743..64215368d6 100644 --- a/tests/runtimes/simple-keyvalue/src/lib.rs +++ b/tests/runtimes/simple-keyvalue/src/lib.rs @@ -37,11 +37,12 @@ impl sdk::Runtime for Runtime { }; type Core = modules::core::Module; + type Accounts = modules::accounts::Module; type Modules = ( keyvalue::Module, modules::accounts::Module, - modules::rewards::Module, + modules::rewards::Module, modules::core::Module, ); @@ -149,7 +150,7 @@ impl sdk::Runtime for Runtime { fn migrate_state(_ctx: &C) { // Fetch current parameters. - type Rewards = modules::rewards::Module; + type Rewards = modules::rewards::Module; let mut params = Rewards::params(); // Update the participation threshold (one of the E2E tests checks this and would fail diff --git a/tools/orc/main.go b/tools/orc/main.go index 1a1aa09966..b4ccdedcec 100644 --- a/tools/orc/main.go +++ b/tools/orc/main.go @@ -30,11 +30,14 @@ const ( var ( // Init flags. - sgxExecutableFn string - sgxSignatureFn string - bundleFn string - overrideRuntimeID string - componentId string + noAutodetection bool + sgxExecutableFn string + sgxSignatureFn string + bundleFn string + overrideRuntimeName string + overrideRuntimeID string + overrideRuntimeVersion string + componentId string // SIGSTRUCT flags. dateStr string @@ -54,82 +57,24 @@ var ( } initCmd = &cobra.Command{ - Use: "init [--sgx-executable SGXS] [--sgx-signature SIG]", - Short: "create a runtime bundle with a RONL component", - Args: cobra.ExactArgs(1), + Use: "init [] [--sgx-executable SGXS] [--sgx-signature SIG]", + Short: "create a runtime bundle (optionally with a RONL component)", + Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - executablePath := args[0] - - // Parse Cargo manifest to get name and version. - data, err := os.ReadFile(cargoTomlName) - if err != nil { - cobra.CheckErr(fmt.Errorf("failed to read Cargo manifest: %w", err)) - } - - type deploymentManifest struct { - RuntimeID common.Namespace `toml:"runtime-id"` - } - type cargoManifest struct { - Package struct { - Name string `toml:"name"` - Version string `toml:"version"` - Metadata struct { - ORC struct { - Release *deploymentManifest `toml:"release"` - Test *deploymentManifest `toml:"test"` - } `toml:"orc"` - } `toml:"metadata"` - } `toml:"package"` - } - var cm cargoManifest - err = toml.Unmarshal(data, &cm) - if err != nil { - cobra.CheckErr(fmt.Errorf("malformed Cargo manifest: %w", err)) + var executablePath string + if len(args) >= 1 { + executablePath = args[0] } - var manifest bundle.Manifest - manifest.Name = cm.Package.Name - manifest.Version, err = version.FromString(cm.Package.Version) - if err != nil { - cobra.CheckErr(fmt.Errorf("malformed runtime version: %w", err)) - } - - var kind string - switch overrideRuntimeID { - case "": - // Automatic runtime ID determination based on the version string. - var dm *deploymentManifest - switch isRelease := (manifest.Version.String() == cm.Package.Version); isRelease { - case true: - // Release build. - dm = cm.Package.Metadata.ORC.Release - kind = "release" - case false: - // Test build. - dm = cm.Package.Metadata.ORC.Test - kind = "test" - } - if dm == nil { - cobra.CheckErr(fmt.Errorf("missing ORC metadata for %s build", kind)) - } - - manifest.ID = dm.RuntimeID - default: - // Manually configured runtime ID. - kind = "manually overriden" - err = manifest.ID.UnmarshalText([]byte(overrideRuntimeID)) - if err != nil { - cobra.CheckErr(fmt.Errorf("malformed runtime identifier: %w", err)) - } - } - - fmt.Printf("Using %s runtime identifier: %s\n", strings.ToUpper(kind), manifest.ID) + manifest := autodetectRuntime() bnd := &bundle.Bundle{ - Manifest: &manifest, + Manifest: manifest, } - addComponent(bnd, component.ID_RONL, executablePath) + if executablePath != "" { + addComponent(bnd, component.ID_RONL, executablePath) + } writeBundle(bnd) }, } @@ -224,7 +169,21 @@ var ( } err = bnd.Add(sgxSigName, signed) cobra.CheckErr(err) - bnd.Manifest.SGX.Signature = sgxSigName + + switch compId { + case component.ID_RONL: + // We need to support legacy manifests, so check where the SGXS is defined. + if bnd.Manifest.SGX != nil { + bnd.Manifest.SGX.Signature = sgxSigName + break + } + + fallthrough + default: + // Configure SGX signature for the right component. + comp := bnd.Manifest.GetComponentByID(compId) + comp.SGX.Signature = sgxSigName + } // Remove previous serialized manifest. bnd.ResetManifest() @@ -273,6 +232,115 @@ var ( } ) +func autodetectRuntime() *bundle.Manifest { + type deploymentManifest struct { + RuntimeID common.Namespace `toml:"runtime-id"` + } + type cargoManifest struct { + Package struct { + Name string `toml:"name"` + Version string `toml:"version"` + Metadata struct { + ORC struct { + Release *deploymentManifest `toml:"release"` + Test *deploymentManifest `toml:"test"` + } `toml:"orc"` + } `toml:"metadata"` + } `toml:"package"` + } + + var cm cargoManifest + switch noAutodetection { + case true: + // Manual, ensure all overrides are set. + if overrideRuntimeName == "" { + cobra.CheckErr(fmt.Errorf("manual configuration requires --runtime-name")) + } + if overrideRuntimeID == "" { + cobra.CheckErr(fmt.Errorf("manual configuration requires --runtime-id")) + } + if overrideRuntimeVersion == "" { + cobra.CheckErr(fmt.Errorf("manual configuration requires --runtime-version")) + } + default: + // Autodetection via Cargo manifest. + fmt.Printf("Attempting to autodetect runtime metadata from '%s'...\n", cargoTomlName) + + data, err := os.ReadFile(cargoTomlName) + if err != nil { + cobra.CheckErr(fmt.Errorf("failed to read Cargo manifest: %w", err)) + } + + err = toml.Unmarshal(data, &cm) + if err != nil { + cobra.CheckErr(fmt.Errorf("malformed Cargo manifest: %w", err)) + } + } + + var manifest bundle.Manifest + switch overrideRuntimeName { + case "": + // Automatic name determination based on the cargo manifest. + manifest.Name = cm.Package.Name + default: + // Manually configured runtime name. + manifest.Name = overrideRuntimeName + } + + fmt.Printf("Using runtime name: %s\n", manifest.Name) + + var versionStr string + switch overrideRuntimeVersion { + case "": + // Automatic version determination based on the cargo manifest. + versionStr = cm.Package.Version + default: + // Manually configured runtime version. + versionStr = overrideRuntimeVersion + } + + var err error + manifest.Version, err = version.FromString(versionStr) + if err != nil { + cobra.CheckErr(fmt.Errorf("malformed runtime version: %w", err)) + } + + fmt.Printf("Using runtime version: %s\n", manifest.Version) + + var kind string + switch overrideRuntimeID { + case "": + // Automatic runtime ID determination based on the version string. + var dm *deploymentManifest + switch isRelease := (manifest.Version.String() == cm.Package.Version); isRelease { + case true: + // Release build. + dm = cm.Package.Metadata.ORC.Release + kind = "release" + case false: + // Test build. + dm = cm.Package.Metadata.ORC.Test + kind = "test" + } + if dm == nil { + cobra.CheckErr(fmt.Errorf("missing ORC metadata for %s build", kind)) + } + + manifest.ID = dm.RuntimeID + default: + // Manually configured runtime ID. + kind = "manually overriden" + err = manifest.ID.UnmarshalText([]byte(overrideRuntimeID)) + if err != nil { + cobra.CheckErr(fmt.Errorf("malformed runtime identifier: %w", err)) + } + } + + fmt.Printf("Using %s runtime identifier: %s\n", strings.ToUpper(kind), manifest.ID) + + return &manifest +} + func getComponentID() (id component.ID) { err := id.UnmarshalText([]byte(componentId)) cobra.CheckErr(err) @@ -431,8 +499,11 @@ func init() { // Init cmd. initFlags := flag.NewFlagSet("", flag.ContinueOnError) + initFlags.BoolVar(&noAutodetection, "custom", false, "disable autodetection") initFlags.StringVar(&bundleFn, "output", "", "output bundle filename") - initFlags.StringVar(&overrideRuntimeID, "runtime-id", "", "override autodetected runtime ID") + initFlags.StringVar(&overrideRuntimeName, "runtime-name", "", "override runtime name") + initFlags.StringVar(&overrideRuntimeVersion, "runtime-version", "", "override runtime version") + initFlags.StringVar(&overrideRuntimeID, "runtime-id", "", "override runtime ID") initCmd.Flags().AddFlagSet(initFlags) initCmd.Flags().AddFlagSet(sgxFlags)