# Ephemeral Signers (Agents)

**MoonPay Wallets ephemeral signers let your users delegate scoped signing authority to an "agent"** (a trading bot, a DCA scheduler, an AI agent, or any server-side process) without ever handing over their wallet's private key. The user authorizes specific wallets from the browser; your backend can then sign on their behalf until the authorization expires or the user revokes it.

<Info>
  Ephemeral signers are built on **SES (Secure Ephemeral Signer)**: an AWS Nitro enclave operated by MoonPay Wallets. The user's wallet key share is HPKE-sealed directly to the enclave, and every signature is produced inside it. Neither your servers nor the regular MoonPay Wallets infrastructure can reconstruct the wallet key.
</Info>

## How it works

<Steps>
  <Step title="User provisions an agent">
    From your app, the user selects which wallets the agent may sign for. The MoonPay Wallets iframe verifies the SES enclave's attestation, generates a fresh **Ed25519 keypair for the agent**, and HPKE-seals the wallet key share(s) to the enclave together with the signing policy (authorized wallets, optional expiry).

    <Frame caption="Provisioning an agent against a user's wallets">
      <img width="70%" src="/images/screens/agents-provision.png" />
    </Frame>
  </Step>

  <Step title="Agent keys are revealed exactly once">
    The provisioning result contains the agent's public and private key. **MoonPay Wallets does not persist the private key.** Your app surfaces it to the user (or stores it in your backend's secret manager) at this moment, and it can never be retrieved again.

    <Frame caption="The agent keypair is shown once at provisioning. The private key is never stored">
      <img width="70%" src="/images/screens/agents-key-reveal.png" />
    </Frame>
  </Step>

  <Step title="Your backend signs on the user's behalf">
    Server-side code calls MoonPay Wallets with your app's **secret key** (`moon_sk_...`) plus a request body signed with the **agent's private key**. SES verifies the agent signature, checks the provisioned policy, reconstructs the wallet key inside the enclave, and returns the wallet's signature. See the [REST API](#rest-api) section below.
  </Step>

  <Step title="User revokes when done">
    The user can revoke an agent from your app. The SDK obtains a scoped presence token from a passkey or eligible identity-backed session.
  </Step>
</Steps>

### Security model

Signing requires **two independent credentials**:

| Credential | Held by | Proves |
|-----------|---------|--------|
| App secret key (`moon_sk_...`) | Your backend | The request comes from the app that provisioned the agent |
| Agent Ed25519 private key | Your backend / the agent | The caller is the specific agent the user authorized |

A leaked secret key alone can't forge signatures (it lacks the agent key), and a leaked agent key alone can't reach the signing endpoint (it lacks the secret key). The wallet key itself never leaves the enclave.

## Supported chains

| Chain | Scheme | Signature behavior |
|-------|--------|--------------------|
| **Ethereum** (and EVM chains) | `ecdsa_secp256k1` | Signatures over a keccak256 digest; an EIP-191 message signature recovers to the wallet address |
| **Solana** | `eddsa_ed25519` | Ed25519 signatures that verify against the wallet's public key |
| **Tron** | `ecdsa_secp256k1` | TIP-191 message signatures and TronWeb transaction signatures that recover to the Tron wallet address |
| **Bitcoin Taproot (P2TR)** | `bip340_secp256k1` | BIP340 signatures assembled into BIP322 Simple proofs or BIP341 key-path transactions |
| **Bitcoin Native SegWit / Legacy** | `ecdsa_secp256k1` | ECDSA signatures assembled into BIP322 Simple/Full proofs or P2WPKH/P2PKH transactions |

<Info>
  Bitcoin support covers the three single-key MoonPay Wallets types: Taproot (`bc1p`), Native SegWit (`bc1q`), and Legacy P2PKH (`1...`). It does not add nested SegWit, multisig, or Taproot script-path signing.
</Info>

## React SDK

The `useEphemeralSigner` hook from `@moon-x/react-sdk` manages the full client-side lifecycle (listing, provisioning, and revoking) in one hook.

### Identity-backed setup

Eligible identity-backed sessions can authorize provisioning, re-provisioning, and revocation without email OTP or a verified email address. The app must have identity-backed encryption enabled and available, and the user must hold a usable identity-backed wrap.

The SDK prefers an enrolled passkey and can fall back to the eligible session if the assertion fails or is canceled. Passkey-only users still need a passkey assertion.

Follow [Set up an identity-backed signer](/wallets/ephemeral-signers/identity-backed-setup) for the version requirements and complete flow. Read the [security model](/security/identity-backed-encryption) before enabling session-authorized delegation.

### Provision an agent

