# @swype-org/deposit

Lightweight merchant deposit SDK — open a hosted Blink payment flow, handle completion, zero runtime dependencies.

## Quick Start

```bash
npm install @swype-org/deposit
```

```typescript
import { Deposit } from '@swype-org/deposit';

const deposit = new Deposit({ signer: '/api/sign-payment' });

document.getElementById('deposit-btn')!.addEventListener('click', async () => {
  try {
    const { transfer: result } = await deposit.requestDeposit({
      amount: 50,
      chainId: 8453,
      address: '0x...', // destination wallet
      token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
    });
    console.log('Transfer complete:', result.id, result.status);
  } catch (error) {
    console.error('Transfer failed:', error);
  }
});
```

> **Important:** `requestDeposit()` must be called from a user-gesture handler (click, keypress, etc.) so the browser allows the iframe to open.

## React Hook

A dedicated React entry point eliminates all boilerplate:

```bash
npm install @swype-org/deposit react
```

```tsx
import { useBlinkDeposit } from '@swype-org/deposit/react';

function DepositButton() {
  const { status, result, error, displayMessage, requestDeposit } = useBlinkDeposit({
    signer: '/api/sign-payment',
  });

  return (
    <>
      <button
        onClick={() =>
          requestDeposit({ amount: 50, chainId: 8453, address: '0x...', token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' })
        }
        disabled={status === 'signer-loading'}
      >
        {status === 'signer-loading' ? 'Preparing…' : 'Deposit'}
      </button>
      {error && <p>{displayMessage}</p>}
      {result && <p>Transfer {result.transfer.id} complete!</p>}
    </>
  );
}
```

The hook returns reactive `status`, `result`, `error`, `displayMessage`, and `isActive` values, plus `requestDeposit`, `focus`, and `close` actions. It manages the `Deposit` lifecycle and cleans up on unmount.

## How It Works

```
Merchant App / SDK              Merchant Signer                Hosted Flow (iframe)
     │                               │                               │
     │  1. requestDeposit(request)    │                               │
     │──────────────────────────────► │                               │
     │                                │  2. signer(data) or POST URL  │
     │                                │  (includes webviewBaseUrl)    │
     │  3. { signature, payload, ...} │                               │
     │◄───────────────────────────────│                               │
     │                                                                │
     │  4. SDK builds URL, opens iframe                               │
     │───────────────────────────────────────────────────────────────► │
     │                                                                │
     │  5. User completes payment in hosted flow                      │
     │                                                                │
     │  6. postMessage: blink:transfer-complete                       │
     │◄──────────────────────────────────────────────────────────────  │
     │                                                                │
     │  7. Promise resolves with DepositResult                        │
```

## Configuration

```typescript
const deposit = new Deposit({
  signer: '/api/sign-payment',
  webviewBaseUrl: 'https://pay.blink.cash',
  hostedFlowOrigin: 'https://pay.blink.cash',
  containerElement: document.getElementById('deposit-root')!,
  signerTimeoutMs: 15_000,
  flowTimeoutMs: 300_000,
  enableFullWidget: true,
  presentation: 'overlay',   // or 'embedded' — see below
  debug: false,
});
```

### `enableFullWidget`

Controls whether the hosted flow may show the full widget (the Deposit Options
entry screen for unauthenticated users). Defaults to `true`. The full widget
only appears when this option is enabled **and** the merchant's backend config
has it enabled — disabling either turns it off. Set it to `false` to force the
hosted flow straight to the standard deposit flow regardless of merchant
config.

## Embedded presentation (payment-method aggregators)

By default Blink opens as a full-screen modal: a dim + blur backdrop with the
payment card centered on top. If you're a payment-method aggregator listing
Blink as one option **inside your own widget**, set
`presentation: 'embedded'` and Blink renders inline in an element you provide —
no backdrop, no page scroll lock, no Escape/backdrop dismissal (all of that
stays yours), and chrome-less so your panel owns the radius, background and
shadow.

