diff --git a/examples/simple-dapp/public/.well-known/radix.json b/examples/simple-dapp/public/.well-known/radix.json index f26d830e..587a44c8 100644 --- a/examples/simple-dapp/public/.well-known/radix.json +++ b/examples/simple-dapp/public/.well-known/radix.json @@ -1,5 +1,8 @@ { + "callbackPath": "#connect", "dApps": [ - {} + { + "dAppDefinitionAddress": "account_tdx_2_12yf9gd53yfep7a669fv2t3wm7nz9zeezwd04n02a433ker8vza6rhe" + } ] } \ No newline at end of file diff --git a/examples/simple-dapp/src/main.ts b/examples/simple-dapp/src/main.ts index 7d533821..dcb21b8a 100644 --- a/examples/simple-dapp/src/main.ts +++ b/examples/simple-dapp/src/main.ts @@ -7,6 +7,8 @@ import { RequestItemClient, ConnectorExtensionClient, DataRequestBuilder, + RadixConnectRelayClient, + OneTimeDataRequestBuilder, } from '@radixdlt/radix-dapp-toolkit' const dAppDefinitionAddress = import.meta.env.VITE_DAPP_DEFINITION_ADDRESS @@ -22,7 +24,12 @@ const stateStore = storageClient.getPartition('state') const content = document.getElementById('app')! content.innerHTML = ` + + + +
+

   

   

@@ -39,6 +46,8 @@ const walletResponse = document.getElementById('walletResponse')!
 const device = document.getElementById('device')!
 const logs = document.getElementById('logs')!
 const state = document.getElementById('state')!
+const continueButton = document.getElementById('continue')!
+const oneTimeRequest = document.getElementById('one-time-request')!
 
 const logger = Logger()
 
@@ -55,6 +64,16 @@ const requestItemClient = RequestItemClient({
   providers: { storageClient: storageClient.getPartition('requests') },
 })
 
+const rcr = RadixConnectRelayClient({
+  logger,
+  walletUrl: 'https://d1rxdfxrfmemlj.cloudfront.net',
+  baseUrl: 'https://radix-connect-relay-dev.rdx-works-main.extratools.works',
+  providers: {
+    requestItemClient,
+    storageClient,
+  },
+})
+
 const dAppToolkit = RadixDappToolkit({
   dAppDefinitionAddress,
   networkId,
@@ -64,6 +83,16 @@ const dAppToolkit = RadixDappToolkit({
     requestItemClient,
     transports: [
       ConnectorExtensionClient({ logger, providers: { requestItemClient } }),
+      RadixConnectRelayClient({
+        logger,
+        walletUrl: 'https://d1rxdfxrfmemlj.cloudfront.net',
+        baseUrl:
+          'https://radix-connect-relay-dev.rdx-works-main.extratools.works',
+        providers: {
+          requestItemClient,
+          storageClient,
+        },
+      }),
     ],
   },
   logger,
@@ -86,6 +115,18 @@ resetButton.onclick = () => {
   window.location.replace(window.location.origin)
 }
 
+continueButton.onclick = () => {
+  requestItemClient.getPendingItems().map((items) => {
+    if (items[0]) rcr.resume(items[0].interactionId)
+  })
+}
+
+oneTimeRequest.onclick = () => {
+  dAppToolkit.walletApi.sendOneTimeRequest(
+    OneTimeDataRequestBuilder.accounts().exactly(1),
+  )
+}
+
 setInterval(() => {
   requestsStore.getState().map((value: any) => {
     requests.innerHTML = JSON.stringify({ requests: value ?? {} }, null, 2)
diff --git a/examples/simple-dapp/src/style.css b/examples/simple-dapp/src/style.css
index 3040e7bb..fd067ca3 100644
--- a/examples/simple-dapp/src/style.css
+++ b/examples/simple-dapp/src/style.css
@@ -102,3 +102,7 @@ pre {
   text-align: left;
   overflow: auto;
 }
+
+.mt-25 {
+  margin-top: 10px;
+}
diff --git a/packages/dapp-toolkit/src/wallet-request/transport/index.ts b/packages/dapp-toolkit/src/wallet-request/transport/index.ts
index 409ff2c3..1fff32e2 100644
--- a/packages/dapp-toolkit/src/wallet-request/transport/index.ts
+++ b/packages/dapp-toolkit/src/wallet-request/transport/index.ts
@@ -1 +1,2 @@
 export * from './connector-extension'
+export * from './radix-connect-relay'
diff --git a/packages/dapp-toolkit/src/wallet-request/transport/radix-connect-relay/deep-link.ts b/packages/dapp-toolkit/src/wallet-request/transport/radix-connect-relay/deep-link.ts
new file mode 100644
index 00000000..ec021631
--- /dev/null
+++ b/packages/dapp-toolkit/src/wallet-request/transport/radix-connect-relay/deep-link.ts
@@ -0,0 +1,89 @@
+import type { Result, ResultAsync } from 'neverthrow'
+import { errAsync, ok, okAsync } from 'neverthrow'
+import { Logger } from '../../../helpers'
+import { BehaviorSubject } from 'rxjs'
+
+export type DeepLinkClient = ReturnType
+export const DeepLinkClient = (input: {
+  logger?: Logger
+  callBackPath: string
+  walletUrl: string
+  origin: string
+  userAgent: Bowser.Parser.ParsedResult
+}) => {
+  const { callBackPath, walletUrl, origin, userAgent } = input
+  const { platform, os, browser } = userAgent
+  const logger = input?.logger?.getSubLogger({ name: 'DeepLinkClient' })
+
+  const walletResponseSubject = new BehaviorSubject>({})
+
+  const isCallbackUrl = () => window.location.hash.includes(callBackPath)
+
+  const shouldHandleWalletCallback = () =>
+    platform.type === 'mobile' && isCallbackUrl()
+
+  const deepLinkToWallet = (
+    values: Record,
+    childWindow?: Window,
+  ): ResultAsync => {
+    const outboundUrl = new URL(walletUrl)
+    const childWindowUrl = new URL(origin)
+    const currentUrl = new URL(window.origin)
+    currentUrl.hash = callBackPath
+
+    if (childWindow) childWindowUrl.hash = callBackPath
+
+    Object.entries(values).forEach(([key, value]) => {
+      outboundUrl.searchParams.append(key, value)
+      if (childWindow) childWindowUrl.searchParams.append(key, value)
+    })
+
+    logger?.debug({
+      method: 'deepLinkToWallet',
+      childWindowUrl: childWindowUrl.toString(),
+      outboundUrl: outboundUrl.toString(),
+    })
+
+    if (childWindow && os.name === 'iOS' && browser.name === 'Safari') {
+      childWindow.location.href = outboundUrl.toString()
+      return okAsync(undefined)
+    } else if (os.name === 'iOS' && browser.name === 'Safari') {
+      window.location.href = outboundUrl.toString()
+      return okAsync(undefined)
+    }
+
+    return okAsync(undefined)
+  }
+
+  const getWalletResponseFromUrl = (): Result<
+    Record,
+    { reason: string }
+  > => {
+    const url = new URL(window.location.href)
+    const values = Object.fromEntries([...url.searchParams.entries()])
+    return ok(values)
+  }
+
+  const handleWalletCallback = () => {
+    if (shouldHandleWalletCallback())
+      return getWalletResponseFromUrl()
+        .map((values) => {
+          walletResponseSubject.next(values)
+
+          return errAsync({ reason: 'InvalidCallbackValues' })
+        })
+        .mapErr((error) => {
+          logger?.debug({
+            method: 'handleWalletCallback.error',
+            reason: error.reason,
+          })
+          return error
+        })
+  }
+
+  return {
+    deepLinkToWallet,
+    handleWalletCallback,
+    walletResponse$: walletResponseSubject.asObservable(),
+  }
+}
diff --git a/packages/dapp-toolkit/src/wallet-request/transport/radix-connect-relay/index.ts b/packages/dapp-toolkit/src/wallet-request/transport/radix-connect-relay/index.ts
new file mode 100644
index 00000000..e7932650
--- /dev/null
+++ b/packages/dapp-toolkit/src/wallet-request/transport/radix-connect-relay/index.ts
@@ -0,0 +1,2 @@
+export * from './deep-link'
+export * from './radix-connect-relay-client'
diff --git a/packages/dapp-toolkit/src/wallet-request/transport/radix-connect-relay/radix-connect-relay-client.ts b/packages/dapp-toolkit/src/wallet-request/transport/radix-connect-relay/radix-connect-relay-client.ts
new file mode 100644
index 00000000..f963e9d6
--- /dev/null
+++ b/packages/dapp-toolkit/src/wallet-request/transport/radix-connect-relay/radix-connect-relay-client.ts
@@ -0,0 +1,435 @@
+import { Result, ResultAsync, errAsync, okAsync } from 'neverthrow'
+import { fetchWrapper } from '../../../helpers/fetch-wrapper'
+import {
+  BehaviorSubject,
+  Subscription,
+  filter,
+  firstValueFrom,
+  merge,
+  mergeMap,
+  switchMap,
+  tap,
+  delay,
+  of,
+  Subject,
+} from 'rxjs'
+import { EncryptionClient, transformBufferToSealbox } from '../../encryption'
+import {
+  ActiveSession,
+  PendingSession,
+  Session,
+  SessionClient,
+} from '../../session/session'
+import { Buffer } from 'buffer'
+import type {
+  CallbackFns,
+  WalletInteraction,
+  WalletInteractionResponse,
+} from '../../../schemas'
+import { Logger, isMobile, parseJSON } from '../../../helpers'
+import { SdkError } from '../../../error'
+import { DeepLinkClient } from './deep-link'
+import { IdentityClient } from '../../identity/identity'
+import { RequestItemClient } from '../../request-items/request-item-client'
+import Bowser from 'bowser'
+import { StorageProvider } from '../../../storage'
+import { Curve25519 } from '../../crypto'
+import { RequestItem } from 'radix-connect-common'
+
+export type RadixConnectRelayClient = ReturnType
+export const RadixConnectRelayClient = (input: {
+  baseUrl: string
+  logger?: Logger
+  walletUrl: string
+  providers: {
+    requestItemClient: RequestItemClient
+    storageClient: StorageProvider
+    encryptionClient?: EncryptionClient
+    identityClient?: IdentityClient
+    sessionClient?: SessionClient
+    deepLinkClient?: DeepLinkClient
+  }
+}) => {
+  const logger = input.logger?.getSubLogger({ name: 'RadixConnectRelayClient' })
+  const { baseUrl, providers, walletUrl } = input
+  const { requestItemClient, storageClient } = providers
+
+  const userAgent = Bowser.parse(window.navigator.userAgent)
+
+  const encryptionClient = providers?.encryptionClient ?? EncryptionClient()
+
+  const deepLinkClient =
+    providers?.deepLinkClient ??
+    DeepLinkClient({
+      logger,
+      origin,
+      walletUrl,
+      callBackPath: '#connect',
+      userAgent,
+    })
+
+  const identityClient =
+    providers?.identityClient ??
+    IdentityClient({
+      providers: {
+        storageClient: storageClient.getPartition('identities'),
+        KeyPairClient: Curve25519,
+      },
+    })
+
+  const sessionClient =
+    providers?.sessionClient ??
+    SessionClient({
+      providers: {
+        storageClient: storageClient.getPartition('sessions'),
+        identityClient,
+      },
+    })
+
+  const apiV1Url = `${baseUrl}/api/v1`
+
+  const sendHandshakeRequestToRadixConnectRelay = (
+    sessionId: string,
+    publicKeyHex: string,
+  ): ResultAsync =>
+    fetchWrapper(
+      fetch(apiV1Url, {
+        method: 'POST',
+        body: JSON.stringify({
+          method: 'sendHandshakeRequest',
+          sessionId,
+          data: publicKeyHex,
+        }),
+      }),
+    )
+      .map(() => {
+        logger?.debug({
+          method: 'sendHandshakeRequestToRadixConnectRelay.success',
+        })
+      })
+      .mapErr(() => {
+        logger?.debug({
+          method: 'sendHandshakeRequestToRadixConnectRelay.error',
+        })
+        return SdkError('FailedToSendHandshakeRequestToRadixConnectRelay', '')
+      })
+
+  const getHandshakeResponseFromRadixConnectRelay = (sessionId: string) =>
+    fetchWrapper(
+      fetch(apiV1Url, {
+        method: 'POST',
+        body: JSON.stringify({
+          method: 'getHandshakeResponse',
+          sessionId,
+        }),
+      }),
+    )
+      .map(({ data }) => {
+        return parseJSON(
+          // @ts-ignore
+          Buffer.from(data.publicKey, 'hex').toString('utf-8'),
+        )._unsafeUnwrap().publicKey
+      })
+      .mapErr(() =>
+        SdkError('FailedToGetHandshakeResponseToRadixConnectRelay', ''),
+      )
+
+  const sendRequestToRadixConnectRelay = (
+    sessionId: string,
+    data: any,
+  ): ResultAsync =>
+    fetchWrapper(
+      fetch(apiV1Url, {
+        method: 'POST',
+        body: JSON.stringify({
+          method: 'sendRequest',
+          sessionId,
+          data,
+        }),
+      }),
+    )
+      .map(() => undefined)
+      .mapErr(() => SdkError('FailedToSendRequestToRadixConnectRelay', ''))
+
+  const getResponsesFromRadixConnectRelay = (
+    sessionId: string,
+  ): ResultAsync<
+    {
+      status: number
+      data: unknown[]
+    },
+    SdkError
+  > =>
+    fetchWrapper(
+      fetch(apiV1Url, {
+        method: 'POST',
+        body: JSON.stringify({
+          method: 'getResponses',
+          sessionId: sessionId,
+        }),
+      }),
+    ).mapErr(() => SdkError('FailedToGetRequestsFromRadixConnectRelay', ''))
+
+  const decryptResponseFactory =
+    (secret: Buffer) =>
+    (
+      value: string,
+    ): ResultAsync<
+      WalletInteractionResponse,
+      { reason: string; shouldRetry: boolean }
+    > =>
+      transformBufferToSealbox(Buffer.from(value, 'hex'))
+        .asyncAndThen(({ ciphertextAndAuthTag, iv }) =>
+          encryptionClient.decrypt(ciphertextAndAuthTag, secret, iv),
+        )
+        .andThen((decrypted) =>
+          parseJSON(decrypted.toString('utf-8')),
+        )
+        .mapErr(() => ({ reason: 'FailedToDecrypt', shouldRetry: true }))
+
+  const subscriptions = new Subscription()
+
+  const waitForWalletResponse = (
+    interactionId: string,
+  ): ResultAsync =>
+    ResultAsync.fromPromise(
+      firstValueFrom(
+        merge(requestItemClient.store.storage$, of(null)).pipe(
+          mergeMap(() =>
+            requestItemClient.store
+              .getItemById(interactionId)
+              .mapErr(() => SdkError('FailedToGetRequestItem', interactionId)),
+          ),
+          filter((result): result is Result => {
+            if (result.isErr()) return false
+            return (
+              result.value?.interactionId === interactionId &&
+              ['success', 'fail'].includes(result.value.status)
+            )
+          }),
+        ),
+      ),
+      () => SdkError('FailedToListenForWalletResponse', interactionId),
+    ).andThen((result) => result)
+
+  const encryptWalletInteraction = (
+    walletInteraction: WalletInteraction,
+    sharedSecret: Buffer,
+  ): ResultAsync =>
+    encryptionClient
+      .encrypt(
+        Buffer.from(JSON.stringify(walletInteraction), 'utf-8'),
+        sharedSecret,
+      )
+      .mapErr(() =>
+        SdkError(
+          'FailEncryptWalletInteraction',
+          walletInteraction.interactionId,
+        ),
+      )
+      .map((sealedBoxProps) => sealedBoxProps.combined.toString('hex'))
+
+  const handleLinkingRequest = (
+    session: PendingSession,
+    walletInteraction: WalletInteraction,
+  ) => {
+    const { sessionId } = session
+    const url = new URL(origin)
+    url.hash = 'connect'
+    url.searchParams.set('sessionId', sessionId)
+    const childWindow = window.open(url.toString())!
+
+    return identityClient
+      .get('dApp')
+      .mapErr(() =>
+        SdkError('FailedToGetDappIdentity', walletInteraction.interactionId),
+      )
+      .andThen((dAppIdentity) =>
+        sendHandshakeRequestToRadixConnectRelay(
+          sessionId,
+          dAppIdentity.getPublicKey(),
+        ),
+      )
+      .andThen(() =>
+        sessionClient
+          .patchSession(sessionId, { sentToWallet: true })
+          .andThen(() =>
+            deepLinkClient.deepLinkToWallet(
+              {
+                sessionId,
+                origin,
+              },
+              childWindow,
+            ),
+          )
+          .mapErr(() =>
+            SdkError('FailedToUpdateSession', walletInteraction.interactionId),
+          ),
+      )
+      .andThen(() =>
+        waitForWalletResponse(walletInteraction.interactionId).map(
+          (item) => item.walletResponse!,
+        ),
+      )
+  }
+
+  const sendEncryptedRequest = (
+    activeSession: ActiveSession,
+    walletInteraction: WalletInteraction,
+  ) =>
+    encryptWalletInteraction(
+      walletInteraction,
+      Buffer.from(activeSession.sharedSecret, 'hex'),
+    ).andThen((encryptedWalletInteraction) =>
+      sendRequestToRadixConnectRelay(
+        activeSession.sessionId,
+        encryptedWalletInteraction,
+      ),
+    )
+
+  const resumeRequest = (
+    session: ActiveSession,
+    walletInteraction: WalletInteraction,
+  ) => {
+    const { sessionId } = session
+    const { interactionId } = walletInteraction
+
+    return requestItemClient
+      .patch(interactionId, { sentToWallet: true })
+      .mapErr(() => SdkError('FailedToUpdateRequestItem', interactionId))
+      .andThen(() => sendEncryptedRequest(session, walletInteraction))
+      .andThen(() =>
+        deepLinkClient.deepLinkToWallet({
+          sessionId,
+          interactionId,
+        }),
+      )
+      .andThen(() =>
+        waitForWalletResponse(walletInteraction.interactionId).map(
+          (item) => item.walletResponse!,
+        ),
+      )
+  }
+
+  const send = (
+    walletInteraction: WalletInteraction,
+    callbackFns: Partial,
+  ): ResultAsync =>
+    sessionClient
+      .getCurrentSession()
+      .mapErr(() =>
+        SdkError('FailedToGetCurrentSession', walletInteraction.interactionId),
+      )
+      .andThen((session) => {
+        return session.status === 'Pending'
+          ? handleLinkingRequest(session, walletInteraction)
+          : resumeRequest(session, walletInteraction)
+      })
+
+  const handleWalletCallback = (values: Record) => {
+    const { sessionId, interactionId } = values
+    if (sessionId) {
+      return sessionClient.getSessionById(sessionId).andThen((session) => {
+        if (session?.status === 'Pending' && session.sentToWallet) {
+          return getHandshakeResponseFromRadixConnectRelay(session.sessionId)
+            .andThen((walletPublicKey) =>
+              walletPublicKey
+                ? sessionClient.convertToActiveSession(
+                    sessionId,
+                    walletPublicKey,
+                  )
+                : errAsync(SdkError('WalletPublicKeyUnavailable', '')),
+            )
+            .map(() => {
+              window.close()
+            })
+        } else if (session?.status === 'Active' && interactionId) {
+          return requestItemClient.getPendingItems().map((pendingItems) => {
+            const sendToWallet = pendingItems.filter(
+              (item) => item.sentToWallet,
+            )
+            if (sendToWallet.length) {
+              const decryptWalletInteraction = decryptResponseFactory(
+                Buffer.from(session.sharedSecret, 'hex'),
+              )
+              getResponsesFromRadixConnectRelay(session.sessionId).map(
+                (value) => {
+                  for (const encryptedWalletInteraction of value.data) {
+                    decryptWalletInteraction(
+                      encryptedWalletInteraction as string,
+                    ).map((decrypted) => {
+                      requestItemClient
+                        .patch(decrypted.interactionId, {
+                          walletResponse: decrypted,
+                        })
+                        .map(() => {
+                          window.close()
+                        })
+                    })
+                  }
+                },
+              )
+            }
+          })
+        }
+
+        return errAsync(SdkError('SessionNotFound', sessionId))
+      })
+    }
+  }
+
+  subscriptions.add(
+    deepLinkClient.walletResponse$
+      .pipe(
+        filter((values) => Object.values(values).length > 0),
+        tap((item) => handleWalletCallback(item)),
+      )
+      .subscribe(),
+  )
+
+  deepLinkClient.handleWalletCallback()
+
+  return {
+    isSupported: () => isMobile(),
+    send,
+    resume: (interactionId: string) => {
+      sessionClient.findActiveSession().andThen((session) => {
+        if (session) {
+          const url = new URL(origin)
+          url.hash = 'connect'
+          url.searchParams.set('sessionId', session.sessionId)
+          url.searchParams.set('interactionId', interactionId)
+          const childWindow = window.open(url.toString())!
+
+          return requestItemClient.getPendingItems().andThen((pendingItems) => {
+            const pendingItem = pendingItems.find(
+              (item) => item.interactionId === interactionId,
+            )
+            if (pendingItem) {
+              return requestItemClient
+                .patch(interactionId, { sentToWallet: true })
+                .andThen(() =>
+                  sendEncryptedRequest(session, pendingItem.walletInteraction),
+                )
+                .andThen(() =>
+                  deepLinkClient.deepLinkToWallet(
+                    {
+                      sessionId: session.sessionId,
+                      interactionId: pendingItem.interactionId,
+                    },
+                    childWindow,
+                  ),
+                )
+            }
+            return errAsync(SdkError('PendingItemNotFound', ''))
+          })
+        }
+        return okAsync(undefined)
+      })
+    },
+    disconnect: () => {},
+    destroy: () => {
+      subscriptions.unsubscribe()
+    },
+  }
+}