```tsx
import { useMoonX, useEphemeralSigner } from '@moon-x/react-sdk';

function ProvisionAgentButton() {
  const { user } = useMoonX();
  const { provision, isProvisioning, lastProvisioned, reset } = useEphemeralSigner();

  const handleProvision = async () => {
    if (!user?.wallets?.length) return;

    try {
      const result = await provision({
        walletIds: [user.wallets[0].id],
        label: 'DCA bot',
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
      });

      // result.apiPubHex:  the agent's public key (identifies the agent)
      // result.apiPrivHex: the agent's private key, revealed ONLY here.
      //                     Show it to the user / hand it to your backend now.
    } catch (error) {
      console.error('Provisioning failed:', error);
    }
  };

  if (lastProvisioned) {
    return (
      <div>
        <h4>Agent provisioned</h4>
        <p>Public key: {lastProvisioned.apiPubHex}</p>
        <p>Private key (shown once): {lastProvisioned.apiPrivHex}</p>
        <button onClick={reset}>I saved the keys</button>
      </div>
    );
  }

  return (
    <button onClick={handleProvision} disabled={isProvisioning}>
      {isProvisioning ? 'Provisioning…' : 'Provision agent'}
    </button>
  );
}
```

<Warning>
  `apiPrivHex` is revealed **exactly once**, in the provision result. MoonPay Wallets never stores it and there is no recovery path. If it is lost, revoke the agent and provision a new one.
</Warning>

### Provision parameters

<ParamField path="walletIds" type="string[]" required>
  Wallet IDs (from `user.wallets[i].id`) the agent is authorized to sign for. The SDK resolves each ID to its MPC key and derivation path internally. Your code never handles MPC internals.
</ParamField>

<ParamField path="expiresAt" type="Date">
  Optional expiration. After this timestamp the SES enclave refuses sign requests from the agent. Omit for an open-ended authorization the user can revoke later.
</ParamField>

<ParamField path="label" type="string">
  Optional user-facing name (e.g. `"DCA bot"`) shown in the manage view. Can be changed without re-provisioning.
</ParamField>

### Provision result

<ResponseField name="apiPubHex" type="string">
  The agent's 32-byte Ed25519 public key (lowercase hex, no `0x`). This is the agent's identity. It appears in the list view and is the handle for revocation and server-side signing.
</ResponseField>

<ResponseField name="apiPrivHex" type="string">
  The agent's 32-byte Ed25519 private key (lowercase hex, no `0x`). **Revealed only here.** Store it server-side in a secret manager; never persist it in the browser.
</ResponseField>

<ResponseField name="moduleId" type="string">
  The AWS Nitro enclave module ID from the verified attestation document.
</ResponseField>

<ResponseField name="pcrsMatched" type="number[]">
  The PCR indices that matched the pinned MoonPay Wallets attestation policy (typically `[0, 1, 2]`), confirming the exact enclave image that received the sealed key shares.
</ResponseField>

### List and revoke agents

```tsx
import { useEphemeralSigner } from '@moon-x/react-sdk';

function ManageAgents() {
  const { signers, isLoading, revoke, isRevoking } = useEphemeralSigner();

  if (isLoading) return <div>Loading agents…</div>;

  return (
    <ul>
      {signers.map((signer) => (
        <li key={signer.api_pub}>
          <strong>{signer.label ?? 'Unnamed agent'}</strong>
          <span>
            Status: {signer.status === 'needs_reauth' ? 'Needs re-authorization' : 'Active'}
          </span>
          {signer.status === 'needs_reauth' && (
            <p>Provision a new signer before this agent can sign again.</p>
          )}
          <ul>
            {signer.wallets.map((w) => (
              <li key={w.address}>{w.chain}: {w.address}</li>
            ))}
          </ul>
          <span>Provisioned: {signer.provisioned_at}</span>
          {signer.expires_at && <span>Expires: {signer.expires_at}</span>}
          <button
            onClick={() => revoke({ apiPubHex: signer.api_pub })}
            disabled={isRevoking}
          >
            Revoke
          </button>
        </li>
      ))}
    </ul>
  );
}
```

The hook auto-fetches the list when the SDK is ready and refreshes it after every successful provision or revoke. The list contains every non-revoked signer. Check `status` before treating a signer as usable: `active` can sign, while `needs_reauth` requires a new provisioning ceremony.

<Info>
  Revocation requires its own `ephemeral_signer_revoke` presence token. An eligible identity-backed session can authorize revocation without OTP. A passkey-only user must confirm with a passkey assertion.
</Info>

## REST API

The API splits into two tiers with different authentication.

### User-side lifecycle

