Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/feebearer-and-quote-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@usewhisk/core": minor
"@usewhisk/react": minor
---

Fee bearer control and a stale-quote fix.

**`feeBearer`** — new config option. Default `"receiver"` preserves current behavior (CCTP + Forwarder fees come out of the transfer). Set `feeBearer: "sender"` to size the burn up by the estimated fees so the recipient receives the full amount — useful for checkout, payroll, and invoice flows. Quotes now expose `amountBurned` (the on-chain transfer amount) alongside `amountIn` (sender debit) and `amountOut` (recipient receives). In sender mode the gross-up pads the forwarding portion by a small margin (2%); the forwarder fee is re-priced from destination gas at mint time, so the cushion keeps the recipient at or above the requested amount despite drift, with any unused margin minted to them.

**Fix** — changing the destination chain after the recipient resolved kept quoting against the original chain. A host-pinned recipient was resolved only once (a one-shot `useRef` latch) and the "Review" gate ignored the chain, so switching destinations reused the stale resolution and bridged to the wrong chain. Auto-resolve is now keyed on recipient + destination, and a resolved recipient whose chain no longer matches the selected destination is re-resolved before quoting.

**Fix** — host theme overrides were ignored in dark mode. The widget's dark palette lived on `[data-whisk][data-whisk-theme="dark"]` (specificity 0,2,0), which out-specified a host re-theming `--whisk-*` on `[data-whisk]` (0,1,0) — so a dark integration kept the default wine/terracotta colors. The theme discriminators are now wrapped in `:where()`, keeping every theme block at `[data-whisk]` specificity, so host overrides win in light, dark, and system (on the card and in portals).

