@swype-org/withdrawals
v0.1.4
Published
Lightweight merchant withdrawals SDK — quote a cross-chain/cross-token withdrawal, sign with your own wallet, report and track to completion, zero runtime dependencies
Downloads
108
Maintainers
Readme
@swype-org/withdrawals
Lightweight merchant withdrawals SDK — quote a withdrawal, sign the returned transactions with your own wallet, report the broadcast hashes, and track to completion. Zero runtime dependencies, fully signer-agnostic.
A withdrawal moves funds out of a user's embedded wallet to a destination (chain / token / address). It covers a plain withdrawal (same chain, new recipient), a cross-chain or cross-token move, and a migration (e.g. USDM on one chain → USDC on another) — all the same lifecycle.
Requirements
Node 16+ or a modern browser — the client relies only on the fetch, AbortController,
btoa, and TextEncoder globals available in those runtimes. Zero runtime dependencies;
ships both ESM and CommonJS builds with TypeScript types.
Quick Start
# Published package
npm install @swype-org/withdrawals
# Or from a monorepo checkout
npm install file:../path/to/withdrawals-sdkimport { WithdrawalsClient } from '@swype-org/withdrawals';
const client = new WithdrawalsClient({
baseUrl: 'https://api.blink.cash',
// Called before every request — return a freshly-signed envelope so a
// long-lived client never sends a stale (expired) credential.
merchantAuthorization: async () => myBackend.signMerchantEnvelope(),
});
// 1. Quote the withdrawal.
const quote = await client.createWithdrawal({
source: { chainId: 4326, tokenAddress: '0x...', wallet: '0xUserEmbeddedWallet' },
destination: { chainId: 1337, address: '0xRecipient', token: { address: '0x...' } },
amount: '1000000000000000000', // source token's smallest unit, as a string
idempotencyKey: 'a-stable-uuid', // optional; same key returns the same withdrawal
});
// 2. Sign + broadcast each returned transaction with your own wallet
// (e.g. a Privy server SDK delegated session signer).
const reported = [];
for (const tx of quote.transactions) {
// tx.kind is 'transaction' (broadcast) or 'signature' (sign a message — no broadcast).
const { hash } = await myPrivyClient.signAndSendTransaction(/* tx.to, tx.data, tx.value, tx.chainId */);
reported.push({ txHash: hash, chainId: tx.chainId, stepIndex: tx.stepIndex });
}
// 3. Report the broadcast hashes.
await client.reportTransactions(quote.withdrawalId, reported);
// 4. Poll status to completion.
let withdrawal = await client.getWithdrawal(quote.withdrawalId);
while (withdrawal.status !== 'completed' && withdrawal.status !== 'failed') {
await new Promise((r) => setTimeout(r, 5_000));
withdrawal = await client.getWithdrawal(quote.withdrawalId);
}How It Works
┌─────────────┐ createWithdrawal ┌──────────────┐
│ Merchant │ ────────────────────▶ │ blink API │ quotes the route,
│ backend │ ◀──────────────────── │ │ returns unsigned txs
└─────────────┘ transactions[] └──────────────┘
│
│ sign + broadcast each tx with your own wallet
│ (e.g. Privy delegated session signer)
▼
┌─────────────┐ reportTransactions ┌──────────────┐
│ Merchant │ ────────────────────▶ │ blink API │ tracks to completion
│ backend │ getWithdrawal │ │ (poll status)
└─────────────┘ ◀──────────────────── └──────────────┘The SDK is signer-agnostic: it returns neutral, unsigned transactions[] and you sign them with whatever wallet you use.
Signing prerequisite (delegated session signer)
For the backend / no-popup flow (the typical withdrawal case), the user must have delegated a session signer to you once on the client (a revocable, one-time consent step in your app). Your backend can then sign on behalf of their embedded wallet with no user present. See your wallet provider's delegated-signing docs.
Data returned
The quote (createWithdrawal):
withdrawalId— reference it in every later call.transactions[]— the neutral, unsigned steps you sign + broadcast. Each has astepIndex, akind('transaction'to broadcast,'signature'to sign a message), andto/data/value/chainId.estimatedOutput/estimatedFeeUsd— string estimates, ornullwhen unavailable.quoteValidUntil— ISO-8601 server-side TTL (see Notes).
The withdrawal record (reportTransactions, getWithdrawal):
status—'quoted' | 'submitted' | 'routing' | 'completed' | 'failed'.submittedTxHashes[]— hashes the API has observed for this withdrawal.destinationTxHash— the destination-chain hash, populated on'completed'(elsenull).failureReason— populated on'failed'(elsenull).createDate/updateDate— ISO-8601 timestamps.
Authorization
merchantAuthorization is a function returning a freshly-signed envelope { merchantId, payload, signature }. The SDK calls it before each request and sends it base64-encoded in the
X-Merchant-Authorization header. This envelope is an API credential (it identifies you as the merchant) — it is not what authorizes funds to move; that authority is the user's delegated session signer. Sign the envelope on your backend; the SDK never signs it.
Envelope format
| Field | Contents |
|--------------|---------------------------------------------------------------------------------------------------|
| merchantId | Your merchant ID, as registered with Blink. |
| payload | base64url-encoded JSON: { "version": "1", "signatureTimestamp": <ISO-8601> } — when you signed, in ISO-8601. |
| signature | base64url-encoded ECDSA P-256 / SHA-256 signature (DER-encoded) over the raw base64url payload string — sign the encoded string itself, not the decoded JSON. |
Expiry is enforced server-side: the API rejects any envelope whose signatureTimestamp is more than 15 minutes old or in the future (30s clock-skew tolerance). You don't declare an expiry — just sign fresh.
Generating a signing key
The API verifies your signature against the PEM public key you registered with Blink, using the ECDSA_P256_SHA256 algorithm. Generate a P-256 keypair and share only the public half during merchant registration:
openssl ecparam -name prime256v1 -genkey -noout -out merchant-signing-key.pem
openssl ec -in merchant-signing-key.pem -pubout -out merchant-signing-key.pub.pemKeep the private key on your backend (e.g. in a secrets manager) — never ship it to a client.
Example: signing the envelope (Node)
import { createSign } from 'node:crypto';
import type { MerchantAuthorizationEnvelope } from '@swype-org/withdrawals';
const MERCHANT_ID = 'your-merchant-id';
const PRIVATE_KEY_PEM = process.env.MERCHANT_SIGNING_KEY!; // P-256 private key, PEM
export function signMerchantEnvelope(): MerchantAuthorizationEnvelope {
// 1. Build the payload and base64url-encode it.
const payload = Buffer.from(
JSON.stringify({
version: '1',
signatureTimestamp: new Date().toISOString(),
}),
'utf8',
).toString('base64url');
// 2. Sign the base64url string itself (its UTF-8 bytes), ECDSA P-256 + SHA-256.
// Node produces a DER-encoded signature by default, which is what the API expects.
const signer = createSign('SHA256');
signer.update(payload);
signer.end();
const signature = signer.sign(PRIVATE_KEY_PEM).toString('base64url');
return { merchantId: MERCHANT_ID, payload, signature };
}
const client = new WithdrawalsClient({
baseUrl: 'https://api.blink.cash',
merchantAuthorization: signMerchantEnvelope, // fresh signature per request
});Any signer that produces a DER-encoded ECDSA P-256 / SHA-256 signature works — e.g. AWS KMS with ECDSA_SHA_256 (KMS returns DER) — as long as the registered public key matches. Signing fresh per request is cheap and avoids ever hitting the expiry window; if you cache an envelope, stay well inside the 15-minute signatureTimestamp age limit.
If the envelope is rejected, the API responds with codes like MERCHANT_AUTHORIZATION_EXPIRED, MERCHANT_SIGNATURE_INVALID, MERCHANT_PAYLOAD_INVALID, or MERCHANT_NOT_REGISTERED (surfaced by the SDK as a WithdrawalError with code REQUEST_FAILED).
Configuration
new WithdrawalsClient({
baseUrl: 'https://api.blink.cash', // required
merchantAuthorization: () => envelope, // required — sync or async, called per request
fetch, // optional — defaults to global fetch
timeoutMs: 15000, // optional — per-request timeout (default 15s)
});Notes
quoteValidUntilis a blink server-side TTL indicating how long the quote stays valid — re-quote if it expires before you broadcast.reportTransactionsis idempotent — replaying the same hashes is harmless.
Error Handling
Every failure throws a WithdrawalError with a machine-readable code:
| Code | Meaning |
|--------------------------|----------------------------------------------------------------------|
| INVALID_REQUEST | Bad input passed to the client (e.g. missing withdrawalId). |
| AMOUNT_TOO_LOW | The amount is too low to cover network/bridge fees — try a larger amount. |
| ROUTE_UNSUPPORTED | The source/destination chain or token can't be bridged. |
| INSUFFICIENT_LIQUIDITY | Not enough liquidity for this amount right now — try smaller or later. |
| INSUFFICIENT_FUNDS | The source wallet has insufficient balance for this withdrawal. |
| REQUEST_FAILED | The server rejected the request (other non-2xx). |
| NETWORK_ERROR | The server was unreachable. |
| TIMEOUT | The request exceeded timeoutMs. |
| RESPONSE_INVALID | The server returned an unexpected response shape. |
import { WithdrawalError, getDisplayMessage } from '@swype-org/withdrawals';
try {
await client.createWithdrawal(input);
} catch (err) {
if (err instanceof WithdrawalError) {
if (err.code === 'AMOUNT_TOO_LOW') {
// e.g. prompt the user to increase the amount
}
console.error(err.code, getDisplayMessage(err));
}
}React (reserved)
A @swype-org/withdrawals/react subpath is reserved for a future client-side popup-signer flow (sign in the user's browser). It is not built yet; the package ships the backend client only.
Exports
Values: WithdrawalsClient, WithdrawalError, getDisplayMessage, VERSION.
Types: WithdrawalsClientConfig, CreateWithdrawalInput, WithdrawalQuote, Withdrawal, WithdrawalTransaction, ReportedTransaction, WithdrawalSource, WithdrawalDestination, WithdrawalKind, WithdrawalStatus, WithdrawalErrorCode, MerchantAuthorizationEnvelope, MerchantAuthorizationProvider, FetchLike.
