Skip to main content
This guide walks through the changes needed to upgrade the example AMM Solver from public intents to confidential intents. If you haven’t already, read the public Example Solver guide first to understand the baseline implementation.

Overview

Migrating to confidential intents requires changes in four areas:
AreaChange
ConfigurationAdd private relay URL, contract, salt, and treasury account
Token identifiersConvert public asset IDs to confidential imt: format
Nonce generationSwitch from deterministic nonces to versioned nonces
WebSocket connectionConnect to the private relay with acknowledgement support
The core quoting logic and AMM pricing remain unchanged — the same token_diff intent structure works for both public and confidential swaps.

Configuration

Environment variables

Add the confidential intents configuration to your environment file:
# Solver mode
SOLVER_MODE=confidential

# Private relay connection (contact Defuse team for access)
PRIVATE_RELAY_WS_URL=wss://your-private-relay-url.chaindefuser.com/ws

# Confidential intents contract (used in signed payloads)
PRIVATE_INTENTS_CONTRACT=intents.far
PRIVATE_INTENTS_CONTRACT_SALT=e110f317

# Treasury account for asset wrapping
PRIVATE_TREASURY_ACCOUNT_ID=51e8f94d77b5e90dc9852ca6113771e11e8382ce69473a75e313e41665de7cbe

# 1Click API for balance queries and liquidity management
ONE_CLICK_BASE_URL=https://1click.chaindefuser.com

# Stable identifier for guaranteed delivery
SOLVER_INSTANCE_ID=my-solver-1

# Partner JWT (used for both relay auth and 1Click API)
PARTNER_JWT=your_partner_jwt_here
PRIVATE_RELAY_WS_URL, PRIVATE_INTENTS_CONTRACT, PRIVATE_INTENTS_CONTRACT_SALT, PRIVATE_TREASURY_ACCOUNT_ID, and ONE_CLICK_BASE_URL are production constants shared by all solver operators. Only SOLVER_INSTANCE_ID and PARTNER_JWT are solver-specific.

Solver mode

Create a mode toggle to switch between public and confidential:
export enum SolverMode {
  PUBLIC = 'public',
  CONFIDENTIAL = 'confidential',
}

export const solverMode = process.env.SOLVER_MODE === 'confidential' 
  ? SolverMode.CONFIDENTIAL 
  : SolverMode.PUBLIC;

export const isConfidentialMode = solverMode === SolverMode.CONFIDENTIAL;

Token identifiers

Converting public to private asset IDs

Confidential assets wrap the public token ID with the treasury account:
const privateTreasuryAccountId = process.env.PRIVATE_TREASURY_ACCOUNT_ID;

export function privateAssetIdentifier(assetId: string): string {
  return `imt:${privateTreasuryAccountId}:${assetId}`;
}

// Example:
// Input:  "nep141:wrap.near"
// Output: "imt:51e8f94d...7cbe:nep141:wrap.near"
The solver uses these wrapped identifiers when:
  • Subscribing to quote requests
  • Checking token pair support
  • Building token_diff intents
For 1Click API balance queries, use the public asset identifiers (without the imt: prefix). The API strips the prefix from response keys. See the Balance queries section for details.

Token configuration

Update the tokens configuration to conditionally use private identifiers:
export const publicTokens = [
  `nep141:${process.env.AMM_TOKEN1_ID}`,
  `nep141:${process.env.AMM_TOKEN2_ID}`
];

export const tokens = isConfidentialMode 
  ? publicTokens.map(token => privateAssetIdentifier(token)) 
  : publicTokens;

Nonce generation

Versioned nonces

Confidential intents require nonces that encode the contract salt and deadline. Use VersionedNonceBuilder from the intents SDK:
import { VersionedNonceBuilder } from '@defuse-protocol/intents-sdk';

public generateVersionedNonce(deadline: Date): string {
  const saltHex = process.env.PRIVATE_INTENTS_CONTRACT_SALT;
  const salt = Buffer.from(saltHex, 'hex');
  
  return VersionedNonceBuilder.encodeNonce(salt, deadline);
}
The salt must be exactly 4 bytes. The production salt e110f317 is a shared constant used by all solvers — do not change it. See the main confidential intents page for more details.

