diff --git a/Makefile b/Makefile index fbfdf6e3..6b57ccb9 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ #!/usr/bin/env gmake OASIS_RELEASE := 20.8.2 -ROSETTA_CLI_RELEASE := 0.2.5 +ROSETTA_CLI_RELEASE := 0.4.0 OASIS_GO ?= go GO := env -u GOPATH $(OASIS_GO) diff --git a/go.mod b/go.mod index 47fa0e8d..3419e5ac 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,11 @@ go 1.14 replace github.com/tendermint/tendermint => github.com/oasisprotocol/tendermint v0.33.4-oasis2 require ( - github.com/coinbase/rosetta-sdk-go v0.2.0 + github.com/coinbase/rosetta-cli v0.4.0 + github.com/coinbase/rosetta-sdk-go v0.3.3 + github.com/dgraph-io/badger v1.6.1 + github.com/oasisprotocol/ed25519 v0.0.0-20200528083105-55566edd6df0 github.com/oasisprotocol/oasis-core/go v0.0.0-20200702171459-20d1a2dc6b66 + github.com/vmihailenco/msgpack/v5 v5.0.0-beta.1 google.golang.org/grpc v1.29.1 ) diff --git a/main.go b/main.go index da7f68d4..3586cdae 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "github.com/coinbase/rosetta-sdk-go/asserter" "github.com/coinbase/rosetta-sdk-go/server" "github.com/coinbase/rosetta-sdk-go/types" + "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" "github.com/oasisprotocol/oasis-core/go/common/logging" "github.com/oasisprotocol/oasis-core-rosetta-gateway/oasis-client" @@ -33,6 +34,8 @@ func NewBlockchainRouter(oasisClient oasis_client.OasisClient) (http.Handler, er } asserter, err := asserter.NewServer( + services.SupportedOperationTypes, + true, []*types.NetworkIdentifier{ &types.NetworkIdentifier{ Blockchain: services.OasisBlockchainName, @@ -94,6 +97,9 @@ func main() { os.Exit(1) } + // Set the chain context for preparing signing payloads. + signature.SetChainContext(cid) + // Initialize logging. err = logging.Initialize(os.Stdout, logging.FmtLogfmt, logging.LevelDebug, nil) if err != nil { diff --git a/services/account.go b/services/account.go index c1258894..e9e7daef 100644 --- a/services/account.go +++ b/services/account.go @@ -7,13 +7,11 @@ import ( "github.com/coinbase/rosetta-sdk-go/server" "github.com/coinbase/rosetta-sdk-go/types" - oc "github.com/oasisprotocol/oasis-core-rosetta-gateway/oasis-client" "github.com/oasisprotocol/oasis-core/go/common/logging" staking "github.com/oasisprotocol/oasis-core/go/staking/api" -) -// SubAccountGeneral specifies the name of the general subaccount. -const SubAccountGeneral = "general" + oc "github.com/oasisprotocol/oasis-core-rosetta-gateway/oasis-client" +) // SubAccountEscrow specifies the name of the escrow subaccount. const SubAccountEscrow = "escrow" @@ -65,17 +63,12 @@ func (s *accountAPIService) AccountBalance( return nil, ErrInvalidAccountAddress } - if request.AccountIdentifier.SubAccount == nil { - loggerAcct.Error("AccountBalance: invalid sub-account (empty)") + switch { + case request.AccountIdentifier.SubAccount == nil: + case request.AccountIdentifier.SubAccount.Address == SubAccountEscrow: + default: + loggerAcct.Error("AccountBalance: invalid subaccount", "sub_account", request.AccountIdentifier.SubAccount) return nil, ErrMustSpecifySubAccount - } else { - switch request.AccountIdentifier.SubAccount.Address { - case SubAccountGeneral: - case SubAccountEscrow: - default: - loggerAcct.Error("AccountBalance: invalid sub-account", "subaccount", request.AccountIdentifier.SubAccount.Address) - return nil, ErrMustSpecifySubAccount - } } act, err := s.oasisClient.GetAccount(ctx, height, owner) @@ -101,10 +94,10 @@ func (s *accountAPIService) AccountBalance( md[NonceKey] = act.General.Nonce var value string - switch request.AccountIdentifier.SubAccount.Address { - case SubAccountGeneral: + switch { + case request.AccountIdentifier.SubAccount == nil: value = act.General.Balance.String() - case SubAccountEscrow: + case request.AccountIdentifier.SubAccount.Address == SubAccountEscrow: // Total is Active + Debonding. total := act.Escrow.Active.Balance.Clone() if err := total.Add(&act.Escrow.Debonding.Balance); err != nil { @@ -141,7 +134,7 @@ func (s *accountAPIService) AccountBalance( loggerAcct.Debug("AccountBalance OK", "response", jr, "account_id", owner.String(), - "subaccount", request.AccountIdentifier.SubAccount.Address, + "sub_account", request.AccountIdentifier.SubAccount, ) return resp, nil diff --git a/services/block.go b/services/block.go index ad454420..9ce6394a 100644 --- a/services/block.go +++ b/services/block.go @@ -7,9 +7,10 @@ import ( "github.com/coinbase/rosetta-sdk-go/server" "github.com/coinbase/rosetta-sdk-go/types" - oc "github.com/oasisprotocol/oasis-core-rosetta-gateway/oasis-client" "github.com/oasisprotocol/oasis-core/go/common/logging" staking "github.com/oasisprotocol/oasis-core/go/staking/api" + + oc "github.com/oasisprotocol/oasis-core-rosetta-gateway/oasis-client" ) // OpTransfer is the Transfer operation. @@ -41,7 +42,7 @@ func NewBlockAPIService(oasisClient oc.OasisClient) server.BlockAPIServicer { } // Helper for making ops in a succinct way. -func appendOp(ops []*types.Operation, kind string, acct string, subacct string, amt string) []*types.Operation { +func appendOp(ops []*types.Operation, kind string, acct string, subacct *types.SubAccountIdentifier, amt string) []*types.Operation { opidx := int64(len(ops)) op := &types.Operation{ OperationIdentifier: &types.OperationIdentifier{ @@ -50,10 +51,8 @@ func appendOp(ops []*types.Operation, kind string, acct string, subacct string, Type: kind, Status: OpStatusOK, Account: &types.AccountIdentifier{ - Address: acct, - SubAccount: &types.SubAccountIdentifier{ - Address: subacct, - }, + Address: acct, + SubAccount: subacct, }, Amount: &types.Amount{ Value: amt, @@ -137,24 +136,24 @@ func (s *blockAPIService) Block( switch { case evt.Transfer != nil: - txns[txidx].Operations = appendOp(txns[txidx].Operations, OpTransfer, evt.Transfer.From.String(), SubAccountGeneral, "-"+evt.Transfer.Tokens.String()) - txns[txidx].Operations = appendOp(txns[txidx].Operations, OpTransfer, evt.Transfer.To.String(), SubAccountGeneral, evt.Transfer.Tokens.String()) + txns[txidx].Operations = appendOp(txns[txidx].Operations, OpTransfer, StringFromAddress(evt.Transfer.From), nil, "-"+evt.Transfer.Tokens.String()) + txns[txidx].Operations = appendOp(txns[txidx].Operations, OpTransfer, StringFromAddress(evt.Transfer.To), nil, evt.Transfer.Tokens.String()) case evt.Burn != nil: - txns[txidx].Operations = appendOp(txns[txidx].Operations, OpBurn, evt.Burn.Owner.String(), SubAccountGeneral, "-"+evt.Burn.Tokens.String()) + txns[txidx].Operations = appendOp(txns[txidx].Operations, OpBurn, StringFromAddress(evt.Burn.Owner), nil, "-"+evt.Burn.Tokens.String()) case evt.Escrow != nil: ee := evt.Escrow switch { case ee.Add != nil: // Owner's general account -> escrow account. - txns[txidx].Operations = appendOp(txns[txidx].Operations, OpTransfer, ee.Add.Owner.String(), SubAccountGeneral, "-"+ee.Add.Tokens.String()) - txns[txidx].Operations = appendOp(txns[txidx].Operations, OpTransfer, ee.Add.Escrow.String(), SubAccountEscrow, ee.Add.Tokens.String()) + txns[txidx].Operations = appendOp(txns[txidx].Operations, OpTransfer, StringFromAddress(ee.Add.Owner), nil, "-"+ee.Add.Tokens.String()) + txns[txidx].Operations = appendOp(txns[txidx].Operations, OpTransfer, StringFromAddress(ee.Add.Escrow), &types.SubAccountIdentifier{Address: SubAccountEscrow}, ee.Add.Tokens.String()) case ee.Take != nil: - txns[txidx].Operations = appendOp(txns[txidx].Operations, OpTransfer, ee.Take.Owner.String(), SubAccountEscrow, "-"+ee.Take.Tokens.String()) - txns[txidx].Operations = appendOp(txns[txidx].Operations, OpTransfer, staking.CommonPoolAddress.String(), SubAccountGeneral, ee.Take.Tokens.String()) + txns[txidx].Operations = appendOp(txns[txidx].Operations, OpTransfer, StringFromAddress(ee.Take.Owner), &types.SubAccountIdentifier{Address: SubAccountEscrow}, "-"+ee.Take.Tokens.String()) + txns[txidx].Operations = appendOp(txns[txidx].Operations, OpTransfer, StringFromAddress(staking.CommonPoolAddress), nil, ee.Take.Tokens.String()) case ee.Reclaim != nil: // Escrow account -> owner's general account. - txns[txidx].Operations = appendOp(txns[txidx].Operations, OpTransfer, ee.Reclaim.Escrow.String(), SubAccountEscrow, "-"+ee.Reclaim.Tokens.String()) - txns[txidx].Operations = appendOp(txns[txidx].Operations, OpTransfer, ee.Reclaim.Owner.String(), SubAccountGeneral, ee.Reclaim.Tokens.String()) + txns[txidx].Operations = appendOp(txns[txidx].Operations, OpTransfer, StringFromAddress(ee.Reclaim.Escrow), &types.SubAccountIdentifier{Address: SubAccountEscrow}, "-"+ee.Reclaim.Tokens.String()) + txns[txidx].Operations = appendOp(txns[txidx].Operations, OpTransfer, StringFromAddress(ee.Reclaim.Owner), nil, ee.Reclaim.Tokens.String()) } } } diff --git a/services/common.go b/services/common.go index b092f4d0..81c9b7ff 100644 --- a/services/common.go +++ b/services/common.go @@ -4,6 +4,7 @@ import ( "context" "github.com/coinbase/rosetta-sdk-go/types" + staking "github.com/oasisprotocol/oasis-core/go/staking/api" oc "github.com/oasisprotocol/oasis-core-rosetta-gateway/oasis-client" ) @@ -17,6 +18,11 @@ var OasisCurrency = &types.Currency{ Decimals: 9, } +// PoolShare is the currency used for debonding. +var PoolShare = &types.Currency{ + Symbol: "(pool share)", +} + // GetChainID returns the chain ID. func GetChainID(ctx context.Context, oc oc.OasisClient) (string, *types.Error) { chainID, err := oc.GetChainID(ctx) @@ -47,3 +53,13 @@ func ValidateNetworkIdentifier(ctx context.Context, oc oc.OasisClient, ni *types } return nil } + +// StringFromAddress converts a staking API address to string using MarshalText. +// If marshalling fails, this panics. +func StringFromAddress(address staking.Address) string { + buf, err := address.MarshalText() + if err != nil { + panic(err) + } + return string(buf) +} diff --git a/services/construction.go b/services/construction.go index cdd37b44..11e0e818 100644 --- a/services/construction.go +++ b/services/construction.go @@ -1,16 +1,24 @@ +// https://djr6hkgq2tjcs.cloudfront.net/docs/construction_api_introduction.html package services import ( "context" + "encoding/hex" "encoding/json" + "fmt" + "math/big" "github.com/coinbase/rosetta-sdk-go/server" "github.com/coinbase/rosetta-sdk-go/types" - - oc "github.com/oasisprotocol/oasis-core-rosetta-gateway/oasis-client" + "github.com/oasisprotocol/oasis-core/go/common/cbor" "github.com/oasisprotocol/oasis-core/go/common/crypto/hash" + "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" "github.com/oasisprotocol/oasis-core/go/common/logging" + "github.com/oasisprotocol/oasis-core/go/common/quantity" + "github.com/oasisprotocol/oasis-core/go/consensus/api/transaction" staking "github.com/oasisprotocol/oasis-core/go/staking/api" + + oc "github.com/oasisprotocol/oasis-core-rosetta-gateway/oasis-client" ) // OptionsIDKey is the name of the key in the Options map inside a @@ -21,6 +29,20 @@ const OptionsIDKey = "id" // ConstructionMetadataResponse that specifies the next valid nonce. const NonceKey = "nonce" +// FeeGasKey is the name of the key in the Metadata map inside a fee +// operation that specifies the gas value in the transaction fee. +// This is optional, and we use DefaultGas if it's absent. +const FeeGasKey = "fee_gas" + +// DefaultGas is the gas limit used in creating a transaction. +const DefaultGas transaction.Gas = 10000 + +// UnsignedTransaction is a transaction with the account that would sign it. +type UnsignedTransaction struct { + Tx transaction.Transaction `json:"tx"` + Signer string `json:"signer"` +} + var loggerCons = logging.GetLogger("services/construction") type constructionAPIService struct { @@ -96,7 +118,7 @@ func (s *constructionAPIService) ConstructionMetadata( func (s *constructionAPIService) ConstructionSubmit( ctx context.Context, request *types.ConstructionSubmitRequest, -) (*types.ConstructionSubmitResponse, *types.Error) { +) (*types.TransactionIdentifierResponse, *types.Error) { terr := ValidateNetworkIdentifier(ctx, s.oasisClient, request.NetworkIdentifier) if terr != nil { loggerCons.Error("ConstructionSubmit: network validation failed", "err", terr.Message) @@ -108,12 +130,19 @@ func (s *constructionAPIService) ConstructionSubmit( return nil, ErrUnableToSubmitTx } - // TODO: Does this match the hashes we actually use in consensus? var h hash.Hash - h.From(request.SignedTransaction) + var st transaction.SignedTransaction + if err := json.Unmarshal([]byte(request.SignedTransaction), &st); err != nil { + loggerCons.Error("ConstructionSubmit: unmarshal unsigned transaction", + "unsigned_transaction", request.SignedTransaction, + "err", err, + ) + return nil, ErrMalformedValue + } + h.From(st) txID := h.String() - resp := &types.ConstructionSubmitResponse{ + resp := &types.TransactionIdentifierResponse{ TransactionIdentifier: &types.TransactionIdentifier{ Hash: txID, }, @@ -124,3 +153,724 @@ func (s *constructionAPIService) ConstructionSubmit( return resp, nil } + +// ConstructionHash implements the /construction/hash endpoint. +func (s *constructionAPIService) ConstructionHash( + ctx context.Context, + request *types.ConstructionHashRequest, +) (*types.TransactionIdentifierResponse, *types.Error) { + terr := ValidateNetworkIdentifier(ctx, s.oasisClient, request.NetworkIdentifier) + if terr != nil { + loggerCons.Error("ConstructionHash: network validation failed", "err", terr.Message) + return nil, terr + } + + var h hash.Hash + var st transaction.SignedTransaction + if err := json.Unmarshal([]byte(request.SignedTransaction), &st); err != nil { + loggerCons.Error("ConstructionHash: unmarshal unsigned transaction", + "unsigned_transaction", request.SignedTransaction, + "err", err, + ) + return nil, ErrMalformedValue + } + h.From(st) + txID := h.String() + + resp := &types.TransactionIdentifierResponse{ + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: txID, + }, + } + + jr, _ := json.Marshal(resp) + loggerCons.Debug("ConstructionHash OK", "response", jr) + + return resp, nil +} + +// ConstructionDerive implements the /construction/derive endpoint. +func (s *constructionAPIService) ConstructionDerive( + ctx context.Context, + request *types.ConstructionDeriveRequest, +) (*types.ConstructionDeriveResponse, *types.Error) { + terr := ValidateNetworkIdentifier(ctx, s.oasisClient, request.NetworkIdentifier) + if terr != nil { + loggerCons.Error("ConstructionDerive: network validation failed", "err", terr.Message) + return nil, terr + } + + var pk signature.PublicKey + if err := pk.UnmarshalBinary(request.PublicKey.Bytes); err != nil { + loggerCons.Error("ConstructionDerive: malformed public key", + "public_key_hex_bytes", hex.EncodeToString(request.PublicKey.Bytes), + "err", err, + ) + return nil, ErrMalformedValue + } + + resp := &types.ConstructionDeriveResponse{ + Address: StringFromAddress(staking.NewAddress(pk)), + } + + jr, _ := json.Marshal(resp) + loggerCons.Debug("ConstructionDerive OK", "response", jr) + + return resp, nil +} + +// ConstructionCombine implements the /construction/combine endpoint. +func (s *constructionAPIService) ConstructionCombine( + ctx context.Context, + request *types.ConstructionCombineRequest, +) (*types.ConstructionCombineResponse, *types.Error) { + terr := ValidateNetworkIdentifier(ctx, s.oasisClient, request.NetworkIdentifier) + if terr != nil { + loggerCons.Error("ConstructionCombine: network validation failed", "err", terr.Message) + return nil, terr + } + + // Combine creates a network-specific transaction from an unsigned + // transaction and an array of provided signatures. The signed + // transaction returned from this method will be sent to the + // `/construction/submit` endpoint by the caller. + + var ut UnsignedTransaction + if err := json.Unmarshal([]byte(request.UnsignedTransaction), &ut); err != nil { + loggerCons.Error("ConstructionCombine: unmarshal unsigned transaction", + "unsigned_transaction", request.UnsignedTransaction, + "err", err, + ) + return nil, ErrMalformedValue + } + txBuf := cbor.Marshal(ut.Tx) + if len(request.Signatures) != 1 { + loggerCons.Error("ConstructionCombine: need exactly one signature", + "len_signatures", len(request.Signatures), + ) + return nil, ErrMalformedValue + } + sig := request.Signatures[0] + var pk signature.PublicKey + if err := pk.UnmarshalBinary(sig.PublicKey.Bytes); err != nil { + loggerCons.Error("ConstructionCombine: malformed signature public key", + "public_key_hex_bytes", hex.EncodeToString(sig.PublicKey.Bytes), + "err", err, + ) + return nil, ErrMalformedValue + } + var rs signature.RawSignature + if err := rs.UnmarshalBinary(sig.Bytes); err != nil { + loggerCons.Error("ConstructionCombine: malformed signature", + "signature_hex_bytes", hex.EncodeToString(sig.Bytes), + "err", err, + ) + return nil, ErrMalformedValue + } + st := transaction.SignedTransaction{ + Signed: signature.Signed{ + Blob: txBuf, + Signature: signature.Signature{ + PublicKey: pk, + Signature: rs, + }, + }, + } + stJSON, err := json.Marshal(st) + if err != nil { + loggerCons.Error("ConstructionCombine: marshal signed transaction", + "signed_transaction", st, + "err", err, + ) + return nil, ErrMalformedValue + } + + resp := &types.ConstructionCombineResponse{ + SignedTransaction: string(stJSON), + } + + jr, _ := json.Marshal(resp) + loggerCons.Debug("ConstructionCombine OK", "response", jr) + + return resp, nil +} + +// ConstructionParse implements the /construction/parse endpoint. +func (s *constructionAPIService) ConstructionParse( + ctx context.Context, + request *types.ConstructionParseRequest, +) (*types.ConstructionParseResponse, *types.Error) { + terr := ValidateNetworkIdentifier(ctx, s.oasisClient, request.NetworkIdentifier) + if terr != nil { + loggerCons.Error("ConstructionParse: network validation failed", "err", terr.Message) + return nil, terr + } + + // Parse is called on both unsigned and signed transactions to understand + // the intent of the formulated transaction. This is run as a sanity check + // before signing (after `/construction/payloads`) and before broadcast + // (after `/construction/combine`). + + // TODO: Unify some of this verboseness with block.go. If you prefer. + + var tx transaction.Transaction + var from string + var signers []string + if request.Signed { + var st transaction.SignedTransaction + if err := json.Unmarshal([]byte(request.Transaction), &st); err != nil { + loggerCons.Error("ConstructionParse: signed transaction unmarshal", + "src", request.Transaction, + "err", err, + ) + return nil, ErrMalformedValue + } + if err := st.Open(&tx); err != nil { + loggerCons.Error("ConstructionParse: signed transaction open", + "signed_transaction", st, + "err", err, + ) + return nil, ErrMalformedValue + } + from = StringFromAddress(staking.NewAddress(st.Signature.PublicKey)) + signers = []string{from} + } else { + var ut UnsignedTransaction + if err := json.Unmarshal([]byte(request.Transaction), &ut); err != nil { + loggerCons.Error("ConstructionParse: unsigned transaction unmarshal", + "src", request.Transaction, + "err", err, + ) + return nil, ErrMalformedValue + } + tx = ut.Tx + from = ut.Signer + } + + feeAmountStr := "-0" + feeGas := transaction.Gas(0) + if tx.Fee != nil { + feeAmountStr = "-" + tx.Fee.Amount.String() + feeGas = tx.Fee.Gas + } + ops := []*types.Operation{ + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + }, + Type: OpTransfer, + Account: &types.AccountIdentifier{ + Address: from, + }, + Amount: &types.Amount{ + Value: feeAmountStr, + Currency: OasisCurrency, + }, + Metadata: map[string]interface{}{ + FeeGasKey: feeGas, + }, + }, + } + switch tx.Method { + case staking.MethodTransfer: + var body staking.Transfer + if err := cbor.Unmarshal(tx.Body, &body); err != nil { + loggerCons.Error("ConstructionParse: transfer unmarshal", + "body", tx.Body, + "err", err, + ) + return nil, ErrMalformedValue + } + ops = append(ops, + &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + Type: OpTransfer, + Account: &types.AccountIdentifier{ + Address: from, + }, + Amount: &types.Amount{ + Value: "-" + body.Tokens.String(), + Currency: OasisCurrency, + }, + }, + &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: 2, + }, + Type: OpTransfer, + Account: &types.AccountIdentifier{ + Address: StringFromAddress(body.To), + }, + Amount: &types.Amount{ + Value: body.Tokens.String(), + Currency: OasisCurrency, + }, + }, + ) + case staking.MethodBurn: + var body staking.Burn + if err := cbor.Unmarshal(tx.Body, &body); err != nil { + loggerCons.Error("ConstructionParse: burn unmarshal", + "body", tx.Body, + "err", err, + ) + return nil, ErrMalformedValue + } + ops = append(ops, + &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + Type: OpBurn, + Account: &types.AccountIdentifier{ + Address: from, + }, + Amount: &types.Amount{ + Value: "-" + body.Tokens.String(), + Currency: OasisCurrency, + }, + }, + ) + case staking.MethodAddEscrow: + var body staking.Escrow + if err := cbor.Unmarshal(tx.Body, &body); err != nil { + loggerCons.Error("ConstructionParse: add escrow unmarshal", + "body", tx.Body, + "err", err, + ) + return nil, ErrMalformedValue + } + ops = append(ops, + &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + Type: OpTransfer, + Account: &types.AccountIdentifier{ + Address: from, + }, + Amount: &types.Amount{ + Value: "-" + body.Tokens.String(), + Currency: OasisCurrency, + }, + }, + &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: 2, + }, + Type: OpTransfer, + Account: &types.AccountIdentifier{ + Address: StringFromAddress(body.Account), + SubAccount: &types.SubAccountIdentifier{ + Address: SubAccountEscrow, + }, + }, + Amount: &types.Amount{ + Value: body.Tokens.String(), + Currency: OasisCurrency, + }, + }, + ) + case staking.MethodReclaimEscrow: + var body staking.ReclaimEscrow + if err := cbor.Unmarshal(tx.Body, &body); err != nil { + loggerCons.Error("ConstructionParse: reclaim escrow unmarshal", + "body", tx.Body, + "err", err, + ) + return nil, ErrMalformedValue + } + ops = append(ops, + &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + Type: OpTransfer, + Account: &types.AccountIdentifier{ + Address: StringFromAddress(body.Account), + SubAccount: &types.SubAccountIdentifier{ + Address: SubAccountEscrow, + }, + }, + Amount: &types.Amount{ + Value: "-" + body.Shares.String(), + Currency: PoolShare, + }, + }, + ) + default: + loggerCons.Error("ConstructionParse: unmatched method", + "method", tx.Method, + ) + return nil, ErrNotImplemented + } + + resp := &types.ConstructionParseResponse{ + Operations: ops, + Signers: signers, + Metadata: map[string]interface{}{ + NonceKey: tx.Nonce, + }, + } + + jr, _ := json.Marshal(resp) + loggerCons.Debug("ConstructionParse OK", "response", jr) + + return resp, nil +} + +// ConstructionPreprocess implements the /construction/preprocess endpoint. +func (s *constructionAPIService) ConstructionPreprocess( + ctx context.Context, + request *types.ConstructionPreprocessRequest, +) (*types.ConstructionPreprocessResponse, *types.Error) { + terr := ValidateNetworkIdentifier(ctx, s.oasisClient, request.NetworkIdentifier) + if terr != nil { + loggerCons.Error("ConstructionPreprocess: network validation failed", "err", terr.Message) + return nil, terr + } + + // Preprocess is called prior to `/construction/payloads` to construct a + // request for any metadata that is needed for transaction construction + // given (i.e. account nonce). The request returned from this method will + // be used by the caller (in a different execution environment) to call + // the `/construction/metadata` endpoint. + + if len(request.Operations) < 1 { + loggerCons.Error("ConstructionPreprocess: missing fee operation") + return nil, ErrMalformedValue + } + feeOp := request.Operations[0] + + resp := &types.ConstructionPreprocessResponse{ + Options: map[string]interface{}{ + OptionsIDKey: feeOp.Account.Address, + }, + } + + jr, _ := json.Marshal(resp) + loggerCons.Debug("ConstructionPreprocess OK", "response", jr) + + return resp, nil +} + +func readCurrency(amount *types.Amount, currency *types.Currency, negative bool) (*quantity.Quantity, error) { + // TODO: Is it up to us to check other fields? + if amount.Currency.Symbol != currency.Symbol { + return nil, fmt.Errorf("wrong currency") + } + bi := new(big.Int) + if err := bi.UnmarshalText([]byte(amount.Value)); err != nil { + return nil, fmt.Errorf("bi UnmarshalText Value: %w", err) + } + if negative { + bi.Neg(bi) + } + q := quantity.NewQuantity() + if err := q.FromBigInt(bi); err != nil { + return nil, fmt.Errorf("q FromBigInt bi: %w", err) + } + return q, nil +} + +func readOasisCurrency(amount *types.Amount) (*quantity.Quantity, error) { + return readCurrency(amount, OasisCurrency, false) +} + +func readOasisCurrencyNeg(amount *types.Amount) (*quantity.Quantity, error) { + return readCurrency(amount, OasisCurrency, true) +} + +func readPoolShareNeg(amount *types.Amount) (*quantity.Quantity, error) { + return readCurrency(amount, PoolShare, true) +} + +// ConstructionPayloads implements the /construction/payloads endpoint. +func (s *constructionAPIService) ConstructionPayloads( + ctx context.Context, + request *types.ConstructionPayloadsRequest, +) (*types.ConstructionPayloadsResponse, *types.Error) { + terr := ValidateNetworkIdentifier(ctx, s.oasisClient, request.NetworkIdentifier) + if terr != nil { + loggerCons.Error("ConstructionPayloads: network validation failed", "err", terr.Message) + return nil, terr + } + + // Payloads is called with an array of operations and the response from + // `/construction/metadata`. It returns an unsigned transaction blob and + // a collection of payloads that must be signed by particular addresses + // using a certain SignatureType. The array of operations provided in + // transaction construction often times can not specify all "effects" of + // a transaction (consider invoked transactions in Ethereum). However, + // they can deterministically specify the "intent" of the transaction, + // which is sufficient for construction. For this reason, parsing the + // corresponding transaction in the Data API (when it lands on chain) + // will contain a superset of whatever operations were provided during + // construction. + + nonceRaw, ok := request.Metadata[NonceKey] + if !ok { + loggerCons.Error("ConstructionPayloads: nonce metadata not given") + return nil, ErrMalformedValue + } + nonceF64, ok := nonceRaw.(float64) + if !ok { + loggerCons.Error("ConstructionPayloads: malformed nonce metadata") + return nil, ErrMalformedValue + } + nonce := uint64(nonceF64) + + if len(request.Operations) < 2 { + loggerCons.Error("ConstructionPayloads: missing fee operation") + return nil, ErrMalformedValue + } + feeOp := request.Operations[0] + if feeOp.Type != OpTransfer { + loggerCons.Error("ConstructionPayloads: fee operation wrong type", + "type", feeOp.Type, + "expected_type", OpTransfer, + ) + return nil, ErrMalformedValue + } + if feeOp.Account.SubAccount != nil { + loggerCons.Error("ConstructionPayloads: fee operation wrong subaccount", + "sub_account", feeOp.Account.SubAccount, + "expected_sub_account", nil, + ) + return nil, ErrMalformedValue + } + signWithAddr := feeOp.Account.Address + feeAmount, err := readOasisCurrencyNeg(feeOp.Amount) + if err != nil { + loggerCons.Error("ConstructionPayloads: readOasisCurrency feeOp.Amount", + "amount", feeOp.Amount, + "err", err, + ) + return nil, ErrMalformedValue + } + feeGas := DefaultGas + if feeGasRaw, ok := feeOp.Metadata[FeeGasKey]; ok { + feeGasF64, ok := feeGasRaw.(float64) + if !ok { + loggerCons.Error("ConstructionPayloads: malformed fee gas metadata") + return nil, ErrMalformedValue + } + feeGas = transaction.Gas(feeGasF64) + } + + var method transaction.MethodName + var body cbor.RawMessage + switch { + case len(request.Operations) == 3 && + request.Operations[1].Type == OpTransfer && + request.Operations[1].Account.SubAccount == nil && + request.Operations[2].Type == OpTransfer && + request.Operations[2].Account.SubAccount == nil: + loggerCons.Debug("ConstructionPayloads: matched transfer") + method = staking.MethodTransfer + + if request.Operations[1].Account.Address != signWithAddr { + loggerCons.Error("ConstructionPayloads: transfer from doesn't match signer", + "from", request.Operations[1].Account.Address, + "signer", signWithAddr, + ) + return nil, ErrMalformedValue + } + amount, err := readOasisCurrencyNeg(request.Operations[1].Amount) + if err != nil { + loggerCons.Error("ConstructionPayloads: transfer from amount", + "amount", request.Operations[1].Amount, + "err", err, + ) + return nil, ErrMalformedValue + } + + var to staking.Address + if err = to.UnmarshalText([]byte(request.Operations[2].Account.Address)); err != nil { + loggerCons.Error("ConstructionPayloads: transfer to UnmarshalText", + "addr", request.Operations[2].Account.Address, + "err", err, + ) + } + amount2, err := readOasisCurrency(request.Operations[2].Amount) + if err != nil { + loggerCons.Error("ConstructionPayloads: transfer to amount", + "amount", request.Operations[2].Amount, + "err", err, + ) + return nil, ErrMalformedValue + } + if amount.Cmp(amount2) != 0 { + loggerCons.Error("ConstructionPayloads: transfer amounts differ", + "amount_from", amount, + "amount_to", amount2, + "err", err, + ) + return nil, ErrMalformedValue + } + + body = cbor.Marshal(staking.Transfer{ + To: to, + Tokens: *amount, + }) + case len(request.Operations) == 2 && + request.Operations[1].Type == OpBurn && + request.Operations[1].Account.SubAccount == nil: + loggerCons.Debug("ConstructionPayloads: matched burn") + method = staking.MethodBurn + + if request.Operations[1].Account.Address != signWithAddr { + loggerCons.Error("ConstructionPayloads: burn from doesn't match signer", + "from", request.Operations[1].Account.Address, + "signer", signWithAddr, + ) + return nil, ErrMalformedValue + } + amount, err := readOasisCurrencyNeg(request.Operations[1].Amount) + if err != nil { + loggerCons.Error("ConstructionPayloads: burn from amount", + "amount", request.Operations[1].Amount, + "err", err, + ) + return nil, ErrMalformedValue + } + + body = cbor.Marshal(staking.Burn{ + Tokens: *amount, + }) + case len(request.Operations) == 3 && + request.Operations[1].Type == OpTransfer && + request.Operations[1].Account.SubAccount == nil && + request.Operations[2].Type == OpTransfer && + request.Operations[2].Account.SubAccount != nil && + request.Operations[2].Account.SubAccount.Address == SubAccountEscrow: + loggerCons.Debug("ConstructionPayloads: matched add escrow") + method = staking.MethodAddEscrow + + if request.Operations[1].Account.Address != signWithAddr { + loggerCons.Error("ConstructionPayloads: add escrow from doesn't match signer", + "from", request.Operations[1].Account.Address, + "signer", signWithAddr, + ) + return nil, ErrMalformedValue + } + amount, err := readOasisCurrencyNeg(request.Operations[1].Amount) + if err != nil { + loggerCons.Error("ConstructionPayloads: add escrow from amount", + "amount", request.Operations[1].Amount, + "err", err, + ) + return nil, ErrMalformedValue + } + + var escrowAccount staking.Address + if err = escrowAccount.UnmarshalText([]byte(request.Operations[2].Account.Address)); err != nil { + loggerCons.Error("ConstructionPayloads: add escrow account UnmarshalText", + "addr", request.Operations[2].Account.Address, + "err", err, + ) + } + amount2, err := readOasisCurrency(request.Operations[2].Amount) + if err != nil { + loggerCons.Error("ConstructionPayloads: add escrow account amount", + "amount", request.Operations[2].Amount, + "err", err, + ) + return nil, ErrMalformedValue + } + if amount.Cmp(amount2) != 0 { + loggerCons.Error("ConstructionPayloads: add escrow amounts differ", + "amount_from", amount, + "amount_to", amount2, + "err", err, + ) + return nil, ErrMalformedValue + } + + body = cbor.Marshal(staking.Escrow{ + Account: escrowAccount, + Tokens: *amount, + }) + case len(request.Operations) == 2 && + request.Operations[1].Type == OpTransfer && + request.Operations[1].Account.SubAccount != nil && + request.Operations[1].Account.SubAccount.Address == SubAccountEscrow: + loggerCons.Debug("ConstructionPayloads: matched reclaim escrow") + method = staking.MethodReclaimEscrow + + var escrowAccount staking.Address + if err = escrowAccount.UnmarshalText([]byte(request.Operations[1].Account.Address)); err != nil { + loggerCons.Error("ConstructionPayloads: reclaim escrow from UnmarshalText", + "addr", request.Operations[1].Account.Address, + "err", err, + ) + } + amount, err := readPoolShareNeg(request.Operations[1].Amount) + if err != nil { + loggerCons.Error("ConstructionPayloads: reclaim escrow from amount", + "amount", request.Operations[1].Amount, + "err", err, + ) + return nil, ErrMalformedValue + } + + body = cbor.Marshal(staking.ReclaimEscrow{ + Account: escrowAccount, + Shares: *amount, + }) + default: + loggerCons.Error("ConstructionPayloads: unmatched operations list", + "operations", request.Operations, + ) + return nil, ErrNotImplemented + } + + ut := UnsignedTransaction{ + Tx: transaction.Transaction{ + Nonce: nonce, + Fee: &transaction.Fee{ + Amount: *feeAmount, + Gas: feeGas, + }, + Method: method, + Body: body, + }, + Signer: signWithAddr, + } + + utJSON, err := json.Marshal(ut) + if err != nil { + loggerCons.Error("ConstructionPayloads: marshal unsigned transaction", + "unsigned_transaction", ut, + "err", err, + ) + return nil, ErrMalformedValue + } + txCBOR := cbor.Marshal(ut.Tx) + txMessage, err := signature.PrepareSignerMessage(transaction.SignatureContext, txCBOR) + if err != nil { + loggerCons.Error("ConstructionPayloads: PrepareSignerMessage", + "signature_context", transaction.SignatureContext, + "tx_hex", hex.EncodeToString(txCBOR), + "err", err, + ) + return nil, ErrMalformedValue + } + resp := &types.ConstructionPayloadsResponse{ + UnsignedTransaction: string(utJSON), + Payloads: []*types.SigningPayload{ + { + Address: signWithAddr, + Bytes: txMessage, + SignatureType: types.Ed25519, + }, + }, + } + + jr, _ := json.Marshal(resp) + loggerCons.Debug("ConstructionPayloads OK", "response", jr) + + return resp, nil +} diff --git a/services/errors.go b/services/errors.go index d9d2ff46..656e7e4e 100644 --- a/services/errors.go +++ b/services/errors.go @@ -65,7 +65,7 @@ var ( ErrMustSpecifySubAccount = &types.Error{ Code: 11, - Message: "a valid subaccount must be specified ('general' or 'escrow')", + Message: "a valid subaccount must be specified (absent or {\"address\": \"escrow\"})", Retriable: false, } diff --git a/services/network.go b/services/network.go index 8da320eb..891129e9 100644 --- a/services/network.go +++ b/services/network.go @@ -8,8 +8,9 @@ import ( "github.com/coinbase/rosetta-sdk-go/server" "github.com/coinbase/rosetta-sdk-go/types" - oc "github.com/oasisprotocol/oasis-core-rosetta-gateway/oasis-client" "github.com/oasisprotocol/oasis-core/go/common/logging" + + oc "github.com/oasisprotocol/oasis-core-rosetta-gateway/oasis-client" ) var loggerNet = logging.GetLogger("services/network") @@ -113,7 +114,7 @@ func (s *networkAPIService) NetworkOptions( return &types.NetworkOptionsResponse{ Version: &types.Version{ - RosettaVersion: "1.3.5", + RosettaVersion: "1.4.0", NodeVersion: status.SoftwareVersion, }, Allow: &types.Allow{ diff --git a/tests/check-prep/main.go b/tests/check-prep/main.go new file mode 100644 index 00000000..b52caf8e --- /dev/null +++ b/tests/check-prep/main.go @@ -0,0 +1,146 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + "os" + "path" + + "github.com/coinbase/rosetta-cli/configuration" + "github.com/coinbase/rosetta-sdk-go/client" + "github.com/coinbase/rosetta-sdk-go/keys" + "github.com/coinbase/rosetta-sdk-go/types" + "github.com/dgraph-io/badger" + "github.com/vmihailenco/msgpack/v5" + + "github.com/oasisprotocol/oasis-core-rosetta-gateway/services" + "github.com/oasisprotocol/oasis-core-rosetta-gateway/tests/common" +) + +func storageEncode(v interface{}) []byte { + var buf bytes.Buffer + enc := msgpack.GetEncoder() + enc.Reset(&buf) + enc.UseJSONTag(true) + if err := enc.Encode(v); err != nil { + panic(err) + } + msgpack.PutEncoder(enc) + return buf.Bytes() +} + +func main() { + // Create a configuration file for the local testnet. + config := configuration.DefaultConfiguration() + + rc := client.NewAPIClient(client.NewConfiguration("http://localhost:8080", "rosetta-sdk-go", nil)) + nlr, re, err := rc.NetworkAPI.NetworkList(context.Background(), &types.MetadataRequest{}) + if err != nil { + panic(err) + } + if re != nil { + panic(re) + } + if len(nlr.NetworkIdentifiers) != 1 { + panic("len(nlr.NetworkIdentifiers)") + } + fmt.Println("network identifiers", common.DumpJSON(nlr.NetworkIdentifiers)) + config.Network = nlr.NetworkIdentifiers[0] + + config.DataDirectory = "/tmp/rosetta-cli-oasistests" + + config.Construction.Currency = services.OasisCurrency + config.Construction.MaximumFee = "0" + config.Construction.CurveType = types.Edwards25519 + config.Construction.Scenario = []*types.Operation{ + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + }, + Type: services.OpTransfer, + Account: &types.AccountIdentifier{ + Address: "{{ SENDER }}", + }, + Amount: &types.Amount{ + Value: "-100", + Currency: services.OasisCurrency, + }, + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + Type: services.OpTransfer, + Account: &types.AccountIdentifier{ + Address: "{{ SENDER }}", + }, + Amount: &types.Amount{ + Value: "{{ SENDER_VALUE }}", + Currency: services.OasisCurrency, + }, + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 2, + }, + Type: services.OpTransfer, + Account: &types.AccountIdentifier{ + Address: "{{ RECIPIENT }}", + }, + Amount: &types.Amount{ + Value: "{{ RECIPIENT_VALUE }}", + Currency: services.OasisCurrency, + }, + }, + } + + if err := ioutil.WriteFile("rosetta-cli-config.json", []byte(common.DumpJSON(config)), 0o666); err != nil { + panic(err) + } + + // Create an account for construction tests. + testEntityAddress, testEntityKeyPair := common.TestEntity() + + constructionStorePath := path.Join(config.DataDirectory, "check-construction", types.Hash(config.Network)) + if err := os.MkdirAll(constructionStorePath, 0o777); err != nil { + panic(err) + } + db, err := badger.Open(badger.DefaultOptions(constructionStorePath)) + if err != nil { + panic(err) + } + if err := db.Update(func(txn *badger.Txn) error { + if err := txn.Set([]byte("key/"+testEntityAddress), storageEncode(&struct { + Address string `json:"address"` + KeyPair *keys.KeyPair `json:"keypair"` + }{ + Address: testEntityAddress, + KeyPair: testEntityKeyPair, + })); err != nil { + panic(err) + } + testEntityAccountIdentifier := types.AccountIdentifier{Address: testEntityAddress} + if err := txn.Set([]byte("balance/"+types.Hash(&testEntityAccountIdentifier)+"/"+types.Hash(services.OasisCurrency)), storageEncode(&struct { + Account *types.AccountIdentifier `json:"account"` + Amount *types.Amount `json:"amount"` + Block *types.BlockIdentifier `json:"block"` + }{ + Account: &testEntityAccountIdentifier, + Amount: &types.Amount{ + // https://github.com/oasisprotocol/oasis-core/blob/v20.8.2/go/oasis-node/cmd/genesis/genesis.go#L534 + Value: "100000000000", + Currency: services.OasisCurrency, + }, + })); err != nil { + panic(err) + } + return nil + }); err != nil { + panic(err) + } + if err := db.Close(); err != nil { + panic(err) + } +} diff --git a/tests/common/common.go b/tests/common/common.go new file mode 100644 index 00000000..65df2d2a --- /dev/null +++ b/tests/common/common.go @@ -0,0 +1,54 @@ +package common + +import ( + "crypto/sha512" + "encoding/json" + "fmt" + + "github.com/coinbase/rosetta-sdk-go/keys" + "github.com/coinbase/rosetta-sdk-go/types" + "github.com/oasisprotocol/ed25519" + "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" + "github.com/oasisprotocol/oasis-core/go/common/entity" + "github.com/oasisprotocol/oasis-core/go/staking/api" + + "github.com/oasisprotocol/oasis-core-rosetta-gateway/services" +) + +const DstAddress = "oasis1qpkant39yhx59sagnzpc8v0sg8aerwa3jyqde3ge" + +func DumpJSON(v interface{}) string { + result, err := json.Marshal(v) + if err != nil { + panic(err) + } + return string(result) +} + +func TestEntity() (string, *keys.KeyPair) { + _, signer, err := entity.TestEntity() + if err != nil { + panic(err) + } + address := services.StringFromAddress(api.NewAddress(signer.Public())) + + seed := sha512.Sum512_256([]byte("ekiden test entity key seed")) + priv := ed25519.NewKeyFromSeed(seed[:]) + pub := priv.Public().(ed25519.PublicKey) + var pub2 signature.PublicKey + if err = pub2.UnmarshalBinary(pub); err != nil { + panic(err) + } + if pub2 != signer.Public() { + panic(fmt.Sprintf("public key mismatch %s %s", pub2, signer.Public())) + } + kp := &keys.KeyPair{ + PublicKey: &types.PublicKey{ + Bytes: pub, + CurveType: types.Edwards25519, + }, + PrivateKey: priv[:32], + } + + return address, kp +} diff --git a/tests/construction-signing/main.go b/tests/construction-signing/main.go new file mode 100644 index 00000000..59be5565 --- /dev/null +++ b/tests/construction-signing/main.go @@ -0,0 +1,226 @@ +package main + +import ( + "context" + "fmt" + "reflect" + + "github.com/coinbase/rosetta-sdk-go/client" + "github.com/coinbase/rosetta-sdk-go/keys" + "github.com/coinbase/rosetta-sdk-go/types" + + "github.com/oasisprotocol/oasis-core-rosetta-gateway/services" + "github.com/oasisprotocol/oasis-core-rosetta-gateway/tests/common" +) + +func main() { + testEntityAddress, testEntityKeyPair := common.TestEntity() + rs := keys.SignerEdwards25519{KeyPair: testEntityKeyPair} + + ops := []*types.Operation{ + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + }, + Type: services.OpTransfer, + Account: &types.AccountIdentifier{ + Address: testEntityAddress, + }, + Amount: &types.Amount{ + Value: "-0", + Currency: services.OasisCurrency, + }, + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + Type: services.OpTransfer, + Account: &types.AccountIdentifier{ + Address: testEntityAddress, + }, + Amount: &types.Amount{ + Value: "-1000", + Currency: services.OasisCurrency, + }, + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 2, + }, + Type: services.OpTransfer, + Account: &types.AccountIdentifier{ + Address: common.DstAddress, + }, + Amount: &types.Amount{ + Value: "1000", + Currency: services.OasisCurrency, + }, + }, + } + fmt.Println("operations", common.DumpJSON(ops)) + + rc := client.NewAPIClient(client.NewConfiguration("http://localhost:8080", "rosetta-sdk-go", nil)) + + r1, re, err := rc.NetworkAPI.NetworkList(context.Background(), &types.MetadataRequest{}) + if err != nil { + panic(err) + } + if re != nil { + panic(re) + } + if len(r1.NetworkIdentifiers) != 1 { + panic("len(r1.NetworkIdentifiers)") + } + fmt.Println("network identifiers", common.DumpJSON(r1.NetworkIdentifiers)) + ni := r1.NetworkIdentifiers[0] + + r2, re, err := rc.ConstructionAPI.ConstructionPreprocess(context.Background(), &types.ConstructionPreprocessRequest{ + NetworkIdentifier: ni, + Operations: ops, + }) + if err != nil { + panic(err) + } + if re != nil { + panic(re) + } + fmt.Println("metadata options", common.DumpJSON(r2.Options)) + + r3, re, err := rc.ConstructionAPI.ConstructionMetadata(context.Background(), &types.ConstructionMetadataRequest{ + NetworkIdentifier: ni, + Options: r2.Options, + }) + if err != nil { + panic(err) + } + if re != nil { + panic(re) + } + fmt.Println("metadata", common.DumpJSON(r3.Metadata)) + + r4, re, err := rc.ConstructionAPI.ConstructionPayloads(context.Background(), &types.ConstructionPayloadsRequest{ + NetworkIdentifier: ni, + Operations: ops, + Metadata: r3.Metadata, + }) + if err != nil { + panic(err) + } + if re != nil { + panic(re) + } + fmt.Println("unsigned transaction", r4.UnsignedTransaction) + fmt.Println("signing payloads", common.DumpJSON(r4.Payloads)) + + r4p, re, err := rc.ConstructionAPI.ConstructionParse(context.Background(), &types.ConstructionParseRequest{ + NetworkIdentifier: ni, + Signed: false, + Transaction: r4.UnsignedTransaction, + }) + if err != nil { + panic(err) + } + if re != nil { + panic(re) + } + fmt.Println("unsigned operations", common.DumpJSON(r4p.Operations)) + fmt.Println("unsigned signers", common.DumpJSON(r4p.Signers)) + fmt.Println("unsigned metadata", common.DumpJSON(r4p.Metadata)) + r4pRef := &types.ConstructionParseResponse{ + Operations: []*types.Operation{ + { + OperationIdentifier: ops[0].OperationIdentifier, + Type: ops[0].Type, + Account: ops[0].Account, + Amount: ops[0].Amount, + Metadata: map[string]interface{}{ + services.FeeGasKey: float64(services.DefaultGas), + }, + }, + ops[1], + ops[2], + }, + Metadata: r3.Metadata, + } + if !reflect.DeepEqual(r4p, r4pRef) { + fmt.Println("unsigned transaction parsed", common.DumpJSON(r4p)) + fmt.Println("reference", common.DumpJSON(r4pRef)) + panic(fmt.Errorf("unsigned transaction parsed wrong")) + } + + var sigs []*types.Signature + for i, sp := range r4.Payloads { + if sp.Address != testEntityAddress { + panic(i) + } + sig, err := rs.Sign(sp, sp.SignatureType) + if err != nil { + panic(err) + } + sigs = append(sigs, sig) + } + + r5, re, err := rc.ConstructionAPI.ConstructionCombine(context.Background(), &types.ConstructionCombineRequest{ + NetworkIdentifier: ni, + UnsignedTransaction: r4.UnsignedTransaction, + Signatures: sigs, + }) + if err != nil { + panic(err) + } + if re != nil { + panic(re) + } + fmt.Println("signed transaction", r5.SignedTransaction) + + r5p, re, err := rc.ConstructionAPI.ConstructionParse(context.Background(), &types.ConstructionParseRequest{ + NetworkIdentifier: ni, + Signed: true, + Transaction: r5.SignedTransaction, + }) + if err != nil { + panic(err) + } + if re != nil { + panic(re) + } + fmt.Println("signed operations", common.DumpJSON(r5p.Operations)) + fmt.Println("signed signers", common.DumpJSON(r5p.Signers)) + fmt.Println("signed metadata", common.DumpJSON(r5p.Metadata)) + r5pRef := &types.ConstructionParseResponse{ + Operations: []*types.Operation{ + { + OperationIdentifier: ops[0].OperationIdentifier, + Type: ops[0].Type, + Account: ops[0].Account, + Amount: ops[0].Amount, + Metadata: map[string]interface{}{ + services.FeeGasKey: float64(services.DefaultGas), + }, + }, + ops[1], + ops[2], + }, + Signers: []string{testEntityAddress}, + Metadata: r3.Metadata, + } + if !reflect.DeepEqual(r5p, r5pRef) { + fmt.Println("signed transaction parsed", common.DumpJSON(r5p)) + fmt.Println("reference", common.DumpJSON(r5pRef)) + panic(fmt.Errorf("signed transaction parsed wrong")) + } + + r6, re, err := rc.ConstructionAPI.ConstructionSubmit(context.Background(), &types.ConstructionSubmitRequest{ + NetworkIdentifier: ni, + SignedTransaction: r5.SignedTransaction, + }) + if err != nil { + panic(err) + } + if re != nil { + panic(re) + } + fmt.Println("transaction hash", r6.TransactionIdentifier.Hash) + fmt.Println("transaction metadata", common.DumpJSON(r6.Metadata)) +} diff --git a/tests/construction-txtypes/main.go b/tests/construction-txtypes/main.go new file mode 100644 index 00000000..65de0f94 --- /dev/null +++ b/tests/construction-txtypes/main.go @@ -0,0 +1,252 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + + "github.com/coinbase/rosetta-sdk-go/client" + "github.com/coinbase/rosetta-sdk-go/types" + "github.com/oasisprotocol/oasis-core/go/common/cbor" + "github.com/oasisprotocol/oasis-core/go/common/quantity" + "github.com/oasisprotocol/oasis-core/go/consensus/api/transaction" + "github.com/oasisprotocol/oasis-core/go/staking/api" + + "github.com/oasisprotocol/oasis-core-rosetta-gateway/services" + "github.com/oasisprotocol/oasis-core-rosetta-gateway/tests/common" +) + +const dummyNonce = 3 + +func main() { + testEntityAddress, _ := common.TestEntity() + + var dstAddr api.Address + if err := dstAddr.UnmarshalText([]byte(common.DstAddress)); err != nil { + panic(err) + } + fee100Op := &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + }, + Type: services.OpTransfer, + Account: &types.AccountIdentifier{ + Address: testEntityAddress, + }, + Amount: &types.Amount{ + Value: "-100", + Currency: services.OasisCurrency, + }, + Metadata: map[string]interface{}{ + services.FeeGasKey: 10001., + }, + } + fee100 := &transaction.Fee{ + Amount: *quantity.NewFromUint64(100), + Gas: 10001, + } + opsTransfer := []*types.Operation{ + fee100Op, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + Type: services.OpTransfer, + Account: &types.AccountIdentifier{ + Address: testEntityAddress, + }, + Amount: &types.Amount{ + Value: "-1000", + Currency: services.OasisCurrency, + }, + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 2, + }, + Type: services.OpTransfer, + Account: &types.AccountIdentifier{ + Address: common.DstAddress, + }, + Amount: &types.Amount{ + Value: "1000", + Currency: services.OasisCurrency, + }, + }, + } + txTransfer := &transaction.Transaction{ + Nonce: dummyNonce, + Fee: fee100, + Method: api.MethodTransfer, + Body: cbor.Marshal(api.Transfer{ + To: dstAddr, + Tokens: *quantity.NewFromUint64(1000), + }), + } + opsBurn := []*types.Operation{ + fee100Op, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + Type: services.OpBurn, + Account: &types.AccountIdentifier{ + Address: testEntityAddress, + }, + Amount: &types.Amount{ + Value: "-1000", + Currency: services.OasisCurrency, + }, + }, + } + txBurn := &transaction.Transaction{ + Nonce: dummyNonce, + Fee: fee100, + Method: api.MethodBurn, + Body: cbor.Marshal(api.Burn{ + Tokens: *quantity.NewFromUint64(1000), + }), + } + opsAddEscrow := []*types.Operation{ + fee100Op, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + Type: services.OpTransfer, + Account: &types.AccountIdentifier{ + Address: testEntityAddress, + }, + Amount: &types.Amount{ + Value: "-1000", + Currency: services.OasisCurrency, + }, + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 2, + }, + Type: services.OpTransfer, + Account: &types.AccountIdentifier{ + Address: common.DstAddress, + SubAccount: &types.SubAccountIdentifier{ + Address: services.SubAccountEscrow, + }, + }, + Amount: &types.Amount{ + Value: "1000", + Currency: services.OasisCurrency, + }, + }, + } + txAddEscrow := &transaction.Transaction{ + Nonce: dummyNonce, + Fee: fee100, + Method: api.MethodAddEscrow, + Body: cbor.Marshal(api.Escrow{ + Account: dstAddr, + Tokens: *quantity.NewFromUint64(1000), + }), + } + opsReclaimEscrow := []*types.Operation{ + fee100Op, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + Type: services.OpTransfer, + Account: &types.AccountIdentifier{ + Address: common.DstAddress, + SubAccount: &types.SubAccountIdentifier{ + Address: services.SubAccountEscrow, + }, + }, + Amount: &types.Amount{ + Value: "-1000", + Currency: services.PoolShare, + }, + }, + } + txReclaimEscrow := &transaction.Transaction{ + Nonce: dummyNonce, + Fee: fee100, + Method: api.MethodReclaimEscrow, + Body: cbor.Marshal(api.ReclaimEscrow{ + Account: dstAddr, + Shares: *quantity.NewFromUint64(1000), + }), + } + + rc := client.NewAPIClient(client.NewConfiguration("http://localhost:8080", "rosetta-sdk-go", nil)) + + r1, re, err := rc.NetworkAPI.NetworkList(context.Background(), &types.MetadataRequest{}) + if err != nil { + panic(err) + } + if re != nil { + panic(re) + } + if len(r1.NetworkIdentifiers) != 1 { + panic("len(r1.NetworkIdentifiers)") + } + fmt.Println("network identifiers", common.DumpJSON(r1.NetworkIdentifiers)) + ni := r1.NetworkIdentifiers[0] + + for _, tt := range []struct { + name string + ops []*types.Operation + reference *transaction.Transaction + }{ + {"transfer", opsTransfer, txTransfer}, + {"burn", opsBurn, txBurn}, + {"add escrow", opsAddEscrow, txAddEscrow}, + {"reclaim escrow", opsReclaimEscrow, txReclaimEscrow}, + } { + r2, re, err := rc.ConstructionAPI.ConstructionPayloads(context.Background(), &types.ConstructionPayloadsRequest{ + NetworkIdentifier: ni, + Operations: tt.ops, + Metadata: map[string]interface{}{ + services.NonceKey: dummyNonce, + }, + }) + if err != nil { + panic(fmt.Errorf("%s payloads: %w", tt.name, err)) + } + if re != nil { + panic(fmt.Errorf("%s payloads: %v", tt.name, re)) + } + fmt.Println(tt.name, "unsigned transaction", r2.UnsignedTransaction) + fmt.Println(tt.name, "signing payloads", common.DumpJSON(r2.Payloads)) + + var ut services.UnsignedTransaction + if err := json.Unmarshal([]byte(r2.UnsignedTransaction), &ut); err != nil { + panic(err) + } + if !reflect.DeepEqual(&ut.Tx, tt.reference) { + fmt.Println(tt.name, "reference transaction", common.DumpJSON(tt.reference)) + panic(fmt.Errorf("%s: transaction mismatch", tt.name)) + } + + r3, re, err := rc.ConstructionAPI.ConstructionParse(context.Background(), &types.ConstructionParseRequest{ + NetworkIdentifier: ni, + Signed: false, + Transaction: r2.UnsignedTransaction, + }) + if err != nil { + panic(fmt.Errorf("%s parse: %w", tt.name, err)) + } + if re != nil { + panic(fmt.Errorf("%s parse: %v", tt.name, re)) + } + fmt.Println(tt.name, "parsed operations", common.DumpJSON(r3.Operations)) + fmt.Println(tt.name, "parsed signers", common.DumpJSON(r3.Signers)) + fmt.Println(tt.name, "parsed metadata", common.DumpJSON(r3.Metadata)) + + if !reflect.DeepEqual(r3.Operations, tt.ops) { + fmt.Println(tt.name, "parsed operations", common.DumpJSON(r3.Operations)) + fmt.Println(tt.name, "reference operations", common.DumpJSON(tt.ops)) + panic(fmt.Errorf("%s: operations mismatch", tt.name)) + } + } +} diff --git a/tests/test.sh b/tests/test.sh index 662d6997..1bc3faa2 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -157,9 +157,21 @@ ${OASIS_ROSETTA_GW} & sleep 3 printf "${GRN}### Validating Rosetta gateway implementation...${OFF}\n" -./rosetta-cli check --end 42 +go run ./check-prep +./rosetta-cli --configuration-file rosetta-cli-config.json check:data --end 42 +{ + # We'll cause a sigpipe on this process, so ignore the exit status. + # The downstream awk will exit with nonzero status if this test actually fails without confirming any transactions. + ./rosetta-cli --configuration-file rosetta-cli-config.json check:construction || true +} | awk '{ print $0 }; $1 == "[STATS]" && $4 > 0 { confirmed = 1; exit }; END { exit !confirmed }' rm -rf "${ROOT}/validator-data" /tmp/rosetta-cli* +printf "${GRN}### Testing construction signing workflow...${OFF}\n" +go run ./construction-signing + +printf "${GRN}### Testing construction transaction types...${OFF}\n" +go run ./construction-txtypes + # Clean up after a successful run. rm -rf "${TEST_BASE_DIR}"