Skip to content

Commit f4dedbf

Browse files
committed
Issue-2063 GetWalletID used to swallow every storage error and return an empty string issue
Signed-off-by: Effi-S <effi.szt@gmail.com>
1 parent 253b284 commit f4dedbf

8 files changed

Lines changed: 302 additions & 34 deletions

File tree

token/services/identity/driver/storage.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ type WalletID = string
8383
//
8484
//go:generate counterfeiter -o mock/wss.go -fake-name WalletStoreService . WalletStoreService
8585
type WalletStoreService interface {
86-
// GetWalletID fetches a walletID that is bound to the identity passed
86+
// GetWalletID fetches a walletID that is bound to the identity passed.
87+
// It returns an empty WalletID and no error if the identity has no stored
88+
// binding; a non-nil error indicates a genuine storage failure and must not
89+
// be interpreted by callers as "no binding".
8790
GetWalletID(ctx context.Context, identity token.Identity, roleID int) (WalletID, error)
8891
// GetWalletIDs fetches all walletID's that have been stored so far without duplicates
8992
GetWalletIDs(ctx context.Context, roleID int) ([]WalletID, error)

token/services/identity/role/registry.go

Lines changed: 137 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,87 @@ type WalletFactory interface {
2222
NewWallet(ctx context.Context, id idriver.WalletID, role idriver.IdentityRoleType, is IdentitySupport, info idriver.IdentityInfo) (driver.Wallet, error)
2323
}
2424

25+
// WalletIDStatus classifies the outcome of resolving an identity to the wallet id
26+
// bound to it. It exists so callers branch on an explicit, named state instead of
27+
// re-deriving intent from the ambiguous "(string, error)" shape — where ("", nil),
28+
// ("", err) and ("id", nil) each mean something different and the difference is easy
29+
// to get wrong (see issue #2063).
30+
type WalletIDStatus int
31+
32+
const (
33+
// WalletIDUnknown is the zero value and never returned by GetWalletID. It is the
34+
// guard against a WalletIDResolution constructed without going through GetWalletID
35+
// (a mock, or a future constructor that forgets to set Status): because callers act
36+
// on a fallthrough only when authoritative() is true — Bound or Unbound — this zero
37+
// value is treated as a lookup failure, not as a safe "no binding" that would create
38+
// a duplicate wallet.
39+
WalletIDUnknown WalletIDStatus = iota
40+
// WalletIDBound means storage holds a wallet id for the identity. WalletID is set.
41+
WalletIDBound
42+
// WalletIDUnbound means storage answered authoritatively that the identity has no
43+
// wallet binding. This is a definitive, successful miss: it is safe to fall through
44+
// to the next resolution step and, ultimately, to create a wallet.
45+
WalletIDUnbound
46+
// WalletIDFailed means the storage lookup itself failed (timeout, connection reset,
47+
// ...), so whether a binding exists is UNKNOWN. Err carries the cause. Callers MUST
48+
// NOT treat this as WalletIDUnbound: doing so lets a transient blip masquerade as an
49+
// unregistered identity and triggers the creation of a duplicate wallet.
50+
WalletIDFailed
51+
)
52+
53+
// WalletIDResolution is the explicit result of resolving an identity to its bound
54+
// wallet id. It is the single shared value every wallet-lookup fallback branches on,
55+
// so the "not found" vs "could not check" distinction is decided once (in GetWalletID)
56+
// rather than re-inferred at each call site.
57+
type WalletIDResolution struct {
58+
// Status is the outcome of the lookup. Always inspect it via Bound/Unbound/Failed
59+
// before reading the other fields, and branch exhaustively: any status that is
60+
// neither Bound nor Unbound (Failed, or the WalletIDUnknown zero value) is not
61+
// authoritative and must abort the lookup rather than fall through to creation.
62+
Status WalletIDStatus
63+
// WalletID is the bound wallet id; meaningful only when Status is WalletIDBound.
64+
WalletID idriver.WalletID
65+
// Err is the underlying storage failure; set only when Status is WalletIDFailed.
66+
Err error
67+
}
68+
69+
// Bound reports whether the identity has a wallet id bound to it.
70+
func (r WalletIDResolution) Bound() bool { return r.Status == WalletIDBound }
71+
72+
// Unbound reports whether storage answered authoritatively that the identity has no
73+
// wallet binding. This is the ONLY non-Bound state that a caller may act on by falling
74+
// through to the next resolution step and, ultimately, wallet creation. Every other
75+
// state — Failed, or the zero value produced by a WalletIDResolution built without
76+
// going through GetWalletID — leaves the binding unknown and must abort the lookup.
77+
func (r WalletIDResolution) Unbound() bool { return r.Status == WalletIDUnbound }
78+
79+
// Failed reports whether the storage lookup failed, leaving the binding unknown.
80+
// A failed resolution must abort the enclosing lookup, never fall through to creation.
81+
func (r WalletIDResolution) Failed() bool { return r.Status == WalletIDFailed }
82+
83+
// authoritative reports whether the resolution definitively answers whether a wallet is
84+
// bound — i.e. it came back from GetWalletID as Bound or Unbound. Any other status
85+
// (Failed, or the zero-value WalletIDUnknown of a resolution built outside GetWalletID)
86+
// is non-authoritative: the binding is unknown and the caller MUST abort rather than
87+
// fall through to wallet creation. This is the guard the WalletIDUnknown zero value was
88+
// introduced to provide.
89+
func (r WalletIDResolution) authoritative() bool { return r.Bound() || r.Unbound() }
90+
91+
// abortError returns the error a non-authoritative resolution must abort a wallet lookup
92+
// with. It preserves the storage cause for a WalletIDFailed and synthesizes one for a
93+
// zero-value / unknown resolution (whose Err is nil), so an unknown status can never
94+
// collapse into a nil error — via errors.WithMessagef(nil, ...) returning nil — and be
95+
// silently mistaken for a successful lookup. It must only be called once Bound and
96+
// Unbound have been ruled out (i.e. authoritative() is false).
97+
func (r WalletIDResolution) abortError(id driver.WalletLookupID) error {
98+
cause := r.Err
99+
if cause == nil {
100+
cause = errors.Errorf("non-authoritative wallet id resolution status [%d]", r.Status)
101+
}
102+
103+
return errors.WithMessagef(cause, "failed to lookup wallet [%s]", id)
104+
}
105+
25106
// Registry manages wallets whose long-term identities have a given role.
26107
//
27108
// Concurrency and invariants:
@@ -82,11 +163,17 @@ func (r *Registry) Lookup(ctx context.Context, id driver.WalletLookupID) (driver
82163
if ok {
83164
r.Logger.DebugfContext(ctx, "lookup failed, check if there is a wallet for identity [%s]", passedIdentity)
84165
// is this identity registered
85-
wID, err := r.GetWalletID(ctx, passedIdentity)
86-
if err == nil && len(wID) != 0 {
87-
r.Logger.DebugfContext(ctx, "lookup failed, there is a wallet for identity [%s]: [%s]", passedIdentity, wID)
166+
res := r.GetWalletID(ctx, passedIdentity)
167+
if !res.authoritative() {
168+
// A storage failure — or a resolution that never went through GetWalletID —
169+
// leaves the binding unknown; it must not be treated as "not registered", or
170+
// a transient blip would fall through to wallet creation and duplicate state.
171+
return nil, nil, "", res.abortError(id)
172+
}
173+
if res.Bound() {
174+
r.Logger.DebugfContext(ctx, "lookup failed, there is a wallet for identity [%s]: [%s]", passedIdentity, res.WalletID)
88175
// we got a hit
89-
walletID = wID
176+
walletID = res.WalletID
90177
ident = passedIdentity
91178
fail = false
92179
}
@@ -111,36 +198,48 @@ func (r *Registry) Lookup(ctx context.Context, id driver.WalletLookupID) (driver
111198
if ok {
112199
r.Logger.DebugfContext(ctx, "no wallet found, check if there is a wallet for identity [%s]", passedIdentity)
113200
// is this identity registered
114-
passedWalletID, err := r.GetWalletID(ctx, passedIdentity)
115-
if err == nil && len(passedWalletID) != 0 {
116-
r.Logger.DebugfContext(ctx, "no wallet found, there is a wallet for identity [%s]: [%s]", passedIdentity, passedWalletID)
201+
res := r.GetWalletID(ctx, passedIdentity)
202+
if !res.authoritative() {
203+
// A storage failure — or a resolution that never went through GetWalletID —
204+
// leaves the binding unknown; it must not be treated as "not registered", or
205+
// a transient blip would fall through to wallet creation and duplicate state.
206+
return nil, nil, "", res.abortError(id)
207+
}
208+
if res.Bound() {
209+
r.Logger.DebugfContext(ctx, "no wallet found, there is a wallet for identity [%s]: [%s]", passedIdentity, res.WalletID)
117210
// we got a hit
118211
r.WalletMu.RLock()
119-
walletEntry, ok = r.Wallets[passedWalletID]
212+
walletEntry, ok = r.Wallets[res.WalletID]
120213
r.WalletMu.RUnlock()
121214
if ok {
122-
return walletEntry, nil, passedWalletID, nil
215+
return walletEntry, nil, res.WalletID, nil
123216
}
124-
r.Logger.DebugfContext(ctx, "no wallet found, there is a wallet for identity [%s]: [%s] but it has not been recreated yet", passedIdentity, passedWalletID)
217+
r.Logger.DebugfContext(ctx, "no wallet found, there is a wallet for identity [%s]: [%s] but it has not been recreated yet", passedIdentity, res.WalletID)
125218
}
126-
walletIdentifiers = append(walletIdentifiers, passedWalletID)
219+
walletIdentifiers = append(walletIdentifiers, res.WalletID)
127220
}
128221

129222
r.Logger.DebugfContext(ctx, "no wallet found for [%s] at [%s]", passedIdentity, logging.Prefix(wID))
130223
if len(ident) != 0 {
131-
identityWID, err := r.GetWalletID(ctx, ident)
132-
r.Logger.DebugfContext(ctx, "wallet for identity [%s] -> [%s:%s]", ident, identityWID, err)
133-
if err == nil && len(identityWID) != 0 {
224+
res := r.GetWalletID(ctx, ident)
225+
r.Logger.DebugfContext(ctx, "wallet for identity [%s] -> [%s:%d]", ident, res.WalletID, res.Status)
226+
if !res.authoritative() {
227+
// A storage failure — or a resolution that never went through GetWalletID —
228+
// leaves the binding unknown; it must not be treated as "not registered", or
229+
// a transient blip would fall through to wallet creation and duplicate state.
230+
return nil, nil, "", res.abortError(id)
231+
}
232+
if res.Bound() {
134233
r.WalletMu.RLock()
135-
w, ok := r.Wallets[identityWID]
234+
w, ok := r.Wallets[res.WalletID]
136235
r.WalletMu.RUnlock()
137236
if ok {
138-
r.Logger.DebugfContext(ctx, "found wallet [%s:%s:%s:%s]", ident, walletID, w.ID(), identityWID)
237+
r.Logger.DebugfContext(ctx, "found wallet [%s:%s:%s:%s]", ident, walletID, w.ID(), res.WalletID)
139238

140-
return w, nil, identityWID, nil
239+
return w, nil, res.WalletID, nil
141240
}
142241
}
143-
walletIdentifiers = append(walletIdentifiers, identityWID)
242+
walletIdentifiers = append(walletIdentifiers, res.WalletID)
144243
}
145244

146245
for _, walletIdentifier := range walletIdentifiers {
@@ -233,16 +332,31 @@ func (r *Registry) GetIdentityMetadata(ctx context.Context, identity driver.Iden
233332
return json.Unmarshal(raw, &meta)
234333
}
235334

236-
// GetWalletID returns the wallet identifier bound to the passed identity
237-
func (r *Registry) GetWalletID(ctx context.Context, identity driver.Identity) (string, error) {
335+
// GetWalletID resolves the wallet identifier bound to the passed identity.
336+
//
337+
// It is the single point that translates the storage layer's (WalletID, error)
338+
// convention into an explicit WalletIDResolution, so no caller has to re-derive the
339+
// meaning of ("", nil) vs ("", err). The storage contract reports an unbound identity
340+
// as ("", nil); a non-nil error is a genuine storage failure (timeout, connection
341+
// reset, ...) whose result is therefore WalletIDFailed, never WalletIDUnbound. Keeping
342+
// the two apart here is what prevents a transient blip from looking like an
343+
// unregistered identity and triggering the creation of a duplicate wallet (issue #2063).
344+
func (r *Registry) GetWalletID(ctx context.Context, identity driver.Identity) WalletIDResolution {
238345
wID, err := r.Storage.GetWalletID(ctx, identity, int(r.Role.ID()))
239346
if err != nil {
240-
//nolint:nilerr
241-
return "", nil
347+
return WalletIDResolution{
348+
Status: WalletIDFailed,
349+
Err: errors.Wrapf(err, "failed to get wallet id for identity [%s]", identity),
350+
}
351+
}
352+
if len(wID) == 0 {
353+
r.Logger.DebugfContext(ctx, "no wallet bound to identity [%s]", identity)
354+
355+
return WalletIDResolution{Status: WalletIDUnbound}
242356
}
243357
r.Logger.DebugfContext(ctx, "wallet [%s] is bound to identity [%s]", wID, identity)
244358

245-
return wID, nil
359+
return WalletIDResolution{Status: WalletIDBound, WalletID: wID}
246360
}
247361

248362
func (r *Registry) WalletByID(ctx context.Context, role idriver.IdentityRoleType, id driver.WalletLookupID) (driver.Wallet, error) {

token/services/identity/role/registry_test.go

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -135,17 +135,100 @@ func TestBindIdentityAndContainsAndMetadataAndGetWalletID(t *testing.T) {
135135
require.NoError(t, reg.GetIdentityMetadata(ctx, []byte("id"), "w", &meta))
136136
require.Equal(t, "v", meta["k"])
137137

138-
// GetWalletID when storage returns value
138+
// GetWalletID when storage returns a bound wallet id.
139139
storage.GetWalletIDReturns("w", nil)
140-
wid, err := reg.GetWalletID(ctx, []byte("id"))
141-
require.NoError(t, err)
142-
require.Equal(t, "w", wid)
143-
144-
// GetWalletID when storage returns error -> suppressed to empty string
140+
res := reg.GetWalletID(ctx, []byte("id"))
141+
require.Equal(t, role.WalletIDBound, res.Status)
142+
require.True(t, res.Bound())
143+
require.NoError(t, res.Err)
144+
require.Equal(t, "w", res.WalletID)
145+
146+
// GetWalletID when storage authoritatively reports no binding -> WalletIDUnbound,
147+
// a clean miss with no error and no wallet id.
148+
storage.GetWalletIDReturns("", nil)
149+
resUnbound := reg.GetWalletID(ctx, []byte("id"))
150+
require.Equal(t, role.WalletIDUnbound, resUnbound.Status)
151+
require.False(t, resUnbound.Bound())
152+
require.True(t, resUnbound.Unbound())
153+
require.False(t, resUnbound.Failed())
154+
require.NoError(t, resUnbound.Err)
155+
require.Empty(t, resUnbound.WalletID)
156+
157+
// GetWalletID when storage fails -> WalletIDFailed with the error preserved, so a
158+
// transient storage blip cannot masquerade as "no binding".
145159
storage.GetWalletIDReturns("", errors.New("boom"))
146-
wid2, err2 := reg.GetWalletID(ctx, []byte("id"))
147-
require.NoError(t, err2)
148-
require.Empty(t, wid2)
160+
resFailed := reg.GetWalletID(ctx, []byte("id"))
161+
require.Equal(t, role.WalletIDFailed, resFailed.Status)
162+
require.True(t, resFailed.Failed())
163+
require.False(t, resFailed.Bound())
164+
require.False(t, resFailed.Unbound())
165+
require.Error(t, resFailed.Err)
166+
require.Empty(t, resFailed.WalletID)
167+
}
168+
169+
// TestWalletIDResolution_ZeroValueIsNotAuthoritative guards the WalletIDUnknown zero
170+
// value: a WalletIDResolution built without going through GetWalletID (a mock, or a
171+
// future constructor that forgets to set Status) must be indistinguishable from a
172+
// failure at every call site, never from an authoritative "unbound" miss. Callers act
173+
// on a fallthrough only when a resolution is Bound or Unbound, so the zero value —
174+
// being neither — is treated as a lookup failure rather than a safe "create a wallet".
175+
func TestWalletIDResolution_ZeroValueIsNotAuthoritative(t *testing.T) {
176+
var zero role.WalletIDResolution
177+
178+
require.Equal(t, role.WalletIDUnknown, zero.Status)
179+
require.False(t, zero.Bound(), "zero value must not read as a bound wallet id")
180+
require.False(t, zero.Unbound(), "zero value must not read as an authoritative miss")
181+
require.False(t, zero.Failed(), "zero value carries no storage error")
182+
require.NoError(t, zero.Err)
183+
require.Empty(t, zero.WalletID)
184+
}
185+
186+
// TestLookup_StorageErrorDoesNotFallThrough pins the fix for #2063: a transient
187+
// storage error out of GetWalletID must abort Lookup rather than be swallowed as
188+
// "no binding". Otherwise the fallback chain would fall through to wallet creation
189+
// and persist a duplicate wallet for an identity that already has one.
190+
func TestLookup_StorageErrorDoesNotFallThrough(t *testing.T) {
191+
t.Run("MapToIdentity fails, view-identity fallback hits storage error", func(t *testing.T) {
192+
reg, storage, role, wf := newRegistryWithFakes()
193+
ctx := t.Context()
194+
195+
role.MapToIdentityReturns(nil, "", errors.New("no mapping"))
196+
storage.GetWalletIDReturns("", errors.New("transient DB blip"))
197+
198+
_, _, _, err := reg.Lookup(ctx, []byte("id-with-binding"))
199+
require.Error(t, err)
200+
require.Equal(t, 0, wf.NewWalletCallCount())
201+
})
202+
203+
t.Run("MapToIdentity succeeds, cache miss, view-identity fallback hits storage error", func(t *testing.T) {
204+
reg, storage, role, wf := newRegistryWithFakes()
205+
ctx := t.Context()
206+
207+
// mapping resolves to a wallet id that is not in the cache, so Lookup falls
208+
// through to the identity->wallet storage probe, which fails transiently.
209+
role.MapToIdentityReturns([]byte("id-with-binding"), "w-not-cached", nil)
210+
storage.GetWalletIDReturns("", errors.New("transient DB blip"))
211+
212+
_, _, _, err := reg.Lookup(ctx, []byte("id-with-binding"))
213+
require.Error(t, err)
214+
require.Equal(t, 0, wf.NewWalletCallCount())
215+
})
216+
}
217+
218+
// TestWalletByID_StorageErrorDoesNotCreateDuplicate ensures that when the cache
219+
// misses and the storage lookup fails transiently, WalletByID surfaces the error
220+
// instead of creating a brand-new wallet (the duplicate-wallet bug of #2063).
221+
func TestWalletByID_StorageErrorDoesNotCreateDuplicate(t *testing.T) {
222+
reg, storage, role, wf := newRegistryWithFakes()
223+
ctx := t.Context()
224+
225+
role.MapToIdentityReturns(nil, "", errors.New("no mapping"))
226+
storage.GetWalletIDReturns("", errors.New("transient DB blip"))
227+
228+
w, err := reg.WalletByID(ctx, 0, []byte("id-with-binding"))
229+
require.Error(t, err)
230+
require.Nil(t, w)
231+
require.Equal(t, 0, wf.NewWalletCallCount())
149232
}
150233

151234
func TestWalletIDs_MergesRoleAndStorage(t *testing.T) {

token/services/storage/db/kvs/walletdb.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,16 @@ func (s *WalletStore) GetWalletID(ctx context.Context, identity driver2.Identity
8585
if err != nil {
8686
return "", errors.Wrapf(err, "failed to create key")
8787
}
88+
// kvs.Get returns an error both for a missing key and for a real store failure.
89+
// The WalletStoreService contract requires that "no binding" be reported as ("", nil)
90+
// so that callers can distinguish it from a transient storage error; probe with Exists
91+
// first (as IdentityExists does) and only Get when a binding is present.
92+
if !s.kvs.Exists(ctx, k) {
93+
return "", nil
94+
}
8895
var wID storage.WalletID
8996
if err := s.kvs.Get(ctx, k, &wID); err != nil {
90-
return "", err
97+
return "", errors.Wrapf(err, "failed to get wallet id for identity [%v]", idHash)
9198
}
9299

93100
return wID, nil

token/services/storage/db/kvs/walletdb_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,34 @@ func TestWalletStoreGetConfID(t *testing.T) {
5050
require.NoError(t, err)
5151
assert.Empty(t, got)
5252
}
53+
54+
// TestWalletStoreGetWalletID asserts the not-found contract that the role Registry relies on to
55+
// tell a transient storage error apart from "this identity has no binding": an unbound identity
56+
// must resolve to ("", nil), and a bound identity must round-trip its wallet id. If GetWalletID
57+
// returned an error for a missing key (as kvs.Get does), the registry would abort every lookup
58+
// for a genuinely-unregistered identity instead of creating its wallet.
59+
func TestWalletStoreGetWalletID(t *testing.T) {
60+
backend, err := NewInMemory()
61+
require.NoError(t, err)
62+
// NewInMemory shares a global in-memory backing store across the package's tests, so use a
63+
// tmsID and identities unique to this test to stay isolated from any other stored bindings.
64+
tmsID := token.TMSID{Network: "getwalletid", Channel: "getwalletid", Namespace: "getwalletid"}
65+
db := NewWalletStore(backend, tmsID)
66+
ctx := t.Context()
67+
68+
// miss: never bound -> ("", nil), NOT an error
69+
got, err := db.GetWalletID(ctx, []byte("gwid-grace"), 0)
70+
require.NoError(t, err)
71+
assert.Empty(t, got)
72+
73+
// bound under role 0 -> round-trips the wallet id
74+
require.NoError(t, db.StoreIdentity(ctx, []byte("gwid-grace"), "eID", "grace_wallet", 0, nil, "conf-1"))
75+
got, err = db.GetWalletID(ctx, []byte("gwid-grace"), 0)
76+
require.NoError(t, err)
77+
assert.Equal(t, "grace_wallet", got)
78+
79+
// the same identity under a different role is still an independent miss
80+
got, err = db.GetWalletID(ctx, []byte("gwid-grace"), 1)
81+
require.NoError(t, err)
82+
assert.Empty(t, got)
83+
}

0 commit comments

Comments
 (0)