Choosing the nonce strategy

In the quoter service, select the nonce based on the solver mode:
const quoteDeadlineMs = params.min_deadline_ms + quoteDeadlineExtraMs;
const deadline = new Date(Date.now() + quoteDeadlineMs);

// Use versioned nonces for confidential, deterministic for public
const nonce = isConfidentialMode 
  ? this.intentsService.generateVersionedNonce(deadline) 
  : currentState.nonce;
Unlike public intents where the nonce only changes after a trade settles, confidential intents use a fresh nonce for each quote. This removes the throughput limitation of one trade per nonce.

Intent signing

Recipient contract

Confidential intents sign against a different contract ID:
export const intentsContract = process.env.INTENTS_CONTRACT || 'intents.near';
export const privateIntentsContract = process.env.PRIVATE_INTENTS_CONTRACT || '';

export const activeIntentsContract = isConfidentialMode 
  ? privateIntentsContract 
  : intentsContract;
The recipient field in the signed payload uses this active contract:
const messageStr = JSON.stringify(message);
const recipient = activeIntentsContract; // "intents.far" for confidential
const nonce = isConfidentialMode 
  ? this.intentsService.generateVersionedNonce(deadline) 
  : currentState.nonce;

const quoteHash = serializeIntent(messageStr, recipient, nonce, 'nep413');
const signature = await nearService.signMessage(quoteHash);

Intent structure

The token_diff intent structure is identical for both modes — only the asset identifiers and recipient change:
const message: IMessage = {
  signer_id: nearService.getIntentsAccountId(),
  deadline: deadline.toISOString(),
  intents: [
    {
      intent: 'token_diff',
      diff: {
        // Confidential asset identifiers
        [params.defuse_asset_identifier_in]: params.exact_amount_in 
          ? params.exact_amount_in 
          : amount,
        [params.defuse_asset_identifier_out]: `-${
          params.exact_amount_out ? params.exact_amount_out : amount
        }`,
      },
    },
  ],
};

WebSocket connection

Private relay URL

Configure the WebSocket to connect to the private relay with an instance ID for guaranteed delivery:
const publicRelayWsUrl = process.env.RELAY_WS_URL || 'wss://solver-relay-v2.chaindefuser.com/ws';
const privateRelayWsUrl = process.env.PRIVATE_RELAY_WS_URL || '';
const solverInstanceId = process.env.SOLVER_INSTANCE_ID?.trim();

function withSolverInstanceId(url: string): string {
  if (!solverInstanceId) return url;
  
  const relayUrl = new URL(url);
  relayUrl.searchParams.set('instance_id', solverInstanceId);
  return relayUrl.toString();
}

export const wsRelayUrl = isConfidentialMode 
  ? withSolverInstanceId(privateRelayWsUrl) 
  : publicRelayWsUrl;

Authentication

Both relays require a Partner JWT in the connection headers:
const partnerJwt = process.env.PARTNER_JWT?.trim();

export const wsRelayOptions: ClientOptions | undefined = partnerJwt
  ? {
      headers: {
        Authorization: `Bearer ${partnerJwt}`,
      },
    }
  : undefined;
Then pass the options when creating the WebSocket:
this.wsConnection = wsRelayOptions 
  ? new WebSocket(wsRelayUrl, wsRelayOptions) 
  : new WebSocket(wsRelayUrl);

Guaranteed delivery

Quote status acknowledgements

The private relay uses an acknowledgement mechanism to guarantee delivery of quote status updates. When subscribing to quote_status_extended, pass a third parameter to enable acknowledgements:
private getSubscribeParams(eventKind: RelayEventKind) {
  if (isConfidentialMode && eventKind === RelayEventKind.QUOTE_STATUS_EXTENDED) {
    // [eventKind, filter, enableAcknowledgements]
    return [eventKind, null, true];
  }
  
  return [eventKind];
}

Processing with acknowledgements