```typescript
// On widget mount. `slot` is an empty <div> inside your method panel.
const deposit = new Deposit({
  signer: '/api/sign-payment',
  merchantId: MERCHANT_ID,
  presentation: 'embedded',
  containerElement: slot,      // required
  embedMaxHeightPx: 640,       // your panel's height budget
});

// Blink reports its rendered height; the SDK has already resized the iframe.
// Follow it to size your own panel — height with NO transition (see below).
deposit.on('resize', ({ heightPx, preferredWidthPx, minWidthPx }) => {
  panel.style.height = `${heightPx}px`;
  // Optional: size the panel's width to the flow too. These are constants
  // (440 / 320), not measurements, so they are safe to transition.
  panel.style.width = `${preferredWidthPx}px`;
});

// When the user picks the Blink row:
try {
  const { transfer } = await deposit.requestDeposit({ amount, chainId, address, token });
} catch (err) {
  if (err instanceof DepositError && err.code === 'DEPOSIT_DISMISSED') {
    showMethodList();          // user backed out of Blink
  }
}

deposit.close();               // your own back button
deposit.destroy();             // widget unmount
```

`close()` rejects the pending `requestDeposit()` with `DEPOSIT_DISMISSED` — the
same code the flow's own close control uses — so the `catch` above is the single
place you restore your method list. (It reports no `error` event and leaves
`status` on `'idle'`: you asked for this one.)

Three things to get right:

- **`containerElement` is required, must be stable across re-renders, and must
  never be hidden with `display: none`.** Moving an iframe in the DOM reloads it,
  throwing away the warm-up and the user's session — so render the slot once and
  never recreate the node. When another method is selected, hide it *without
  removing its layout*:

  ```css
  /* the Blink slot while another method is showing */
  visibility: hidden; height: 0; overflow: hidden; pointer-events: none;
  ```

  `display: none` looks equivalent and is not: that subtree has no layout, so the
  flow Blink re-warms into it after every close cannot measure itself and may
  report no height at all. The panel then reopens blank at its budget height with
  nothing in your console. It is timing-dependent, which is what makes it nasty —
  closing your whole dialog tends to flip `display` in the same commit and works,
  while a back button inside the panel does not. The SDK warns when it reveals
  into an unrendered container; that warning is this.
- **Don't add a CSS transition to the height you get from `resize`.** Blink
  animates its own height over ~300ms and reports every frame of it. A
  transition on your side makes the panel chase each of those frames, so it
  keeps moving after the content has settled — which shifts elements under the
  pointer and shows up as the cursor flickering between pointer and default.
  Apply the height directly. `preferredWidthPx` is a constant, so transitioning
  the *width* is fine.
- **Embedded is a desktop presentation.** On mobile the same config presents as
  Blink's normal full-screen sheet, because a phone-sized method panel cannot
  hold a flow with a keypad, wallet lists, a QR code and a passkey ceremony.
  Nothing else downgrades it: no failure in the hosted flow will drop a modal
  over your app unannounced. Read the resolved value from `deposit.presentation`
  (`'overlay' | 'embedded'`) — on `'overlay'` no `resize` event ever fires, so
  collapse or skip the slot rather than leaving an empty box behind the sheet:

  ```typescript
  // Re-read on resize; it is live until Blink builds its warm-up frame.
  {deposit.presentation === 'embedded' && <MethodPanel />}
  ```

### Deposit titles

If your container already has a deposit heading, opt out of Blink's repeated title:

```typescript
const deposit = new Deposit({
  signer: '/api/sign',
  appearance: { hideDepositTitle: true },
});
```

This hides only the “Deposit” title on One Tap and manual deposit entry, progress,
and completion screens. Settings still opens **More** with its title. Back, QR,
settings and close controls, merchant balances, status messages and action labels
remain available. Titles stay visible when the option is omitted or `false`.

### Brand colors

Five colors, and Blink derives its whole palette from them — surfaces, the
recessed and hover steps, the text ramp, borders, the button hover and label, the
tinted semantic surfaces — so the flow reads as part of your product rather than a
foreign panel inside it. Names follow Stripe's `appearance.variables`.

