import { CHAIN_NAMESPACES } from '@portal-hq/utils' import type { IPortalApi } from '@portal-hq/utils' import PortalDelegationsApi, { IPortalDelegationsApi, } from './PortalDelegationsApi' import type { ApproveDelegationResponse, RevokeDelegationResponse, TransferFromResponse, DelegationSubmitProgress, } from './types' export type { ApproveDelegationRequest, ApproveDelegationResponse, RevokeDelegationRequest, RevokeDelegationResponse, GetDelegationStatusRequest, DelegationStatusResponse, TransferFromRequest, TransferFromResponse, DelegationSubmitProgress, } from './types' /** * Returns true if the given CAIP-2 chainId belongs to the Solana namespace. * Solana chain IDs follow the pattern "solana:". */ export function isSolanaChain(chainId: string): boolean { return chainId.split(':', 1)[0] === CHAIN_NAMESPACES.SOLANA } /** * Options for sign-and-submit flows. Called multiple times per * transaction (e.g., once when signing begins and once after submission). */ export interface DelegationSubmitOptions { /** * Function to sign and broadcast a transaction. Optional per-call override. * When not provided, falls back to the instance-level signer configured via * {@link Delegations.setSignAndSendTransaction}. */ signAndSendTransaction?: ( transaction: unknown, chainId: string, ) => Promise onProgress?: (event: DelegationSubmitProgress) => void } export interface IDelegations extends IPortalDelegationsApi { /** * Configures the signer used by approveAndSubmit(), revokeAndSubmit(), and transferAndSubmit() * when no per-call `signAndSendTransaction` option is provided. Call this once after construction * (e.g. when Portal wires in its MPC signer) instead of passing it to the constructor. * * Per-call `signAndSendTransaction` in {@link DelegationSubmitOptions} always takes precedence * over the instance-level setter. */ setSignAndSendTransaction( fn: (transaction: unknown, chainId: string) => Promise, ): void approveAndSubmit( params: Parameters[0], options?: DelegationSubmitOptions, ): Promise<{ hashes: string[] }> revokeAndSubmit( params: Parameters[0], options?: DelegationSubmitOptions, ): Promise<{ hashes: string[] }> transferAndSubmit( params: Parameters[0], options?: DelegationSubmitOptions, ): Promise<{ hashes: string[] }> } const DELEGATIONS_SUBMIT_CONFIG_ERROR = '[Delegations] No signer configured. Call setSignAndSendTransaction() on the instance or pass signAndSendTransaction in options.' export interface DelegationsOptions { api: IPortalApi } export class Delegations implements IDelegations { private readonly delegationsApi: PortalDelegationsApi private signAndSendTransactionFn?: ( transaction: unknown, chainId: string, ) => Promise constructor(options: DelegationsOptions) { this.delegationsApi = new PortalDelegationsApi({ api: options.api }) } /** * Configures the signer used by approveAndSubmit(), revokeAndSubmit(), and transferAndSubmit() * when no per-call `signAndSendTransaction` option is provided. Typically called once after * construction (e.g. Portal wires in its MPC signer via this method instead of the constructor). * * Per-call `signAndSendTransaction` in {@link DelegationSubmitOptions} always takes precedence. */ public setSignAndSendTransaction( fn: (transaction: unknown, chainId: string) => Promise, ): void { this.signAndSendTransactionFn = fn } public async approve( params: Parameters[0], ): ReturnType { return this.delegationsApi.approve(params) } public async revoke( params: Parameters[0], ): ReturnType { return this.delegationsApi.revoke(params) } public async getStatus( params: Parameters[0], ): ReturnType { return this.delegationsApi.getStatus(params) } public async transferFrom( params: Parameters[0], ): ReturnType { return this.delegationsApi.transferFrom(params) } /** * Approve a delegation, then sign and submit the returned transaction(s). * * The signer is resolved in priority order: * 1. `options.signAndSendTransaction` — per-call override. * 2. Instance setter — configured via {@link Delegations.setSignAndSendTransaction} (e.g. Portal). * 3. Error — thrown if neither is available. * * @returns Hashes of submitted transactions. Wait for confirmation is the app's responsibility. */ public async approveAndSubmit( params: Parameters[0], options?: DelegationSubmitOptions, ): Promise<{ hashes: string[] }> { const signAndSend = options?.signAndSendTransaction ?? this.signAndSendTransactionFn if (!signAndSend) { throw new Error(DELEGATIONS_SUBMIT_CONFIG_ERROR) } const response = await this.delegationsApi.approve(params) return this.executeAndTrack( response, params.chain, signAndSend, options?.onProgress, ) } /** * Revoke a delegation, then sign and submit the returned transaction(s). * * The signer is resolved in priority order: * 1. `options.signAndSendTransaction` — per-call override. * 2. Instance setter — configured via {@link Delegations.setSignAndSendTransaction} (e.g. Portal). * 3. Error — thrown if neither is available. * * @returns Hashes of submitted transactions. Wait for confirmation is the app's responsibility. */ public async revokeAndSubmit( params: Parameters[0], options?: DelegationSubmitOptions, ): Promise<{ hashes: string[] }> { const signAndSend = options?.signAndSendTransaction ?? this.signAndSendTransactionFn if (!signAndSend) { throw new Error(DELEGATIONS_SUBMIT_CONFIG_ERROR) } const response = await this.delegationsApi.revoke(params) return this.executeAndTrack( response, params.chain, signAndSend, options?.onProgress, ) } /** * Transfer from delegation, then sign and submit the returned transaction(s). * * The signer is resolved in priority order: * 1. `options.signAndSendTransaction` — per-call override. * 2. Instance setter — configured via {@link Delegations.setSignAndSendTransaction} (e.g. Portal). * 3. Error — thrown if neither is available. * * @returns Hashes of submitted transactions. Wait for confirmation is the app's responsibility. */ public async transferAndSubmit( params: Parameters[0], options?: DelegationSubmitOptions, ): Promise<{ hashes: string[] }> { const signAndSend = options?.signAndSendTransaction ?? this.signAndSendTransactionFn if (!signAndSend) { throw new Error(DELEGATIONS_SUBMIT_CONFIG_ERROR) } const response = await this.delegationsApi.transferFrom(params) return this.executeAndTrack( response, params.chain, signAndSend, options?.onProgress, ) } private normalizeToTransactionList( response: | ApproveDelegationResponse | RevokeDelegationResponse | TransferFromResponse, ): unknown[] { const transactions = Array.isArray(response.transactions) ? response.transactions : [] const encodedTransactions = Array.isArray(response.encodedTransactions) ? response.encodedTransactions : [] if (transactions.length > 0) { return transactions } if (encodedTransactions.length > 0) { return encodedTransactions } return [] } private async executeAndTrack( response: | ApproveDelegationResponse | RevokeDelegationResponse | TransferFromResponse, chainId: string, signAndSend: (transaction: unknown, chainId: string) => Promise, onProgress?: (event: DelegationSubmitProgress) => void, ): Promise<{ hashes: string[] }> { const transactions = this.normalizeToTransactionList(response) if (transactions.length === 0) { throw new Error('No transactions in delegation response.') } const total = transactions.length const hashes: string[] = [] for (let index = 0; index < transactions.length; index++) { const tx = transactions[index] onProgress?.({ step: 'signing', index, total }) const hash = await signAndSend(tx, chainId) if (typeof hash !== 'string' || hash.trim().length === 0) { throw new Error( `Invalid transaction hash returned from signAndSendTransaction at index ${index} for chain ${chainId}.`, ) } hashes.push(hash) onProgress?.({ step: 'submitted', index, total, hash }) } return { hashes } } }