Each quote_status event includes a sequence number. The solver must acknowledge receipt before processing:
private async acknowledgeQuoteStatus(
  params: Record<string, unknown>, 
  logger: LoggerService
): Promise<boolean> {
  if (!isConfidentialMode) {
    return true; // No acknowledgement needed for public relay
  }

  if (typeof params.subscription !== 'string' || typeof params.seq !== 'number') {
    logger.debug('Skipping quote status without subscription id and seq');
    return false;
  }

  try {
    await this.sendRequestToRelay(
      RelayMethod.ACKNOWLEDGE, 
      [params.subscription, params.seq], 
      logger
    );
    return true;
  } catch (error) {
    logger.error('Error acknowledging quote status', error);
    return false;
  }
}
Call this before processing the quote status:
case RelayEventKind.QUOTE_STATUS_EXTENDED:
  if (!(await this.acknowledgeQuoteStatus(req.params, logger))) {
    return;
  }
  await this.processQuoteStatus(req.params.data);
  break;
The instance_id in the WebSocket URL enables the relay to track which events have been acknowledged. If the solver disconnects, unacknowledged events are redelivered on reconnect.

Balance queries

1Click API integration

Confidential balances are not visible on the public NEAR chain. Query them through the 1Click API instead.
The 1Click API returns balances with the imt:<minter>: prefix stripped. When querying balances, pass the public asset identifiers (e.g., nep141:wrap.near), not the IMT-wrapped versions. The response will use these same public identifiers as keys.
import { AccountService, UserAuthService, OpenAPI } from '@defuse-protocol/one-click-sdk-typescript';

private userToken: { accessToken: string; expiresAtMs: number } | null = null;

private async getBalancesFromOneClick(publicTokenIds: string[]): Promise<string[]> {
  // Authenticate and configure the SDK
  const userToken = await this.getOneClickUserToken();
  OpenAPI.TOKEN = userToken;
  
  // Query using PUBLIC asset IDs (not IMT-wrapped)
  const response = await AccountService.getBalances(publicTokenIds);
  const balancesByTokenId = new Map(
    response.balances.map(({ tokenId, available }) => [tokenId, available])
  );

  return publicTokenIds.map(tokenId => balancesByTokenId.get(tokenId) ?? '0');
}

Authentication

The 1Click API requires an authenticated user token. Generate one by signing an intent:
import { createIntentSignerNEP413, IntentsSDK, VersionedNonceBuilder } from '@defuse-protocol/intents-sdk';

private async getOneClickUserToken(): Promise<string> {
  // Return cached token if still valid
  if (this.userToken && this.userToken.expiresAtMs > Date.now()) {
    return this.userToken.accessToken;
  }

  const signer = createIntentSignerNEP413({
    accountId: this.nearService.getIntentsAccountId(),
    signMessage: async (_payload, hash) => {
      const signature = await this.nearService.signMessage(hash);
      return {
        publicKey: signature.publicKey.toString(),
        signature: Buffer.from(signature.signature).toString('base64'),
      };
    },
  });

  const sdk = new IntentsSDK({ referral: 'solver', env: 'production' });
  const { signed } = await sdk
    .intentBuilder()
    .setDeadline(new Date(Date.now() + 5 * 60_000))
    .setNonceRandomBytes(VersionedNonceBuilder.createTimestampedNonceBytes(new Date()))
    .buildAndSign(signer);

  const auth = await UserAuthService.authenticate({ signedData: signed });
  
  this.userToken = {
    accessToken: auth.accessToken,
    expiresAtMs: Date.now() + auth.expiresIn * 1000 - 60_000,
  };

  return this.userToken.accessToken;
}

Unified balance method

Switch between on-chain and 1Click queries based on mode. Note that for confidential mode, you need to convert IMT-wrapped token IDs back to public format for the API call:
public async getBalances(tokenIds: string[]): Promise<string[]> {
  if (isConfidentialMode) {
    // Convert IMT-wrapped IDs to public format for the API
    const publicTokenIds = tokenIds.map(id => this.toPublicAssetId(id));
    return this.getBalancesFromOneClick(publicTokenIds);
  }
  
  return this.getBalancesOnContract(tokenIds);
}

