diff --git a/.gitignore b/.gitignore index 6fe160927..89e285f84 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ chain/ # Coverage coverage.txt coverage.html + +# Compiler output +examples/*/*.nef +examples/*/*.json diff --git a/examples/README.md b/examples/README.md index 362aa9318..03a8e2927 100644 --- a/examples/README.md +++ b/examples/README.md @@ -25,6 +25,7 @@ See the table below for the detailed examples description. | [engine](engine) | This contract demonstrates how to use `runtime` interop package which implements an API for `System.Runtime.*` NEO system calls. Please, refer to the `runtime` [package documentation](../pkg/interop/doc.go) for details. | | [events](events) | The contract shows how execution notifications with the different arguments types can be sent with the help of `runtime.Notify` function of the `runtime` interop package. Please, refer to the `runtime.Notify` [function documentation](../pkg/interop/runtime/runtime.go) for details. | | [iterator](iterator) | This example describes a way to work with NEO iterators. Please, refer to the `iterator` [package documentation](../pkg/interop/iterator/iterator.go) for details. | +| [oracle](oracle) | Oracle demo contract exposing two methods that you can use to process URLs. It uses oracle native contract, see [interop package documentation](../pkg/interop/native/oracle/oracle.go) also. | | [runtime](runtime) | This contract demonstrates how to use special `_initialize` and `_deploy` methods. See the [compiler documentation](../docs/compiler.md#vm-api-interop-layer ) for methods details. It also shows the pattern for checking owner witness inside the contract with the help of `runtime.CheckWitness` interop [function](../pkg/interop/runtime/runtime.go). | | [storage](storage) | The contract implements API for basic operations with a contract storage. It shows hos to use `storage` interop package. See the `storage` [package documentation](../pkg/interop/storage/storage.go). | | [timer](timer) | The idea of the contract is to count `tick` method invocations and destroy itself after the third invocation. It shows how to use `contract.Call` interop function to call, update (migrate) and destroy the contract. Please, refer to the `contract.Call` [function documentation](../pkg/interop/contract/contract.go) | diff --git a/examples/oracle/oracle.go b/examples/oracle/oracle.go new file mode 100644 index 000000000..dcaefb614 --- /dev/null +++ b/examples/oracle/oracle.go @@ -0,0 +1,36 @@ +package oraclecontract + +import ( + "github.com/nspcc-dev/neo-go/pkg/interop/native/oracle" + "github.com/nspcc-dev/neo-go/pkg/interop/native/std" + "github.com/nspcc-dev/neo-go/pkg/interop/runtime" +) + +// Request does an oracle request for the URL specified. It adds minimum +// response fee which should suffice for small requests. The data from this +// URL is subsequently processed by OracleCallback function. This request +// has no JSONPath filters or user data. +func Request(url string) { + oracle.Request(url, nil, "oracleCallback", nil, oracle.MinimumResponseGas) +} + +// FilteredRequest is similar to Request but allows you to specify JSONPath filter +// to run against data got from the url specified. +func FilteredRequest(url string, filter []byte) { + oracle.Request(url, filter, "oracleCallback", nil, oracle.MinimumResponseGas) +} + +// OracleCallback is called by Oracle native contract when request is finished. +// It either throws an error (if the result is not successful) or logs the data +// got as a result. +func OracleCallback(url string, data interface{}, code int, res []byte) { + // This function shouldn't be called directly, we only expect oracle native + // contract to be calling it. + if string(runtime.GetCallingScriptHash()) != oracle.Hash { + panic("not called from oracle contract") + } + if code != oracle.Success { + panic("request failed for " + url + " with code " + std.Itoa(code, 10)) + } + runtime.Log("result for " + url + ": " + string(res)) +} diff --git a/examples/oracle/oracle.yml b/examples/oracle/oracle.yml new file mode 100644 index 000000000..6e67ec888 --- /dev/null +++ b/examples/oracle/oracle.yml @@ -0,0 +1,3 @@ +name: "Oracle example" +supportedstandards: [] +events: diff --git a/pkg/compiler/native_test.go b/pkg/compiler/native_test.go index a8ac1361e..7aa75fdc6 100644 --- a/pkg/compiler/native_test.go +++ b/pkg/compiler/native_test.go @@ -11,6 +11,7 @@ import ( "github.com/nspcc-dev/neo-go/pkg/core/native" "github.com/nspcc-dev/neo-go/pkg/core/native/nnsrecords" "github.com/nspcc-dev/neo-go/pkg/core/native/noderoles" + "github.com/nspcc-dev/neo-go/pkg/core/transaction" "github.com/nspcc-dev/neo-go/pkg/interop/native/crypto" "github.com/nspcc-dev/neo-go/pkg/interop/native/gas" "github.com/nspcc-dev/neo-go/pkg/interop/native/ledger" @@ -89,6 +90,20 @@ func TestCryptoLibNamedCurve(t *testing.T) { require.EqualValues(t, native.Secp256r1, crypto.Secp256r1) } +func TestOracleContractValues(t *testing.T) { + require.EqualValues(t, oracle.Success, transaction.Success) + require.EqualValues(t, oracle.ProtocolNotSupported, transaction.ProtocolNotSupported) + require.EqualValues(t, oracle.ConsensusUnreachable, transaction.ConsensusUnreachable) + require.EqualValues(t, oracle.NotFound, transaction.NotFound) + require.EqualValues(t, oracle.Timeout, transaction.Timeout) + require.EqualValues(t, oracle.Forbidden, transaction.Forbidden) + require.EqualValues(t, oracle.ResponseTooLarge, transaction.ResponseTooLarge) + require.EqualValues(t, oracle.InsufficientFunds, transaction.InsufficientFunds) + require.EqualValues(t, oracle.Error, transaction.Error) + + require.EqualValues(t, oracle.MinimumResponseGas, native.MinimumResponseGas) +} + type nativeTestCase struct { method string params []string diff --git a/pkg/core/native/oracle.go b/pkg/core/native/oracle.go index 9bc81cd18..abd99fbf5 100644 --- a/pkg/core/native/oracle.go +++ b/pkg/core/native/oracle.go @@ -61,6 +61,9 @@ const ( // DefaultOracleRequestPrice is default amount GAS needed for oracle request. DefaultOracleRequestPrice = 5000_0000 + + // MinimumResponseGas is the minimum response fee permitted for request. + MinimumResponseGas = 10_000_000 ) var ( @@ -74,6 +77,7 @@ var ( var ( ErrBigArgument = errors.New("some of the arguments are invalid") ErrInvalidWitness = errors.New("witness check failed") + ErrLowResponseGas = errors.New("not enough gas for response") ErrNotEnoughGas = errors.New("gas limit exceeded") ErrRequestNotFound = errors.New("oracle request not found") ErrResponseNotFound = errors.New("oracle response not found") @@ -323,9 +327,12 @@ func (o *Oracle) request(ic *interop.Context, args []stackitem.Item) stackitem.I // RequestInternal processes oracle request. func (o *Oracle) RequestInternal(ic *interop.Context, url string, filter *string, cb string, userData stackitem.Item, gas *big.Int) error { - if len(url) > maxURLLength || (filter != nil && len(*filter) > maxFilterLength) || len(cb) > maxCallbackLength || gas.Uint64() < 1000_0000 { + if len(url) > maxURLLength || (filter != nil && len(*filter) > maxFilterLength) || len(cb) > maxCallbackLength { return ErrBigArgument } + if gas.Uint64() < MinimumResponseGas { + return ErrLowResponseGas + } if strings.HasPrefix(cb, "_") { return errors.New("disallowed callback method (starts with '_')") } diff --git a/pkg/core/transaction/attribute_test.go b/pkg/core/transaction/attribute_test.go index 4d43df95f..b38c88181 100644 --- a/pkg/core/transaction/attribute_test.go +++ b/pkg/core/transaction/attribute_test.go @@ -29,6 +29,18 @@ func TestAttribute_EncodeBinary(t *testing.T) { }, } testserdes.EncodeDecodeBinary(t, attr, new(Attribute)) + for _, code := range []OracleResponseCode{ProtocolNotSupported, ConsensusUnreachable, + NotFound, Timeout, Forbidden, ResponseTooLarge, InsufficientFunds, Error} { + attr = &Attribute{ + Type: OracleResponseT, + Value: &OracleResponse{ + ID: 42, + Code: code, + Result: []byte{}, + }, + } + testserdes.EncodeDecodeBinary(t, attr, new(Attribute)) + } }) t.Run("NotValidBefore", func(t *testing.T) { t.Run("positive", func(t *testing.T) { @@ -144,7 +156,7 @@ func TestAttribute_MarshalJSON(t *testing.T) { require.JSONEq(t, `{ "type":"OracleResponse", "id": 123, - "code": 0, + "code": "Success", "result": "`+base64.StdEncoding.EncodeToString(res)+`"}`, string(data)) actual := new(Attribute) diff --git a/pkg/core/transaction/oracle.go b/pkg/core/transaction/oracle.go index 477567810..3c89d644c 100644 --- a/pkg/core/transaction/oracle.go +++ b/pkg/core/transaction/oracle.go @@ -1,12 +1,16 @@ package transaction import ( + "encoding/json" "errors" "math" + "strings" "github.com/nspcc-dev/neo-go/pkg/io" ) +//go:generate stringer -type=OracleResponseCode + // OracleResponseCode represents result code of oracle response. type OracleResponseCode byte @@ -46,6 +50,43 @@ func (c OracleResponseCode) IsValid() bool { c == InsufficientFunds || c == Error } +// MarshalJSON implements json.Marshaler interface. +func (c OracleResponseCode) MarshalJSON() ([]byte, error) { + return []byte(`"` + c.String() + `"`), nil +} + +// UnmarshalJSON implements json.Unmarshaler interface. +func (c *OracleResponseCode) UnmarshalJSON(data []byte) error { + var js string + if err := json.Unmarshal(data, &js); err != nil { + return err + } + js = strings.ToLower(js) + switch js { + case "success": + *c = Success + case "protocolnotsupported": + *c = ProtocolNotSupported + case "consensusunreachable": + *c = ConsensusUnreachable + case "notfound": + *c = NotFound + case "timeout": + *c = Timeout + case "forbidden": + *c = Forbidden + case "responsetoolarge": + *c = ResponseTooLarge + case "insufficientfunds": + *c = InsufficientFunds + case "error": + *c = Error + default: + return errors.New("invalid oracle response code") + } + return nil +} + // DecodeBinary implements io.Serializable interface. func (r *OracleResponse) DecodeBinary(br *io.BinReader) { r.ID = br.ReadU64LE() diff --git a/pkg/core/transaction/oracleresponsecode_string.go b/pkg/core/transaction/oracleresponsecode_string.go new file mode 100644 index 000000000..53a57e45c --- /dev/null +++ b/pkg/core/transaction/oracleresponsecode_string.go @@ -0,0 +1,57 @@ +// Code generated by "stringer -type=OracleResponseCode"; DO NOT EDIT. + +package transaction + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[Success-0] + _ = x[ProtocolNotSupported-16] + _ = x[ConsensusUnreachable-18] + _ = x[NotFound-20] + _ = x[Timeout-22] + _ = x[Forbidden-24] + _ = x[ResponseTooLarge-26] + _ = x[InsufficientFunds-28] + _ = x[Error-255] +} + +const ( + _OracleResponseCode_name_0 = "Success" + _OracleResponseCode_name_1 = "ProtocolNotSupported" + _OracleResponseCode_name_2 = "ConsensusUnreachable" + _OracleResponseCode_name_3 = "NotFound" + _OracleResponseCode_name_4 = "Timeout" + _OracleResponseCode_name_5 = "Forbidden" + _OracleResponseCode_name_6 = "ResponseTooLarge" + _OracleResponseCode_name_7 = "InsufficientFunds" + _OracleResponseCode_name_8 = "Error" +) + +func (i OracleResponseCode) String() string { + switch { + case i == 0: + return _OracleResponseCode_name_0 + case i == 16: + return _OracleResponseCode_name_1 + case i == 18: + return _OracleResponseCode_name_2 + case i == 20: + return _OracleResponseCode_name_3 + case i == 22: + return _OracleResponseCode_name_4 + case i == 24: + return _OracleResponseCode_name_5 + case i == 26: + return _OracleResponseCode_name_6 + case i == 28: + return _OracleResponseCode_name_7 + case i == 255: + return _OracleResponseCode_name_8 + default: + return "OracleResponseCode(" + strconv.FormatInt(int64(i), 10) + ")" + } +} diff --git a/pkg/interop/native/oracle/oracle.go b/pkg/interop/native/oracle/oracle.go index 44329d354..d054539c3 100644 --- a/pkg/interop/native/oracle/oracle.go +++ b/pkg/interop/native/oracle/oracle.go @@ -10,22 +10,74 @@ import ( "github.com/nspcc-dev/neo-go/pkg/interop/contract" ) +// These are potential response codes you get in your callback completing +// oracle request. Resulting data is only passed with Success code, it's +// nil otherwise. +const ( + Success = 0x00 + ProtocolNotSupported = 0x10 + ConsensusUnreachable = 0x12 + NotFound = 0x14 + Timeout = 0x16 + Forbidden = 0x18 + ResponseTooLarge = 0x1a + InsufficientFunds = 0x1c + Error = 0xff +) + // Hash represents Oracle contract hash. const Hash = "\x58\x87\x17\x11\x7e\x0a\xa8\x10\x72\xaf\xab\x71\xd2\xdd\x89\xfe\x7c\x4b\x92\xfe" -// Request represents `request` method of Oracle native contract. +// MinimumResponseGas is the minimum response fee permitted for request (that is +// you can't attach less than that to your request). It's 0.1 GAS at the moment. +const MinimumResponseGas = 10_000_000 + +// Request makes an oracle request. It can only be successfully invoked by +// deployed contract and it takes the following parameters: +// +// url +// URL to fetch, only https and neofs URLs are supported like +// https://example.com/some.json or +// neofs:6pJtLUnGqDxE2EitZYLsDzsfTDVegD6BrRUn8QAFZWyt/5Cyxb3wrHDw5pqY63hb5otCSsJ24ZfYmsA8NAjtho2gr +// +// filter +// JSONPath filter to process the result, if specified it will be +// applied to the data returned from HTTP/NeoFS and you'll only get +// filtered data in your callback method. +// +// cb +// name of the method that will process oracle data, it must be a method +// of the same contract that invokes Request and it must have the following +// signature for correct invocation: +// +// Method(url string, userData interface{}, code int, result []byte) +// +// where url is the same url specified for Request, userData is anything +// passed in the next parameter, code is the status of the reply and +// result is data returned from request if any. +// +// userData +// data to pass to the callback function. +// +// gasForResponse +// GAS attached to this request for reply callback processing, +// note that it's different from the oracle request price, this +// GAS is used for oracle transaction's network and system fees, +// so it should be enough to pay for reply data as well as +// its processing. func Request(url string, filter []byte, cb string, userData interface{}, gasForResponse int) { contract.Call(interop.Hash160(Hash), "request", contract.States|contract.AllowNotify, url, filter, cb, userData, gasForResponse) } -// GetPrice represents `getPrice` method of Oracle native contract. +// GetPrice returns current oracle request price. func GetPrice() int { return contract.Call(interop.Hash160(Hash), "getPrice", contract.ReadStates).(int) } -// SetPrice represents `setPrice` method of Oracle native contract. +// SetPrice allows to set oracle request price. This method can only be +// successfully invoked by the committee. func SetPrice(amount int) { contract.Call(interop.Hash160(Hash), "setPrice", contract.States, amount) }