diff --git a/.changeset/config.json b/.changeset/config.json index d89afcc368291..8b0970666d420 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,21 +1,24 @@ { - "$schema": "https://unpkg.com/@changesets/config@2.2.0/schema.json", - "changelog": "@changesets/cli/changelog", - "commit": false, - "fixed": [], - "linked": [], - "access": "public", - "baseBranch": "main", - "updateInternalDependencies": "minor", - "privatePackages": false, - "ignore": [ - "sui-wallet", - "sui-explorer", - "@mysten/core", - "@mysten/ui", - "sponsored-transactions", - "kiosk-demo", - "kiosk-cli", - "@mysten/sdk-docs" - ] + "$schema": "https://unpkg.com/@changesets/config@2.2.0/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "minor", + "privatePackages": false, + "ignore": [ + "sui-wallet", + "sui-explorer", + "@mysten/core", + "@mysten/ui", + "sponsored-transactions", + "kiosk-demo", + "kiosk-cli", + "@mysten/sdk-docs" + ], + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + } } diff --git a/.changeset/fluffy-turtles-argue.md b/.changeset/fluffy-turtles-argue.md new file mode 100644 index 0000000000000..0f9d4e1fe8119 --- /dev/null +++ b/.changeset/fluffy-turtles-argue.md @@ -0,0 +1,5 @@ +--- +'@mysten/zksend': minor +--- + +Add SDK for creating ZKSend links diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 409945e9e224a..da229826b95a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1738,9 +1738,21 @@ importers: sdk/zksend: dependencies: + '@mysten/sui.js': + specifier: workspace:* + version: link:../typescript '@mysten/wallet-standard': specifier: workspace:* version: link:../wallet-standard + mitt: + specifier: ^3.0.1 + version: 3.0.1 + nanostores: + specifier: ^0.9.3 + version: 0.9.3 + valibot: + specifier: ^0.25.0 + version: 0.25.0 devDependencies: '@mysten/build-scripts': specifier: workspace:* @@ -24215,6 +24227,10 @@ packages: convert-source-map: 1.9.0 dev: true + /valibot@0.25.0: + resolution: {integrity: sha512-cmD0ca15oyAbT75iYLNW6uU6doAeIwYfOshpXka/E1Bx4frzbkrgb7gvkI7K0YK/DVOksei4FfxWfRoBP3NFTg==} + dev: false + /validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} dependencies: diff --git a/sdk/zksend/package.json b/sdk/zksend/package.json index f20dacd806153..90fd2e4257c22 100644 --- a/sdk/zksend/package.json +++ b/sdk/zksend/package.json @@ -46,6 +46,10 @@ "vitest": "^0.33.0" }, "dependencies": { - "@mysten/wallet-standard": "workspace:*" + "@mysten/sui.js": "workspace:*", + "@mysten/wallet-standard": "workspace:*", + "mitt": "^3.0.1", + "nanostores": "^0.9.3", + "valibot": "^0.25.0" } } diff --git a/sdk/zksend/src/channel/events.ts b/sdk/zksend/src/channel/events.ts new file mode 100644 index 0000000000000..148b08a8f533b --- /dev/null +++ b/sdk/zksend/src/channel/events.ts @@ -0,0 +1,88 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { Output } from 'valibot'; +import { literal, object, string, union, uuid } from 'valibot'; + +export type ZkSendSignPersonalMessageResponse = Output; + +export const ZkSendRejectResponse = object({ + type: literal('reject'), +}); + +export const ZdSendConnectResponse = object({ + address: string(), +}); + +export const ZkSendSignTransactionBlockResponse = object({ + signature: string(), +}); + +export const ZkSendSignPersonalMessageResponse = object({ + signature: string(), +}); + +export const ZkSendRequestType = union([ + literal('connect'), + literal('sign-transaction-block'), + literal('sign-personal-message'), +]); + +export const ZkSendConnectRequest = object({}); +export const ZkSendSignTransactionBlockRequest = object({ + bytes: string(), + address: string(), +}); +export const ZkSendSignPersonalMessageRequest = object({ + bytes: string(), + address: string(), +}); +export const ZkSendRequestData = union([ + ZkSendConnectRequest, + ZkSendSignTransactionBlockRequest, + ZkSendSignPersonalMessageRequest, +]); + +export const ZkSendRequest = object({ + id: string([uuid()]), + origin: string(), + name: string(), + type: ZkSendRequestType, + data: ZkSendRequestData, +}); +export interface ZkSendRequestTypes extends Record> { + // eslint-disable-next-line @typescript-eslint/ban-types + connect: Output; + 'sign-transaction-block': Output; + 'sign-personal-message': Output; +} + +export type ZkSendResponseTypes = { + connect: Output; + 'sign-transaction-block': Output; + 'sign-personal-message': Output; +}; + +export const ZkSendResponseData = union([ + ZdSendConnectResponse, + ZkSendSignTransactionBlockResponse, + ZkSendSignPersonalMessageResponse, +]); + +export const ZkSendResolveResponse = object({ + type: literal('resolve'), + data: ZkSendResponseData, +}); + +export type ZkSendResolveResponse = Output; + +export const ZkSendResponsePayload = union([ZkSendRejectResponse, ZkSendResolveResponse]); +export type ZkSendResponsePayload = Output; + +export const ZkSendResponse = object({ + id: string([uuid()]), + source: literal('zksend-channel'), + payload: ZkSendResponsePayload, +}); + +export type ZkSendResponse = Output; diff --git a/sdk/zksend/src/channel/index.ts b/sdk/zksend/src/channel/index.ts new file mode 100644 index 0000000000000..5308e532f7849 --- /dev/null +++ b/sdk/zksend/src/channel/index.ts @@ -0,0 +1,125 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { Output } from 'valibot'; +import { safeParse } from 'valibot'; + +import { withResolvers } from '../utils/withResolvers.js'; +import type { ZkSendRequestTypes, ZkSendResponsePayload, ZkSendResponseTypes } from './events.js'; +import { ZkSendRequest, ZkSendResponse } from './events.js'; + +const DEFAULT_ZKSEND_ORIGIN = 'https://zksend.com'; + +interface ZkSendPopupOptions { + origin?: string; + name: string; +} + +export class ZkSendPopup { + #id: string; + #origin: string; + #name: string; + + #close?: () => void; + + constructor({ origin = DEFAULT_ZKSEND_ORIGIN, name }: ZkSendPopupOptions) { + this.#id = crypto.randomUUID(); + this.#origin = origin; + this.#name = name; + } + + async createRequest( + type: T, + data: ZkSendRequestTypes[T], + ): Promise { + const { promise, resolve, reject } = withResolvers(); + + let popup: Window | null = null; + + const listener = (event: MessageEvent) => { + if (event.origin !== this.#origin) { + return; + } + const { success, output } = safeParse(ZkSendResponse, event.data); + if (!success || output.id !== this.#id) return; + + window.removeEventListener('message', listener); + + if (output.payload.type === 'reject') { + reject(new Error('TODO: Better error message')); + } else if (output.payload.type === 'resolve') { + resolve(output.payload.data as ZkSendResponseTypes[T]); + } + }; + + this.#close = () => { + popup?.close(); + window.removeEventListener('message', listener); + }; + + window.addEventListener('message', listener); + + popup = window.open( + `${this.#origin}/dapp/${type}?${new URLSearchParams({ + id: this.#id, + origin: window.origin, + name: this.#name, + })}${data ? `#${new URLSearchParams(data)}` : ''}`, + ); + + if (!popup) { + throw new Error('TODO: Better error message'); + } + + return promise; + } + + close() { + this.#close?.(); + } +} + +export class ZkSendHost { + #request: Output; + + constructor(request: Output) { + if (typeof window === 'undefined' || !window.opener) { + throw new Error('TODO: Better error message'); + } + + this.#request = request; + } + + static fromUrl(url: string = window.location.href) { + const parsed = new URL(url); + + const request = safeParse(ZkSendRequest, { + id: parsed.searchParams.get('id'), + origin: parsed.searchParams.get('origin'), + name: parsed.searchParams.get('name'), + type: parsed.pathname.split('/').pop(), + data: parsed.hash ? Object.fromEntries(new URLSearchParams(parsed.hash.slice(1))) : {}, + }); + + if (request.issues) { + throw new Error('TODO: Better error message'); + } + + return new ZkSendHost(request.output); + } + + getRequestData() { + return this.#request; + } + + sendMessage(payload: ZkSendResponsePayload) { + window.opener.postMessage( + { + id: this.#request.id, + source: 'zksend-channel', + payload, + } satisfies ZkSendResponse, + this.#request.origin, + ); + } +} diff --git a/sdk/zksend/src/index.ts b/sdk/zksend/src/index.ts index 40a8edabb3265..ca3c3febaee07 100644 --- a/sdk/zksend/src/index.ts +++ b/sdk/zksend/src/index.ts @@ -1,2 +1,6 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 + +export * from './links.js'; +export * from './wallet.js'; +export * from './channel/index.js'; diff --git a/sdk/zksend/src/links.ts b/sdk/zksend/src/links.ts new file mode 100644 index 0000000000000..8df8223668526 --- /dev/null +++ b/sdk/zksend/src/links.ts @@ -0,0 +1,339 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { URL } from 'url'; +import { Ed25519Keypair } from '@mysten/sui.js/keypairs/ed25519'; +import type { TransactionObjectInput } from '@mysten/sui.js/src/builder'; +import { TransactionBlock } from '@mysten/sui.js/src/builder'; +import type { SuiObjectChange } from '@mysten/sui.js/src/client'; +import { getFullnodeUrl, SuiClient } from '@mysten/sui.js/src/client'; +import type { Keypair } from '@mysten/sui.js/src/cryptography'; +import { + fromB64, + normalizeStructTag, + normalizeSuiAddress, + normalizeSuiObjectId, + parseStructTag, +} from '@mysten/sui.js/utils'; + +export interface ZkSendLinkBuilderOptions { + host?: string; + path?: string; + mist?: number; + keypair?: Keypair; +} + +export interface ZkSendLinkOptions { + keypair?: Keypair; + client?: SuiClient; +} + +const DEFAULT_ZK_SEND_LINK_OPTIONS = { + host: 'https://zksend.com', + path: '/claim', + client: new SuiClient({ url: getFullnodeUrl('mainnet') }), +}; + +const SUI_COIN_TYPE = normalizeStructTag('0x2::coin::Coin<0x2::sui::SUI>'); + +export class ZkSendLinkBuilder { + #host: string; + #path: string; + #keypair: Keypair; + #objects = new Set(); + #mist = 0n; + #gasFee = 0n; + + constructor({ + host = DEFAULT_ZK_SEND_LINK_OPTIONS.host, + path = DEFAULT_ZK_SEND_LINK_OPTIONS.path, + keypair = new Ed25519Keypair(), + }: ZkSendLinkBuilderOptions) { + this.#host = host; + this.#path = path; + this.#keypair = keypair; + } + + addClaimableMist(amount: bigint) { + this.#mist += amount; + } + + addClaimableObject(id: TransactionObjectInput) { + this.#objects.add(id); + } + + getLink(): string { + const link = new URL(this.#host); + link.pathname = this.#path; + link.hash = this.#keypair.export().privateKey; + + return link.toString(); + } + + async addGasForClaim( + getAmount?: (options: { + mist: bigint; + objects: TransactionObjectInput[]; + estimatedFee: bigint; + }) => Promise | bigint, + ) { + const estimatedFee = await this.#estimateClaimGasFee(); + this.#gasFee = getAmount + ? await getAmount({ + mist: this.#mist, + objects: [...this.#objects], + estimatedFee, + }) + : estimatedFee; + } + + createSendTransaction() { + const txb = new TransactionBlock(); + const address = this.#keypair.toSuiAddress(); + const objectsToTransfer = [...this.#objects].map((id) => txb.object(id)); + const totalMist = this.#mist + this.#gasFee; + + if (totalMist) { + const [coin] = txb.splitCoins(txb.gas, [totalMist]); + objectsToTransfer.push(coin); + } + + txb.transferObjects(objectsToTransfer, address); + + return txb; + } + + #estimateClaimGasFee(): Promise { + return Promise.resolve(0n); + } +} + +export interface ZkSendLinkOptions { + keypair?: Keypair; + client?: SuiClient; +} +export class ZkSendLink { + #client: SuiClient; + #keypair: Keypair; + #initiallyOwnedObjects = new Set(); + #ownedBalances = new Map(); + #ownedObjects: Array<{ + objectId: string; + version: string; + digest: string; + type: string; + }> = []; + + constructor({ + client = DEFAULT_ZK_SEND_LINK_OPTIONS.client, + keypair = new Ed25519Keypair(), + }: ZkSendLinkOptions) { + this.#client = client; + this.#keypair = keypair; + } + + static async fromUrl(url: string, options?: Omit) { + const parsed = new URL(url); + const keypair = Ed25519Keypair.fromSecretKey(fromB64(parsed.hash.slice(1))); + + const link = new ZkSendLink({ + ...options, + keypair, + }); + + await link.loadOwnedData(); + + return link; + } + + async loadOwnedData() { + await Promise.all([ + this.#loadInitialTransactionData(), + this.#loadOwnedObjects(), + this.#loadOwnedBalances(), + ]); + } + + async listClaimableAssets( + address: string, + options?: { + claimObjectsAddedAfterCreation?: boolean; + coinTypes?: string[]; + objects?: string[]; + }, + ) { + const normalizedAddress = normalizeStructTag(address); + const txb = this.createClaimTransaction(normalizedAddress, options); + + const dryRun = await this.#client.dryRunTransactionBlock({ + transactionBlock: await txb.build({ client: this.#client }), + }); + + const balances: { + coinType: string; + amount: bigint; + }[] = []; + + const nfts: { + objectId: string; + type: string; + version: string; + digest: string; + }[] = []; + + dryRun.balanceChanges.map((balanceChange) => + balances.push({ coinType: balanceChange.coinType, amount: BigInt(balanceChange.amount) }), + ); + + dryRun.objectChanges.forEach((objectChange) => { + if ('objectType' in objectChange) { + const type = parseStructTag(objectChange.objectType); + + if ( + type.address === normalizeSuiAddress('0x2') && + type.module === 'coin' && + type.name === 'Coin' + ) { + return; + } + } + + if (ownedAfterChange(objectChange, normalizedAddress)) { + nfts.push(objectChange); + } + }); + + return { + balances, + nfts, + }; + } + + createClaimTransaction( + address: string, + options?: { + claimObjectsAddedAfterCreation?: boolean; + coinTypes?: string[]; + objects?: string[]; + }, + ) { + const claimAll = !options?.coinTypes && !options?.objects; + const txb = new TransactionBlock(); + const coinTypes = new Set( + options?.coinTypes?.map((type) => normalizeStructTag(`0x2::coin::Coin<${type}>`)) ?? [], + ); + + const objectsToTransfer = this.#ownedObjects + .filter((object) => { + if (object.type === SUI_COIN_TYPE) { + return false; + } + + if (coinTypes?.has(object.type) || options?.objects?.includes(object.objectId)) { + return true; + } + + if ( + !options?.claimObjectsAddedAfterCreation && + !this.#initiallyOwnedObjects.has(object.objectId) + ) { + return false; + } + + return claimAll; + }) + .map((object) => txb.object(object.objectId)); + + if (claimAll || options?.coinTypes?.includes(SUI_COIN_TYPE)) { + objectsToTransfer.push(txb.gas); + } + + txb.transferObjects(objectsToTransfer, address); + + return txb; + } + + async #loadOwnedObjects() { + this.#ownedObjects = []; + let nextCursor: string | null | undefined; + do { + const ownedObjects = await this.#client.getOwnedObjects({ + cursor: nextCursor, + owner: this.#keypair.toSuiAddress(), + options: { + showType: true, + }, + }); + + nextCursor = ownedObjects.nextCursor; + for (const object of ownedObjects.data) { + if (object.data) { + this.#ownedObjects.push({ + objectId: normalizeSuiObjectId(object.data.objectId), + version: object.data.version, + digest: object.data.digest, + type: normalizeStructTag(object.data.type!), + }); + } + } + } while (nextCursor); + } + + async #loadOwnedBalances() { + this.#ownedBalances = new Map(); + + const balances = await this.#client.getAllBalances({ + owner: this.#keypair.toSuiAddress(), + }); + + for (const balance of balances) { + this.#ownedBalances.set(normalizeStructTag(balance.coinType), BigInt(balance.totalBalance)); + } + } + + async #loadInitialTransactionData() { + const result = await this.#client.queryTransactionBlocks({ + limit: 1, + order: 'ascending', + filter: { + ToAddress: this.#keypair.toSuiAddress(), + }, + options: { + showObjectChanges: true, + }, + }); + + const address = this.#keypair.toSuiAddress(); + + result.data[0]?.objectChanges?.forEach((objectChange) => { + if (ownedAfterChange(objectChange, address)) { + this.#initiallyOwnedObjects.add(normalizeSuiObjectId(objectChange.objectId)); + } + }); + } +} + +function ownedAfterChange( + objectChange: SuiObjectChange, + address: string, +): objectChange is Extract { + if ( + objectChange.type === 'transferred' && + typeof objectChange.recipient === 'object' && + 'AddressOwner' in objectChange.recipient && + normalizeSuiAddress(objectChange.recipient.AddressOwner) === address + ) { + return true; + } + + if ( + objectChange.type === 'created' && + typeof objectChange.owner === 'object' && + 'AddressOwner' in objectChange.owner && + normalizeSuiAddress(objectChange.owner.AddressOwner) === address + ) { + return true; + } + + return false; +} diff --git a/sdk/zksend/src/utils/withResolvers.ts b/sdk/zksend/src/utils/withResolvers.ts new file mode 100644 index 0000000000000..173b59f843f52 --- /dev/null +++ b/sdk/zksend/src/utils/withResolvers.ts @@ -0,0 +1,20 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +export interface Resolvers { + promise: Promise; + reject: (error: Error) => void; + resolve: (value: T) => void; +} + +export function withResolvers(): Resolvers { + let resolve: (value: T) => void; + let reject: (error: Error) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, reject: reject!, resolve: resolve! }; +} diff --git a/sdk/zksend/src/wallet.ts b/sdk/zksend/src/wallet.ts new file mode 100644 index 0000000000000..26423b1f72013 --- /dev/null +++ b/sdk/zksend/src/wallet.ts @@ -0,0 +1,188 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { getFullnodeUrl, SuiClient } from '@mysten/sui.js/client'; +import { bcs } from '@mysten/sui.js/src/bcs/index.js'; +import { toB64 } from '@mysten/sui.js/utils'; +import type { + StandardConnectFeature, + StandardConnectMethod, + StandardDisconnectFeature, + StandardDisconnectMethod, + StandardEventsFeature, + StandardEventsListeners, + StandardEventsOnMethod, + SuiSignPersonalMessageFeature, + SuiSignPersonalMessageMethod, + SuiSignTransactionBlockFeature, + SuiSignTransactionBlockMethod, + Wallet, +} from '@mysten/wallet-standard'; +import { getWallets, ReadonlyWalletAccount, SUI_MAINNET_CHAIN } from '@mysten/wallet-standard'; +import type { Emitter } from 'mitt'; +import mitt from 'mitt'; + +import { ZkSendPopup } from './channel/index.js'; + +type WalletEventsMap = { + [E in keyof StandardEventsListeners]: Parameters[0]; +}; + +const ZKSEND_RECENT_ADDRESS_KEY = 'zksend:recentAddress'; + +export class ZkSendWallet implements Wallet { + #events: Emitter; + #accounts: ReadonlyWalletAccount[]; + #client: SuiClient; + #name: string; + + get name() { + return 'zkSend'; + } + + get icon() { + return 'data:image/svg+xml;base64,PHN2ZyBmaWxsPSJub25lIiBoZWlnaHQ9IjMyIiB2aWV3Qm94PSIwIDAgMzIgMzIiIHdpZHRoPSIzMiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PGNsaXBQYXRoIGlkPSJhIj48cmVjdCBoZWlnaHQ9IjMyIiByeD0iMiIgd2lkdGg9IjMyIi8+PC9jbGlwUGF0aD48ZyBjbGlwLXBhdGg9InVybCgjYSkiPjxyZWN0IGZpbGw9IiNmZmYiIGhlaWdodD0iMzIiIHJ4PSIyIiB3aWR0aD0iMzIiLz48cGF0aCBkPSJtMCAwaDMydjMyaC0zMnoiIGZpbGw9IiNkNDA1NTEiLz48cGF0aCBkPSJtNS42NjgyNSAyNS4yNDkxYy0uNzgyNjMtLjc4MjctLjc4MDgxLTIuMDUyMS4wMDQwNi0yLjgzMjVsMTYuNjA1MjktMTYuNTEwNDdjLjc4MTctLjc3NzIzIDIuMDQ0OS0uNzc1NDMgMi44MjQzLjAwNDA0bC44Mzg3LjgzODYyYy43ODI1Ljc4MjUxLjc4MDggMi4wNTE3NC0uMDAzOCAyLjgzMjE4bC0xNi42MDE4OCAxNi41MTM4M2MtLjc4MTY1Ljc3NzUtMi4wNDUwOC43NzU4LTIuODI0NjYtLjAwMzd6bTUuNDQzMzUtMTUuOTExNjZjLTEuODA5NzIuMDUzNjctMi43NTM3MS0yLjEzMzA5LTEuNDczNDctMy40MTMzM2wuODM4MzctLjgzODMyYy4zNzUtLjM3NTA4Ljg4MzctLjU4NTc5IDEuNDE0Mi0uNTg1NzloMTMuNDc5N2MxLjEwNDYgMCAyIC44OTU0MyAyIDJ2MTMuNDc5N2MwIC41MzA1LS4yMTA3IDEuMDM5Mi0uNTg1OCAxLjQxNDJsLS44MjY5LjgyN2MtMS4yODE4IDEuMjgxOC0zLjQ3MDkuMzMzOS0zLjQxMzItMS40Nzc5bC4zMDY2LTkuNjI5OWMuMDM2Ny0xLjE1MjI3LS45MDU5LTIuMDk2OS0yLjA1ODMtMi4wNjI3M3oiIGZpbGw9IiNmZmYiLz48L2c+PC9zdmc+' as const; + } + + get version() { + return '1.0.0' as const; + } + + get chains() { + return [SUI_MAINNET_CHAIN] as const; + } + + get accounts() { + return this.#accounts; + } + + get features(): StandardConnectFeature & + StandardDisconnectFeature & + StandardEventsFeature & + SuiSignTransactionBlockFeature & + SuiSignPersonalMessageFeature { + return { + 'standard:connect': { + version: '1.0.0', + connect: this.#connect, + }, + 'standard:disconnect': { + version: '1.0.0', + disconnect: this.#disconnect, + }, + 'standard:events': { + version: '1.0.0', + on: this.#on, + }, + 'sui:signTransactionBlock': { + version: '1.0.0', + signTransactionBlock: this.#signTransactionBlock, + }, + 'sui:signPersonalMessage': { + version: '1.0.0', + signPersonalMessage: this.#signPersonalMessage, + }, + }; + } + + constructor(suiClient: SuiClient, name: string) { + this.#accounts = []; + this.#events = mitt(); + this.#client = suiClient; + this.#name = name; + } + + #signTransactionBlock: SuiSignTransactionBlockMethod = async ({ transactionBlock, account }) => { + transactionBlock.setSenderIfNotSet(account.address); + + const bytes = toB64( + await transactionBlock.build({ + client: this.#client, + }), + ); + + const popup = new ZkSendPopup({ name: this.#name }); + const response = await popup.createRequest('sign-transaction-block', { + bytes, + address: account.address, + }); + + return { + transactionBlockBytes: bytes, + signature: response.signature, + }; + }; + + #signPersonalMessage: SuiSignPersonalMessageMethod = async ({ message, account }) => { + const bytes = toB64(bcs.vector(bcs.u8()).serialize(message).toBytes()); + const popup = new ZkSendPopup({ name: this.#name }); + const response = await popup.createRequest('sign-personal-message', { + bytes, + address: account.address, + }); + + return { + bytes, + signature: response.signature, + }; + }; + + #on: StandardEventsOnMethod = (event, listener) => { + this.#events.on(event, listener); + return () => this.#events.off(event, listener); + }; + + #setAccount(address?: string) { + if (address) { + this.#accounts = [ + new ReadonlyWalletAccount({ + address, + chains: [SUI_MAINNET_CHAIN], + features: ['sui:signTransactionBlock', 'sui:signPersonalMessage'], + // NOTE: zkSend doesn't support getting public keys, and zkLogin accounts don't have meaningful public keys anyway + publicKey: new Uint8Array(), + }), + ]; + + localStorage.setItem(ZKSEND_RECENT_ADDRESS_KEY, address); + } else { + this.#accounts = []; + } + + this.#events.emit('change', { accounts: this.accounts }); + } + + #connect: StandardConnectMethod = async (input) => { + if (input?.silent) { + const address = localStorage.getItem(ZKSEND_RECENT_ADDRESS_KEY); + + if (address) { + this.#setAccount(address); + } + + return { accounts: this.accounts }; + } + + const popup = new ZkSendPopup({ name: this.#name }); + const response = await popup.createRequest('connect', {}); + if (!('address' in response)) { + throw new Error('Unexpected response'); + } + + this.#setAccount(response.address); + + return { accounts: this.accounts }; + }; + + #disconnect: StandardDisconnectMethod = async () => { + localStorage.removeItem(ZKSEND_RECENT_ADDRESS_KEY); + this.#setAccount(); + }; +} + +export function registerZkSendWallet(name: string) { + const wallets = getWallets(); + const client = new SuiClient({ url: getFullnodeUrl('mainnet') }); + + return wallets.register(new ZkSendWallet(client, name)); +}