# Sign a transaction

The wallet sign endpoint signs an on-chain transaction with a wallet in one of your [vaults](/develop/vaults-wallets). You build and serialize the unsigned transaction for the target network and submit it; the Crypto Vault decodes it, evaluates the vault's Signing Policy against the decoded transfer (network, asset, destination, USD value), and returns the signature. You assemble and broadcast the transaction; the vault never broadcasts.

A transaction the policy engine cannot decode matches no transfer rule and is denied; the vault never signs blind. See the [Wallets reference](/api-reference#tag/wallets) for the full endpoint schemas.

## Request

```
POST /v1/vaults/{vault_id}/wallets/{address}/sign
```

All four authentication headers are required. See [Machine Users and Agents](/develop/authentication/machine-users) for how to build them.

Request body:

```json
{
  "network": "<network name or CAIP-2 id>",
  "transaction": {
    "blob": "<serialized unsigned transaction>",
    "encoding": "hex"
  }
}
```

| Field | Required | Description |
|---|---|---|
| `network` | yes | The network the transaction targets. Must be one of the wallet's networks. Accepts the network name (case-insensitive, for example `"Ethereum"`) or the CAIP-2 id (`"eip155:1"`). |
| `transaction.blob` | yes | The raw unsigned transaction, serialized for the target network and encoded per `encoding`. Not a JSON transaction object. |
| `transaction.encoding` | yes | How `blob` is encoded: `"hex"`, `"base64"`, or `"base58"`. |

Sign an EVM transaction (hex-encoded) with a wallet in the vault `treasury`:

```bash
curl -s -X POST "$CV_BASE/v1/vaults/treasury/wallets/0x7E5F4552091A69125d5DfCb7B8C2659029395Bdf/sign" \
  -H "Authorization: ApiKey $API_KEY_UUID" \
  -H "X-Request-Timestamp: $TS_HEX" \
  -H "X-Request-Nonce: $NONCE_HEX" \
  -H "X-Request-Assertion: $ASSERTION" \
  -H "Content-Type: application/json" \
  -d '{
    "network": "Ethereum",
    "transaction": {
      "blob": "02ef0182012345",
      "encoding": "hex"
    }
  }'
```

For a Solana wallet, submit the serialized transaction in `base64` (or `base58`):

```json
{
  "network": "Solana",
  "transaction": {
    "blob": "AQABAgMEBQYHCAk=",
    "encoding": "base64"
  }
}
```

## Response

The endpoint is synchronous: it returns `200` with the signature or an immediate error, never a `request_id`, and never enters a pending state. The shape depends on the wallet's network family.

An EVM wallet returns an `ecdsa_secp256k1` signature:

```json
{
  "scheme": "ecdsa_secp256k1",
  "signature": {
    "full": "<0x-prefixed hex, 65 bytes: r || s || v>",
    "r": "<0x + 64 hex chars, zero-padded>",
    "s": "<0x + 64 hex chars>", /* low-S (EIP-2) guaranteed */
    "v": 27, /* 27 + y_parity */
    "y_parity": 0 /* 0 or 1, the recovery bit typed transactions use */
  }
}
```

A Solana wallet returns an `ed25519` signature:

```json
{
  "scheme": "ed25519",
  "signature": {
    "full": "<0x-prefixed hex, 64-byte Ed25519 signature>"
  }
}
```

Attach the signature to the unsigned transaction (typed EVM transactions take `y_parity`; formats that expect `v` get `27 + y_parity`), then broadcast the signed transaction yourself. For the stages a signing request passes through, see [Signing request lifecycle](/develop/signing/request-lifecycle).

## How policy is applied

Before signing, the Crypto Vault evaluates the vault's Signing Policy against the decoded transfer. On-chain transfers are governed by transfer rules, which combine:

* `network`: the networks the rule applies to (`any_network` or an explicit list).
* `asset`: the assets it permits (`any_asset` or entries with optional per-asset amount caps: operator `lte`, `gte`, or `eq` with a decimal-string value in the asset's units).
* `destination`: the recipients it permits (`any_destination` or an allowlist of addresses, each listing the networks it applies on).
* `usd_value` (optional): a bound on the transfer's USD value, using the same operator and decimal-string shape.

Rules are evaluated in order and the first match wins. Deny is the default: a transfer that matches no rule is denied, and so is a transaction the policy engine cannot decode. See [The policy model](/introduction/policy-model) for the concepts and the [Signing Policies reference](/api-reference#tag/signingpolicies) for the full rule schema.

## Supported networks and assets

Transfer rules can name these assets on these networks:

| Asset | Networks |
|---|---|
| `eth` | Ethereum, Arbitrum, Optimism, Base, EthereumSepolia, BaseSepolia |
| `pol` | Polygon |
| `avax` | Avalanche |
| `bnb` | Bsc |
| `sol` | Solana |
| `usdc` | Ethereum, Arbitrum, Optimism, Polygon, Base, Avalanche, Solana, EthereumSepolia, BaseSepolia |
| `usdt` | Ethereum, Avalanche, Solana, EthereumSepolia |

Native coins use 18 decimals on EVM networks and 9 on Solana; the stablecoins use 6. A transfer of any token outside this set does not decode to a recognized transfer: it matches no transfer rule and is denied; the vault never signs blind.

:::note
`usd_value` rules price transfers with the built-in price feed and fail closed: when no fresh price exists for an asset, a matching `usd_value` rule denies the transfer. Avalanche USDT is transferable but not priced, so a `usd_value` rule always denies it (rules without `usd_value` work normally). Testnet assets price via their mainnet counterpart.
:::

## End-to-end examples

Both walkthroughs create a vault, generate a wallet, sign a transfer, and broadcast it. They assume the vault's Signing Policy has a transfer rule that allows the transfer; a transfer that matches no rule is denied. `cvFetch(method, path, body)` is a small wrapper that sends the request with the four authentication headers (and polls `202` responses); build it from the transcript helper in [Machine Users and Agents](/develop/authentication/machine-users).

### Ethereum with viem (EVM)

Install [viem](https://viem.sh) (`npm install viem`). The comments mark the only lines that change for Base:

```js
import { createPublicClient, http, parseEther, parseGwei, serializeTransaction } from 'viem';
import { mainnet } from 'viem/chains'; // Base: import { base } from 'viem/chains'
import { cvFetch } from './cv-auth.js';

// --- 1. Create a vault and generate an EVM wallet (one-time setup) ---
await cvFetch('POST', '/v1/vaults', {
  id: 'treasury',
  display_name: 'Treasury',
});
const wallet = await cvFetch('POST', '/v1/vaults/treasury/wallets', {
  display_name: 'ops-wallet',
  network_family: 'evm',
  networks: ['Ethereum'], // Base: ['Base'] (or both; an evm wallet has one address on every EVM network)
});
const address = wallet.address;

// --- 2. Build and serialize the unsigned transaction ---
const client = createPublicClient({ chain: mainnet, transport: http() }); // Base: chain: base
const tx = {
  chainId: mainnet.id, // Base: base.id (8453)
  type: 'eip1559',
  nonce: await client.getTransactionCount({ address }),
  to: '0x52908400098527886E0F7030069857D2E4169EE7',
  value: parseEther('0.05'),
  gas: 21000n,
  maxFeePerGas: parseGwei('20'),
  maxPriorityFeePerGas: parseGwei('1'),
};
const unsigned = serializeTransaction(tx);

// --- 3. Sign with the wallet ---
const res = await cvFetch('POST', `/v1/vaults/treasury/wallets/${address}/sign`, {
  network: 'Ethereum', // Base: 'Base'
  transaction: { blob: unsigned.slice(2), encoding: 'hex' }, // hex blob, no 0x prefix
});

// --- 4. Attach the signature and broadcast yourself ---
const { r, s, y_parity } = res.signature;
const signed = serializeTransaction(tx, { r, s, yParity: y_parity });
const hash = await client.sendRawTransaction({ serializedTransaction: signed });
console.log('broadcast:', hash);
```

### Solana with Solana Kit

Install [Solana Kit](https://solana.com/docs/clients/official/javascript#solana-kit) (`npm install @solana/kit @solana-program/system`). The wallet is a `createNoopSigner`: it marks which account must sign, while the actual signature comes from the Crypto Vault:

```js
import {
  address, appendTransactionMessageInstructions, compileTransaction,
  createNoopSigner, createSolanaRpc, createTransactionMessage,
  getBase64EncodedWireTransaction, lamports, pipe,
  setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash,
} from '@solana/kit';
import { getTransferSolInstruction } from '@solana-program/system';
import { cvFetch } from './cv-auth.js';

// --- 1. Create a vault and generate a Solana wallet (one-time setup) ---
await cvFetch('POST', '/v1/vaults', {
  id: 'sol-treasury',
  display_name: 'Solana Treasury',
});
const wallet = await cvFetch('POST', '/v1/vaults/sol-treasury/wallets', {
  display_name: 'ops-wallet',
  network_family: 'solana',
  networks: ['Solana'],
});
const walletSigner = createNoopSigner(address(wallet.address)); // the Crypto Vault signs, not a local key

// --- 2. Build the transfer and compile the unsigned transaction ---
const rpc = createSolanaRpc('https://api.mainnet-beta.solana.com');
const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
const message = pipe(
  createTransactionMessage({ version: 0 }),
  (m) => setTransactionMessageFeePayerSigner(walletSigner, m),
  (m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),
  (m) => appendTransactionMessageInstructions([
    getTransferSolInstruction({
      source: walletSigner,
      destination: address('9aE476sH92Vz7DMPyq5WLPkrKWivxeuTKEFKd2sZZcde'),
      amount: lamports(50_000_000n), // 0.05 SOL
    }),
  ], m),
);
const transaction = compileTransaction(message);

// --- 3. Sign with the wallet (the blob is the unsigned wire transaction, base64) ---
const res = await cvFetch('POST', `/v1/vaults/sol-treasury/wallets/${wallet.address}/sign`, {
  network: 'Solana',
  transaction: { blob: getBase64EncodedWireTransaction(transaction), encoding: 'base64' },
});

// --- 4. Attach the 64-byte Ed25519 signature and broadcast yourself ---
const sigBytes = Buffer.from(res.signature.full.slice(2), 'hex');
const signed = { ...transaction, signatures: { [wallet.address]: sigBytes } };
await rpc.sendTransaction(getBase64EncodedWireTransaction(signed), { encoding: 'base64' }).send();
```

## Raw signing with a wallet

Wallets can also sign arbitrary bytes that are not decoded or interpreted as a transaction:

```
POST /v1/vaults/{vault_id}/wallets/{address}/raw-sign
```

| Field | Required | Description |
|---|---|---|
| `message` | yes | Hex-encoded bytes to sign. |
| `extra_data` | no | Hex-encoded opaque data made available to policy evaluation. Not part of the signed message. |

An EVM wallet signs `keccak256(message)` with no EIP-191 prefix, so submit the full preimage rather than a pre-computed digest (a digest would be hashed again). A Solana wallet signs the message bytes verbatim. The response is the same signature shape as the sign endpoint.

Raw signing is gated by a `raw_signing` rule in the vault's Signing Policy; a transfer rule never approves a raw-sign. For raw signing with an off-chain key, see [Raw signing](/develop/signing/raw-sign).

:::warning\[Raw signing grants transaction-equivalent power]
In a vault's Signing Policy a `raw_signing` rule grants transaction-equivalent signing power (an EVM wallet raw-sign covers `keccak256(message)`, which is exactly a transaction's signing hash; an Ed25519 wallet signs the bytes verbatim), so transfer conditions such as `destination` and `usd_value` do not bound what it can sign. Scope such rules tightly via user groups and security groups.
:::

## Errors

Both endpoints share the common error shape:

```json
{ "err_type": "forbidden", "err_msg": "<reason>" }
```

| Status | `err_type` | Meaning |
|---|---|---|
| `400` | `parse_error` | Malformed request body. |
| `401` | `unauthorized` | Auth headers missing, invalid, or expired. |
| `403` | `forbidden` | The Signing Policy denied the request, the [wallet or vault is frozen](/develop/freeze), or the transaction could not be decoded. |
| `404` | `resource_not_found` | Vault or wallet not found. |
| `422` | `invalid_inputs` | Invalid field values, for example a transaction whose embedded chain does not match the requested `network`. |
| `500` | `internal_server_error` | MPC signing failure or internal error. |
