Migrate the AMM Solver to support confidential intents
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.
Add the confidential intents configuration to your environment file:
# Solver modeSOLVER_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.farPRIVATE_INTENTS_CONTRACT_SALT=e110f317# Treasury account for asset wrappingPRIVATE_TREASURY_ACCOUNT_ID=51e8f94d77b5e90dc9852ca6113771e11e8382ce69473a75e313e41665de7cbe# 1Click API for balance queries and liquidity managementONE_CLICK_BASE_URL=https://1click.chaindefuser.com# Stable identifier for guaranteed deliverySOLVER_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.
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.
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.
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 publicconst 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.
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:
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.
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');}
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 identifierprivate toPublicAssetId(imtTokenId: string): string { const prefix = `imt:${privateTreasuryAccountId}:`; if (imtTokenId.startsWith(prefix)) { return imtTokenId.slice(prefix.length); } return imtTokenId;}
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}
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.