**Fix** — wallet errors surfaced the raw viem/App Kit dump (`Request Arguments: …`, `Version: viem@…`). Declining the wallet prompt now reads "You cancelled the transaction in your wallet.", and other errors are trimmed to their human first line (App Kit's "Unknown blockchain error on \<chain\>:" wrapper and the argument dump are stripped). The full provider error is preserved on `cause` for debugging. This cleanup now covers every surface that renders an error — the transfer result screen, the connect modal (EVM and Solana), the manual-mint flow, and the ENS resolver — not just the bridge path. The message cleaner is also exported as `cleanErrorMessage` for custom surfaces.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export default {
| **Multi-wallet** | Ecosystem-first connect modal — EVM (MetaMask, Coinbase, WalletConnect, Rabby) and Solana (Phantom, Solflare, Backpack) never collide. |
| **Recipient resolvers** | Address + ENSIP-11 multichain ENS built in; bring your own (email, phone…). |
| **Fee transparency** | Custom + protocol + gas + forwarder, displayed before confirm. |
| **Fee bearer** | `feeBearer: "sender"` grosses up the burn so the recipient nets the exact amount; default `"receiver"` deducts fees from the transfer. |
| **Progress streaming** | Live step state from App Kit's bridge events. |
| **Mid-flight recovery** | Burn-but-no-mint failures: in-tab retry, cross-refresh persistence (localStorage, 48h), manual-mint escape hatch via direct `MessageTransmitter.receiveMessage`. |
| **Pre-flight checks** | Read-only balance / gas / chain-alignment inspection before the user signs. |
Expand Down
24 changes: 19 additions & 5 deletions apps/docs/src/content/docs/api/create-whisk-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ createWhiskConfig(options: CreateWhiskConfigOptions): WhiskClientConfig
| `defaultDestinationChain` | `Chain` | no | Initial destination. |
| `token` | `Token` | no | Default token alias. USDC today. |
| `resolver` | `Resolver` | no | Custom resolver chain. Defaults to address + ENS. |
| `feePolicy` | `FeePolicy` | no | Platform fee config. |
| `feePolicy` | `FeePolicy` | no | Platform fee, charged on top of the transfer. |
| `feeBearer` | `"sender" \| "receiver"` | no | Who absorbs the bridge fees. Defaults to `"receiver"`. |
| `rpcUrls` | `Partial<Record<Chain, string>>` | no | Override the default public RPC for a chain. |
| `useForwarder` | `boolean` | no | Forwarder Service. Defaults to `true`. |
| `appLabel` | `string` | no | Shown in some wallet UIs as the connecting app's name. |
Expand Down Expand Up @@ -94,10 +95,23 @@ const config = createWhiskConfig({
wallets: [evm({ projectId: "abc123…" })],
chains: ["Base"],
feePolicy: {
flat: "0.05",
percent: 0.002,
recipient: "0xMyFeeTreasury…",
label: "Service fee",
value: "0.05", // absolute USDC, added on top of the transfer
recipient: "0xMyFeeTreasury…", // split 90% to you, 10% to Arc
},
});
```

### Recipient receives the exact amount

Set `feeBearer: "sender"` so the bridge fees are added to the sender's
debit instead of deducted from the transfer. Useful for checkout,
payroll, and invoice flows where the recipient needs to net an exact
figure. See [Fees and quotes](/docs/concepts/fees#who-pays-the-bridge-fees-feebearer).

```ts
const config = createWhiskConfig({
wallets: [evm({ projectId: "abc123…" })],
chains: ["Base", "Arbitrum"],
feeBearer: "sender",
});
```
16 changes: 9 additions & 7 deletions apps/docs/src/content/docs/api/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,20 @@ type FeeBreakdown = FeeEntry[];

```ts
type Quote = {
route: Route;
sourceChain: Chain;
destinationChain: Chain;
amountIn: string;
amountOut: string;
fees: FeeBreakdown;
expiresAt: number; // ms-epoch
route: Route; // same-chain "send" or cross-chain "bridge"
recipient: ResolvedRecipient;
amountIn: string; // total debited from the sender ("You pay")
amountOut: string; // what the recipient receives ("Recipient gets")
amountBurned?: string; // on-chain transfer amount; grossed up in sender mode
token: Token;
fees: FeeBreakdown;
estimatedDurationMs?: number;
};
```

`amountIn`, `amountOut`, and `amountBurned` can all differ on a bridge,
depending on [`feeBearer`](/docs/concepts/fees#who-pays-the-bridge-fees-feebearer).

## `FeePolicy`

```ts
Expand Down
161 changes: 112 additions & 49 deletions apps/docs/src/content/docs/concepts/fees.mdx
Original file line number Diff line number Diff line change
@@ -1,101 +1,164 @@
---
title: Fees and quotes
description: What's in a Quote, how fees are calculated, and how to charge your own platform fee.
description: What a Quote contains, who pays the bridge fees, and how to charge your own platform fee.
---

A `Quote` is the engine's answer to "if I send X to Y, what
happens?". It's pure data until the user clicks confirm, no
transaction is submitted. You can build a quote, throw it away,
build another one, do whatever you want with it before the user
commits.
A `Quote` is the engine's answer to "if I send X to Y, what happens?".
It's pure data. Nothing is signed or submitted until the user confirms,
so you can build a quote, inspect it, throw it away, and build another
one freely.

## What a Quote contains

```ts
type Quote = {
route: Route; // same-chain "send" or cross-chain "bridge"
sourceChain: Chain;
destinationChain: Chain;
amountIn: string; // what the user types
amountOut: string; // what the recipient receives
fees: FeeBreakdown; // every line item
expiresAt: number; // ms-epoch, quotes get stale
recipient: ResolvedRecipient;
amountIn: string; // total debited from the sender ("You pay")
amountOut: string; // what the recipient receives ("Recipient gets")
amountBurned?: string; // the on-chain transfer amount fed to App Kit
token: Token;
fees: FeeBreakdown; // every line item
estimatedDurationMs?: number;
};
```

`amountIn - amountOut === sumFees(fees)`. Whisk never sneaks in a
fee that isn't enumerated in the breakdown. If the math doesn't add
up, that's a bug — file it.
Three amounts, because on a bridge they can all differ:

- **`amountIn`** is what leaves the sender's wallet. This is the "You
pay" figure on the review screen.
- **`amountOut`** is what the recipient receives. This is the "Recipient
gets" figure.
- **`amountBurned`** is the amount handed to CCTP on the source chain.
Fees are deducted from it during the bridge. It equals `amountOut` in
receiver mode and is grossed up in sender mode (see below). The widget
uses this internally; you rarely read it directly.

## Where fees come from

Two sources contribute:
Two sources contribute, and they behave differently.

**Circle App Kit fees** are the cost of moving USDC across chains:

| Fee | When it applies | Sized by |
| ---------------------- | -------------------------------------------- | ------------------------------------------------- |
| CCTP protocol fee | Fast Transfers only (Standard is free) | A few basis points of the amount, by source chain |
| Forwarding Service fee | When the destination mint is relayed for you | Destination-chain gas |
| Gas | Source-chain approve/burn | Paid in the source chain's native token |

The CCTP protocol and Forwarding fees come out of the bridged USDC. Gas
is paid separately in the native token, so it never reduces the USDC the
recipient receives.

The Forwarding fee is the one to watch. It tracks destination-chain gas,
not the transfer amount, so it's a near-fixed cost. Bridging to an L2
(Base, Arbitrum, Optimism, Arc) costs cents; bridging to Ethereum L1 can
cost dollars regardless of how much you send. Pick cheap destinations for
small transfers.

- **App Kit fees.** Gas, the bridge protocol fee, the forwarder fee
when applicable. These come from Circle's quote API; Whisk passes
them through.
- **Your platform fee.** Optional. If you've configured a `feePolicy`
on the config, Whisk adds your line item and (if you've set a
recipient) routes the cut to your treasury.
**Your platform fee** is optional and added on top. See
[Adding a platform fee](#adding-a-platform-fee).

The review step renders each line so the user can see what they're
paying before they sign.
## Who pays the bridge fees: `feeBearer`

By default the recipient absorbs the App Kit fees, the same way a wire
transfer arrives short of the amount sent. For a checkout, payroll run,
or invoice, that's usually wrong: the merchant needs to net an exact
figure. `feeBearer` controls this.

```ts
const config = createWhiskConfig({
wallets: [evm({ projectId })],
chains: ["Base", "Arbitrum"],
feeBearer: "sender", // default is "receiver"
});
```

**`"receiver"` (default)** — the bridge fees are deducted from the
transfer. The sender is debited the amount; the recipient nets
`amount − fees`.

```
amount = 100 USDC, fees = 0.30 USDC
You pay: 100.00 USDC
Recipient gets: 99.70 USDC
```

**`"sender"`** — the burn is sized up by the estimated fees so the
recipient receives the full amount. The sender's debit grows to cover
them.

```
amount = 100 USDC, fees = 0.30 USDC
You pay: 100.30 USDC
Recipient gets: 100.00 USDC
```

### The accuracy caveat

In sender mode the gross-up uses the fee **estimated at quote time**. The
Forwarding fee is priced from destination gas at mint time, which lands a
little later, so the live cost can drift from the estimate. To keep the
recipient whole, the gross-up pads the forwarding portion by a small margin
(2%) before sizing the burn. The recipient therefore receives **at least**
the requested amount; if gas comes in under the estimate, the unused margin
is minted to them. Penny-exact delivery isn't possible with a variable-fee
bridge unless you control the mint with a custom contract, so Whisk rounds
in the recipient's favor.

Same-chain sends never deduct USDC fees (only native gas), so `feeBearer`
is a no-op there: the recipient always receives the full amount.

## Adding a platform fee

Pass `feePolicy` to `createWhiskConfig`:
`feePolicy` charges your own fee on top of the transfer. Per Circle App
Kit, the fee is split 90% to your `recipient` and 10% to Arc, and it's
collected on the source chain.

```ts
const config = createWhiskConfig({
wallets: [evm({ projectId })],
chains: ["Base"],
feePolicy: {
flat: "0.05", // 5¢ flat
percent: 0.001, // plus 0.1%
recipient: "0xYourTreasury…", // fee wallet
label: "Platform fee", // shown in the review UI
value: "0.05", // absolute USDC amount, added on top
recipient: "0xYourTreasury…", // your fee wallet (source chain)
},
});
```

Every field is optional. Mix flat + percent, leave `recipient` off
to display the fee without collecting (useful when you're staging a
rollout), or skip `feePolicy` entirely if you don't charge.
`feePolicy` and `feeBearer` are independent. Your platform fee is always
added on top of the amount; `feeBearer` only decides who absorbs the
network fees. With both set to `"sender"` plus a `feePolicy`, the sender
is debited `amount + network fees + your fee`, and the recipient still
nets the full amount.

## Building a quote without rendering

Want a fee preview on a checkout summary before the widget mounts?
Call the engine directly. The engine doesn't render, so this works
on the server or in a button click handler:
Want a fee preview on a checkout summary before the widget mounts? Call
the engine directly. It doesn't render, so this works in a click handler
or anywhere you hold an adapter:

```ts
const quote = await engine.quote({
recipient,
amount: "100",
sourceChain: "Base",
destinationChain: "Arbitrum",
adapter,
});

console.log(quote.amountOut); // "99.92"
console.log(quote.fees);
console.log(quote.amountIn); // "100.3" in sender mode
console.log(quote.amountOut); // "100"
console.log(quote.fees.entries);
// → [
// { kind: "gas", label: "Gas", amount: "0.04" },
// { kind: "platform", label: "Platform fee", amount: "0.04" },
// { kind: "provider", amount: "0.013", token: "USDC" },
// { kind: "forwarder", amount: "0.287", token: "USDC" },
// ]
```

## Stale quotes

`expiresAt` is in milliseconds since epoch. Quotes go stale fast
because gas prices and bridge availability shift. If a user lingers
on a confirmation screen for a few minutes, re-quote before they
sign — `engine.quote(...)` is idempotent, and the new quote will
have a fresh `expiresAt`.

The bundled `<WhiskSend>` handles this automatically. The check
matters when you're driving the engine yourself.
Quotes go stale as gas prices shift, so re-quote before the user signs if
they've lingered. The bundled `<WhiskSend>` handles that for you; it
matters when you drive the engine yourself.

## Next

Expand Down
39 changes: 39 additions & 0 deletions apps/docs/src/content/docs/getting-started/install.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,45 @@ NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your-project-id
protocol. No project ID needed on that side.
</Callout>

## Vite + Solana: polyfill `Buffer`

Skip this unless you're on **Vite** _and_ using Solana. Next.js polyfills
Node globals for you, so App Router and Pages Router apps need nothing here.

`@solana/web3.js` reaches for Node's `Buffer` global (base58 encoding,
transaction serialization). The browser has no `Buffer`, and Vite doesn't
shim it, so the first Solana operation — connecting a Solana wallet, or a
CCTP bridge that crosses the Solana/EVM boundary — throws
`Buffer is not defined`.

Add the polyfill once, at the very top of your entry file, before any other
import so it's in place before wallet code loads:

```tsx title="src/main.tsx"
import { Buffer } from "buffer";
if (typeof globalThis.Buffer === "undefined") {
globalThis.Buffer = Buffer;
}

import React from "react";
import ReactDOM from "react-dom/client";
import "@usewhisk/react/styles.css";
import { App } from "./App";
// …
```

The `buffer` package ships as a transitive dependency of the Solana SDK, but
some package managers won't surface it to your app's `node_modules`. If the
import doesn't resolve, install it directly:

<InstallCommand packages={["buffer"]} />

<Callout type="info">
Prefer a plugin? `vite-plugin-node-polyfills` handles `Buffer` (plus `process`
and `crypto`) for you. The three-line shim above is lighter if Buffer is all
you need.
</Callout>

## Verify the install

Quick sanity check before you move on. Add this to any page in your
Expand Down
2 changes: 2 additions & 0 deletions examples/donate-button/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ with a locked recipient + soft-prefilled amount per tier.
- Public donor wall, impact stats, and an active-projects grid with
per-project progress.
- `onSuccess` shows a tier-specific thank-you with a tx hash link.
- Runs `feeBearer: "sender"` so the donor covers the bridge fees and the
treasury receives the full tier amount ($25 lands as $25).

## Stack

Expand Down
1 change: 1 addition & 0 deletions examples/donate-button/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@tanstack/react-query": "^5.59.0",
"@usewhisk/core": "workspace:*",
"@usewhisk/react": "workspace:*",
"buffer": "^6.0.3",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
Expand Down
Loading