Skip to main content

Why Verify Events?

On StarkNet, a transaction can have status “SUCCEEDED” while the token transfer inside it fails silently. This happens because:
  1. The Chipi paymaster wraps your calls in execute_from_outside_v2
  2. The forwarder contract executes the wrapped call
  3. If the inner call fails (e.g., insufficient balance), the forwarder may still succeed
  4. The transaction is marked “SUCCEEDED” on explorers
Always verify Transfer events to confirm tokens actually moved.

Checking Transfer Events via RPC

After a transfer, query the transaction receipt for Transfer events from the token contract:
const provider = new RpcProvider({
  nodeUrl: "https://starknet-mainnet.infura.io/v3/YOUR_KEY",
});

const receipt = await provider.getTransactionReceipt(txHash);

if (receipt.execution_status !== "SUCCEEDED") {
  throw new Error(`Transaction failed: ${receipt.execution_status}`);
}

// Transfer event key = starknet_keccak("Transfer")
const TRANSFER_KEY =
  "0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9";

const USDC_ADDRESS =
  "0x033068f6539f8e6e6b131e6b2b814e6c34a5224bc66947c47dab9dfee93b35fb";

const transferEvent = receipt.events.find((event) => {
  const isUsdc = BigInt(event.from_address) === BigInt(USDC_ADDRESS);
  const isTransfer = event.keys[0] === TRANSFER_KEY;
  return isUsdc && isTransfer;
});

if (!transferEvent) {
  throw new Error("No USDC Transfer event found — transfer failed silently");
}

// StarkNet Transfer events use indexed parameters (keys):
// keys[0] = Transfer selector
// keys[1] = from address
// keys[2] = to address
// data[0] = amount (low 128 bits)
// data[1] = amount (high 128 bits)
const from = transferEvent.keys[1];
const to = transferEvent.keys[2];
const amountLow = BigInt(transferEvent.data[0]);
const amountHigh = BigInt(transferEvent.data[1]);
const amount = amountLow + (amountHigh << 128n);

console.log(`Verified: ${amount} units from ${from} to ${to}`);

Checking Balance Before and After

For additional certainty, query the token balance before and after the transfer:
const balanceBefore = await getBalance(provider, tokenAddress, recipientAddress);

const txHash = await serverClient.transfer({ params: { ... } });

// Wait for the tx to be included in a block
await provider.waitForTransaction(txHash);

const balanceAfter = await getBalance(provider, tokenAddress, recipientAddress);

if (balanceAfter <= balanceBefore) {
  throw new Error("Balance did not increase — transfer may have failed");
}

async function getBalance(provider, token, account) {
  const result = await provider.callContract({
    contractAddress: token,
    entrypoint: "balance_of",
    calldata: [account],
  });
  return BigInt(result[0]) + (BigInt(result[1]) << 128n);
}

Using getTransactionStatus for Polling

The SDK’s getTransactionStatus method polls the chain for finality, but it only checks transaction inclusion — not whether the inner transfer succeeded:
// This tells you the tx is on-chain, NOT that tokens moved
const status = await serverClient.getTransactionStatus({ hash: txHash });
// status.onChainStatus: "ACCEPTED_ON_L2" | "ACCEPTED_ON_L1" | "REJECTED" | ...
Use getTransactionStatus for finality, then verify Transfer events for correctness.

Common Pitfalls

PitfallWhat HappensHow to Detect
Insufficient token balanceTx succeeds, no Transfer eventCheck events in receipt
Wrong recipient addressTokens sent to wrong addressVerify keys[2] matches expected recipient
Amount overflow/underflowTransfer of 0 tokensCheck data[0] and data[1] are non-zero
Tx status “SUCCEEDED” but transfer failedForwarder catches inner errorAlways check Transfer events