```typescript
const deposit = new Deposit({
  signer: '/api/sign-payment',
  presentation: 'embedded',
  containerElement: slot,
  appearance: {
    theme: 'light',
    variables: {
      colorPrimary: '#0f62fe',      // primary button, focus ring, selected states
      colorBackground: '#101828',   // the card surface
      colorText: '#f7f9fc',
      colorDanger: '#ff5c5c',       // optional
      colorBorder: '#22304a',       // optional — derived from surface + text if omitted
    },
  },
});
```

Worth knowing before you pick values:

- **Opaque hex only** (`#rgb` or `#rrggbb`). `rgb()`, CSS color names and 8-digit
  hex with alpha are dropped with a console error naming the key — a solid card
  cannot honour translucency, and an unparseable color cannot be contrast-checked.
- **`colorBackground` picks the light or dark base** for everything that is not
  themeable (shadows, the scrim, semantic hues), by its own luminance. A dark
  surface stays coherent even with `theme: 'light'`.
- **Contrast is enforced.** Where a derived pair would fall below WCAG AA the
  *derived* value is substituted — never your input — and the substitution is
  reported in the hosted flow's console. A `colorPrimary` too mid-toned for either
  a black or a white label is reported and left alone; only you can fix that one.
- **Not themeable:** the type scale, weights, radii, fonts, Blink's own marks, and
  the success/error/warning hues and authorization states.

Everything else derives, deliberately: the palette has ~35 mutually-constrained
tokens and five inputs cannot produce a broken combination, where thirty-five
reliably would.

`embedMaxHeightPx` (default: 90% of the host viewport) caps the iframe and is
also the basis Blink sizes its own layout against. Past that height the flow
scrolls internally instead of growing your panel.

Blink's screens are designed for **320–440px wide**, and the flow reports that
envelope on every `resize` as `minWidthPx` (320) and `preferredWidthPx` (440).
The iframe fills the width you give it, capped at 440 — it cannot widen your
panel on its own, so if you want the panel to fit the flow, size it from
`preferredWidthPx`. Below `minWidthPx`, fixed-width elements inside the flow
overflow.

Your CSP needs `frame-src https://pay.blink.cash`.

### Merchant balance

If your users hold a balance on your platform, pass it on the request and the
flow shows it under the deposit header — "Acme balance: $20.70" — so the user
tops up with their number in view:

```typescript
await deposit.requestDeposit({
  amount: null,
  chainId: 8453,
  address: userWalletAddress,
  token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
  balance: 20.7,            // USD — display only
});
```

Display-only by design: the value is never part of the signed payload and never
reaches your signer, so no signer changes are needed — and it cannot affect the
transfer amount or destination. It must be a finite number, `>= 0` and below
`1e12`; the SDK floors it to whole cents (`20.789` shows as `$20.78`). Anything
else is dropped with a console error naming the field, and the deposit proceeds
without the subtitle. When you don't pass it, Blink may fall back to showing
the destination wallet's on-chain balance instead.

## Blink Trust: device identifiers

