From 7b8bd4edabe083a817ee748bc57ae6c5ee99d295 Mon Sep 17 00:00:00 2001 From: Patrick O'Grady Date: Wed, 22 Jul 2020 17:53:41 -0700 Subject: [PATCH] Add scenario processing --- internal/scenario/scenario.go | 116 ++++++++++++++++ internal/scenario/scenario_test.go | 208 +++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 internal/scenario/scenario.go create mode 100644 internal/scenario/scenario_test.go diff --git a/internal/scenario/scenario.go b/internal/scenario/scenario.go new file mode 100644 index 00000000..f2291f8c --- /dev/null +++ b/internal/scenario/scenario.go @@ -0,0 +1,116 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package scenario + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "strings" + + "github.com/coinbase/rosetta-sdk-go/types" +) + +const ( + // Scenarios can contain one of many of the following reserved + // keywords that are automatically populated. + + // Sender is the sender and signer of a transaction. + Sender = "{{ SENDER }}" + + // SenderValue is the amount the sender is paying. + SenderValue = "{{ SENDER_VALUE }}" + + // Recipient is the recipient of the transaction. + Recipient = "{{ RECIPIENT }}" + + // RecipientValue is the amount the recipient is + // receiving from the sender. Note, this is distinct + // from the SenderValue so that UTXO transfers + // can be supported. + RecipientValue = "{{ RECIPIENT_VALUE }}" + + // UTXOIdentifier is the globally unique identifier + // of a UTXO. This should be in the Operation.metadata + // of any UTXO-based blockchain ("utxo_created" when + // a new UTXO is created and "utxo_spent" when a + // UTXO is spent). + UTXOIdentifier = "{{ UTXO_IDENTIFIER }}" +) + +// Context is all information passed to PopulateScenario. +// As more exotic scenario testing is supported, this will +// likely be expanded. +type Context struct { + Sender string + SenderValue *big.Int + Recipient string + RecipientValue *big.Int + UTXOIdentifier string + Currency *types.Currency +} + +// PopulateScenario populates a provided scenario (slice of +// []*types.Operation) with the information in Context. +func PopulateScenario( + ctx context.Context, + scenarioContext *Context, + scenario []*types.Operation, +) ([]*types.Operation, error) { + // Convert operations to a string + bytes, err := json.Marshal(scenario) + if err != nil { + return nil, fmt.Errorf("%w: unable to marshal scenario", err) + } + + // Replace all keywords with information in Context + stringBytes := string(bytes) + stringBytes = strings.ReplaceAll(stringBytes, Sender, scenarioContext.Sender) + stringBytes = strings.ReplaceAll( + stringBytes, + SenderValue, + new(big.Int).Neg(scenarioContext.SenderValue).String(), + ) + stringBytes = strings.ReplaceAll(stringBytes, Recipient, scenarioContext.Recipient) + stringBytes = strings.ReplaceAll( + stringBytes, + RecipientValue, + new(big.Int).Abs(scenarioContext.RecipientValue).String(), + ) + + if len(scenarioContext.UTXOIdentifier) > 0 { + stringBytes = strings.ReplaceAll( + stringBytes, + UTXOIdentifier, + scenarioContext.UTXOIdentifier, + ) + } + + // Convert back to ops + var ops []*types.Operation + if err := json.Unmarshal([]byte(stringBytes), &ops); err != nil { + return nil, fmt.Errorf("%w: unable to unmarshal ops", err) + } + + // Post-process operations + for _, op := range ops { + if op.Amount != nil { + op.Amount.Currency = scenarioContext.Currency + } + } + + return ops, nil +} diff --git a/internal/scenario/scenario_test.go b/internal/scenario/scenario_test.go new file mode 100644 index 00000000..f8649e97 --- /dev/null +++ b/internal/scenario/scenario_test.go @@ -0,0 +1,208 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package scenario + +import ( + "context" + "math/big" + "testing" + + "github.com/coinbase/rosetta-sdk-go/types" + "github.com/stretchr/testify/assert" +) + +var ( + sender = "addr1" + senderValue = big.NewInt(100) + recipient = "addr2" + recipientValue = big.NewInt(90) + utxoIdentifier = "utxo1" + + bitcoinCurrency = &types.Currency{ + Symbol: "BTC", + Decimals: 8, + } + ethereumCurrency = &types.Currency{ + Symbol: "ETH", + Decimals: 18, + } +) + +func TestPopulateScenario(t *testing.T) { + var tests = map[string]struct { + context *Context + scenario []*types.Operation + + expected []*types.Operation + }{ + "bitcoin": { + context: &Context{ + Sender: sender, + SenderValue: senderValue, + Recipient: recipient, + RecipientValue: recipientValue, + UTXOIdentifier: utxoIdentifier, + Currency: bitcoinCurrency, + }, + scenario: []*types.Operation{ + { + Type: "Vin", + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + }, + Account: &types.AccountIdentifier{ + Address: "{{ SENDER }}", + }, + Amount: &types.Amount{ + Value: "{{ SENDER_VALUE }}", + }, + Metadata: map[string]interface{}{ + "utxo_spent": "{{ UTXO_IDENTIFIER }}", + }, + }, + { + Type: "Vout", + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + Account: &types.AccountIdentifier{ + Address: "{{ RECIPIENT }}", + }, + Amount: &types.Amount{ + Value: "{{ RECIPIENT_VALUE }}", + }, + }, + }, + expected: []*types.Operation{ + { + Type: "Vin", + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + }, + Account: &types.AccountIdentifier{ + Address: sender, + }, + Amount: &types.Amount{ + Value: new(big.Int).Neg(senderValue).String(), + Currency: bitcoinCurrency, + }, + Metadata: map[string]interface{}{ + "utxo_spent": utxoIdentifier, + }, + }, + { + Type: "Vout", + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + Account: &types.AccountIdentifier{ + Address: recipient, + }, + Amount: &types.Amount{ + Value: new(big.Int).Abs(recipientValue).String(), + Currency: bitcoinCurrency, + }, + }, + }, + }, + "ethereum": { + context: &Context{ + Sender: sender, + SenderValue: senderValue, + Recipient: recipient, + RecipientValue: recipientValue, + Currency: ethereumCurrency, + }, + scenario: []*types.Operation{ + { + Type: "transfer", + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + }, + Account: &types.AccountIdentifier{ + Address: "{{ SENDER }}", + }, + Amount: &types.Amount{ + Value: "{{ SENDER_VALUE }}", + }, + }, + { + Type: "transfer", + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + RelatedOperations: []*types.OperationIdentifier{ + { + Index: 0, + }, + }, + Account: &types.AccountIdentifier{ + Address: "{{ RECIPIENT }}", + }, + Amount: &types.Amount{ + Value: "{{ RECIPIENT_VALUE }}", + }, + }, + }, + expected: []*types.Operation{ + { + Type: "transfer", + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + }, + Account: &types.AccountIdentifier{ + Address: sender, + }, + Amount: &types.Amount{ + Value: new(big.Int).Neg(senderValue).String(), + Currency: ethereumCurrency, + }, + }, + { + Type: "transfer", + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + RelatedOperations: []*types.OperationIdentifier{ + { + Index: 0, + }, + }, + Account: &types.AccountIdentifier{ + Address: recipient, + }, + Amount: &types.Amount{ + Value: new(big.Int).Abs(recipientValue).String(), + Currency: ethereumCurrency, + }, + }, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + + ops, err := PopulateScenario( + ctx, + test.context, + test.scenario, + ) + assert.NoError(t, err) + assert.ElementsMatch(t, test.expected, ops) + }) + } +}