Skip to content

Commit 1380eff

Browse files
committed
fix(tokens): always report a failed token delete
DBTransaction.DeleteToken only returned a Delete error when the preceding GetToken lookup had found the token. GetToken returns (nil, owners, nil) whenever the token is absent from the local store, which is the ordinary outcome for inputs that are not mine and, under graph hiding, for spent identifiers that were never stored locally. On that common path the code discarded a genuine Delete failure and returned nil, so a transient storage error let AppendValid report success and the surrounding transaction be recorded as processed while the spend was never applied. Delete is documented and implemented as idempotent, so marking an unknown token as spent is not an error and any error it returns is a real storage failure. Propagate it unconditionally, and keep the token-absent check only where it belongs: skipping the delete-token notification. Fixes #2182 Signed-off-by: AkramBitar <akram@il.ibm.com>
1 parent 2a9d181 commit 1380eff

3 files changed

Lines changed: 55 additions & 8 deletions

File tree

docs/services/tokens.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ The internal [Service](../../token/services/tokens/tokens.go) is responsible for
1212
* **Lifecycle Monitoring**: Notifying local listeners (via `events.Publisher`) when tokens are added or removed.
1313
* **Consistency**: Identifying and removing stale unspent tokens by cross-referencing local storage with the ledger via the [Network Service](../../token/services/network/network.go) (see `PruneInvalidUnspentTokens`).
1414

15+
### Marking Spent Tokens
16+
17+
`DBTransaction.DeleteToken` marks a spent token as deleted. Deletion is *idempotent*: a
18+
token that is not present in the local store — the ordinary case for inputs that are not
19+
mine, and for spent identifiers under graph hiding — is not an error, and no delete-token
20+
event is published for it. A failure reported by the underlying store is always returned,
21+
regardless of whether the token was found locally, so that a transaction is never recorded
22+
as processed while its spends were not applied.
23+
1524

1625
## Token Representations
1726

token/services/tokens/storage.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -135,22 +135,22 @@ func NewTransaction(notifier events.Publisher, tx *tokendb.Transaction, tmsID to
135135
}
136136

137137
// DeleteToken removes a single token from the database and notifies listeners.
138+
//
139+
// Delete is idempotent: marking an unknown token as spent is not an error, so a
140+
// failure returned by Delete always signals a real storage failure and is
141+
// propagated even when the token is not present in the local store. Swallowing
142+
// it there would let a lost spend be recorded as a processed transaction.
138143
func (t *DBTransaction) DeleteToken(ctx context.Context, tokenID token2.ID, deletedBy string) error {
139144
tok, owners, err := t.Tx.GetToken(ctx, tokenID, true)
140145
if err != nil {
141146
return errors.WithMessagef(err, "failed to get token [%s]", tokenID)
142147
}
143148

144-
err = t.Tx.Delete(ctx, tokenID, deletedBy)
145-
if err != nil {
146-
if tok == nil {
147-
logger.DebugfContext(ctx, "nothing further to delete for [%s]", tokenID)
148-
149-
return nil
150-
}
151-
149+
if err := t.Tx.Delete(ctx, tokenID, deletedBy); err != nil {
152150
return errors.WithMessagef(err, "failed to delete token [%s]", tokenID)
153151
}
152+
153+
// The token is not in the local store, so there are no owners to notify.
154154
if tok == nil {
155155
logger.DebugfContext(ctx, "nothing further to delete for [%s]", tokenID)
156156

token/services/tokens/storage_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,44 @@ func TestTransaction_DeleteToken_Error(t *testing.T) {
168168
assert.Error(t, err)
169169
}
170170

171+
// TestTransaction_DeleteToken_DeleteErrorWhenTokenAbsent checks that a Delete
172+
// failure is reported even when the token is not present in the local store,
173+
// which is the ordinary case for inputs that are not mine. Swallowing it would
174+
// let a transaction be recorded as processed while the spend was never applied.
175+
func TestTransaction_DeleteToken_DeleteErrorWhenTokenAbsent(t *testing.T) {
176+
ctx := context.Background()
177+
tmsID := token.TMSID{Network: "net", Channel: "ch", Namespace: "ns"}
178+
mockTx := &mock.FakeTokenStoreTransaction{}
179+
180+
tx, err := tokens.NewTransaction(nil, &tokendb.Transaction{TokenStoreTransaction: mockTx}, tmsID)
181+
require.NoError(t, err)
182+
183+
// token absent locally, but Delete hits a genuine storage failure
184+
mockTx.GetTokenReturns(nil, nil, nil)
185+
mockTx.DeleteReturns(assert.AnError)
186+
187+
err = tx.DeleteToken(ctx, token2.ID{TxId: "tx1", Index: 0}, "me")
188+
assert.ErrorIs(t, err, assert.AnError)
189+
}
190+
191+
// TestTransaction_DeleteToken_AbsentTokenIsNotAnError checks that deleting a
192+
// token that is not in the local store succeeds without notifying any owner.
193+
func TestTransaction_DeleteToken_AbsentTokenIsNotAnError(t *testing.T) {
194+
ctx := context.Background()
195+
tmsID := token.TMSID{Network: "net", Channel: "ch", Namespace: "ns"}
196+
mockTx := &mock.FakeTokenStoreTransaction{}
197+
pub := &mock.FakePublisher{}
198+
199+
tx, err := tokens.NewTransaction(pub, &tokendb.Transaction{TokenStoreTransaction: mockTx}, tmsID)
200+
require.NoError(t, err)
201+
202+
mockTx.GetTokenReturns(nil, nil, nil)
203+
204+
require.NoError(t, tx.DeleteToken(ctx, token2.ID{TxId: "tx1", Index: 0}, "me"))
205+
assert.Equal(t, 1, mockTx.DeleteCallCount())
206+
assert.Equal(t, 0, pub.PublishCallCount())
207+
}
208+
171209
func TestTransaction_SetSpendableBySupportedTokenTypes(t *testing.T) {
172210
ctx := context.Background()
173211
tmsID := token.TMSID{Network: "net", Channel: "ch", Namespace: "ns"}

0 commit comments

Comments
 (0)