Skip to Content
Reference code

Reference code

Everything on this page ships as the official SDK — prefer it over copying:

npm install sweep-external-sdk
ImportRuns onContains
sweep-external-sdk/serverBackend onlySweepExternalClient, buildCanonicalSignature, verifyWebhook
sweep-external-sdk/browserBrowserreadWalletCapabilities, ensureChain, signAuthorization, executeChainAction, waitForReceipt
sweep-external-sdk/sharedAnywhereAPI contract types, decimalToRawUnits, sumSettledTargetRaw, status constants

TypeScript, ESM + CJS, zero runtime dependencies; sources at TinyTimur/sweep-external-sdk . The partner secret is a constructor parameter of the server client — only /server ever touches it, and it must never enter a browser bundle.

The code below is the SDK’s reference implementation, kept here for auditing and for integrators who cannot take a dependency. It is dependency-free: node:crypto and fetch on the backend, a raw EIP-1193 provider in the browser. Either way, attach your own persistence (schema in Execution rules) and UI.

Signed client

The complete canonical-v2 client for all endpoints. The secret never leaves this process.

import crypto from 'node:crypto'; const sha256Hex = (v) => crypto.createHash('sha256').update(v).digest('hex'); const hmacHex = (secret, v) => crypto.createHmac('sha256', secret).update(v).digest('hex'); export function buildCanonicalSignature( secret, { partnerId, timestamp, nonce, method, path, idempotencyKey, rawBody } ) { const signingString = [ 'SWEEP-EXTERNAL-REQUEST', 'auth-version:2', 'api-version:1', `partner-id:${partnerId}`, `timestamp:${timestamp}`, `nonce:${nonce}`, `method:${method.toUpperCase()}`, `path:${path}`, `idempotency-key:${idempotencyKey || ''}`, `body-sha256:${sha256Hex(rawBody || '')}`, ].join('\n'); return { signingString, signature: hmacHex(secret, signingString) }; } export class SweepExternalClient { // config: { sweepApiUrl, partnerId, secret } constructor(config, { fetchImpl = fetch, timeoutMs = 45_000 } = {}) { this.config = config; this.fetchImpl = fetchImpl; this.timeoutMs = timeoutMs; } ping() { return this.request('GET', '/api/external/ping'); } quote(intent, idempotencyKey) { return this.request('POST', '/api/external/intents/quote', intent, idempotencyKey); } prepare(intentSessionId, body, idempotencyKey) { return this.request( 'POST', `/api/external/intents/${encodeURIComponent(intentSessionId)}/prepare`, body, idempotencyKey ); } submitted(attemptId, body, idempotencyKey) { return this.request( 'POST', `/api/external/attempts/${encodeURIComponent(attemptId)}/submitted`, body, idempotencyKey ); } status(attemptId) { return this.request( 'GET', `/api/external/attempts/${encodeURIComponent(attemptId)}/status` ); } async request(method, path, payload, idempotencyKey) { // Serialize ONCE; hash, sign and transmit these exact bytes. const rawBody = method === 'GET' ? '' : JSON.stringify(payload ?? {}); const signedKey = method === 'GET' ? '' : idempotencyKey || ''; const nonce = `req-${crypto.randomUUID()}`; const timestamp = String(Date.now()); const { signingString, signature } = buildCanonicalSignature(this.config.secret, { partnerId: this.config.partnerId, timestamp, nonce, method, path, // full deployed pathname, no query string idempotencyKey: signedKey, rawBody, }); const headers = { 'x-partner-id': this.config.partnerId, 'x-timestamp': timestamp, 'x-sweep-auth-version': '2', 'x-nonce': nonce, 'x-signature': signature, }; if (method !== 'GET') { headers['content-type'] = 'application/json'; if (idempotencyKey) headers['idempotency-key'] = idempotencyKey; } let upstream; try { upstream = await this.fetchImpl(`${this.config.sweepApiUrl}${path}`, { method, headers, body: method === 'GET' ? undefined : rawBody, signal: AbortSignal.timeout(this.timeoutMs), }); } catch (error) { // Ambiguous outcome: retry the SAME bytes with the SAME idempotency key. return { reachable: false, error: error?.name === 'TimeoutError' ? 'sweep_timeout' : 'sweep_unreachable' }; } const text = await upstream.text(); let response; try { response = text ? JSON.parse(text) : {}; } catch { response = { raw: text }; } return { reachable: true, ok: upstream.ok, upstreamStatus: upstream.status, response, debug: { signingString }, // compare against the Authentication envelope }; } }

Persist { operation, path, idempotencyKey, rawBody } before calling request, and reuse them verbatim on retry.

Webhook verification

Verify raw bytes before trusting JSON. After ok: true, durably deduplicate payload.eventId, persist, then return 2xx.

import crypto from 'node:crypto'; const WEBHOOK_MAX_SKEW_MS = 5 * 60_000; const WEBHOOK_EVENT_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; const WEBHOOK_EVENTS = new Set([ 'wallet_submitted', 'provider_pending', 'settled', 'failed', ]); const safeHexEqual = (a, b) => { const A = Buffer.from(String(a || ''), 'utf8'); const B = Buffer.from(String(b || ''), 'utf8'); return A.length > 0 && A.length === B.length && crypto.timingSafeEqual(A, B); }; export const buildWebhookSignature = (secret, timestamp, rawBody) => crypto.createHmac('sha256', secret) .update(`${timestamp}.`).update(rawBody).digest('hex'); export function verifyWebhook(config, { rawBody, headers, now = Date.now() }) { if (!Buffer.isBuffer(rawBody)) { return { ok: false, status: 400, error: 'invalid_webhook_body' }; } const partnerId = String(headers['x-partner-id'] || '').trim(); const timestamp = String(headers['x-timestamp'] || '').trim(); const signature = String(headers['x-signature'] || '').trim(); const eventId = String(headers['x-sweep-event-id'] || '').trim(); const timestampMs = Number(timestamp); if ( partnerId !== config.partnerId || !Number.isFinite(timestampMs) || Math.abs(now - timestampMs) > WEBHOOK_MAX_SKEW_MS || !safeHexEqual(signature, buildWebhookSignature(config.secret, timestamp, rawBody)) ) { return { ok: false, status: 401, error: 'invalid_webhook_signature' }; } let payload; try { payload = JSON.parse(rawBody.toString('utf8')); } catch { return { ok: false, status: 400, error: 'invalid_webhook_body' }; } if ( !payload || typeof payload !== 'object' || Array.isArray(payload) || !WEBHOOK_EVENT_ID_RE.test(eventId) || payload.eventId !== eventId || payload.partnerId !== config.partnerId || !WEBHOOK_EVENTS.has(payload.event) || payload.status !== payload.event || typeof payload.attemptId !== 'string' ) { return { ok: false, status: 400, error: 'invalid_webhook_payload' }; } return { ok: true, payload }; }

Browser: capabilities and network switching

export async function readWalletCapabilities(provider, address) { try { return await provider.request({ method: 'wallet_getCapabilities', params: [address], }); } catch { return {}; // Sweep then returns sequential sendTransaction actions. } } // chains: { [chainId]: { name, rpcUrls, nativeCurrency, explorerUrl? } } export async function ensureChain(provider, chainId, chains) { const meta = chains[chainId]; const hex = `0x${Number(chainId).toString(16)}`; const current = String(await provider.request({ method: 'eth_chainId' })); if (current.toLowerCase() === hex) return; try { await provider.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: hex }], }); } catch (error) { if (error?.code !== 4902 || !meta?.rpcUrls) throw error; await provider.request({ method: 'wallet_addEthereumChain', params: [{ chainId: hex, chainName: meta.name, nativeCurrency: meta.nativeCurrency, rpcUrls: meta.rpcUrls, blockExplorerUrls: meta.explorerUrl ? [meta.explorerUrl] : undefined, }], }); } const after = String(await provider.request({ method: 'eth_chainId' })); if (after.toLowerCase() !== hex) { throw new Error(`Wallet is not on ${meta?.name || chainId}`); } }

Browser: sign the wallet authorization

Build the EIP712Domain declaration from the fields the returned domain actually carries — currently name and version only — and change no value.

export async function signAuthorization(provider, address, authorization) { const domain = authorization.domain; const typedData = { domain, types: { EIP712Domain: [ ...(domain?.name !== undefined ? [{ name: 'name', type: 'string' }] : []), ...(domain?.version !== undefined ? [{ name: 'version', type: 'string' }] : []), ...(domain?.chainId !== undefined ? [{ name: 'chainId', type: 'uint256' }] : []), ...(domain?.verifyingContract !== undefined ? [{ name: 'verifyingContract', type: 'address' }] : []), ], ...authorization.types, }, primaryType: authorization.primaryType, message: authorization.message, }; const signature = await provider.request({ method: 'eth_signTypedData_v4', params: [address, JSON.stringify(typedData)], }); return { signature }; // 65-byte ECDSA hex }

Browser: execute one chain action

Returns the evidence to report: a bundle id for wallet_sendCalls, ordered hashes for sendTransaction. Never alter to, data, value or order.

const toHexQuantity = (v) => `0x${BigInt(v).toString(16)}`; export async function executeChainAction(provider, address, action, { waitForReceipt, // (chainId, hash) => receipt; throws on revert addGasMargin = (g) => g, // optional: raise the gas LIMIT, e.g. ×1.25 }) { if (action.submitMethod === 'wallet_sendCalls') { const calls = action.calls.map((c) => ({ to: c.to, data: c.data || '0x', value: toHexQuantity(c.value || '0'), })); let batchId; try { const r = await provider.request({ method: 'wallet_sendCalls', params: [{ version: '2.0.0', id: `partner-call-${crypto.randomUUID()}`, chainId: toHexQuantity(action.chainId), from: address, atomicRequired: Boolean(action.atomicRequired), calls, }], }); batchId = typeof r === 'string' ? r : r?.id; } catch (error) { if (error?.code === 4001) throw error; // user rejection: stop, don't retry // Older EIP-5792 wallets accept the 1.0 shape. const r = await provider.request({ method: 'wallet_sendCalls', params: [{ version: '1.0', chainId: toHexQuantity(action.chainId), from: address, calls, }], }); batchId = typeof r === 'string' ? r : r?.id; } if (!batchId || typeof batchId !== 'string' || [...batchId].length > 255) { throw new Error('Wallet accepted the batch but returned no reportable id'); } return { bundleId: batchId, txHashes: [] }; // report the bundle id NOW } if (action.submitMethod !== 'sendTransaction') { throw new Error(`Unsupported submitMethod "${action.submitMethod}"`); } const txHashes = []; const transactions = action.transactions || []; for (let i = 0; i < transactions.length; i++) { const tx = transactions[i]; const params = { from: address, to: tx.to, value: toHexQuantity(tx.value || '0'), }; if (tx.data) params.data = tx.data; if (tx.gas) params.gas = toHexQuantity(addGasMargin(tx.gas)); const hash = await provider.request({ method: 'eth_sendTransaction', params: [params], }); txHashes.push(hash); // persist and report as soon as it exists if (i < transactions.length - 1) { await waitForReceipt(action.chainId, hash); // later calls depend on it } } return { bundleId: null, txHashes }; }

A minimal waitForReceipt against a read-only RPC:

export async function waitForReceipt(rpc, chainId, hash, timeoutMs = 300_000) { const started = Date.now(); while (Date.now() - started < timeoutMs) { const receipt = await rpc(chainId, 'eth_getTransactionReceipt', [hash]); if (receipt) { if (receipt.status && BigInt(receipt.status) === 0n) { throw new Error(`Transaction reverted: ${hash}`); } return receipt; } await new Promise((r) => setTimeout(r, 3000)); } throw new Error(`Timed out waiting for ${hash}`); }

Settled amounts to raw units

settledOutputs[].amount is a decimal string padded past the token’s decimals ("9.950000000000000000" for a 6-decimal token), so strict parsers like parseUnits(amount, 6) throw. This conversion trims zero padding and refuses to round real value:

// "9.950000000000000000" with decimals 6 -> 9950000n export function decimalToRawUnits(amount, decimals) { const parts = String(amount).split('.'); if (parts.length > 2) { throw new Error(`invalid decimal amount: ${amount}`); } const [whole, fraction = ''] = parts; if (!/^\d+$/.test(whole) || (fraction && !/^\d+$/.test(fraction))) { throw new Error(`invalid decimal amount: ${amount}`); } if (/[1-9]/.test(fraction.slice(decimals))) { throw new Error(`more than ${decimals} significant decimals: ${amount}`); } const kept = fraction.slice(0, decimals).padEnd(decimals, '0'); return BigInt(whole) * 10n ** BigInt(decimals) + BigInt(kept || '0'); } // target: { chainId: number, symbol: string, decimals: number } export function sumSettledTargetRaw(settledOutputs, target) { return settledOutputs .filter((o) => o.chainId === target.chainId && o.symbol === target.symbol) .reduce((acc, o) => acc + decimalToRawUnits(o.amount, target.decimals), 0n); }

Use the sum under the amountBasis rules in Status: auto-spend only actual amounts; resolve and confirm estimated amounts against the recipient’s balance.

Last updated on