/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable react-refresh/only-export-components */ import { _createActor, handleDelegation } from './baseConnection'; import { idlFactory as verifierIDL } from './idls/ic_siwb_provider.idl'; import type { SignedDelegation, _SERVICE as verifierService, LoginDetails } from './idls/ic_siwb_provider'; import { hasOwnProperty } from './utils'; // import { Principal } from '@dfinity/principal'; import { type ActorConfig, type ActorSubclass, type HttpAgentOptions, SignIdentity } from '@dfinity/agent'; import { KEY_ICSTORAGE_DELEGATION, KEY_ICSTORAGE_KEY, _deleteStorage, storage } from './storage'; import { DelegationChain, DelegationIdentity, Ed25519KeyIdentity, isDelegationValid } from '@dfinity/identity'; import { IC_SIWB_CANISTERID, IC_SIWB_CANISTERID_TESTNET } from './shared/constant'; import { Principal } from '@dfinity/principal'; import { LaserEyesClient, LEATHER, MAGIC_EDEN, ORANGE, PHANTOM, type ContentType, } from "@omnisat/lasereyes-core"; import { XVERSE, type NetworkType, type ProviderType, } from "@omnisat/lasereyes"; import { createContext, useContext, type ReactNode, useEffect, useState, useRef, } from "react"; import type { IDL } from "@dfinity/candid"; import { AddressType, getAddressType, type NetworkItem } from "./wallet"; import type { LoginStatus, PrepareLoginStatus, State } from "./state.type"; import { callGetDelegation, callLogin, callPrepareLogin, createAnonymousActor, } from "./siwb-provider"; import { clearIdentity, loadIdentity, saveIdentity } from "./local-storage"; import type { LoginOkResponse, SignMessageType, SignedDelegation as ServiceSignedDelegation, } from "./service.interface"; import { createDelegationChain } from "./delegation"; export * from "./service.interface"; export * from "./storage.type"; // import { toHexString } from '@dfinity/candid'; export class SiwbConnector { constructor( private delegationIdentity: DelegationIdentity, private publicKey: string, private userAddress: string ) {} static async connect( provider: LaserEyesClient, canisterId?: string ): Promise { let key: null | SignIdentity = null; const maybeIdentityStorage = await storage.get(KEY_ICSTORAGE_KEY); if (maybeIdentityStorage) { try { key = Ed25519KeyIdentity.fromJSON( maybeIdentityStorage ) as unknown as SignIdentity; } catch (e) { // Ignore this, this means that the ICStorage value isn't a valid Ed25519KeyIdentity // serialization. } } let chain: null | DelegationChain = null; let delegationTargets: string[] = []; let delegationIdentityK: DelegationIdentity | undefined = undefined; if (key) { try { const chainStorage = await storage.get(KEY_ICSTORAGE_DELEGATION); if (chainStorage) { chain = DelegationChain.fromJSON(chainStorage); chain.delegations.forEach((signedDelegation) => { const targets = signedDelegation.delegation.targets && signedDelegation.delegation.targets.length > 0 ? signedDelegation.delegation.targets : undefined; if (targets) { delegationTargets = [ ...new Set( delegationTargets.concat(targets.map((e) => e.toText())) ), ]; } }); // Verify that the delegation isn't expired. if (!isDelegationValid(chain)) { await _deleteStorage(storage); key = null; } else { delegationIdentityK = DelegationIdentity.fromDelegation(key, chain); } } } catch (e) { console.error(e); // If there was a problem loading the chain, delete the key. await _deleteStorage(storage); key = null; } } const sessionId = Ed25519KeyIdentity.generate(); if (!key) { await storage.set(KEY_ICSTORAGE_KEY, JSON.stringify(sessionId)); } const _network = await provider.getNetwork(); let canister_id = canisterId; if ( _network && (_network.toLowerCase().includes("test") || _network.toLowerCase().includes("sig")) ) { canister_id = canisterId ??= IC_SIWB_CANISTERID_TESTNET; } // sessionId save to localstorage const verifierActor = await _createActor( verifierIDL, canister_id ?? IC_SIWB_CANISTERID, delegationIdentityK ?? (sessionId as unknown as SignIdentity) ); const rawAccounts = (await provider?.requestAccounts()) as (string | { address: string })[]; // console.log('raw accounts',rawAccounts) const accounts = rawAccounts.map((acc) => typeof acc === "string" ? acc : acc.address ); if (accounts === undefined || accounts.length === 0) { throw new Error("No accounts found"); } else { const currentAccount = accounts[0]!; const public_key = (await provider?.getPublicKey()) as string; if (delegationIdentityK === undefined) { const messageRes = await verifierActor.actor.siwb_prepare_login( currentAccount ); let message: string; if (hasOwnProperty(messageRes, "Ok")) { // console.log(`prepare success ${messageRes.Ok}`); message = messageRes.Ok as string; } else { throw new Error(messageRes["Err"]); } // console.log(`message is ${message}`); const signature = (await provider?.signMessage(message)) as string; // console.log(`signature is ${signature}`); const { delegationIdentity, delegationChain } = await handleDelegationVerification( verifierActor.actor, currentAccount, sessionId as unknown as SignIdentity, public_key, signature ); delegationIdentityK = delegationIdentity; await storage.set( KEY_ICSTORAGE_DELEGATION, JSON.stringify(delegationChain) ); } // delegationChain save to localstorage return new SiwbConnector(delegationIdentityK, public_key, currentAccount); } } public static async disconnect() { await _deleteStorage(storage); } public static async hasStorage(): Promise { return !!(await storage.get(KEY_ICSTORAGE_DELEGATION)); } public getPrincipal(): Principal { return this.delegationIdentity.getPrincipal(); } public getAddress(): string { return this.userAddress; } public static async getPrincialFromBitcoinAddress(address: string) { const verifierActor = await _createActor( verifierIDL, IC_SIWB_CANISTERID, undefined ); const p = await verifierActor.actor.get_principal(address); if (hasOwnProperty(p, "Ok")) { return p.Ok; } else { throw new Error(p.Err); } } public static async getDelegationIdentity() { let key: null | SignIdentity = null; const maybeIdentityStorage = await storage.get(KEY_ICSTORAGE_KEY); if (maybeIdentityStorage) { try { key = Ed25519KeyIdentity.fromJSON( maybeIdentityStorage ) as unknown as SignIdentity; } catch (e) { // Ignore this, this means that the ICStorage value isn't a valid Ed25519KeyIdentity // serialization. } } let chain: null | DelegationChain = null; let delegationTargets: string[] = []; let delegationIdentityK: DelegationIdentity | undefined = undefined; if (key) { try { const chainStorage = await storage.get(KEY_ICSTORAGE_DELEGATION); if (chainStorage) { chain = DelegationChain.fromJSON(chainStorage); chain.delegations.forEach((signedDelegation) => { const targets = signedDelegation.delegation.targets && signedDelegation.delegation.targets.length > 0 ? signedDelegation.delegation.targets : undefined; if (targets) { delegationTargets = [ ...new Set( delegationTargets.concat(targets.map((e) => e.toText())) ), ]; } }); // Verify that the delegation isn't expired. if (!isDelegationValid(chain)) { await _deleteStorage(storage); key = null; } else { delegationIdentityK = DelegationIdentity.fromDelegation(key, chain); } } } catch (e) { // If there was a problem loading the chain, delete the key. await _deleteStorage(storage); key = null; } } return delegationIdentityK; } } export async function handleDelegationVerification( actor: ActorSubclass, address: string, sessionId: SignIdentity, public_key: string, signature: string ): Promise<{ delegationIdentity: DelegationIdentity; delegationChain: DelegationChain; }> { const session_key = Array.from( new Uint8Array(sessionId.getPublicKey().toDer()) ); const result = await actor.siwb_login( signature, address, public_key, session_key, { ECDSA: null } ); // new SiwbConnector(); // const result = verifyMessage(publicKey, sig, message); if (hasOwnProperty(result, "Ok")) { const { expiration, user_canister_pubkey } = result.Ok as LoginDetails; const res = await actor.siwb_get_delegation( address, session_key, expiration ); if (res && hasOwnProperty(res, "Ok")) { const signed_delegation = res.Ok as SignedDelegation; const targets = signed_delegation.delegation.targets.length > 0 ? signed_delegation.delegation.targets[0] : undefined; const s = { delegation: { pubkey: Uint8Array.from(signed_delegation.delegation.pubkey), expiration: BigInt(signed_delegation.delegation.expiration), targets: targets && targets.length > 0 ? targets : undefined, }, signature: Uint8Array.from(signed_delegation.signature), userKey: user_canister_pubkey, timestamp: expiration, }; const delegationResult = { kind: "success", delegations: [s], userPublicKey: Uint8Array.from(s.userKey), }; // console.log(toHexString(Uint8Array.from(s.userKey))); return await handleDelegation(delegationResult, sessionId); } else { throw new Error("No signed delegation found"); } } else { throw new Error(result.Err as string); } } export type LaserEyesContextType = { isInitializing: boolean; connected: boolean; isConnecting: boolean; publicKey: string; address: string; paymentAddress: string; paymentPublicKey: string; balance: number | undefined; network: NetworkType; library: any; provider: any; accounts: string[]; hasUnisat: boolean; hasXverse: boolean; hasOrange: boolean; hasOpNet: boolean; hasOyl: boolean; hasMagicEden: boolean; hasOkx: boolean; hasLeather: boolean; hasPhantom: boolean; hasWizz: boolean; connect: (walletName: ProviderType) => Promise; disconnect: () => void; requestAccounts: () => Promise; getNetwork: () => Promise; switchNetwork: (network: NetworkType) => Promise; getPublicKey: () => Promise; getBalance: () => Promise; getInscriptions: () => Promise; sendBTC: (to: string, amount: number) => Promise; signMessage: (message: string, toSignAddress?: string) => Promise; signPsbt: ( tx: string, finalize?: boolean, broadcast?: boolean ) => Promise< | { signedPsbtHex: string | undefined; signedPsbtBase64: string | undefined; txId?: string; } | undefined >; pushPsbt: (tx: string) => Promise; inscribe: ( contentBase64: string, mimeType: ContentType ) => Promise; }; export type SiwbIdentityContextType = { /** Is set to `true` on mount until a stored identity is loaded from local storage or * none is found. */ isInitializing: boolean; /** Load a SIWb message from the provider canister, to be used for login. Calling prepareLogin * is optional, as it will be called automatically on login if not called manually. */ prepareLogin: () => void; /** Reflects the current status of the prepareLogin process. */ prepareLoginStatus: PrepareLoginStatus; /** `prepareLoginStatus === "loading"` */ isPreparingLogin: boolean; /** `prepareLoginStatus === "error"` */ isPrepareLoginError: boolean; /** `prepareLoginStatus === "success"` */ isPrepareLoginSuccess: boolean; /** `prepareLoginStatus === "idle"` */ isPrepareLoginIdle: boolean; /** Error that occurred during the prepareLogin process. */ prepareLoginError?: Error; /** Initiates the login process by requesting a SIWb message from the backend. */ login: () => Promise; /** Reflects the current status of the login process. */ loginStatus: LoginStatus; /** `loginStatus === "logging-in"` */ isLoggingIn: boolean; /** `loginStatus === "error"` */ isLoginError: boolean; /** `loginStatus === "success"` */ isLoginSuccess: boolean; /** `loginStatus === "idle"` */ isLoginIdle: boolean; /** Error that occurred during the login process. */ loginError?: Error; /** Status of the SIWb message signing process. This is a re-export of the Wagmi * signMessage / status type. */ signMessageStatus: "error" | "idle" | "pending" | "success"; /** Error that occurred during the SIWb message signing process. This is a re-export of the * Wagmi signMessage / error type. */ signMessageError: Error | null; /** The delegation chain is available after successfully loading the identity from local * storage or completing the login process. */ delegationChain?: DelegationChain; /** The identity is available after successfully loading the identity from local storage * or completing the login process. */ identity?: DelegationIdentity; /** The Bitcoin address associated with current identity. This address is not necessarily * the same as the address of the currently connected wallet - on wallet change, the addresses * will differ. */ identityAddress?: string; identityPublicKey?: string; /** Clears the identity from the state and local storage. Effectively "logs the user out". */ clear: () => void; /** Network Identitfier, not adding bitcoinjs-lib network directly, simple string */ network?: NetworkType; /** We don't have things like rainbow kit right now, so we have to manually set provider key */ // setLaserEyes: (laserEyes: LaserEyesContextType) => Promise; /** We don't have things like rainbow kit right now, so we have to manually return provider key */ selectedProvider?: ProviderType; /** We don't have things like rainbow kit right now, so we have to manually return btc address */ connectedBtcAddress?: string; /** We don't have things like rainbow kit right now, so we have to manually return btc address */ getAddress: () => string | undefined; getPublicKey: () => string | undefined; setLaserEyes: ( laserEyes: LaserEyesContextType, providerType?: ProviderType ) => Promise; }; export const SiwbIdentityContext = createContext< SiwbIdentityContextType | undefined >(undefined); export function createClient( context: LaserEyesContextType ): [LaserEyesClient, ProviderType] { const { getPublicKey, signMessage, getNetwork, requestAccounts, connect, provider, } = context; const client = { getPublicKey, signMessage, getNetwork, requestAccounts, connect, } as LaserEyesClient; return [client, provider as ProviderType]; } /** * Hook to access the SiwbIdentityContext. */ export const useSiwbIdentity = (): SiwbIdentityContextType => { const context = useContext(SiwbIdentityContext); if (!context) { throw new Error( "useSiwbIdentity must be used within an SiwbIdentityProvider" ); } return { ...context }; }; export function SiwbIdentityProvider({ httpAgentOptions, actorOptions, idlFactory, canisterId, children, }: { /** Configuration options for the HTTP agent used to communicate with the Internet Computer network. */ httpAgentOptions?: HttpAgentOptions; /** Configuration options for the actor. These options are passed to the actor upon its creation. */ actorOptions?: ActorConfig; /** The Interface Description Language (IDL) factory for the canister. This factory is used to create an actor interface for the canister. */ idlFactory: IDL.InterfaceFactory; /** The unique identifier of the canister on the Internet Computer network. This ID is used to establish a connection to the canister. */ canisterId: string; /** The child components that the SiwbIdentityProvider will wrap. This allows any child component to access the authentication context provided by the SiwbIdentityProvider. */ children: ReactNode; }) { let signMessageStatus: "error" | "idle" | "pending" | "success" = "idle"; let signMessageError = null; const [state, setState] = useState({ isInitializing: true, prepareLoginStatus: "idle", loginStatus: "idle", selectedProvider: undefined, }); function updateState(newState: Partial) { setState((prevState) => ({ ...prevState, ...newState, })); } // Keep track of the promise handlers for the login method during the async login process. const loginPromiseHandlers = useRef<{ resolve: ( value: DelegationIdentity | PromiseLike ) => void; reject: (error: Error) => void; } | null>(null); // const { provider, address: connectedBtcAddress, network } = useRegisterExtension(state.selectedProvider); async function setLaserEyes( laserEyes: LaserEyesContextType, providerType?: ProviderType ) { const [provider, p] = createClient(laserEyes); // console.log("setlasereyes", provider, p, "providerType", providerType) try { await laserEyes.connect(providerType ?? p); // console.log("Connect resolved", c); } catch (err) { // console.error("Connect error:", err); throw err; } const network = await provider.getNetwork(); const address = await provider.requestAccounts(); const publicKey = await provider.getPublicKey(); // console.log("network", network) // console.log("address", address) // console.log("publicKey", publicKey) const getConnectedBtcAddress = (accounts: any) => { // Normalize each account so that we get a string address const normalizedAccounts = Array.isArray(accounts) ? accounts.map((acc: any) => typeof acc === "string" ? acc : acc.address ) : []; // console.log('normalized accounts', normalizedAccounts); return normalizedAccounts[0] || ""; }; updateState({ selectedProvider: providerType ?? p, provider: provider, network, connectedBtcAddress: address ? getConnectedBtcAddress(address) : "", connectedBtcPublicKey: publicKey, }); } function getAddress() { return state.connectedBtcAddress; } function getPublicKey() { return state.connectedBtcPublicKey; } /** * Load a SIWB message from the provider, to be used for login. Calling prepareLogin * is optional, as it will be called automatically on login if not called manually. */ async function prepareLogin(): Promise { if (!state.anonymousActor) { throw new Error( "Hook not initialized properly. Make sure to supply all required props to the SiwbIdentityProvider." ); } if (!state.connectedBtcAddress) { throw new Error( "No Bitcoin address available. Call prepareLogin after the user has connected their wallet." ); } updateState({ prepareLoginStatus: "preparing", prepareLoginError: undefined, }); try { const siwbMessage = await callPrepareLogin( state.anonymousActor, state.connectedBtcAddress ); updateState({ siwbMessage, prepareLoginStatus: "success", }); return siwbMessage; } catch (e) { const error = normalizeError(e); console.error(error); updateState({ prepareLoginStatus: "error", prepareLoginError: error as any, }); } } async function rejectLoginWithError( error: Error | unknown, message?: string ) { const e = normalizeError(error); const errorMessage = message || (e as any).message; console.error(e); updateState({ siwbMessage: undefined, loginStatus: "error", loginError: new Error(errorMessage), }); loginPromiseHandlers.current?.reject(new Error(errorMessage)); } /** * This function is called when the signMessage hook has settled, that is, when the * user has signed the message or canceled the signing process. */ async function onLoginSignatureSettled( loginSignature: string | undefined, publickeyHex: string, signMessageType: SignMessageType, error: Error | null ) { if (error) { rejectLoginWithError( error, "An error occurred while signing the login message." ); return; } if (!loginSignature) { rejectLoginWithError(new Error("Sign message returned no data.")); return; } // Important for security! A random session identity is created on each login. const sessionIdentity = Ed25519KeyIdentity.generate(); const sessionPublicKey = sessionIdentity.getPublicKey().toDer(); if ( !state.anonymousActor || !state.connectedBtcAddress || !state.connectedBtcPublicKey ) { rejectLoginWithError(new Error("Invalid actor or address.")); return; } // Logging in is a two-step process. First, the signed SIWB message is sent to the backend. // Then, the backend's siwb_get_delegation method is called to get the delegation. let loginOkResponse: LoginOkResponse; try { loginOkResponse = await callLogin( state.anonymousActor, loginSignature, state.connectedBtcAddress, publickeyHex, sessionPublicKey, signMessageType ); } catch (e) { rejectLoginWithError(e, "Unable to login."); return; } // Call the backend's siwb_get_delegation method to get the delegation. let signedDelegation: ServiceSignedDelegation; try { signedDelegation = await callGetDelegation( state.anonymousActor, state.connectedBtcAddress, sessionPublicKey, loginOkResponse.expiration ); } catch (e) { rejectLoginWithError(e, "Unable to get identity."); return; } // Create a new delegation chain from the delegation. const delegationChain = createDelegationChain( signedDelegation, loginOkResponse.user_canister_pubkey ); // Create a new delegation identity from the session identity and the // delegation chain. const identity = DelegationIdentity.fromDelegation( sessionIdentity, delegationChain ); // Save the identity to local storage. saveIdentity( state.connectedBtcAddress, publickeyHex, sessionIdentity, delegationChain ); // Set the identity in state. updateState({ loginStatus: "success", identityAddress: state.connectedBtcAddress, identityPublicKey: publickeyHex, identity, delegationChain, }); loginPromiseHandlers.current?.resolve(identity); // The signMessage hook is reset so that it can be used again. // reset(); } /** * Initiates the login process. If a SIWB message is not already available, it will be * generated by calling prepareLogin. * * @returns {void} Login does not return anything. If an error occurs, the error is available in * the loginError property. */ async function login() { const promise = new Promise((resolve, reject) => { loginPromiseHandlers.current = { resolve, reject }; }); // Set the promise handlers immediately to ensure they are available for error handling. if (!state.anonymousActor) { rejectLoginWithError( new Error( "Hook not initialized properly. Make sure to supply all required props to the SiwbIdentityProvider." ) ); return promise; } if (!state.connectedBtcAddress) { rejectLoginWithError( new Error( "No Bitcoin address available. Call login after the user has connected their wallet." ) ); return promise; } if (state.prepareLoginStatus === "preparing") { rejectLoginWithError( new Error("Don't call login while prepareLogin is running.") ); return promise; } updateState({ loginStatus: "logging-in", loginError: undefined, }); try { // The SIWB message can be prepared in advance, or it can be generated as part of the login process. let siwbMessage = state.siwbMessage; if (!siwbMessage) { siwbMessage = await prepareLogin(); if (!siwbMessage) { throw new Error( "Prepare login failed did not return a SIWB message." ); } } if (state.provider !== undefined) { signMessageStatus = "pending"; let signMessageType; if (state.selectedProvider === XVERSE) { const [addressType, _] = getAddressType(state.connectedBtcAddress); if ( addressType === AddressType.P2TR || addressType === AddressType.P2WPKH ) { signMessageType = { Bip322Simple: null }; } else { signMessageType = { ECDSA: null }; } } else if (state.selectedProvider === LEATHER) { const [addressType, _] = getAddressType(state.connectedBtcAddress); if ( addressType === AddressType.P2TR || addressType === AddressType.P2WPKH ) { signMessageType = { Bip322Simple: null }; } else { signMessageType = { ECDSA: null }; } } else if (state.selectedProvider === MAGIC_EDEN) { const [addressType, _] = getAddressType(state.connectedBtcAddress); if ( addressType === AddressType.P2TR || addressType === AddressType.P2WPKH ) { signMessageType = { Bip322Simple: null }; } else { signMessageType = { ECDSA: null }; } } else if (state.selectedProvider === ORANGE) { const [addressType, _] = getAddressType(state.connectedBtcAddress); if ( addressType === AddressType.P2TR || addressType === AddressType.P2WPKH ) { signMessageType = { Bip322Simple: null }; } else { signMessageType = { ECDSA: null }; } } else if (state.selectedProvider === PHANTOM) { const [addressType, _] = getAddressType(state.connectedBtcAddress); if ( addressType === AddressType.P2TR || addressType === AddressType.P2WPKH ) { signMessageType = { Bip322Simple: null }; } else { signMessageType = { ECDSA: null }; } } else { // Fallback for USAT and OKX signMessageType = { ECDSA: null }; } const signature = await state.provider.signMessage( siwbMessage as string, state.connectedBtcAddress ); updateState({ signMessageType, }); signMessageStatus = "success"; if (signature === undefined) { signMessageStatus = "error"; signMessageError = new Error("No signed message returned."); rejectLoginWithError(signMessageError); return promise; } const pubKey = await state.provider.getPublicKey(); onLoginSignatureSettled(signature, pubKey!, signMessageType, null); } } catch (e) { signMessageStatus = "error"; signMessageError = e; rejectLoginWithError(e); } return promise; } /** * Clears the state and local storage. Effectively "logs the user out". */ function clear() { updateState({ isInitializing: false, prepareLoginStatus: "idle", prepareLoginError: undefined, siwbMessage: undefined, loginStatus: "idle", loginError: undefined, identity: undefined, identityAddress: undefined, identityPublicKey: undefined, delegationChain: undefined, connectedBtcAddress: undefined, connectedBtcPublicKey: undefined, signMessageType: undefined, }); clearIdentity(); } /** * Load the identity from local storage on mount. */ useEffect(() => { try { const [a, p, i, d] = loadIdentity(); updateState({ identityAddress: a, identityPublicKey: p, identity: i, delegationChain: d, isInitializing: false, }); } catch (e) { if (e instanceof Error) { console.log("Could not load identity from local storage: ", e.message); } updateState({ isInitializing: false, }); } }, []); /** * On address change, reset the state. Action is conditional on state.isInitializing * being false. */ useEffect(() => { if (state.isInitializing) return; clear(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.connectedBtcAddress]); /** * Create an anonymous actor on mount. This actor is used during the login * process. */ useEffect(() => { const a = createAnonymousActor({ idlFactory, canisterId, httpAgentOptions, actorOptions, }); updateState({ anonymousActor: a, }); }, [idlFactory, canisterId, httpAgentOptions, actorOptions]); return ( {children} ); } function normalizeError(e: unknown): Error { // console.log('the error', e) if (e instanceof Error) { return e; } if (typeof e === "string") { return new Error(e); } if (e && typeof e === "object") { // Try to use the message property if it exists if ("message" in e && typeof (e as any).message === "string") { return new Error((e as any).message); } try { return new Error(JSON.stringify(e)); } catch { return new Error(String(e)); } } return new Error("Unknown error"); } // function normalizeError(e: unknown) { // console.log('the error', e) // // throw new Error('Function not implemented.'); // }