diff --git a/db/migrations/postgres/000026_create_tokenaccount_table.down.sql b/db/migrations/postgres/000026_create_tokenaccount_table.down.sql deleted file mode 100644 index 81591f43df..0000000000 --- a/db/migrations/postgres/000026_create_tokenaccount_table.down.sql +++ /dev/null @@ -1,3 +0,0 @@ -BEGIN; -DROP TABLE IF EXISTS tokenaccount; -COMMIT; diff --git a/db/migrations/postgres/000026_create_tokenaccount_table.up.sql b/db/migrations/postgres/000026_create_tokenaccount_table.up.sql deleted file mode 100644 index 0c34812018..0000000000 --- a/db/migrations/postgres/000026_create_tokenaccount_table.up.sql +++ /dev/null @@ -1,12 +0,0 @@ -BEGIN; -CREATE TABLE tokenaccount ( - seq SERIAL PRIMARY KEY, - protocol_id VARCHAR(1024) NOT NULL, - token_index VARCHAR(1024) NOT NULL, - identity VARCHAR(1024) NOT NULL, - balance BIGINT DEFAULT 0 -); - -CREATE INDEX tokenaccount_pool ON tokenaccount(protocol_id,token_index,identity); - -COMMIT; diff --git a/db/migrations/postgres/000031_create_tokentransfer_table.down.sql b/db/migrations/postgres/000031_create_tokentransfer_table.down.sql new file mode 100644 index 0000000000..e56e89ef32 --- /dev/null +++ b/db/migrations/postgres/000031_create_tokentransfer_table.down.sql @@ -0,0 +1,3 @@ +BEGIN; +DROP TABLE IF EXISTS tokentransfer; +COMMIT; diff --git a/db/migrations/postgres/000031_create_tokentransfer_table.up.sql b/db/migrations/postgres/000031_create_tokentransfer_table.up.sql new file mode 100644 index 0000000000..1b408e2f1e --- /dev/null +++ b/db/migrations/postgres/000031_create_tokentransfer_table.up.sql @@ -0,0 +1,23 @@ +BEGIN; +CREATE TABLE tokentransfer ( + seq SERIAL PRIMARY KEY, + local_id UUID NOT NULL, + type VARCHAR(64) NOT NULL, + pool_protocol_id VARCHAR(1024) NOT NULL, + token_index VARCHAR(1024), + key VARCHAR(1024) NOT NULL, + from_key VARCHAR(1024), + to_key VARCHAR(1024), + amount VARCHAR(65), + protocol_id VARCHAR(1024) NOT NULL, + message_hash CHAR(64), + tx_type VARCHAR(64), + tx_id UUID, + created BIGINT NOT NULL +); + +CREATE UNIQUE INDEX tokentransfer_id ON tokentransfer(local_id); +CREATE INDEX tokentransfer_pool ON tokentransfer(pool_protocol_id,token_index); +CREATE UNIQUE INDEX tokentransfer_protocolid ON tokentransfer(protocol_id); + +COMMIT; diff --git a/db/migrations/postgres/000032_create_tokenaccount_table.down.sql b/db/migrations/postgres/000032_create_tokenaccount_table.down.sql new file mode 100644 index 0000000000..b0b688b76c --- /dev/null +++ b/db/migrations/postgres/000032_create_tokenaccount_table.down.sql @@ -0,0 +1,3 @@ +BEGIN; +DROP TABLE tokenaccount; +COMMIT; diff --git a/db/migrations/postgres/000032_create_tokenaccount_table.up.sql b/db/migrations/postgres/000032_create_tokenaccount_table.up.sql new file mode 100644 index 0000000000..ab6898a0e3 --- /dev/null +++ b/db/migrations/postgres/000032_create_tokenaccount_table.up.sql @@ -0,0 +1,14 @@ +BEGIN; +DROP TABLE IF EXISTS tokenaccount; + +CREATE TABLE tokenaccount ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + pool_protocol_id VARCHAR(1024) NOT NULL, + token_index VARCHAR(1024), + identity VARCHAR(1024) NOT NULL, + balance VARCHAR(65) +); + +CREATE UNIQUE INDEX tokenaccount_pool ON tokenaccount(pool_protocol_id,token_index,identity); + +COMMIT; diff --git a/db/migrations/sqlite/000026_create_tokenaccount_table.down.sql b/db/migrations/sqlite/000026_create_tokenaccount_table.down.sql deleted file mode 100644 index 8cd2f99465..0000000000 --- a/db/migrations/sqlite/000026_create_tokenaccount_table.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS tokenaccount; diff --git a/db/migrations/sqlite/000026_create_tokenaccount_table.up.sql b/db/migrations/sqlite/000026_create_tokenaccount_table.up.sql deleted file mode 100644 index b9efd063d7..0000000000 --- a/db/migrations/sqlite/000026_create_tokenaccount_table.up.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE tokenaccount ( - seq INTEGER PRIMARY KEY AUTOINCREMENT, - protocol_id VARCHAR(1024) NOT NULL, - token_index VARCHAR(1024) NOT NULL, - identity VARCHAR(1024) NOT NULL, - balance BIGINT DEFAULT 0 -); - -CREATE INDEX tokenaccount_pool ON tokenaccount(protocol_id,token_index,identity); diff --git a/db/migrations/sqlite/000031_create_tokentransfer_table.down.sql b/db/migrations/sqlite/000031_create_tokentransfer_table.down.sql new file mode 100644 index 0000000000..43b6143bdc --- /dev/null +++ b/db/migrations/sqlite/000031_create_tokentransfer_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tokentransfer; diff --git a/db/migrations/sqlite/000031_create_tokentransfer_table.up.sql b/db/migrations/sqlite/000031_create_tokentransfer_table.up.sql new file mode 100644 index 0000000000..ef575cb71b --- /dev/null +++ b/db/migrations/sqlite/000031_create_tokentransfer_table.up.sql @@ -0,0 +1,20 @@ +CREATE TABLE tokentransfer ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + local_id UUID NOT NULL, + type VARCHAR(64) NOT NULL, + pool_protocol_id VARCHAR(1024) NOT NULL, + token_index VARCHAR(1024), + key VARCHAR(1024) NOT NULL, + from_key VARCHAR(1024), + to_key VARCHAR(1024), + amount VARCHAR(65), + protocol_id VARCHAR(1024) NOT NULL, + message_hash CHAR(64), + tx_type VARCHAR(64), + tx_id UUID, + created BIGINT NOT NULL +); + +CREATE UNIQUE INDEX tokentransfer_id ON tokentransfer(local_id); +CREATE INDEX tokentransfer_pool ON tokentransfer(pool_protocol_id,token_index); +CREATE UNIQUE INDEX tokentransfer_protocolid ON tokentransfer(protocol_id); diff --git a/db/migrations/sqlite/000032_create_tokenaccount_table.down.sql b/db/migrations/sqlite/000032_create_tokenaccount_table.down.sql new file mode 100644 index 0000000000..d3e9f88323 --- /dev/null +++ b/db/migrations/sqlite/000032_create_tokenaccount_table.down.sql @@ -0,0 +1 @@ +DROP TABLE tokenaccount; \ No newline at end of file diff --git a/db/migrations/sqlite/000032_create_tokenaccount_table.up.sql b/db/migrations/sqlite/000032_create_tokenaccount_table.up.sql new file mode 100644 index 0000000000..9cf823aaef --- /dev/null +++ b/db/migrations/sqlite/000032_create_tokenaccount_table.up.sql @@ -0,0 +1,11 @@ +DROP TABLE IF EXISTS tokenaccount; + +CREATE TABLE tokenaccount ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + pool_protocol_id VARCHAR(1024) NOT NULL, + token_index VARCHAR(1024), + identity VARCHAR(1024) NOT NULL, + balance VARCHAR(65) +); + +CREATE UNIQUE INDEX tokenaccount_pool ON tokenaccount(pool_protocol_id,token_index,identity); diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 5bb1dd1dfc..b8ec1bb19b 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -4638,11 +4638,371 @@ paths: name: identity schema: type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: poolprotocolid + schema: + type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: tokenindex + schema: + type: string + - description: Sort field. For multi-field sort use comma separated values (or + multiple query values) with '-' prefix for descending + in: query + name: sort + schema: + type: string + - description: Ascending sort order (overrides all fields in a multi-field sort) + in: query + name: ascending + schema: + type: string + - description: Descending sort order (overrides all fields in a multi-field + sort) + in: query + name: descending + schema: + type: string + - description: 'The number of records to skip (max: 1,000). Unsuitable for bulk + operations' + in: query + name: skip + schema: + type: string + - description: 'The maximum number of records to return (max: 1,000)' + in: query + name: limit + schema: + example: "25" + type: string + - description: Return a total count as well as items (adds extra database processing) + in: query + name: count + schema: + type: string + responses: + "200": + content: + application/json: + schema: + items: + properties: + balance: {} + identity: + type: string + poolProtocolId: + type: string + tokenIndex: + type: string + type: object + type: array + description: Success + default: + description: "" + /namespaces/{ns}/tokens/{type}/pools/{name}/burn: + post: + description: 'TODO: Description' + operationId: postTokenBurn + parameters: + - description: 'TODO: Description' + in: path + name: ns + required: true + schema: + example: default + type: string + - description: 'TODO: Description' + in: path + name: type + required: true + schema: + type: string + - description: 'TODO: Description' + in: path + name: name + required: true + schema: + type: string + - description: When true the HTTP request blocks until the message is confirmed + in: query + name: confirm + schema: + type: string + - description: Server-side request timeout (millseconds, or set a custom suffix + like 10s) + in: header + name: Request-Timeout + schema: + default: 120s + type: string + requestBody: + content: + application/json: + schema: + properties: + amount: {} + from: + type: string + key: + type: string + tokenIndex: + type: string + type: object + responses: + "200": + content: + application/json: + schema: + properties: + amount: {} + created: {} + from: + type: string + key: + type: string + localId: {} + messageHash: {} + poolProtocolId: + type: string + protocolId: + type: string + to: + type: string + tokenIndex: + type: string + tx: + properties: + id: {} + type: + type: string + type: object + type: + type: string + type: object + description: Success + "202": + content: + application/json: + schema: + properties: + amount: {} + created: {} + from: + type: string + key: + type: string + localId: {} + messageHash: {} + poolProtocolId: + type: string + protocolId: + type: string + to: + type: string + tokenIndex: + type: string + tx: + properties: + id: {} + type: + type: string + type: object + type: + type: string + type: object + description: Success + default: + description: "" + /namespaces/{ns}/tokens/{type}/pools/{name}/mint: + post: + description: 'TODO: Description' + operationId: postTokenMint + parameters: + - description: 'TODO: Description' + in: path + name: ns + required: true + schema: + example: default + type: string + - description: 'TODO: Description' + in: path + name: type + required: true + schema: + type: string + - description: 'TODO: Description' + in: path + name: name + required: true + schema: + type: string + - description: When true the HTTP request blocks until the message is confirmed + in: query + name: confirm + schema: + type: string + - description: Server-side request timeout (millseconds, or set a custom suffix + like 10s) + in: header + name: Request-Timeout + schema: + default: 120s + type: string + requestBody: + content: + application/json: + schema: + properties: + amount: {} + key: + type: string + to: + type: string + type: object + responses: + "200": + content: + application/json: + schema: + properties: + amount: {} + created: {} + from: + type: string + key: + type: string + localId: {} + messageHash: {} + poolProtocolId: + type: string + protocolId: + type: string + to: + type: string + tokenIndex: + type: string + tx: + properties: + id: {} + type: + type: string + type: object + type: + type: string + type: object + description: Success + "202": + content: + application/json: + schema: + properties: + amount: {} + created: {} + from: + type: string + key: + type: string + localId: {} + messageHash: {} + poolProtocolId: + type: string + protocolId: + type: string + to: + type: string + tokenIndex: + type: string + tx: + properties: + id: {} + type: + type: string + type: object + type: + type: string + type: object + description: Success + default: + description: "" + /namespaces/{ns}/tokens/{type}/pools/{name}/transfers: + get: + description: 'TODO: Description' + operationId: getTokenTransfers + parameters: + - description: 'TODO: Description' + in: path + name: ns + required: true + schema: + example: default + type: string + - description: 'TODO: Description' + in: path + name: type + required: true + schema: + type: string + - description: 'TODO: Description' + in: path + name: name + required: true + schema: + type: string + - description: Server-side request timeout (millseconds, or set a custom suffix + like 10s) + in: header + name: Request-Timeout + schema: + default: 120s + type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: amount + schema: + type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: created + schema: + type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: from + schema: + type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: key + schema: + type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: localid + schema: + type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: messagehash + schema: + type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: poolprotocolid + schema: + type: string - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' in: query name: protocolid schema: type: string + - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' + in: query + name: to + schema: + type: string - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^' in: query name: tokenindex @@ -4689,12 +5049,10 @@ paths: schema: items: properties: - balance: - format: int64 - type: integer + balance: {} identity: type: string - protocolId: + poolProtocolId: type: string tokenIndex: type: string @@ -4703,6 +5061,121 @@ paths: description: Success default: description: "" + post: + description: 'TODO: Description' + operationId: postTokenTransfer + parameters: + - description: 'TODO: Description' + in: path + name: ns + required: true + schema: + example: default + type: string + - description: 'TODO: Description' + in: path + name: type + required: true + schema: + type: string + - description: 'TODO: Description' + in: path + name: name + required: true + schema: + type: string + - description: When true the HTTP request blocks until the message is confirmed + in: query + name: confirm + schema: + type: string + - description: Server-side request timeout (millseconds, or set a custom suffix + like 10s) + in: header + name: Request-Timeout + schema: + default: 120s + type: string + requestBody: + content: + application/json: + schema: + properties: + amount: {} + from: + type: string + key: + type: string + to: + type: string + tokenIndex: + type: string + type: object + responses: + "200": + content: + application/json: + schema: + properties: + amount: {} + created: {} + from: + type: string + key: + type: string + localId: {} + messageHash: {} + poolProtocolId: + type: string + protocolId: + type: string + to: + type: string + tokenIndex: + type: string + tx: + properties: + id: {} + type: + type: string + type: object + type: + type: string + type: object + description: Success + "202": + content: + application/json: + schema: + properties: + amount: {} + created: {} + from: + type: string + key: + type: string + localId: {} + messageHash: {} + poolProtocolId: + type: string + protocolId: + type: string + to: + type: string + tokenIndex: + type: string + tx: + properties: + id: {} + type: + type: string + type: object + type: + type: string + type: object + description: Success + default: + description: "" /namespaces/{ns}/transactions: get: description: 'TODO: Description' diff --git a/go.sum b/go.sum index 7184caf8e8..e8a55523ed 100644 --- a/go.sum +++ b/go.sum @@ -341,8 +341,6 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4 github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0uQR+pM/VdlL83bw= github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA= github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= -github.com/getkin/kin-openapi v0.76.1-0.20211007120119-47bb0b2707dc h1:JUXgvs86/exZ9TA9k+TvuqXgcWMYNLFx6KrJmaNbjgM= -github.com/getkin/kin-openapi v0.76.1-0.20211007120119-47bb0b2707dc/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg= github.com/getkin/kin-openapi v0.77.0 h1:yeShypHFCtG6L0ZyIFWAPZuitkybgyIs9Pg3iFVECgQ= github.com/getkin/kin-openapi v0.77.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= diff --git a/internal/apiserver/route_get_token_transfers.go b/internal/apiserver/route_get_token_transfers.go new file mode 100644 index 0000000000..d3b4bd8f3a --- /dev/null +++ b/internal/apiserver/route_get_token_transfers.go @@ -0,0 +1,47 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 apiserver + +import ( + "net/http" + + "github.com/hyperledger/firefly/internal/config" + "github.com/hyperledger/firefly/internal/i18n" + "github.com/hyperledger/firefly/internal/oapispec" + "github.com/hyperledger/firefly/pkg/database" + "github.com/hyperledger/firefly/pkg/fftypes" +) + +var getTokenTransfers = &oapispec.Route{ + Name: "getTokenTransfers", + Path: "namespaces/{ns}/tokens/{type}/pools/{name}/transfers", + Method: http.MethodGet, + PathParams: []*oapispec.PathParam{ + {Name: "ns", ExampleFromConf: config.NamespacesDefault, Description: i18n.MsgTBD}, + {Name: "type", Description: i18n.MsgTBD}, + {Name: "name", Description: i18n.MsgTBD}, + }, + QueryParams: nil, + FilterFactory: database.TokenTransferQueryFactory, + Description: i18n.MsgTBD, + JSONInputValue: nil, + JSONOutputValue: func() interface{} { return []*fftypes.TokenAccount{} }, + JSONOutputCodes: []int{http.StatusOK}, + JSONHandler: func(r *oapispec.APIRequest) (output interface{}, err error) { + return filterResult(r.Or.Assets().GetTokenTransfers(r.Ctx, r.PP["ns"], r.PP["type"], r.PP["name"], r.Filter)) + }, +} diff --git a/internal/apiserver/route_get_token_transfers_test.go b/internal/apiserver/route_get_token_transfers_test.go new file mode 100644 index 0000000000..2de66b163b --- /dev/null +++ b/internal/apiserver/route_get_token_transfers_test.go @@ -0,0 +1,42 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 apiserver + +import ( + "net/http/httptest" + "testing" + + "github.com/hyperledger/firefly/mocks/assetmocks" + "github.com/hyperledger/firefly/pkg/fftypes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestGetTokenTransfers(t *testing.T) { + o, r := newTestAPIServer() + mam := &assetmocks.Manager{} + o.On("Assets").Return(mam) + req := httptest.NewRequest("GET", "/api/v1/namespaces/ns1/tokens/tok1/pools/pool1/transfers", nil) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + res := httptest.NewRecorder() + + mam.On("GetTokenTransfers", mock.Anything, "ns1", "tok1", "pool1", mock.Anything). + Return([]*fftypes.TokenTransfer{}, nil, nil) + r.ServeHTTP(res, req) + + assert.Equal(t, 200, res.Result().StatusCode) +} diff --git a/internal/apiserver/route_post_token_burn.go b/internal/apiserver/route_post_token_burn.go new file mode 100644 index 0000000000..607a8a8b9f --- /dev/null +++ b/internal/apiserver/route_post_token_burn.go @@ -0,0 +1,52 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 apiserver + +import ( + "net/http" + "strings" + + "github.com/hyperledger/firefly/internal/config" + "github.com/hyperledger/firefly/internal/i18n" + "github.com/hyperledger/firefly/internal/oapispec" + "github.com/hyperledger/firefly/pkg/fftypes" +) + +var postTokenBurn = &oapispec.Route{ + Name: "postTokenBurn", + Path: "namespaces/{ns}/tokens/{type}/pools/{name}/burn", + Method: http.MethodPost, + PathParams: []*oapispec.PathParam{ + {Name: "ns", ExampleFromConf: config.NamespacesDefault, Description: i18n.MsgTBD}, + {Name: "type", Description: i18n.MsgTBD}, + {Name: "name", Description: i18n.MsgTBD}, + }, + QueryParams: []*oapispec.QueryParam{ + {Name: "confirm", Description: i18n.MsgConfirmQueryParam, IsBool: true}, + }, + FilterFactory: nil, + Description: i18n.MsgTBD, + JSONInputValue: func() interface{} { return &fftypes.TokenTransfer{} }, + JSONInputMask: []string{"Type", "LocalID", "PoolProtocolID", "To", "ProtocolID", "MessageHash", "TX", "Created"}, + JSONOutputValue: func() interface{} { return &fftypes.TokenTransfer{} }, + JSONOutputCodes: []int{http.StatusAccepted, http.StatusOK}, + JSONHandler: func(r *oapispec.APIRequest) (output interface{}, err error) { + waitConfirm := strings.EqualFold(r.QP["confirm"], "true") + r.SuccessStatus = syncRetcode(waitConfirm) + return r.Or.Assets().BurnTokens(r.Ctx, r.PP["ns"], r.PP["type"], r.PP["name"], r.Input.(*fftypes.TokenTransfer), waitConfirm) + }, +} diff --git a/internal/apiserver/route_post_token_burn_test.go b/internal/apiserver/route_post_token_burn_test.go new file mode 100644 index 0000000000..a2addefaf1 --- /dev/null +++ b/internal/apiserver/route_post_token_burn_test.go @@ -0,0 +1,47 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 apiserver + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/hyperledger/firefly/mocks/assetmocks" + "github.com/hyperledger/firefly/pkg/fftypes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestPostTokenBurn(t *testing.T) { + o, r := newTestAPIServer() + mam := &assetmocks.Manager{} + o.On("Assets").Return(mam) + input := fftypes.TokenTransfer{} + var buf bytes.Buffer + json.NewEncoder(&buf).Encode(&input) + req := httptest.NewRequest("POST", "/api/v1/namespaces/ns1/tokens/tok1/pools/pool1/burn", &buf) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + res := httptest.NewRecorder() + + mam.On("BurnTokens", mock.Anything, "ns1", "tok1", "pool1", mock.AnythingOfType("*fftypes.TokenTransfer"), false). + Return(&fftypes.TokenTransfer{}, nil) + r.ServeHTTP(res, req) + + assert.Equal(t, 202, res.Result().StatusCode) +} diff --git a/internal/apiserver/route_post_token_mint.go b/internal/apiserver/route_post_token_mint.go new file mode 100644 index 0000000000..ed874752e8 --- /dev/null +++ b/internal/apiserver/route_post_token_mint.go @@ -0,0 +1,52 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 apiserver + +import ( + "net/http" + "strings" + + "github.com/hyperledger/firefly/internal/config" + "github.com/hyperledger/firefly/internal/i18n" + "github.com/hyperledger/firefly/internal/oapispec" + "github.com/hyperledger/firefly/pkg/fftypes" +) + +var postTokenMint = &oapispec.Route{ + Name: "postTokenMint", + Path: "namespaces/{ns}/tokens/{type}/pools/{name}/mint", + Method: http.MethodPost, + PathParams: []*oapispec.PathParam{ + {Name: "ns", ExampleFromConf: config.NamespacesDefault, Description: i18n.MsgTBD}, + {Name: "type", Description: i18n.MsgTBD}, + {Name: "name", Description: i18n.MsgTBD}, + }, + QueryParams: []*oapispec.QueryParam{ + {Name: "confirm", Description: i18n.MsgConfirmQueryParam, IsBool: true}, + }, + FilterFactory: nil, + Description: i18n.MsgTBD, + JSONInputValue: func() interface{} { return &fftypes.TokenTransfer{} }, + JSONInputMask: []string{"Type", "LocalID", "PoolProtocolID", "TokenIndex", "From", "ProtocolID", "MessageHash", "TX", "Created"}, + JSONOutputValue: func() interface{} { return &fftypes.TokenTransfer{} }, + JSONOutputCodes: []int{http.StatusAccepted, http.StatusOK}, + JSONHandler: func(r *oapispec.APIRequest) (output interface{}, err error) { + waitConfirm := strings.EqualFold(r.QP["confirm"], "true") + r.SuccessStatus = syncRetcode(waitConfirm) + return r.Or.Assets().MintTokens(r.Ctx, r.PP["ns"], r.PP["type"], r.PP["name"], r.Input.(*fftypes.TokenTransfer), waitConfirm) + }, +} diff --git a/internal/apiserver/route_post_token_mint_test.go b/internal/apiserver/route_post_token_mint_test.go new file mode 100644 index 0000000000..28af3cf91b --- /dev/null +++ b/internal/apiserver/route_post_token_mint_test.go @@ -0,0 +1,47 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 apiserver + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/hyperledger/firefly/mocks/assetmocks" + "github.com/hyperledger/firefly/pkg/fftypes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestPostTokenMint(t *testing.T) { + o, r := newTestAPIServer() + mam := &assetmocks.Manager{} + o.On("Assets").Return(mam) + input := fftypes.TokenTransfer{} + var buf bytes.Buffer + json.NewEncoder(&buf).Encode(&input) + req := httptest.NewRequest("POST", "/api/v1/namespaces/ns1/tokens/tok1/pools/pool1/mint", &buf) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + res := httptest.NewRecorder() + + mam.On("MintTokens", mock.Anything, "ns1", "tok1", "pool1", mock.AnythingOfType("*fftypes.TokenTransfer"), false). + Return(&fftypes.TokenTransfer{}, nil) + r.ServeHTTP(res, req) + + assert.Equal(t, 202, res.Result().StatusCode) +} diff --git a/internal/apiserver/route_post_token_transfer.go b/internal/apiserver/route_post_token_transfer.go new file mode 100644 index 0000000000..00a9df5474 --- /dev/null +++ b/internal/apiserver/route_post_token_transfer.go @@ -0,0 +1,52 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 apiserver + +import ( + "net/http" + "strings" + + "github.com/hyperledger/firefly/internal/config" + "github.com/hyperledger/firefly/internal/i18n" + "github.com/hyperledger/firefly/internal/oapispec" + "github.com/hyperledger/firefly/pkg/fftypes" +) + +var postTokenTransfer = &oapispec.Route{ + Name: "postTokenTransfer", + Path: "namespaces/{ns}/tokens/{type}/pools/{name}/transfers", + Method: http.MethodPost, + PathParams: []*oapispec.PathParam{ + {Name: "ns", ExampleFromConf: config.NamespacesDefault, Description: i18n.MsgTBD}, + {Name: "type", Description: i18n.MsgTBD}, + {Name: "name", Description: i18n.MsgTBD}, + }, + QueryParams: []*oapispec.QueryParam{ + {Name: "confirm", Description: i18n.MsgConfirmQueryParam, IsBool: true}, + }, + FilterFactory: nil, + Description: i18n.MsgTBD, + JSONInputValue: func() interface{} { return &fftypes.TokenTransfer{} }, + JSONInputMask: []string{"Type", "LocalID", "PoolProtocolID", "ProtocolID", "MessageHash", "TX", "Created"}, + JSONOutputValue: func() interface{} { return &fftypes.TokenTransfer{} }, + JSONOutputCodes: []int{http.StatusAccepted, http.StatusOK}, + JSONHandler: func(r *oapispec.APIRequest) (output interface{}, err error) { + waitConfirm := strings.EqualFold(r.QP["confirm"], "true") + r.SuccessStatus = syncRetcode(waitConfirm) + return r.Or.Assets().TransferTokens(r.Ctx, r.PP["ns"], r.PP["type"], r.PP["name"], r.Input.(*fftypes.TokenTransfer), waitConfirm) + }, +} diff --git a/internal/apiserver/route_post_token_transfer_test.go b/internal/apiserver/route_post_token_transfer_test.go new file mode 100644 index 0000000000..caf1c8bef7 --- /dev/null +++ b/internal/apiserver/route_post_token_transfer_test.go @@ -0,0 +1,47 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 apiserver + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/hyperledger/firefly/mocks/assetmocks" + "github.com/hyperledger/firefly/pkg/fftypes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestPostTokenTransfer(t *testing.T) { + o, r := newTestAPIServer() + mam := &assetmocks.Manager{} + o.On("Assets").Return(mam) + input := fftypes.TokenTransfer{} + var buf bytes.Buffer + json.NewEncoder(&buf).Encode(&input) + req := httptest.NewRequest("POST", "/api/v1/namespaces/ns1/tokens/tok1/pools/pool1/transfers", &buf) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + res := httptest.NewRecorder() + + mam.On("TransferTokens", mock.Anything, "ns1", "tok1", "pool1", mock.AnythingOfType("*fftypes.TokenTransfer"), false). + Return(&fftypes.TokenTransfer{}, nil) + r.ServeHTTP(res, req) + + assert.Equal(t, 202, res.Result().StatusCode) +} diff --git a/internal/apiserver/routes.go b/internal/apiserver/routes.go index 025388f4cd..78bcc642d6 100644 --- a/internal/apiserver/routes.go +++ b/internal/apiserver/routes.go @@ -81,4 +81,8 @@ var routes = []*oapispec.Route{ getTokenPools, getTokenPoolByName, getTokenAccounts, + getTokenTransfers, + postTokenMint, + postTokenBurn, + postTokenTransfer, } diff --git a/internal/assets/manager.go b/internal/assets/manager.go index f7cf4c1974..8d5488579e 100644 --- a/internal/assets/manager.go +++ b/internal/assets/manager.go @@ -35,14 +35,18 @@ import ( type Manager interface { CreateTokenPool(ctx context.Context, ns, typeName string, pool *fftypes.TokenPool, waitConfirm bool) (*fftypes.TokenPool, error) - CreateTokenPoolWithID(ctx context.Context, ns string, id *fftypes.UUID, typeName string, pool *fftypes.TokenPool, waitConfirm bool) (*fftypes.TokenPool, error) GetTokenPools(ctx context.Context, ns, typeName string, filter database.AndFilter) ([]*fftypes.TokenPool, *database.FilterResult, error) - GetTokenPool(ctx context.Context, ns, typeName, name string) (*fftypes.TokenPool, error) - GetTokenAccounts(ctx context.Context, ns, typeName, name string, filter database.AndFilter) ([]*fftypes.TokenAccount, *database.FilterResult, error) + GetTokenPool(ctx context.Context, ns, typeName, poolName string) (*fftypes.TokenPool, error) + GetTokenAccounts(ctx context.Context, ns, typeName, poolName string, filter database.AndFilter) ([]*fftypes.TokenAccount, *database.FilterResult, error) ValidateTokenPoolTx(ctx context.Context, pool *fftypes.TokenPool, protocolTxID string) error + GetTokenTransfers(ctx context.Context, ns, typeName, poolName string, filter database.AndFilter) ([]*fftypes.TokenTransfer, *database.FilterResult, error) + MintTokens(ctx context.Context, ns, typeName, poolName string, transfer *fftypes.TokenTransfer, waitConfirm bool) (*fftypes.TokenTransfer, error) + BurnTokens(ctx context.Context, ns, typeName, poolName string, transfer *fftypes.TokenTransfer, waitConfirm bool) (*fftypes.TokenTransfer, error) + TransferTokens(ctx context.Context, ns, typeName, poolName string, transfer *fftypes.TokenTransfer, waitConfirm bool) (*fftypes.TokenTransfer, error) // Bound token callbacks TokenPoolCreated(tk tokens.Plugin, tokenType fftypes.TokenType, tx *fftypes.UUID, protocolID, signingIdentity, protocolTxID string, additionalInfo fftypes.JSONObject) error + TokensTransferred(tk tokens.Plugin, transfer *fftypes.TokenTransfer, signingIdentity string, protocolTxID string, additionalInfo fftypes.JSONObject) error Start() error WaitStop() @@ -116,10 +120,10 @@ func retrieveTokenPoolCreateInputs(ctx context.Context, op *fftypes.Operation, p } func (am *assetManager) CreateTokenPool(ctx context.Context, ns string, typeName string, pool *fftypes.TokenPool, waitConfirm bool) (*fftypes.TokenPool, error) { - return am.CreateTokenPoolWithID(ctx, ns, fftypes.NewUUID(), typeName, pool, waitConfirm) + return am.createTokenPoolWithID(ctx, fftypes.NewUUID(), ns, typeName, pool, waitConfirm) } -func (am *assetManager) CreateTokenPoolWithID(ctx context.Context, ns string, id *fftypes.UUID, typeName string, pool *fftypes.TokenPool, waitConfirm bool) (*fftypes.TokenPool, error) { +func (am *assetManager) createTokenPoolWithID(ctx context.Context, id *fftypes.UUID, ns string, typeName string, pool *fftypes.TokenPool, waitConfirm bool) (*fftypes.TokenPool, error) { if err := am.data.VerifyNamespaceExists(ctx, ns); err != nil { return nil, err } @@ -139,7 +143,7 @@ func (am *assetManager) CreateTokenPoolWithID(ctx context.Context, ns string, id if waitConfirm { return am.syncasync.SendConfirmTokenPool(ctx, ns, func(requestID *fftypes.UUID) error { - _, err := am.CreateTokenPoolWithID(ctx, ns, requestID, typeName, pool, false) + _, err := am.createTokenPoolWithID(ctx, requestID, ns, typeName, pool, false) return err }) } @@ -173,7 +177,7 @@ func (am *assetManager) CreateTokenPoolWithID(ctx context.Context, ns string, id ns, tx.ID, "", - fftypes.OpTypeTokensCreatePool, + fftypes.OpTypeTokenCreatePool, fftypes.OpStatusPending, "") addTokenPoolCreateInputs(op, pool) @@ -199,25 +203,32 @@ func (am *assetManager) GetTokenPools(ctx context.Context, ns string, typeName s return am.database.GetTokenPools(ctx, am.scopeNS(ns, filter)) } -func (am *assetManager) GetTokenPool(ctx context.Context, ns, typeName, name string) (*fftypes.TokenPool, error) { +func (am *assetManager) GetTokenPool(ctx context.Context, ns, typeName, poolName string) (*fftypes.TokenPool, error) { if _, err := am.selectTokenPlugin(ctx, typeName); err != nil { return nil, err } if err := fftypes.ValidateFFNameField(ctx, ns, "namespace"); err != nil { return nil, err } - if err := fftypes.ValidateFFNameField(ctx, name, "name"); err != nil { + if err := fftypes.ValidateFFNameField(ctx, poolName, "name"); err != nil { return nil, err } - return am.database.GetTokenPool(ctx, ns, name) + pool, err := am.database.GetTokenPool(ctx, ns, poolName) + if err != nil { + return nil, err + } + if pool == nil { + return nil, i18n.NewError(ctx, i18n.Msg404NotFound) + } + return pool, nil } -func (am *assetManager) GetTokenAccounts(ctx context.Context, ns, typeName, name string, filter database.AndFilter) ([]*fftypes.TokenAccount, *database.FilterResult, error) { - pool, err := am.GetTokenPool(ctx, ns, typeName, name) +func (am *assetManager) GetTokenAccounts(ctx context.Context, ns, typeName, poolName string, filter database.AndFilter) ([]*fftypes.TokenAccount, *database.FilterResult, error) { + pool, err := am.GetTokenPool(ctx, ns, typeName, poolName) if err != nil { return nil, nil, err } - return am.database.GetTokenAccounts(ctx, filter.Condition(filter.Builder().Eq("protocolid", pool.ProtocolID))) + return am.database.GetTokenAccounts(ctx, filter.Condition(filter.Builder().Eq("poolprotocolid", pool.ProtocolID))) } func (am *assetManager) ValidateTokenPoolTx(ctx context.Context, pool *fftypes.TokenPool, protocolTxID string) error { @@ -225,6 +236,112 @@ func (am *assetManager) ValidateTokenPoolTx(ctx context.Context, pool *fftypes.T return nil } +func (am *assetManager) GetTokenTransfers(ctx context.Context, ns, typeName, name string, filter database.AndFilter) ([]*fftypes.TokenTransfer, *database.FilterResult, error) { + pool, err := am.GetTokenPool(ctx, ns, typeName, name) + if err != nil { + return nil, nil, err + } + return am.database.GetTokenTransfers(ctx, filter.Condition(filter.Builder().Eq("poolprotocolid", pool.ProtocolID))) +} + +func (am *assetManager) MintTokens(ctx context.Context, ns, typeName, poolName string, transfer *fftypes.TokenTransfer, waitConfirm bool) (*fftypes.TokenTransfer, error) { + transfer.Type = fftypes.TokenTransferTypeMint + if transfer.Key == "" { + org, err := am.identity.GetLocalOrganization(ctx) + if err != nil { + return nil, err + } + transfer.Key = org.Identity + } + transfer.From = "" + if transfer.To == "" { + transfer.To = transfer.Key + } + return am.transferTokensWithID(ctx, fftypes.NewUUID(), ns, typeName, poolName, transfer, waitConfirm) +} + +func (am *assetManager) BurnTokens(ctx context.Context, ns, typeName, poolName string, transfer *fftypes.TokenTransfer, waitConfirm bool) (*fftypes.TokenTransfer, error) { + transfer.Type = fftypes.TokenTransferTypeBurn + if transfer.Key == "" { + org, err := am.identity.GetLocalOrganization(ctx) + if err != nil { + return nil, err + } + transfer.Key = org.Identity + } + if transfer.From == "" { + transfer.From = transfer.Key + } + transfer.To = "" + return am.transferTokensWithID(ctx, fftypes.NewUUID(), ns, typeName, poolName, transfer, waitConfirm) +} + +func (am *assetManager) TransferTokens(ctx context.Context, ns, typeName, poolName string, transfer *fftypes.TokenTransfer, waitConfirm bool) (*fftypes.TokenTransfer, error) { + transfer.Type = fftypes.TokenTransferTypeTransfer + if transfer.Key == "" { + org, err := am.identity.GetLocalOrganization(ctx) + if err != nil { + return nil, err + } + transfer.Key = org.Identity + } + if transfer.From == "" { + transfer.From = transfer.Key + } + if transfer.To == "" { + transfer.To = transfer.Key + } + if transfer.From == transfer.To { + return nil, i18n.NewError(ctx, i18n.MsgCannotTransferToSelf) + } + return am.transferTokensWithID(ctx, fftypes.NewUUID(), ns, typeName, poolName, transfer, waitConfirm) +} + +func (am *assetManager) transferTokensWithID(ctx context.Context, id *fftypes.UUID, ns, typeName, poolName string, transfer *fftypes.TokenTransfer, waitConfirm bool) (*fftypes.TokenTransfer, error) { + plugin, err := am.selectTokenPlugin(ctx, typeName) + if err != nil { + return nil, err + } + pool, err := am.GetTokenPool(ctx, ns, typeName, poolName) + if err != nil { + return nil, err + } + + if waitConfirm { + return am.syncasync.SendConfirmTokenTransfer(ctx, ns, func(requestID *fftypes.UUID) error { + _, err := am.transferTokensWithID(ctx, requestID, ns, typeName, poolName, transfer, false) + return err + }) + } + + op := fftypes.NewTXOperation( + plugin, + ns, + fftypes.NewUUID(), + "", + fftypes.OpTypeTokenTransfer, + fftypes.OpStatusPending, + "") + err = am.database.UpsertOperation(ctx, op, false) + if err != nil { + return nil, err + } + + transfer.LocalID = id + transfer.PoolProtocolID = pool.ProtocolID + + switch transfer.Type { + case fftypes.TokenTransferTypeMint: + return transfer, plugin.MintTokens(ctx, op.ID, transfer) + case fftypes.TokenTransferTypeTransfer: + return transfer, plugin.TransferTokens(ctx, op.ID, transfer) + case fftypes.TokenTransferTypeBurn: + return transfer, plugin.BurnTokens(ctx, op.ID, transfer) + default: + panic(fmt.Sprintf("unknown transfer type: %v", transfer.Type)) + } +} + func (am *assetManager) Start() error { return nil } diff --git a/internal/assets/manager_test.go b/internal/assets/manager_test.go index 93dcbe3ec5..fcda27a11d 100644 --- a/internal/assets/manager_test.go +++ b/internal/assets/manager_test.go @@ -96,7 +96,7 @@ func TestCreateTokenPoolBadConnector(t *testing.T) { mdm := am.data.(*datamocks.Manager) mim := am.identity.(*identitymanagermocks.Manager) - mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil).Maybe() + mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil) mdm.On("VerifyNamespaceExists", context.Background(), "ns1").Return(nil) _, err := am.CreateTokenPool(context.Background(), "ns1", "bad", &fftypes.TokenPool{}, false) @@ -111,7 +111,7 @@ func TestCreateTokenPoolFail(t *testing.T) { mdm := am.data.(*datamocks.Manager) mti := am.tokens["magic-tokens"].(*tokenmocks.Plugin) mim := am.identity.(*identitymanagermocks.Manager) - mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil).Maybe() + mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil) mdm.On("VerifyNamespaceExists", context.Background(), "ns1").Return(nil) mdi.On("UpsertTransaction", context.Background(), mock.MatchedBy(func(tx *fftypes.Transaction) bool { return tx.Subject.Type == fftypes.TransactionTypeTokenPool @@ -130,7 +130,7 @@ func TestCreateTokenPoolTransactionFail(t *testing.T) { mdi := am.database.(*databasemocks.Plugin) mdm := am.data.(*datamocks.Manager) mim := am.identity.(*identitymanagermocks.Manager) - mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil).Maybe() + mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil) mdm.On("VerifyNamespaceExists", context.Background(), "ns1").Return(nil) mdi.On("UpsertTransaction", context.Background(), mock.Anything, false).Return(fmt.Errorf("pop")) @@ -145,7 +145,7 @@ func TestCreateTokenPoolOperationFail(t *testing.T) { mdi := am.database.(*databasemocks.Plugin) mdm := am.data.(*datamocks.Manager) mim := am.identity.(*identitymanagermocks.Manager) - mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil).Maybe() + mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil) mdm.On("VerifyNamespaceExists", context.Background(), "ns1").Return(nil) mdi.On("UpsertTransaction", context.Background(), mock.MatchedBy(func(tx *fftypes.Transaction) bool { return tx.Subject.Type == fftypes.TransactionTypeTokenPool @@ -164,7 +164,7 @@ func TestCreateTokenPoolSuccess(t *testing.T) { mdm := am.data.(*datamocks.Manager) mti := am.tokens["magic-tokens"].(*tokenmocks.Plugin) mim := am.identity.(*identitymanagermocks.Manager) - mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil).Maybe() + mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil) mdm.On("VerifyNamespaceExists", context.Background(), "ns1").Return(nil) mti.On("CreateTokenPool", context.Background(), mock.Anything, mock.Anything, mock.Anything).Return(nil) mdi.On("UpsertTransaction", context.Background(), mock.MatchedBy(func(tx *fftypes.Transaction) bool { @@ -187,7 +187,7 @@ func TestCreateTokenPoolConfirm(t *testing.T) { msa := am.syncasync.(*syncasyncmocks.Bridge) mti := am.tokens["magic-tokens"].(*tokenmocks.Plugin) mim := am.identity.(*identitymanagermocks.Manager) - mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil).Maybe() + mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil) mdm.On("VerifyNamespaceExists", context.Background(), "ns1").Return(nil).Times(2) mti.On("CreateTokenPool", context.Background(), mock.Anything, mock.MatchedBy(func(pool *fftypes.TokenPool) bool { return pool.ID == requestID @@ -212,7 +212,7 @@ func TestGetTokenPool(t *testing.T) { defer cancel() mdi := am.database.(*databasemocks.Plugin) - mdi.On("GetTokenPool", context.Background(), "ns1", "abc").Return(nil, nil) + mdi.On("GetTokenPool", context.Background(), "ns1", "abc").Return(&fftypes.TokenPool{}, nil) _, err := am.GetTokenPool(context.Background(), "ns1", "magic-tokens", "abc") assert.NoError(t, err) } @@ -308,3 +308,295 @@ func TestValidateTokenPoolTx(t *testing.T) { err := am.ValidateTokenPoolTx(context.Background(), nil, "") assert.NoError(t, err) } + +func TestGetTokenTransfers(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + pool := &fftypes.TokenPool{ + ID: fftypes.NewUUID(), + } + mdi := am.database.(*databasemocks.Plugin) + fb := database.TokenTransferQueryFactory.NewFilter(context.Background()) + f := fb.And() + mdi.On("GetTokenPool", context.Background(), "ns1", "test").Return(pool, nil) + mdi.On("GetTokenTransfers", context.Background(), f).Return([]*fftypes.TokenTransfer{}, nil, nil) + _, _, err := am.GetTokenTransfers(context.Background(), "ns1", "magic-tokens", "test", f) + assert.NoError(t, err) +} + +func TestGetTokenTransfersBadPool(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + mdi := am.database.(*databasemocks.Plugin) + fb := database.TokenTransferQueryFactory.NewFilter(context.Background()) + f := fb.And() + mdi.On("GetTokenPool", context.Background(), "ns1", "test").Return(nil, fmt.Errorf("pop")) + _, _, err := am.GetTokenTransfers(context.Background(), "ns1", "magic-tokens", "test", f) + assert.EqualError(t, err, "pop") +} + +func TestGetTokenTransfersNoPool(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + mdi := am.database.(*databasemocks.Plugin) + fb := database.TokenTransferQueryFactory.NewFilter(context.Background()) + f := fb.And() + mdi.On("GetTokenPool", context.Background(), "ns1", "test").Return(nil, nil) + _, _, err := am.GetTokenTransfers(context.Background(), "ns1", "magic-tokens", "test", f) + assert.Regexp(t, "FF10109", err) +} + +func TestMintTokensSuccess(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + mint := &fftypes.TokenTransfer{} + mint.Amount.Int().SetInt64(5) + + mdi := am.database.(*databasemocks.Plugin) + mti := am.tokens["magic-tokens"].(*tokenmocks.Plugin) + mim := am.identity.(*identitymanagermocks.Manager) + mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil) + mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(&fftypes.TokenPool{}, nil) + mti.On("MintTokens", context.Background(), mock.Anything, mint).Return(nil) + mdi.On("UpsertOperation", mock.Anything, mock.Anything, false).Return(nil) + + _, err := am.MintTokens(context.Background(), "ns1", "magic-tokens", "pool1", mint, false) + assert.NoError(t, err) +} + +func TestMintTokensBadPlugin(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + mim := am.identity.(*identitymanagermocks.Manager) + mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil) + + _, err := am.MintTokens(context.Background(), "", "", "", &fftypes.TokenTransfer{}, false) + assert.Regexp(t, "FF10272", err) +} + +func TestMintTokensBadPool(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + mint := &fftypes.TokenTransfer{} + mint.Amount.Int().SetInt64(5) + + mdi := am.database.(*databasemocks.Plugin) + mim := am.identity.(*identitymanagermocks.Manager) + mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil) + mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(nil, fmt.Errorf("pop")) + + _, err := am.MintTokens(context.Background(), "ns1", "magic-tokens", "pool1", mint, false) + assert.EqualError(t, err, "pop") +} + +func TestMintTokensIdentityFail(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + mint := &fftypes.TokenTransfer{} + mint.Amount.Int().SetInt64(5) + + mdi := am.database.(*databasemocks.Plugin) + mim := am.identity.(*identitymanagermocks.Manager) + mim.On("GetLocalOrganization", context.Background()).Return(nil, fmt.Errorf("pop")) + mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(&fftypes.TokenPool{}, nil) + + _, err := am.MintTokens(context.Background(), "ns1", "magic-tokens", "pool1", mint, false) + assert.EqualError(t, err, "pop") +} + +func TestMintTokensFail(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + mint := &fftypes.TokenTransfer{} + mint.Amount.Int().SetInt64(5) + + mdi := am.database.(*databasemocks.Plugin) + mti := am.tokens["magic-tokens"].(*tokenmocks.Plugin) + mim := am.identity.(*identitymanagermocks.Manager) + mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil) + mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(&fftypes.TokenPool{}, nil) + mdi.On("UpsertOperation", mock.Anything, mock.Anything, false).Return(nil) + mti.On("MintTokens", context.Background(), mock.Anything, mint).Return(fmt.Errorf("pop")) + + _, err := am.MintTokens(context.Background(), "ns1", "magic-tokens", "pool1", mint, false) + assert.EqualError(t, err, "pop") +} + +func TestMintTokensOperationFail(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + mint := &fftypes.TokenTransfer{} + mint.Amount.Int().SetInt64(5) + + mdi := am.database.(*databasemocks.Plugin) + mim := am.identity.(*identitymanagermocks.Manager) + mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil) + mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(&fftypes.TokenPool{}, nil) + mdi.On("UpsertOperation", mock.Anything, mock.Anything, false).Return(fmt.Errorf("pop")) + + _, err := am.MintTokens(context.Background(), "ns1", "magic-tokens", "pool1", mint, false) + assert.EqualError(t, err, "pop") +} + +func TestMintTokensConfirm(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + requestID := fftypes.NewUUID() + mint := &fftypes.TokenTransfer{} + mint.Amount.Int().SetInt64(5) + + mdi := am.database.(*databasemocks.Plugin) + mdm := am.data.(*datamocks.Manager) + msa := am.syncasync.(*syncasyncmocks.Bridge) + mti := am.tokens["magic-tokens"].(*tokenmocks.Plugin) + mim := am.identity.(*identitymanagermocks.Manager) + mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil) + mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(&fftypes.TokenPool{}, nil) + mti.On("MintTokens", context.Background(), mock.Anything, mint).Return(nil) + mdi.On("UpsertOperation", mock.Anything, mock.Anything, false).Return(nil) + msa.On("SendConfirmTokenTransfer", context.Background(), "ns1", mock.Anything). + Run(func(args mock.Arguments) { + send := args[2].(syncasync.RequestSender) + send(requestID) + }). + Return(nil, nil) + + _, err := am.MintTokens(context.Background(), "ns1", "magic-tokens", "pool1", mint, true) + assert.NoError(t, err) + + mdi.AssertExpectations(t) + mdm.AssertExpectations(t) + msa.AssertExpectations(t) + mti.AssertExpectations(t) +} + +func TestBurnTokensSuccess(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + burn := &fftypes.TokenTransfer{} + burn.Amount.Int().SetInt64(5) + + mdi := am.database.(*databasemocks.Plugin) + mti := am.tokens["magic-tokens"].(*tokenmocks.Plugin) + mim := am.identity.(*identitymanagermocks.Manager) + mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil) + mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(&fftypes.TokenPool{}, nil) + mti.On("BurnTokens", context.Background(), mock.Anything, burn).Return(nil) + mdi.On("UpsertOperation", mock.Anything, mock.Anything, false).Return(nil) + + _, err := am.BurnTokens(context.Background(), "ns1", "magic-tokens", "pool1", burn, false) + assert.NoError(t, err) + + mim.AssertExpectations(t) + mdi.AssertExpectations(t) + mti.AssertExpectations(t) +} + +func TestBurnTokensIdentityFail(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + burn := &fftypes.TokenTransfer{} + burn.Amount.Int().SetInt64(5) + + mdi := am.database.(*databasemocks.Plugin) + mim := am.identity.(*identitymanagermocks.Manager) + mim.On("GetLocalOrganization", context.Background()).Return(nil, fmt.Errorf("pop")) + mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(&fftypes.TokenPool{}, nil) + + _, err := am.BurnTokens(context.Background(), "ns1", "magic-tokens", "pool1", burn, false) + assert.EqualError(t, err, "pop") +} + +func TestTransferTokensSuccess(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + transfer := &fftypes.TokenTransfer{ + From: "A", + To: "B", + } + transfer.Amount.Int().SetInt64(5) + + mdi := am.database.(*databasemocks.Plugin) + mti := am.tokens["magic-tokens"].(*tokenmocks.Plugin) + mim := am.identity.(*identitymanagermocks.Manager) + mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil) + mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(&fftypes.TokenPool{}, nil) + mti.On("TransferTokens", context.Background(), mock.Anything, transfer).Return(nil) + mdi.On("UpsertOperation", mock.Anything, mock.Anything, false).Return(nil) + + _, err := am.TransferTokens(context.Background(), "ns1", "magic-tokens", "pool1", transfer, false) + assert.NoError(t, err) + + mim.AssertExpectations(t) + mdi.AssertExpectations(t) + mti.AssertExpectations(t) +} + +func TestTransferTokensIdentityFail(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + transfer := &fftypes.TokenTransfer{ + From: "A", + To: "B", + } + transfer.Amount.Int().SetInt64(5) + + mdi := am.database.(*databasemocks.Plugin) + mim := am.identity.(*identitymanagermocks.Manager) + mim.On("GetLocalOrganization", context.Background()).Return(nil, fmt.Errorf("pop")) + mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(&fftypes.TokenPool{}, nil) + + _, err := am.TransferTokens(context.Background(), "ns1", "magic-tokens", "pool1", transfer, false) + assert.EqualError(t, err, "pop") +} + +func TestTransferTokensNoFromOrTo(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + transfer := &fftypes.TokenTransfer{} + transfer.Amount.Int().SetInt64(5) + + mim := am.identity.(*identitymanagermocks.Manager) + mim.On("GetLocalOrganization", context.Background()).Return(&fftypes.Organization{Identity: "0x12345"}, nil) + + _, err := am.TransferTokens(context.Background(), "ns1", "magic-tokens", "pool1", transfer, false) + assert.Regexp(t, "FF10280", err) + + mim.AssertExpectations(t) +} + +func TestTransferTokensInvalidType(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + + transfer := &fftypes.TokenTransfer{ + From: "A", + To: "B", + } + transfer.Amount.Int().SetInt64(5) + + mdi := am.database.(*databasemocks.Plugin) + mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(&fftypes.TokenPool{}, nil) + mdi.On("UpsertOperation", mock.Anything, mock.Anything, false).Return(nil) + + assert.Panics(t, func() { + am.transferTokensWithID(context.Background(), fftypes.NewUUID(), "ns1", "magic-tokens", "pool1", transfer, false) + }) + + mdi.AssertExpectations(t) +} diff --git a/internal/assets/token_pool_created.go b/internal/assets/token_pool_created.go index 08cebf4068..96fc287c6e 100644 --- a/internal/assets/token_pool_created.go +++ b/internal/assets/token_pool_created.go @@ -28,7 +28,7 @@ func (am *assetManager) TokenPoolCreated(tk tokens.Plugin, tokenType fftypes.Tok fb := database.OperationQueryFactory.NewFilter(am.ctx) filter := fb.And( fb.Eq("tx", tx), - fb.Eq("type", fftypes.OpTypeTokensCreatePool), + fb.Eq("type", fftypes.OpTypeTokenCreatePool), ) operations, _, err := am.database.GetOperations(am.ctx, filter) if err != nil || len(operations) == 0 { @@ -76,7 +76,7 @@ func (am *assetManager) TokenPoolCreated(tk tokens.Plugin, tokenType fftypes.Tok pool.Namespace, tx, "", - fftypes.OpTypeTokensAnnouncePool, + fftypes.OpTypeTokenAnnouncePool, fftypes.OpStatusPending, signingIdentity) diff --git a/internal/assets/token_pool_created_test.go b/internal/assets/token_pool_created_test.go index 5c0419e2d5..5faeac6508 100644 --- a/internal/assets/token_pool_created_test.go +++ b/internal/assets/token_pool_created_test.go @@ -55,7 +55,7 @@ func TestTokenPoolCreatedSuccess(t *testing.T) { return tx.Subject.Type == fftypes.TransactionTypeTokenPool }), false).Return(nil) mdi.On("UpsertOperation", am.ctx, mock.MatchedBy(func(op *fftypes.Operation) bool { - return op.Type == fftypes.OpTypeTokensAnnouncePool + return op.Type == fftypes.OpTypeTokenAnnouncePool }), false).Return(nil) mbm.On("BroadcastTokenPool", am.ctx, "test-ns", mock.MatchedBy(func(pool *fftypes.TokenPoolAnnouncement) bool { return pool.Namespace == "test-ns" && pool.Name == "my-pool" && *pool.ID == *poolID diff --git a/internal/assets/tokens_transferred.go b/internal/assets/tokens_transferred.go new file mode 100644 index 0000000000..caeca96b6e --- /dev/null +++ b/internal/assets/tokens_transferred.go @@ -0,0 +1,71 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 assets + +import ( + "context" + + "github.com/hyperledger/firefly/internal/log" + "github.com/hyperledger/firefly/pkg/fftypes" + "github.com/hyperledger/firefly/pkg/tokens" +) + +func (am *assetManager) TokensTransferred(tk tokens.Plugin, transfer *fftypes.TokenTransfer, signingIdentity string, protocolTxID string, additionalInfo fftypes.JSONObject) error { + return am.retry.Do(am.ctx, "persist token transfer", func(attempt int) (bool, error) { + err := am.database.RunAsGroup(am.ctx, func(ctx context.Context) error { + pool, err := am.database.GetTokenPoolByProtocolID(ctx, transfer.PoolProtocolID) + if err != nil { + return err + } + if pool == nil { + log.L(ctx).Warnf("Token transfer received for unknown pool '%s' - ignoring: %s", transfer.PoolProtocolID, protocolTxID) + return nil + } + if err := am.database.UpsertTokenTransfer(ctx, transfer); err != nil { + log.L(ctx).Errorf("Failed to record token transfer '%s': %s", transfer.ProtocolID, err) + return err + } + + balance := &fftypes.TokenBalanceChange{ + PoolProtocolID: transfer.PoolProtocolID, + TokenIndex: transfer.TokenIndex, + } + if transfer.Type != fftypes.TokenTransferTypeMint { + balance.Identity = transfer.From + balance.Amount.Int().Neg(transfer.Amount.Int()) + if err := am.database.AddTokenAccountBalance(ctx, balance); err != nil { + log.L(ctx).Errorf("Failed to update account '%s' for token transfer '%s': %s", balance.Identity, transfer.ProtocolID, err) + return err + } + } + + if transfer.Type != fftypes.TokenTransferTypeBurn { + balance.Identity = transfer.To + balance.Amount.Int().Set(transfer.Amount.Int()) + if err := am.database.AddTokenAccountBalance(ctx, balance); err != nil { + log.L(ctx).Errorf("Failed to update account '%s for token transfer '%s': %s", balance.Identity, transfer.ProtocolID, err) + return err + } + } + + log.L(ctx).Infof("Token transfer recorded id=%s author=%s", transfer.ProtocolID, signingIdentity) + event := fftypes.NewEvent(fftypes.EventTypeTransferConfirmed, pool.Namespace, transfer.LocalID) + return am.database.InsertEvent(ctx, event) + }) + return err != nil, err // retry indefinitely (until context closes) + }) +} diff --git a/internal/assets/tokens_transferred_test.go b/internal/assets/tokens_transferred_test.go new file mode 100644 index 0000000000..94dede2de8 --- /dev/null +++ b/internal/assets/tokens_transferred_test.go @@ -0,0 +1,103 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 assets + +import ( + "fmt" + "testing" + + "github.com/hyperledger/firefly/mocks/databasemocks" + "github.com/hyperledger/firefly/mocks/tokenmocks" + "github.com/hyperledger/firefly/pkg/fftypes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestTokensTransferredAddBalanceSucceedWithRetries(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + mdi := am.database.(*databasemocks.Plugin) + mti := &tokenmocks.Plugin{} + + transfer := &fftypes.TokenTransfer{ + Type: fftypes.TokenTransferTypeTransfer, + PoolProtocolID: "F1", + TokenIndex: "0", + From: "0x1", + To: "0x2", + } + transfer.Amount.Int().SetInt64(1) + fromBalance := &fftypes.TokenBalanceChange{ + PoolProtocolID: "F1", + TokenIndex: "0", + Identity: "0x1", + } + fromBalance.Amount.Int().SetInt64(-1) + toBalance := &fftypes.TokenBalanceChange{ + PoolProtocolID: "F1", + TokenIndex: "0", + Identity: "0x2", + } + toBalance.Amount.Int().SetInt64(1) + pool := &fftypes.TokenPool{ + Namespace: "ns1", + } + + mdi.On("GetTokenPoolByProtocolID", am.ctx, "F1").Return(nil, fmt.Errorf("pop")).Once() + mdi.On("GetTokenPoolByProtocolID", am.ctx, "F1").Return(pool, nil).Times(4) + mdi.On("UpsertTokenTransfer", am.ctx, transfer).Return(fmt.Errorf("pop")).Once() + mdi.On("UpsertTokenTransfer", am.ctx, transfer).Return(nil).Times(3) + mdi.On("AddTokenAccountBalance", am.ctx, fromBalance).Return(fmt.Errorf("pop")).Once() + mdi.On("AddTokenAccountBalance", am.ctx, fromBalance).Return(nil).Times(2) + mdi.On("AddTokenAccountBalance", am.ctx, toBalance).Return(fmt.Errorf("pop")).Once() + mdi.On("AddTokenAccountBalance", am.ctx, toBalance).Return(nil).Once() + mdi.On("InsertEvent", am.ctx, mock.MatchedBy(func(ev *fftypes.Event) bool { + return ev.Type == fftypes.EventTypeTransferConfirmed && ev.Reference == pool.ID && ev.Namespace == pool.Namespace + })).Return(nil).Once() + + info := fftypes.JSONObject{"some": "info"} + err := am.TokensTransferred(mti, transfer, "0x12345", "tx1", info) + assert.NoError(t, err) + + mdi.AssertExpectations(t) + mti.AssertExpectations(t) +} + +func TestTokensTransferredAddBalanceIgnore(t *testing.T) { + am, cancel := newTestAssets(t) + defer cancel() + mdi := am.database.(*databasemocks.Plugin) + mti := &tokenmocks.Plugin{} + + transfer := &fftypes.TokenTransfer{ + Type: fftypes.TokenTransferTypeTransfer, + PoolProtocolID: "F1", + TokenIndex: "0", + From: "0x1", + To: "0x2", + } + transfer.Amount.Int().SetInt64(1) + + mdi.On("GetTokenPoolByProtocolID", am.ctx, "F1").Return(nil, nil) + + info := fftypes.JSONObject{"some": "info"} + err := am.TokensTransferred(mti, transfer, "0x12345", "tx1", info) + assert.NoError(t, err) + + mdi.AssertExpectations(t) + mti.AssertExpectations(t) +} diff --git a/internal/database/sqlcommon/tokenaccount_sql.go b/internal/database/sqlcommon/tokenaccount_sql.go index 18a03aa881..bda07ab062 100644 --- a/internal/database/sqlcommon/tokenaccount_sql.go +++ b/internal/database/sqlcommon/tokenaccount_sql.go @@ -29,18 +29,18 @@ import ( var ( tokenAccountColumns = []string{ - "protocol_id", + "pool_protocol_id", "token_index", "identity", "balance", } tokenAccountFilterFieldMap = map[string]string{ - "protocolid": "protocol_id", - "tokenindex": "token_index", + "poolprotocolid": "pool_protocol_id", + "tokenindex": "token_index", } ) -func (s *SQLCommon) UpsertTokenAccount(ctx context.Context, account *fftypes.TokenAccount) (err error) { +func (s *SQLCommon) AddTokenAccountBalance(ctx context.Context, account *fftypes.TokenBalanceChange) (err error) { ctx, tx, autoCommit, err := s.beginOrUseTx(ctx) if err != nil { return err @@ -48,10 +48,10 @@ func (s *SQLCommon) UpsertTokenAccount(ctx context.Context, account *fftypes.Tok defer s.rollbackTx(ctx, tx, autoCommit) rows, _, err := s.queryTx(ctx, tx, - sq.Select("seq"). + sq.Select("balance"). From("tokenaccount"). Where(sq.And{ - sq.Eq{"protocol_id": account.ProtocolID}, + sq.Eq{"pool_protocol_id": account.PoolProtocolID}, sq.Eq{"token_index": account.TokenIndex}, sq.Eq{"identity": account.Identity}, }), @@ -60,14 +60,22 @@ func (s *SQLCommon) UpsertTokenAccount(ctx context.Context, account *fftypes.Tok return err } existing := rows.Next() + + var balance fftypes.BigInt + if existing { + if err = rows.Scan(&balance); err != nil { + return i18n.WrapError(ctx, err, i18n.MsgDBReadErr, "tokenaccount") + } + } + balance.Int().Add(balance.Int(), account.Amount.Int()) rows.Close() if existing { if err = s.updateTx(ctx, tx, sq.Update("tokenaccount"). - Set("balance", account.Balance). + Set("balance", balance). Where(sq.And{ - sq.Eq{"protocol_id": account.ProtocolID}, + sq.Eq{"pool_protocol_id": account.PoolProtocolID}, sq.Eq{"token_index": account.TokenIndex}, sq.Eq{"identity": account.Identity}, }), @@ -80,10 +88,10 @@ func (s *SQLCommon) UpsertTokenAccount(ctx context.Context, account *fftypes.Tok sq.Insert("tokenaccount"). Columns(tokenAccountColumns...). Values( - account.ProtocolID, + account.PoolProtocolID, account.TokenIndex, account.Identity, - account.Balance, + account.Amount, ), nil, ); err != nil { @@ -97,7 +105,7 @@ func (s *SQLCommon) UpsertTokenAccount(ctx context.Context, account *fftypes.Tok func (s *SQLCommon) tokenAccountResult(ctx context.Context, row *sql.Rows) (*fftypes.TokenAccount, error) { account := fftypes.TokenAccount{} err := row.Scan( - &account.ProtocolID, + &account.PoolProtocolID, &account.TokenIndex, &account.Identity, &account.Balance, @@ -135,7 +143,7 @@ func (s *SQLCommon) getTokenAccountPred(ctx context.Context, desc string, pred i func (s *SQLCommon) GetTokenAccount(ctx context.Context, protocolID, tokenIndex, identity string) (message *fftypes.TokenAccount, err error) { desc := fftypes.TokenAccountIdentifier(protocolID, tokenIndex, identity) return s.getTokenAccountPred(ctx, desc, sq.And{ - sq.Eq{"protocol_id": protocolID}, + sq.Eq{"pool_protocol_id": protocolID}, sq.Eq{"token_index": tokenIndex}, sq.Eq{"identity": identity}, }) diff --git a/internal/database/sqlcommon/tokenaccount_sql_test.go b/internal/database/sqlcommon/tokenaccount_sql_test.go index 747745f2af..1a80df0d2a 100644 --- a/internal/database/sqlcommon/tokenaccount_sql_test.go +++ b/internal/database/sqlcommon/tokenaccount_sql_test.go @@ -36,15 +36,21 @@ func TestTokenAccountE2EWithDB(t *testing.T) { ctx := context.Background() // Create a new token account + operation := &fftypes.TokenBalanceChange{ + PoolProtocolID: "F1", + TokenIndex: "1", + Identity: "0x0", + } + operation.Amount.Int().SetInt64(10) account := &fftypes.TokenAccount{ - ProtocolID: "F1", - TokenIndex: "1", - Identity: "0x0", - Balance: 10, + PoolProtocolID: "F1", + TokenIndex: "1", + Identity: "0x0", } + account.Balance.Int().SetInt64(10) accountJson, _ := json.Marshal(&account) - err := s.UpsertTokenAccount(ctx, account) + err := s.AddTokenAccountBalance(ctx, operation) assert.NoError(t, err) // Query back the token account (by pool ID and identity) @@ -57,7 +63,7 @@ func TestTokenAccountE2EWithDB(t *testing.T) { // Query back the token account (by query filter) fb := database.TokenAccountQueryFactory.NewFilter(ctx) filter := fb.And( - fb.Eq("protocolid", account.ProtocolID), + fb.Eq("poolprotocolid", account.PoolProtocolID), fb.Eq("tokenindex", account.TokenIndex), fb.Eq("identity", account.Identity), ) @@ -67,96 +73,122 @@ func TestTokenAccountE2EWithDB(t *testing.T) { assert.Equal(t, int64(1), *res.TotalCount) accountReadJson, _ = json.Marshal(accounts[0]) assert.Equal(t, string(accountJson), string(accountReadJson)) + + // Add to the balance + err = s.AddTokenAccountBalance(ctx, operation) + assert.NoError(t, err) + + // Query back the token account (by pool ID and identity) + accountRead, err = s.GetTokenAccount(ctx, "F1", "1", "0x0") + assert.NoError(t, err) + assert.NotNil(t, accountRead) + accountReadJson, _ = json.Marshal(&accountRead) + account.Balance.Int().SetInt64(20) + accountJson, _ = json.Marshal(&account) + assert.Equal(t, string(accountJson), string(accountReadJson)) } -func TestUpsertTokenAccountFailBegin(t *testing.T) { +func TestAddTokenAccountBalanceFailBegin(t *testing.T) { s, mock := newMockProvider().init() mock.ExpectBegin().WillReturnError(fmt.Errorf("pop")) - err := s.UpsertTokenAccount(context.Background(), &fftypes.TokenAccount{}) + err := s.AddTokenAccountBalance(context.Background(), &fftypes.TokenBalanceChange{}) assert.Regexp(t, "FF10114", err) assert.NoError(t, mock.ExpectationsWereMet()) } -func TestUpsertTokenAccountFailSelect(t *testing.T) { +func TestAddTokenAccountBalanceFailSelect(t *testing.T) { s, mock := newMockProvider().init() mock.ExpectBegin() mock.ExpectQuery("SELECT .*").WillReturnError(fmt.Errorf("pop")) - err := s.UpsertTokenAccount(context.Background(), &fftypes.TokenAccount{}) + err := s.AddTokenAccountBalance(context.Background(), &fftypes.TokenBalanceChange{}) assert.Regexp(t, "FF10115", err) assert.NoError(t, mock.ExpectationsWereMet()) } -func TestUpsertTokenAccountFailInsert(t *testing.T) { +func TestAddTokenAccountSelectBadExistingValue(t *testing.T) { + s, mock := newMockProvider().init() + mock.ExpectBegin() + mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{ + "balance", + }).AddRow( + "!not an integer", + )) + err := s.AddTokenAccountBalance(context.Background(), &fftypes.TokenBalanceChange{}) + assert.Regexp(t, "FF10121", err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestAddTokenAccountBalanceFailInsert(t *testing.T) { s, mock := newMockProvider().init() mock.ExpectBegin() mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{})) mock.ExpectExec("INSERT .*").WillReturnError(fmt.Errorf("pop")) mock.ExpectRollback() - err := s.UpsertTokenAccount(context.Background(), &fftypes.TokenAccount{}) + err := s.AddTokenAccountBalance(context.Background(), &fftypes.TokenBalanceChange{}) assert.Regexp(t, "FF10116", err) assert.NoError(t, mock.ExpectationsWereMet()) } -func TestUpsertTokenAccountFailUpdate(t *testing.T) { +func TestAddTokenAccountBalanceFailUpdate(t *testing.T) { s, mock := newMockProvider().init() mock.ExpectBegin() mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("1")) mock.ExpectExec("UPDATE .*").WillReturnError(fmt.Errorf("pop")) mock.ExpectRollback() - err := s.UpsertTokenAccount(context.Background(), &fftypes.TokenAccount{}) + err := s.AddTokenAccountBalance(context.Background(), &fftypes.TokenBalanceChange{}) assert.Regexp(t, "FF10117", err) assert.NoError(t, mock.ExpectationsWereMet()) } -func TestUpsertTokenAccountFailCommit(t *testing.T) { +func TestAddTokenAccountBalanceFailCommit(t *testing.T) { s, mock := newMockProvider().init() mock.ExpectBegin() mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{"id"})) mock.ExpectExec("INSERT .*").WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit().WillReturnError(fmt.Errorf("pop")) - err := s.UpsertTokenAccount(context.Background(), &fftypes.TokenAccount{}) + err := s.AddTokenAccountBalance(context.Background(), &fftypes.TokenBalanceChange{}) assert.Regexp(t, "FF10119", err) assert.NoError(t, mock.ExpectationsWereMet()) } -func TestUpsertTokenAccountInsertSuccess(t *testing.T) { +func TestAddTokenAccountBalanceInsertSuccess(t *testing.T) { s, db := newMockProvider().init() callbacks := &databasemocks.Callbacks{} s.SQLCommon.callbacks = callbacks - account := &fftypes.TokenAccount{ - ProtocolID: "F1", - TokenIndex: "1", - Identity: "0x0", - Balance: 10, + operation := &fftypes.TokenBalanceChange{ + PoolProtocolID: "F1", + TokenIndex: "1", + Identity: "0x0", } + operation.Amount.Int().SetInt64(10) db.ExpectBegin() db.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{"id"})) db.ExpectExec("INSERT .*"). - WithArgs("F1", "1", "0x0", 10). + WithArgs("F1", "1", "0x0", sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(1, 1)) db.ExpectCommit() - err := s.UpsertTokenAccount(context.Background(), account) + err := s.AddTokenAccountBalance(context.Background(), operation) assert.NoError(t, err) assert.NoError(t, db.ExpectationsWereMet()) } -func TestUpsertTokenAccountUpdateSuccess(t *testing.T) { +func TestAddTokenAccountBalanceUpdateSuccess(t *testing.T) { s, db := newMockProvider().init() callbacks := &databasemocks.Callbacks{} s.SQLCommon.callbacks = callbacks - account := &fftypes.TokenAccount{ - ProtocolID: "F1", - TokenIndex: "1", - Identity: "0x0", - Balance: 10, + operation := &fftypes.TokenBalanceChange{ + PoolProtocolID: "F1", + TokenIndex: "1", + Identity: "0x0", } + operation.Amount.Int().SetInt64(10) db.ExpectBegin() db.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{"seq"}).AddRow("1")) db.ExpectExec("UPDATE .*").WillReturnResult(sqlmock.NewResult(1, 1)) db.ExpectCommit() - err := s.UpsertTokenAccount(context.Background(), account) + err := s.AddTokenAccountBalance(context.Background(), operation) assert.NoError(t, err) assert.NoError(t, db.ExpectationsWereMet()) } @@ -189,7 +221,7 @@ func TestGetTokenAccountScanFail(t *testing.T) { func TestGetTokenAccountsQueryFail(t *testing.T) { s, mock := newMockProvider().init() mock.ExpectQuery("SELECT .*").WillReturnError(fmt.Errorf("pop")) - f := database.TokenAccountQueryFactory.NewFilter(context.Background()).Eq("protocolid", "") + f := database.TokenAccountQueryFactory.NewFilter(context.Background()).Eq("poolprotocolid", "") _, _, err := s.GetTokenAccounts(context.Background(), f) assert.Regexp(t, "FF10115", err) assert.NoError(t, mock.ExpectationsWereMet()) @@ -197,15 +229,15 @@ func TestGetTokenAccountsQueryFail(t *testing.T) { func TestGetTokenAccountsBuildQueryFail(t *testing.T) { s, _ := newMockProvider().init() - f := database.TokenAccountQueryFactory.NewFilter(context.Background()).Eq("protocolid", map[bool]bool{true: false}) + f := database.TokenAccountQueryFactory.NewFilter(context.Background()).Eq("poolprotocolid", map[bool]bool{true: false}) _, _, err := s.GetTokenAccounts(context.Background(), f) assert.Regexp(t, "FF10149.*id", err) } func TestGetTokenAccountsScanFail(t *testing.T) { s, mock := newMockProvider().init() - mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{"protocolid"}).AddRow("only one")) - f := database.TokenAccountQueryFactory.NewFilter(context.Background()).Eq("protocolid", "") + mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{"poolprotocolid"}).AddRow("only one")) + f := database.TokenAccountQueryFactory.NewFilter(context.Background()).Eq("poolprotocolid", "") _, _, err := s.GetTokenAccounts(context.Background(), f) assert.Regexp(t, "FF10121", err) assert.NoError(t, mock.ExpectationsWereMet()) diff --git a/internal/database/sqlcommon/tokentransfer_sql.go b/internal/database/sqlcommon/tokentransfer_sql.go new file mode 100644 index 0000000000..de163d06f2 --- /dev/null +++ b/internal/database/sqlcommon/tokentransfer_sql.go @@ -0,0 +1,202 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 sqlcommon + +import ( + "context" + "database/sql" + + sq "github.com/Masterminds/squirrel" + "github.com/hyperledger/firefly/internal/i18n" + "github.com/hyperledger/firefly/internal/log" + "github.com/hyperledger/firefly/pkg/database" + "github.com/hyperledger/firefly/pkg/fftypes" +) + +var ( + tokenTransferColumns = []string{ + "type", + "local_id", + "pool_protocol_id", + "token_index", + "key", + "from_key", + "to_key", + "amount", + "protocol_id", + "message_hash", + "tx_type", + "tx_id", + "created", + } + tokenTransferFilterFieldMap = map[string]string{ + "localid": "local_id", + "poolprotocolid": "pool_protocol_id", + "tokenindex": "token_index", + "from": "from_key", + "to": "to_key", + "protocolid": "protocol_id", + "messagehash": "message_hash", + "transaction.type": "tx_type", + "transaction.id": "tx_id", + } +) + +func (s *SQLCommon) UpsertTokenTransfer(ctx context.Context, transfer *fftypes.TokenTransfer) (err error) { + ctx, tx, autoCommit, err := s.beginOrUseTx(ctx) + if err != nil { + return err + } + defer s.rollbackTx(ctx, tx, autoCommit) + + rows, _, err := s.queryTx(ctx, tx, + sq.Select("seq"). + From("tokentransfer"). + Where(sq.Eq{"protocol_id": transfer.ProtocolID}), + ) + if err != nil { + return err + } + existing := rows.Next() + rows.Close() + + if existing { + if err = s.updateTx(ctx, tx, + sq.Update("tokentransfer"). + Set("type", transfer.Type). + Set("local_id", transfer.LocalID). + Set("pool_protocol_id", transfer.PoolProtocolID). + Set("token_index", transfer.TokenIndex). + Set("key", transfer.Key). + Set("from_key", transfer.From). + Set("to_key", transfer.To). + Set("amount", transfer.Amount). + Set("message_hash", transfer.MessageHash). + Set("tx_type", transfer.TX.Type). + Set("tx_id", transfer.TX.ID). + Where(sq.Eq{"protocol_id": transfer.ProtocolID}), + func() { + s.callbacks.UUIDCollectionEvent(database.CollectionTokenTransfers, fftypes.ChangeEventTypeUpdated, transfer.LocalID) + }, + ); err != nil { + return err + } + } else { + transfer.Created = fftypes.Now() + if _, err = s.insertTx(ctx, tx, + sq.Insert("tokentransfer"). + Columns(tokenTransferColumns...). + Values( + transfer.Type, + transfer.LocalID, + transfer.PoolProtocolID, + transfer.TokenIndex, + transfer.Key, + transfer.From, + transfer.To, + transfer.Amount, + transfer.ProtocolID, + transfer.MessageHash, + transfer.TX.Type, + transfer.TX.ID, + transfer.Created, + ), + func() { + s.callbacks.UUIDCollectionEvent(database.CollectionTokenTransfers, fftypes.ChangeEventTypeCreated, transfer.LocalID) + }, + ); err != nil { + return err + } + } + + return s.commitTx(ctx, tx, autoCommit) +} + +func (s *SQLCommon) tokenTransferResult(ctx context.Context, row *sql.Rows) (*fftypes.TokenTransfer, error) { + transfer := fftypes.TokenTransfer{} + err := row.Scan( + &transfer.Type, + &transfer.LocalID, + &transfer.PoolProtocolID, + &transfer.TokenIndex, + &transfer.Key, + &transfer.From, + &transfer.To, + &transfer.Amount, + &transfer.ProtocolID, + &transfer.MessageHash, + &transfer.TX.Type, + &transfer.TX.ID, + &transfer.Created, + ) + if err != nil { + return nil, i18n.WrapError(ctx, err, i18n.MsgDBReadErr, "tokentransfer") + } + return &transfer, nil +} + +func (s *SQLCommon) getTokenTransferPred(ctx context.Context, desc string, pred interface{}) (*fftypes.TokenTransfer, error) { + rows, _, err := s.query(ctx, + sq.Select(tokenTransferColumns...). + From("tokentransfer"). + Where(pred), + ) + if err != nil { + return nil, err + } + defer rows.Close() + + if !rows.Next() { + log.L(ctx).Debugf("Token transfer '%s' not found", desc) + return nil, nil + } + + transfer, err := s.tokenTransferResult(ctx, rows) + if err != nil { + return nil, err + } + + return transfer, nil +} + +func (s *SQLCommon) GetTokenTransfer(ctx context.Context, localID *fftypes.UUID) (*fftypes.TokenTransfer, error) { + return s.getTokenTransferPred(ctx, localID.String(), sq.Eq{"local_id": localID}) +} + +func (s *SQLCommon) GetTokenTransfers(ctx context.Context, filter database.Filter) (message []*fftypes.TokenTransfer, fr *database.FilterResult, err error) { + query, fop, fi, err := s.filterSelect(ctx, "", sq.Select(tokenTransferColumns...).From("tokentransfer"), filter, tokenTransferFilterFieldMap, []string{"seq"}) + if err != nil { + return nil, nil, err + } + + rows, tx, err := s.query(ctx, query) + if err != nil { + return nil, nil, err + } + defer rows.Close() + + transfers := []*fftypes.TokenTransfer{} + for rows.Next() { + d, err := s.tokenTransferResult(ctx, rows) + if err != nil { + return nil, nil, err + } + transfers = append(transfers, d) + } + + return transfers, s.queryRes(ctx, tx, "tokentransfer", fop, fi), err +} diff --git a/internal/database/sqlcommon/tokentransfer_sql_test.go b/internal/database/sqlcommon/tokentransfer_sql_test.go new file mode 100644 index 0000000000..50693a5cff --- /dev/null +++ b/internal/database/sqlcommon/tokentransfer_sql_test.go @@ -0,0 +1,199 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 sqlcommon + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/hyperledger/firefly/pkg/database" + "github.com/hyperledger/firefly/pkg/fftypes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestTokenTransferE2EWithDB(t *testing.T) { + s, cleanup := newSQLiteTestProvider(t) + defer cleanup() + ctx := context.Background() + + // Create a new token transfer entry + transfer := &fftypes.TokenTransfer{ + LocalID: fftypes.NewUUID(), + Type: fftypes.TokenTransferTypeTransfer, + PoolProtocolID: "F1", + TokenIndex: "1", + From: "0x01", + To: "0x02", + ProtocolID: "12345", + MessageHash: fftypes.NewRandB32(), + } + transfer.Amount.Int().SetInt64(10) + + s.callbacks.On("UUIDCollectionEvent", database.CollectionTokenTransfers, fftypes.ChangeEventTypeCreated, transfer.LocalID, mock.Anything). + Return().Once() + s.callbacks.On("UUIDCollectionEvent", database.CollectionTokenTransfers, fftypes.ChangeEventTypeUpdated, transfer.LocalID, mock.Anything). + Return().Once() + + err := s.UpsertTokenTransfer(ctx, transfer) + assert.NoError(t, err) + + assert.NotNil(t, transfer.Created) + transferJson, _ := json.Marshal(&transfer) + + // Query back the token transfer (by ID) + transferRead, err := s.GetTokenTransfer(ctx, transfer.LocalID) + assert.NoError(t, err) + assert.NotNil(t, transferRead) + transferReadJson, _ := json.Marshal(&transferRead) + assert.Equal(t, string(transferJson), string(transferReadJson)) + + // Query back the token transfer (by query filter) + fb := database.TokenTransferQueryFactory.NewFilter(ctx) + filter := fb.And( + fb.Eq("poolprotocolid", transfer.PoolProtocolID), + fb.Eq("tokenindex", transfer.TokenIndex), + fb.Eq("from", transfer.From), + fb.Eq("to", transfer.To), + fb.Eq("protocolid", transfer.ProtocolID), + fb.Eq("created", transfer.Created), + ) + transfers, res, err := s.GetTokenTransfers(ctx, filter.Count(true)) + assert.NoError(t, err) + assert.Equal(t, 1, len(transfers)) + assert.Equal(t, int64(1), *res.TotalCount) + transferReadJson, _ = json.Marshal(transfers[0]) + assert.Equal(t, string(transferJson), string(transferReadJson)) + + // Update the token transfer + transfer.Type = fftypes.TokenTransferTypeMint + transfer.Amount.Int().SetInt64(1) + transfer.To = "0x03" + err = s.UpsertTokenTransfer(ctx, transfer) + assert.NoError(t, err) + + // Query back the token transfer (by ID) + transferRead, err = s.GetTokenTransfer(ctx, transfer.LocalID) + assert.NoError(t, err) + assert.NotNil(t, transferRead) + transferJson, _ = json.Marshal(&transfer) + transferReadJson, _ = json.Marshal(&transferRead) + assert.Equal(t, string(transferJson), string(transferReadJson)) +} + +func TestUpsertTokenTransferFailBegin(t *testing.T) { + s, mock := newMockProvider().init() + mock.ExpectBegin().WillReturnError(fmt.Errorf("pop")) + err := s.UpsertTokenTransfer(context.Background(), &fftypes.TokenTransfer{}) + assert.Regexp(t, "FF10114", err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUpsertTokenTransferFailSelect(t *testing.T) { + s, mock := newMockProvider().init() + mock.ExpectBegin() + mock.ExpectQuery("SELECT .*").WillReturnError(fmt.Errorf("pop")) + err := s.UpsertTokenTransfer(context.Background(), &fftypes.TokenTransfer{}) + assert.Regexp(t, "FF10115", err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUpsertTokenTransferFailInsert(t *testing.T) { + s, mock := newMockProvider().init() + mock.ExpectBegin() + mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{})) + mock.ExpectExec("INSERT .*").WillReturnError(fmt.Errorf("pop")) + mock.ExpectRollback() + err := s.UpsertTokenTransfer(context.Background(), &fftypes.TokenTransfer{}) + assert.Regexp(t, "FF10116", err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUpsertTokenTransferFailUpdate(t *testing.T) { + s, mock := newMockProvider().init() + mock.ExpectBegin() + mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{"protocolid"}).AddRow("1")) + mock.ExpectExec("UPDATE .*").WillReturnError(fmt.Errorf("pop")) + mock.ExpectRollback() + err := s.UpsertTokenTransfer(context.Background(), &fftypes.TokenTransfer{}) + assert.Regexp(t, "FF10117", err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUpsertTokenTransferFailCommit(t *testing.T) { + s, mock := newMockProvider().init() + mock.ExpectBegin() + mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{"protocolid"})) + mock.ExpectExec("INSERT .*").WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit().WillReturnError(fmt.Errorf("pop")) + err := s.UpsertTokenTransfer(context.Background(), &fftypes.TokenTransfer{}) + assert.Regexp(t, "FF10119", err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestGetTokenTransferByIDSelectFail(t *testing.T) { + s, mock := newMockProvider().init() + mock.ExpectQuery("SELECT .*").WillReturnError(fmt.Errorf("pop")) + _, err := s.GetTokenTransfer(context.Background(), fftypes.NewUUID()) + assert.Regexp(t, "FF10115", err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestGetTokenTransferByIDNotFound(t *testing.T) { + s, mock := newMockProvider().init() + mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{"protocolid"})) + msg, err := s.GetTokenTransfer(context.Background(), fftypes.NewUUID()) + assert.NoError(t, err) + assert.Nil(t, msg) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestGetTokenTransferByIDScanFail(t *testing.T) { + s, mock := newMockProvider().init() + mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{"protocolid"}).AddRow("only one")) + _, err := s.GetTokenTransfer(context.Background(), fftypes.NewUUID()) + assert.Regexp(t, "FF10121", err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestGetTokenTransfersQueryFail(t *testing.T) { + s, mock := newMockProvider().init() + mock.ExpectQuery("SELECT .*").WillReturnError(fmt.Errorf("pop")) + f := database.TokenTransferQueryFactory.NewFilter(context.Background()).Eq("protocolid", "") + _, _, err := s.GetTokenTransfers(context.Background(), f) + assert.Regexp(t, "FF10115", err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestGetTokenTransfersBuildQueryFail(t *testing.T) { + s, _ := newMockProvider().init() + f := database.TokenTransferQueryFactory.NewFilter(context.Background()).Eq("protocolid", map[bool]bool{true: false}) + _, _, err := s.GetTokenTransfers(context.Background(), f) + assert.Regexp(t, "FF10149.*id", err) +} + +func TestGetTokenTransfersScanFail(t *testing.T) { + s, mock := newMockProvider().init() + mock.ExpectQuery("SELECT .*").WillReturnRows(sqlmock.NewRows([]string{"protocolid"}).AddRow("only one")) + f := database.TokenTransferQueryFactory.NewFilter(context.Background()).Eq("protocolid", "") + _, _, err := s.GetTokenTransfers(context.Background(), f) + assert.Regexp(t, "FF10121", err) + assert.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/internal/i18n/en_translations.go b/internal/i18n/en_translations.go index 8ca18a4c16..1748fc491a 100644 --- a/internal/i18n/en_translations.go +++ b/internal/i18n/en_translations.go @@ -123,7 +123,7 @@ var ( MsgNilID = ffm("FF10203", "ID is nil") MsgDataReferenceUnresolvable = ffm("FF10204", "Data reference %d cannot be resolved", 400) MsgDataMissing = ffm("FF10205", "Data entry %d has neither 'id' to refer to existing data, or 'value' to include in-line JSON data", 400) - MsgAuthorInvalid = ffm("FF10206", "Invalid header.author in message", 400) + MsgAuthorInvalid = ffm("FF10206", "Invalid author specified", 400) MsgNoTransaction = ffm("FF10207", "Message does not have a transaction", 404) MsgBatchNotSet = ffm("FF10208", "Message does not have an assigned batch", 404) MsgBatchNotFound = ffm("FF10209", "Batch '%s' not found for message", 500) @@ -197,5 +197,8 @@ var ( MsgAuthorNotFoundByDID = ffm("FF10277", "Author could not be resolved via DID '%s'") MsgAuthorOrgNotFoundByName = ffm("FF10278", "Author organization could not be resolved via name '%s'") MsgAuthorOrgSigningKeyMismatch = ffm("FF10279", "Author organization '%s' is not associated with signing key '%s'") + MsgCannotTransferToSelf = ffm("FF10280", "From and to addresses must be different", 400) MsgLocalOrgLookupFailed = ffm("FF10290", "Unable resolve the local org by the configured signing key on the node. Please confirm the org is registered with key '%s'", 500) + MsgBigIntTooLarge = ffm("FF10291", "Byte length of serialized integer is too large %d (max=%d)") + MsgBigIntParseFailed = ffm("FF10292", "Failed to parse JSON value '%s' into BigInt") ) diff --git a/internal/orchestrator/bound_callbacks.go b/internal/orchestrator/bound_callbacks.go index 0f81fa1063..57f459f154 100644 --- a/internal/orchestrator/bound_callbacks.go +++ b/internal/orchestrator/bound_callbacks.go @@ -59,3 +59,7 @@ func (bc *boundCallbacks) MessageReceived(peerID string, data []byte) error { func (bc *boundCallbacks) TokenPoolCreated(plugin tokens.Plugin, tokenType fftypes.TokenType, tx *fftypes.UUID, protocolID, signingIdentity, protocolTxID string, additionalInfo fftypes.JSONObject) error { return bc.am.TokenPoolCreated(plugin, tokenType, tx, protocolID, signingIdentity, protocolTxID, additionalInfo) } + +func (bc *boundCallbacks) TokensTransferred(plugin tokens.Plugin, transfer *fftypes.TokenTransfer, signingIdentity string, protocolTxID string, additionalInfo fftypes.JSONObject) error { + return bc.am.TokensTransferred(plugin, transfer, signingIdentity, protocolTxID, additionalInfo) +} diff --git a/internal/orchestrator/bound_callbacks_test.go b/internal/orchestrator/bound_callbacks_test.go index 388a1516eb..4af6c914b9 100644 --- a/internal/orchestrator/bound_callbacks_test.go +++ b/internal/orchestrator/bound_callbacks_test.go @@ -40,6 +40,7 @@ func TestBoundCallbacks(t *testing.T) { info := fftypes.JSONObject{"hello": "world"} batch := &blockchain.BatchPin{TransactionID: fftypes.NewUUID()} + transfer := &fftypes.TokenTransfer{} hash := fftypes.NewRandB32() opID := fftypes.NewUUID() txID := fftypes.NewUUID() @@ -71,4 +72,8 @@ func TestBoundCallbacks(t *testing.T) { mam.On("TokenPoolCreated", mti, fftypes.TokenTypeFungible, txID, "123", "0x12345", "tx12345", info).Return(fmt.Errorf("pop")) err = bc.TokenPoolCreated(mti, fftypes.TokenTypeFungible, txID, "123", "0x12345", "tx12345", info) assert.EqualError(t, err, "pop") + + mam.On("TokensTransferred", mti, transfer, "0x12345", "tx12345", info).Return(fmt.Errorf("pop")) + err = bc.TokensTransferred(mti, transfer, "0x12345", "tx12345", info) + assert.EqualError(t, err, "pop") } diff --git a/internal/syncasync/sync_async_bridge.go b/internal/syncasync/sync_async_bridge.go index fae5361399..9ca4a291e6 100644 --- a/internal/syncasync/sync_async_bridge.go +++ b/internal/syncasync/sync_async_bridge.go @@ -41,6 +41,8 @@ type Bridge interface { SendConfirm(ctx context.Context, ns string, send RequestSender) (*fftypes.Message, error) // SendConfirmTokenPool blocks until the token pool is confirmed (or rejected) SendConfirmTokenPool(ctx context.Context, ns string, send RequestSender) (*fftypes.TokenPool, error) + // SendConfirmTokenTransfer blocks until the token transfer is confirmed + SendConfirmTokenTransfer(ctx context.Context, ns string, send RequestSender) (*fftypes.TokenTransfer, error) } type RequestSender func(requestID *fftypes.UUID) error @@ -51,6 +53,7 @@ const ( messageConfirm requestType = iota messageReply tokenPoolConfirm + tokenTransferConfirm ) type inflightRequest struct { @@ -143,6 +146,42 @@ func (inflight *inflightRequest) msInflight() float64 { return float64(dur) / float64(time.Millisecond) } +func (sa *syncAsyncBridge) getMessageFromEvent(event *fftypes.EventDelivery) (*fftypes.Message, error) { + msg, err := sa.database.GetMessageByID(sa.ctx, event.Reference) + if err != nil { + return nil, err + } + if msg == nil { + // This should not happen (but we need to move on) + log.L(sa.ctx).Errorf("Unable to resolve message '%s' for %s event '%s'", event.Reference, event.Type, event.ID) + } + return msg, nil +} + +func (sa *syncAsyncBridge) getPoolFromEvent(event *fftypes.EventDelivery) (*fftypes.TokenPool, error) { + pool, err := sa.database.GetTokenPoolByID(sa.ctx, event.Reference) + if err != nil { + return nil, err + } + if pool == nil { + // This should not happen (but we need to move on) + log.L(sa.ctx).Errorf("Unable to resolve token pool '%s' for %s event '%s'", event.Reference, event.Type, event.ID) + } + return pool, nil +} + +func (sa *syncAsyncBridge) getTransferFromEvent(event *fftypes.EventDelivery) (*fftypes.TokenTransfer, error) { + transfer, err := sa.database.GetTokenTransfer(sa.ctx, event.Reference) + if err != nil { + return nil, err + } + if transfer == nil { + // This should not happen (but we need to move on) + log.L(sa.ctx).Errorf("Unable to resolve token transfer '%s' for %s event '%s'", event.Reference, event.Type, event.ID) + } + return transfer, nil +} + func (sa *syncAsyncBridge) eventCallback(event *fftypes.EventDelivery) error { sa.inflightMux.Lock() defer sa.inflightMux.Unlock() @@ -153,33 +192,9 @@ func (sa *syncAsyncBridge) eventCallback(event *fftypes.EventDelivery) error { return nil } - getMessage := func() (*fftypes.Message, error) { - msg, err := sa.database.GetMessageByID(sa.ctx, event.Reference) - if err != nil { - return nil, err - } - if msg == nil { - // This should not happen (but we need to move on) - log.L(sa.ctx).Errorf("Unable to resolve message '%s' for %s event '%s'", event.Reference, event.Type, event.ID) - } - return msg, nil - } - - getPool := func() (*fftypes.TokenPool, error) { - pool, err := sa.database.GetTokenPoolByID(sa.ctx, event.Reference) - if err != nil { - return nil, err - } - if pool == nil { - // This should not happen (but we need to move on) - log.L(sa.ctx).Errorf("Unable to resolve token pool '%s' for %s event '%s'", event.Reference, event.Type, event.ID) - } - return pool, nil - } - switch event.Type { case fftypes.EventTypeMessageConfirmed: - msg, err := getMessage() + msg, err := sa.getMessageFromEvent(event) if err != nil || msg == nil { return err } @@ -196,7 +211,7 @@ func (sa *syncAsyncBridge) eventCallback(event *fftypes.EventDelivery) error { } case fftypes.EventTypeMessageRejected: - msg, err := getMessage() + msg, err := sa.getMessageFromEvent(event) if err != nil || msg == nil { return err } @@ -207,7 +222,7 @@ func (sa *syncAsyncBridge) eventCallback(event *fftypes.EventDelivery) error { } case fftypes.EventTypePoolConfirmed: - pool, err := getPool() + pool, err := sa.getPoolFromEvent(event) if err != nil || pool == nil { return err } @@ -218,7 +233,7 @@ func (sa *syncAsyncBridge) eventCallback(event *fftypes.EventDelivery) error { } case fftypes.EventTypePoolRejected: - pool, err := getPool() + pool, err := sa.getPoolFromEvent(event) if err != nil || pool == nil { return err } @@ -227,6 +242,17 @@ func (sa *syncAsyncBridge) eventCallback(event *fftypes.EventDelivery) error { if inflight != nil { go sa.resolveRejectedTokenPool(inflight, pool) } + + case fftypes.EventTypeTransferConfirmed: + transfer, err := sa.getTransferFromEvent(event) + if err != nil || transfer == nil { + return err + } + // See if this is a confirmation of an inflight token transfer + inflight := sa.getInFlight(event.Namespace, tokenTransferConfirm, transfer.LocalID) + if inflight != nil { + go sa.resolveConfirmedTokenTransfer(inflight, transfer) + } } return nil @@ -267,6 +293,11 @@ func (sa *syncAsyncBridge) resolveRejectedTokenPool(inflight *inflightRequest, p inflight.response <- inflightResponse{err: err} } +func (sa *syncAsyncBridge) resolveConfirmedTokenTransfer(inflight *inflightRequest, transfer *fftypes.TokenTransfer) { + log.L(sa.ctx).Debugf("Resolving token transfer confirmation request '%s' with ID '%s'", inflight.id, transfer.LocalID) + inflight.response <- inflightResponse{id: transfer.LocalID, data: transfer} +} + func (sa *syncAsyncBridge) sendAndWait(ctx context.Context, ns string, reqType requestType, send RequestSender) (interface{}, error) { inflight, err := sa.addInFlight(ns, reqType) if err != nil { @@ -320,3 +351,11 @@ func (sa *syncAsyncBridge) SendConfirmTokenPool(ctx context.Context, ns string, } return reply.(*fftypes.TokenPool), err } + +func (sa *syncAsyncBridge) SendConfirmTokenTransfer(ctx context.Context, ns string, send RequestSender) (*fftypes.TokenTransfer, error) { + reply, err := sa.sendAndWait(ctx, ns, tokenTransferConfirm, send) + if err != nil { + return nil, err + } + return reply.(*fftypes.TokenTransfer), err +} diff --git a/internal/syncasync/sync_async_bridge_test.go b/internal/syncasync/sync_async_bridge_test.go index 9303f0634d..cf074270dd 100644 --- a/internal/syncasync/sync_async_bridge_test.go +++ b/internal/syncasync/sync_async_bridge_test.go @@ -311,6 +311,33 @@ func TestEventCallbackTokenPoolLookupFail(t *testing.T) { } +func TestEventCallbackTokenTransferLookupFail(t *testing.T) { + + sa, cancel := newTestSyncAsyncBridge(t) + defer cancel() + + responseID := fftypes.NewUUID() + sa.inflight = map[string]map[fftypes.UUID]*inflightRequest{ + "ns1": { + *responseID: &inflightRequest{}, + }, + } + + mdi := sa.database.(*databasemocks.Plugin) + mdi.On("GetTokenTransfer", sa.ctx, mock.Anything).Return(nil, fmt.Errorf("pop")) + + err := sa.eventCallback(&fftypes.EventDelivery{ + Event: fftypes.Event{ + Namespace: "ns1", + ID: fftypes.NewUUID(), + Reference: fftypes.NewUUID(), + Type: fftypes.EventTypeTransferConfirmed, + }, + }) + assert.EqualError(t, err, "pop") + +} + func TestEventCallbackMsgNotFound(t *testing.T) { sa, cancel := newTestSyncAsyncBridge(t) @@ -395,6 +422,34 @@ func TestEventCallbackTokenPoolNotFound(t *testing.T) { mdi.AssertExpectations(t) } +func TestEventCallbackTokenTransferNotFound(t *testing.T) { + + sa, cancel := newTestSyncAsyncBridge(t) + defer cancel() + + responseID := fftypes.NewUUID() + sa.inflight = map[string]map[fftypes.UUID]*inflightRequest{ + "ns1": { + *responseID: &inflightRequest{}, + }, + } + + mdi := sa.database.(*databasemocks.Plugin) + mdi.On("GetTokenTransfer", sa.ctx, mock.Anything).Return(nil, nil) + + err := sa.eventCallback(&fftypes.EventDelivery{ + Event: fftypes.Event{ + Namespace: "ns1", + ID: fftypes.NewUUID(), + Reference: fftypes.NewUUID(), + Type: fftypes.EventTypeTransferConfirmed, + }, + }) + assert.NoError(t, err) + + mdi.AssertExpectations(t) +} + func TestEventCallbackTokenPoolRejectedNotFound(t *testing.T) { sa, cancel := newTestSyncAsyncBridge(t) @@ -536,3 +591,59 @@ func TestAwaitTokenPoolConfirmationRejected(t *testing.T) { }) assert.Regexp(t, "FF10276", err) } + +func TestAwaitTokenTransferConfirmation(t *testing.T) { + + sa, cancel := newTestSyncAsyncBridge(t) + defer cancel() + + var requestID *fftypes.UUID + + mse := sa.sysevents.(*sysmessagingmocks.SystemEvents) + mse.On("AddSystemEventListener", "ns1", mock.Anything).Return(nil) + + mdi := sa.database.(*databasemocks.Plugin) + gmid := mdi.On("GetTokenTransfer", sa.ctx, mock.Anything) + gmid.RunFn = func(a mock.Arguments) { + assert.NotNil(t, requestID) + pool := &fftypes.TokenTransfer{ + LocalID: requestID, + ProtocolID: "abc", + } + gmid.ReturnArguments = mock.Arguments{ + pool, nil, + } + } + + reply, err := sa.SendConfirmTokenTransfer(sa.ctx, "ns1", func(id *fftypes.UUID) error { + requestID = id + go func() { + sa.eventCallback(&fftypes.EventDelivery{ + Event: fftypes.Event{ + ID: fftypes.NewUUID(), + Type: fftypes.EventTypeTransferConfirmed, + Reference: requestID, + Namespace: "ns1", + }, + }) + }() + return nil + }) + assert.NoError(t, err) + assert.Equal(t, *requestID, *reply.LocalID) + assert.Equal(t, "abc", reply.ProtocolID) +} + +func TestAwaitTokenTransferConfirmationSendFail(t *testing.T) { + + sa, cancel := newTestSyncAsyncBridge(t) + defer cancel() + + mse := sa.sysevents.(*sysmessagingmocks.SystemEvents) + mse.On("AddSystemEventListener", "ns1", mock.Anything).Return(nil) + + _, err := sa.SendConfirmTokenTransfer(sa.ctx, "ns1", func(id *fftypes.UUID) error { + return fmt.Errorf("pop") + }) + assert.EqualError(t, err, "pop") +} diff --git a/internal/syshandlers/syshandler_tokenpool.go b/internal/syshandlers/syshandler_tokenpool.go index d68b6972d2..8942e8562c 100644 --- a/internal/syshandlers/syshandler_tokenpool.go +++ b/internal/syshandlers/syshandler_tokenpool.go @@ -29,7 +29,7 @@ func (sh *systemHandlers) persistTokenPool(ctx context.Context, pool *fftypes.To fb := database.OperationQueryFactory.NewFilter(ctx) filter := fb.And( fb.Eq("tx", pool.TX.ID), - fb.Eq("type", fftypes.OpTypeTokensAnnouncePool), + fb.Eq("type", fftypes.OpTypeTokenAnnouncePool), ) operations, _, err := sh.database.GetOperations(ctx, filter) if err != nil { diff --git a/internal/tokens/fftokens/fftokens.go b/internal/tokens/fftokens/fftokens.go index 9db572f336..0e7e1db271 100644 --- a/internal/tokens/fftokens/fftokens.go +++ b/internal/tokens/fftokens/fftokens.go @@ -49,8 +49,11 @@ type wsEvent struct { type msgType string const ( - messageReceipt msgType = "receipt" - messageTokenPool msgType = "token-pool" + messageReceipt msgType = "receipt" + messageTokenPool msgType = "token-pool" + messageTokenMint msgType = "token-mint" + messageTokenBurn msgType = "token-burn" + messageTokenTransfer msgType = "token-transfer" ) type createPool struct { @@ -60,6 +63,33 @@ type createPool struct { Config fftypes.JSONObject `json:"config"` } +type mintTokens struct { + PoolID string `json:"poolId"` + To string `json:"to"` + Amount string `json:"amount"` + RequestID string `json:"requestId,omitempty"` + TrackingID string `json:"trackingId"` +} + +type burnTokens struct { + PoolID string `json:"poolId"` + TokenIndex string `json:"tokenIndex,omitempty"` + From string `json:"from"` + Amount string `json:"amount"` + RequestID string `json:"requestId,omitempty"` + TrackingID string `json:"trackingId"` +} + +type transferTokens struct { + PoolID string `json:"poolId"` + TokenIndex string `json:"tokenIndex,omitempty"` + From string `json:"from"` + To string `json:"to"` + Amount string `json:"amount"` + RequestID string `json:"requestId,omitempty"` + TrackingID string `json:"trackingId"` +} + func (h *FFTokens) Name() string { return "fftokens" } @@ -150,6 +180,66 @@ func (h *FFTokens) handleTokenPoolCreate(ctx context.Context, data fftypes.JSONO return h.callbacks.TokenPoolCreated(h, fftypes.FFEnum(tokenType), txID, protocolID, operatorAddress, txHash, tx) } +func (h *FFTokens) handleTokenTransfer(ctx context.Context, t fftypes.TokenTransferType, data fftypes.JSONObject) (err error) { + tokenIndex := data.GetString("tokenIndex") + poolProtocolID := data.GetString("poolId") + operatorAddress := data.GetString("operator") + fromAddress := data.GetString("from") + toAddress := data.GetString("to") + value := data.GetString("amount") + tx := data.GetObject("transaction") + txHash := tx.GetString("transactionHash") + + var eventName string + switch t { + case fftypes.TokenTransferTypeMint: + eventName = "Mint" + case fftypes.TokenTransferTypeBurn: + eventName = "Burn" + default: + eventName = "Transfer" + } + + if poolProtocolID == "" || + operatorAddress == "" || + value == "" || + txHash == "" || + (t != fftypes.TokenTransferTypeMint && fromAddress == "") || + (t != fftypes.TokenTransferTypeBurn && toAddress == "") { + log.L(ctx).Errorf("%s event is not valid - missing data: %+v", eventName, data) + return nil // move on + } + + // We want to process all transfers, even those not initiated by FireFly. + // The trackingID is an optional argument from the connector, so it's important not to + // fail if it's missing or malformed. + trackingID := data.GetString("trackingId") + localID, err := fftypes.ParseUUID(ctx, trackingID) + if err != nil { + log.L(ctx).Infof("%s event contains invalid ID - continuing anyway (%s): %+v", eventName, err, data) + localID = fftypes.NewUUID() + } + + transfer := &fftypes.TokenTransfer{ + LocalID: localID, + Type: t, + PoolProtocolID: poolProtocolID, + TokenIndex: tokenIndex, + From: fromAddress, + To: toAddress, + ProtocolID: txHash, + } + + _, ok := transfer.Amount.Int().SetString(value, 10) + if !ok { + log.L(ctx).Errorf("%s event is not valid - invalid amount: %+v", eventName, data) + return nil // move on + } + + // If there's an error dispatching the event, we must return the error and shutdown + return h.callbacks.TokensTransferred(h, transfer, operatorAddress, txHash, tx) +} + func (h *FFTokens) eventLoop() { defer h.wsconn.Close() l := log.L(h.ctx).WithField("role", "event-loop") @@ -177,6 +267,12 @@ func (h *FFTokens) eventLoop() { err = h.handleReceipt(ctx, msg.Data) case messageTokenPool: err = h.handleTokenPoolCreate(ctx, msg.Data) + case messageTokenMint: + err = h.handleTokenTransfer(ctx, fftypes.TokenTransferTypeMint, msg.Data) + case messageTokenBurn: + err = h.handleTokenTransfer(ctx, fftypes.TokenTransferTypeBurn, msg.Data) + case messageTokenTransfer: + err = h.handleTokenTransfer(ctx, fftypes.TokenTransferTypeTransfer, msg.Data) default: l.Errorf("Message unexpected: %s", msg.Event) } @@ -214,3 +310,54 @@ func (h *FFTokens) CreateTokenPool(ctx context.Context, operationID *fftypes.UUI } return nil } + +func (h *FFTokens) MintTokens(ctx context.Context, operationID *fftypes.UUID, mint *fftypes.TokenTransfer) error { + res, err := h.client.R().SetContext(ctx). + SetBody(&mintTokens{ + PoolID: mint.PoolProtocolID, + To: mint.To, + Amount: mint.Amount.Int().String(), + RequestID: operationID.String(), + TrackingID: mint.LocalID.String(), + }). + Post("/api/v1/mint") + if err != nil || !res.IsSuccess() { + return restclient.WrapRestErr(ctx, res, err, i18n.MsgTokensRESTErr) + } + return nil +} + +func (h *FFTokens) BurnTokens(ctx context.Context, operationID *fftypes.UUID, burn *fftypes.TokenTransfer) error { + res, err := h.client.R().SetContext(ctx). + SetBody(&burnTokens{ + PoolID: burn.PoolProtocolID, + TokenIndex: burn.TokenIndex, + From: burn.From, + Amount: burn.Amount.Int().String(), + RequestID: operationID.String(), + TrackingID: burn.LocalID.String(), + }). + Post("/api/v1/burn") + if err != nil || !res.IsSuccess() { + return restclient.WrapRestErr(ctx, res, err, i18n.MsgTokensRESTErr) + } + return nil +} + +func (h *FFTokens) TransferTokens(ctx context.Context, operationID *fftypes.UUID, transfer *fftypes.TokenTransfer) error { + res, err := h.client.R().SetContext(ctx). + SetBody(&transferTokens{ + PoolID: transfer.PoolProtocolID, + TokenIndex: transfer.TokenIndex, + From: transfer.From, + To: transfer.To, + Amount: transfer.Amount.Int().String(), + RequestID: operationID.String(), + TrackingID: transfer.LocalID.String(), + }). + Post("/api/v1/transfer") + if err != nil || !res.IsSuccess() { + return restclient.WrapRestErr(ctx, res, err, i18n.MsgTokensRESTErr) + } + return nil +} diff --git a/internal/tokens/fftokens/fftokens_test.go b/internal/tokens/fftokens/fftokens_test.go index d70e381ad8..2b241cfb7a 100644 --- a/internal/tokens/fftokens/fftokens_test.go +++ b/internal/tokens/fftokens/fftokens_test.go @@ -161,6 +161,168 @@ func TestCreateTokenPoolError(t *testing.T) { assert.Regexp(t, "FF10274", err) } +func TestMintTokens(t *testing.T) { + h, _, _, httpURL, done := newTestFFTokens(t) + defer done() + + mint := &fftypes.TokenTransfer{ + PoolProtocolID: "123", + LocalID: fftypes.NewUUID(), + To: "user1", + } + mint.Amount.Int().SetInt64(10) + opID := fftypes.NewUUID() + + httpmock.RegisterResponder("POST", fmt.Sprintf("%s/api/v1/mint", httpURL), + func(req *http.Request) (*http.Response, error) { + body := make(fftypes.JSONObject) + err := json.NewDecoder(req.Body).Decode(&body) + assert.NoError(t, err) + assert.Equal(t, fftypes.JSONObject{ + "poolId": "123", + "to": "user1", + "amount": "10", + "requestId": opID.String(), + "trackingId": mint.LocalID.String(), + }, body) + + res := &http.Response{ + Body: ioutil.NopCloser(bytes.NewReader([]byte(`{"id":"1"}`))), + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + StatusCode: 202, + } + return res, nil + }) + + err := h.MintTokens(context.Background(), opID, mint) + assert.NoError(t, err) +} + +func TestMintTokensError(t *testing.T) { + h, _, _, httpURL, done := newTestFFTokens(t) + defer done() + + mint := &fftypes.TokenTransfer{} + + httpmock.RegisterResponder("POST", fmt.Sprintf("%s/api/v1/mint", httpURL), + httpmock.NewJsonResponderOrPanic(500, fftypes.JSONObject{})) + + err := h.MintTokens(context.Background(), fftypes.NewUUID(), mint) + assert.Regexp(t, "FF10274", err) +} + +func TestBurnTokens(t *testing.T) { + h, _, _, httpURL, done := newTestFFTokens(t) + defer done() + + burn := &fftypes.TokenTransfer{ + PoolProtocolID: "123", + LocalID: fftypes.NewUUID(), + TokenIndex: "1", + From: "user1", + } + burn.Amount.Int().SetInt64(10) + opID := fftypes.NewUUID() + + httpmock.RegisterResponder("POST", fmt.Sprintf("%s/api/v1/burn", httpURL), + func(req *http.Request) (*http.Response, error) { + body := make(fftypes.JSONObject) + err := json.NewDecoder(req.Body).Decode(&body) + assert.NoError(t, err) + assert.Equal(t, fftypes.JSONObject{ + "poolId": "123", + "tokenIndex": "1", + "from": "user1", + "amount": "10", + "requestId": opID.String(), + "trackingId": burn.LocalID.String(), + }, body) + + res := &http.Response{ + Body: ioutil.NopCloser(bytes.NewReader([]byte(`{"id":"1"}`))), + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + StatusCode: 202, + } + return res, nil + }) + + err := h.BurnTokens(context.Background(), opID, burn) + assert.NoError(t, err) +} + +func TestBurnTokensError(t *testing.T) { + h, _, _, httpURL, done := newTestFFTokens(t) + defer done() + + burn := &fftypes.TokenTransfer{} + + httpmock.RegisterResponder("POST", fmt.Sprintf("%s/api/v1/burn", httpURL), + httpmock.NewJsonResponderOrPanic(500, fftypes.JSONObject{})) + + err := h.BurnTokens(context.Background(), fftypes.NewUUID(), burn) + assert.Regexp(t, "FF10274", err) +} + +func TestTransferTokens(t *testing.T) { + h, _, _, httpURL, done := newTestFFTokens(t) + defer done() + + transfer := &fftypes.TokenTransfer{ + PoolProtocolID: "123", + LocalID: fftypes.NewUUID(), + TokenIndex: "1", + From: "user1", + To: "user2", + } + transfer.Amount.Int().SetInt64(10) + opID := fftypes.NewUUID() + + httpmock.RegisterResponder("POST", fmt.Sprintf("%s/api/v1/transfer", httpURL), + func(req *http.Request) (*http.Response, error) { + body := make(fftypes.JSONObject) + err := json.NewDecoder(req.Body).Decode(&body) + assert.NoError(t, err) + assert.Equal(t, fftypes.JSONObject{ + "poolId": "123", + "tokenIndex": "1", + "from": "user1", + "to": "user2", + "amount": "10", + "requestId": opID.String(), + "trackingId": transfer.LocalID.String(), + }, body) + + res := &http.Response{ + Body: ioutil.NopCloser(bytes.NewReader([]byte(`{"id":"1"}`))), + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + StatusCode: 202, + } + return res, nil + }) + + err := h.TransferTokens(context.Background(), opID, transfer) + assert.NoError(t, err) +} + +func TestTransferTokensError(t *testing.T) { + h, _, _, httpURL, done := newTestFFTokens(t) + defer done() + + transfer := &fftypes.TokenTransfer{} + + httpmock.RegisterResponder("POST", fmt.Sprintf("%s/api/v1/transfer", httpURL), + httpmock.NewJsonResponderOrPanic(500, fftypes.JSONObject{})) + + err := h.TransferTokens(context.Background(), fftypes.NewUUID(), transfer) + assert.Regexp(t, "FF10274", err) +} + func TestEvents(t *testing.T) { h, toServer, fromServer, _, done := newTestFFTokens(t) defer done() @@ -176,6 +338,7 @@ func TestEvents(t *testing.T) { mcb := h.callbacks.(*tokenmocks.Callbacks) opID := fftypes.NewUUID() + txID := fftypes.NewUUID() fromServer <- `{"id":"2","event":"receipt","data":{}}` fromServer <- `{"id":"3","event":"receipt","data":{"id":"abc"}}` @@ -198,14 +361,59 @@ func TestEvents(t *testing.T) { msg = <-toServer assert.Equal(t, `{"data":{"id":"7"},"event":"ack"}`, string(msg)) - txID := fftypes.NewUUID() - // token-pool: success mcb.On("TokenPoolCreated", h, fftypes.TokenTypeFungible, txID, "F1", "0x0", "abc", fftypes.JSONObject{"transactionHash": "abc"}).Return(nil) fromServer <- `{"id":"8","event":"token-pool","data":{"trackingId":"` + txID.String() + `","type":"fungible","poolId":"F1","operator":"0x0","transaction":{"transactionHash":"abc"}}}` msg = <-toServer assert.Equal(t, `{"data":{"id":"8"},"event":"ack"}`, string(msg)) + // token-mint: missing data + fromServer <- `{"id":"9","event":"token-mint"}` + msg = <-toServer + assert.Equal(t, `{"data":{"id":"9"},"event":"ack"}`, string(msg)) + + // token-mint: invalid amount + fromServer <- `{"id":"10","event":"token-mint","data":{"poolId":"F1","tokenIndex":"0","operator":"0x0","to":"0x0","amount":"bad","trackingId":"` + txID.String() + `","transaction":{"transactionHash":"abc"}}}` + msg = <-toServer + assert.Equal(t, `{"data":{"id":"10"},"event":"ack"}`, string(msg)) + + // token-mint: success + mcb.On("TokensTransferred", h, mock.MatchedBy(func(t *fftypes.TokenTransfer) bool { + return t.PoolProtocolID == "F1" && t.Amount.Int().Int64() == 2 && t.To == "0x0" && t.TokenIndex == "" + }), "0x0", "abc", fftypes.JSONObject{"transactionHash": "abc"}).Return(nil) + fromServer <- `{"id":"11","event":"token-mint","data":{"poolId":"F1","operator":"0x0","to":"0x0","amount":"2","trackingId":"` + txID.String() + `","transaction":{"transactionHash":"abc"}}}` + msg = <-toServer + assert.Equal(t, `{"data":{"id":"11"},"event":"ack"}`, string(msg)) + + // token-mint: invalid uuid (success) + mcb.On("TokensTransferred", h, mock.MatchedBy(func(t *fftypes.TokenTransfer) bool { + return t.PoolProtocolID == "N1" && t.Amount.Int().Int64() == 1 && t.To == "0x0" && t.TokenIndex == "1" + }), "0x0", "abc", fftypes.JSONObject{"transactionHash": "abc"}).Return(nil) + fromServer <- `{"id":"12","event":"token-mint","data":{"poolId":"N1","tokenIndex":"1","operator":"0x0","to":"0x0","amount":"1","trackingId":"bad","transaction":{"transactionHash":"abc"}}}` + msg = <-toServer + assert.Equal(t, `{"data":{"id":"12"},"event":"ack"}`, string(msg)) + + // token-transfer: missing from + fromServer <- `{"id":"13","event":"token-transfer","data":{"poolId":"F1","tokenIndex":"0","operator":"0x0","to":"0x0","amount":"2","trackingId":"` + txID.String() + `","transaction":{"transactionHash":"abc"}}}` + msg = <-toServer + assert.Equal(t, `{"data":{"id":"13"},"event":"ack"}`, string(msg)) + + // token-transfer: success + mcb.On("TokensTransferred", h, mock.MatchedBy(func(t *fftypes.TokenTransfer) bool { + return t.PoolProtocolID == "F1" && t.Amount.Int().Int64() == 2 && t.From == "0x0" && t.To == "0x1" && t.TokenIndex == "" + }), "0x0", "abc", fftypes.JSONObject{"transactionHash": "abc"}).Return(nil) + fromServer <- `{"id":"14","event":"token-transfer","data":{"poolId":"F1","operator":"0x0","from":"0x0","to":"0x1","amount":"2","trackingId":"` + txID.String() + `","transaction":{"transactionHash":"abc"}}}` + msg = <-toServer + assert.Equal(t, `{"data":{"id":"14"},"event":"ack"}`, string(msg)) + + // token-burn: success + mcb.On("TokensTransferred", h, mock.MatchedBy(func(t *fftypes.TokenTransfer) bool { + return t.PoolProtocolID == "F1" && t.Amount.Int().Int64() == 2 && t.From == "0x0" && t.TokenIndex == "0" + }), "0x0", "abc", fftypes.JSONObject{"transactionHash": "abc"}).Return(nil) + fromServer <- `{"id":"15","event":"token-burn","data":{"poolId":"F1","tokenIndex":"0","operator":"0x0","from":"0x0","amount":"2","trackingId":"` + txID.String() + `","transaction":{"transactionHash":"abc"}}}` + msg = <-toServer + assert.Equal(t, `{"data":{"id":"15"},"event":"ack"}`, string(msg)) + mcb.AssertExpectations(t) } diff --git a/mocks/assetmocks/manager.go b/mocks/assetmocks/manager.go index 7213fbab5b..5472cf4060 100644 --- a/mocks/assetmocks/manager.go +++ b/mocks/assetmocks/manager.go @@ -18,22 +18,22 @@ type Manager struct { mock.Mock } -// CreateTokenPool provides a mock function with given fields: ctx, ns, typeName, pool, waitConfirm -func (_m *Manager) CreateTokenPool(ctx context.Context, ns string, typeName string, pool *fftypes.TokenPool, waitConfirm bool) (*fftypes.TokenPool, error) { - ret := _m.Called(ctx, ns, typeName, pool, waitConfirm) +// BurnTokens provides a mock function with given fields: ctx, ns, typeName, poolName, transfer, waitConfirm +func (_m *Manager) BurnTokens(ctx context.Context, ns string, typeName string, poolName string, transfer *fftypes.TokenTransfer, waitConfirm bool) (*fftypes.TokenTransfer, error) { + ret := _m.Called(ctx, ns, typeName, poolName, transfer, waitConfirm) - var r0 *fftypes.TokenPool - if rf, ok := ret.Get(0).(func(context.Context, string, string, *fftypes.TokenPool, bool) *fftypes.TokenPool); ok { - r0 = rf(ctx, ns, typeName, pool, waitConfirm) + var r0 *fftypes.TokenTransfer + if rf, ok := ret.Get(0).(func(context.Context, string, string, string, *fftypes.TokenTransfer, bool) *fftypes.TokenTransfer); ok { + r0 = rf(ctx, ns, typeName, poolName, transfer, waitConfirm) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*fftypes.TokenPool) + r0 = ret.Get(0).(*fftypes.TokenTransfer) } } var r1 error - if rf, ok := ret.Get(1).(func(context.Context, string, string, *fftypes.TokenPool, bool) error); ok { - r1 = rf(ctx, ns, typeName, pool, waitConfirm) + if rf, ok := ret.Get(1).(func(context.Context, string, string, string, *fftypes.TokenTransfer, bool) error); ok { + r1 = rf(ctx, ns, typeName, poolName, transfer, waitConfirm) } else { r1 = ret.Error(1) } @@ -41,13 +41,13 @@ func (_m *Manager) CreateTokenPool(ctx context.Context, ns string, typeName stri return r0, r1 } -// CreateTokenPoolWithID provides a mock function with given fields: ctx, ns, id, typeName, pool, waitConfirm -func (_m *Manager) CreateTokenPoolWithID(ctx context.Context, ns string, id *fftypes.UUID, typeName string, pool *fftypes.TokenPool, waitConfirm bool) (*fftypes.TokenPool, error) { - ret := _m.Called(ctx, ns, id, typeName, pool, waitConfirm) +// CreateTokenPool provides a mock function with given fields: ctx, ns, typeName, pool, waitConfirm +func (_m *Manager) CreateTokenPool(ctx context.Context, ns string, typeName string, pool *fftypes.TokenPool, waitConfirm bool) (*fftypes.TokenPool, error) { + ret := _m.Called(ctx, ns, typeName, pool, waitConfirm) var r0 *fftypes.TokenPool - if rf, ok := ret.Get(0).(func(context.Context, string, *fftypes.UUID, string, *fftypes.TokenPool, bool) *fftypes.TokenPool); ok { - r0 = rf(ctx, ns, id, typeName, pool, waitConfirm) + if rf, ok := ret.Get(0).(func(context.Context, string, string, *fftypes.TokenPool, bool) *fftypes.TokenPool); ok { + r0 = rf(ctx, ns, typeName, pool, waitConfirm) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*fftypes.TokenPool) @@ -55,8 +55,8 @@ func (_m *Manager) CreateTokenPoolWithID(ctx context.Context, ns string, id *fft } var r1 error - if rf, ok := ret.Get(1).(func(context.Context, string, *fftypes.UUID, string, *fftypes.TokenPool, bool) error); ok { - r1 = rf(ctx, ns, id, typeName, pool, waitConfirm) + if rf, ok := ret.Get(1).(func(context.Context, string, string, *fftypes.TokenPool, bool) error); ok { + r1 = rf(ctx, ns, typeName, pool, waitConfirm) } else { r1 = ret.Error(1) } @@ -64,13 +64,13 @@ func (_m *Manager) CreateTokenPoolWithID(ctx context.Context, ns string, id *fft return r0, r1 } -// GetTokenAccounts provides a mock function with given fields: ctx, ns, typeName, name, filter -func (_m *Manager) GetTokenAccounts(ctx context.Context, ns string, typeName string, name string, filter database.AndFilter) ([]*fftypes.TokenAccount, *database.FilterResult, error) { - ret := _m.Called(ctx, ns, typeName, name, filter) +// GetTokenAccounts provides a mock function with given fields: ctx, ns, typeName, poolName, filter +func (_m *Manager) GetTokenAccounts(ctx context.Context, ns string, typeName string, poolName string, filter database.AndFilter) ([]*fftypes.TokenAccount, *database.FilterResult, error) { + ret := _m.Called(ctx, ns, typeName, poolName, filter) var r0 []*fftypes.TokenAccount if rf, ok := ret.Get(0).(func(context.Context, string, string, string, database.AndFilter) []*fftypes.TokenAccount); ok { - r0 = rf(ctx, ns, typeName, name, filter) + r0 = rf(ctx, ns, typeName, poolName, filter) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*fftypes.TokenAccount) @@ -79,7 +79,7 @@ func (_m *Manager) GetTokenAccounts(ctx context.Context, ns string, typeName str var r1 *database.FilterResult if rf, ok := ret.Get(1).(func(context.Context, string, string, string, database.AndFilter) *database.FilterResult); ok { - r1 = rf(ctx, ns, typeName, name, filter) + r1 = rf(ctx, ns, typeName, poolName, filter) } else { if ret.Get(1) != nil { r1 = ret.Get(1).(*database.FilterResult) @@ -88,7 +88,7 @@ func (_m *Manager) GetTokenAccounts(ctx context.Context, ns string, typeName str var r2 error if rf, ok := ret.Get(2).(func(context.Context, string, string, string, database.AndFilter) error); ok { - r2 = rf(ctx, ns, typeName, name, filter) + r2 = rf(ctx, ns, typeName, poolName, filter) } else { r2 = ret.Error(2) } @@ -96,13 +96,13 @@ func (_m *Manager) GetTokenAccounts(ctx context.Context, ns string, typeName str return r0, r1, r2 } -// GetTokenPool provides a mock function with given fields: ctx, ns, typeName, name -func (_m *Manager) GetTokenPool(ctx context.Context, ns string, typeName string, name string) (*fftypes.TokenPool, error) { - ret := _m.Called(ctx, ns, typeName, name) +// GetTokenPool provides a mock function with given fields: ctx, ns, typeName, poolName +func (_m *Manager) GetTokenPool(ctx context.Context, ns string, typeName string, poolName string) (*fftypes.TokenPool, error) { + ret := _m.Called(ctx, ns, typeName, poolName) var r0 *fftypes.TokenPool if rf, ok := ret.Get(0).(func(context.Context, string, string, string) *fftypes.TokenPool); ok { - r0 = rf(ctx, ns, typeName, name) + r0 = rf(ctx, ns, typeName, poolName) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*fftypes.TokenPool) @@ -111,7 +111,7 @@ func (_m *Manager) GetTokenPool(ctx context.Context, ns string, typeName string, var r1 error if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { - r1 = rf(ctx, ns, typeName, name) + r1 = rf(ctx, ns, typeName, poolName) } else { r1 = ret.Error(1) } @@ -151,6 +151,61 @@ func (_m *Manager) GetTokenPools(ctx context.Context, ns string, typeName string return r0, r1, r2 } +// GetTokenTransfers provides a mock function with given fields: ctx, ns, typeName, poolName, filter +func (_m *Manager) GetTokenTransfers(ctx context.Context, ns string, typeName string, poolName string, filter database.AndFilter) ([]*fftypes.TokenTransfer, *database.FilterResult, error) { + ret := _m.Called(ctx, ns, typeName, poolName, filter) + + var r0 []*fftypes.TokenTransfer + if rf, ok := ret.Get(0).(func(context.Context, string, string, string, database.AndFilter) []*fftypes.TokenTransfer); ok { + r0 = rf(ctx, ns, typeName, poolName, filter) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*fftypes.TokenTransfer) + } + } + + var r1 *database.FilterResult + if rf, ok := ret.Get(1).(func(context.Context, string, string, string, database.AndFilter) *database.FilterResult); ok { + r1 = rf(ctx, ns, typeName, poolName, filter) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*database.FilterResult) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func(context.Context, string, string, string, database.AndFilter) error); ok { + r2 = rf(ctx, ns, typeName, poolName, filter) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// MintTokens provides a mock function with given fields: ctx, ns, typeName, poolName, transfer, waitConfirm +func (_m *Manager) MintTokens(ctx context.Context, ns string, typeName string, poolName string, transfer *fftypes.TokenTransfer, waitConfirm bool) (*fftypes.TokenTransfer, error) { + ret := _m.Called(ctx, ns, typeName, poolName, transfer, waitConfirm) + + var r0 *fftypes.TokenTransfer + if rf, ok := ret.Get(0).(func(context.Context, string, string, string, *fftypes.TokenTransfer, bool) *fftypes.TokenTransfer); ok { + r0 = rf(ctx, ns, typeName, poolName, transfer, waitConfirm) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*fftypes.TokenTransfer) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, string, string, *fftypes.TokenTransfer, bool) error); ok { + r1 = rf(ctx, ns, typeName, poolName, transfer, waitConfirm) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Start provides a mock function with given fields: func (_m *Manager) Start() error { ret := _m.Called() @@ -179,6 +234,43 @@ func (_m *Manager) TokenPoolCreated(tk tokens.Plugin, tokenType fftypes.FFEnum, return r0 } +// TokensTransferred provides a mock function with given fields: tk, transfer, signingIdentity, protocolTxID, additionalInfo +func (_m *Manager) TokensTransferred(tk tokens.Plugin, transfer *fftypes.TokenTransfer, signingIdentity string, protocolTxID string, additionalInfo fftypes.JSONObject) error { + ret := _m.Called(tk, transfer, signingIdentity, protocolTxID, additionalInfo) + + var r0 error + if rf, ok := ret.Get(0).(func(tokens.Plugin, *fftypes.TokenTransfer, string, string, fftypes.JSONObject) error); ok { + r0 = rf(tk, transfer, signingIdentity, protocolTxID, additionalInfo) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// TransferTokens provides a mock function with given fields: ctx, ns, typeName, poolName, transfer, waitConfirm +func (_m *Manager) TransferTokens(ctx context.Context, ns string, typeName string, poolName string, transfer *fftypes.TokenTransfer, waitConfirm bool) (*fftypes.TokenTransfer, error) { + ret := _m.Called(ctx, ns, typeName, poolName, transfer, waitConfirm) + + var r0 *fftypes.TokenTransfer + if rf, ok := ret.Get(0).(func(context.Context, string, string, string, *fftypes.TokenTransfer, bool) *fftypes.TokenTransfer); ok { + r0 = rf(ctx, ns, typeName, poolName, transfer, waitConfirm) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*fftypes.TokenTransfer) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, string, string, *fftypes.TokenTransfer, bool) error); ok { + r1 = rf(ctx, ns, typeName, poolName, transfer, waitConfirm) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // ValidateTokenPoolTx provides a mock function with given fields: ctx, pool, protocolTxID func (_m *Manager) ValidateTokenPoolTx(ctx context.Context, pool *fftypes.TokenPool, protocolTxID string) error { ret := _m.Called(ctx, pool, protocolTxID) diff --git a/mocks/databasemocks/plugin.go b/mocks/databasemocks/plugin.go index 0976cf72f5..42b6ac33fb 100644 --- a/mocks/databasemocks/plugin.go +++ b/mocks/databasemocks/plugin.go @@ -19,6 +19,20 @@ type Plugin struct { mock.Mock } +// AddTokenAccountBalance provides a mock function with given fields: ctx, account +func (_m *Plugin) AddTokenAccountBalance(ctx context.Context, account *fftypes.TokenBalanceChange) error { + ret := _m.Called(ctx, account) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *fftypes.TokenBalanceChange) error); ok { + r0 = rf(ctx, account) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Capabilities provides a mock function with given fields: func (_m *Plugin) Capabilities() *database.Capabilities { ret := _m.Called() @@ -1449,6 +1463,61 @@ func (_m *Plugin) GetTokenPools(ctx context.Context, filter database.Filter) ([] return r0, r1, r2 } +// GetTokenTransfer provides a mock function with given fields: ctx, localID +func (_m *Plugin) GetTokenTransfer(ctx context.Context, localID *fftypes.UUID) (*fftypes.TokenTransfer, error) { + ret := _m.Called(ctx, localID) + + var r0 *fftypes.TokenTransfer + if rf, ok := ret.Get(0).(func(context.Context, *fftypes.UUID) *fftypes.TokenTransfer); ok { + r0 = rf(ctx, localID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*fftypes.TokenTransfer) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *fftypes.UUID) error); ok { + r1 = rf(ctx, localID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetTokenTransfers provides a mock function with given fields: ctx, filter +func (_m *Plugin) GetTokenTransfers(ctx context.Context, filter database.Filter) ([]*fftypes.TokenTransfer, *database.FilterResult, error) { + ret := _m.Called(ctx, filter) + + var r0 []*fftypes.TokenTransfer + if rf, ok := ret.Get(0).(func(context.Context, database.Filter) []*fftypes.TokenTransfer); ok { + r0 = rf(ctx, filter) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*fftypes.TokenTransfer) + } + } + + var r1 *database.FilterResult + if rf, ok := ret.Get(1).(func(context.Context, database.Filter) *database.FilterResult); ok { + r1 = rf(ctx, filter) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*database.FilterResult) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func(context.Context, database.Filter) error); ok { + r2 = rf(ctx, filter) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + // GetTransactionByID provides a mock function with given fields: ctx, id func (_m *Plugin) GetTransactionByID(ctx context.Context, id *fftypes.UUID) (*fftypes.Transaction, error) { ret := _m.Called(ctx, id) @@ -2013,13 +2082,13 @@ func (_m *Plugin) UpsertSubscription(ctx context.Context, data *fftypes.Subscrip return r0 } -// UpsertTokenAccount provides a mock function with given fields: ctx, account -func (_m *Plugin) UpsertTokenAccount(ctx context.Context, account *fftypes.TokenAccount) error { - ret := _m.Called(ctx, account) +// UpsertTokenPool provides a mock function with given fields: ctx, pool +func (_m *Plugin) UpsertTokenPool(ctx context.Context, pool *fftypes.TokenPool) error { + ret := _m.Called(ctx, pool) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *fftypes.TokenAccount) error); ok { - r0 = rf(ctx, account) + if rf, ok := ret.Get(0).(func(context.Context, *fftypes.TokenPool) error); ok { + r0 = rf(ctx, pool) } else { r0 = ret.Error(0) } @@ -2027,13 +2096,13 @@ func (_m *Plugin) UpsertTokenAccount(ctx context.Context, account *fftypes.Token return r0 } -// UpsertTokenPool provides a mock function with given fields: ctx, pool -func (_m *Plugin) UpsertTokenPool(ctx context.Context, pool *fftypes.TokenPool) error { - ret := _m.Called(ctx, pool) +// UpsertTokenTransfer provides a mock function with given fields: ctx, transfer +func (_m *Plugin) UpsertTokenTransfer(ctx context.Context, transfer *fftypes.TokenTransfer) error { + ret := _m.Called(ctx, transfer) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *fftypes.TokenPool) error); ok { - r0 = rf(ctx, pool) + if rf, ok := ret.Get(0).(func(context.Context, *fftypes.TokenTransfer) error); ok { + r0 = rf(ctx, transfer) } else { r0 = ret.Error(0) } diff --git a/mocks/syncasyncmocks/bridge.go b/mocks/syncasyncmocks/bridge.go index 9070d581c2..c07c18675f 100644 --- a/mocks/syncasyncmocks/bridge.go +++ b/mocks/syncasyncmocks/bridge.go @@ -91,3 +91,26 @@ func (_m *Bridge) SendConfirmTokenPool(ctx context.Context, ns string, send sync return r0, r1 } + +// SendConfirmTokenTransfer provides a mock function with given fields: ctx, ns, send +func (_m *Bridge) SendConfirmTokenTransfer(ctx context.Context, ns string, send syncasync.RequestSender) (*fftypes.TokenTransfer, error) { + ret := _m.Called(ctx, ns, send) + + var r0 *fftypes.TokenTransfer + if rf, ok := ret.Get(0).(func(context.Context, string, syncasync.RequestSender) *fftypes.TokenTransfer); ok { + r0 = rf(ctx, ns, send) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*fftypes.TokenTransfer) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, syncasync.RequestSender) error); ok { + r1 = rf(ctx, ns, send) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/mocks/tokenmocks/callbacks.go b/mocks/tokenmocks/callbacks.go index 5c34674a8b..b7b3780fcc 100644 --- a/mocks/tokenmocks/callbacks.go +++ b/mocks/tokenmocks/callbacks.go @@ -41,3 +41,17 @@ func (_m *Callbacks) TokensOpUpdate(plugin tokens.Plugin, operationID *fftypes.U return r0 } + +// TokensTransferred provides a mock function with given fields: plugin, transfer, signingIdentity, protocolTxID, additionalInfo +func (_m *Callbacks) TokensTransferred(plugin tokens.Plugin, transfer *fftypes.TokenTransfer, signingIdentity string, protocolTxID string, additionalInfo fftypes.JSONObject) error { + ret := _m.Called(plugin, transfer, signingIdentity, protocolTxID, additionalInfo) + + var r0 error + if rf, ok := ret.Get(0).(func(tokens.Plugin, *fftypes.TokenTransfer, string, string, fftypes.JSONObject) error); ok { + r0 = rf(plugin, transfer, signingIdentity, protocolTxID, additionalInfo) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/mocks/tokenmocks/plugin.go b/mocks/tokenmocks/plugin.go index db3d61dd30..22e2c1c325 100644 --- a/mocks/tokenmocks/plugin.go +++ b/mocks/tokenmocks/plugin.go @@ -19,6 +19,20 @@ type Plugin struct { mock.Mock } +// BurnTokens provides a mock function with given fields: ctx, operationID, burn +func (_m *Plugin) BurnTokens(ctx context.Context, operationID *fftypes.UUID, burn *fftypes.TokenTransfer) error { + ret := _m.Called(ctx, operationID, burn) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *fftypes.UUID, *fftypes.TokenTransfer) error); ok { + r0 = rf(ctx, operationID, burn) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Capabilities provides a mock function with given fields: func (_m *Plugin) Capabilities() *tokens.Capabilities { ret := _m.Called() @@ -68,6 +82,20 @@ func (_m *Plugin) InitPrefix(prefix config.PrefixArray) { _m.Called(prefix) } +// MintTokens provides a mock function with given fields: ctx, operationID, mint +func (_m *Plugin) MintTokens(ctx context.Context, operationID *fftypes.UUID, mint *fftypes.TokenTransfer) error { + ret := _m.Called(ctx, operationID, mint) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *fftypes.UUID, *fftypes.TokenTransfer) error); ok { + r0 = rf(ctx, operationID, mint) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Name provides a mock function with given fields: func (_m *Plugin) Name() string { ret := _m.Called() @@ -95,3 +123,17 @@ func (_m *Plugin) Start() error { return r0 } + +// TransferTokens provides a mock function with given fields: ctx, operationID, mint +func (_m *Plugin) TransferTokens(ctx context.Context, operationID *fftypes.UUID, mint *fftypes.TokenTransfer) error { + ret := _m.Called(ctx, operationID, mint) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *fftypes.UUID, *fftypes.TokenTransfer) error); ok { + r0 = rf(ctx, operationID, mint) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/pkg/database/plugin.go b/pkg/database/plugin.go index f1181b92f6..5da7c3a02c 100644 --- a/pkg/database/plugin.go +++ b/pkg/database/plugin.go @@ -368,16 +368,27 @@ type iTokenPoolCollection interface { } type iTokenAccountCollection interface { - // UpsertTokenAccount - Upsert a token account - UpsertTokenAccount(ctx context.Context, account *fftypes.TokenAccount) error + // AddTokenAccountBalance - Add a (positive or negative) balance to the account's current balance + AddTokenAccountBalance(ctx context.Context, account *fftypes.TokenBalanceChange) error // GetTokenAccount - Get a token account by pool and account identity GetTokenAccount(ctx context.Context, protocolID, tokenIndex, identity string) (*fftypes.TokenAccount, error) - // GetTokenAccounts - Get all known token accounts in a pool + // GetTokenAccounts - Get token accounts GetTokenAccounts(ctx context.Context, filter Filter) ([]*fftypes.TokenAccount, *FilterResult, error) } +type iTokenTransferCollection interface { + // UpsertTokenTransfer - Upsert a token transfer + UpsertTokenTransfer(ctx context.Context, transfer *fftypes.TokenTransfer) error + + // GetTokenTransfer - Get a token transfer by ID + GetTokenTransfer(ctx context.Context, localID *fftypes.UUID) (*fftypes.TokenTransfer, error) + + // GetTokenTransfers - Get token transfers + GetTokenTransfers(ctx context.Context, filter Filter) ([]*fftypes.TokenTransfer, *FilterResult, error) +} + // PeristenceInterface are the operations that must be implemented by a database interfavce plugin. // The database mechanism of Firefly is designed to provide the balance between being able // to query the data a member of the network has transferred/received via Firefly efficiently, @@ -430,6 +441,7 @@ type PeristenceInterface interface { iConfigRecordCollection iTokenPoolCollection iTokenAccountCollection + iTokenTransferCollection } // CollectionName represents all collections @@ -481,9 +493,10 @@ const ( type UUIDCollection CollectionName const ( - CollectionNamespaces UUIDCollection = "namespaces" - CollectionNodes UUIDCollection = "nodes" - CollectionOrganizations UUIDCollection = "organizations" + CollectionNamespaces UUIDCollection = "namespaces" + CollectionNodes UUIDCollection = "nodes" + CollectionOrganizations UUIDCollection = "organizations" + CollectionTokenTransfers UUIDCollection = "tokentransfers" ) // OtherCollection are odd balls, that don't fit any of the categories above. @@ -757,8 +770,22 @@ var TokenPoolQueryFactory = &queryFields{ // TokenAccountQueryFactory filter fields for token accounts var TokenAccountQueryFactory = &queryFields{ - "protocolid": &StringField{}, - "tokenindex": &StringField{}, - "identity": &StringField{}, - "balance": &Int64Field{}, + "poolprotocolid": &StringField{}, + "tokenindex": &StringField{}, + "identity": &StringField{}, + "balance": &Int64Field{}, +} + +// TokenTransferQueryFactory filter fields for token transfers +var TokenTransferQueryFactory = &queryFields{ + "localid": &StringField{}, + "poolprotocolid": &StringField{}, + "tokenindex": &StringField{}, + "key": &StringField{}, + "from": &StringField{}, + "to": &StringField{}, + "amount": &Int64Field{}, + "protocolid": &StringField{}, + "messagehash": &Bytes32Field{}, + "created": &TimeField{}, } diff --git a/pkg/fftypes/bigint.go b/pkg/fftypes/bigint.go new file mode 100644 index 0000000000..1c0c498466 --- /dev/null +++ b/pkg/fftypes/bigint.go @@ -0,0 +1,100 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 fftypes + +import ( + "context" + "database/sql/driver" + "encoding/json" + "math/big" + + "github.com/hyperledger/firefly/internal/i18n" +) + +const MaxBigIntHexLength = 65 + +// BigInt is a wrapper on a Go big.Int that standardizes JSON and DB serialization +type BigInt big.Int + +func (i BigInt) MarshalText() ([]byte, error) { + // Represent as base 10 string in Marshalled JSON + // This could become configurable to other options, such as: + // - Hex formatted string + // - Number up to max float64, then string if larger + return []byte((*big.Int)(&i).Text(10)), nil +} + +func (i *BigInt) UnmarshalJSON(b []byte) error { + var val interface{} + if err := json.Unmarshal(b, &val); err != nil { + return i18n.WrapError(context.Background(), err, i18n.MsgBigIntParseFailed, b) + } + switch val := val.(type) { + case string: + if _, ok := i.Int().SetString(val, 0); !ok { + return i18n.NewError(context.Background(), i18n.MsgBigIntParseFailed, b) + } + return nil + case float64: + i.Int().SetInt64(int64(val)) + return nil + default: + return i18n.NewError(context.Background(), i18n.MsgBigIntParseFailed, b) + } +} + +func (i BigInt) Value() (driver.Value, error) { + // Represent as base 16 string in database, to allow a 64 character limit + res := (*big.Int)(&i).Text(16) + if len(res) > MaxBigIntHexLength { + return nil, i18n.NewError(context.Background(), i18n.MsgBigIntTooLarge, len(res), MaxBigIntHexLength) + } + return res, nil +} + +func (i *BigInt) Scan(src interface{}) error { + switch src := src.(type) { + case nil: + return nil + case string: + if src == "" { + return nil + } + // Scan is different to JSON deserialization - always read as HEX (without any 0x prefix) + if _, ok := i.Int().SetString(src, 16); !ok { + return i18n.NewError(context.Background(), i18n.MsgScanFailed, src, i) + } + return nil + default: + return i18n.NewError(context.Background(), i18n.MsgScanFailed, src, i) + } +} + +func (i *BigInt) Int() *big.Int { + return (*big.Int)(i) +} + +func (i *BigInt) Equals(i2 *BigInt) bool { + switch { + case i == nil && i2 == nil: + return true + case i == nil || i2 == nil: + return false + default: + return (*big.Int)(i).Cmp((*big.Int)(i2)) == 0 + } +} diff --git a/pkg/fftypes/bigint_test.go b/pkg/fftypes/bigint_test.go new file mode 100644 index 0000000000..e8be07ad78 --- /dev/null +++ b/pkg/fftypes/bigint_test.go @@ -0,0 +1,210 @@ +// Copyright © 2021 Kaleido, 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, souware +// 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 fftypes + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBigIntEmptyJSON(t *testing.T) { + + var myStruct struct { + Field1 BigInt `json:"field1,omitempty"` + Field2 *BigInt `json:"field2,omitempty"` + Field3 *BigInt `json:"field3"` + } + + jsonVal := []byte(`{}`) + + err := json.Unmarshal(jsonVal, &myStruct) + assert.NoError(t, err) + assert.Zero(t, myStruct.Field1.Int().Int64()) + assert.Nil(t, myStruct.Field2) + assert.Nil(t, myStruct.Field3) + +} + +func TestBigIntSetJSONOk(t *testing.T) { + + var myStruct struct { + Field1 BigInt `json:"field1"` + Field2 *BigInt `json:"field2"` + Field3 *BigInt `json:"field3"` + Field4 *BigInt `json:"field4"` + } + + jsonVal := []byte(`{ + "field1": -111111, + "field2": 2222.22, + "field3": "333333", + "field4": "0xfeedBEEF" + }`) + + err := json.Unmarshal(jsonVal, &myStruct) + assert.NoError(t, err) + assert.Equal(t, int64(-111111), myStruct.Field1.Int().Int64()) + assert.Equal(t, int64(2222), myStruct.Field2.Int().Int64()) + assert.Equal(t, int64(333333), myStruct.Field3.Int().Int64()) + assert.Equal(t, int64(4276993775), myStruct.Field4.Int().Int64()) + + jsonValSerialized, err := json.Marshal(&myStruct) + assert.JSONEq(t, `{ + "field1": "-111111", + "field2": "2222", + "field3": "333333", + "field4": "4276993775" + }`, string(jsonValSerialized)) + +} + +func TestBigIntJSONBadString(t *testing.T) { + + jsonVal := []byte(`"0xZZ"`) + + var bi BigInt + err := json.Unmarshal(jsonVal, &bi) + assert.Regexp(t, "FF10292", err) + +} + +func TestBigIntJSONBadType(t *testing.T) { + + jsonVal := []byte(`{ + "field1": { "not": "valid" } + }`) + + var bi BigInt + err := json.Unmarshal(jsonVal, &bi) + assert.Regexp(t, "FF10292", err) + +} + +func TestBigIntJSONBadJSON(t *testing.T) { + + jsonVal := []byte(`!JSON`) + + var bi BigInt + err := bi.UnmarshalJSON(jsonVal) + assert.Regexp(t, "FF10292", err) + +} + +func TestLagePositiveBigIntValue(t *testing.T) { + + var iMax BigInt + _ = iMax.Int().Exp(big.NewInt(2), big.NewInt(256), nil) + iMax.Int().Sub(iMax.Int(), big.NewInt(1)) + iMaxVal, err := iMax.Value() + assert.NoError(t, err) + assert.Equal(t, "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", iMaxVal) + + var iRead big.Int + _, ok := iRead.SetString(iMaxVal.(string), 16) + assert.True(t, ok) + +} + +func TestLargeNegativeBigIntValue(t *testing.T) { + + var iMax BigInt + _ = iMax.Int().Exp(big.NewInt(2), big.NewInt(256), nil) + iMax.Int().Neg(iMax.Int()) + iMax.Int().Add(iMax.Int(), big.NewInt(1)) + iMaxVal, err := iMax.Value() + assert.NoError(t, err) + // Note that this is a "-" prefix with a variable width big-endian positive number (not a fixed width two's compliment) + assert.Equal(t, "-ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", iMaxVal) + + var iRead big.Int + _, ok := iRead.SetString(iMaxVal.(string), 16) + assert.True(t, ok) + +} +func TestTooLargeInteger(t *testing.T) { + + var iMax BigInt + _ = iMax.Int().Exp(big.NewInt(2), big.NewInt(256), nil) + iMax.Int().Neg(iMax.Int()) + _, err := iMax.Value() + assert.Regexp(t, "FF10291", err) + +} + +func TestScanNil(t *testing.T) { + + var nilVal interface{} + var i BigInt + err := i.Scan(nilVal) + assert.NoError(t, err) + assert.Zero(t, i.Int().Int64()) + +} + +func TestScanString(t *testing.T) { + + var i BigInt + err := i.Scan("-feedbeef") + assert.NoError(t, err) + assert.Equal(t, int64(-4276993775), i.Int().Int64()) + +} + +func TestScanEmptyString(t *testing.T) { + + var i BigInt + err := i.Scan("") + assert.NoError(t, err) + assert.Zero(t, i.Int().Int64()) + +} + +func TestScanBadString(t *testing.T) { + + var i BigInt + err := i.Scan("!hex") + assert.Regexp(t, "FF10125", err) + +} + +func TestScanBadType(t *testing.T) { + + var i BigInt + err := i.Scan(123456) + assert.Regexp(t, "FF10125", err) + +} + +func TestEquals(t *testing.T) { + + var pi1, pi2 *BigInt + assert.True(t, pi1.Equals(pi2)) + + var i1 BigInt + i1.Int().Set(big.NewInt(1)) + + assert.False(t, i1.Equals(pi2)) + assert.False(t, pi2.Equals(&i1)) + + var i2 BigInt + i2.Int().Set(big.NewInt(1)) + + assert.True(t, i1.Equals(&i2)) + assert.True(t, i2.Equals(&i1)) + +} diff --git a/pkg/fftypes/event.go b/pkg/fftypes/event.go index a231008cd7..d4471d34d9 100644 --- a/pkg/fftypes/event.go +++ b/pkg/fftypes/event.go @@ -35,6 +35,8 @@ var ( EventTypePoolConfirmed EventType = ffEnum("eventtype", "token_pool_confirmed") // EventTypePoolRejected occurs when a new token pool is rejected (due to validation errors, duplicates, etc) EventTypePoolRejected EventType = ffEnum("eventtype", "token_pool_rejected") + // EventTypeTransferConfirmed occurs when a token transfer has been confirmed + EventTypeTransferConfirmed EventType = ffEnum("eventtype", "token_transfer_confirmed") ) // Event is an activity in the system, delivered reliably to applications, that indicates something has happened in the network diff --git a/pkg/fftypes/operation.go b/pkg/fftypes/operation.go index 96afc8d951..3fde4b20dc 100644 --- a/pkg/fftypes/operation.go +++ b/pkg/fftypes/operation.go @@ -30,10 +30,12 @@ var ( OpTypeDataExchangeBatchSend OpType = ffEnum("optype", "dataexchange_batch_send") // OpTypeDataExchangeBlobSend is a private send OpTypeDataExchangeBlobSend OpType = ffEnum("optype", "dataexchange_blob_send") - // OpTypeTokensCreatePool is a token pool creation - OpTypeTokensCreatePool OpType = ffEnum("optype", "tokens_create_pool") - // OpTypeTokensAnnounce is a broadcast of token pool info - OpTypeTokensAnnouncePool OpType = ffEnum("optype", "tokens_announce_pool") + // OpTypeTokenCreatePool is a token pool creation + OpTypeTokenCreatePool OpType = ffEnum("optype", "token_create_pool") + // OpTypeTokenAnnouncePool is a broadcast of token pool info + OpTypeTokenAnnouncePool OpType = ffEnum("optype", "token_announce_pool") + // OpTypeTokenTransfer is a token transfer + OpTypeTokenTransfer OpType = ffEnum("optype", "token_transfer") ) // OpStatus is the current status of an operation diff --git a/pkg/fftypes/tokenaccount.go b/pkg/fftypes/tokenaccount.go index fec512e87a..4b6217d627 100644 --- a/pkg/fftypes/tokenaccount.go +++ b/pkg/fftypes/tokenaccount.go @@ -17,16 +17,23 @@ package fftypes type TokenAccount struct { - ProtocolID string `json:"protocolId,omitempty"` - TokenIndex string `json:"tokenIndex,omitempty"` - Identity string `json:"identity,omitempty"` - Balance int64 `json:"balance"` + PoolProtocolID string `json:"poolProtocolId,omitempty"` + TokenIndex string `json:"tokenIndex,omitempty"` + Identity string `json:"identity,omitempty"` + Balance BigInt `json:"balance"` } func TokenAccountIdentifier(protocolID, tokenIndex, identity string) string { return protocolID + ":" + tokenIndex + ":" + identity } -func (a *TokenAccount) Identifier() string { - return TokenAccountIdentifier(a.ProtocolID, a.TokenIndex, a.Identity) +func (t *TokenAccount) Identifier() string { + return TokenAccountIdentifier(t.PoolProtocolID, t.TokenIndex, t.Identity) +} + +type TokenBalanceChange struct { + PoolProtocolID string + TokenIndex string + Identity string + Amount BigInt } diff --git a/pkg/fftypes/tokenaccount_test.go b/pkg/fftypes/tokenaccount_test.go index 5408666b9a..5328ce96fd 100644 --- a/pkg/fftypes/tokenaccount_test.go +++ b/pkg/fftypes/tokenaccount_test.go @@ -24,10 +24,9 @@ import ( func TestTokenAccountIdentifier(t *testing.T) { account := &TokenAccount{ - ProtocolID: "123", - TokenIndex: "1", - Identity: "0x00", - Balance: 5, + PoolProtocolID: "123", + TokenIndex: "1", + Identity: "0x00", } assert.Equal(t, "123:1:0x00", account.Identifier()) } diff --git a/pkg/fftypes/tokentransfer.go b/pkg/fftypes/tokentransfer.go new file mode 100644 index 0000000000..de5bc251f0 --- /dev/null +++ b/pkg/fftypes/tokentransfer.go @@ -0,0 +1,40 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 fftypes + +type TokenTransferType = FFEnum + +var ( + TokenTransferTypeMint TokenType = ffEnum("tokentransfertype", "mint") + TokenTransferTypeBurn TokenType = ffEnum("tokentransfertype", "burn") + TokenTransferTypeTransfer TokenType = ffEnum("tokentransfertype", "transfer") +) + +type TokenTransfer struct { + Type TokenTransferType `json:"type" ffenum:"tokentransfertype"` + LocalID *UUID `json:"localId,omitempty"` + PoolProtocolID string `json:"poolProtocolId,omitempty"` + TokenIndex string `json:"tokenIndex,omitempty"` + Key string `json:"key,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Amount BigInt `json:"amount"` + ProtocolID string `json:"protocolId,omitempty"` + MessageHash *Bytes32 `json:"messageHash,omitempty"` + Created *FFTime `json:"created,omitempty"` + TX TransactionRef `json:"tx,omitempty"` +} diff --git a/pkg/tokens/plugin.go b/pkg/tokens/plugin.go index fbb5b928cd..2c91aa664f 100644 --- a/pkg/tokens/plugin.go +++ b/pkg/tokens/plugin.go @@ -42,6 +42,15 @@ type Plugin interface { // CreateTokenPool creates a new (fungible or non-fungible) pool of tokens CreateTokenPool(ctx context.Context, operationID *fftypes.UUID, pool *fftypes.TokenPool) error + + // MintTokens mints new tokens in a pool and adds them to the recipient's account + MintTokens(ctx context.Context, operationID *fftypes.UUID, mint *fftypes.TokenTransfer) error + + // BurnTokens burns tokens from an account + BurnTokens(ctx context.Context, operationID *fftypes.UUID, burn *fftypes.TokenTransfer) error + + // TransferTokens transfers tokens within a pool from one account to another + TransferTokens(ctx context.Context, operationID *fftypes.UUID, mint *fftypes.TokenTransfer) error } // Callbacks is the interface provided to the tokens plugin, to allow it to pass events back to firefly. @@ -64,6 +73,11 @@ type Callbacks interface { // // Error should will only be returned in shutdown scenarios TokenPoolCreated(plugin Plugin, tokenType fftypes.TokenType, tx *fftypes.UUID, protocolID, signingIdentity, protocolTxID string, additionalInfo fftypes.JSONObject) error + + // TokensTransferred notifies on a transfer between token accounts. + // + // Error should will only be returned in shutdown scenarios + TokensTransferred(plugin Plugin, transfer *fftypes.TokenTransfer, signingIdentity string, protocolTxID string, additionalInfo fftypes.JSONObject) error } // Capabilities the supported featureset of the tokens diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 1778afdd79..80ed733f4d 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -109,6 +109,13 @@ func validateReceivedMessages(ts *testState, client *resty.Client, msgType fftyp return msgData.Value } +func validateAccountBalances(t *testing.T, client *resty.Client, poolName, tokenIndex string, balances map[string]int64) { + for identity, balance := range balances { + account := GetTokenAccount(t, client, poolName, tokenIndex, identity) + assert.Equal(t, balance, account.Balance.Int().Int64()) + } +} + func beforeE2ETest(t *testing.T) *testState { stackFile := os.Getenv("STACK_FILE") if stackFile == "" { @@ -176,17 +183,20 @@ func beforeE2ETest(t *testing.T) *testState { time.Sleep(3 * time.Second) } + eventNames := "message_confirmed|token_pool_confirmed|token_transfer_confirmed" + queryString := fmt.Sprintf("namespace=default&ephemeral&autoack&filter.events=%s&changeevents=.*", eventNames) + wsUrl1 := url.URL{ Scheme: websocketProtocolClient1, Host: fmt.Sprintf("%s:%d", stack.Members[0].FireflyHostname, stack.Members[0].ExposedFireflyPort), Path: "/ws", - RawQuery: "namespace=default&ephemeral&autoack&filter.events=message_confirmed|token_pool_confirmed&changeevents=.*", + RawQuery: queryString, } wsUrl2 := url.URL{ Scheme: websocketProtocolClient2, Host: fmt.Sprintf("%s:%d", stack.Members[1].FireflyHostname, stack.Members[1].ExposedFireflyPort), Path: "/ws", - RawQuery: "namespace=default&ephemeral&autoack&filter.events=message_confirmed|token_pool_confirmed&changeevents=.*", + RawQuery: queryString, } t.Logf("Websocket 1: " + wsUrl1.String()) @@ -357,8 +367,8 @@ func TestE2ETokenPool(t *testing.T) { ts := beforeE2ETest(t) defer ts.done() - received1, changes1 := wsReader(t, ts.ws1) - received2, changes2 := wsReader(t, ts.ws2) + received1, _ := wsReader(t, ts.ws1) + received2, _ := wsReader(t, ts.ws2) pools := GetTokenPools(t, ts.client1, time.Unix(0, 0)) poolName := fmt.Sprintf("pool%d", len(pools)) @@ -371,22 +381,98 @@ func TestE2ETokenPool(t *testing.T) { CreateTokenPool(t, ts.client1, pool) <-received1 - <-changes1 // also expect database change events + pools = GetTokenPools(t, ts.client1, ts.startTime) + assert.Equal(t, 1, len(pools)) + assert.Equal(t, "default", pools[0].Namespace) + assert.Equal(t, poolName, pools[0].Name) + assert.Equal(t, fftypes.TokenTypeFungible, pools[0].Type) + + <-received2 + pools = GetTokenPools(t, ts.client1, ts.startTime) + assert.Equal(t, 1, len(pools)) + assert.Equal(t, "default", pools[0].Namespace) + assert.Equal(t, poolName, pools[0].Name) + assert.Equal(t, fftypes.TokenTypeFungible, pools[0].Type) + + transfer := &fftypes.TokenTransfer{} + transfer.Amount.Int().SetInt64(1) + MintTokens(t, ts.client1, poolName, transfer) - pools1 := GetTokenPools(t, ts.client1, ts.startTime) - assert.Equal(t, 1, len(pools1)) - assert.Equal(t, "default", pools1[0].Namespace) - assert.Equal(t, poolName, pools1[0].Name) - assert.Equal(t, fftypes.TokenTypeFungible, pools1[0].Type) + <-received1 + transfers := GetTokenTransfers(t, ts.client1, poolName) + assert.Equal(t, 1, len(transfers)) + assert.Equal(t, fftypes.TokenTransferTypeMint, transfers[0].Type) + assert.Equal(t, "0", transfers[0].TokenIndex) + assert.Equal(t, int64(1), transfers[0].Amount.Int().Int64()) + validateAccountBalances(t, ts.client1, poolName, "0", map[string]int64{ + ts.org1.Identity: 1, + }) <-received2 - <-changes2 // also expect database change events + transfers = GetTokenTransfers(t, ts.client2, poolName) + assert.Equal(t, 1, len(transfers)) + assert.Equal(t, fftypes.TokenTransferTypeMint, transfers[0].Type) + assert.Equal(t, int64(1), transfers[0].Amount.Int().Int64()) + validateAccountBalances(t, ts.client2, poolName, "0", map[string]int64{ + ts.org1.Identity: 1, + }) - pools2 := GetTokenPools(t, ts.client1, ts.startTime) - assert.Equal(t, 1, len(pools2)) - assert.Equal(t, "default", pools2[0].Namespace) - assert.Equal(t, poolName, pools2[0].Name) - assert.Equal(t, fftypes.TokenTypeFungible, pools2[0].Type) + transfer = &fftypes.TokenTransfer{ + TokenIndex: "0", + To: ts.org2.Identity, + } + transfer.Amount.Int().SetInt64(1) + TransferTokens(t, ts.client1, poolName, transfer) + + <-received1 + transfers = GetTokenTransfers(t, ts.client1, poolName) + assert.Equal(t, 2, len(transfers)) + assert.Equal(t, fftypes.TokenTransferTypeTransfer, transfers[0].Type) + assert.Equal(t, "0", transfers[0].TokenIndex) + assert.Equal(t, int64(1), transfers[0].Amount.Int().Int64()) + validateAccountBalances(t, ts.client1, poolName, "0", map[string]int64{ + ts.org1.Identity: 0, + ts.org2.Identity: 1, + }) + + <-received2 + transfers = GetTokenTransfers(t, ts.client2, poolName) + assert.Equal(t, 2, len(transfers)) + assert.Equal(t, fftypes.TokenTransferTypeTransfer, transfers[0].Type) + assert.Equal(t, "0", transfers[0].TokenIndex) + assert.Equal(t, int64(1), transfers[0].Amount.Int().Int64()) + validateAccountBalances(t, ts.client2, poolName, "0", map[string]int64{ + ts.org1.Identity: 0, + ts.org2.Identity: 1, + }) + + transfer = &fftypes.TokenTransfer{ + TokenIndex: "0", + } + transfer.Amount.Int().SetInt64(1) + BurnTokens(t, ts.client2, poolName, transfer) + + <-received2 + transfers = GetTokenTransfers(t, ts.client2, poolName) + assert.Equal(t, 3, len(transfers)) + assert.Equal(t, fftypes.TokenTransferTypeBurn, transfers[0].Type) + assert.Equal(t, "0", transfers[0].TokenIndex) + assert.Equal(t, int64(1), transfers[0].Amount.Int().Int64()) + validateAccountBalances(t, ts.client2, poolName, "0", map[string]int64{ + ts.org1.Identity: 0, + ts.org2.Identity: 0, + }) + + <-received1 + transfers = GetTokenTransfers(t, ts.client1, poolName) + assert.Equal(t, 3, len(transfers)) + assert.Equal(t, fftypes.TokenTransferTypeBurn, transfers[0].Type) + assert.Equal(t, "0", transfers[0].TokenIndex) + assert.Equal(t, int64(1), transfers[0].Amount.Int().Int64()) + validateAccountBalances(t, ts.client1, poolName, "0", map[string]int64{ + ts.org1.Identity: 0, + ts.org2.Identity: 0, + }) } func TestE2EWebhookExchange(t *testing.T) { diff --git a/test/e2e/restclient.go b/test/e2e/restclient.go index 1085f16eee..afca143016 100644 --- a/test/e2e/restclient.go +++ b/test/e2e/restclient.go @@ -43,6 +43,10 @@ var ( urlGetDataBlob = "/namespaces/default/data/%s/blob" urlSubscriptions = "/namespaces/default/subscriptions" urlTokenPools = "/namespaces/default/tokens/erc1155/pools" + urlTokenMint = "/namespaces/default/tokens/erc1155/pools/%s/mint" + urlTokenBurn = "/namespaces/default/tokens/erc1155/pools/%s/burn" + urlTokenTransfers = "/namespaces/default/tokens/erc1155/pools/%s/transfers" + urlTokenAccounts = "/namespaces/default/tokens/erc1155/pools/%s/accounts" urlGetOrganizations = "/network/organizations" ) @@ -312,3 +316,54 @@ func GetTokenPools(t *testing.T, client *resty.Client, startTime time.Time) (poo require.Equal(t, 200, resp.StatusCode(), "GET %s [%d]: %s", path, resp.StatusCode(), resp.String()) return pools } + +func MintTokens(t *testing.T, client *resty.Client, poolName string, mint *fftypes.TokenTransfer) { + path := fmt.Sprintf(urlTokenMint, poolName) + resp, err := client.R(). + SetBody(mint). + Post(path) + require.NoError(t, err) + require.Equal(t, 202, resp.StatusCode(), "POST %s [%d]: %s", path, resp.StatusCode(), resp.String()) +} + +func BurnTokens(t *testing.T, client *resty.Client, poolName string, burn *fftypes.TokenTransfer) { + path := fmt.Sprintf(urlTokenBurn, poolName) + resp, err := client.R(). + SetBody(burn). + Post(path) + require.NoError(t, err) + require.Equal(t, 202, resp.StatusCode(), "POST %s [%d]: %s", path, resp.StatusCode(), resp.String()) +} + +func TransferTokens(t *testing.T, client *resty.Client, poolName string, transfer *fftypes.TokenTransfer) { + path := fmt.Sprintf(urlTokenTransfers, poolName) + resp, err := client.R(). + SetBody(transfer). + Post(path) + require.NoError(t, err) + require.Equal(t, 202, resp.StatusCode(), "POST %s [%d]: %s", path, resp.StatusCode(), resp.String()) +} + +func GetTokenTransfers(t *testing.T, client *resty.Client, poolName string) (transfers []*fftypes.TokenTransfer) { + path := fmt.Sprintf(urlTokenTransfers, poolName) + resp, err := client.R(). + SetResult(&transfers). + Get(path) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode(), "GET %s [%d]: %s", path, resp.StatusCode(), resp.String()) + return transfers +} + +func GetTokenAccount(t *testing.T, client *resty.Client, poolName, tokenIndex, identity string) (account *fftypes.TokenAccount) { + var accounts []*fftypes.TokenAccount + path := fmt.Sprintf(urlTokenAccounts, poolName) + resp, err := client.R(). + SetQueryParam("tokenIndex", tokenIndex). + SetQueryParam("identity", identity). + SetResult(&accounts). + Get(path) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode(), "GET %s [%d]: %s", path, resp.StatusCode(), resp.String()) + require.Greater(t, len(accounts), 0) + return accounts[0] +}