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()
+ },
+ }
+}