Summary
When sending multiple token transfers from the same sender to any recipient, only the last transfer survives on the Nostr relay. All previous transfers are silently replaced and permanently lost. Additionally, a secondary timestamp comparison bug in Sphere GUI can drop events that arrive within the same second.
These two bugs were discovered while investigating why only 1 of 6 UCT tokens sent from user cryptohog-aux to babaika10 was received.
Root Cause Analysis
Bug 1: Nostr Parameterized Replaceable Event Collision (Critical — Data Loss)
Affected components: @unicitylabs/nostr-js-sdk, sphere-sdk, sphere (GUI)
TOKEN_TRANSFER is defined as kind 31113 (EventKinds.js), which falls in the Nostr parameterized replaceable event range (30000–39999). Per NIP-01, relays keep only the latest event for each unique combination of (kind, pubkey, d-tag).
Both Sphere SDK and Sphere GUI hardcode the d-tag to a fixed string 'token-transfer':
Sphere SDK (transport/NostrTransportProvider.ts, ~line 623):
const event = await this.createEncryptedEvent(
EVENT_KINDS.TOKEN_TRANSFER,
content,
[
['p', recipientPubkey],
['d', 'token-transfer'], // ← Fixed d-tag
['type', 'token_transfer'],
]
);
Sphere GUI (src/components/wallet/L3/services/NostrService.ts):
The same fixed ['d', 'token-transfer'] tag is used when publishing transfer events.
Consequence: Every token transfer from the same sender occupies the same replaceable slot on the relay. When sender A sends 6 tokens to recipient B, each subsequent transfer replaces the previous one. Only the 6th (last) transfer remains on the relay. The first 5 are permanently deleted by the relay.
Fix: Use a unique d-tag per transfer, e.g., the token ID:
['d', `token-transfer:${tokenId}`]
Bug 2: Off-by-one Timestamp Filter (Medium — Event Dropping)
Affected component: Sphere GUI only
In src/components/wallet/L3/services/NostrService.ts (line 128), the incoming event filter uses <= instead of <:
if (event.created_at <= currentLastSync) {
console.log(`⏭️ Skipping old event (Time: ${event.created_at} <= Sync: ${currentLastSync})`);
return;
}
After processing an event, lastSync is set to that event's created_at timestamp (second precision). Any subsequent event arriving with the same timestamp is incorrectly skipped as "already seen."
Consequence: If two transfer events have the same created_at second, only the first is processed; the second is silently dropped.
Fix: Change <= to < on line 128:
if (event.created_at < currentLastSync) {
And rely on the existing event-ID deduplication (processedEventIds Set) to prevent true duplicates.
Note: Sphere SDK (NostrTransportProvider.ts) does NOT have this bug — it uses event-ID-based deduplication only.
Impact
| Bug |
Sphere GUI |
Sphere SDK |
@unicitylabs/nostr-js-sdk |
| Fixed d-tag replacement (Bug 1) |
Inherits |
YES (hardcodes d-tag) |
YES (kind 31113 in replaceable range) |
<= timestamp filter (Bug 2) |
YES (line 128) |
NO |
NO |
Data loss scenario (observed)
- User
cryptohog-aux sent 6 UCT tokens to babaika10 in quick succession
- Each transfer event was published with kind 31113 and
d=token-transfer
- The relay replaced each previous event, keeping only the last one
babaika10 received only 1 of 6 tokens (the last one sent)
- The other 5 tokens are lost — they no longer exist on the relay and the recipient never received them
Verification
Querying the Nostr relay (wss://nostr-relay.testnet.unicity.network) confirms:
cryptohog-aux (pubkey cb111b85...) has only 1 kind-31113 event stored (the last transfer)
babaika10 (pubkey eb037a51...) received transfers from 7 other senders (who used older code without d-tag), but only 1 from cryptohog-aux
Suggested Fixes
Short-term (Sphere SDK + Sphere GUI)
- Change the d-tag to include a unique identifier per transfer:
['d', `token-transfer:${tokenId}`]
- Fix the
<= to < in Sphere GUI's NostrService.ts:128
Long-term (Nostr SDK)
Consider whether TOKEN_TRANSFER should use a non-replaceable event kind (< 30000) instead of 31113, since token transfers are inherently non-idempotent events that should never be replaced.
Reproduction Steps
- Create two test wallets (sender and recipient)
- Send 3+ tokens from sender to recipient in quick succession
- Query the Nostr relay for kind 31113 events from the sender's pubkey
- Observe that only 1 event exists (the last one sent)
- Check recipient's wallet — only 1 token received
Summary
When sending multiple token transfers from the same sender to any recipient, only the last transfer survives on the Nostr relay. All previous transfers are silently replaced and permanently lost. Additionally, a secondary timestamp comparison bug in Sphere GUI can drop events that arrive within the same second.
These two bugs were discovered while investigating why only 1 of 6 UCT tokens sent from user
cryptohog-auxtobabaika10was received.Root Cause Analysis
Bug 1: Nostr Parameterized Replaceable Event Collision (Critical — Data Loss)
Affected components:
@unicitylabs/nostr-js-sdk,sphere-sdk,sphere(GUI)TOKEN_TRANSFERis defined as kind 31113 (EventKinds.js), which falls in the Nostr parameterized replaceable event range (30000–39999). Per NIP-01, relays keep only the latest event for each unique combination of(kind, pubkey, d-tag).Both Sphere SDK and Sphere GUI hardcode the d-tag to a fixed string
'token-transfer':Sphere SDK (
transport/NostrTransportProvider.ts, ~line 623):Sphere GUI (
src/components/wallet/L3/services/NostrService.ts):The same fixed
['d', 'token-transfer']tag is used when publishing transfer events.Consequence: Every token transfer from the same sender occupies the same replaceable slot on the relay. When sender A sends 6 tokens to recipient B, each subsequent transfer replaces the previous one. Only the 6th (last) transfer remains on the relay. The first 5 are permanently deleted by the relay.
Fix: Use a unique d-tag per transfer, e.g., the token ID:
Bug 2: Off-by-one Timestamp Filter (Medium — Event Dropping)
Affected component: Sphere GUI only
In
src/components/wallet/L3/services/NostrService.ts(line 128), the incoming event filter uses<=instead of<:After processing an event,
lastSyncis set to that event'screated_attimestamp (second precision). Any subsequent event arriving with the same timestamp is incorrectly skipped as "already seen."Consequence: If two transfer events have the same
created_atsecond, only the first is processed; the second is silently dropped.Fix: Change
<=to<on line 128:And rely on the existing event-ID deduplication (
processedEventIdsSet) to prevent true duplicates.Impact
<=timestamp filter (Bug 2)Data loss scenario (observed)
cryptohog-auxsent 6 UCT tokens tobabaika10in quick successiond=token-transferbabaika10received only 1 of 6 tokens (the last one sent)Verification
Querying the Nostr relay (
wss://nostr-relay.testnet.unicity.network) confirms:cryptohog-aux(pubkeycb111b85...) has only 1 kind-31113 event stored (the last transfer)babaika10(pubkeyeb037a51...) received transfers from 7 other senders (who used older code without d-tag), but only 1 fromcryptohog-auxSuggested Fixes
Short-term (Sphere SDK + Sphere GUI)
<=to<in Sphere GUI'sNostrService.ts:128Long-term (Nostr SDK)
Consider whether
TOKEN_TRANSFERshould use a non-replaceable event kind (< 30000) instead of 31113, since token transfers are inherently non-idempotent events that should never be replaced.Reproduction Steps