`getIdentifiers()` collects the third-party identifiers Blink Trust accepts on
`POST /v1/identity/users`. It runs one Fingerprint identification on **your**
page (first party, unlike the hosted flow's identification on a Blink origin)
and resolves to `[{ provider: 'fingerprint', id, eventId }]`.

```ts
import { getIdentifiers, DepositError, type ThirdPartyIdentifier } from '@swype-org/deposit';

// Your backend authenticates the user and creates the Blink device challenge.
const response = await fetch('/api/trust/assessments', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
  body: '{}',
});
if (!response.ok) throw new Error('Could not create Blink assessment');
const { assessmentId } = await response.json();
if (!assessmentId) throw new Error('Missing Blink assessmentId');
let thirdPartyIdentifiers: ThirdPartyIdentifier[] = [];
try {
  thirdPartyIdentifiers = await getIdentifiers({ assessmentId });
} catch (err) {
  if (!(err instanceof DepositError) || err.code !== 'IDENTIFIERS_UNAVAILABLE') throw err;
  // Register without new device evidence; do not assume a passing score.
}
const registration = await fetch('/api/trust/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
  body: JSON.stringify({ assessmentId, thirdPartyIdentifiers, embeddedWallet }),
});
if (!registration.ok) throw new Error('Blink registration failed');
```

The browser only collects. Your backend adds the identity fields your auth
provider vouches for (email, phone), signs the merchant authorization
envelope, and forwards to Blink, which verifies each `eventId` with the
provider's secret key before trusting the `id`. When supplying a wallet, your
backend also obtains a Blink wallet challenge and signs it using the user's
wallet through Privy. Core verifies `walletProof`; the browser does not choose
the signing message or wallet ID. See the [merchant integration guide](../docs/blink-trust-merchant-integration.md).

- Fails with `IDENTIFIERS_UNAVAILABLE` rather than resolving `[]`, so "no
  signal" is distinguishable from "nothing collected". Identification is
  bounded by `timeoutMs` (default 10 s).
- Loads `@fingerprint/agent` on first call only. The agent fetches its script
  from `https://fpnpmcdn.net`, posts to `https://api.fpjs.io`, and runs part of
  its collection in a Web Worker created from a `blob:` URL. A strict CSP needs
  all three: `script-src https://fpnpmcdn.net`, `connect-src https://fpnpmcdn.net
  https://api.fpjs.io`, and `worker-src 'self' blob:` (without an explicit
  `worker-src` the browser falls back to `script-src`, which rejects blobs, and
  identification fails with `worker_initialization_failed`).
- `apiKey` / `region` default to Blink's public Fingerprint key. Override only
  to point your page at a separate Fingerprint environment.

To reduce ad-blocker failures, Blink can provision a Fingerprint custom
subdomain under the merchant's own domain. The merchant adds the DNS records
and passes the activated endpoint to the existing call:

```ts
await getIdentifiers({ assessmentId, endpoint: 'https://metrics.yourdomain.com' });
```

`endpoint` routes both the v4 agent download and identification through that
URL, with no fallback to Fingerprint's public domains. Add its origin to
`script-src` and `connect-src`; retain `worker-src 'self' blob:`. Activate and
verify the subdomain before enabling it. Keep the unavailable-device fallback:
first-party routing cannot guarantee identification in every browser.
Merchants do not need their own Fingerprint account or proxy server. DNS
records are returned by Fingerprint during provisioning; do not guess them.

## Human verification (`Verification`)

Prove that your signed-in user is a real, unique human with a palm scan, and
let Blink guarantee **one palm ↔ one of your user ids**. No Blink account,
passkey or deposit is involved: the user is signed in to *you*, and your
backend names them in a signed grant before the scan starts.

```ts
import { Verification, VerificationError } from '@swype-org/deposit';

const verification = new Verification({ signer: '/api/sign-verification' });

try {
  const { sessionId, status } = await verification.verifyHuman(); // status: 'verified' — a HINT
  await fetch('/api/me/verification/refresh');                   // your server asks Blink — the ANSWER
} catch (e) {
  if (e instanceof VerificationError) {
    switch (e.code) {
      case 'ALREADY_VERIFIED_OTHER_ACCOUNT': // "You already verified another account"
      case 'ACCOUNT_ALREADY_VERIFIED':
      case 'VERIFICATION_DISMISSED':
      case 'VERIFICATION_EXPIRED':
      case 'VERIFICATION_DECLINED':          // cancelled at the provider
      case 'VERIFICATION_UNAVAILABLE':       // not enabled for this merchant, or switched off
    }
  }
}
```

**Unlock on your server's answer, never on the promise.** The page that
produced it runs in the user's own browser. Your backend reads
`GET /v1/identity/human-verifications?merchantUserId=…` with your merchant
key (the same `X-Merchant-Authorization` envelope as Blink Trust) and gets
`{ status: 'verified', humanId, verifiedAt }` — `humanId` is stable per human
at your merchant, so you can dedupe an incentive ledger on the human rather
than the account.

The signer is asked with `{ kind: 'human-verification', callbackScheme: null, url, version: 'v1' }`
and must run **behind your own user auth**: verify the caller's session, then
sign `{ version: 'v1', kind: 'human-verification', merchantUserId, callbackScheme: null, signatureTimestamp, expiresAt }`
(base64url, ECDSA P-256 / SHA-256 — the same scheme as a deposit payload) and
return `{ merchantId, payload, signature }`. The SDK never sends a user id; a
signer that took its subject from the browser would let anyone bind any account
to their own palm. Existing deposit signers keep working — deposit requests
never carry `kind`.

`VerificationConfig` takes `signer`, `environment` / `webviewBaseUrl`,
`containerElement`, `signerTimeoutMs`, `flowTimeoutMs`, `appearance.theme`
and `debug`, with the same meanings as `DepositConfig`. Events: `complete`,
`error`, `close`, `status-change`. `close()` rejects a flow in progress with
`VERIFICATION_DISMISSED`; `destroy()` tears down.

## Error Handling

Every error is a `DepositError` with a machine-readable `code`:

| Code                     | Meaning                                         |
| ------------------------ | ----------------------------------------------- |
| `DEPOSIT_DISMISSED`     | User closed the transfer before completing      |
| `SIGNER_REQUEST_FAILED`  | Signer returned a non-2xx response              |
| `SIGNER_NETWORK_ERROR`   | Network failure reaching the signer             |
| `SIGNER_RESPONSE_INVALID`| Signer response missing required fields         |
| `SIGNER_TIMEOUT`         | Signer did not respond within `signerTimeoutMs` |
| `FLOW_TIMEOUT`           | Entire flow exceeded `flowTimeoutMs`            |
| `INVALID_REQUEST`        | Bad input (amount, address, etc.)               |

```typescript
import { DepositError, getDisplayMessage } from '@swype-org/deposit';

try {
  await deposit.requestDeposit({ /* ... */ });
} catch (err) {
  if (err instanceof DepositError) {
    showToast(getDisplayMessage(err));
  }
}
```

## Events

```typescript
transfer.on('complete', (result) => { /* DepositResult */ });
transfer.on('error', (error) => { /* DepositError */ });
transfer.on('close', () => { /* iframe closed */ });
transfer.on('status-change', (status) => { /* DepositStatus */ });
// Embedded presentation only — the inline iframe was resized to this height.
transfer.on('resize', ({ heightPx }) => { /* number */ });
```

## Lifecycle

```typescript
transfer.focus();   // No-op — retained for API compatibility
transfer.close();   // Close the iframe without waiting for completion
transfer.destroy(); // Tear down and release all resources (call on unmount)
```

## TypeScript

All types are exported:

```typescript
import type {
  DepositConfig,
  DepositStatus,
  DepositRequest,
  DepositResult,
  SignerFunction,
  SignerRequest,
  SignerResponse,
  TransferSummary,
} from '@swype-org/deposit'; 

import type { DepositErrorCode } from '@swype-org/deposit';
```

## Security & content integrity

The deposit UI loads live from `https://pay.blink.cash` every render — the
standard model for embedded payment UIs (it lets us ship security/fraud fixes
instantly). We do not version-pin the iframe; instead:

- **Verifiable build manifest** — every deploy publishes
  [`https://pay.blink.cash/manifest.json`](https://pay.blink.cash/manifest.json)
  with the build version, source commit, and a SHA-256 of every served file, so
  you can confirm exactly what we serve.
- **Lock the iframe origin** — add a CSP to your page so the iframe can only ever
  load from Swype:

  ```
  Content-Security-Policy: frame-src https://pay.blink.cash; script-src 'self';
  ```

See [docs/iframe-security.md](../docs/iframe-security.md) for the full security
model (CSP, postMessage hardening, Privy origin allow-listing, passkey
delegation, and SRI rationale).

## Backward Compatibility

The previous `Checkout`-named symbols are re-exported as deprecated aliases:

```typescript
import { Checkout } from '@swype-org/deposit';       // deprecated alias for Transfer
import { CheckoutError } from '@swype-org/deposit';   // deprecated alias for DepositError
import { useBlinkCheckout } from '@swype-org/deposit/react'; // deprecated alias for useBlinkDeposit
```
