coinbase / coinbase/agentkit

CdpEvmWalletProvider.sendTransaction omits idempotencyKey, which the CDP SDK accepts — an agent retry after a lost response sends the transfer twice (cdpEvmWalletProvider.ts:195)

Open
#1,483 5 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
1.3k
Forks
815
Avg merge
13h 31m
Merged PRs (30d)
2

Description

## Summary

`CdpEvmWalletProvider.sendTransaction` does not pass an idempotency key, although the CDP SDK it calls accepts one and Coinbase's own docs use idempotency keys elsewhere in this same repository.

```ts
// typescript/agentkit/src/wallet-providers/cdpEvmWalletProvider.ts:195-206
async sendTransaction(transaction: TransactionRequest): Promise {
const result = await this.#cdp.evm.sendTransaction({
address: this.#serverAccount.address,
transaction: { to, value, data },
network: this.getCdpSdkNetwork(),
// no idempotencyKey
});
return result.transactionHash;
}
```

The SDK signature (`@coinbase/cdp-sdk`, `_types/actions/evm/sendTransaction.d.ts`) is:

```ts
export interface SendTransactionOptions {
address: Address;
transaction: Hex | TransactionRequestEIP1559;
network: SendEvmTransactionBodyNetwork;
/** The idempotency key. */
idempotencyKey?: string;
}
```

So the field exists, is documented, and is not used on the one path that moves value. `nativeTransfer` (`:296`) routes through this method, as does every ERC-20 transfer and contract write that reaches this provider. `CdpSmartWalletProvider.nativeTransfer` (`:374`) has the same shape.

## Why this matters here more than in most SDKs

An agent framework's defining property is that the caller is a model inside a harness that retries. A dropped connection or a timeout after `sendTransaction` leaves the caller unable to distinguish "the transaction was never submitted" from "it was submitted and the response was lost". With no idempotency key, a retry is a second, independently valid transaction — a second transfer of real value. Nothing in either response marks it as a duplicate, and no replay guard fires, because the second submission genuinely is new.

This is also the case Coinbase already solved. The idempotency-key flow appears in this repo's own examples for wallet creation:

```py
# python/examples/langchain-cdp-chatbot/chatbot.py:51-52
address=config.address, # Optional, will trigger idempotency flow if not provided
idempotency_key=config.idempotency_key,
```

Creating a wallet twice is an inconvenience. Sending value twice is not. The protection is applied to the cheaper failure and omitted from the expensive one.

## What I checked

- `cdpEvmWalletProvider.ts:195-206` — `sendTransaction`, no `idempotencyKey` in the options object.
- `cdpEvmWalletProvider.ts:296-302` — `nativeTransfer` delegates to it unchanged.
- `cdpSmartWalletProvider.ts:374-380` — same delegation.
- `@coinbase/cdp-sdk@1.55.0` — `SendTransactionOptions.idempotencyKey?: string`, present and commented as *"The idempotency key."*
- A repository-wide search for `idempot` in TypeScript and Python sources: matches occur only in wallet-creation examples, never in a wallet provider or an action provider.

## Limits, stated plainly

This is a code read. I have not run an agent against CDP, induced a timeout mid-transfer and observed two transfers — that experiment is what turns a mechanism into a measured rate, and I would want it before quoting one.

If the CDP backend deduplicates `sendTransaction` server-side by account and nonce independently of the key, then the omission is harmless and this reduces to a documentation gap — in which case I will publish that correction as prominently as the claim. I could not determine that from outside, and the SDK exposing the parameter suggests the backend does rely on it.

I have also not reviewed the Solana provider's equivalent path in the same detail, so I am not making a claim about it here.

## On the fix

The fix is small and local to these providers, and there is a design choice inside it — where a stable key should come from so that a retry of the *same intent* reuses it while distinct transfers do not collide. That choice is the part that actually matters; the plumbing is trivial. I am not posting the patch.

I would rather explain that than be coy. I have filed seven of these over three weeks, each with the complete remedy and a failing test attached, free. Seven teams shipped fixes, the fastest in 4.3 hours. It built a real public record and it has not been a business, so the finding is free and the fix is the work now.

If it is useful: I read one money path end to end and return every finding tied to your own file and line numbers, each with its patch and a failing test in your own harness. Five working days, written only, no call. $1,200, and no invoice if the path is clean. The deliverable is specified up front so you can check the report against it: https://github.com/aurumflux20/seal/blob/main/docs/REVIEW-DELIVERABLE.md

And if you would rather simply have this one, say so on this thread and I will post it here for nothing. I am not going to withhold a payment-safety fix on a public repository, and I would think less of anyone who did. But it is what I sell, and asking costs you nothing.

Context on the seam: of ten agent-payment money paths read in three days, seven could charge a payer twice on an ambiguous outcome. The public record, with the evidence for each row and nobody named while their finding is open, is at https://aurumflux.co/retry-safety/

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.