Tempo Signature Integration (secp256k1 / P-256 / WebAuthn / Keychain V2)

Accept Tempo wallets signing with secp256k1, raw P-256, WebAuthn passkeys, or Keychain V2 access-key envelopes for EVM credits redemption on the Tempo chain.

Overview

Coinflow’s Tempo credits-redemption path accepts four signature shapes through the same <CoinflowPurchase> integration:

  • secp256k1 — the standard EVM curve used by MetaMask and any EIP-1193 wallet.
  • P-256 (raw) — secp256r1 / NIST-P-256 keys signing a digest directly, without the WebAuthn envelope. Useful for non-browser signers.
  • WebAuthn — P-256 keys held in a passkey authenticator (browser, OS keychain, hardware key). Coinflow accepts root WebAuthn passkey signatures for credits-only redemptions, so users do not need a separate MetaMask-style secp256k1 wallet.
  • Keychain V2 access keys (0x04) — an authorized access key signing on behalf of a root Tempo account, wrapped in a canonical Keychain V2 envelope. The wallet must register the access key on-chain via Tempo’s AccountKeychain precompile before signing — see Tempo access-key wallets (Keychain V2) below.

All four resolve through the same Coinflow checkout API. The merchant’s <CoinflowPurchase> integration stays the same regardless of which signer the user holds — only the bytes inside the permitCredits field differ.

Your app owns the registration / sign-in UI and passes a compatible EthWallet adapter into <CoinflowPurchase>. Coinflow does not create or store keys or passkeys for the merchant.

If you are also settling to a merchant contract on Tempo, your contract must be whitelisted first. See Whitelist Your Contracts.

What’s supported

FlowSupported on TempoNotes
secp256k1 wallet redemptionWorks with any EIP-1193 wallet the same as on other EVM chains.
Raw P-256 (P256) wallet redemptionSign with a raw P-256 key via viem/tempo Account.fromP256.
WebAuthn / passkey wallet redemptionIntegrated through Tempo’s webAuthn connector and a standard Coinflow EthWallet adapter.
Tempo access-key wallets (Keychain V2)Inner signature wrapped in a canonical 0x04 envelope. Requires prior on-chain access-key registration via AccountKeychain. See section below.
Tempo access-key wallets (Keychain V1)The 0x03 envelope was deprecated by the Tempo protocol post-T1C. Wallets must emit V2 (0x04).

Tempo access-key wallets (Keychain V2)

Tempo lets a root account authorize a separate access key to sign on its behalf, sparing users a root-passkey prompt on every transaction. Coinflow accepts these access-key signatures when the wallet emits a canonical Keychain V2 envelope:

0x04 || rootAccount(20 bytes) || innerSig(secp256k1 65B | P-256 130B | WebAuthn 129–2049B)

Prerequisites

The wallet must complete the following BEFORE the customer reaches checkout — Coinflow performs only the verification step:

  1. Authorize the access key on-chain. The wallet submits a Tempo AA transaction (type 0x76) carrying a key_authorization field, signed by the root account’s secp256k1 / P-256 / WebAuthn key. The Tempo node writes the authorization into the AccountKeychain precompile at 0xaAAA…0000. See Tempo’s AccountKeychain spec for the canonical registration flow.
  2. Sign with the access key. The wallet signs the credits-auth digest with the registered access key, wraps the inner signature in the V2 envelope, and returns the resulting bytes through EthWallet.signMessage as usual.
  3. Match the registered signature type. The on-chain signatureType recorded for the access key (0=Secp256k1, 1=P-256, 2=WebAuthn) must match the inner signature’s scheme. Mismatched types reject on-chain per Tempo’s validate_keychain_authorization rule.

Common failure: unregistered access key

If the wallet emits a V2 envelope without registering the access key first, Coinflow’s on-chain verification surfaces “Invalid credits auth signature” to the user. The wallet team must either:

  • Implement the authorizeKey AA-transaction registration flow above, or
  • Downgrade to raw secp256k1 / 0x01 P-256 / 0x02 WebAuthn signatures, which require no AccountKeychain registration.

Merchant integration impact

None. The merchant’s <CoinflowPurchase> integration code stays identical — the customer’s wallet emits the V2 envelope, your EthWallet adapter forwards the opaque bytes unchanged, and Coinflow’s contract performs the keychain composition (TIP-1020 inner-signature recovery followed by AccountKeychain authorization lookup) on-chain. No new SDK parameters and no new API fields.

