# Sign a Wallet Step

This guide covers how to sign a step that originates funds movement out of a self-custodial wallet. Applies to payments, rebalances, and withdrawals. Tesser orchestrates the funds-movement lifecycle and prepares the unsigned transaction, but cannot submit the on-chain transaction itself — you must sign client-side with your wallet's signing key, then return the signature to Tesser via the resource-appropriate sign endpoint. Deposits via a liquidity provider — even those that land in a self-custodial wallet — don't require customer-signed steps, as the wallet is a destination for the deposit, not a source of funds. The wallet-step signing flow covered here is for resources where the customer's wallet is the **source** of funds.

:::note
Gas for on-chain transfers initiated through this flow is sponsored by Tesser. You do not need to fund the source wallet with native gas tokens — only the asset being transferred needs to be present on the source wallet.
:::

The shape of the signing flow — the SDK call, the request body, the validation error codes, and the on-chain follow-up — is the same across all three resource types. Only the URL of the sign endpoint differs by resource.

## Step Status Flow

A wallet-source step transitions through the following states:

<table className="status-flow-table">
  <thead>
    <tr>
      <th>Status</th>
      <th>Webhook event</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>`created`</td>
      <td>(none)</td>
      <td>The step has been planned and inserted into `step_sequence` but is not yet ready to sign.</td>
    </tr>
    <tr>
      <td>`signature_requested`</td>
      <td>`step.signature_requested`</td>
      <td>Tesser has prepared the unsigned transaction; the step DTO now carries `unsigned_transaction`. You sign client-side and submit the signature to the sign endpoint.</td>
    </tr>
    <tr>
      <td>`signed`</td>
      <td>`step.signed`</td>
      <td>The sign endpoint accepted your signature and the transaction is ready to broadcast. It may rest here while earlier nonces on the same wallet clear; `submitted` marks the actual broadcast attempt. `transaction_hash` is already populated at this point — it is derived from the signed payload, so it is known before broadcast. Treat it as the identifier to correlate against, not as proof that the transaction has reached the network.</td>
    </tr>
    <tr>
      <td>`submitted`</td>
      <td>`step.submitted`</td>
      <td>Tesser has broadcast the signed transaction to the network. For Turnkey-managed wallets (the self-custodial wallet provider in scope for this flow), `step.submitted` is an event-only marker — the persisted step row transitions directly from `signed` to `confirmed` once Tesser observes the broadcast result (mempool acceptance), while the webhook payload and GET response both surface `status: "submitted"` and a populated `submitted_at` at the event boundary.</td>
    </tr>
    <tr>
      <td>`confirmed`</td>
      <td>`step.confirmed`</td>
      <td>The transaction has been broadcast and accepted into the network's mempool — it is not yet included in a block. `transaction_hash` is confirmed against the value the node returned.</td>
    </tr>
    <tr>
      <td>`completed`</td>
      <td>`step.completed`</td>
      <td>Funds have settled at the destination. The step is terminal.</td>
    </tr>
  </tbody>
</table>

## The `unsigned_transaction` Field

When a wallet-source step reaches `signature_requested`, Tesser publishes the unsigned transaction on the step DTO as the `unsigned_transaction` field. The value is the serialized transaction call, ready to sign with the source wallet's key. The same field is retrievable at any time via a `GET` request on the parent resource: `` `GET /v1/payments/{paymentId}` ``, `` `GET /v1/treasury/rebalances/{rebalanceId}` ``, or `` `GET /v1/treasury/withdrawals/{withdrawalId}` ``.

Example field shape (truncated for display — the actual value is much longer):

```json
{
  "unsigned_transaction": "0x02ed81893a85 ... 942e8f4c6b3a1d48e79b ... 3a764000080c0"
}
```

## Sign the Step with the LocalSigner SDK