// Strip the IMT prefix to get the public asset identifier
private toPublicAssetId(imtTokenId: string): string {
  const prefix = `imt:${privateTreasuryAccountId}:`;
  if (imtTokenId.startsWith(prefix)) {
    return imtTokenId.slice(prefix.length);
  }
  return imtTokenId;
}

Solver response format

When responding to confidential quote requests, solvers must return their signed intents in the private_signed_data bundle. This structure contains up to three signed intents:
interface PrivateSignedData {
  shield?: SignedData;   // Optional: shield public liquidity to Treasury
  swap: SignedData;      // Required: the token_diff intent for the swap
  recover?: SignedData;  // Required if shield present: fallback burn if swap fails
  solver_id?: string;    // Your solver identifier
}

Building the response

When quoting, construct the private_signed_data bundle with your signed swap intent:
// Build the swap intent (token_diff)
const swapIntent = await this.buildSignedSwapIntent(params, deadline);

// Return the quote response with private_signed_data
const quoteResponse = {
  quote_output: {
    type: 'EXACT',
    amount: calculatedAmount,
  },
  private_signed_data: {
    swap: swapIntent.signedData,
    solver_id: process.env.SOLVER_ID,
  },
};
If you need to shield public liquidity for the swap, include all three intents:
const quoteResponse = {
  quote_output: { type: 'EXACT', amount: calculatedAmount },
  private_signed_data: {
    shield: shieldIntent.signedData,    // Transfer to Treasury
    swap: swapIntent.signedData,        // The actual swap
    recover: recoverIntent.signedData,  // Fallback burn (same nonce as swap)
    solver_id: process.env.SOLVER_ID,
  },
};
The recover intent must share the same nonce as the swap intent. This ensures only one of them can execute — either the swap succeeds, or the recover burns the shielded tokens back to public.

Depositing liquidity

Before quoting confidential swaps, deposit liquidity from public Intents into your confidential balance.

Using the deposit script

The example solver includes a script for shielding liquidity:
NODE_ENV=local \
ONE_CLICK_ASSET_ID=nep141:wrap.near \
ONE_CLICK_AMOUNT=100000000000000000000000 \
npm run one-click:deposit-confidential
Run this for each token in your pair:
# Deposit wNEAR
NODE_ENV=local \
ONE_CLICK_ASSET_ID=nep141:wrap.near \
ONE_CLICK_AMOUNT=100000000000000000000000 \
npm run one-click:deposit-confidential

# Deposit USDT
NODE_ENV=local \
ONE_CLICK_ASSET_ID=nep141:usdt.tether-token.near \
ONE_CLICK_AMOUNT=1000000 \
npm run one-click:deposit-confidential
Amounts are in the token’s smallest unit. For 24-decimal tokens like wNEAR, 100000000000000000000000 equals 0.1 wNEAR.

Withdrawing liquidity

To move liquidity back to public Intents:
NODE_ENV=local \
ONE_CLICK_ASSET_ID=nep141:wrap.near \
ONE_CLICK_AMOUNT=100000000000000000000000 \
npm run one-click:withdraw-confidential

Running the solver

With liquidity deposited, start the solver in confidential mode:
NODE_ENV=local npm start
The solver will:
  1. Connect to the private relay with your instance ID
  2. Subscribe to confidential quote requests
  3. Query balances from the 1Click API
  4. Respond with signed quotes using versioned nonces
  5. Acknowledge quote status events for guaranteed delivery

Quick reference

Public IntentsConfidential Intents
SOLVER_MODE=publicSOLVER_MODE=confidential
wss://solver-relay-v2.chaindefuser.com/wsPrivate relay URL (contact Defuse team)
intents.nearintents.far
nep141:wrap.nearimt:<treasury>:nep141:wrap.near
Deterministic nonceVersioned nonce with salt
On-chain balance query1Click API balance query
No acknowledgementsquote_status_extended with acknowledgements

Next steps

Confidential Overview

Learn more about the confidential intents architecture

Guaranteed Delivery

Deep dive into the acknowledgement protocol