Reference code
Everything on this page ships as the official SDK — prefer it over copying:
npm install sweep-external-sdk| Import | Runs on | Contains |
|---|---|---|
sweep-external-sdk/server | Backend only | SweepExternalClient, buildCanonicalSignature, verifyWebhook |
sweep-external-sdk/browser | Browser | readWalletCapabilities, ensureChain, signAuthorization, executeChainAction, waitForReceipt |
sweep-external-sdk/shared | Anywhere | API 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.