Tesser publishes a signer-only SDK in two languages: [TypeScript](https://github.com/tesser-payments/sdk-ts) (`@tesser-payments/sdk-ts`) and [Kotlin](https://github.com/tesser-payments/sdk-kotlin) (`xyz.tesser:sdk`). Both expose a `LocalSigner` that stamps the step locally with your signing key and returns a `signature` string. The SDK does **not** make HTTP calls. Obtaining a token, looking up the wallet address, and submitting the signature are yours to do (any HTTP client works).

### Install

<Tabs>
  <TabItem label="TypeScript">

```bash
bun add @tesser-payments/sdk-ts @tesser-payments/types
```

`@tesser-payments/types` is a peer dependency; install it alongside the SDK.

  </TabItem>
  <TabItem label="Kotlin">

```kotlin title="build.gradle.kts"
dependencies {
  implementation("xyz.tesser:sdk:0.0.3")
}
```

Targets JVM 17+ and Kotlin 2.0+. `signStep` is a `suspend` function, so call it from a coroutine.

  </TabItem>
</Tabs>

### Sign

To sign, construct a `StepForSigning` from the webhook step and call `signStep`. You supply three values: the `unsigned_transaction` from the step, the source wallet's on-chain address, and the network. Resolve the on-chain address with `GET /v1/accounts/{account_id}` using the step's `estimated.from.account_id` (the public, GET-resolvable account ID); its `crypto_wallet_address` is the `signWith` value.

<Tabs>
  <TabItem label="TypeScript">

```typescript
import { LocalSigner, type StepForSigning } from "@tesser-payments/sdk-ts";

// Initialize the signer once at application startup.
const signer = new LocalSigner({
  signing: {
    publicKey: process.env.SIGNING_PUBLIC_KEY!,
    privateKey: process.env.SIGNING_PRIVATE_KEY!,
    enclaveId: process.env.SIGNING_ENCLAVE_ID!,
  },
});

// 1. Extract the step from a step.signature_requested webhook event.
const step = webhookPayload.data.object;

// 2. Resolve the source wallet's on-chain address.
const account = await fetch(
  `https://api.tesser.xyz/v1/accounts/${step.estimated.from.account_id}`,
  { headers: { Authorization: `Bearer ${token}` } }
).then((res) => res.json());

// 3. Build the StepForSigning payload and sign locally.
const toSign: StepForSigning = {
  unsignedTransaction: step.unsigned_transaction,
  signWith: account.crypto_wallet_address,
  network: step.estimated.from.network, // BASE | BASE_SEPOLIA | ETHEREUM | POLYGON | POLYGON_AMOY | SOLANA
};
const { signature } = await signer.signStep(toSign);

// 4. Submit the signature to the resource-appropriate sign endpoint
//    (replace <resource-path> per the table in the next section).
await fetch(
  `https://api.tesser.xyz/v1/<resource-path>/${step.payment_id ?? step.rebalance_id ?? step.withdrawal_id}/steps/${step.id}/sign`,
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({ signature }),
  }
);
```

`signStep` returns `{ signature, metadata }`. Send `signature` to the sign endpoint to execute the step; `metadata` (`stampHeaderName`, `stampHeaderValue`, `body`) stays client-side for debugging and is never sent to Tesser.

  </TabItem>
  <TabItem label="Kotlin">

```kotlin
import kotlinx.coroutines.runBlocking
import xyz.tesser.sdk.LocalSigner
import xyz.tesser.sdk.SigningConfig
import xyz.tesser.sdk.StepForSigning

// Initialize the signer once at application startup.
val signer = LocalSigner(
  SigningConfig(
    publicKey = System.getenv("SIGNING_PUBLIC_KEY"),
    privateKey = System.getenv("SIGNING_PRIVATE_KEY"),
    enclaveId = System.getenv("SIGNING_ENCLAVE_ID"),
  ),
)

