> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dakota.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Wallet Transaction Signing

> How to build, canonicalize, and sign wallet transaction intents — including the browser DER-encoding trap.

Every wallet transaction in Dakota is an **endorsed request**: a canonical JSON intent plus one or more ECDSA P-256 signatures. This page is the reference for doing that correctly. If you only want the conceptual model and the signer/policy lifecycles, read [Signing & Endorsed Requests](/documentation/signing-guide). If you've just stood up a wallet in [Common Flows](/documentation/common-flows#create-a-wallet-non-custodial), this is where you continue.

<Info>
  **Prerequisites.** You already have a wallet, a signer group, and at least one ES256 private key. If not, start with [Create a Wallet (Non-Custodial)](/documentation/common-flows#create-a-wallet-non-custodial).
</Info>

<Note>
  **Signing with a passkey or hardware authenticator?** This page covers raw `ES256` keys. If your signer is a WebAuthn credential (`key_type: WEBAUTHN`), the signature is a WebAuthn assertion bundle instead of a DER signature — follow [WebAuthn & Passkey Signing](/documentation/webauthn-signing) instead.
</Note>

## The Three Parts of a Signed Transaction

Dakota wallets use an **intent-based** model: every transaction begins with a canonical JSON description of what the wallet should do, signed with the private key of at least one signer in the wallet's signer group. Platform forwards the signed intent to the policy engine, which verifies each signature against the stored public keys and — if the approval threshold is satisfied — executes the transaction on-chain.

A signed wallet transaction has three parts:

1. **The intent** — a JSON object describing the operation. Fields use `snake_case`, amounts are strings.
2. **The canonical digest** — the intent canonicalized per [RFC 8785 JCS](https://www.rfc-editor.org/rfc/rfc8785), then hashed with SHA-256.
3. **The signature(s)** — ECDSA P-256 signatures of that digest in ASN.1 DER encoding, each base64-encoded.

## The Intent

```json theme={null}
{
  "wallet_id": "2LfZm5KMnRvLFtRP7nJJug4zJEP",
  "caip2": "eip155:1",
  "operation": {
    "kind": "transfer",
    "from": "0xYourWalletAddress...",
    "to": "0xDestinationAddress...",
    "amount": "10.5",
    "asset_id": "USDC"
  },
  "idempotency_key": "a6f8c8c0-6f0a-4a24-a3a3-9e8a0cf2f7c0"
}
```

<Note>
  Field names are `snake_case` (`wallet_id`, `asset_id`, `idempotency_key`). Amounts are **strings** (`"10.5"`, not `10.5`). The `operation.kind` field determines which sub-fields are required: `transfer` uses `from` / `to` / `amount` / `asset_id`; `evm_contract_call` additionally accepts `method` / `args` / `data`. Omit any field that doesn't apply — canonicalization drops unset fields and sorts keys alphabetically before hashing.
</Note>

## Canonicalize, Hash, and Sign

Canonicalization is what ensures that a client and the policy engine hash the same bytes regardless of JSON key ordering or whitespace. All three reference libraries below implement RFC 8785 JCS, so their output is byte-identical for the same input.

<CodeGroup>
  ```javascript Node.js theme={null}
  import { createSign } from 'node:crypto';
  import canonicalize from 'canonicalize'; // npm install canonicalize

  function signIntent(intent, privateKey) {
    const canonical = canonicalize(intent);
    const signer = createSign('SHA256');
    signer.update(canonical);
    // dsaEncoding 'der' matches Go's ecdsa.SignASN1 / the server's verify path.
    const signature = signer.sign({ key: privateKey, dsaEncoding: 'der' });
    return signature.toString('base64');
  }
  ```

  ```python Python theme={null}
  import base64
  import jcs  # pip install jcs
  from cryptography.hazmat.primitives import hashes
  from cryptography.hazmat.primitives.asymmetric import ec

  def sign_intent(intent, private_key):
      canonical = jcs.canonicalize(intent)
      # cryptography's ec.ECDSA(SHA256) signs the digest and returns ASN.1 DER.
      signature = private_key.sign(canonical, ec.ECDSA(hashes.SHA256()))
      return base64.b64encode(signature).decode('ascii')
  ```

  ```go Go theme={null}
  import (
      "crypto/ecdsa"
      "crypto/rand"
      "crypto/sha256"
      "encoding/base64"
      "encoding/json"

      "github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer"
  )

  func signIntent(intent any, priv *ecdsa.PrivateKey) (string, error) {
      raw, err := json.Marshal(intent)
      if err != nil {
          return "", err
      }
      canonical, err := jsoncanonicalizer.Transform(raw)
      if err != nil {
          return "", err
      }
      hash := sha256.Sum256(canonical)
      sig, err := ecdsa.SignASN1(rand.Reader, priv, hash[:])
      if err != nil {
          return "", err
      }
      return base64.StdEncoding.EncodeToString(sig), nil
  }
  ```

  ```javascript JavaScript (Browser) theme={null}
  import canonicalize from 'canonicalize'; // works in browsers via bundlers

  async function signIntent(intent, privateKey) {
    const canonical = canonicalize(intent);
    const data = new TextEncoder().encode(canonical);
    // WebCrypto returns raw r||s (IEEE P1363); Dakota expects ASN.1 DER.
    const rawSig = new Uint8Array(
      await crypto.subtle.sign(
        { name: 'ECDSA', hash: 'SHA-256' },
        privateKey,
        data,
      ),
    );
    const der = rawEcdsaSignatureToDer(rawSig);
    let binary = '';
    for (const b of der) binary += String.fromCharCode(b);
    return btoa(binary);
  }

  // Convert a WebCrypto ECDSA P-256 signature (64 bytes, r || s) to
  // ASN.1 DER: SEQUENCE { INTEGER r, INTEGER s }.
  function rawEcdsaSignatureToDer(raw) {
    const r = trimLeadingZeros(raw.slice(0, 32));
    const s = trimLeadingZeros(raw.slice(32, 64));
    const rDer = encodeInteger(r);
    const sDer = encodeInteger(s);
    const seqLen = rDer.length + sDer.length;
    const out = new Uint8Array(2 + seqLen);
    out[0] = 0x30; // SEQUENCE
    out[1] = seqLen;
    out.set(rDer, 2);
    out.set(sDer, 2 + rDer.length);
    return out;
  }

  function trimLeadingZeros(bytes) {
    let i = 0;
    while (i < bytes.length - 1 && bytes[i] === 0) i++;
    return bytes.slice(i);
  }

  function encodeInteger(bytes) {
    // Prepend 0x00 if high bit is set so the INTEGER stays positive.
    const needsPad = (bytes[0] & 0x80) !== 0;
    const body = needsPad ? new Uint8Array([0, ...bytes]) : bytes;
    return new Uint8Array([0x02, body.length, ...body]);
  }
  ```
</CodeGroup>

<Warning>
  **Browser only:** `crypto.subtle.sign` with ECDSA returns an **IEEE P1363** raw `r || s` encoding, not ASN.1 DER. You must convert it before submitting — the `rawEcdsaSignatureToDer` helper above does this. Submitting the raw form will fail signature verification.
</Warning>

## Submit the Signed Transaction

```bash theme={null}
curl -X POST https://api.platform.dakota.xyz/wallets/2LfZm5KMnRvLFtRP7nJJug4zJEP/transactions \
  -H "X-API-Key: $DAKOTA_API_KEY" \
  -H "X-Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "signatures": [
      "MEQCIEtPHo4edFaeOAWql3CHzcEJTX0MlUxjnqdlQwv+FYbrAiAhRAXEiruewidHx1JTofP1QQ+mJnRx6cXQ6vjCHp9wlQ=="
    ],
    "intent": {
      "wallet_id": "2LfZm5KMnRvLFtRP7nJJug4zJEP",
      "caip2": "eip155:1",
      "operation": {
        "kind": "transfer",
        "from": "0xYourWalletAddress...",
        "to": "0xDestinationAddress...",
        "amount": "10.5",
        "asset_id": "USDC"
      },
      "idempotency_key": "a6f8c8c0-6f0a-4a24-a3a3-9e8a0cf2f7c0"
    }
  }'
```

The `signatures` array holds as many entries as the signer group's approval threshold requires; each entry is a DER-then-base64 ECDSA signature from a distinct signer in the group. The `intent` object must be byte-equivalent to what was canonicalized and signed — platform re-canonicalizes the intent on the server side before signature verification, so ordering and whitespace in the wire JSON do not matter, but field values must match exactly.

## Modifying Policies, Wallets, and Signer Groups

Adding rules to a policy, attaching a signer group to a wallet, and any other mutation of a wallet's authorization graph after creation go through the same endorsed-request pattern:

1. Build an intent JSON object with a `type` discriminator field
2. Canonicalize it per RFC 8785 JCS and sign with ECDSA P-256 (use the same `signIntent` function above — it works for every intent type)
3. POST to the mutation endpoint with `{ "signatures": [...], "intent": {...} }`

The signing process is identical to transactions — only the intent schema and endpoint differ. Each intent carries a `type` field that tells the server which schema to expect.

| Operation                           | Endpoint                                                      | `type` value                |
| ----------------------------------- | ------------------------------------------------------------- | --------------------------- |
| Add a rule to a policy              | `POST /policies/{policy_id}/rules`                            | `add_policy_rule`           |
| Update a rule's definition          | `PATCH /policies/{policy_id}/rules/{rule_id}`                 | `update_policy_rule`        |
| Remove a rule from a policy         | `DELETE /policies/{policy_id}/rules/{rule_id}`                | `remove_policy_rule`        |
| Delete a policy                     | `DELETE /policies/{policy_id}`                                | `delete_policy`             |
| Attach a policy to a wallet         | `PUT /policies/{policy_id}/wallets/{wallet_id}`               | `attach_policy_to_wallet`   |
| Detach a policy from a wallet       | `DELETE /policies/{policy_id}/wallets/{wallet_id}`            | `detach_policy_from_wallet` |
| Attach a signer group to a wallet   | `PUT /wallets/{wallet_id}/signer-groups/{signer_group_id}`    | `attach_group_to_wallet`    |
| Detach a signer group from a wallet | `DELETE /wallets/{wallet_id}/signer-groups/{signer_group_id}` | `detach_group_from_wallet`  |

<Note>
  Adding or removing signers from a signer group (`POST /signer-groups/{signer_group_id}/signers` and `DELETE /signer-groups/{signer_group_id}/signers/{signer_id}`) is a **plain API call** authenticated with your API key — it does not require an endorsed intent. See [Signing & Endorsed Requests](/documentation/signing-guide) for the full list of which mutations require signatures.
</Note>

<Note>
  The signers that must endorse each intent are determined by the resource being mutated:

  * **Policy mutations** (`add_policy_rule`, `remove_policy_rule`, `update_policy_rule`, `delete_policy`) must be signed by members of the **policy's own signer group**.
  * **Wallet mutations** (`attach_policy_to_wallet`, `detach_policy_from_wallet`, `attach_group_to_wallet`, `detach_group_from_wallet`) must be signed by members of a signer group already attached to the wallet.

  The number of signatures required matches the approval threshold of the relevant policy.
</Note>

### Example: Add a Rule to an Existing Policy

The `AddPolicyRuleIntent` shape:

```json theme={null}
{
  "type": "add_policy_rule",
  "policy_id": "2LfQm5KMnRvLFtRP7nJJug4zJEP",
  "rule_type": "approval_threshold",
  "action": "allow",
  "definition": { "threshold": 2 },
  "idempotency_key": "a6f8c8c0-6f0a-4a24-a3a3-9e8a0cf2f7c0"
}
```

<Note>
  Field shape reminders:

  * `type` is required on every intent and must match the operation (e.g. `"add_policy_rule"`) — it's part of what you sign
  * `action` is `"allow"` or `"deny"`
  * `rule_type` is `"approval_threshold"`, `"amount_threshold"`, or `"address_list"`
  * `definition` is a flat object for the chosen `rule_type` — e.g. `{"threshold": 2}` for `approval_threshold`, `{"addresses": [...]}` for `address_list`
</Note>

Sign the intent with the same `signIntent` function from above, then submit:

```bash theme={null}
curl -X POST https://api.platform.dakota.xyz/policies/2LfQm5KMnRvLFtRP7nJJug4zJEP/rules \
  -H "X-API-Key: $DAKOTA_API_KEY" \
  -H "X-Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "signatures": [
      "MEYCIQCr24vqv9xdz92Kj8xMsTxd8cOalqiRCuXzjYdDSA/VtgIhAPzJqR/tvG8eUgX/b4sTL6/+bCpaliRa/r5Y1toKJkSl"
    ],
    "intent": {
      "type": "add_policy_rule",
      "policy_id": "2LfQm5KMnRvLFtRP7nJJug4zJEP",
      "rule_type": "approval_threshold",
      "action": "allow",
      "definition": { "threshold": 2 },
      "idempotency_key": "a6f8c8c0-6f0a-4a24-a3a3-9e8a0cf2f7c0"
    }
  }'
```

The intent schemas for the other mutations are listed in the [OpenAPI reference](/api-reference). They follow the same conventions: snake\_case fields, a `type` discriminator matching the operation, and string enum values.
