tfpdev
3.6Part 3 · Payouts

Paid Twice

The studio pays a freelancer and never hears back. Try again the simple way and the freelancer is paid twice, for real. Save the exact payment before sending and retry that same one, and they're paid once.

01Try it

The studio pays a freelancer and never hears back. Each side tries again its own way. Then see what the freelancer actually got.

Freelancer · October · 1,200 USDCTo the freelancer at —
Real payments on a test network. Both sides send, the lab throws away the reply on purpose, and each side tries again its own way.15 of 15 payouts left this hour
BreaksTries again from scratch
No reply? Make the payment again and send it.
  1. #1—
  2. #2—
The freelancer got—
HoldsTries the same payment again
Saves the exact payment first. No reply? Sends that same one again.
  1. #1—
  2. #2—
The freelancer got—

Pay the freelancer. Both sides never hear back and try again. Then check what really happened, on a public explorer.

15 tries an hour each. Every payment is real and uses up the lab's test money.

02Step 2

Why sending the same payment wins

No reply isn't a no

The payment may have gone out and only the answer got lost. From your side, 'it failed' and 'it worked' look exactly the same.

Each payment has a number

Every payment from a wallet gets a number, and only one payment per number can ever go through. Re-sending the same saved payment can't pay twice.

Save first, send second

Write the payment down before sending it. Then a lost reply never leaves you guessing: you know exactly what to look for.

03Step 3

The code

What runs behind the buttons, in one sentence. The code itself is one click away.

The bad version makes a brand-new payment when it hears nothing back. The good version saves the signed payment first and, when it hears nothing, sends that same payment again.

Show the code anyway
BreaksBreaks
The version that loses money. Read the grey notes under it, not the code.
payout-careless.tsts
try {  await wallet.writeContract({ ...transfer(to, amount) });} catch (err) {  if (isTimeout(err)) {    // "it failed, try again"    await wallet.writeContract({ ...transfer(to, amount) });  }}
A timeout says nothing about whether the transfer went out. Sending again builds a new transaction with the next nonce, and the chain happily runs both.
HoldsHolds
The version running behind the buttons above.
payout-careful.tsts
const req = await wallet.prepareTransactionRequest({ to: USDC, data, nonce });const raw = await wallet.signTransaction(req);await db.payouts.insert({ id, nonce, raw, hash: keccak256(raw) });await send(raw);              // timeout? fine.// retry, any time, any number of times:await send(raw);              // "already known" or the same hash
The nonce is the idempotency key. Sign once, store the bytes and the hash before sending, and every retry sends exactly those bytes. At most one of them can ever be mined.
04Step 4

Still curious?

A few more questions people ask. Open what you need.

Is the lost reply real?

The lost reply is staged: the lab ignores the first answer on purpose. Everything after that really happens on the test network, and you can check it on the explorer.

What if the saved payment never goes through?

Then you replace it with a new payment that has the same number and a higher fee. Only one of them can go through, so even that can't pay twice.

Isn't this like the retry problem in Part 2?

Same problem. In Part 2 the database refuses a repeat. On a blockchain, the payment's number does that job, but only if you resend the same payment.

Not sure this could happen to you?

That's what the call is for. Tell me how your product works, and I'll tell you what applies to you and what I'd check first.