runBlocking {
  // `step` is the step.signature_requested webhook's data.object (parsed).
  // Resolve the source wallet's on-chain address via GET /v1/accounts/{id};
  // `fetchAccount` / `submitSignature` wrap your own HTTP client.
  val signWith = fetchAccount(step.estimated.from.accountId).cryptoWalletAddress

  val toSign = StepForSigning(
    id = step.id,
    transferId = step.rebalanceId, // or paymentId / withdrawalId per resource
    unsignedTransaction = step.unsignedTransaction,
    signWith = signWith,
    network = step.estimated.from.network, // BASE | BASE_SEPOLIA | ETHEREUM | POLYGON | POLYGON_AMOY | SOLANA
  )

  val signed = signer.signStep(toSign)

  // POST /v1/<resource-path>/{transferId}/steps/{stepId}/sign
  submitSignature(toSign.transferId, toSign.id, signed.signature)
}
```

`signStep` returns a `SignedStepResult` with `signature` (send this to the sign endpoint), `unsignedTransaction` (an echo of the input, for audit trails), and `metadata` (`stampHeaderName`, `stampHeaderValue`, `body`) for debugging. Only `signature` is sent to Tesser.

  </TabItem>
</Tabs>

For runnable end-to-end scripts (webhook-driven and polling-based), see the `examples/` directory in the [TypeScript](https://github.com/tesser-payments/sdk-ts/tree/main/examples) and [Kotlin](https://github.com/tesser-payments/sdk-kotlin/tree/main/examples) repos.

## Sign-Endpoint Contract

The sign endpoint takes a single JSON body with one field — the `signature` returned by `LocalSigner.signStep`:

```json
{
  "signature": "<result.signature from LocalSigner.signStep>"
}
```

The body shape is identical across all three resources. Only the endpoint URL differs:

| Resource | Endpoint |
|---|---|
| Payment | [`POST /v1/payments/{paymentId}/steps/{stepId}/sign`](/api/payments#sign-payment-step) |
| Rebalance | `POST /v1/treasury/rebalances/{rebalanceId}/steps/{stepId}/sign` |
| Withdrawal | `POST /v1/treasury/withdrawals/{withdrawalId}/steps/{stepId}/sign` |

Use the URL that matches the parent resource — i.e., the resource that emitted the `step.signature_requested` webhook. The per-resource how-tos ([Send a Payout (from a wallet)](/how-tos/send-a-stablecoin-payout/create-a-payout-from-a-wallet), [Rebalance Funds](/how-tos/rebalance-funds), [Withdraw Funds via a Liquidity Provider](/how-tos/withdraw-funds-via-a-liquidity-provider)) carry the surrounding scenario context for when wallet-step signing applies in each flow.

## Signing Several Transfers from One Wallet

On-chain transactions from a wallet must reach the network in nonce order, and each transfer's nonce is fixed when Tesser prepares its unsigned transaction. Tesser queues them for you: a signed transfer whose predecessors have not been broadcast yet is held, then sent automatically as soon as they are.

What you see when a transfer is queued:

- The sign call returns **200** and the step stays `signed` — the signature was accepted, funds are reserved. `transaction_hash` is already populated (it is derived from the signed payload), but `submitted_at` stays `null` until the transfer's turn comes: the hash identifies the transaction, it does not mean the transaction was broadcast.
- When its turn comes, `step.submitted` and `step.confirmed` fire as usual, and `submitted_at` is stamped. No retry or second call is needed.

Two signing patterns are safe: **sign the batch in parallel** (arrival order does not matter — the queue sorts them), or **sign sequentially in nonce order**. What is not safe is signing a later transfer first and then taking your time over its predecessors. A queued transfer waits roughly 12 seconds; if the transfers ahead of it have not been broadcast by then — because they were signed too late, or never signed at all — Tesser fails the whole outstanding group for that wallet **on that network** (the same address on another network has its own independent queue). The waiting transfer fails with `transfers-9311` and the rest with `transfers-9312`, each naming the transfer that caused it. Nothing reaches the chain, reservations are released, and the wallet is immediately reusable. Create new transfers to retry.

## Sign-Endpoint Validation Error Codes

The sign endpoint validates the submitted signature before accepting it. Validation failures are surfaced as 4XX responses with an `error_code` identifying the cause. The rebalance and withdrawal sign endpoints share the `treasury-30xx` namespace; the payment sign endpoint uses `payments-30xx`.

| Semantic | Rebalance + Withdrawal | Payment |
|---|---|---|
| Invalid signature | `treasury-3015` | `payments-3013` |
| Signed transaction does not match step/payment | `treasury-3016` | `payments-3014` |
| Step not signable (wrong state) | `treasury-3013` | — (no direct equivalent — see [Errors overview](/overviews/errors)) |
| Unsigned transaction missing | `treasury-3014` | — (no direct equivalent — see [Errors overview](/overviews/errors)) |

Two of the four sign-validation semantics have direct counterparts across both code families; the other two — "step not signable" and "unsigned transaction missing" — are only surfaced as distinct codes by the treasury endpoints today. For the payment sign endpoint, those conditions are surfaced by adjacent codes in the broader `payments-30xx` range (for example, `payments-3026` "Transfer step is already in status `<status>`. Cannot execute."). See the full [Errors overview](/overviews/errors) for the per-domain reference.

## Sign-Endpoint Failure Response

A validation failure returns a 4XX response in the standard Tesser error envelope — a top-level `errors` array of error objects, each carrying an `error_code` from the table above. Example using `treasury-3015` (invalid signature — the most commonly reproducible failure during integration testing):

```json
{
  "errors": [
    {
      "error_code": "treasury-3015",
      "error_message": "Signature is invalid or could not be verified"
    }
  ]
}
```

The equivalent response from the payment sign endpoint uses `error_code: "payments-3013"` (`"signature is malformed or signed with incorrect key"`). The envelope shape is the same; only the resource-domain prefix on the code differs.

After a validation failure, the step's status remains `signature_requested` and the step-level `actual.*` overlay remains all-null — the failure is at the sign endpoint, not at the step. Retry the signing flow with a fresh signature. For `treasury-3013` ("step not signable"), the step may have advanced beyond `signature_requested` while you were preparing the signature — fetch the latest step state via `GET` on the parent resource before retrying.

## Wallet-Source Insufficient Funds at Sign Time

The sign endpoint also validates that the source wallet has sufficient funds to cover `desired.from.amount` before accepting the signature. The balance check is synchronous: it runs inside the same request that submits the signature.

The full sequence:

1. **Sufficient funds.** The sign endpoint accepts the signature, emits `step.signed`, and emits `<resource>.balance_updated` with `balance_status: "reserved"` from the sign-API path.
2. **Insufficient funds.** The sign endpoint returns a 4XX response and emits `<resource>.balance_updated` with `balance_status: "awaiting_funds"`. The signature is not consumed; the step stays at `signature_requested`.
3. **On-chain funding watcher.** Tesser monitors the source wallet's on-chain balance. When the wallet is funded enough to cover `desired.from.amount`, Tesser automatically republishes `step.signature_requested` with a refreshed `signature_requested_at`. Repeat the signing flow with the refreshed step.
4. **Expiration.** The retry loop continues until the wallet is funded or the parent resource hits `expires_at`. If the resource expires, the funds-movement is terminal — create a new resource to retry.

For per-resource example payloads of the `balance_updated` webhook and the surrounding scenario context, see each guide's Balance Check section:

- [Payout Balance Check (wallet payout)](/how-tos/send-a-stablecoin-payout/create-a-payout-from-a-wallet#payout-balance-check-paymentbalance_updated)
- [Rebalance Balance Check](/how-tos/rebalance-funds#rebalance-balance-check-rebalancebalance_updated)
- [Withdrawal Balance Check](/how-tos/withdraw-funds-via-a-liquidity-provider#withdrawal-balance-check-withdrawalbalance_updated)

## See Also

For the scenario-specific context around wallet-step signing in each resource type:

- [Send a Payout (from a wallet)](/how-tos/send-a-stablecoin-payout/create-a-payout-from-a-wallet) — wallet-source payouts to a counterparty wallet (stablecoin) or fiat off-ramp.
- [Rebalance Funds — Scenario 3](/how-tos/rebalance-funds#scenario-3-on-chain-transfer-between-self-custodial-wallets) — on-chain transfer between two self-custodial wallets.
- [Withdraw Funds via a Liquidity Provider — Scenario 3](/how-tos/withdraw-funds-via-a-liquidity-provider#scenario-3-on-chain-transfer-from-a-self-custodial-wallet-then-off-ramp-at-openfx) — on-chain transfer from a self-custodial wallet, then off-ramp at OpenFX.

For the funds-movement lifecycle, status taxonomies, and overlay model that contextualize wallet-step signing within a resource's broader flow:

- [Funds Movement Lifecycle and Data Model](/overviews/funds-movement-lifecycle-and-data-model)