diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ddd6b54792..7eb8b41323 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -89,6 +89,8 @@ jobs: itest: needs: checks runs-on: ubuntu-latest + env: + FAB_BINS: ${{ github.workspace }}/../fabric/bin strategy: fail-fast: false matrix: @@ -150,6 +152,18 @@ jobs: - name: Download fabric binaries run: make download-fabric + - name: Verify fabric binaries + run: | + echo "FAB_BINS=${FAB_BINS}" + ls -la "${FAB_BINS}" + for bin in configtxgen configtxlator cryptogen discover orderer osnadmin peer; do + if [ ! -f "${FAB_BINS}/${bin}" ]; then + echo "Missing required binary: ${FAB_BINS}/${bin}" + exit 1 + fi + chmod +x "${FAB_BINS}/${bin}" + done + - name: Docker run: make docker-images @@ -161,6 +175,9 @@ jobs: - name: Run ${{ matrix.tests }} run: | + export FAB_BINS="${FAB_BINS}" + echo "Running integration with FAB_BINS=${FAB_BINS}" + ls -la "${FAB_BINS}" mkdir -p covdata GOCOVERDIR=$(realpath covdata) make integration-tests-${{ matrix.tests }} go tool covdata textfmt -i=covdata -o coverage.profile diff --git a/integration/token/dvp/views/balance.go b/integration/token/dvp/views/balance.go index f2c924d74b..a1e088efd8 100644 --- a/integration/token/dvp/views/balance.go +++ b/integration/token/dvp/views/balance.go @@ -39,7 +39,7 @@ func (b *BalanceView) Call(context view.Context) (any, error) { return nil, fmt.Errorf("wallet %s not found: %w", b.Wallet, err) } - balance, err := wallet.Balance(context.Context(), token.WithType(b.Type)) + balance, err := wallet.Balance(context.Context(), token.WithBalanceTokenType(b.Type)) if err != nil { return nil, err } diff --git a/integration/token/fungible/sdk/issuer/sdk.go b/integration/token/fungible/sdk/issuer/sdk.go index bd67b2e25a..31a9798d85 100644 --- a/integration/token/fungible/sdk/issuer/sdk.go +++ b/integration/token/fungible/sdk/issuer/sdk.go @@ -39,6 +39,9 @@ func (p *SDK) Install() error { registry.RegisterFactory("balance", &views.BalanceViewFactory{}), registry.RegisterFactory("historyIssuedToken", &views.ListIssuedTokensViewFactory{}), registry.RegisterFactory("issuedTokenQuery", &views.ListIssuedTokensViewFactory{}), + registry.RegisterFactory("issuedBalance", &views.IssuedBalanceViewFactory{}), + registry.RegisterFactory("redeemedBalance", &views.RedeemedBalanceViewFactory{}), + registry.RegisterFactory("outstandingBalance", &views.OutstandingBalanceViewFactory{}), registry.RegisterFactory("GetEnrollmentID", &views.GetEnrollmentIDViewFactory{}), registry.RegisterFactory("acceptedTransactionHistory", &views.ListAcceptedTransactionsViewFactory{}), registry.RegisterFactory("transactionInfo", &views.TransactionInfoViewFactory{}), diff --git a/integration/token/fungible/support.go b/integration/token/fungible/support.go index f450a407e1..3a61c450d8 100644 --- a/integration/token/fungible/support.go +++ b/integration/token/fungible/support.go @@ -423,6 +423,36 @@ func ListUnspentTokens(network *integration.Infrastructure, id *token3.NodeRefer return ListUnspentTokensForTMSID(network, id, wallet, typ, nil) } +func CheckIssuedBalance(network *integration.Infrastructure, issuer *token3.NodeReference, wallet string, typ token.Type, expected uint64) { + res, err := network.Client(issuer.ReplicaName()).CallView("issuedBalance", common.JSONMarshall(&views.IssuerBalanceQuery{ + Wallet: wallet, + TokenType: typ, + })) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + balance := JSONUnmarshalUint64(res) + gomega.Expect(balance).To(gomega.Equal(expected), "issued balance: got %d, expected %d", balance, expected) +} + +func CheckRedeemedBalance(network *integration.Infrastructure, issuer *token3.NodeReference, wallet string, typ token.Type, expected uint64) { + res, err := network.Client(issuer.ReplicaName()).CallView("redeemedBalance", common.JSONMarshall(&views.IssuerBalanceQuery{ + Wallet: wallet, + TokenType: typ, + })) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + balance := JSONUnmarshalUint64(res) + gomega.Expect(balance).To(gomega.Equal(expected), "redeemed balance: got %d, expected %d", balance, expected) +} + +func CheckOutstandingBalance(network *integration.Infrastructure, issuer *token3.NodeReference, wallet string, typ token.Type, expected uint64) { + res, err := network.Client(issuer.ReplicaName()).CallView("outstandingBalance", common.JSONMarshall(&views.IssuerBalanceQuery{ + Wallet: wallet, + TokenType: typ, + })) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + balance := JSONUnmarshalUint64(res) + gomega.Expect(balance).To(gomega.Equal(expected), "outstanding balance: got %d, expected %d", balance, expected) +} + func ListUnspentTokensForTMSID(network *integration.Infrastructure, id *token3.NodeReference, wallet string, typ token.Type, tmsId *token2.TMSID) *token.UnspentTokens { res, err := network.Client(id.ReplicaName()).CallView("history", common.JSONMarshall(&views.ListUnspentTokens{ Wallet: wallet, @@ -1237,6 +1267,22 @@ func JSONUnmarshalFloat64(v any) float64 { return s } +func JSONUnmarshalUint64(v interface{}) uint64 { + var s uint64 + switch v := v.(type) { + case []byte: + err := json.Unmarshal(v, &s) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + case string: + err := json.Unmarshal([]byte(v), &s) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + default: + panic(fmt.Sprintf("type not recognized [%T]", v)) + } + + return s +} + type deleteVaultPlatform interface { DeleteVault(id string) } diff --git a/integration/token/fungible/tests.go b/integration/token/fungible/tests.go index 0f16252db5..a43427f6a3 100644 --- a/integration/token/fungible/tests.go +++ b/integration/token/fungible/tests.go @@ -445,6 +445,12 @@ func TestAll(network *integration.Infrastructure, auditorId string, onRestart On CheckAuditedTransactions(network, auditor, AuditedTransactions[:10], &t0, &t12) CheckSpending(network, bob, "", "USD", auditor, 11) + // Verify issuer balance APIs after issue + redeem cycle. + // Default issuer wallet issued 110 + 10 (withdraw) + 10 (post-redeem) = 130 USD; redeemed 11 USD. + CheckIssuedBalance(network, issuer, "", "USD", 130) + CheckRedeemedBalance(network, issuer, "", "USD", 11) + CheckOutstandingBalance(network, issuer, "", "USD", 119) + // test multi action transfer... t13 := time.Now() IssueCash(network, "", "LIRA", 3, alice, auditor, true, issuer) @@ -506,6 +512,16 @@ func TestAll(network *integration.Infrastructure, auditorId string, onRestart On gomega.Expect(h.Sum(64).ToBigInt().Cmp(big.NewInt(180))).To(gomega.BeEquivalentTo(0)) gomega.Expect(h.ByType("EUR").Count()).To(gomega.BeEquivalentTo(h.Count())) + // Verify issuer balances after all issue and redeem operations. + // Default issuer: 241 USD issued, 21 redeemed (11 + 10). + CheckIssuedBalance(network, issuer, "", "USD", 241) + CheckRedeemedBalance(network, issuer, "", "USD", 21) + CheckOutstandingBalance(network, issuer, "", "USD", 220) + // New issuer wallet: 180 EUR issued, 0 redeemed. + CheckIssuedBalance(network, issuer, "newIssuerWallet", "EUR", 180) + CheckRedeemedBalance(network, issuer, "newIssuerWallet", "EUR", 0) + CheckOutstandingBalance(network, issuer, "newIssuerWallet", "EUR", 180) + CheckBalanceAndHolding(network, issuer, "", "USD", 110, auditor) CheckBalanceAndHolding(network, issuer, "", "EUR", 150, auditor) CheckBalanceAndHolding(network, issuer, "issuer.owner", "EUR", 10, auditor) diff --git a/integration/token/fungible/views/accept.go b/integration/token/fungible/views/accept.go index 6a70ec862d..df89ab35a5 100644 --- a/integration/token/fungible/views/accept.go +++ b/integration/token/fungible/views/accept.go @@ -44,7 +44,7 @@ func (a *AcceptCashView) Call(context view.Context) (any, error) { if output.Type == "MAX" { continue } - balance, err := ttx.MyWallet(context, token.WithTMSID(tx.TMSID())).Balance(context.Context(), ttx.WithType(output.Type)) + balance, err := ttx.MyWallet(context, token.WithTMSID(tx.TMSID())).Balance(context.Context(), token.WithBalanceTokenType(output.Type)) assert.NoError(err, "failed retrieving balance for type [%s]", output.Type) assert.True(balance.Cmp(big.NewInt(3000)) <= 0, "cannot have more than 3000 unspent quantity for type [%s]", output.Type) } diff --git a/integration/token/fungible/views/balance.go b/integration/token/fungible/views/balance.go index 6579a6840d..8d1a7e9e4c 100644 --- a/integration/token/fungible/views/balance.go +++ b/integration/token/fungible/views/balance.go @@ -63,7 +63,7 @@ func (b *BalanceView) Call(context view.Context) (any, error) { assert.NoError(err, "failed to compute the sum of the co-owned tokens") if !b.SkipCheck { - balance, err := wallet.Balance(context.Context(), token.WithType(b.Type)) + balance, err := wallet.Balance(context.Context(), token.WithBalanceTokenType(b.Type)) if err != nil { return nil, err } diff --git a/integration/token/fungible/views/history.go b/integration/token/fungible/views/history.go index 87971f4f8e..241b33a8fd 100644 --- a/integration/token/fungible/views/history.go +++ b/integration/token/fungible/views/history.go @@ -181,3 +181,85 @@ func (p *TransactionInfoViewFactory) NewView(in []byte) (view.View, error) { return f, nil } + +// IssuerBalanceQuery contains the input parameters for issuer balance views. +type IssuerBalanceQuery struct { + Wallet string + TokenType token2.Type + TMSID *token.TMSID +} + +// IssuedBalanceView returns the total amount of non-redeemed tokens issued by the given wallet. +type IssuedBalanceView struct { + *IssuerBalanceQuery +} + +func (p *IssuedBalanceView) Call(context view.Context) (interface{}, error) { + wallet := ttx.GetIssuerWallet(context, p.Wallet, ServiceOpts(p.TMSID)...) + if wallet == nil { + return nil, errors.Errorf("wallet [%s] not found", p.Wallet) + } + + return wallet.IssuedBalance(context.Context(), token.WithBalanceTokenType(p.TokenType)) +} + +type IssuedBalanceViewFactory struct{} + +func (f *IssuedBalanceViewFactory) NewView(in []byte) (view.View, error) { + v := &IssuedBalanceView{IssuerBalanceQuery: &IssuerBalanceQuery{}} + if err := json.Unmarshal(in, v.IssuerBalanceQuery); err != nil { + return nil, errors.Wrapf(err, "failed unmarshalling input") + } + + return v, nil +} + +// RedeemedBalanceView returns the total amount of redeemed tokens originally issued by the given wallet. +type RedeemedBalanceView struct { + *IssuerBalanceQuery +} + +func (p *RedeemedBalanceView) Call(context view.Context) (interface{}, error) { + wallet := ttx.GetIssuerWallet(context, p.Wallet, ServiceOpts(p.TMSID)...) + if wallet == nil { + return nil, errors.Errorf("wallet [%s] not found", p.Wallet) + } + + return wallet.RedeemedBalance(context.Context(), token.WithBalanceTokenType(p.TokenType)) +} + +type RedeemedBalanceViewFactory struct{} + +func (f *RedeemedBalanceViewFactory) NewView(in []byte) (view.View, error) { + v := &RedeemedBalanceView{IssuerBalanceQuery: &IssuerBalanceQuery{}} + if err := json.Unmarshal(in, v.IssuerBalanceQuery); err != nil { + return nil, errors.Wrapf(err, "failed unmarshalling input") + } + + return v, nil +} + +// OutstandingBalanceView returns IssuedBalance − RedeemedBalance for the given wallet. +type OutstandingBalanceView struct { + *IssuerBalanceQuery +} + +func (p *OutstandingBalanceView) Call(context view.Context) (interface{}, error) { + wallet := ttx.GetIssuerWallet(context, p.Wallet, ServiceOpts(p.TMSID)...) + if wallet == nil { + return nil, errors.Errorf("wallet [%s] not found", p.Wallet) + } + + return wallet.OutstandingBalance(context.Context(), token.WithBalanceTokenType(p.TokenType)) +} + +type OutstandingBalanceViewFactory struct{} + +func (f *OutstandingBalanceViewFactory) NewView(in []byte) (view.View, error) { + v := &OutstandingBalanceView{IssuerBalanceQuery: &IssuerBalanceQuery{}} + if err := json.Unmarshal(in, v.IssuerBalanceQuery); err != nil { + return nil, errors.Wrapf(err, "failed unmarshalling input") + } + + return v, nil +} diff --git a/integration/token/interop/views/balance.go b/integration/token/interop/views/balance.go index 006a9c0f39..7184e2b429 100644 --- a/integration/token/interop/views/balance.go +++ b/integration/token/interop/views/balance.go @@ -47,7 +47,7 @@ func (b *BalanceView) Call(context view.Context) (any, error) { precision := tms.PublicParametersManager().PublicParameters().Precision() // owned - balance, err := wallet.Balance(context.Context(), token.WithType(b.Type)) + balance, err := wallet.Balance(context.Context(), token.WithBalanceTokenType(b.Type)) assert.NoError(err, "failed to get unspent tokens") htlcWallet := htlc.Wallet(context, wallet) diff --git a/token/core/common/authorization.go b/token/core/common/authorization.go index e4302ad185..1adcd9bb39 100644 --- a/token/core/common/authorization.go +++ b/token/core/common/authorization.go @@ -76,6 +76,17 @@ func (w *WalletBasedAuthorization) AmIAnAuditor() bool { // Issued returns true if the passed issuer issued the passed token func (w *WalletBasedAuthorization) Issued(ctx context.Context, issuer token.Identity, tok *token2.Token) bool { _, err := w.WalletService.IssuerWallet(ctx, issuer) + if err == nil { + return true + } + + // In some setups (notably HSM-backed identities), the issuer identity serialized in + // transfer metadata may not byte-match the locally registered wallet identity. + // Fall back to checking if this node has a default issuer wallet configured. + if issuer.IsNone() { + return false + } + _, err = w.WalletService.IssuerWallet(ctx, "") return err == nil } diff --git a/token/core/common/authorization_test.go b/token/core/common/authorization_test.go index 73f2527ad9..d786030341 100644 --- a/token/core/common/authorization_test.go +++ b/token/core/common/authorization_test.go @@ -91,6 +91,27 @@ func TestWalletBasedAuthorization(t *testing.T) { assert.False(t, auth.Issued(context.Background(), issuer, tok)) }) + + t.Run("Issued_True_DefaultIssuerFallback", func(t *testing.T) { + localWS := &mock.WalletService{} + localAuth := &WalletBasedAuthorization{Logger: logger, PublicParameters: pp, WalletService: localWS} + issuer := driver.Identity("issuer-id-hsm-variant") + tok := &token2.Token{} + localWS.IssuerWalletReturnsOnCall(0, nil, errors.New("not issuer by raw identity")) + localWS.IssuerWalletReturnsOnCall(1, &mock.IssuerWallet{}, nil) + + assert.True(t, localAuth.Issued(context.Background(), issuer, tok)) + }) + + t.Run("Issued_False_EmptyIssuer_NoFallback", func(t *testing.T) { + localWS := &mock.WalletService{} + localAuth := &WalletBasedAuthorization{Logger: logger, PublicParameters: pp, WalletService: localWS} + issuer := driver.Identity(nil) + tok := &token2.Token{} + localWS.IssuerWalletReturns(nil, errors.New("not issuer")) + + assert.False(t, localAuth.Issued(context.Background(), issuer, tok)) + }) } type mockAuth struct { diff --git a/token/driver/mock/iw.go b/token/driver/mock/iw.go index 18b3ce2134..ada53bd51d 100644 --- a/token/driver/mock/iw.go +++ b/token/driver/mock/iw.go @@ -85,6 +85,62 @@ type IssuerWallet struct { iDReturnsOnCall map[int]struct { result1 string } + IssuedBalanceStub func(context.Context, *driver.BalanceOpts) (uint64, error) + issuedBalanceMutex sync.RWMutex + issuedBalanceArgsForCall []struct { + arg1 context.Context + arg2 *driver.BalanceOpts + } + issuedBalanceReturns struct { + result1 uint64 + result2 error + } + issuedBalanceReturnsOnCall map[int]struct { + result1 uint64 + result2 error + } + OutstandingBalanceStub func(context.Context, *driver.BalanceOpts) (uint64, error) + outstandingBalanceMutex sync.RWMutex + outstandingBalanceArgsForCall []struct { + arg1 context.Context + arg2 *driver.BalanceOpts + } + outstandingBalanceReturns struct { + result1 uint64 + result2 error + } + outstandingBalanceReturnsOnCall map[int]struct { + result1 uint64 + result2 error + } + RedeemedBalanceStub func(context.Context, *driver.BalanceOpts) (uint64, error) + redeemedBalanceMutex sync.RWMutex + redeemedBalanceArgsForCall []struct { + arg1 context.Context + arg2 *driver.BalanceOpts + } + redeemedBalanceReturns struct { + result1 uint64 + result2 error + } + redeemedBalanceReturnsOnCall map[int]struct { + result1 uint64 + result2 error + } + RedeemedTokensStub func(context.Context, *driver.ListTokensOptions) (*token.IssuedTokens, error) + redeemedTokensMutex sync.RWMutex + redeemedTokensArgsForCall []struct { + arg1 context.Context + arg2 *driver.ListTokensOptions + } + redeemedTokensReturns struct { + result1 *token.IssuedTokens + result2 error + } + redeemedTokensReturnsOnCall map[int]struct { + result1 *token.IssuedTokens + result2 error + } invocations map[string][][]interface{} invocationsMutex sync.RWMutex } @@ -460,6 +516,266 @@ func (fake *IssuerWallet) IDReturnsOnCall(i int, result1 string) { }{result1} } +func (fake *IssuerWallet) IssuedBalance(arg1 context.Context, arg2 *driver.BalanceOpts) (uint64, error) { + fake.issuedBalanceMutex.Lock() + ret, specificReturn := fake.issuedBalanceReturnsOnCall[len(fake.issuedBalanceArgsForCall)] + fake.issuedBalanceArgsForCall = append(fake.issuedBalanceArgsForCall, struct { + arg1 context.Context + arg2 *driver.BalanceOpts + }{arg1, arg2}) + stub := fake.IssuedBalanceStub + fakeReturns := fake.issuedBalanceReturns + fake.recordInvocation("IssuedBalance", []interface{}{arg1, arg2}) + fake.issuedBalanceMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *IssuerWallet) IssuedBalanceCallCount() int { + fake.issuedBalanceMutex.RLock() + defer fake.issuedBalanceMutex.RUnlock() + return len(fake.issuedBalanceArgsForCall) +} + +func (fake *IssuerWallet) IssuedBalanceCalls(stub func(context.Context, *driver.BalanceOpts) (uint64, error)) { + fake.issuedBalanceMutex.Lock() + defer fake.issuedBalanceMutex.Unlock() + fake.IssuedBalanceStub = stub +} + +func (fake *IssuerWallet) IssuedBalanceArgsForCall(i int) (context.Context, *driver.BalanceOpts) { + fake.issuedBalanceMutex.RLock() + defer fake.issuedBalanceMutex.RUnlock() + argsForCall := fake.issuedBalanceArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *IssuerWallet) IssuedBalanceReturns(result1 uint64, result2 error) { + fake.issuedBalanceMutex.Lock() + defer fake.issuedBalanceMutex.Unlock() + fake.IssuedBalanceStub = nil + fake.issuedBalanceReturns = struct { + result1 uint64 + result2 error + }{result1, result2} +} + +func (fake *IssuerWallet) IssuedBalanceReturnsOnCall(i int, result1 uint64, result2 error) { + fake.issuedBalanceMutex.Lock() + defer fake.issuedBalanceMutex.Unlock() + fake.IssuedBalanceStub = nil + if fake.issuedBalanceReturnsOnCall == nil { + fake.issuedBalanceReturnsOnCall = make(map[int]struct { + result1 uint64 + result2 error + }) + } + fake.issuedBalanceReturnsOnCall[i] = struct { + result1 uint64 + result2 error + }{result1, result2} +} + +func (fake *IssuerWallet) OutstandingBalance(arg1 context.Context, arg2 *driver.BalanceOpts) (uint64, error) { + fake.outstandingBalanceMutex.Lock() + ret, specificReturn := fake.outstandingBalanceReturnsOnCall[len(fake.outstandingBalanceArgsForCall)] + fake.outstandingBalanceArgsForCall = append(fake.outstandingBalanceArgsForCall, struct { + arg1 context.Context + arg2 *driver.BalanceOpts + }{arg1, arg2}) + stub := fake.OutstandingBalanceStub + fakeReturns := fake.outstandingBalanceReturns + fake.recordInvocation("OutstandingBalance", []interface{}{arg1, arg2}) + fake.outstandingBalanceMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *IssuerWallet) OutstandingBalanceCallCount() int { + fake.outstandingBalanceMutex.RLock() + defer fake.outstandingBalanceMutex.RUnlock() + return len(fake.outstandingBalanceArgsForCall) +} + +func (fake *IssuerWallet) OutstandingBalanceCalls(stub func(context.Context, *driver.BalanceOpts) (uint64, error)) { + fake.outstandingBalanceMutex.Lock() + defer fake.outstandingBalanceMutex.Unlock() + fake.OutstandingBalanceStub = stub +} + +func (fake *IssuerWallet) OutstandingBalanceArgsForCall(i int) (context.Context, *driver.BalanceOpts) { + fake.outstandingBalanceMutex.RLock() + defer fake.outstandingBalanceMutex.RUnlock() + argsForCall := fake.outstandingBalanceArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *IssuerWallet) OutstandingBalanceReturns(result1 uint64, result2 error) { + fake.outstandingBalanceMutex.Lock() + defer fake.outstandingBalanceMutex.Unlock() + fake.OutstandingBalanceStub = nil + fake.outstandingBalanceReturns = struct { + result1 uint64 + result2 error + }{result1, result2} +} + +func (fake *IssuerWallet) OutstandingBalanceReturnsOnCall(i int, result1 uint64, result2 error) { + fake.outstandingBalanceMutex.Lock() + defer fake.outstandingBalanceMutex.Unlock() + fake.OutstandingBalanceStub = nil + if fake.outstandingBalanceReturnsOnCall == nil { + fake.outstandingBalanceReturnsOnCall = make(map[int]struct { + result1 uint64 + result2 error + }) + } + fake.outstandingBalanceReturnsOnCall[i] = struct { + result1 uint64 + result2 error + }{result1, result2} +} + +func (fake *IssuerWallet) RedeemedBalance(arg1 context.Context, arg2 *driver.BalanceOpts) (uint64, error) { + fake.redeemedBalanceMutex.Lock() + ret, specificReturn := fake.redeemedBalanceReturnsOnCall[len(fake.redeemedBalanceArgsForCall)] + fake.redeemedBalanceArgsForCall = append(fake.redeemedBalanceArgsForCall, struct { + arg1 context.Context + arg2 *driver.BalanceOpts + }{arg1, arg2}) + stub := fake.RedeemedBalanceStub + fakeReturns := fake.redeemedBalanceReturns + fake.recordInvocation("RedeemedBalance", []interface{}{arg1, arg2}) + fake.redeemedBalanceMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *IssuerWallet) RedeemedBalanceCallCount() int { + fake.redeemedBalanceMutex.RLock() + defer fake.redeemedBalanceMutex.RUnlock() + return len(fake.redeemedBalanceArgsForCall) +} + +func (fake *IssuerWallet) RedeemedBalanceCalls(stub func(context.Context, *driver.BalanceOpts) (uint64, error)) { + fake.redeemedBalanceMutex.Lock() + defer fake.redeemedBalanceMutex.Unlock() + fake.RedeemedBalanceStub = stub +} + +func (fake *IssuerWallet) RedeemedBalanceArgsForCall(i int) (context.Context, *driver.BalanceOpts) { + fake.redeemedBalanceMutex.RLock() + defer fake.redeemedBalanceMutex.RUnlock() + argsForCall := fake.redeemedBalanceArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *IssuerWallet) RedeemedBalanceReturns(result1 uint64, result2 error) { + fake.redeemedBalanceMutex.Lock() + defer fake.redeemedBalanceMutex.Unlock() + fake.RedeemedBalanceStub = nil + fake.redeemedBalanceReturns = struct { + result1 uint64 + result2 error + }{result1, result2} +} + +func (fake *IssuerWallet) RedeemedBalanceReturnsOnCall(i int, result1 uint64, result2 error) { + fake.redeemedBalanceMutex.Lock() + defer fake.redeemedBalanceMutex.Unlock() + fake.RedeemedBalanceStub = nil + if fake.redeemedBalanceReturnsOnCall == nil { + fake.redeemedBalanceReturnsOnCall = make(map[int]struct { + result1 uint64 + result2 error + }) + } + fake.redeemedBalanceReturnsOnCall[i] = struct { + result1 uint64 + result2 error + }{result1, result2} +} + +func (fake *IssuerWallet) RedeemedTokens(arg1 context.Context, arg2 *driver.ListTokensOptions) (*token.IssuedTokens, error) { + fake.redeemedTokensMutex.Lock() + ret, specificReturn := fake.redeemedTokensReturnsOnCall[len(fake.redeemedTokensArgsForCall)] + fake.redeemedTokensArgsForCall = append(fake.redeemedTokensArgsForCall, struct { + arg1 context.Context + arg2 *driver.ListTokensOptions + }{arg1, arg2}) + stub := fake.RedeemedTokensStub + fakeReturns := fake.redeemedTokensReturns + fake.recordInvocation("RedeemedTokens", []interface{}{arg1, arg2}) + fake.redeemedTokensMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *IssuerWallet) RedeemedTokensCallCount() int { + fake.redeemedTokensMutex.RLock() + defer fake.redeemedTokensMutex.RUnlock() + return len(fake.redeemedTokensArgsForCall) +} + +func (fake *IssuerWallet) RedeemedTokensCalls(stub func(context.Context, *driver.ListTokensOptions) (*token.IssuedTokens, error)) { + fake.redeemedTokensMutex.Lock() + defer fake.redeemedTokensMutex.Unlock() + fake.RedeemedTokensStub = stub +} + +func (fake *IssuerWallet) RedeemedTokensArgsForCall(i int) (context.Context, *driver.ListTokensOptions) { + fake.redeemedTokensMutex.RLock() + defer fake.redeemedTokensMutex.RUnlock() + argsForCall := fake.redeemedTokensArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *IssuerWallet) RedeemedTokensReturns(result1 *token.IssuedTokens, result2 error) { + fake.redeemedTokensMutex.Lock() + defer fake.redeemedTokensMutex.Unlock() + fake.RedeemedTokensStub = nil + fake.redeemedTokensReturns = struct { + result1 *token.IssuedTokens + result2 error + }{result1, result2} +} + +func (fake *IssuerWallet) RedeemedTokensReturnsOnCall(i int, result1 *token.IssuedTokens, result2 error) { + fake.redeemedTokensMutex.Lock() + defer fake.redeemedTokensMutex.Unlock() + fake.RedeemedTokensStub = nil + if fake.redeemedTokensReturnsOnCall == nil { + fake.redeemedTokensReturnsOnCall = make(map[int]struct { + result1 *token.IssuedTokens + result2 error + }) + } + fake.redeemedTokensReturnsOnCall[i] = struct { + result1 *token.IssuedTokens + result2 error + }{result1, result2} +} + func (fake *IssuerWallet) Invocations() map[string][][]interface{} { fake.invocationsMutex.RLock() defer fake.invocationsMutex.RUnlock() diff --git a/token/driver/mock/qe.go b/token/driver/mock/qe.go index 6279ccaf9d..ae64f83baf 100644 --- a/token/driver/mock/qe.go +++ b/token/driver/mock/qe.go @@ -5,6 +5,7 @@ import ( "context" "math/big" "sync" + "time" "github.com/hyperledger-labs/fabric-token-sdk/token/driver" "github.com/hyperledger-labs/fabric-token-sdk/token/token" @@ -129,6 +130,23 @@ type QueryEngine struct { result1 bool result2 error } + IssuedBalanceStub func(context.Context, token.Type, driver.Identity, *time.Time, *time.Time) (uint64, error) + issuedBalanceMutex sync.RWMutex + issuedBalanceArgsForCall []struct { + arg1 context.Context + arg2 token.Type + arg3 driver.Identity + arg4 *time.Time + arg5 *time.Time + } + issuedBalanceReturns struct { + result1 uint64 + result2 error + } + issuedBalanceReturnsOnCall map[int]struct { + result1 uint64 + result2 error + } ListAuditTokensStub func(context.Context, ...*token.ID) ([]*token.Token, error) listAuditTokensMutex sync.RWMutex listAuditTokensArgsForCall []struct { @@ -156,6 +174,25 @@ type QueryEngine struct { result1 *token.IssuedTokens result2 error } + ListRedeemedTokensStub func(context.Context, token.Type, driver.Identity, *time.Time, *time.Time, driver.SortField, driver.SortDirection) (*token.IssuedTokens, error) + listRedeemedTokensMutex sync.RWMutex + listRedeemedTokensArgsForCall []struct { + arg1 context.Context + arg2 token.Type + arg3 driver.Identity + arg4 *time.Time + arg5 *time.Time + arg6 driver.SortField + arg7 driver.SortDirection + } + listRedeemedTokensReturns struct { + result1 *token.IssuedTokens + result2 error + } + listRedeemedTokensReturnsOnCall map[int]struct { + result1 *token.IssuedTokens + result2 error + } ListUnspentTokensStub func(context.Context) (*token.UnspentTokens, error) listUnspentTokensMutex sync.RWMutex listUnspentTokensArgsForCall []struct { @@ -182,6 +219,23 @@ type QueryEngine struct { result1 []byte result2 error } + RedeemedBalanceStub func(context.Context, token.Type, driver.Identity, *time.Time, *time.Time) (uint64, error) + redeemedBalanceMutex sync.RWMutex + redeemedBalanceArgsForCall []struct { + arg1 context.Context + arg2 token.Type + arg3 driver.Identity + arg4 *time.Time + arg5 *time.Time + } + redeemedBalanceReturns struct { + result1 uint64 + result2 error + } + redeemedBalanceReturnsOnCall map[int]struct { + result1 uint64 + result2 error + } UnspentLedgerTokensIteratorByStub func(context.Context) (driver.LedgerTokensIterator, error) unspentLedgerTokensIteratorByMutex sync.RWMutex unspentLedgerTokensIteratorByArgsForCall []struct { @@ -786,6 +840,74 @@ func (fake *QueryEngine) IsPendingReturnsOnCall(i int, result1 bool, result2 err }{result1, result2} } +func (fake *QueryEngine) IssuedBalance(arg1 context.Context, arg2 token.Type, arg3 driver.Identity, arg4 *time.Time, arg5 *time.Time) (uint64, error) { + fake.issuedBalanceMutex.Lock() + ret, specificReturn := fake.issuedBalanceReturnsOnCall[len(fake.issuedBalanceArgsForCall)] + fake.issuedBalanceArgsForCall = append(fake.issuedBalanceArgsForCall, struct { + arg1 context.Context + arg2 token.Type + arg3 driver.Identity + arg4 *time.Time + arg5 *time.Time + }{arg1, arg2, arg3, arg4, arg5}) + stub := fake.IssuedBalanceStub + fakeReturns := fake.issuedBalanceReturns + fake.recordInvocation("IssuedBalance", []interface{}{arg1, arg2, arg3, arg4, arg5}) + fake.issuedBalanceMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4, arg5) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *QueryEngine) IssuedBalanceCallCount() int { + fake.issuedBalanceMutex.RLock() + defer fake.issuedBalanceMutex.RUnlock() + return len(fake.issuedBalanceArgsForCall) +} + +func (fake *QueryEngine) IssuedBalanceCalls(stub func(context.Context, token.Type, driver.Identity, *time.Time, *time.Time) (uint64, error)) { + fake.issuedBalanceMutex.Lock() + defer fake.issuedBalanceMutex.Unlock() + fake.IssuedBalanceStub = stub +} + +func (fake *QueryEngine) IssuedBalanceArgsForCall(i int) (context.Context, token.Type, driver.Identity, *time.Time, *time.Time) { + fake.issuedBalanceMutex.RLock() + defer fake.issuedBalanceMutex.RUnlock() + argsForCall := fake.issuedBalanceArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5 +} + +func (fake *QueryEngine) IssuedBalanceReturns(result1 uint64, result2 error) { + fake.issuedBalanceMutex.Lock() + defer fake.issuedBalanceMutex.Unlock() + fake.IssuedBalanceStub = nil + fake.issuedBalanceReturns = struct { + result1 uint64 + result2 error + }{result1, result2} +} + +func (fake *QueryEngine) IssuedBalanceReturnsOnCall(i int, result1 uint64, result2 error) { + fake.issuedBalanceMutex.Lock() + defer fake.issuedBalanceMutex.Unlock() + fake.IssuedBalanceStub = nil + if fake.issuedBalanceReturnsOnCall == nil { + fake.issuedBalanceReturnsOnCall = make(map[int]struct { + result1 uint64 + result2 error + }) + } + fake.issuedBalanceReturnsOnCall[i] = struct { + result1 uint64 + result2 error + }{result1, result2} +} + func (fake *QueryEngine) ListAuditTokens(arg1 context.Context, arg2 ...*token.ID) ([]*token.Token, error) { fake.listAuditTokensMutex.Lock() ret, specificReturn := fake.listAuditTokensReturnsOnCall[len(fake.listAuditTokensArgsForCall)] @@ -915,6 +1037,76 @@ func (fake *QueryEngine) ListHistoryIssuedTokensReturnsOnCall(i int, result1 *to }{result1, result2} } +func (fake *QueryEngine) ListRedeemedTokens(arg1 context.Context, arg2 token.Type, arg3 driver.Identity, arg4 *time.Time, arg5 *time.Time, arg6 driver.SortField, arg7 driver.SortDirection) (*token.IssuedTokens, error) { + fake.listRedeemedTokensMutex.Lock() + ret, specificReturn := fake.listRedeemedTokensReturnsOnCall[len(fake.listRedeemedTokensArgsForCall)] + fake.listRedeemedTokensArgsForCall = append(fake.listRedeemedTokensArgsForCall, struct { + arg1 context.Context + arg2 token.Type + arg3 driver.Identity + arg4 *time.Time + arg5 *time.Time + arg6 driver.SortField + arg7 driver.SortDirection + }{arg1, arg2, arg3, arg4, arg5, arg6, arg7}) + stub := fake.ListRedeemedTokensStub + fakeReturns := fake.listRedeemedTokensReturns + fake.recordInvocation("ListRedeemedTokens", []interface{}{arg1, arg2, arg3, arg4, arg5, arg6, arg7}) + fake.listRedeemedTokensMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4, arg5, arg6, arg7) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *QueryEngine) ListRedeemedTokensCallCount() int { + fake.listRedeemedTokensMutex.RLock() + defer fake.listRedeemedTokensMutex.RUnlock() + return len(fake.listRedeemedTokensArgsForCall) +} + +func (fake *QueryEngine) ListRedeemedTokensCalls(stub func(context.Context, token.Type, driver.Identity, *time.Time, *time.Time, driver.SortField, driver.SortDirection) (*token.IssuedTokens, error)) { + fake.listRedeemedTokensMutex.Lock() + defer fake.listRedeemedTokensMutex.Unlock() + fake.ListRedeemedTokensStub = stub +} + +func (fake *QueryEngine) ListRedeemedTokensArgsForCall(i int) (context.Context, token.Type, driver.Identity, *time.Time, *time.Time, driver.SortField, driver.SortDirection) { + fake.listRedeemedTokensMutex.RLock() + defer fake.listRedeemedTokensMutex.RUnlock() + argsForCall := fake.listRedeemedTokensArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5, argsForCall.arg6, argsForCall.arg7 +} + +func (fake *QueryEngine) ListRedeemedTokensReturns(result1 *token.IssuedTokens, result2 error) { + fake.listRedeemedTokensMutex.Lock() + defer fake.listRedeemedTokensMutex.Unlock() + fake.ListRedeemedTokensStub = nil + fake.listRedeemedTokensReturns = struct { + result1 *token.IssuedTokens + result2 error + }{result1, result2} +} + +func (fake *QueryEngine) ListRedeemedTokensReturnsOnCall(i int, result1 *token.IssuedTokens, result2 error) { + fake.listRedeemedTokensMutex.Lock() + defer fake.listRedeemedTokensMutex.Unlock() + fake.ListRedeemedTokensStub = nil + if fake.listRedeemedTokensReturnsOnCall == nil { + fake.listRedeemedTokensReturnsOnCall = make(map[int]struct { + result1 *token.IssuedTokens + result2 error + }) + } + fake.listRedeemedTokensReturnsOnCall[i] = struct { + result1 *token.IssuedTokens + result2 error + }{result1, result2} +} + func (fake *QueryEngine) ListUnspentTokens(arg1 context.Context) (*token.UnspentTokens, error) { fake.listUnspentTokensMutex.Lock() ret, specificReturn := fake.listUnspentTokensReturnsOnCall[len(fake.listUnspentTokensArgsForCall)] @@ -1043,6 +1235,74 @@ func (fake *QueryEngine) PublicParamsReturnsOnCall(i int, result1 []byte, result }{result1, result2} } +func (fake *QueryEngine) RedeemedBalance(arg1 context.Context, arg2 token.Type, arg3 driver.Identity, arg4 *time.Time, arg5 *time.Time) (uint64, error) { + fake.redeemedBalanceMutex.Lock() + ret, specificReturn := fake.redeemedBalanceReturnsOnCall[len(fake.redeemedBalanceArgsForCall)] + fake.redeemedBalanceArgsForCall = append(fake.redeemedBalanceArgsForCall, struct { + arg1 context.Context + arg2 token.Type + arg3 driver.Identity + arg4 *time.Time + arg5 *time.Time + }{arg1, arg2, arg3, arg4, arg5}) + stub := fake.RedeemedBalanceStub + fakeReturns := fake.redeemedBalanceReturns + fake.recordInvocation("RedeemedBalance", []interface{}{arg1, arg2, arg3, arg4, arg5}) + fake.redeemedBalanceMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4, arg5) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *QueryEngine) RedeemedBalanceCallCount() int { + fake.redeemedBalanceMutex.RLock() + defer fake.redeemedBalanceMutex.RUnlock() + return len(fake.redeemedBalanceArgsForCall) +} + +func (fake *QueryEngine) RedeemedBalanceCalls(stub func(context.Context, token.Type, driver.Identity, *time.Time, *time.Time) (uint64, error)) { + fake.redeemedBalanceMutex.Lock() + defer fake.redeemedBalanceMutex.Unlock() + fake.RedeemedBalanceStub = stub +} + +func (fake *QueryEngine) RedeemedBalanceArgsForCall(i int) (context.Context, token.Type, driver.Identity, *time.Time, *time.Time) { + fake.redeemedBalanceMutex.RLock() + defer fake.redeemedBalanceMutex.RUnlock() + argsForCall := fake.redeemedBalanceArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5 +} + +func (fake *QueryEngine) RedeemedBalanceReturns(result1 uint64, result2 error) { + fake.redeemedBalanceMutex.Lock() + defer fake.redeemedBalanceMutex.Unlock() + fake.RedeemedBalanceStub = nil + fake.redeemedBalanceReturns = struct { + result1 uint64 + result2 error + }{result1, result2} +} + +func (fake *QueryEngine) RedeemedBalanceReturnsOnCall(i int, result1 uint64, result2 error) { + fake.redeemedBalanceMutex.Lock() + defer fake.redeemedBalanceMutex.Unlock() + fake.RedeemedBalanceStub = nil + if fake.redeemedBalanceReturnsOnCall == nil { + fake.redeemedBalanceReturnsOnCall = make(map[int]struct { + result1 uint64 + result2 error + }) + } + fake.redeemedBalanceReturnsOnCall[i] = struct { + result1 uint64 + result2 error + }{result1, result2} +} + func (fake *QueryEngine) UnspentLedgerTokensIteratorBy(arg1 context.Context) (driver.LedgerTokensIterator, error) { fake.unspentLedgerTokensIteratorByMutex.Lock() ret, specificReturn := fake.unspentLedgerTokensIteratorByReturnsOnCall[len(fake.unspentLedgerTokensIteratorByArgsForCall)] diff --git a/token/driver/vault.go b/token/driver/vault.go index 6806cf5ca5..3863407c00 100644 --- a/token/driver/vault.go +++ b/token/driver/vault.go @@ -9,6 +9,7 @@ package driver import ( "context" "math/big" + "time" "github.com/hyperledger-labs/fabric-smart-client/platform/common/utils/collections/iterators" "github.com/hyperledger-labs/fabric-token-sdk/token/token" @@ -87,6 +88,20 @@ type QueryEngine interface { ListAuditTokens(ctx context.Context, ids ...*token.ID) ([]*token.Token, error) // ListHistoryIssuedTokens returns a list of all tokens issued by the service. ListHistoryIssuedTokens(ctx context.Context) (*token.IssuedTokens, error) + // IssuedBalance returns the sum of amounts of non-deleted tokens flagged as issued. + // If tokenType is non-empty, only tokens of that type are included. + // If issuerRaw is non-empty, only tokens issued by that identity are included. + IssuedBalance(ctx context.Context, tokenType token.Type, issuerRaw Identity, from, to *time.Time) (uint64, error) + // ListRedeemedTokens returns issued tokens that were spent by a Redeem action. + // If tokenType is non-empty, only tokens of that type are included. + // If issuerRaw is non-empty, only tokens issued by that identity are included. + // If from/to are non-nil, only tokens stored within that time range are included. + ListRedeemedTokens(ctx context.Context, tokenType token.Type, issuerRaw Identity, from, to *time.Time, sortBy SortField, sortDirection SortDirection) (*token.IssuedTokens, error) + // RedeemedBalance returns the sum of amounts of issued tokens spent by a Redeem action. + // If tokenType is non-empty, only tokens of that type are included. + // If issuerRaw is non-empty, only tokens issued by that identity are included. + // If from/to are non-nil, only tokens stored within that time range are included. + RedeemedBalance(ctx context.Context, tokenType token.Type, issuerRaw Identity, from, to *time.Time) (uint64, error) // PublicParams returns the serialized public parameters used by the driver. PublicParams(ctx context.Context) ([]byte, error) // GetTokenMetadata retrieves the private information (metadata) for the given token IDs. @@ -105,7 +120,6 @@ type QueryEngine interface { } //go:generate counterfeiter -o mock/token_vault.go -fake-name TokenVault . TokenVault - type TokenVault interface { IsPending(ctx context.Context, id *token.ID) (bool, error) GetTokenOutputsAndMeta(ctx context.Context, ids []*token.ID) ([][]byte, [][]byte, []token.Format, error) @@ -117,13 +131,11 @@ type TokenVault interface { } //go:generate counterfeiter -o mock/ledger_token.go -fake-name LedgerToken . LedgerToken - type LedgerToken interface { GetOwner() []byte } //go:generate counterfeiter -o mock/token_certification_storage.go -fake-name TokenCertificationStorage . TokenCertificationStorage - type TokenCertificationStorage interface { GetCertifications(ctx context.Context, ids []*token.ID) ([][]byte, error) } diff --git a/token/driver/wallet.go b/token/driver/wallet.go index f92c46efd7..dda93be45c 100644 --- a/token/driver/wallet.go +++ b/token/driver/wallet.go @@ -9,6 +9,7 @@ package driver import ( "context" "math/big" + "time" "github.com/hyperledger-labs/fabric-smart-client/platform/view/view" "github.com/hyperledger-labs/fabric-token-sdk/token/token" @@ -69,10 +70,51 @@ type RecipientData struct { TokenMetadataAuditInfo []byte } +// SortField specifies which field to use for ordering token results. +type SortField int + +const ( + // SortByDefault leaves ordering to the database default. + SortByDefault SortField = iota + // SortByQuantity orders results by token quantity (amount). + SortByQuantity +) + +// SortDirection specifies ascending or descending order. +type SortDirection int + +const ( + // SortAscending orders results from smallest to largest. + SortAscending SortDirection = iota + // SortDescending orders results from largest to smallest. + SortDescending +) + // ListTokensOptions contains options that can be used to list tokens from a wallet type ListTokensOptions struct { // TokenType is the type of token to list TokenType token.Type + // SortBy specifies which field to use for ordering the results. + SortBy SortField + // SortDirection specifies ascending or descending order. + SortDirection SortDirection + // From, if non-nil, restricts results to tokens stored at or after this time. + From *time.Time + // To, if non-nil, restricts results to tokens stored at or before this time. + To *time.Time +} + +// BalanceOpts contains options for balance queries (methods that return a +// scalar total). Sort fields are intentionally omitted because balance +// results are single numbers, not ordered lists. +type BalanceOpts struct { + // TokenType restricts the balance computation to tokens of this type. + // An empty value means all token types. + TokenType token.Type + // From, if non-nil, restricts the computation to tokens stored at or after this time. + From *time.Time + // To, if non-nil, restricts the computation to tokens stored at or before this time. + To *time.Time } // Wallet models a generic wallet @@ -124,8 +166,7 @@ type OwnerWallet interface { // ListTokensIterator returns an iterator of unspent tokens owned by this wallet filtered using the passed options. ListTokensIterator(ctx context.Context, opts *ListTokensOptions) (UnspentTokensIterator, error) - // Balance returns the sum of the amounts of the tokens with type and EID equal to those passed as arguments. - // The result is returned as a *big.Int to support arbitrary precision and prevent overflow. + // Balance returns the sun of the amounts, with 64 bits of precision, of the tokens with type and EID equal to those passed as arguments. Balance(ctx context.Context, opts *ListTokensOptions) (*big.Int, error) // EnrollmentID returns the enrollment ID of the owner wallet @@ -151,6 +192,18 @@ type IssuerWallet interface { // HistoryTokens returns the list of tokens issued by this wallet filtered using the passed options. HistoryTokens(ctx context.Context, opts *ListTokensOptions) (*token.IssuedTokens, error) + + // IssuedBalance returns the total amount of tokens issued by this wallet (not yet redeemed). + IssuedBalance(ctx context.Context, opts *BalanceOpts) (uint64, error) + + // RedeemedTokens returns the list of redeemed tokens that were originally issued by this wallet. + RedeemedTokens(ctx context.Context, opts *ListTokensOptions) (*token.IssuedTokens, error) + + // RedeemedBalance returns the total amount of redeemed tokens that were originally issued by this wallet. + RedeemedBalance(ctx context.Context, opts *BalanceOpts) (uint64, error) + + // OutstandingBalance returns the net amount still in circulation: IssuedBalance − RedeemedBalance. + OutstandingBalance(ctx context.Context, opts *BalanceOpts) (uint64, error) } // AuditorWallet models the wallet of an auditor diff --git a/token/request.go b/token/request.go index 06f8a55c00..c3a2d4ca63 100644 --- a/token/request.go +++ b/token/request.go @@ -738,6 +738,11 @@ func (r *Request) extractTransferOutputs(ctx context.Context, i int, counter uin if err != nil { return nil, 0, errors.Wrapf(err, "failed getting quantity [%d,%d]", i, j) } + // For redeem outputs, the per-output metadata does not carry the issuer identity. + // Fall back to the action-level issuer (set only for redeem transfers). + if issuer.IsNone() && !transferAction.GetIssuer().IsNone() { + issuer = transferAction.GetIssuer() + } if noOutputForRecipient { outputs = append(outputs, &Output{ Token: *tok, diff --git a/token/services/identity/role/mock/itv.go b/token/services/identity/role/mock/itv.go index 5b5bc9ab54..0cb9ede7ab 100644 --- a/token/services/identity/role/mock/itv.go +++ b/token/services/identity/role/mock/itv.go @@ -4,12 +4,31 @@ package mock import ( "context" "sync" + "time" + "github.com/hyperledger-labs/fabric-token-sdk/token/driver" "github.com/hyperledger-labs/fabric-token-sdk/token/services/identity/role" "github.com/hyperledger-labs/fabric-token-sdk/token/token" ) type IssuerTokenVault struct { + IssuedBalanceStub func(context.Context, token.Type, role.Identity, *time.Time, *time.Time) (uint64, error) + issuedBalanceMutex sync.RWMutex + issuedBalanceArgsForCall []struct { + arg1 context.Context + arg2 token.Type + arg3 role.Identity + arg4 *time.Time + arg5 *time.Time + } + issuedBalanceReturns struct { + result1 uint64 + result2 error + } + issuedBalanceReturnsOnCall map[int]struct { + result1 uint64 + result2 error + } ListHistoryIssuedTokensStub func(context.Context) (*token.IssuedTokens, error) listHistoryIssuedTokensMutex sync.RWMutex listHistoryIssuedTokensArgsForCall []struct { @@ -23,10 +42,114 @@ type IssuerTokenVault struct { result1 *token.IssuedTokens result2 error } + ListRedeemedTokensStub func(context.Context, token.Type, role.Identity, *time.Time, *time.Time, driver.SortField, driver.SortDirection) (*token.IssuedTokens, error) + listRedeemedTokensMutex sync.RWMutex + listRedeemedTokensArgsForCall []struct { + arg1 context.Context + arg2 token.Type + arg3 role.Identity + arg4 *time.Time + arg5 *time.Time + arg6 driver.SortField + arg7 driver.SortDirection + } + listRedeemedTokensReturns struct { + result1 *token.IssuedTokens + result2 error + } + listRedeemedTokensReturnsOnCall map[int]struct { + result1 *token.IssuedTokens + result2 error + } + RedeemedBalanceStub func(context.Context, token.Type, role.Identity, *time.Time, *time.Time) (uint64, error) + redeemedBalanceMutex sync.RWMutex + redeemedBalanceArgsForCall []struct { + arg1 context.Context + arg2 token.Type + arg3 role.Identity + arg4 *time.Time + arg5 *time.Time + } + redeemedBalanceReturns struct { + result1 uint64 + result2 error + } + redeemedBalanceReturnsOnCall map[int]struct { + result1 uint64 + result2 error + } invocations map[string][][]interface{} invocationsMutex sync.RWMutex } +func (fake *IssuerTokenVault) IssuedBalance(arg1 context.Context, arg2 token.Type, arg3 role.Identity, arg4 *time.Time, arg5 *time.Time) (uint64, error) { + fake.issuedBalanceMutex.Lock() + ret, specificReturn := fake.issuedBalanceReturnsOnCall[len(fake.issuedBalanceArgsForCall)] + fake.issuedBalanceArgsForCall = append(fake.issuedBalanceArgsForCall, struct { + arg1 context.Context + arg2 token.Type + arg3 role.Identity + arg4 *time.Time + arg5 *time.Time + }{arg1, arg2, arg3, arg4, arg5}) + stub := fake.IssuedBalanceStub + fakeReturns := fake.issuedBalanceReturns + fake.recordInvocation("IssuedBalance", []interface{}{arg1, arg2, arg3, arg4, arg5}) + fake.issuedBalanceMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4, arg5) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *IssuerTokenVault) IssuedBalanceCallCount() int { + fake.issuedBalanceMutex.RLock() + defer fake.issuedBalanceMutex.RUnlock() + return len(fake.issuedBalanceArgsForCall) +} + +func (fake *IssuerTokenVault) IssuedBalanceCalls(stub func(context.Context, token.Type, role.Identity, *time.Time, *time.Time) (uint64, error)) { + fake.issuedBalanceMutex.Lock() + defer fake.issuedBalanceMutex.Unlock() + fake.IssuedBalanceStub = stub +} + +func (fake *IssuerTokenVault) IssuedBalanceArgsForCall(i int) (context.Context, token.Type, role.Identity, *time.Time, *time.Time) { + fake.issuedBalanceMutex.RLock() + defer fake.issuedBalanceMutex.RUnlock() + argsForCall := fake.issuedBalanceArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5 +} + +func (fake *IssuerTokenVault) IssuedBalanceReturns(result1 uint64, result2 error) { + fake.issuedBalanceMutex.Lock() + defer fake.issuedBalanceMutex.Unlock() + fake.IssuedBalanceStub = nil + fake.issuedBalanceReturns = struct { + result1 uint64 + result2 error + }{result1, result2} +} + +func (fake *IssuerTokenVault) IssuedBalanceReturnsOnCall(i int, result1 uint64, result2 error) { + fake.issuedBalanceMutex.Lock() + defer fake.issuedBalanceMutex.Unlock() + fake.IssuedBalanceStub = nil + if fake.issuedBalanceReturnsOnCall == nil { + fake.issuedBalanceReturnsOnCall = make(map[int]struct { + result1 uint64 + result2 error + }) + } + fake.issuedBalanceReturnsOnCall[i] = struct { + result1 uint64 + result2 error + }{result1, result2} +} + func (fake *IssuerTokenVault) ListHistoryIssuedTokens(arg1 context.Context) (*token.IssuedTokens, error) { fake.listHistoryIssuedTokensMutex.Lock() ret, specificReturn := fake.listHistoryIssuedTokensReturnsOnCall[len(fake.listHistoryIssuedTokensArgsForCall)] @@ -91,6 +214,144 @@ func (fake *IssuerTokenVault) ListHistoryIssuedTokensReturnsOnCall(i int, result }{result1, result2} } +func (fake *IssuerTokenVault) ListRedeemedTokens(arg1 context.Context, arg2 token.Type, arg3 role.Identity, arg4 *time.Time, arg5 *time.Time, arg6 driver.SortField, arg7 driver.SortDirection) (*token.IssuedTokens, error) { + fake.listRedeemedTokensMutex.Lock() + ret, specificReturn := fake.listRedeemedTokensReturnsOnCall[len(fake.listRedeemedTokensArgsForCall)] + fake.listRedeemedTokensArgsForCall = append(fake.listRedeemedTokensArgsForCall, struct { + arg1 context.Context + arg2 token.Type + arg3 role.Identity + arg4 *time.Time + arg5 *time.Time + arg6 driver.SortField + arg7 driver.SortDirection + }{arg1, arg2, arg3, arg4, arg5, arg6, arg7}) + stub := fake.ListRedeemedTokensStub + fakeReturns := fake.listRedeemedTokensReturns + fake.recordInvocation("ListRedeemedTokens", []interface{}{arg1, arg2, arg3, arg4, arg5, arg6, arg7}) + fake.listRedeemedTokensMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4, arg5, arg6, arg7) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *IssuerTokenVault) ListRedeemedTokensCallCount() int { + fake.listRedeemedTokensMutex.RLock() + defer fake.listRedeemedTokensMutex.RUnlock() + return len(fake.listRedeemedTokensArgsForCall) +} + +func (fake *IssuerTokenVault) ListRedeemedTokensCalls(stub func(context.Context, token.Type, role.Identity, *time.Time, *time.Time, driver.SortField, driver.SortDirection) (*token.IssuedTokens, error)) { + fake.listRedeemedTokensMutex.Lock() + defer fake.listRedeemedTokensMutex.Unlock() + fake.ListRedeemedTokensStub = stub +} + +func (fake *IssuerTokenVault) ListRedeemedTokensArgsForCall(i int) (context.Context, token.Type, role.Identity, *time.Time, *time.Time, driver.SortField, driver.SortDirection) { + fake.listRedeemedTokensMutex.RLock() + defer fake.listRedeemedTokensMutex.RUnlock() + argsForCall := fake.listRedeemedTokensArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5, argsForCall.arg6, argsForCall.arg7 +} + +func (fake *IssuerTokenVault) ListRedeemedTokensReturns(result1 *token.IssuedTokens, result2 error) { + fake.listRedeemedTokensMutex.Lock() + defer fake.listRedeemedTokensMutex.Unlock() + fake.ListRedeemedTokensStub = nil + fake.listRedeemedTokensReturns = struct { + result1 *token.IssuedTokens + result2 error + }{result1, result2} +} + +func (fake *IssuerTokenVault) ListRedeemedTokensReturnsOnCall(i int, result1 *token.IssuedTokens, result2 error) { + fake.listRedeemedTokensMutex.Lock() + defer fake.listRedeemedTokensMutex.Unlock() + fake.ListRedeemedTokensStub = nil + if fake.listRedeemedTokensReturnsOnCall == nil { + fake.listRedeemedTokensReturnsOnCall = make(map[int]struct { + result1 *token.IssuedTokens + result2 error + }) + } + fake.listRedeemedTokensReturnsOnCall[i] = struct { + result1 *token.IssuedTokens + result2 error + }{result1, result2} +} + +func (fake *IssuerTokenVault) RedeemedBalance(arg1 context.Context, arg2 token.Type, arg3 role.Identity, arg4 *time.Time, arg5 *time.Time) (uint64, error) { + fake.redeemedBalanceMutex.Lock() + ret, specificReturn := fake.redeemedBalanceReturnsOnCall[len(fake.redeemedBalanceArgsForCall)] + fake.redeemedBalanceArgsForCall = append(fake.redeemedBalanceArgsForCall, struct { + arg1 context.Context + arg2 token.Type + arg3 role.Identity + arg4 *time.Time + arg5 *time.Time + }{arg1, arg2, arg3, arg4, arg5}) + stub := fake.RedeemedBalanceStub + fakeReturns := fake.redeemedBalanceReturns + fake.recordInvocation("RedeemedBalance", []interface{}{arg1, arg2, arg3, arg4, arg5}) + fake.redeemedBalanceMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4, arg5) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *IssuerTokenVault) RedeemedBalanceCallCount() int { + fake.redeemedBalanceMutex.RLock() + defer fake.redeemedBalanceMutex.RUnlock() + return len(fake.redeemedBalanceArgsForCall) +} + +func (fake *IssuerTokenVault) RedeemedBalanceCalls(stub func(context.Context, token.Type, role.Identity, *time.Time, *time.Time) (uint64, error)) { + fake.redeemedBalanceMutex.Lock() + defer fake.redeemedBalanceMutex.Unlock() + fake.RedeemedBalanceStub = stub +} + +func (fake *IssuerTokenVault) RedeemedBalanceArgsForCall(i int) (context.Context, token.Type, role.Identity, *time.Time, *time.Time) { + fake.redeemedBalanceMutex.RLock() + defer fake.redeemedBalanceMutex.RUnlock() + argsForCall := fake.redeemedBalanceArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5 +} + +func (fake *IssuerTokenVault) RedeemedBalanceReturns(result1 uint64, result2 error) { + fake.redeemedBalanceMutex.Lock() + defer fake.redeemedBalanceMutex.Unlock() + fake.RedeemedBalanceStub = nil + fake.redeemedBalanceReturns = struct { + result1 uint64 + result2 error + }{result1, result2} +} + +func (fake *IssuerTokenVault) RedeemedBalanceReturnsOnCall(i int, result1 uint64, result2 error) { + fake.redeemedBalanceMutex.Lock() + defer fake.redeemedBalanceMutex.Unlock() + fake.RedeemedBalanceStub = nil + if fake.redeemedBalanceReturnsOnCall == nil { + fake.redeemedBalanceReturnsOnCall = make(map[int]struct { + result1 uint64 + result2 error + }) + } + fake.redeemedBalanceReturnsOnCall[i] = struct { + result1 uint64 + result2 error + }{result1, result2} +} + func (fake *IssuerTokenVault) Invocations() map[string][][]interface{} { fake.invocationsMutex.RLock() defer fake.invocationsMutex.RUnlock() diff --git a/token/services/identity/role/mock/tv.go b/token/services/identity/role/mock/tv.go index b463ac19ce..4ab3d15a58 100644 --- a/token/services/identity/role/mock/tv.go +++ b/token/services/identity/role/mock/tv.go @@ -5,6 +5,7 @@ import ( "context" "math/big" "sync" + "time" "github.com/hyperledger-labs/fabric-token-sdk/token/driver" "github.com/hyperledger-labs/fabric-token-sdk/token/services/identity/role" @@ -27,6 +28,23 @@ type TokenVault struct { result1 *big.Int result2 error } + IssuedBalanceStub func(context.Context, token.Type, driver.Identity, *time.Time, *time.Time) (uint64, error) + issuedBalanceMutex sync.RWMutex + issuedBalanceArgsForCall []struct { + arg1 context.Context + arg2 token.Type + arg3 driver.Identity + arg4 *time.Time + arg5 *time.Time + } + issuedBalanceReturns struct { + result1 uint64 + result2 error + } + issuedBalanceReturnsOnCall map[int]struct { + result1 uint64 + result2 error + } ListHistoryIssuedTokensStub func(context.Context) (*token.IssuedTokens, error) listHistoryIssuedTokensMutex sync.RWMutex listHistoryIssuedTokensArgsForCall []struct { @@ -40,6 +58,42 @@ type TokenVault struct { result1 *token.IssuedTokens result2 error } + ListRedeemedTokensStub func(context.Context, token.Type, driver.Identity, *time.Time, *time.Time, driver.SortField, driver.SortDirection) (*token.IssuedTokens, error) + listRedeemedTokensMutex sync.RWMutex + listRedeemedTokensArgsForCall []struct { + arg1 context.Context + arg2 token.Type + arg3 driver.Identity + arg4 *time.Time + arg5 *time.Time + arg6 driver.SortField + arg7 driver.SortDirection + } + listRedeemedTokensReturns struct { + result1 *token.IssuedTokens + result2 error + } + listRedeemedTokensReturnsOnCall map[int]struct { + result1 *token.IssuedTokens + result2 error + } + RedeemedBalanceStub func(context.Context, token.Type, driver.Identity, *time.Time, *time.Time) (uint64, error) + redeemedBalanceMutex sync.RWMutex + redeemedBalanceArgsForCall []struct { + arg1 context.Context + arg2 token.Type + arg3 driver.Identity + arg4 *time.Time + arg5 *time.Time + } + redeemedBalanceReturns struct { + result1 uint64 + result2 error + } + redeemedBalanceReturnsOnCall map[int]struct { + result1 uint64 + result2 error + } UnspentTokensIteratorByStub func(context.Context, string, token.Type) (driver.UnspentTokensIterator, error) unspentTokensIteratorByMutex sync.RWMutex unspentTokensIteratorByArgsForCall []struct { @@ -125,6 +179,74 @@ func (fake *TokenVault) BalanceReturnsOnCall(i int, result1 *big.Int, result2 er }{result1, result2} } +func (fake *TokenVault) IssuedBalance(arg1 context.Context, arg2 token.Type, arg3 driver.Identity, arg4 *time.Time, arg5 *time.Time) (uint64, error) { + fake.issuedBalanceMutex.Lock() + ret, specificReturn := fake.issuedBalanceReturnsOnCall[len(fake.issuedBalanceArgsForCall)] + fake.issuedBalanceArgsForCall = append(fake.issuedBalanceArgsForCall, struct { + arg1 context.Context + arg2 token.Type + arg3 driver.Identity + arg4 *time.Time + arg5 *time.Time + }{arg1, arg2, arg3, arg4, arg5}) + stub := fake.IssuedBalanceStub + fakeReturns := fake.issuedBalanceReturns + fake.recordInvocation("IssuedBalance", []interface{}{arg1, arg2, arg3, arg4, arg5}) + fake.issuedBalanceMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4, arg5) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *TokenVault) IssuedBalanceCallCount() int { + fake.issuedBalanceMutex.RLock() + defer fake.issuedBalanceMutex.RUnlock() + return len(fake.issuedBalanceArgsForCall) +} + +func (fake *TokenVault) IssuedBalanceCalls(stub func(context.Context, token.Type, driver.Identity, *time.Time, *time.Time) (uint64, error)) { + fake.issuedBalanceMutex.Lock() + defer fake.issuedBalanceMutex.Unlock() + fake.IssuedBalanceStub = stub +} + +func (fake *TokenVault) IssuedBalanceArgsForCall(i int) (context.Context, token.Type, driver.Identity, *time.Time, *time.Time) { + fake.issuedBalanceMutex.RLock() + defer fake.issuedBalanceMutex.RUnlock() + argsForCall := fake.issuedBalanceArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5 +} + +func (fake *TokenVault) IssuedBalanceReturns(result1 uint64, result2 error) { + fake.issuedBalanceMutex.Lock() + defer fake.issuedBalanceMutex.Unlock() + fake.IssuedBalanceStub = nil + fake.issuedBalanceReturns = struct { + result1 uint64 + result2 error + }{result1, result2} +} + +func (fake *TokenVault) IssuedBalanceReturnsOnCall(i int, result1 uint64, result2 error) { + fake.issuedBalanceMutex.Lock() + defer fake.issuedBalanceMutex.Unlock() + fake.IssuedBalanceStub = nil + if fake.issuedBalanceReturnsOnCall == nil { + fake.issuedBalanceReturnsOnCall = make(map[int]struct { + result1 uint64 + result2 error + }) + } + fake.issuedBalanceReturnsOnCall[i] = struct { + result1 uint64 + result2 error + }{result1, result2} +} + func (fake *TokenVault) ListHistoryIssuedTokens(arg1 context.Context) (*token.IssuedTokens, error) { fake.listHistoryIssuedTokensMutex.Lock() ret, specificReturn := fake.listHistoryIssuedTokensReturnsOnCall[len(fake.listHistoryIssuedTokensArgsForCall)] @@ -189,6 +311,144 @@ func (fake *TokenVault) ListHistoryIssuedTokensReturnsOnCall(i int, result1 *tok }{result1, result2} } +func (fake *TokenVault) ListRedeemedTokens(arg1 context.Context, arg2 token.Type, arg3 driver.Identity, arg4 *time.Time, arg5 *time.Time, arg6 driver.SortField, arg7 driver.SortDirection) (*token.IssuedTokens, error) { + fake.listRedeemedTokensMutex.Lock() + ret, specificReturn := fake.listRedeemedTokensReturnsOnCall[len(fake.listRedeemedTokensArgsForCall)] + fake.listRedeemedTokensArgsForCall = append(fake.listRedeemedTokensArgsForCall, struct { + arg1 context.Context + arg2 token.Type + arg3 driver.Identity + arg4 *time.Time + arg5 *time.Time + arg6 driver.SortField + arg7 driver.SortDirection + }{arg1, arg2, arg3, arg4, arg5, arg6, arg7}) + stub := fake.ListRedeemedTokensStub + fakeReturns := fake.listRedeemedTokensReturns + fake.recordInvocation("ListRedeemedTokens", []interface{}{arg1, arg2, arg3, arg4, arg5, arg6, arg7}) + fake.listRedeemedTokensMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4, arg5, arg6, arg7) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *TokenVault) ListRedeemedTokensCallCount() int { + fake.listRedeemedTokensMutex.RLock() + defer fake.listRedeemedTokensMutex.RUnlock() + return len(fake.listRedeemedTokensArgsForCall) +} + +func (fake *TokenVault) ListRedeemedTokensCalls(stub func(context.Context, token.Type, driver.Identity, *time.Time, *time.Time, driver.SortField, driver.SortDirection) (*token.IssuedTokens, error)) { + fake.listRedeemedTokensMutex.Lock() + defer fake.listRedeemedTokensMutex.Unlock() + fake.ListRedeemedTokensStub = stub +} + +func (fake *TokenVault) ListRedeemedTokensArgsForCall(i int) (context.Context, token.Type, driver.Identity, *time.Time, *time.Time, driver.SortField, driver.SortDirection) { + fake.listRedeemedTokensMutex.RLock() + defer fake.listRedeemedTokensMutex.RUnlock() + argsForCall := fake.listRedeemedTokensArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5, argsForCall.arg6, argsForCall.arg7 +} + +func (fake *TokenVault) ListRedeemedTokensReturns(result1 *token.IssuedTokens, result2 error) { + fake.listRedeemedTokensMutex.Lock() + defer fake.listRedeemedTokensMutex.Unlock() + fake.ListRedeemedTokensStub = nil + fake.listRedeemedTokensReturns = struct { + result1 *token.IssuedTokens + result2 error + }{result1, result2} +} + +func (fake *TokenVault) ListRedeemedTokensReturnsOnCall(i int, result1 *token.IssuedTokens, result2 error) { + fake.listRedeemedTokensMutex.Lock() + defer fake.listRedeemedTokensMutex.Unlock() + fake.ListRedeemedTokensStub = nil + if fake.listRedeemedTokensReturnsOnCall == nil { + fake.listRedeemedTokensReturnsOnCall = make(map[int]struct { + result1 *token.IssuedTokens + result2 error + }) + } + fake.listRedeemedTokensReturnsOnCall[i] = struct { + result1 *token.IssuedTokens + result2 error + }{result1, result2} +} + +func (fake *TokenVault) RedeemedBalance(arg1 context.Context, arg2 token.Type, arg3 driver.Identity, arg4 *time.Time, arg5 *time.Time) (uint64, error) { + fake.redeemedBalanceMutex.Lock() + ret, specificReturn := fake.redeemedBalanceReturnsOnCall[len(fake.redeemedBalanceArgsForCall)] + fake.redeemedBalanceArgsForCall = append(fake.redeemedBalanceArgsForCall, struct { + arg1 context.Context + arg2 token.Type + arg3 driver.Identity + arg4 *time.Time + arg5 *time.Time + }{arg1, arg2, arg3, arg4, arg5}) + stub := fake.RedeemedBalanceStub + fakeReturns := fake.redeemedBalanceReturns + fake.recordInvocation("RedeemedBalance", []interface{}{arg1, arg2, arg3, arg4, arg5}) + fake.redeemedBalanceMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4, arg5) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *TokenVault) RedeemedBalanceCallCount() int { + fake.redeemedBalanceMutex.RLock() + defer fake.redeemedBalanceMutex.RUnlock() + return len(fake.redeemedBalanceArgsForCall) +} + +func (fake *TokenVault) RedeemedBalanceCalls(stub func(context.Context, token.Type, driver.Identity, *time.Time, *time.Time) (uint64, error)) { + fake.redeemedBalanceMutex.Lock() + defer fake.redeemedBalanceMutex.Unlock() + fake.RedeemedBalanceStub = stub +} + +func (fake *TokenVault) RedeemedBalanceArgsForCall(i int) (context.Context, token.Type, driver.Identity, *time.Time, *time.Time) { + fake.redeemedBalanceMutex.RLock() + defer fake.redeemedBalanceMutex.RUnlock() + argsForCall := fake.redeemedBalanceArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5 +} + +func (fake *TokenVault) RedeemedBalanceReturns(result1 uint64, result2 error) { + fake.redeemedBalanceMutex.Lock() + defer fake.redeemedBalanceMutex.Unlock() + fake.RedeemedBalanceStub = nil + fake.redeemedBalanceReturns = struct { + result1 uint64 + result2 error + }{result1, result2} +} + +func (fake *TokenVault) RedeemedBalanceReturnsOnCall(i int, result1 uint64, result2 error) { + fake.redeemedBalanceMutex.Lock() + defer fake.redeemedBalanceMutex.Unlock() + fake.RedeemedBalanceStub = nil + if fake.redeemedBalanceReturnsOnCall == nil { + fake.redeemedBalanceReturnsOnCall = make(map[int]struct { + result1 uint64 + result2 error + }) + } + fake.redeemedBalanceReturnsOnCall[i] = struct { + result1 uint64 + result2 error + }{result1, result2} +} + func (fake *TokenVault) UnspentTokensIteratorBy(arg1 context.Context, arg2 string, arg3 token.Type) (driver.UnspentTokensIterator, error) { fake.unspentTokensIteratorByMutex.Lock() ret, specificReturn := fake.unspentTokensIteratorByReturnsOnCall[len(fake.unspentTokensIteratorByArgsForCall)] diff --git a/token/services/identity/role/wallets.go b/token/services/identity/role/wallets.go index 8fc48c9c84..18f5ed2ecb 100644 --- a/token/services/identity/role/wallets.go +++ b/token/services/identity/role/wallets.go @@ -9,6 +9,7 @@ package role import ( "context" "math/big" + "time" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" "github.com/hyperledger-labs/fabric-smart-client/platform/common/utils/collections/iterators" @@ -117,6 +118,20 @@ func (w *AuditorWallet) GetSigner(ctx context.Context, identity Identity) (Signe //go:generate counterfeiter -o mock/itv.go -fake-name IssuerTokenVault . IssuerTokenVault type IssuerTokenVault interface { ListHistoryIssuedTokens(context.Context) (*token.IssuedTokens, error) + // IssuedBalance returns the sum of amounts of non-deleted tokens issued by this node. + // If tokenType is non-empty, only tokens of that type are included. + // If issuerRaw is non-empty, only tokens issued by that identity are included. + IssuedBalance(ctx context.Context, tokenType token.Type, issuerRaw Identity, from, to *time.Time) (uint64, error) + // ListRedeemedTokens returns issued tokens that were redeemed (spent via a Redeem action). + // If tokenType is non-empty, only tokens of that type are included. + // If issuerRaw is non-empty, only tokens issued by that identity are included. + // If from/to are non-nil, only tokens stored within that time range are included. + ListRedeemedTokens(ctx context.Context, tokenType token.Type, issuerRaw Identity, from, to *time.Time, sortBy driver.SortField, sortDirection driver.SortDirection) (*token.IssuedTokens, error) + // RedeemedBalance returns the sum of amounts of redeemed tokens issued by this node. + // If tokenType is non-empty, only tokens of that type are included. + // If issuerRaw is non-empty, only tokens issued by that identity are included. + // If from/to are non-nil, only tokens stored within that time range are included. + RedeemedBalance(ctx context.Context, tokenType token.Type, issuerRaw Identity, from, to *time.Time) (uint64, error) } // IssuerWallet represents a wallet that manages a single issuer identity. @@ -214,6 +229,71 @@ func (w *IssuerWallet) HistoryTokens(ctx context.Context, opts *driver.ListToken return unspentTokens, nil } +// IssuedBalance returns the total amount of non-redeemed tokens issued by +// this wallet. +func (w *IssuerWallet) IssuedBalance(ctx context.Context, opts *driver.BalanceOpts) (uint64, error) { + w.Logger.DebugfContext(ctx, "issuer wallet [%s]: issued balance, type [%s]", w.ID(), opts.TokenType) + + return w.TokenVault.IssuedBalance(ctx, opts.TokenType, w.Identity, opts.From, opts.To) +} + +// RedeemedTokens returns the list of redeemed tokens originally issued by this wallet. +func (w *IssuerWallet) RedeemedTokens(ctx context.Context, opts *driver.ListTokensOptions) (*token.IssuedTokens, error) { + w.Logger.DebugfContext(ctx, "issuer wallet [%s]: redeemed tokens, type [%s]", w.ID(), opts.TokenType) + source, err := w.TokenVault.ListRedeemedTokens(ctx, opts.TokenType, w.Identity, opts.From, opts.To, opts.SortBy, opts.SortDirection) + if err != nil { + return nil, errors.Wrap(err, "redeemed tokens query failed") + } + + return w.filterTokens(ctx, source, opts), nil +} + +// RedeemedBalance returns the total amount of redeemed tokens originally +// issued by this wallet. +func (w *IssuerWallet) RedeemedBalance(ctx context.Context, opts *driver.BalanceOpts) (uint64, error) { + w.Logger.DebugfContext(ctx, "issuer wallet [%s]: redeemed balance, type [%s]", w.ID(), opts.TokenType) + + return w.TokenVault.RedeemedBalance(ctx, opts.TokenType, w.Identity, opts.From, opts.To) +} + +// OutstandingBalance returns the net amount still in circulation: +// IssuedBalance − RedeemedBalance. +func (w *IssuerWallet) OutstandingBalance(ctx context.Context, opts *driver.BalanceOpts) (uint64, error) { + w.Logger.DebugfContext(ctx, "issuer wallet [%s]: outstanding balance, type [%s]", w.ID(), opts.TokenType) + + issued, err := w.IssuedBalance(ctx, opts) + if err != nil { + return 0, errors.Wrap(err, "outstanding balance: issued query failed") + } + + redeemed, err := w.RedeemedBalance(ctx, opts) + if err != nil { + return 0, errors.Wrap(err, "outstanding balance: redeemed query failed") + } + + if redeemed > issued { + return 0, errors.Errorf("outstanding balance: redeemed (%d) exceeds issued (%d)", redeemed, issued) + } + + return issued - redeemed, nil +} + +// filterTokens filters the source tokens by token type and wallet identity. +func (w *IssuerWallet) filterTokens(ctx context.Context, source *token.IssuedTokens, opts *driver.ListTokensOptions) *token.IssuedTokens { + result := &token.IssuedTokens{} + for _, t := range source.Tokens { + if len(opts.TokenType) != 0 && t.Type != opts.TokenType { + continue + } + if !w.Contains(ctx, t.Issuer) { + continue + } + result.Tokens = append(result.Tokens, t) + } + + return result +} + // CertifierWallet represents a wallet bounded to a single certifier // identity. It provides access to a signer and exposes whether a given // identity is the certifier for this wallet. @@ -379,8 +459,7 @@ func (w *LongTermOwnerWallet) ListTokens(ctx context.Context, opts *driver.ListT } // Balance returns the on-chain balance for the wallet for the given token -// type in the listing options. The result is returned as a *big.Int to support -// arbitrary precision and prevent overflow. +// type in the listing options. func (w *LongTermOwnerWallet) Balance(ctx context.Context, opts *driver.ListTokensOptions) (*big.Int, error) { balance, err := w.TokenVault.Balance(ctx, w.WalletID, opts.TokenType) if err != nil { diff --git a/token/services/identity/role/wallets_test.go b/token/services/identity/role/wallets_test.go index 598e97f393..d21c4cb1b2 100644 --- a/token/services/identity/role/wallets_test.go +++ b/token/services/identity/role/wallets_test.go @@ -155,6 +155,94 @@ func TestIssuerWallet(t *testing.T) { _, err = w.HistoryTokens(t.Context(), &driver.ListTokensOptions{}) require.Error(t, err) }) + + t.Run("IssuedBalance", func(t *testing.T) { + w, tv, _ := setup() + + // Happy path + tv.IssuedBalanceReturns(500, nil) + bal, err := w.IssuedBalance(t.Context(), &driver.BalanceOpts{TokenType: "USD"}) + require.NoError(t, err) + assert.Equal(t, uint64(500), bal) + + // Error propagation + tv.IssuedBalanceReturns(0, errors.New("db down")) + _, err = w.IssuedBalance(t.Context(), &driver.BalanceOpts{}) + require.Error(t, err) + }) + + t.Run("RedeemedTokens", func(t *testing.T) { + w, tv, _ := setup() + id := driver.Identity("issuerIdentity") + + tokens := &token.IssuedTokens{ + Tokens: []*token.IssuedToken{ + {Type: "USD", Quantity: "100", Issuer: id}, + {Type: "USD", Quantity: "200", Issuer: driver.Identity("other")}, + }, + } + tv.ListRedeemedTokensReturns(tokens, nil) + + // Filters out tokens from other issuers + res, err := w.RedeemedTokens(t.Context(), &driver.ListTokensOptions{TokenType: "USD"}) + require.NoError(t, err) + assert.Len(t, res.Tokens, 1) + assert.Equal(t, "100", res.Tokens[0].Quantity) + + // Error propagation + tv.ListRedeemedTokensReturns(nil, errors.New("vault error")) + _, err = w.RedeemedTokens(t.Context(), &driver.ListTokensOptions{}) + require.Error(t, err) + }) + + t.Run("RedeemedBalance", func(t *testing.T) { + w, tv, _ := setup() + + tv.RedeemedBalanceReturns(300, nil) + bal, err := w.RedeemedBalance(t.Context(), &driver.BalanceOpts{TokenType: "USD"}) + require.NoError(t, err) + assert.Equal(t, uint64(300), bal) + + // Error propagation + tv.RedeemedBalanceReturns(0, errors.New("db down")) + _, err = w.RedeemedBalance(t.Context(), &driver.BalanceOpts{}) + require.Error(t, err) + }) + + t.Run("OutstandingBalance", func(t *testing.T) { + w, tv, _ := setup() + + // Normal case: issued > redeemed + tv.IssuedBalanceReturns(1000, nil) + tv.RedeemedBalanceReturns(300, nil) + bal, err := w.OutstandingBalance(t.Context(), &driver.BalanceOpts{TokenType: "USD"}) + require.NoError(t, err) + assert.Equal(t, uint64(700), bal) + + // Edge case: issued == redeemed + tv.IssuedBalanceReturns(500, nil) + tv.RedeemedBalanceReturns(500, nil) + bal, err = w.OutstandingBalance(t.Context(), &driver.BalanceOpts{}) + require.NoError(t, err) + assert.Equal(t, uint64(0), bal) + + // Error case: redeemed > issued + tv.IssuedBalanceReturns(100, nil) + tv.RedeemedBalanceReturns(200, nil) + _, err = w.OutstandingBalance(t.Context(), &driver.BalanceOpts{}) + require.Error(t, err) + + // Error propagation from issued + tv.IssuedBalanceReturns(0, errors.New("issued fail")) + _, err = w.OutstandingBalance(t.Context(), &driver.BalanceOpts{}) + require.Error(t, err) + + // Error propagation from redeemed + tv.IssuedBalanceReturns(1000, nil) + tv.RedeemedBalanceReturns(0, errors.New("redeemed fail")) + _, err = w.OutstandingBalance(t.Context(), &driver.BalanceOpts{}) + require.Error(t, err) + }) } func TestLongTermOwnerWallet(t *testing.T) { diff --git a/token/services/identity/role/wfactory.go b/token/services/identity/role/wfactory.go index 5e357b1649..bc54c73201 100644 --- a/token/services/identity/role/wfactory.go +++ b/token/services/identity/role/wfactory.go @@ -9,6 +9,7 @@ package role import ( "context" "math/big" + "time" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" "github.com/hyperledger-labs/fabric-token-sdk/token/core/common/metrics" @@ -23,6 +24,9 @@ type TokenVault interface { UnspentTokensIteratorBy(ctx context.Context, id string, tokenType token.Type) (driver.UnspentTokensIterator, error) ListHistoryIssuedTokens(ctx context.Context) (*token.IssuedTokens, error) Balance(ctx context.Context, id string, tokenType token.Type) (*big.Int, error) + IssuedBalance(ctx context.Context, tokenType token.Type, issuerRaw driver.Identity, from, to *time.Time) (uint64, error) + ListRedeemedTokens(ctx context.Context, tokenType token.Type, issuerRaw driver.Identity, from, to *time.Time, sortBy driver.SortField, sortDirection driver.SortDirection) (*token.IssuedTokens, error) + RedeemedBalance(ctx context.Context, tokenType token.Type, issuerRaw driver.Identity, from, to *time.Time) (uint64, error) } //go:generate counterfeiter -o mock/wc.go -fake-name WalletsConfiguration . WalletsConfiguration diff --git a/token/services/storage/db/dbtest/tokens.go b/token/services/storage/db/dbtest/tokens.go index f0380cb174..51f1e6746c 100644 --- a/token/services/storage/db/dbtest/tokens.go +++ b/token/services/storage/db/dbtest/tokens.go @@ -74,6 +74,8 @@ var tokensCases = []struct { {"QueryTokenDetails", TQueryTokenDetails}, {"TTokenTypes", TTokenTypes}, {"ListUnspentTokensByWallets", TListUnspentTokensByWallets}, + {"IssuedBalance", TIssuedBalance}, + {"RedeemedBalance", TRedeemedBalance}, } func TTokenTransaction(t *testing.T, db TestTokenDB) { @@ -916,6 +918,12 @@ func TQueryTokenDetails(t *testing.T, db TestTokenDB) { require.NoError(t, err) assert.Equal(t, balance.Uint64(), res[0].Amount.Uint64()) + // empty result must return zero balance (not nil) + balance, err = db.Balance(ctx, "nobody", "ZZZ") + require.NoError(t, err) + require.NotNil(t, balance) + assert.Equal(t, uint64(0), balance.Uint64()) + // spent require.NoError(t, db.DeleteTokens(ctx, "delby", &token.ID{TxId: "tx2", Index: 1})) res, err = db.QueryTokenDetails(ctx, driver2.QueryTokenDetailsParams{}) @@ -1179,6 +1187,234 @@ func consumeSpendableTokensIterator(t *testing.T, it tdriver.SpendableTokensIter assert.Nil(t, tok) } +// TIssuedBalance tests that IssuedBalance counts only issuance roots and does not +// double count transfer lineage outputs that may carry issuer=true. +func TIssuedBalance(t *testing.T, db TestTokenDB) { + t.Helper() + ctx := t.Context() + + issuerA := tdriver.Identity([]byte{10, 20, 30}) + + // Issue roots created by tx issue-1 and issue-2 + require.NoError(t, db.StoreToken(ctx, driver2.TokenRecord{ + TxID: "issue-1", + Index: 0, + IssuerRaw: issuerA, + OwnerRaw: []byte{1}, + OwnerType: "idemix", + OwnerIdentity: []byte{}, + Ledger: []byte("ledger"), + LedgerMetadata: []byte{}, + Quantity: "0x6e", + Type: TST, + Amount: 110, + Issuer: true, + }, nil)) + require.NoError(t, db.StoreToken(ctx, driver2.TokenRecord{ + TxID: "issue-2", + Index: 0, + IssuerRaw: issuerA, + OwnerRaw: []byte{1}, + OwnerType: "idemix", + OwnerIdentity: []byte{}, + Ledger: []byte("ledger"), + LedgerMetadata: []byte{}, + Quantity: "0x0a", + Type: TST, + Amount: 10, + Issuer: true, + }, nil)) + + // Spend both roots in transfer tx-1. + require.NoError(t, db.DeleteTokens(ctx, "tx-1", &token.ID{TxId: "issue-1", Index: 0}, &token.ID{TxId: "issue-2", Index: 0})) + + // Transfer outputs may be marked issuer=true by some backends, but must not affect IssuedBalance. + require.NoError(t, db.StoreToken(ctx, driver2.TokenRecord{ + TxID: "tx-1", + Index: 0, + IssuerRaw: issuerA, + OwnerRaw: []byte{2}, + OwnerType: "idemix", + OwnerIdentity: []byte{}, + Ledger: []byte("ledger"), + LedgerMetadata: []byte{}, + Quantity: "0x6f", + Type: TST, + Amount: 111, + Issuer: true, + }, nil)) + require.NoError(t, db.StoreToken(ctx, driver2.TokenRecord{ + TxID: "tx-1", + Index: 1, + IssuerRaw: issuerA, + OwnerRaw: []byte{3}, + OwnerType: "idemix", + OwnerIdentity: []byte{}, + Ledger: []byte("ledger"), + LedgerMetadata: []byte{}, + Quantity: "0x09", + Type: TST, + Amount: 9, + Issuer: true, + }, nil)) + + // A later fresh issue root. + require.NoError(t, db.StoreToken(ctx, driver2.TokenRecord{ + TxID: "issue-3", + Index: 0, + IssuerRaw: issuerA, + OwnerRaw: []byte{4}, + OwnerType: "idemix", + OwnerIdentity: []byte{}, + Ledger: []byte("ledger"), + LedgerMetadata: []byte{}, + Quantity: "0x0a", + Type: TST, + Amount: 10, + Issuer: true, + }, nil)) + + balance, err := db.IssuedBalance(ctx, TST, issuerA, nil, nil) + require.NoError(t, err) + assert.Equal(t, uint64(130), balance) +} + +// TRedeemedBalance tests the RedeemedBalance method which calculates the +// total balance of issued tokens that have been redeemed (spent without a +// successor in the token store) for a given issuer identity and token type. +func TRedeemedBalance(t *testing.T, db TestTokenDB) { + t.Helper() + ctx := t.Context() + + issuerA := tdriver.Identity([]byte{10, 20, 30}) + issuerB := tdriver.Identity([]byte{40, 50, 60}) + + // Store burn tokens: these represent redeemed amounts stored during Parse(). + // A burn token is identified by issuer=true, is_deleted=true, spent_by=tx_id (self-referencing). + // We simulate this by storing the token and then deleting it with its own tx_id. + + // Burn 5 TST for issuerA + require.NoError(t, db.StoreToken(ctx, driver2.TokenRecord{ + TxID: "redeem-tx1", + Index: 0, + IssuerRaw: issuerA, + OwnerRaw: []byte{}, + OwnerType: "", + OwnerIdentity: []byte{}, + Ledger: []byte("ledger"), + LedgerMetadata: []byte{}, + Quantity: "0x05", + Type: TST, + Amount: 5, + Issuer: true, + }, nil)) + require.NoError(t, db.DeleteTokens(ctx, "redeem-tx1", &token.ID{TxId: "redeem-tx1", Index: 0})) + + // Initially: 5 redeemed for issuerA TST + balance, err := db.RedeemedBalance(ctx, TST, issuerA, nil, nil) + require.NoError(t, err) + assert.Equal(t, uint64(5), balance) + + // Burn 3 TST for issuerA + require.NoError(t, db.StoreToken(ctx, driver2.TokenRecord{ + TxID: "redeem-tx2", + Index: 0, + IssuerRaw: issuerA, + OwnerRaw: []byte{}, + OwnerType: "", + OwnerIdentity: []byte{}, + Ledger: []byte("ledger"), + LedgerMetadata: []byte{}, + Quantity: "0x03", + Type: TST, + Amount: 3, + Issuer: true, + }, nil)) + require.NoError(t, db.DeleteTokens(ctx, "redeem-tx2", &token.ID{TxId: "redeem-tx2", Index: 0})) + + // issuerA redeemed TST: 5 + 3 = 8 + balance, err = db.RedeemedBalance(ctx, TST, issuerA, nil, nil) + require.NoError(t, err) + assert.Equal(t, uint64(8), balance) + + // Burn 2 ABC for issuerA + require.NoError(t, db.StoreToken(ctx, driver2.TokenRecord{ + TxID: "redeem-tx3", + Index: 0, + IssuerRaw: issuerA, + OwnerRaw: []byte{}, + OwnerType: "", + OwnerIdentity: []byte{}, + Ledger: []byte("ledger"), + LedgerMetadata: []byte{}, + Quantity: "0x02", + Type: ABC, + Amount: 2, + Issuer: true, + }, nil)) + require.NoError(t, db.DeleteTokens(ctx, "redeem-tx3", &token.ID{TxId: "redeem-tx3", Index: 0})) + + // ABC for issuerA: 2 + balance, err = db.RedeemedBalance(ctx, ABC, issuerA, nil, nil) + require.NoError(t, err) + assert.Equal(t, uint64(2), balance) + + // Burn 7 TST for issuerB + require.NoError(t, db.StoreToken(ctx, driver2.TokenRecord{ + TxID: "redeem-tx4", + Index: 0, + IssuerRaw: issuerB, + OwnerRaw: []byte{}, + OwnerType: "", + OwnerIdentity: []byte{}, + Ledger: []byte("ledger"), + LedgerMetadata: []byte{}, + Quantity: "0x07", + Type: TST, + Amount: 7, + Issuer: true, + }, nil)) + require.NoError(t, db.DeleteTokens(ctx, "redeem-tx4", &token.ID{TxId: "redeem-tx4", Index: 0})) + + // issuerB TST: 7 + balance, err = db.RedeemedBalance(ctx, TST, issuerB, nil, nil) + require.NoError(t, err) + assert.Equal(t, uint64(7), balance) + + // Unknown issuer returns 0 + balance, err = db.RedeemedBalance(ctx, TST, tdriver.Identity([]byte{99}), nil, nil) + require.NoError(t, err) + assert.Equal(t, uint64(0), balance) + + // Empty token type sums across all types for issuerA: 5 + 3 + 2 = 10 + balance, err = db.RedeemedBalance(ctx, "", issuerA, nil, nil) + require.NoError(t, err) + assert.Equal(t, uint64(10), balance) + + // Partial redeem: bob redeems 11 from 111 (change 100 goes back). + // Only the burn (11) is stored as a self-referencing burn token. + require.NoError(t, db.StoreToken(ctx, driver2.TokenRecord{ + TxID: "redeem-partial", + Index: 0, + IssuerRaw: issuerA, + OwnerRaw: []byte{}, + OwnerType: "", + OwnerIdentity: []byte{}, + Ledger: []byte("ledger"), + LedgerMetadata: []byte{}, + Quantity: "0x0b", + Type: TST, + Amount: 11, + Issuer: true, + }, nil)) + require.NoError(t, db.DeleteTokens(ctx, "redeem-partial", &token.ID{TxId: "redeem-partial", Index: 0})) + + // issuerA TST: 5 + 3 + 11 = 19 + balance, err = db.RedeemedBalance(ctx, TST, issuerA, nil, nil) + require.NoError(t, err) + assert.Equal(t, uint64(19), balance) +} + func assertEqual(t *testing.T, r driver2.TokenRecord, d driver2.TokenDetails) { t.Helper() assert.Equal(t, r.TxID, d.TxID) diff --git a/token/services/storage/db/driver/token.go b/token/services/storage/db/driver/token.go index 462478626f..b8273519cf 100644 --- a/token/services/storage/db/driver/token.go +++ b/token/services/storage/db/driver/token.go @@ -202,6 +202,20 @@ type TokenStore interface { ListAuditTokens(ctx context.Context, ids ...*token.ID) ([]*token.Token, error) // ListHistoryIssuedTokens returns the list of all issued tokens ListHistoryIssuedTokens(ctx context.Context) (*token.IssuedTokens, error) + // IssuedBalance returns the sum of amounts of non-deleted tokens flagged as issued. + // If tokenType is non-empty, only tokens of that type are included. + // If issuerRaw is non-empty, only tokens issued by that identity are included. + IssuedBalance(ctx context.Context, tokenType token.Type, issuerRaw driver.Identity, from, to *time.Time) (uint64, error) + // ListRedeemedTokens returns issued tokens that were spent by a Redeem action. + // If tokenType is non-empty, only tokens of that type are included. + // If issuerRaw is non-empty, only tokens issued by that identity are included. + // If from/to are non-nil, only tokens stored within that time range are included. + ListRedeemedTokens(ctx context.Context, tokenType token.Type, issuerRaw driver.Identity, from, to *time.Time, sortBy driver.SortField, sortDirection driver.SortDirection) (*token.IssuedTokens, error) + // RedeemedBalance returns the sum of amounts of issued tokens spent by a Redeem action. + // If tokenType is non-empty, only tokens of that type are included. + // If issuerRaw is non-empty, only tokens issued by that identity are included. + // If from/to are non-nil, only tokens stored within that time range are included. + RedeemedBalance(ctx context.Context, tokenType token.Type, issuerRaw driver.Identity, from, to *time.Time) (uint64, error) // GetTokenOutputs returns the value of the tokens as they appear on the ledger for the passed ids. // For each token, the call-back function is invoked. The call-back function is invoked respecting the order of the passed ids. GetTokenOutputs(ctx context.Context, ids []*token.ID, callback driver.QueryCallbackFunc) error diff --git a/token/services/storage/db/sql/common/tokens.go b/token/services/storage/db/sql/common/tokens.go index cb5ffa0486..7adf923924 100644 --- a/token/services/storage/db/sql/common/tokens.go +++ b/token/services/storage/db/sql/common/tokens.go @@ -38,6 +38,7 @@ type tokenTables struct { PublicParams string Certifications string Requests string + Transactions string } type TokenStore struct { @@ -68,6 +69,7 @@ func NewTokenStoreWithNotifier(readDB, writeDB *sql.DB, tables TableNames, ci co PublicParams: tables.PublicParams, Certifications: tables.Certifications, Requests: tables.Requests, + Transactions: tables.Transactions, }, ci, notifier), nil } @@ -132,7 +134,7 @@ func (db *TokenStore) IsMine(ctx context.Context, txID string, index uint64) (bo Limit(1). Format(db.ci) - id, err := common.QueryUniqueContext[string](ctx, db.readDB, query, args...) + id, err := common.QueryUnique[string](db.readDB, query, args...) logger.DebugfContext(ctx, "token [%s:%d] is mine [%s]", txID, index, id) @@ -365,8 +367,7 @@ func (db *TokenStore) queryLedgerTokens(ctx context.Context, details driver.Quer }), nil } -// Balance returns the sum of the amounts of the tokens with type and EID equal to those passed as arguments. -// The result is returned as a *big.Int to support arbitrary precision and prevent overflow. +// Balance returns the sum of the amounts, with arbitrary precision, of the tokens with type and EID equal to those passed as arguments. func (db *TokenStore) Balance(ctx context.Context, walletID string, typ token.Type) (*big.Int, error) { return db.balance(ctx, driver.QueryTokenDetailsParams{ WalletID: walletID, @@ -384,20 +385,15 @@ func (db *TokenStore) balance(ctx context.Context, opts driver.QueryTokenDetails Where(HasTokenDetails(opts, tokenTable)). Format(db.ci) - var sum BigInt - err := db.readDB.QueryRowContext(ctx, query, args...).Scan(&sum) + sum, err := common.QueryUnique[*uint64](db.readDB, query, args...) if err != nil { - if err == sql.ErrNoRows { - return big.NewInt(0), nil - } - return nil, err } - if sum.Int == nil { + if sum == nil { return big.NewInt(0), nil } - return sum.Int, nil + return big.NewInt(0).SetUint64(*sum), nil } // ListUnspentTokensBy returns the list of unspent tokens, filtered by owner and token type @@ -610,6 +606,177 @@ func (db *TokenStore) ListHistoryIssuedTokens(ctx context.Context) (*token.Issue return &token.IssuedTokens{Tokens: tokens}, rows.Err() } +// IssuedBalance returns the total amount originally issued by this issuer. +// +// We count only issuance roots: tokens with issuer=true that were created by transactions +// with no input tokens in the local token table (no predecessor with spent_by = tx_id). +// This avoids double-counting transfer lineage tokens that may carry issuer=true in some backends. +// If tokenType is non-empty, only tokens of that type are included. +func (db *TokenStore) IssuedBalance(ctx context.Context, tokenType token.Type, issuerRaw tdriver.Identity, from, to *time.Time) (uint64, error) { + tokTable := q.Table(db.table.Tokens) + pred := q.AliasedTable(db.table.Tokens, "pred") + conds := []cond.Condition{ + cond.CmpVal(tokTable.Field("issuer"), "=", true), + isNull(pred.Field("tx_id")), + } + if len(tokenType) != 0 { + conds = append(conds, cond.CmpVal(tokTable.Field("token_type"), "=", tokenType)) + } + if len(issuerRaw) != 0 { + conds = append(conds, cond.CmpVal(tokTable.Field("issuer_raw"), "=", issuerRaw)) + } + if from != nil { + conds = append(conds, cond.CmpVal(tokTable.Field("stored_at"), ">=", from.UTC())) + } + if to != nil { + conds = append(conds, cond.CmpVal(tokTable.Field("stored_at"), "<=", to.UTC())) + } + query, args := q.Select(). + FieldsByName(common3.FieldName("SUM(" + db.table.Tokens + ".amount)")). + From(tokTable.JoinAs(common3.Left, pred, cond.Cmp(pred.Field("spent_by"), "=", tokTable.Field("tx_id")))). + Where(cond.And(conds...)). + Format(db.ci) + + logging.Debug(logger, query, args) + sum, err := common.QueryUnique[*uint64](db.readDB, query, args...) + if err != nil || sum == nil { + return 0, err + } + + return *sum, nil +} + +// nullCond is a condition that checks if a field IS NULL. +type nullCond struct { + f common3.Field +} + +func (c *nullCond) WriteString(_ common3.CondInterpreter, sb common3.Builder) { + sb.WriteSerializables(c.f).WriteString(" IS NULL") +} + +func isNull(f common3.Field) cond.Condition { + return &nullCond{f: f} +} + +// notNullCond is a condition that checks if a field IS NOT NULL. +type notNullCond struct { + f common3.Field +} + +func (c *notNullCond) WriteString(_ common3.CondInterpreter, sb common3.Builder) { + sb.WriteSerializables(c.f).WriteString(" IS NOT NULL") +} + +func isNotNull(f common3.Field) cond.Condition { + return ¬NullCond{f: f} +} + +// ListRedeemedTokens returns issued tokens that were spent by a Redeem action. +// A redeemed token is identified as: issuer=true, is_deleted=true, spent_by is set, +// and the spending tx_id has NO output tokens in the tokens table (because Parse() +// skips redeem outputs with empty owner). This avoids relying on the transactions +// table, which may not have entries on the issuer node. +func (db *TokenStore) ListRedeemedTokens(ctx context.Context, tokenType token.Type, issuerRaw tdriver.Identity, from, to *time.Time, sortBy tdriver.SortField, sortDirection tdriver.SortDirection) (*token.IssuedTokens, error) { + tokTable := q.Table(db.table.Tokens) + // Alias the same tokens table to check for output tokens of the spending tx. + t2 := q.AliasedTable(db.table.Tokens, "t2") + conds := []cond.Condition{ + cond.CmpVal(tokTable.Field("issuer"), "=", true), + cond.CmpVal(tokTable.Field("is_deleted"), "=", true), + isNotNull(tokTable.Field("spent_by")), + isNull(t2.Field("tx_id")), + } + if len(tokenType) != 0 { + conds = append(conds, cond.CmpVal(tokTable.Field("token_type"), "=", tokenType)) + } + if len(issuerRaw) != 0 { + conds = append(conds, cond.CmpVal(tokTable.Field("issuer_raw"), "=", issuerRaw)) + } + if from != nil { + conds = append(conds, cond.CmpVal(tokTable.Field("stored_at"), ">=", from.UTC())) + } + if to != nil { + conds = append(conds, cond.CmpVal(tokTable.Field("stored_at"), "<=", to.UTC())) + } + sel := q.Select(). + Fields( + tokTable.Field("tx_id"), tokTable.Field("idx"), + tokTable.Field("owner_raw"), tokTable.Field("token_type"), + tokTable.Field("quantity"), tokTable.Field("issuer_raw"), + tokTable.Field("is_deleted"), tokTable.Field("spent_by"), + ). + From(tokTable.JoinAs(common3.Left, t2, cond.Cmp(t2.Field("tx_id"), "=", tokTable.Field("spent_by")))). + Where(cond.And(conds...)) + var query string + var args []any + if sortBy == tdriver.SortByQuantity { + if sortDirection == tdriver.SortDescending { + query, args = sel.OrderBy(q.Desc(tokTable.Field("amount"))).Format(db.ci) + } else { + query, args = sel.OrderBy(q.Asc(tokTable.Field("amount"))).Format(db.ci) + } + } else { + query, args = sel.Format(db.ci) + } + + logging.Debug(logger, query, args) + rows, err := db.readDB.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + + it := common.NewIterator(rows, func(tok *token.IssuedToken) error { + return rows.Scan(&tok.Id.TxId, &tok.Id.Index, &tok.Owner, &tok.Type, &tok.Quantity, &tok.Issuer, &tok.IsSpent, &tok.SpentBy) + }) + tokens, err := iterators.ReadAllPointers(it) + if err != nil { + return nil, err + } + + return &token.IssuedTokens{Tokens: tokens}, rows.Err() +} + +// RedeemedBalance returns the amount burned by redeem operations for this issuer. +// RedeemedBalance returns the total amount burned by redeem operations for this issuer. +// +// Burn tokens are stored during transaction processing with issuer=true and immediately +// marked as deleted by their own tx (spent_by = tx_id). This self-referencing condition +// uniquely identifies burn records. +func (db *TokenStore) RedeemedBalance(ctx context.Context, tokenType token.Type, issuerRaw tdriver.Identity, from, to *time.Time) (uint64, error) { + tokTable := q.Table(db.table.Tokens) + conds := []cond.Condition{ + cond.CmpVal(tokTable.Field("issuer"), "=", true), + cond.CmpVal(tokTable.Field("is_deleted"), "=", true), + cond.Cmp(tokTable.Field("spent_by"), "=", tokTable.Field("tx_id")), + } + if len(issuerRaw) != 0 { + conds = append(conds, cond.CmpVal(tokTable.Field("issuer_raw"), "=", issuerRaw)) + } + if len(tokenType) != 0 { + conds = append(conds, cond.CmpVal(tokTable.Field("token_type"), "=", tokenType)) + } + if from != nil { + conds = append(conds, cond.CmpVal(tokTable.Field("stored_at"), ">=", from.UTC())) + } + if to != nil { + conds = append(conds, cond.CmpVal(tokTable.Field("stored_at"), "<=", to.UTC())) + } + query, args := q.Select(). + FieldsByName(common3.FieldName("SUM(" + db.table.Tokens + ".amount)")). + From(tokTable). + Where(cond.And(conds...)). + Format(db.ci) + + logging.Debug(logger, query, args) + sum, err := common.QueryUnique[*uint64](db.readDB, query, args...) + if err != nil || sum == nil { + return 0, err + } + + return *sum, nil +} + func (db *TokenStore) GetTokenOutputs(ctx context.Context, ids []*token.ID, callback tdriver.QueryCallbackFunc) error { tokens, err := db.getLedgerToken(ctx, ids) if err != nil { @@ -844,11 +1011,11 @@ func (db *TokenStore) QueryTokenDetails(ctx context.Context, params driver.Query } it := common.NewIterator(rows, func(td *driver.TokenDetails) error { - var amount BigInt - if err := rows.Scan(&td.TxID, &td.Index, &td.OwnerIdentity, &td.OwnerType, &td.OwnerEnrollment, &td.Type, &amount, &td.IsSpent, &td.SpentBy, &td.StoredAt); err != nil { + var amountRaw int64 + if err := rows.Scan(&td.TxID, &td.Index, &td.OwnerIdentity, &td.OwnerType, &td.OwnerEnrollment, &td.Type, &amountRaw, &td.IsSpent, &td.SpentBy, &td.StoredAt); err != nil { return err } - td.Amount = amount.Int + td.Amount = big.NewInt(amountRaw) return nil }) @@ -961,7 +1128,7 @@ func (db *TokenStore) PublicParams(ctx context.Context) ([]byte, error) { Limit(1). Format(db.ci) - return common.QueryUniqueContext[[]byte](ctx, db.readDB, query, args...) + return common.QueryUnique[[]byte](db.readDB, query, args...) } func (db *TokenStore) PublicParamsByHash(ctx context.Context, rawHash tdriver.PPHash) ([]byte, error) { @@ -971,7 +1138,7 @@ func (db *TokenStore) PublicParamsByHash(ctx context.Context, rawHash tdriver.PP Where(cond.Eq("raw_hash", rawHash)). Format(db.ci) - return common.QueryUniqueContext[[]byte](ctx, db.readDB, query, args...) + return common.QueryUnique[[]byte](db.readDB, query, args...) } func (db *TokenStore) StoreCertifications(ctx context.Context, certifications map[*token.ID][]byte) error { @@ -1009,7 +1176,7 @@ func (db *TokenStore) ExistsCertification(ctx context.Context, tokenID *token.ID Where(HasTokens("tx_id", "idx", tokenID)). Format(db.ci) - certification, err := common.QueryUniqueContext[[]byte](ctx, db.readDB, query, args...) + certification, err := common.QueryUnique[[]byte](db.readDB, query, args...) if err != nil { logger.Warnf("tried to check certification existence for token id %s, err %s", tokenID, err) @@ -1091,7 +1258,7 @@ func (db *TokenStore) GetSchema() string { CREATE TABLE IF NOT EXISTS %s ( tx_id TEXT NOT NULL, idx INT NOT NULL, - amount NUMERIC(78, 0) NOT NULL, + amount BIGINT NOT NULL, token_type TEXT NOT NULL, quantity TEXT NOT NULL, issuer_raw BYTEA, diff --git a/token/services/tokens/mock/token_store.go b/token/services/tokens/mock/token_store.go index ddd0aac0e5..286c3dd11c 100644 --- a/token/services/tokens/mock/token_store.go +++ b/token/services/tokens/mock/token_store.go @@ -5,6 +5,7 @@ import ( "context" "math/big" "sync" + "time" drivera "github.com/hyperledger-labs/fabric-token-sdk/token/driver" "github.com/hyperledger-labs/fabric-token-sdk/token/services/storage/db/driver" @@ -163,6 +164,23 @@ type FakeTokenStore struct { result1 bool result2 error } + IssuedBalanceStub func(context.Context, token.Type, drivera.Identity, *time.Time, *time.Time) (uint64, error) + issuedBalanceMutex sync.RWMutex + issuedBalanceArgsForCall []struct { + arg1 context.Context + arg2 token.Type + arg3 drivera.Identity + arg4 *time.Time + arg5 *time.Time + } + issuedBalanceReturns struct { + result1 uint64 + result2 error + } + issuedBalanceReturnsOnCall map[int]struct { + result1 uint64 + result2 error + } ListAuditTokensStub func(context.Context, ...*token.ID) ([]*token.Token, error) listAuditTokensMutex sync.RWMutex listAuditTokensArgsForCall []struct { @@ -190,6 +208,25 @@ type FakeTokenStore struct { result1 *token.IssuedTokens result2 error } + ListRedeemedTokensStub func(context.Context, token.Type, drivera.Identity, *time.Time, *time.Time, drivera.SortField, drivera.SortDirection) (*token.IssuedTokens, error) + listRedeemedTokensMutex sync.RWMutex + listRedeemedTokensArgsForCall []struct { + arg1 context.Context + arg2 token.Type + arg3 drivera.Identity + arg4 *time.Time + arg5 *time.Time + arg6 drivera.SortField + arg7 drivera.SortDirection + } + listRedeemedTokensReturns struct { + result1 *token.IssuedTokens + result2 error + } + listRedeemedTokensReturnsOnCall map[int]struct { + result1 *token.IssuedTokens + result2 error + } ListUnspentTokensStub func(context.Context) (*token.UnspentTokens, error) listUnspentTokensMutex sync.RWMutex listUnspentTokensArgsForCall []struct { @@ -298,6 +335,23 @@ type FakeTokenStore struct { result1 []driver.TokenDetails result2 error } + RedeemedBalanceStub func(context.Context, token.Type, drivera.Identity, *time.Time, *time.Time) (uint64, error) + redeemedBalanceMutex sync.RWMutex + redeemedBalanceArgsForCall []struct { + arg1 context.Context + arg2 token.Type + arg3 drivera.Identity + arg4 *time.Time + arg5 *time.Time + } + redeemedBalanceReturns struct { + result1 uint64 + result2 error + } + redeemedBalanceReturnsOnCall map[int]struct { + result1 uint64 + result2 error + } SetSupportedTokenFormatsStub func([]token.Format) error setSupportedTokenFormatsMutex sync.RWMutex setSupportedTokenFormatsArgsForCall []struct { @@ -1161,6 +1215,74 @@ func (fake *FakeTokenStore) IsMineReturnsOnCall(i int, result1 bool, result2 err }{result1, result2} } +func (fake *FakeTokenStore) IssuedBalance(arg1 context.Context, arg2 token.Type, arg3 drivera.Identity, arg4 *time.Time, arg5 *time.Time) (uint64, error) { + fake.issuedBalanceMutex.Lock() + ret, specificReturn := fake.issuedBalanceReturnsOnCall[len(fake.issuedBalanceArgsForCall)] + fake.issuedBalanceArgsForCall = append(fake.issuedBalanceArgsForCall, struct { + arg1 context.Context + arg2 token.Type + arg3 drivera.Identity + arg4 *time.Time + arg5 *time.Time + }{arg1, arg2, arg3, arg4, arg5}) + stub := fake.IssuedBalanceStub + fakeReturns := fake.issuedBalanceReturns + fake.recordInvocation("IssuedBalance", []interface{}{arg1, arg2, arg3, arg4, arg5}) + fake.issuedBalanceMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4, arg5) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeTokenStore) IssuedBalanceCallCount() int { + fake.issuedBalanceMutex.RLock() + defer fake.issuedBalanceMutex.RUnlock() + return len(fake.issuedBalanceArgsForCall) +} + +func (fake *FakeTokenStore) IssuedBalanceCalls(stub func(context.Context, token.Type, drivera.Identity, *time.Time, *time.Time) (uint64, error)) { + fake.issuedBalanceMutex.Lock() + defer fake.issuedBalanceMutex.Unlock() + fake.IssuedBalanceStub = stub +} + +func (fake *FakeTokenStore) IssuedBalanceArgsForCall(i int) (context.Context, token.Type, drivera.Identity, *time.Time, *time.Time) { + fake.issuedBalanceMutex.RLock() + defer fake.issuedBalanceMutex.RUnlock() + argsForCall := fake.issuedBalanceArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5 +} + +func (fake *FakeTokenStore) IssuedBalanceReturns(result1 uint64, result2 error) { + fake.issuedBalanceMutex.Lock() + defer fake.issuedBalanceMutex.Unlock() + fake.IssuedBalanceStub = nil + fake.issuedBalanceReturns = struct { + result1 uint64 + result2 error + }{result1, result2} +} + +func (fake *FakeTokenStore) IssuedBalanceReturnsOnCall(i int, result1 uint64, result2 error) { + fake.issuedBalanceMutex.Lock() + defer fake.issuedBalanceMutex.Unlock() + fake.IssuedBalanceStub = nil + if fake.issuedBalanceReturnsOnCall == nil { + fake.issuedBalanceReturnsOnCall = make(map[int]struct { + result1 uint64 + result2 error + }) + } + fake.issuedBalanceReturnsOnCall[i] = struct { + result1 uint64 + result2 error + }{result1, result2} +} + func (fake *FakeTokenStore) ListAuditTokens(arg1 context.Context, arg2 ...*token.ID) ([]*token.Token, error) { fake.listAuditTokensMutex.Lock() ret, specificReturn := fake.listAuditTokensReturnsOnCall[len(fake.listAuditTokensArgsForCall)] @@ -1290,6 +1412,76 @@ func (fake *FakeTokenStore) ListHistoryIssuedTokensReturnsOnCall(i int, result1 }{result1, result2} } +func (fake *FakeTokenStore) ListRedeemedTokens(arg1 context.Context, arg2 token.Type, arg3 drivera.Identity, arg4 *time.Time, arg5 *time.Time, arg6 drivera.SortField, arg7 drivera.SortDirection) (*token.IssuedTokens, error) { + fake.listRedeemedTokensMutex.Lock() + ret, specificReturn := fake.listRedeemedTokensReturnsOnCall[len(fake.listRedeemedTokensArgsForCall)] + fake.listRedeemedTokensArgsForCall = append(fake.listRedeemedTokensArgsForCall, struct { + arg1 context.Context + arg2 token.Type + arg3 drivera.Identity + arg4 *time.Time + arg5 *time.Time + arg6 drivera.SortField + arg7 drivera.SortDirection + }{arg1, arg2, arg3, arg4, arg5, arg6, arg7}) + stub := fake.ListRedeemedTokensStub + fakeReturns := fake.listRedeemedTokensReturns + fake.recordInvocation("ListRedeemedTokens", []interface{}{arg1, arg2, arg3, arg4, arg5, arg6, arg7}) + fake.listRedeemedTokensMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4, arg5, arg6, arg7) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeTokenStore) ListRedeemedTokensCallCount() int { + fake.listRedeemedTokensMutex.RLock() + defer fake.listRedeemedTokensMutex.RUnlock() + return len(fake.listRedeemedTokensArgsForCall) +} + +func (fake *FakeTokenStore) ListRedeemedTokensCalls(stub func(context.Context, token.Type, drivera.Identity, *time.Time, *time.Time, drivera.SortField, drivera.SortDirection) (*token.IssuedTokens, error)) { + fake.listRedeemedTokensMutex.Lock() + defer fake.listRedeemedTokensMutex.Unlock() + fake.ListRedeemedTokensStub = stub +} + +func (fake *FakeTokenStore) ListRedeemedTokensArgsForCall(i int) (context.Context, token.Type, drivera.Identity, *time.Time, *time.Time, drivera.SortField, drivera.SortDirection) { + fake.listRedeemedTokensMutex.RLock() + defer fake.listRedeemedTokensMutex.RUnlock() + argsForCall := fake.listRedeemedTokensArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5, argsForCall.arg6, argsForCall.arg7 +} + +func (fake *FakeTokenStore) ListRedeemedTokensReturns(result1 *token.IssuedTokens, result2 error) { + fake.listRedeemedTokensMutex.Lock() + defer fake.listRedeemedTokensMutex.Unlock() + fake.ListRedeemedTokensStub = nil + fake.listRedeemedTokensReturns = struct { + result1 *token.IssuedTokens + result2 error + }{result1, result2} +} + +func (fake *FakeTokenStore) ListRedeemedTokensReturnsOnCall(i int, result1 *token.IssuedTokens, result2 error) { + fake.listRedeemedTokensMutex.Lock() + defer fake.listRedeemedTokensMutex.Unlock() + fake.ListRedeemedTokensStub = nil + if fake.listRedeemedTokensReturnsOnCall == nil { + fake.listRedeemedTokensReturnsOnCall = make(map[int]struct { + result1 *token.IssuedTokens + result2 error + }) + } + fake.listRedeemedTokensReturnsOnCall[i] = struct { + result1 *token.IssuedTokens + result2 error + }{result1, result2} +} + func (fake *FakeTokenStore) ListUnspentTokens(arg1 context.Context) (*token.UnspentTokens, error) { fake.listUnspentTokensMutex.Lock() ret, specificReturn := fake.listUnspentTokensReturnsOnCall[len(fake.listUnspentTokensArgsForCall)] @@ -1797,6 +1989,74 @@ func (fake *FakeTokenStore) QueryTokenDetailsReturnsOnCall(i int, result1 []driv }{result1, result2} } +func (fake *FakeTokenStore) RedeemedBalance(arg1 context.Context, arg2 token.Type, arg3 drivera.Identity, arg4 *time.Time, arg5 *time.Time) (uint64, error) { + fake.redeemedBalanceMutex.Lock() + ret, specificReturn := fake.redeemedBalanceReturnsOnCall[len(fake.redeemedBalanceArgsForCall)] + fake.redeemedBalanceArgsForCall = append(fake.redeemedBalanceArgsForCall, struct { + arg1 context.Context + arg2 token.Type + arg3 drivera.Identity + arg4 *time.Time + arg5 *time.Time + }{arg1, arg2, arg3, arg4, arg5}) + stub := fake.RedeemedBalanceStub + fakeReturns := fake.redeemedBalanceReturns + fake.recordInvocation("RedeemedBalance", []interface{}{arg1, arg2, arg3, arg4, arg5}) + fake.redeemedBalanceMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4, arg5) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeTokenStore) RedeemedBalanceCallCount() int { + fake.redeemedBalanceMutex.RLock() + defer fake.redeemedBalanceMutex.RUnlock() + return len(fake.redeemedBalanceArgsForCall) +} + +func (fake *FakeTokenStore) RedeemedBalanceCalls(stub func(context.Context, token.Type, drivera.Identity, *time.Time, *time.Time) (uint64, error)) { + fake.redeemedBalanceMutex.Lock() + defer fake.redeemedBalanceMutex.Unlock() + fake.RedeemedBalanceStub = stub +} + +func (fake *FakeTokenStore) RedeemedBalanceArgsForCall(i int) (context.Context, token.Type, drivera.Identity, *time.Time, *time.Time) { + fake.redeemedBalanceMutex.RLock() + defer fake.redeemedBalanceMutex.RUnlock() + argsForCall := fake.redeemedBalanceArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5 +} + +func (fake *FakeTokenStore) RedeemedBalanceReturns(result1 uint64, result2 error) { + fake.redeemedBalanceMutex.Lock() + defer fake.redeemedBalanceMutex.Unlock() + fake.RedeemedBalanceStub = nil + fake.redeemedBalanceReturns = struct { + result1 uint64 + result2 error + }{result1, result2} +} + +func (fake *FakeTokenStore) RedeemedBalanceReturnsOnCall(i int, result1 uint64, result2 error) { + fake.redeemedBalanceMutex.Lock() + defer fake.redeemedBalanceMutex.Unlock() + fake.RedeemedBalanceStub = nil + if fake.redeemedBalanceReturnsOnCall == nil { + fake.redeemedBalanceReturnsOnCall = make(map[int]struct { + result1 uint64 + result2 error + }) + } + fake.redeemedBalanceReturnsOnCall[i] = struct { + result1 uint64 + result2 error + }{result1, result2} +} + func (fake *FakeTokenStore) SetSupportedTokenFormats(arg1 []token.Format) error { var arg1Copy []token.Format if arg1 != nil { diff --git a/token/services/tokens/tokens.go b/token/services/tokens/tokens.go index ce9f7737c3..1ec5837ba1 100644 --- a/token/services/tokens/tokens.go +++ b/token/services/tokens/tokens.go @@ -426,9 +426,40 @@ func (t *Service) Parse( // parse the outputs for _, output := range os.Outputs() { - // if this is a redeem, then skip + // if this is a redeem (burn) output if len(output.Token.Owner) == 0 { - logger.DebugfContext(ctx, "output [%s:%d] is a redeem", requestAnchor, output.Index) + // On the issuer node, store the burn so RedeemedBalance can track it. + issuerFlag := !output.Issuer.IsNone() && auth.Issued(ctx, output.Issuer, &output.Token) + if issuerFlag { + logger.DebugfContext(ctx, "output [%s:%d] is a redeem, storing for issuer tracking", requestAnchor, output.Index) + // Create a copy with non-nil Owner to satisfy DB NOT NULL constraint. + burnTok := token2.Token{ + Owner: []byte{}, + Type: output.Token.Type, + Quantity: output.Token.Quantity, + } + tta := TokenToAppend{ + TxID: string(requestAnchor), + Index: output.Index, + Tok: &burnTok, + TokenOnLedger: output.LedgerOutput, + TokenOnLedgerFormat: output.LedgerOutputFormat, + TokenOnLedgerMetadata: output.LedgerOutputMetadata, + Issuer: output.Issuer, + Precision: precision, + Flags: Flags{ + Mine: false, + Auditor: false, + Issuer: true, + }, + } + toAppend = append(toAppend, tta) + // Mark the burn as immediately spent (by its own tx) so that + // is_deleted=true AND spent_by=tx_id uniquely identifies burns. + toSpend = append(toSpend, &token2.ID{TxId: string(requestAnchor), Index: output.Index}) + } else { + logger.DebugfContext(ctx, "output [%s:%d] is a redeem", requestAnchor, output.Index) + } continue } diff --git a/token/token/token.go b/token/token/token.go index 6044b670db..579370ff68 100644 --- a/token/token/token.go +++ b/token/token/token.go @@ -61,6 +61,10 @@ type IssuedToken struct { Quantity string `json:"quantity,omitempty" protobuf:"bytes,3,opt,name=quantity,proto3"` // Issuer is the issuer of this token Issuer []byte + // IsSpent is true if the token has been spent (transferred or redeemed) + IsSpent bool `json:"is_spent,omitempty"` + // SpentBy is the transaction ID that consumed this token, if any + SpentBy string `json:"spent_by,omitempty"` } type IssuedTokens struct { diff --git a/token/vault.go b/token/vault.go index 9d33cf2f30..3906962437 100644 --- a/token/vault.go +++ b/token/vault.go @@ -123,6 +123,24 @@ func (q *QueryEngine) ListHistoryIssuedTokens(ctx context.Context) (*token.Issue return q.qe.ListHistoryIssuedTokens(ctx) } +// IssuedBalance returns the total amount of tokens issued by the given issuer that have not been redeemed, +// optionally filtered by token type and time range. +func (q *QueryEngine) IssuedBalance(ctx context.Context, tokenType token.Type, issuerRaw driver.Identity, from, to *time.Time) (uint64, error) { + return q.qe.IssuedBalance(ctx, tokenType, issuerRaw, from, to) +} + +// ListRedeemedTokens returns the list of redeemed tokens originally issued by the given issuer, +// optionally filtered by token type, time range, and sorted by the specified field and direction. +func (q *QueryEngine) ListRedeemedTokens(ctx context.Context, tokenType token.Type, issuerRaw driver.Identity, from, to *time.Time, sortBy driver.SortField, sortDirection driver.SortDirection) (*token.IssuedTokens, error) { + return q.qe.ListRedeemedTokens(ctx, tokenType, issuerRaw, from, to, sortBy, sortDirection) +} + +// RedeemedBalance returns the total amount of redeemed tokens originally issued by the given issuer, +// optionally filtered by token type and time range. +func (q *QueryEngine) RedeemedBalance(ctx context.Context, tokenType token.Type, issuerRaw driver.Identity, from, to *time.Time) (uint64, error) { + return q.qe.RedeemedBalance(ctx, tokenType, issuerRaw, from, to) +} + // PublicParams returns the public parameters stored in the vault func (q *QueryEngine) PublicParams(ctx context.Context) ([]byte, error) { return q.qe.PublicParams(ctx) diff --git a/token/wallet.go b/token/wallet.go index a9797826e5..9f1b2e2c54 100644 --- a/token/wallet.go +++ b/token/wallet.go @@ -10,6 +10,7 @@ package token import ( "context" "math/big" + "time" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" "github.com/hyperledger-labs/fabric-token-sdk/token/driver" @@ -37,6 +38,46 @@ func WithType(tokenType token.Type) ListTokensOption { } } +// WithSortBy returns a list token option that specifies the sort field and direction. +func WithSortBy(field driver.SortField, dir driver.SortDirection) ListTokensOption { + return func(o *ListTokensOptions) error { + o.SortBy = field + o.SortDirection = dir + + return nil + } +} + +// BalanceOpts contains options for balance queries (methods that return a scalar +// total rather than a list of tokens). Sort fields are intentionally excluded +// because the result is a single number. +type BalanceOpts = driver.BalanceOpts + +// BalanceOption is a functional option that configures a BalanceOpts struct. +// Use WithBalanceTokenType and WithTimeRange to construct values. +type BalanceOption func(*BalanceOpts) error + +// WithBalanceTokenType returns a BalanceOption that restricts the balance +// computation to the given token type. An empty token type means all types. +func WithBalanceTokenType(tokenType token.Type) BalanceOption { + return func(o *BalanceOpts) error { + o.TokenType = tokenType + + return nil + } +} + +// WithTimeRange returns a balance option that restricts the computation to +// tokens stored within [from, to]. Nil values are treated as unbounded. +func WithTimeRange(from, to *time.Time) BalanceOption { + return func(o *BalanceOpts) error { + o.From = from + o.To = to + + return nil + } +} + type IdentityConfiguration = driver.IdentityConfiguration // WalletManager defines the interface for managing wallets. @@ -293,12 +334,17 @@ func (o *OwnerWallet) ListUnspentTokensIterator(ctx context.Context, opts ...Lis // Balance returns the sum of the amounts of the tokens with type and EID equal to those passed as arguments. // The result is returned as a *big.Int to support arbitrary precision and prevent overflow. -func (o *OwnerWallet) Balance(ctx context.Context, opts ...ListTokensOption) (*big.Int, error) { - compiledOpts, err := CompileListTokensOption(opts...) +func (o *OwnerWallet) Balance(ctx context.Context, opts ...BalanceOption) (*big.Int, error) { + compiledOpts, err := CompileBalanceOption(opts...) if err != nil { return nil, err } - sum, err := o.w.Balance(ctx, compiledOpts) + driverOpts := &ListTokensOptions{ + TokenType: compiledOpts.TokenType, + From: compiledOpts.From, + To: compiledOpts.To, + } + sum, err := o.w.Balance(ctx, driverOpts) if err != nil { return nil, err } @@ -349,6 +395,50 @@ func (i *IssuerWallet) ListIssuedTokens(ctx context.Context, opts ...ListTokensO return i.w.HistoryTokens(ctx, compiledOpts) } +// IssuedBalance returns the total amount of non-redeemed tokens issued by this wallet. +// Options: WithBalanceTokenType, WithTimeRange +func (i *IssuerWallet) IssuedBalance(ctx context.Context, opts ...BalanceOption) (uint64, error) { + compiledOpts, err := CompileBalanceOption(opts...) + if err != nil { + return 0, err + } + + return i.w.IssuedBalance(ctx, compiledOpts) +} + +// ListRedeemedTokens returns the list of redeemed tokens originally issued by this wallet. +// Options: WithType +func (i *IssuerWallet) ListRedeemedTokens(ctx context.Context, opts ...ListTokensOption) (*token.IssuedTokens, error) { + compiledOpts, err := CompileListTokensOption(opts...) + if err != nil { + return nil, err + } + + return i.w.RedeemedTokens(ctx, compiledOpts) +} + +// RedeemedBalance returns the total amount of redeemed tokens originally issued by this wallet. +// Options: WithBalanceTokenType, WithTimeRange +func (i *IssuerWallet) RedeemedBalance(ctx context.Context, opts ...BalanceOption) (uint64, error) { + compiledOpts, err := CompileBalanceOption(opts...) + if err != nil { + return 0, err + } + + return i.w.RedeemedBalance(ctx, compiledOpts) +} + +// OutstandingBalance returns the net amount still in circulation: IssuedBalance − RedeemedBalance. +// Options: WithBalanceTokenType, WithTimeRange +func (i *IssuerWallet) OutstandingBalance(ctx context.Context, opts ...BalanceOption) (uint64, error) { + compiledOpts, err := CompileBalanceOption(opts...) + if err != nil { + return 0, err + } + + return i.w.OutstandingBalance(ctx, compiledOpts) +} + func CompileListTokensOption(opts ...ListTokensOption) (*driver.ListTokensOptions, error) { txOptions := &ListTokensOptions{} for _, opt := range opts { @@ -358,6 +448,21 @@ func CompileListTokensOption(opts ...ListTokensOption) (*driver.ListTokensOption } return &driver.ListTokensOptions{ - TokenType: txOptions.TokenType, + TokenType: txOptions.TokenType, + SortBy: txOptions.SortBy, + SortDirection: txOptions.SortDirection, }, nil } + +// CompileBalanceOption compiles variadic BalanceOption values into a single +// BalanceOpts struct ready to be passed to a driver-level balance method. +func CompileBalanceOption(opts ...BalanceOption) (*driver.BalanceOpts, error) { + bo := &driver.BalanceOpts{} + for _, opt := range opts { + if err := opt(bo); err != nil { + return nil, err + } + } + + return bo, nil +} diff --git a/token/wallet_test.go b/token/wallet_test.go index f48bfeff6c..e939409a73 100644 --- a/token/wallet_test.go +++ b/token/wallet_test.go @@ -11,6 +11,7 @@ import ( "context" "math/big" "testing" + "time" "github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors" "github.com/hyperledger-labs/fabric-token-sdk/token/driver" @@ -549,7 +550,7 @@ func TestOwnerWallet_Balance(t *testing.T) { wallet := &OwnerWallet{w: mockOW} ctx := context.Background() - balance, err := wallet.Balance(ctx, WithType("USD")) + balance, err := wallet.Balance(ctx, WithBalanceTokenType("USD")) require.NoError(t, err) assert.Equal(t, big.NewInt(1000), balance) @@ -600,6 +601,51 @@ func TestIssuerWallet_ListIssuedTokens(t *testing.T) { assert.Equal(t, expectedTokens, tokens) } +// TestIssuerWallet_ListIssuedTokens_WithSortBy verifies that WithSortBy passes the sort +// field and direction through to the driver. +func TestIssuerWallet_ListIssuedTokens_WithSortBy(t *testing.T) { + mockIW := &mock.IssuerWallet{} + expectedTokens := &token.IssuedTokens{} + mockIW.HistoryTokensReturns(expectedTokens, nil) + + wallet := &IssuerWallet{w: mockIW} + ctx := context.Background() + + tokens, err := wallet.ListIssuedTokens(ctx, WithType("USD"), WithSortBy(driver.SortByQuantity, driver.SortDescending)) + + require.NoError(t, err) + assert.Equal(t, expectedTokens, tokens) + + // Verify the compiled options include sort fields. + _, opts := mockIW.HistoryTokensArgsForCall(0) + assert.Equal(t, driver.SortByQuantity, opts.SortBy) + assert.Equal(t, driver.SortDescending, opts.SortDirection) +} + +// TestOwnerWallet_Balance_WithTimeRange verifies that WithTimeRange passes the +// time bounds through to the driver. +func TestOwnerWallet_Balance_WithTimeRange(t *testing.T) { + mockOW := &mock.OwnerWallet{} + mockOW.BalanceReturns(big.NewInt(500), nil) + + wallet := &OwnerWallet{w: mockOW} + ctx := context.Background() + + from := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + to := time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC) + + balance, err := wallet.Balance(ctx, WithBalanceTokenType("USD"), WithTimeRange(&from, &to)) + + require.NoError(t, err) + assert.Equal(t, big.NewInt(500), balance) + + // Verify the compiled options include time range. + _, opts := mockOW.BalanceArgsForCall(0) + assert.Equal(t, &from, opts.From) + assert.Equal(t, &to, opts.To) + assert.Equal(t, token.Type("USD"), opts.TokenType) +} + // TestWalletManager_OwnerWalletIDs_Error verifies error handling func TestWalletManager_OwnerWalletIDs_Error(t *testing.T) { mockWS := &mock.WalletService{} @@ -740,7 +786,7 @@ func TestOwnerWallet_Balance_Error(t *testing.T) { wallet := &OwnerWallet{w: mockOW} ctx := context.Background() - balance, err := wallet.Balance(ctx, WithType("USD")) + balance, err := wallet.Balance(ctx, WithBalanceTokenType("USD")) require.Error(t, err) assert.Equal(t, expectedErr, err)