How it works

  • Your app — hosts the registration / sign-in UI, manages the signer session, and exposes a standard EthWallet adapter to <CoinflowPurchase>. The adapter routes Coinflow’s signMessage request to whichever signer the user chose (secp256k1, raw P-256, or WebAuthn). Nothing else in your Coinflow integration changes.
  • Coinflow — forwards the opaque signature bytes to Tempo’s on-chain signature-verification precompile, which accepts any of the three types. Coinflow does not need to know which type the user used.
  • Networks — Coinflow supports Tempo mainnet and the Moderato testnet. Switch between them by changing the wagmi chain id — no other configuration.

Constructing each signer with viem

The snippets below show the minimum viem / viem/tempo calls to construct each signer type. In a real merchant integration, wrap the resulting Account (or wagmi connection) inside an EthWallet adapter — see Adapt the wagmi session to an EthWallet below for the WebAuthn path; the secp256k1 / P-256 paths follow the same shape.

The snippets below generate ephemeral keys at runtime for illustration only. In production, derive keys from your secure key-management system and never commit private keys to source control.

secp256k1

1import {generatePrivateKey, privateKeyToAccount} from 'viem/accounts';
2import {hashMessage, type Hex} from 'viem';
3
4const privateKey = generatePrivateKey();
5const account = privateKeyToAccount(privateKey);
6
7const message = 'coinflow login challenge';
8const digest = hashMessage(message) as Hex;
9const signature = (await account.sign({hash: digest})) as Hex;
10// 65-byte r||s||v signature ready to forward as `permitCredits`.

Raw P-256

1import {Account as TempoAccount, P256 as TempoP256} from 'viem/tempo';
2import {hashMessage, type Hex} from 'viem';
3
4const privateKey = TempoP256.randomPrivateKey();
5const account = TempoAccount.fromP256(privateKey);
6
7const message = 'coinflow login challenge';
8const digest = hashMessage(message) as Hex;
9const signature = (await account.sign({hash: digest})) as Hex;
10// Raw P-256 envelope (typeId 0x01) ready to forward as `permitCredits`.

WebAuthn (passkey)

For production browser flows, use Tempo’s wagmi webAuthn connector with a remote KeyManager (covered in detail below). For headless / non-browser contexts and tests, Account.fromHeadlessWebAuthn constructs a WebAuthn signer from a P-256 key:

1import {Account as TempoAccount, P256 as TempoP256} from 'viem/tempo';
2import {hashMessage, type Hex} from 'viem';
3
4const privateKey = TempoP256.randomPrivateKey();
5const account = TempoAccount.fromHeadlessWebAuthn(privateKey, {
6 origin: 'https://example.com',
7 rpId: 'example.com',
8});
9
10const message = 'coinflow login challenge';
11const digest = hashMessage(message) as Hex;
12const signature = (await account.sign({hash: digest})) as Hex;
13// WebAuthn envelope (typeId 0x02) ready to forward as `permitCredits`.

Merchant integration (WebAuthn passkey reference flow)

The walkthrough below targets the WebAuthn / passkey case, which has the most setup. The secp256k1 and raw P-256 paths reuse the same <CoinflowPurchase> integration; only the signer backing EthWallet.signMessage differs. For secp256k1 or P-256, use any wagmi connector (injected, walletConnect, or a custom one wrapping a viem/tempo Account) and skip to Adapt the wagmi session to an EthWallet.

1. Configure the Tempo webAuthn connector in your wagmi config

Register Tempo’s WebAuthn connector alongside your existing EVM connectors. Use a remote key manager in production. Use KeyManager.localStorage() for demos only: it stores the credential / public-key mapping in the browser, so clearing storage or switching devices breaks lookup. (The passkey key material itself stays in the authenticator.)

KeyManager.localStorage() is demo-only — ship a server-backed KeyManager.http(url) before production. The snippet below disables the connector entirely in production builds when VITE_TEMPO_KEY_MANAGER_URL is missing, preventing silent fallback to browser storage.

