@meteorwallet/connect
v0.25.0
Published
Downloads
2,916
Readme
@meteorwallet/connect
Client library for connecting applications to Meteor Wallet over the Meteor Connect bridge.
The session-native API has two protocol roles over one bridge:
PartnerSessionClient— the requesting application (including a source wallet in a wallet-to-wallet flow), which creates turns and consumes signed results.WalletSessionClient— the destination wallet, which claims one session and independently reviews/completes each turn.
One-action interactions use the same protocol through runSingleAction(). Multi-turn workflows keep
the same bridge, fixed wallet, authorization level, and secure channel until explicit close or a
bounded terminal condition. Both roles are typed end to end: actions, session/turn state, and errors
keep their types across the network boundary.
Installation
npm install @meteorwallet/connect @meteorwallet/connect-shared
# or
bun add @meteorwallet/connect @meteorwallet/connect-sharedTwo packages, and that is the whole integration surface. @meteorwallet/connect carries the two
clients, the storage adapters, the helpers, and the wire payload types; @meteorwallet/connect-shared
carries the protocol enums and the action implementations you build requests and results with.
Quickstart — Partner (one-action)
The fastest integration: create a session, present a wallet link, wait for the wallet's signed result, and the SDK acknowledges-and-closes atomically.
import {
buildWalletLinkUrl, createWebStorageAdapter, describeSessionError, PartnerSessionClient,
selectWalletLink,
} from "@meteorwallet/connect";
import { EPartnerOrigin } from "@meteorwallet/connect-shared";
const client = new PartnerSessionClient({
backendUrl: "https://mc.meteorwallet.app",
backendStorageScope: "meteor-production",
httpRequestTimeoutMs: 15_000,
partnerMetadata: { name: "My App", origin: `${EPartnerOrigin.web_url}::${location.origin}` },
storageAdapter: createWebStorageAdapter({ keyPrefix: "my-app::" }),
});
await client.initializeClient();
// Build a server-eligible single-turn request in your feature host. `transfer_accounts` is the
// current production single-turn option; persist its host receipt before starting the session.
const initialActionRequest = await buildTransferAccountsRequestInYourFeature();
try {
const { result } = await client.runSingleAction({
initialActionRequest,
onSessionReady: (session) => {
// Pick by preference, never by index: `walletLinks` is ordered by the backend, so
// `walletLinks[0]` is whichever app it happened to list first.
const link = selectWalletLink(session, { preferredAppIds: MY_PREFERRED_WALLET_APP_IDS });
if (link == null) throw new Error("No wallet link for a supported app");
showQrCode(buildWalletLinkUrl(session, link));
},
});
// result.actionResult is the wallet's verified signed result; session already closed.
} catch (error) {
const desc = describeSessionError(error, { backendUrl: client.backendUrl });
console.error(desc.headline, desc.detail);
} finally {
if (client.sessionFacts != null) {
await client.closePhaseSafe().catch(() => undefined);
await client.disconnectBridge();
}
}runSingleAction creates the session with the single_turn_v1 resource profile, calls your
onSessionReady callback to present the wallet link, waits for the verified signed result,
acknowledges-and-closes atomically, and disconnects. On a callback throw or failure it
phase-safely closes and disconnects before re-throwing your error.
Two-turn partner flow with advance
Multi-turn workflows use createSession + waitForResult + advance:
const session = await client.createSession({ initialActionRequest });
const link = selectWalletLink(session, { preferredAppIds: MY_PREFERRED_WALLET_APP_IDS });
if (link == null) throw new Error("No wallet link for a supported app");
showQrCode(buildWalletLinkUrl(session, link));
const firstResult = await client.waitForResult();
// Ordinary first links wait for confirmation in the wallet. Only fresh_pin sessions use verifyPin(pin).
await client.advance({ acknowledgedResult: firstResult.receipt, nextActionRequest });
const secondResult = await client.waitForResult();
await client.acknowledgeAndClose(secondResult.receipt);
await client.disconnectBridge();createSession derives resourceProfile, authorizationMode, requiredWalletCapabilities, and
partnerRequestId from the action's server-authoritative policy; pass any of them only to override.
The generated partnerRequestId is echoed as session.partnerRequestId — reuse it only for an exact
creation retry.
Quickstart — Wallet
The wallet claims a session from a parsed wallet link, iterates turns, completes each one, and phase-safely closes.
import {
createWebStorageAdapter, hydrateAndValidateActionRequest, parseWalletLinkUrl, WalletSessionClient,
} from "@meteorwallet/connect";
import {
act_impl_meteor_wallet_core, EMeteorAppId, EWalletProtocolCapability,
} from "@meteorwallet/connect-shared";
const client = new WalletSessionClient({
backendUrl: "https://mc.meteorwallet.app",
backendStorageScope: "meteor-production",
httpRequestTimeoutMs: 15_000,
meteorAppId: EMeteorAppId.meteor_wallet_mobile,
additionalWalletCapabilities: [EWalletProtocolCapability.new_key_account_transfer_v1],
// On React Native: createAsyncStorageAdapter({ asyncStorage: AsyncStorage })
storageAdapter: createWebStorageAdapter({ keyPrefix: "my-wallet::" }),
});
await client.initializeClient();
// If the identity was previously deleted, initializeClient throws — call createWallet() to
// create a fresh account, or resumePendingCompletion() if a completion was interrupted.
// Parse the wallet link (QR/deep link) — the fragment secret is handled inside.
const claimInput = parseWalletLinkUrl(walletLinkUrl);
if (claimInput == null) throw new Error("Invalid wallet link");
const claimed = await client.claimSession(claimInput);
if (client.requiresPartnerConfirmation) {
// Show client.linkConfirmation.partnerMetadata as app-provided (unverified) details.
// Warn: continue only if you initiated this connection; it remembers the app for future requests.
// Wire explicit Link / Cancel buttons to confirmPartnerLink() / rejectPartnerLink().
// Do not confirm automatically on claim, push, focus or reload.
}
if (claimed.pinCode != null) {
// Show this PIN to the user. The partner/source role is the one that submits it.
await showPinCodeToUser(claimed.pinCode);
}
// Iterate turns — the async generator finishes on terminal.
for await (const turn of client.turns()) {
// The SDK validates first-party wire input before yielding; hydration restores the typed API.
const hydrated = hydrateAndValidateActionRequest(
act_impl_meteor_wallet_core,
turn.actionRequest,
);
const actionResult = await reviewAndResolveWalletCoreAction(hydrated);
await client.completeAction(actionResult);
// completeAction persists the result before sending; an ambiguous response can call
// resumePendingCompletion() without executing the action twice.
}
await client.closePhaseSafe();
await client.disconnectBridge();Wallet event subscription
Subscribe to session lifecycle events without subclassing:
client.events.on("factsChanged", ({ facts }) => updateUi(facts));
client.events.on("terminal", ({ outcome }) => handleTerminal(outcome));
client.events.on("pinRequired", () => showPinPrompt());
client.events.on("linkStatusChanged", ({ status }) => updateLinkBanner(status));The event names are a closed literal union: factsChanged, terminal, linkStatusChanged,
realmStatus, attachError, pinRequired. Handlers must not invoke client mutations during
dispatch (same re-entrancy expectation as the old protected hooks); a handler throw is caught
and reported, never corrupting client state.
Framework-free state snapshot
For React/Vue/Svelte stores that need a snapshot of the wallet session lifecycle:
import { createWalletSessionState } from "@meteorwallet/connect";
const state = createWalletSessionState(client);
// state.getSnapshot() → { clientStatus, identity, facts, turn, error, ... }
// state.subscribe(listener) → unsubscribe
// state.initialize() / state.applyClaimedSession(claimed) / state.complete(input) / state.clear()Links that name their backend
Not in 0.12.0.
buildWalletLinkUrl'sbackendUrlHintoption andresolveTrustedBackendUrlHintship in the next release. On 0.12.0 the two-argumentbuildWalletLinkUrl(session, link)is the whole surface — until then, a multi-backend integration has to resolve its own backend from configuration it already trusts, and must not take one from a scanned link.
A wallet built against one backend cannot claim a bridgeId that only exists on another — the
claim fails as a plain "not found" that says nothing about why. When your wallet and partner can
legitimately meet on more than one backend (a staging partner presenting to a wallet whose default
is production, a locally-run wallet claiming from a deployed environment), the partner can say which
backend the session lives on:
import {
buildWalletLinkUrl, parseWalletLinkUrl, resolveTrustedBackendUrlHint,
} from "@meteorwallet/connect";
// Partner side — opt-in; a single-backend integration should leave this unset.
const url = buildWalletLinkUrl(session, session.walletLinks[0], {
backendUrlHint: partnerClient.backendUrl,
});
// Wallet side — the hint is UNTRUSTED input off the same QR as everything else.
const parsed = parseWalletLinkUrl(url);
if (parsed != null) {
const backendUrl = resolveTrustedBackendUrlHint({
hint: parsed.untrustedBackendUrlHint,
allowedBackendUrls: MY_APP_BACKENDS, // your configuration, never the link
});
// `backendUrl` is one of MY_APP_BACKENDS, or null. Only now decide whether to act on it.
}Treat the hint as routing advice, never authorization. Dialing a backend named by a scanned link
would hand that host the session's bridgeId, bridgeLease, and partnerSecret, so
resolveTrustedBackendUrlHint returns your allowlist entry rather than the hint string — a
look-alike host cannot match, and the value you act on is always one you configured. The hint is
deliberately not part of the claimSession input, so passing a parsed link straight to
claimSession ignores it; and most apps should ignore it outright in production builds.
Switching backends switches identity: backendStorageScope derives from the backend URL, so each
backend keeps its own client keys, trust pins, and stored partners. Build a new client rather than
trying to move one.
Error handling
Every failure in a session flow is classifiable through one function:
import { describeSessionError } from "@meteorwallet/connect";
const desc = describeSessionError(error, { backendUrl: client.backendUrl });
// desc.kind → "terminal" | "session" | "bridge" | "connection" | "local" | "unknown"
// desc.headline → one plain-language sentence
// desc.detail → what it means and what to do next
// desc.originalMessage → the untouched underlying messageterminal— the session ended permanently; create a new session to continue.session— a typed protocol rejection withids[]and aretryhint ("exact","no", or"new_operation").bridge— a typed bridge-level rejection (allocation, lease, transport authorization).connection— a connection-level failure (embeds the fulldescribeBackendConnectionErroroutput, whose ownkindisprotocol_mismatch/network_unreachable/handshake_rejected/bridge_gone/unknown, plus the exactendpointdialed when the error names one).local— an SDK fail-closed guard (SessionLocalGuardError) raised before/after transmission; address the stated precondition and rerun.unknown— read the original message for the specific cause.
See the error handling guide for the full EErr_Bridge_Session
catalogue with retryability and the turn_already_active/session_closing precedence note.
Session lifecycle and recovery contract
Each logical session uses a fresh unpredictable partnerRequestId (generated for you and echoed on
the created session when omitted); reuse it only for exact allocation and staging retries. The
immutable server-approved profile fixes turn, idle, absolute-lifetime, and redial bounds. The SDK
carries the lease on authenticated HTTP/WebSocket traffic, never extends a deadline through traffic,
and treats action bridge_gone and WebSocket close 4410 as the same non-retryable terminal state.
Create a fresh session rather than reviving an expired one.
Turn completion is not session completion. The wallet persists the exact encrypted/signed completion
before sending it, so an ambiguous response can call resumePendingCompletion() without executing
the wallet action twice. The partner exposes exact result receipts plus prepare/submit methods for
host-owned D33 journals. advance() acknowledges the old result and installs the next turn atomically;
acknowledgeAndClose() performs the final acknowledgement and close atomically.
The bridge secret and lease remain memory-only. A live partner can create an authenticated D24 current-turn handoff or send a paired-wallet notification to wake the fixed claimed wallet; the handoff contains no action/account plaintext and expires with the idle deadline. If both processes lose the session or the bridge expires, use the feature's durable journal and a fresh session. Never log the lease, partner secret, PIN, device token, durable IDs, sealed payload, action, or result.
One session per process
Each client instance binds at most one bridge session at a time. Claiming a new session while one is
live throws a SessionLocalGuardError — disconnect the current session first. This is a protocol
invariant, not a configuration choice: the bridge enforces one claimed wallet per session, and the
SDK's mutation locks, event hub, and turn iterators are all scoped to the bound session.
Acknowledgement is receipt, not acceptance
acknowledgeResult tells the backend "I received this result" — it is transport receipt, never
acceptance of the result's content. A declined result (signed typed-error) is still acknowledged so
the session is not parked in result_ready; the partner then decides whether to close, replay, or
advance. acknowledgeAndClose is the final receipt-and-close; advance is receipt-plus-next-turn.
Same-process and multi-tab mutation locks
Where the platform provides the Web Locks API (navigator.locks — multi-tab browsers), the SDK
locks durable session mutations by default under one stable name keyed by backend storage scope +
role, so every tab or client instance sharing one identity serializes on one lock. Platforms without
Web Locks (React Native/AsyncStorage hosts, tests) fall back to per-instance serialization. Supply
your own callback only to override the default.
Phase-safe close
closePhaseSafe() on either role inspects the current phase and chooses the correct close verb —
never a blind destructive operation. Use describeCloseOptions(facts) to label close buttons in UI,
and isTerminalPhase(facts) to detect when the session is already over.
Push wake (mobile wallets)
The SDK persists claimed-partner public keys on every successful claim, so push-delivered session handoffs can be unsealed without a consumer-built key store:
import { parsePushSessionClaim, parsePushSessionTurnWake } from "@meteorwallet/connect";
// On an initial push notification. Both parsers are fail-closed: `null` means the payload was not
// a well-formed, in-bounds push, and the only correct response is to drop it.
const claimPayload = parsePushSessionClaim(notification);
if (claimPayload != null) {
const claimed = await client.claimInitialPush({ payload: claimPayload });
}
// On a per-turn wake push:
const wakePayload = parsePushSessionTurnWake(notification);
if (wakePayload != null) {
const { claimed, turn } = await client.claimTurnWake({ payload: wakePayload });
}The SessionClaimRouter unifies link/QR/push/manual claim inputs through one serial chain with
live-session refusal (a manual claim while a session is live emits refused, never a silent no-op).
See the push guide for the full integration.
Migration from pre-cut APIs
If you are migrating from the pre-P6 snake_case API, see
MIGRATION.md for the old→new name/shape table.
Backend-scoped persistence
Every backend-coupled client record is stored below a versioned backend-and-role prefix before the client reads or creates anything. This includes the persistent Durable Object ID, signing/exchange keys and trust pins, paired-wallet records, and channel state. Switching between local, development, and production backends therefore preserves three independent identities instead of presenting an ID minted by one Cloudflare Durable Object namespace to another.
If backendStorageScope is omitted, the SDK uses a versioned SHA-256-derived identifier for the
canonical HTTP(S) origin of backendUrl. Set it explicitly when several URL aliases intentionally
reach the same backend, or when separate backends are mounted below different paths on the same
origin. The value must be a stable 1–64 character identifier containing only letters, digits, .,
_, or -. Never reuse one scope across development and production.
Network trust boundary
The SDK uses the platform fetch and WebSocket implementations without attaching a static
client-identifying header or exposing a host option to do so. Browser and native clients reach the
same narrowly scoped signed/leased routes under the backend's normal WAF, rate, and body-size
controls. If a native production build cannot reach those routes, correct the edge policy — do not
work around it by adding an SDK header, environment tag, or custom socket constructor.
Set httpRequestTimeoutMs on partner and wallet clients so an unreachable backend settles into
your normal describeSessionError connection path instead of relying on a platform-specific fetch
deadline. The timeout aborts only the local HTTP attempt; retries must still follow the operation's
documented idempotency and retryability contract.
Session eligibility
Session creation is fail-closed per exact action input. The current first-party admitted workflows
are the journaled new-key start/verify pair and transfer_accounts; the latter uses single_turn_v1,
fresh PIN authorization, claimant-encrypted delivery, and a wallet-owned transactional import receipt.
Other NEAR and third-party actions remain ineligible until their host recovery journals and
reconciliation seams are implemented and reviewed.
Consumer guides
- Partner inside a wallet — D19/MNW integration shape
- D33 action-host obligations — journal-before-hold, prepare/submit
- Error handling — full error catalogue with retryability
- Push wake — mobile wallet push integration
- Migration from pre-cut APIs — old→new name/shape table
License
MIT
Wallet-owned first-link consent (protocol 3)
Use createWalletSessionState(client) to render requiresPartnerConfirmation, linkConfirmation,
confirmingPartner and confirmationError. Bind the Link button to state.confirmPartnerLink() and
Cancel to state.rejectPartnerLink(). The helper owns error/busy state; claiming never approves.
Show the app name/address as self-asserted, warn users to cancel unexpected connections, explain
that the link is remembered and expose authoritative connected-app revocation. Action consent
remains separate. partnerConfirmationRequired and pinRequired are distinct events.
Ordinary/new-key requests default to partner_link; first consent creates trust and subsequent
requests from the same signing identity skip linking prompts. Existing-secret transfer_accounts
defaults to mandatory fresh_pin on every new session, including remembered partners. Explicit
fresh_pin for ordinary requests remains supported. Rejecting the action after pairing does not
revoke the relationship. All sessions now bind a revocable link incarnation.
This is a pre-production protocol cut: upgrade backend, SDK and wallet hosts together. Protocol-2
sessions and trusted_allowed are unsupported; there is no fallback or storage migration for them.
Do not advertise wallet protocol 3 before implementing both consent and required-PIN screens.