The client-side lifecycle endpoints (the React SDK drives these for you) are authenticated with your **publishable key** plus the user's wallet session:

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/v0/wallets/ephemeral-signers/policy` | Attestation verification policy (enclave PCR measurements + root CA) |
| `POST` | `/v0/wallets/ephemeral-signers/attestation` | Fetch a nonce-bound enclave attestation document |
| `POST` | `/v0/wallets/ephemeral-signers/` | Provision an agent with sealed shares, policy, and an `ephemeral_signer_provision` presence token |
| `GET` | `/v0/wallets/ephemeral-signers/` | List the user's non-revoked agents and their status |
| `DELETE` | `/v0/wallets/ephemeral-signers/{api_pub}` | Revoke an agent with an `ephemeral_signer_revoke` presence token |

Send lifecycle presence tokens in `X-MoonX-Presence`. Provisioning and revocation accept passkey, eligible email OTP, or eligible identity-backed session proofs. The SDK obtains the 30-second provisioning token after preparing the selected wallets. Wallet reads use separate `keyshare_read` tokens.

### Server-side signing

Your backend signs on behalf of a provisioned agent with your app's **secret key** (`moon_sk_...` via `X-API-Key`), no wallet session involved. The secret key proves app ownership and the target agent must have been provisioned under the same app; on top of that, every request body must carry the **agent's own Ed25519 signature**, which the SES enclave verifies before signing. A leaked secret key alone cannot forge a signature, and a leaked agent key alone cannot reach the endpoint.

| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/v0/wallets/ephemeral-signers/sign` | Sign with an agent - request signed by the agent's Ed25519 key, verified inside the enclave |
| `POST` | `/v0/wallets/ephemeral-signers/me/wallets` | List the agent's authorized wallet bindings. Bitcoin entries also include `address_type`, `public_key`, and the Taproot `internal_public_key`, so adapters can build the correct script contract from only a `walletId` |

The `public_key` and `internal_public_key` values come from this authenticated wallet-binding response. SDK import callers select the Bitcoin format with the `addressType` option.

Agent requests include a freshness-bounded `signed_body` (an `{issued_at}` JSON payload, hex-encoded) to prevent replay. See the [API Reference](/api-reference) for the full request/response schemas.

The list response contains one entry per non-revoked agent:

```json
{
  "signers": [
    {
      "api_pub": "<64-char lowercase hex Ed25519 public key>",
      "label": "DCA bot",
      "status": "active",
      "wallets": [
        { "address": "0xEXAMPLE_WALLET_ADDRESS", "chain": "ethereum" },
        { "address": "TEXAMPLE_TRON_ADDRESS", "chain": "tron" }
      ],
      "expires_at": "2026-08-01T00:00:00Z",
      "provisioned_at": "2026-07-01T12:00:00Z"
    }
  ]
}
```

Provisioning the same agent public key twice within one app returns `409 ephemeral_signer_already_exists`.

### Recover a signer that needs re-authorization

A signer can become permanently unavailable if its key material is no longer present in the secure signing environment. MoonPay Wallets exposes the same condition in two places:

* The list endpoint returns `status: "needs_reauth"` for the signer.
* The sign endpoint returns HTTP `410` with `{"error":"signer_not_provisioned"}`.

Do not retry with the same agent keypair. Delete the stored `apiPubHex` and `apiPrivHex`, notify the wallet owner, and send them through the provisioning flow again. The replacement signer has a new public key.

The Node SDK exports `isSignerNotProvisioned` for the sign-error path:

```ts
import { isSignerNotProvisioned } from '@moon-x/node-sdk';

try {
  const signedTransaction = await signer.signTransaction(transaction);
  // Broadcast signedTransaction.
} catch (error) {
  if (!isSignerNotProvisioned(error)) throw error;

  // Stop retrying. Remove this agent keypair from your secret store and
  // ask the wallet owner to provision a new signer.
}
```

For direct REST integrations, require both the `410` status and the exact `signer_not_provisioned` error value before starting this recovery flow. Other signing failures may be transient and should retain their normal retry policy.

## Server-side adapter packages

For server-side agents, use the chain-aware adapter package for the wallet being signed. The adapters call the server-side ephemeral signer endpoint with your app's Secret API Key and handle the chain-specific digest and signature format.

| Chain | Package | Returns |
|-------|---------|---------|
| Ethereum/EVM | `@moon-x/evm-adapter` | EIP-191 messages, EIP-712 typed data, transactions, and hashes |
| Solana | `@moon-x/solana-adapter` | Solana message and transaction signatures |
| Tron | `@moon-x/tron-adapter` | TIP-191 messages and broadcast-ready TronWeb transactions |
| Bitcoin | `@moon-x/bitcoin-adapter` | BIP322 message proofs and finalized P2TR/P2WPKH/P2PKH transactions from PSBT v0 |

### Tron agent signing

