@yieldfabric/wallet
v0.1.0
Published
Reusable YieldFabric wallet — login, signing, profile, and key management
Maintainers
Readme
@yieldfabric/wallet
Reusable YieldFabric wallet for service developers — login + onboarding,
signing (manual MQ + generic API), profile, and key management. Drop
<WalletProvider> into your React app and the rest of the surface
(hooks, UIs, services) becomes available.
Install
Published to the public npm registry — no scope .npmrc, no token:
npm install @yieldfabric/walletPeer deps: react ^18, react-dom ^18 (and optionally @stripe/stripe-js for
the Stripe-backed KYC provider). All other runtime deps (@avatune/*,
@averer/averer-websdk, @heroicons/react, js-sha3, zod) install
automatically from the public npm registry. The SDK bundles a Tailwind-styled
UI; consumers must add it to their Tailwind content glob (see below).
Quick start
// app/layout.tsx (Next.js App Router)
'use client';
import { WalletProvider, SignatureWorkflow } from '@yieldfabric/wallet';
import { useRouter } from 'next/navigation';
export default function RootLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
return (
<html>
<body>
<WalletProvider
config={{
// Imperative navigation hook so the SDK can route between
// login / onboarding screens. Wire to your router.
onNavigate: (href) => router.push(href),
// (Optional) Called after a session is established.
// onSessionBootstrapped: (userId) => { /* invalidate caches */ },
}}
>
{children}
{/* Zero-config global mount — wires the wallet's signature
queue, drawer, toast notifier, and (optionally) the
Signature Center launcher. This is the component every
host wants at the root; <SignatureDrawer /> by itself is
a *controlled inner* that needs `message` / `onClose` /
`userId` props and is rendered by <SignatureWorkflow>
for each pending message. */}
<SignatureWorkflow />
</WalletProvider>
</body>
</html>
);
}// app/login/page.tsx
'use client';
import { LoginForm, useAuth } from '@yieldfabric/wallet';
export default function LoginPage() {
const { isAuthenticated } = useAuth();
if (isAuthenticated) /* redirect */;
return <LoginForm />;
}Configuring service URLs
The SDK reads service base URLs from process.env.REACT_APP_* at
build time (see src/core/config.ts):
| Var | Default | Use |
|--|--|--|
| REACT_APP_AUTH_SERVICE_URL | http://localhost:3000 | auth REST |
| REACT_APP_PAYMENTS_SERVICE_URL | http://localhost:3002 | payments + WebSocket |
| REACT_APP_API_URL | http://localhost:3002 | shared API base (WS derivation) |
| REACT_APP_CORE_SERVICE_URL | http://localhost:3002 | core service URL |
| REACT_APP_CHAIN_ID | 31337 | default chain id |
How to expose them to the browser depends on your bundler:
- CRA / craco:
REACT_APP_*works natively. Put them in.env. - Vite: define them with
define: { 'process.env.REACT_APP_*': ... }invite.config.ts, or forkcore/config.tsto readimport.meta.env.VITE_*. - Next.js: prefix with
NEXT_PUBLIC_in.env.localAND map them innext.config.js'senvblock soprocess.env.REACT_APP_*resolves in client bundles.
Future work: a follow-up will move URL resolution onto the
WalletRuntimeConfig so consumers can pass URLs at mount time and
not rely on env-var plumbing. See the comment at the top of
core/config.ts.
The full public surface — providers, hooks, UI components, core REST/crypto
services, types — is enumerated in src/index.ts.
Login surface API
The login screen is built from three independently-mountable pieces:
| Component | Use when |
|---|---|
| <LoginAltMethods /> | You want the wallet / passkey chip row (Averer, MetaMask, Dynamic, Passkey). No props. Reads auth.providers.enabled from /auth/providers/config. |
| <LoginForm /> | You want the bundled email/password form with sensible defaults (services: ['vault', 'payments'], autoRedirect: false). |
| <LoginComponent> | You want full control. Pass options for hook config; pass render={...} or children={...} to take over the JSX while keeping the form state machine. |
Custom-JSX login via render prop
<LoginComponent render={...}> receives the full form state — both
the submission half (login, clearError, isLoading, isSubmitting,
error) and the useForm machine (values, errors, touched,
handleChange, handleBlur, handleSubmit). This means you can render
any layout you want without re-implementing form state:
<LoginComponent
options={{ services: ['vault', 'payments'], autoRedirect: false }}
render={({ values, errors, touched, handleChange, handleSubmit, isSubmitting, error }) => (
<form onSubmit={handleSubmit} className="space-y-5">
<input
type="email"
value={values.email}
onChange={(e) => handleChange('email', e.target.value)}
className="my-custom-field"
/>
{/* …password, error, submit… */}
</form>
)}
/>Incoming payments + obligations inbox
Embeddable surface for letting an authenticated user view and accept
pending payments (instant sends, swap legs) and obligations (contracts
whose status is ACTIVE and whose counterpart is the active entity).
Three drop-in components, ordered most opinionated → least:
| Component | Use when |
|---|---|
| <IncomingPaymentsLauncher /> | You want a nav-chip with a badge count. Click opens an internal drawer with the full inbox. Mirrors <SignatureCenterLauncher> in shape. |
| <IncomingPaymentsCenter /> | You want a dedicated /payments/inbox-style page. Adds a header, refresh button, and summary tiles on top of the inbox. |
| <IncomingPaymentsInbox /> | You want just the card list — embed in your own chrome (drawer, dashboard card, sidebar). |
| useIncomingPayments() | You want full UI control. Returns { items, groups, accept, acceptState, loading, error, refetch, activeEntityId, … }. |
import {
IncomingPaymentsLauncher,
IncomingPaymentsCenter,
IncomingPaymentsInbox,
useIncomingPayments,
} from '@yieldfabric/wallet';
// Launcher in your nav.
<IncomingPaymentsLauncher
variant="icon-label"
onReview={(item) => router.push(`/contracts/${item.contract?.id ?? ''}`)}
getAccountLabel={(id) => entityNameCache.get(id) ?? id}
/>
// Or a full page.
<Route path="/payments/inbox" element={<IncomingPaymentsCenter />} />
// Or build your own layout, SDK does the data.
function MyInbox() {
const { items, accept, acceptState } = useIncomingPayments();
return items.map((item) => (
<MyRow
key={item.key}
item={item}
onAccept={() => accept(item)}
busy={acceptState.itemKey === item.key}
/>
));
}Acting-as / delegation is automatic
When a host mints a delegation token via
useAuthContext().createDelegationToken(group), the inbox automatically
scopes every query and mutation to the delegated group. No prop
threading required. The resolution order for the active entity is:
- Explicit
entityIdprop on the hook / component (if passed). localStorage.delegation_group_id— set by<AppAuthProvider>when delegation is active.useAuth().user.id— the authenticated user's own entity.
Hosts that maintain their own actingAs model can pass entityId
explicitly to override the auto-detection.
Signature requirement
Accepting a payment or obligation enqueues an on-chain transaction
through the MQ. When the user is in Manual signature mode the
SDK's signature drawer prompts them to sign before the executor
fires. Consumers using this inbox must mount
<SignatureWorkflow /> once at the app root — that's the zero-prop
component that wires the queue + drawer + toast + Signature Center
launcher together. <SignatureDrawer /> by itself is a controlled
inner that takes message / onClose / userId props; you don't
mount it directly.
// Once at the app root.
import { WalletProvider, SignatureWorkflow } from '@yieldfabric/wallet';
<WalletProvider config={…}>
{children}
<SignatureWorkflow />
</WalletProvider>Without this mount, manual-mode users will see the inbox's Accept
mutation succeed at the resolver but the on-chain settlement will
never run.
Live refresh
The hook subscribes to the same entity-events SSE stream the
signature center uses (Notification, MessageStatusChange,
WorkflowUpdate, PositionChange). When any of those fire for the
active entity, the inbox refetches automatically. The cost of a
refetch is small enough that a coarse "refresh on any relevant
event" strategy is correct — no manual cache invalidation is
required.
Pass disableLiveRefresh: true to opt out (e.g. for tests).
Send payment (confidential YF "instant send")
Embeddable surface for the outbound side of the wallet. Submits a YF
confidential payment via the instant GraphQL mutation — amounts are
encrypted on chain, queued through the MQ, gated by
<SignatureWorkflow /> when the user is in Manual mode.
Three drop-in surfaces:
| Component | Use when |
|---|---|
| <SendPaymentDrawer /> | You want a "Send" button somewhere that opens a drawer. Zero-config. |
| <SendPaymentForm /> | You want the form embedded in your own chrome (page, modal, sidebar). |
| useSendPayment() | You want full UI control. Returns { send, sending, error, lastResponse, reset }. |
import {
SendPaymentDrawer,
SendPaymentForm,
useSendPayment,
convertAmountToRaw,
} from '@yieldfabric/wallet';
// 1. Drawer — open from a button in your nav / wallet panel.
const [open, setOpen] = useState(false);
<button onClick={() => setOpen(true)}>Send</button>
<SendPaymentDrawer
isOpen={open}
onClose={() => setOpen(false)}
assets={[
{ id: 'aud-token-asset', currency: 'AUD', decimals: '18' },
{ id: 'usdc-token-asset', currency: 'USDC', decimals: '6' },
]}
onSent={(response) => {
// Resolver accepted — the recipient's inbox will now show the
// payment as PENDING. `response.messageId` is the MQ id for
// optional `messagesService.pollMessageCompletion` polling.
}}
/>
// 2. Embed the form directly (no drawer).
<SendPaymentForm
assets={assets}
defaultAssetId="aud-token-asset"
onSuccess={(response) => navigate(`/payments/${response.paymentId}`)}
/>
// 3. Build your own form, SDK handles the submit + state.
function MySendForm() {
const { send, sending, error } = useSendPayment();
return (
<form onSubmit={async (e) => {
e.preventDefault();
await send({
assetId: 'aud-token-asset',
amount: convertAmountToRaw('100.50', 18),
destinationId: '[email protected]',
});
}}>
… your fields …
<button disabled={sending}>{sending ? 'Sending…' : 'Send'}</button>
{error && <p>{error}</p>}
</form>
);
}Asset list is host-supplied
The SDK doesn't bundle an asset-lookup query — that's host-specific
(GraphQL assets.all for some hosts, a hardcoded enum for others).
Pass the asset list as a prop to <SendPaymentForm> /
<SendPaymentDrawer>. The shape is minimal:
interface SendPaymentAsset {
id: string; // token asset id (e.g. "aud-token-asset")
currency: string; // display label ("AUD", "USDC")
decimals?: string | number; // defaults to 18 when omitted
name?: string; // optional long name for the picker
}Amount conversion
The resolver wants a raw on-chain integer string ("100500000000000000000"
for 100.50 AUD with 18 decimals). The form does this conversion
internally via convertAmountToRaw(humanAmount, decimals). If you're
calling the service / hook directly, do the same:
import { convertAmountToRaw } from '@yieldfabric/wallet';
const raw = convertAmountToRaw('100.50', 18); // → "100500000000000000000"convertAmountToRaw rejects negatives, NaN, scientific notation,
and over-precision (more fractional digits than the token's
decimals), so you can use it as your validator too.
Recipient resolution
The form's "Recipient" field accepts an entity name or id
([email protected], a UUID, …). Behind the scenes the resolver
looks up the entity's default wallet. For multi-wallet entities
where the sender wants to target a specific wallet, set
enableWalletIdMode to expose a small toggle that switches the field
to wallet-UUID input.
Direct calls have all three options:
await send({ assetId, amount, destinationId: '[email protected]' });
await send({ assetId, amount, destinationWalletId: 'd1b3…wallet-uuid' });
await send({ assetId, amount, contractId: 'CONTRACT-OBLIGATION-…' });Pass exactly one. The resolver throws when zero or multiple are provided.
Acting-as is automatic
Same story as the inbox: when a delegation token is installed
(useAuthContext().createDelegationToken(group)), the send
originates from the delegated group instead of the user. No prop
threading needed — tokenManager picks the right token, the
resolver reads the acting_as claim and signs against the group's
wallet.
Signature requirement
For Manual-mode users (external wallets — passkey, MetaMask,
Dynamic), the on-chain send is gated by <SignatureWorkflow />.
Mount it once at the app root. Without it, send() resolves
successfully but the executor never runs.
Block-explorer links
Transaction hashes rendered by the SDK's signature drawer + signature center row + EOA-send status panel are automatically wrapped in explorer links when the SDK has a registered explorer for the message's chain id. Click the hash → opens the chain's explorer (Etherscan, Polygonscan, BscScan, SnowTrace, Redbelly Explorer, …) in a new tab.
Built-in coverage:
| Chain | Explorer |
|---|---|
| Ethereum Mainnet (1) | etherscan.io |
| Goerli (5) / Sepolia (11155111) | (goerli|sepolia).etherscan.io |
| Polygon (137) / Mumbai (80001) | polygonscan.com / mumbai.polygonscan.com |
| BSC (56) / Testnet (97) | bscscan.com / testnet.bscscan.com |
| Avalanche (43114) / Fuji (43113) | snowtrace.io / testnet.snowtrace.io |
| Redbelly Mainnet (151) | redbelly.routescan.io |
| Redbelly Testnet (153) | redbelly.testnet.routescan.io |
Local-dev chains (31337, 33117, 1337) intentionally have no
explorer registered — the hash renders as plain text.
Adding or overriding an explorer
import { registerBlockExplorer } from '@yieldfabric/wallet';
// Add a chain not in the defaults:
registerBlockExplorer(8453, {
name: 'Basescan',
txUrl: (hash) => `https://basescan.org/tx/${hash}`,
addressUrl: (addr) => `https://basescan.org/address/${addr}`,
});
// Override one path while inheriting the rest (e.g. prefer
// Blockscout for addresses but keep Etherscan for txs on mainnet):
registerBlockExplorer(1, {
addressUrl: (addr) => `https://eth.blockscout.com/address/${addr}`,
});Direct helpers if you need to render your own link:
import {
getBlockExplorerTxUrl,
getBlockExplorerAddressUrl,
} from '@yieldfabric/wallet';
const txUrl = getBlockExplorerTxUrl(chainId, txHash); // string | null
const addrUrl = getBlockExplorerAddressUrl(chainId, addr); // string | nullBoth helpers return null when the chain has no registered explorer
or the input fails validation (empty / not 0x…-hex / wrong length
for addresses), so the calling component can fall back to plain text
without an if-cascade.
Passkey sign-in: no chain id needed by default
signInWithPasskey() and signUpWithPasskey() both accept an optional
chainId. When omitted, the SDK falls back to
Number(getDefaultChainId()) (which reads REACT_APP_CHAIN_ID, defaulting
to 31337):
// All three call the same default chain
const r1 = await signInWithPasskey();
const r2 = await signInWithPasskey({});
const r3 = await signInWithPasskey({ chainId: Number(getDefaultChainId()) });
// Multi-chain consumers can still pin the chain explicitly
const r4 = await signInWithPasskey({ chainId: 1 });Tailwind setup
The SDK ships UI components styled with Tailwind utility classes. Tailwind needs to scan the SDK source so it picks up which utilities to keep.
// tailwind.config.js
module.exports = {
content: [
'./src/**/*.{ts,tsx}',
// Scan the published bundles — className strings are intact (tsup
// does not minify), so the utilities survive purge.
'./node_modules/@yieldfabric/wallet/dist/**/*.{js,cjs}',
],
// …
};The package ships only dist/ (compiled, source closed), so scan the bundles —
the class strings are preserved there verbatim.
Theming for consumer apps
The SDK is theme-portable: every surface (login, onboarding, signature
drawer / center, keys manager, profile editor, primitives, identity card)
renders against a fixed set of semantic Tailwind token names. Map
them in your tailwind.config.js to re-skin the SDK to any palette —
dark, light, brand-coloured, anything. No dark: prefix is required; no
runtime theme switch; no SDK code change.
The token contract
The SDK uses these names. Define them under theme.colors (or
theme.extend.colors) in your tailwind config.
// tailwind.config.js
module.exports = {
// …content paths…
theme: {
extend: {
colors: {
// ── Surfaces ────────────────────────────────────────────
page: '#ffffff', // viewport background
surface: '#ffffff', // card / sheet background
'surface-alt': '#f7f8fa', // muted card / striped row
raised: '#ffffff', // floating elements (toasts, drawers)
'raised-hover': '#f7f8fa',
overlay: 'rgba(255,255,255,0.92)',
background: '#ffffff', // legacy alias, still used by some SDK chrome
// ── Borders ─────────────────────────────────────────────
'border-default': '#e3e8f0',
'border-light': '#eef1f6',
'border-strong': '#c7d0dc',
// ── Text ────────────────────────────────────────────────
'text-primary': '#0a1b30', // body text
'text-secondary': '#32373c', // secondary copy
'text-muted': '#5f6b7a', // labels, captions
'text-inverse': '#ffffff', // text on dark surfaces
// ── Brand primary (accent / glow / focus ring) ─────────
primary: '#054e92',
'primary-muted': 'rgba(5,78,146,0.10)',
accent: '#0b6acf',
// ── CTA (high-emphasis button) ─────────────────────────
// In a dark theme this is intentionally near-text-color so the
// button reads as inverted; in a light theme it's typically the
// brand color. The two tokens are kept distinct so each consumer
// can choose what "CTA" should look like vs. the brand accent.
cta: '#054e92',
'cta-hover':'#054075',
'on-cta': '#ffffff',
// ── Chip (elevated small surface: sign-in chips,
// segmented controls, selected pills) ──────────
chip: '#f7f8fa',
'chip-hover': '#eef2f8',
'chip-active': '#e3e8f0',
// ── Input (subtle filled input / inline card bg) ───────
'input-bg': '#ffffff',
// ── Status (used by signature drawer / center / toasts) ─
'status-success-bg': '#dcf0e6',
'status-success-text': '#1a6b42',
'status-warning-bg': '#fbf3d4',
'status-warning-text': '#7a6518',
'status-error-bg': '#fde2e7',
'status-error-text': '#9b2c3c',
'status-info-bg': '#dceaf8',
'status-info-text': '#1a5090',
// ── Pastels (soft badge fills) ──────────────────────────
'pastel-peach': '#fde6d5',
'pastel-lavender': '#e9e2f5',
'pastel-mint': '#d4f0e4',
'pastel-sky': '#dceaf8',
'pastel-rose': '#fbe2e6',
'pastel-lemon': '#fbf3d4',
'pastel-lilac': '#ecdef6',
'pastel-coral': '#fde4dd',
},
},
},
};The example values above are a light theme. Drop the same token names into your own palette and the SDK picks up your colours.
Backing tokens with CSS variables (optional)
If you run a single app with both light and dark modes, back each token
with a CSS variable in :root and .dark, then point Tailwind at the
variable. See the YieldFabric host's index.css for the canonical
pattern; the values to define are the same as the table above, prefixed
with --color-.
Autofill (Chrome / Safari)
The login + signup inputs use a .autofill-fix class to keep autofilled
text legible. Add this CSS once in your global stylesheet (light theme
shown; flip the box-shadow + fill-color for dark):
.autofill-fix:-webkit-autofill,
.autofill-fix:-webkit-autofill:hover,
.autofill-fix:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 1000px #ffffff inset !important;
-webkit-text-fill-color: #0a1b30 !important;
caret-color: #0a1b30;
transition: background-color 5000s ease-in-out 0s;
}What you don't have to do
- No
theme="light" | "dark"prop — the SDK has no theme switch; the same JSX renders in either palette. - No CSS-in-JS — Tailwind classes only, so your build pipeline is unchanged.
- No code forks per consumer — every SDK surface is portable today; a future consumer needs only the token map above.
Package layout
src/
├── core/ no React, no DOM — REST clients, WebAuthn, crypto, token store
├── providers/ auth provider impls (email, passkey, MetaMask, Averer) + config
├── react/ hooks + headless controllers
└── ui/ styled components (LoginWidget, SignatureDrawer, KeysManager, …)Import graph: ui → react → providers → core. Forbid backward edges.
Build
npm install
npm run build # one-shot — emits dist/index.{js,cjs,d.ts}
npm run dev # watch mode — recompiles on save
npm run typecheck # tsc --noEmit
npm run clean # rm -rf disttsup (esbuild + dts) produces:
dist/index.js— ESM bundle (consumed by modern bundlers / Next.js)dist/index.cjs— CommonJS bundle (Node fallback)dist/index.d.ts/.d.cts— type definitions
Both bundles are prepended with "use client"; so Next.js App Router
treats the SDK as client-side by default (the surface is hook + DOM-heavy).
Externals: react, react-dom, and the runtime deps (@averer/averer-websdk,
@heroicons/react, js-sha3, zod) are NOT bundled — consumers install
them via the dependencies / peerDependencies declarations.
Consuming from another monorepo package
If your app lives in the same repo and uses file: reference, the consumer
reads dist/ via the main / module / exports fields:
{
"dependencies": {
"@yieldfabric/wallet": "file:../yieldfabric-wallet-sdk"
}
}Workflow:
# In the SDK
cd yieldfabric-wallet-sdk
npm install
npm run build # one-shot, or `npm run dev` for watch mode
# In the consumer
cd ../my-app
npm install # picks up the freshly-built dist
npm run devIf you're iterating on the SDK while running the consumer, leave
npm run dev running in the SDK directory; the consumer's bundler will
pick up changes on the next reload.
Publishing
Ships to the public npm registry as a public scoped package
(publishConfig.access: public). Publishing @yieldfabric/* requires the
yieldfabric scope on npm — i.e. an npm org (or user) named yieldfabric.
Authenticate once with npm login, then:
# bump "version" first — re-publishing an existing version is rejected
npm publish # prepublishOnly runs clean && build automaticallyprepublishOnly runs clean && build, so a stale dist/ can't leak into the
tarball. The published tarball ships dist/ + README.md only — no src/
and no source maps, so the TypeScript source stays closed (only compiled
output ships). Verify with npm pack --dry-run.
License
Proprietary. See repo root for terms.