1// ContextWrapper.tsx
2import {KeyManager, webAuthn} from '@wagmi/core/tempo';
3import {createConfig, http, WagmiProvider} from 'wagmi';
4import {
5 tempo,
6 tempoModerato /* ...plus your other chains */,
7} from 'wagmi/chains';
8import {injected, walletConnect} from 'wagmi/connectors';
9
10const tempoKeyManagerUrl = import.meta.env.VITE_TEMPO_KEY_MANAGER_URL?.trim();
11
12function createTempoWebAuthnConnector() {
13 if (tempoKeyManagerUrl) {
14 return webAuthn({
15 keyManager: KeyManager.http(tempoKeyManagerUrl),
16 createOptions: {label: 'My Merchant - Tempo passkey'},
17 });
18 }
19
20 if (import.meta.env.PROD) {
21 // Do not silently fall back to browser-local key lookup in production.
22 // Disable passkeys or fail startup until VITE_TEMPO_KEY_MANAGER_URL is set.
23 return null;
24 }
25
26 return webAuthn({
27 // eslint-disable-next-line @typescript-eslint/no-deprecated
28 keyManager: KeyManager.localStorage(),
29 createOptions: {label: 'My Merchant - Tempo passkey'},
30 });
31}
32
33const tempoWebAuthnConnector = createTempoWebAuthnConnector();
34
35const config = createConfig({
36 chains: [tempo, tempoModerato /* ...plus your other chains */],
37 connectors: tempoWebAuthnConnector
38 ? [tempoWebAuthnConnector, injected(), walletConnect({projectId})]
39 : [injected(), walletConnect({projectId})],
40 transports: {
41 [tempo.id]: http(),
42 [tempoModerato.id]: http(),
43 // ...other chains
44 },
45});
46
47export const TEMPO_WEBAUTHN_CONNECTOR_TYPE = webAuthn.type; // 'webAuthn'
48
49export function ContextWrapper({children}) {
50 return (
51 <WagmiProvider config={config}>
52 {/* ...QueryClientProvider, your app tree... */}
53 {children}
54 </WagmiProvider>
55 );
56}

2. Drive sign-up / sign-in through wagmi

The connector accepts a capabilities argument on connect() that selects between registering a new passkey (sign-up) and authenticating with an existing one (sign-in). Pass the Tempo chain id when connecting so the connector session targets the same Tempo network Coinflow uses.

1import {tempo, tempoModerato} from 'wagmi/chains';
2import {useConnect, useConnections, useDisconnect} from 'wagmi';
3import {TEMPO_WEBAUTHN_CONNECTOR_TYPE} from './ContextWrapper';
4
5const tempoChainId = import.meta.env.PROD ? tempo.id : tempoModerato.id;
6
7function TempoPasskeyGate({children}) {
8 const connections = useConnections();
9 const {connect, connectors, isPending, error} = useConnect();
10 const {disconnect} = useDisconnect();
11
12 const tempoAccount = connections.find(
13 c => c.connector.type === TEMPO_WEBAUTHN_CONNECTOR_TYPE
14 );
15 const tempoConnector = connectors.find(
16 c => c.type === TEMPO_WEBAUTHN_CONNECTOR_TYPE
17 );
18
19 // wagmi's public `ConnectVariables` type does not expose `capabilities`,
20 // but the Tempo `webAuthn` connector consumes it at runtime. Scope the
21 // cast to `Parameters<typeof connect>[0]` so a future wagmi rename still
22 // fails type-checking at this call site.
23 const connectWith = (type: 'sign-up' | 'sign-in') => {
24 if (!tempoConnector) return;
25 const variables = {
26 connector: tempoConnector,
27 chainId: tempoChainId,
28 capabilities: {type},
29 } as Parameters<typeof connect>[0];
30 connect(variables);
31 };
32
33 if (!tempoAccount) {
34 return (
35 <div>
36 <button disabled={isPending} onClick={() => connectWith('sign-up')}>
37 Register new passkey
38 </button>
39 <button disabled={isPending} onClick={() => connectWith('sign-in')}>
40 Sign in with existing passkey
41 </button>
42 {error && <p>{error.message}</p>}
43 </div>
44 );
45 }
46
47 return (
48 <>
49 <div>
50 Signed in as <code>{tempoAccount.accounts[0]}</code>
51 <button onClick={() => disconnect({connector: tempoAccount.connector})}>
52 Sign out
53 </button>
54 </div>
55 {children(tempoAccount)}
56 </>
57 );
58}