```ts
import { createTronSigner } from '@moon-x/tron-adapter';
import { TronWeb } from 'tronweb';

const tronWeb = new TronWeb({ fullHost: 'https://api.trongrid.io' });

const signer = createTronSigner({
  apiPubHex: process.env.MOONX_AGENT_PUBLIC_KEY!,
  apiPrivHex: process.env.MOONX_AGENT_PRIVATE_KEY!,
  walletId: process.env.MOONX_AGENT_TRON_WALLET_ID!,
  secretKey: process.env.MOONX_SECRET_API_KEY!,
});

const messageSignature = await signer.signMessage('rebalance approved');

const unsignedTx = await tronWeb.transactionBuilder.sendTrx(
  'TRecipientAddressHere',
  1_000_000,
  'TAgentWalletAddressHere'
);

const signedTx = await signer.signTransaction(unsignedTx);
await tronWeb.trx.sendRawTransaction(signedTx);
```

<Info>
  Build Tron transactions with TronWeb's `TransactionBuilder`; the adapter signs the resulting transaction object and returns the shape expected by `tronWeb.trx.sendRawTransaction`.
</Info>

### Bitcoin agent signing

```ts
import { createBitcoinSigner } from '@moon-x/bitcoin-adapter';

const signer = createBitcoinSigner({
  apiPubHex: process.env.MOONX_AGENT_PUBLIC_KEY!,
  apiPrivHex: process.env.MOONX_AGENT_PRIVATE_KEY!,
  walletId: process.env.MOONX_AGENT_BITCOIN_WALLET_ID!,
  secretKey: process.env.MOONX_SECRET_API_KEY!,
});

const proof = await signer.signMessage('rebalance approved');
// proof.signature is BIP322 Simple for P2TR/P2WPKH or Full for P2PKH.

const signed = await signer.signTransaction(psbtBase64);
// signed.transactionHex is finalized and can be submitted to a
// sendrawtransaction-compatible Bitcoin RPC after your policy checks.
```

The adapter resolves the selected wallet's address type and public-key metadata from `walletId`, calculates every input digest locally, signs through SES, verifies the signatures, and finalizes the transaction. The PSBT must spend only inputs owned by the selected wallet and must satisfy the same [PSBT requirements](/wallets/using-wallets/bitcoin/sign-transaction) as browser signing.

P2WPKH and P2PKH inputs require the full previous transaction in `nonWitnessUtxo`. Taproot wallets use rootless key-path signing. Provisioning rejects non-empty `taproot_merkle_roots` policies.

<Warning>
  An ephemeral signer enforces wallet, derivation-path, expiry, and revocation policy. Your backend must still validate transaction destinations, values, fees, and rate limits before passing a PSBT to the adapter.
</Warning>

## Best Practices

<AccordionGroup>
  <Accordion title="Treat the agent private key like a server secret">
    Hand `apiPrivHex` to your backend over a secure channel and store it in a secret manager (or show it to the user once if the agent runs on their infrastructure). Never keep it in browser storage. Anything that can read the browser can then sign as the agent.
  </Accordion>

  <Accordion title="Scope agents narrowly">
    Provision an agent for exactly the wallets it needs, and set `expiresAt` for time-boxed tasks. Provision separate agents for separate jobs so revoking one doesn't break the others.
  </Accordion>

  <Accordion title="Label agents meaningfully">
    The `label` is what users see in the manage view. "DCA bot - ETH mainnet" lets a user confidently revoke the right agent later; "agent-1" doesn't.
  </Accordion>

  <Accordion title="Build a manage view">
    Give users a page that lists their non-revoked agents (`signers`) with status and a revoke button. For `needs_reauth`, disable signing actions and link to fresh provisioning. Users should always be able to see and end every delegation they've granted.
  </Accordion>

  <Accordion title="Handle the one-time reveal deliberately">
    Keep `lastProvisioned` on screen until the user confirms they've stored the keys, then call `reset()`. Don't auto-dismiss the reveal: there is no second chance to read `apiPrivHex`.
  </Accordion>
</AccordionGroup>

## Related Documentation

<CardGroup cols={2}>
  <Card title="Step-Up Authentication" icon="shield" href="/authentication/user-authentication/presence-tokens">
    Developer-facing passkey proofs for your own backend routes
  </Card>

  <Card title="Wallets Overview" icon="book" href="/wallets/overview">
    Complete guide to the MoonPay Wallets infrastructure
  </Card>

  <Card title="Create a Wallet" icon="plus" href="/wallets/embedded-wallets/create-a-wallet/overview">
    Create embedded wallets for users
  </Card>

  <Card title="Export a Wallet" icon="key" href="/wallets/embedded-wallets/export-a-wallet">
    Let users export their wallet's private key
  </Card>
</CardGroup>
