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
126 changes: 100 additions & 26 deletions src/browser/create-remote-wallet-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { base64ToBytes, bytesToBase58, bytesToBase64 } from '../protocol/encodin
import {
createNostrAssociationUrl,
createNostrEvent,
deriveNostrSessionIdentifier,
generateNostrKeypair,
getNostrEventTags,
isNostrEvent,
Expand All @@ -24,6 +25,9 @@ import {

export const REMOTE_WALLET_PAIRING_TTL_MS = 60_000

const MIN_DAPP_CONNECT_TIMEOUT_MS = 30_000
const remoteWalletSessionRuntimeOptions = new WeakMap<RemoteWalletPairingSession, RemoteWalletSessionRuntimeOptions>()

type ProtocolVersion = 'legacy' | 'v1'

interface RemoteWalletAuthorizationResult {
Expand Down Expand Up @@ -135,6 +139,11 @@ export interface RemoteWalletPairingSession {
status: RemoteWalletPairingStatus
}

interface RemoteWalletSessionRuntimeOptions {
identity?: RemoteWalletIdentity
onStatusChange?: (status: RemoteWalletPairingStatus) => void
}

export type RemoteWalletPairingStatus =
| 'authorizing'
| 'cancelled'
Expand Down Expand Up @@ -200,6 +209,7 @@ export interface RemoteWalletSignTransactionOutput {
}

class RemoteWalletRpcClient {
readonly #getIdentity: () => RemoteWalletIdentity
readonly #getNextJsonRpcMessageId: () => number
readonly #pendingRequests = new Map<
number,
Expand All @@ -215,18 +225,21 @@ class RemoteWalletRpcClient {
readonly #walletNostrPubkey: string

constructor({
getIdentity,
getNextJsonRpcMessageId,
sendNostrEvent,
sharedSecret,
timeoutMs,
walletNostrPubkey,
}: {
getIdentity: () => RemoteWalletIdentity
getNextJsonRpcMessageId: () => number
sendNostrEvent: (content: string, recipientPubkey: string) => void
sharedSecret: CryptoKey
timeoutMs: number
walletNostrPubkey: string
}) {
this.#getIdentity = getIdentity
this.#getNextJsonRpcMessageId = getNextJsonRpcMessageId
this.#sendNostrEvent = sendNostrEvent
this.#sharedSecret = sharedSecret
Expand Down Expand Up @@ -299,6 +312,7 @@ class RemoteWalletRpcClient {
async signIn(chain: string, protocolVersion: ProtocolVersion, input?: RemoteWalletSignInInput) {
return authorizeRemoteWallet({
chain,
getIdentity: this.#getIdentity,
protocolVersion,
rpcClient: this,
signInPayload: normalizeRemoteWalletSignInPayload(input),
Expand Down Expand Up @@ -342,22 +356,19 @@ class RemoteWalletRpcClient {
}

export async function connectRemoteWalletSession({
identity = getDefaultIdentity(),
identity,
onStatusChange,
session,
timeoutMs = REMOTE_WALLET_PAIRING_TTL_MS,
}: ConnectRemoteWalletSessionOptions) {
if (session.authorizedSession) {
return session.authorizedSession
}

const relay = await connectToNostrRelay({ identity, onStatusChange, session, timeoutMs })
}: ConnectRemoteWalletSessionOptions): Promise<RemoteWalletAuthorizedSession> {
setRemoteWalletSessionRuntimeOptions(session, { identity, onStatusChange })
await prepareRemoteWalletSession({ session, timeoutMs: getDappConnectTimeoutMs(timeoutMs) })

session.authorizedSession = relay.authorizedSession
session.cancel = relay.close
session.close = relay.close
if (!session.authorizedSession) {
throw new Error('Remote Wallet session was not prepared')
}

return relay.authorizedSession
return session.authorizedSession
}

export async function createRemoteWalletSession({
Expand All @@ -372,7 +383,8 @@ export async function createRemoteWalletSession({
const chain = chains[0] ?? 'solana:devnet'
const { privateKey: dappNostrPrivateKey, publicKey: dappNostrPubkey } = generateNostrKeypair()
const relay = normalizeRelayUrl(relayUrl)
const sessionIdentifier = crypto.randomUUID()
const sessionIdentifier = deriveNostrSessionIdentifier(associationPublicKey)
const effectiveTimeoutMs = getDappConnectTimeoutMs(timeoutMs)
const session: RemoteWalletPairingSession = {
associationKeyPair,
cancel() {
Expand All @@ -385,36 +397,37 @@ export async function createRemoteWalletSession({
connect: (options = {}) => connectRemoteWalletSession({ ...options, session }),
dappNostrPrivateKey,
dappNostrPubkey,
expiresAt: new Date(Date.now() + timeoutMs).toISOString(),
expiresAt: new Date(Date.now() + effectiveTimeoutMs).toISOString(),
pairingUrl: createNostrAssociationUrl({
associationPublicKey,
dappNostrPubkey,
relayUrl: relay,
sessionIdentifier,
}),
relayDomain: new URL(relay).host,
relayUrl: relay,
sessionIdentifier,
status: 'waiting-for-wallet',
}

if (connect) {
const authorizedSession = connectRemoteWalletSession({ identity, session, timeoutMs })
setRemoteWalletSessionRuntimeOptions(session, { identity })
await prepareRemoteWalletSession({ session, timeoutMs: effectiveTimeoutMs })

void authorizedSession.catch(() => undefined)
session.authorizedSession = authorizedSession
if (connect) {
void session.authorizedSession?.catch(() => undefined)
}

return session
}

async function authorizeRemoteWallet({
chain,
getIdentity,
protocolVersion,
rpcClient,
signInPayload,
}: {
chain: string
getIdentity: () => RemoteWalletIdentity
protocolVersion: ProtocolVersion
rpcClient: RemoteWalletRpcClient
signInPayload?: RemoteWalletSignInInput
Expand All @@ -424,7 +437,7 @@ async function authorizeRemoteWallet({
return rpcClient.request<RemoteWalletAuthorizationResult>('authorize', {
...(protocolVersion === 'legacy' && legacyCluster ? { cluster: legacyCluster } : null),
chain,
identity: getDefaultIdentity(),
identity: getIdentity(),
...(signInPayload ? { sign_in_payload: signInPayload } : null),
})
}
Expand All @@ -442,14 +455,37 @@ function chainToLegacyCluster(chain: string) {
}
}

async function prepareRemoteWalletSession({
session,
timeoutMs,
}: {
session: RemoteWalletPairingSession
timeoutMs: number
}) {
if (session.authorizedSession) {
return
}

const relay = await connectToNostrRelay({
getIdentity: () => remoteWalletSessionRuntimeOptions.get(session)?.identity ?? getDefaultIdentity(),
getOnStatusChange: () => remoteWalletSessionRuntimeOptions.get(session)?.onStatusChange,
session,
timeoutMs,
})
Comment thread
beeman marked this conversation as resolved.

session.authorizedSession = relay.authorizedSession
session.cancel = relay.close
session.close = relay.close
}

function connectToNostrRelay({
identity,
onStatusChange,
getIdentity,
getOnStatusChange,
session,
timeoutMs,
}: {
identity: RemoteWalletIdentity
onStatusChange?: (status: RemoteWalletPairingStatus) => void
getIdentity: () => RemoteWalletIdentity
getOnStatusChange: () => ((status: RemoteWalletPairingStatus) => void) | undefined
session: RemoteWalletPairingSession
timeoutMs: number
}) {
Expand Down Expand Up @@ -477,7 +513,7 @@ function connectToNostrRelay({
}, timeoutMs)
const setStatus = (status: RemoteWalletPairingStatus) => {
setSessionStatus(session, status)
onStatusChange?.(status)
getOnStatusChange()?.(status)
}
const rejectAuthorization = (error: Error) => {
if (state.type === 'connected') {
Expand Down Expand Up @@ -563,10 +599,18 @@ function connectToNostrRelay({
const handleMessage = async (event: MessageEvent<string>) => {
const message = parseNostrRelayMessage(event.data)

if (message?.[0] === 'CLOSED' && message[1] === subscriptionId) {
failSession(new Error(`Nostr relay ${session.relayDomain} closed subscription: ${String(message[2] ?? '')}`))
return
}
if (message?.[0] === 'EOSE' && message[1] === subscriptionId) {
resolveSubscription()
return
}
if (message?.[0] === 'OK' && message[2] === false) {
failSession(new Error(`Nostr relay ${session.relayDomain} rejected event: ${String(message[3] ?? '')}`))
return
}
if (message?.[0] !== 'EVENT') {
return
}
Expand Down Expand Up @@ -634,6 +678,7 @@ function connectToNostrRelay({

setStatus('authorizing')
const rpcClient = new RemoteWalletRpcClient({
getIdentity,
getNextJsonRpcMessageId: () => nextJsonRpcMessageId++,
sendNostrEvent,
sharedSecret,
Expand All @@ -648,7 +693,7 @@ function connectToNostrRelay({
? { cluster: chainToLegacyCluster(session.chain) }
: null),
chain: session.chain,
identity,
identity: getIdentity(),
})
let accounts = authorizationResult.accounts.map((account) => mapRemoteWalletAccount(account, session.chain))
let authToken = authorizationResult.auth_token
Expand Down Expand Up @@ -700,7 +745,7 @@ function connectToNostrRelay({
break
}
case 'subscribed': {
if (nostrEvent.content.length !== 0) {
if (nostrEvent.content.length !== 0 || !eventTags.msg?.includes('CONNECT')) {
return
}

Expand All @@ -724,6 +769,19 @@ function connectToNostrRelay({
close()
}
}
const failSession = (error: Error) => {
cleanup()

if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
socket.close()
}
if (!settled) {
reject(error)
return
}

rejectAuthorization(error)
}

socket.addEventListener('close', handleClose)
socket.addEventListener('error', handleError)
Expand Down Expand Up @@ -759,6 +817,10 @@ function getDefaultIdentity(): RemoteWalletIdentity {
}
}

function getDappConnectTimeoutMs(timeoutMs: number) {
return Math.max(timeoutMs, MIN_DAPP_CONNECT_TIMEOUT_MS)
}

function getRemoteWalletAuthorizedAccount(
accounts: readonly RemoteWalletAuthorizedAccount[],
encodedAddress: string,
Expand Down Expand Up @@ -848,6 +910,18 @@ async function parseSessionProtocolVersion(message: Uint8Array, sharedSecret: Cr
}
}

function setRemoteWalletSessionRuntimeOptions(
session: RemoteWalletPairingSession,
options: RemoteWalletSessionRuntimeOptions,
) {
const currentOptions = remoteWalletSessionRuntimeOptions.get(session) ?? {}

remoteWalletSessionRuntimeOptions.set(session, {
identity: options.identity ?? currentOptions.identity,
onStatusChange: options.onStatusChange ?? currentOptions.onStatusChange,
})
}

function setSessionStatus(session: RemoteWalletPairingSession, status: RemoteWalletPairingStatus) {
session.status = status
}
Expand Down
20 changes: 18 additions & 2 deletions src/node/connect-remote-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
import { sendSignedTransaction } from './send-signed-transaction.ts'
import { createRemoteWalletAuthorizationAccount, getSolanaRpcUrl, type RemoteWalletSigner } from './signer.ts'

const MIN_WALLET_HELLO_REQ_TIMEOUT_MS = 10_000

type SessionState =
| {
lastInboundSequenceNumber: number
Expand Down Expand Up @@ -60,10 +62,11 @@ export async function connectRemoteWallet({ pairing, signer, timeoutMs, writeEve
closed = true
socket.close()
}
const helloRequestTimeoutMs = Math.max(timeoutMs, MIN_WALLET_HELLO_REQ_TIMEOUT_MS)
const timeoutId = setTimeout(() => {
closeSocket()
reject(new Error(`Timed out waiting for dapp handshake after ${timeoutMs}ms`))
}, timeoutMs)
reject(new Error(`Timed out waiting for dapp handshake after ${helloRequestTimeoutMs}ms`))
}, helloRequestTimeoutMs)
const cleanup = () => {
clearTimeout(timeoutId)
socket.removeEventListener('close', handleClose)
Expand Down Expand Up @@ -120,11 +123,19 @@ export async function connectRemoteWallet({ pairing, signer, timeoutMs, writeEve
try {
const message = parseNostrRelayMessage(event.data)

if (message?.[0] === 'CLOSED' && message[1] === subscriptionId) {
failSession(new Error(`Nostr relay ${pairing.relayDomain} closed subscription: ${String(message[2] ?? '')}`))
return
}
if (message?.[0] === 'EOSE' && message[1] === subscriptionId) {
sendNostrEvent('', pairing.dappNostrPubkey, [['msg', 'CONNECT']])
writeEvent?.('wallet-joined', {})
return
}
if (message?.[0] === 'OK' && message[2] === false) {
failSession(new Error(`Nostr relay ${pairing.relayDomain} rejected event: ${String(message[3] ?? '')}`))
return
}
if (message?.[0] === 'NOTICE') {
writeEvent?.('relay-notice', { message: String(message[1] ?? '') })
return
Expand Down Expand Up @@ -214,6 +225,11 @@ export async function connectRemoteWallet({ pairing, signer, timeoutMs, writeEve
reject(error)
}
}
const failSession = (error: Error) => {
cleanup()
closeSocket()
reject(error)
}
const handleOpen = () => {
socket.send(
JSON.stringify(['REQ', subscriptionId, { '#d': [pairing.sessionIdentifier], kinds: [NOSTR_EVENT_KIND_MWA] }]),
Expand Down
Loading
Loading