3. Adapt the wagmi session to an EthWallet for <CoinflowPurchase>

Coinflow’s iframe passes EIP-712 typed data into signMessage as a JSON-stringified object — the same shape secp256k1 merchants already handle. Parse the string, detect the EIP-712 shape, and route typed data through wagmi’s useSignTypedData (which invokes the chosen connector). Let typed-data errors surface — do not retry as personal_sign.

1import {useCallback} from 'react';
2import {useConnections, useSignMessage, useSignTypedData} from 'wagmi';
3import type {Hex} from 'viem';
4import type {EthWallet} from '@coinflowlabs/react';
5import {TEMPO_WEBAUTHN_CONNECTOR_TYPE} from './ContextWrapper';
6
7type Eip712Payload = {
8 domain: Record<string, unknown>;
9 types: Record<string, readonly {name: string; type: string}[]>;
10 primaryType: string;
11 message: Record<string, unknown>;
12};
13
14function parseJsonMessage(message: string): unknown {
15 try {
16 return JSON.parse(message) as unknown;
17 } catch {
18 return undefined;
19 }
20}
21
22function isEip712Payload(value: unknown): value is Eip712Payload {
23 if (value === null || typeof value !== 'object') return false;
24 const v = value as Record<string, unknown>;
25 return (
26 typeof v.domain === 'object' &&
27 v.domain !== null &&
28 typeof v.types === 'object' &&
29 v.types !== null &&
30 typeof v.primaryType === 'string' &&
31 typeof v.message === 'object' &&
32 v.message !== null
33 );
34}
35
36export function useTempoPasskeyWallet(): EthWallet | undefined {
37 const connections = useConnections();
38 const {signMessageAsync} = useSignMessage();
39 const {signTypedDataAsync} = useSignTypedData();
40
41 const tempoAccount = connections.find(
42 c => c.connector.type === TEMPO_WEBAUTHN_CONNECTOR_TYPE
43 );
44 const address = tempoAccount?.accounts[0];
45
46 const signMessage = useCallback(
47 async (message: string): Promise<Hex> => {
48 if (!tempoAccount || !address) {
49 throw new Error('Tempo passkey wallet not connected - sign in first.');
50 }
51
52 const parsed = parseJsonMessage(message);
53 if (isEip712Payload(parsed)) {
54 return signTypedDataAsync({
55 ...parsed,
56 connector: tempoAccount.connector,
57 } as Parameters<typeof signTypedDataAsync>[0]);
58 }
59
60 return signMessageAsync({
61 message,
62 connector: tempoAccount.connector,
63 });
64 },
65 [tempoAccount, address, signMessageAsync, signTypedDataAsync]
66 );
67
68 if (!tempoAccount || !address) return undefined;
69
70 return {
71 address,
72 signMessage,
73 sendTransaction: () =>
74 Promise.reject(
75 new Error(
76 'Tempo passkey redemption does not require sendTransaction — Coinflow submits the redemption on your behalf.'
77 )
78 ),
79 };
80}

Compose the gate, adapter hook, and <CoinflowPurchase>:

1function TempoPurchaseContent() {
2 const wallet = useTempoPasskeyWallet();
3 if (!wallet) return null;
4
5 return (
6 <CoinflowPurchase
7 wallet={wallet}
8 blockchain={'tempo'}
9 /* ...other props... */
10 />
11 );
12}
13
14function TempoPurchase() {
15 return <TempoPasskeyGate>{() => <TempoPurchaseContent />}</TempoPasskeyGate>;
16}

Login signatures

The signMessage adapter from step 3 also handles Coinflow’s login challenge. Users sign in with their Tempo signer (secp256k1, P-256, or WebAuthn) with no extra client-side code.

Errors your users may see

The Coinflow iframe surfaces this message to the user during the Tempo signature flow:

When it happensWhat the user sees
The signature cannot be verified on-chainInvalid credits auth signature.

Operational notes

  • Production key storage (WebAuthn). Point KeyManager.http(url) at a server-side key store. KeyManager.localStorage() is demo-only — it stores the credential / public-key mapping in the browser, so the app loses the mapping when browser storage is cleared or the user switches devices.
  • Same OpenAPI shape across all three signer types. Coinflow adds no new request / response fields. The permitCredits field carries the opaque signature bytes for whichever signer the user used.