Skip to content

DIRECT address transfers saved unfinalized — tokens appear in balance but are unspendable #16

@jvsteiner

Description

@jvsteiner

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

  1. 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)
  2. The receiving wallet shows the token in its balance
  3. Attempt to spend/split the token
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions