Bug Summary
When receiving a DIRECT address token transfer, if finalizeTransaction() fails or stClient/trustBase is unavailable, handleIncomingTransfer() silently falls back to saving the raw source token (sender's token data with sender's predicate). This token appears in the wallet balance as confirmed, but any attempt to spend it fails with:
Error: Ownership verification failed: Authenticator does not match source state predicate.
The token's state predicate still references the sender's public key, so the recipient wallet cannot produce a valid authenticator for it.
Root Cause
In PaymentsModule.ts, handleIncomingTransfer() around line 2518-2557, the DIRECT address branch has two fallback paths that save the unfinalized source token:
Fallback 1 — missing dependencies (line 2539-2541):
if (!stClient || !trustBase) {
this.log('Cannot finalize DIRECT transfer - missing client, using source token');
tokenData = sourceTokenInput; // ← saves sender's token
}
Fallback 2 — finalization exception (line 2553-2555):
} catch (finalizeError) {
this.log('DIRECT finalization failed, using source token:', finalizeError);
tokenData = sourceTokenInput; // ← saves sender's token
}
In both cases, the token proceeds through validation, gets status confirmed, and is added to the wallet via addToken(). The user sees the balance, but the token is unspendable.
Contrast with PROXY path
The PROXY address branch (line 2473-2517) handles this correctly — it rejects the token on failure:
// Missing nametag token:
console.error('[Payments] Cannot finalize PROXY transfer - no nametag token. Token rejected.');
return;
// Finalization exception:
console.error('[Payments] Finalization failed:', finalizeError);
return; // Reject token - cannot spend without finalization
The DIRECT path should behave the same way.
How to Reproduce
- Send a token transfer to a DIRECT address where the receiver's
finalizeTransaction() fails (e.g., due to SDK version mismatch between sender and receiver, or stClient not yet initialized)
- The receiving wallet shows the token in its balance
- Attempt to spend/split the token
StateTransitionClient.submitTransferCommitment() throws "Ownership verification failed: Authenticator does not match source state predicate." because the token's predicate still belongs to the sender
Observed Error Trace
[SplitCalculator] Token TokenId[ec9af7ae...]: coinId=455ad872..., status=confirmed, hasSdkData=true
[SplitCalculator] Token balance for 455ad872...: 126000000000000000
[SplitCalculator] Split required. Sending: 119000000000000000, Remainder: 7000000000000000
[TokenSplitExecutor] Splitting token ec9af7ae...
[TokenSplitExecutor] Step 1: Burning original token...
Approve transaction error: Error: Ownership verification failed: Authenticator does not match source state predicate.
The ownership check in StateTransitionClient.submitTransferCommitment():
const predicate = await PredicateEngineService.createPredicate(
commitment.transactionData.sourceState.predicate
);
if (!(await predicate.isOwner(commitment.authenticator.publicKey))) {
throw new Error('Ownership verification failed: Authenticator does not match source state predicate.');
}
Suggested Fix
Match the PROXY path behavior — reject the token instead of saving it unfinalized:
// Fallback 1:
if (!stClient || !trustBase) {
console.error('[Payments] Cannot finalize DIRECT transfer - missing state transition client or trust base. Token rejected.');
return;
}
// Fallback 2:
} catch (finalizeError) {
console.error('[Payments] DIRECT finalization failed. Token rejected:', finalizeError);
return;
}
Additionally, it may be worth investigating why DIRECT finalization is failing in the first place — the root cause may be a separate issue (e.g., SDK version mismatch between sender and receiver affecting the transfer data format).
Impact
- Users receive tokens that appear as spendable balance but cannot be spent
- Any payment that selects an unfinalized token for splitting/transfer fails
- Users have no way to distinguish finalized (spendable) from unfinalized (phantom) tokens in their wallet
Bug Summary
When receiving a DIRECT address token transfer, if
finalizeTransaction()fails orstClient/trustBaseis unavailable,handleIncomingTransfer()silently falls back to saving the raw source token (sender's token data with sender's predicate). This token appears in the wallet balance asconfirmed, but any attempt to spend it fails with:The token's state predicate still references the sender's public key, so the recipient wallet cannot produce a valid authenticator for it.
Root Cause
In
PaymentsModule.ts,handleIncomingTransfer()around line 2518-2557, the DIRECT address branch has two fallback paths that save the unfinalized source token:Fallback 1 — missing dependencies (line 2539-2541):
Fallback 2 — finalization exception (line 2553-2555):
In both cases, the token proceeds through validation, gets status
confirmed, and is added to the wallet viaaddToken(). The user sees the balance, but the token is unspendable.Contrast with PROXY path
The PROXY address branch (line 2473-2517) handles this correctly — it rejects the token on failure:
The DIRECT path should behave the same way.
How to Reproduce
finalizeTransaction()fails (e.g., due to SDK version mismatch between sender and receiver, orstClientnot yet initialized)StateTransitionClient.submitTransferCommitment()throws"Ownership verification failed: Authenticator does not match source state predicate."because the token's predicate still belongs to the senderObserved Error Trace
The ownership check in
StateTransitionClient.submitTransferCommitment():Suggested Fix
Match the PROXY path behavior — reject the token instead of saving it unfinalized:
Additionally, it may be worth investigating why DIRECT finalization is failing in the first place — the root cause may be a separate issue (e.g., SDK version mismatch between sender and receiver affecting the transfer data format).
Impact