import { Address, encodeAbiParameters, Hex, hexToBigInt, isAddressEqual, keccak256, zeroAddress, zeroHash, } from 'viem'; import { PERMIT2_MAP, TOKEN_DISTRIBUTOR, TokenDistributeSubmitRequest, TokenDistributeDepositRequest, TokenDistributorAbi, } from '@prex0/prex-structs'; import { DistributeRequest, DistributionRequestStatus, PagingOptions, PrexUser, TokenDistributeRequestEntity, } from '../types'; import { PrexSigner } from '../core/sign'; import { PrexApiService } from '../api'; import { PrexClient } from '../prex-client'; import { encryptByTmpSecret, generateKey, generateSecret, decryptByTmpSecret, generateShortCode, } from '../utils/tmp-secret'; import { privateKeyToAccount, signMessage } from 'viem/accounts'; import { getDistributeRequest, getDistributionInfoMap, } from '../evm-api/get-distribute-request'; import { normalizeErrorFn, PrexSDKError } from '../errors'; import { PrexStorage } from '../storage/PrexStorage'; import { DistributeActionInterface } from '../interfaces/prex-client-interface'; import { queryTokenDistributeRequests } from '../graph/distributions'; import { privateKeyToAddress } from 'viem/accounts'; import { DistributionSecret, parseDistributionSecret, serializeDistributionSecret, } from '../utils/distribution-secrets'; import { retryFetch } from '../utils/retry-fetch'; import { hexToBase64 } from '../utils/base64'; const DEADLINE_UNTIL = 24 * 60 * 60; type DistributionSecretParams = DistributionSecret & { distributionId: Hex; recipient?: Address; }; export interface RegisterServerGeneratedSecretInput { distributionId: string; secret: string; encodedRequest: Hex; chainId: number; shortCode: string; } export interface ServerGeneratedSecretInput { distributionId: string; recipient: Address; coordinate: Hex; deadline: string; shortCode: string; } export class DistributeAction implements DistributeActionInterface { constructor( private client: PrexClient, private apiService: PrexApiService, private storage: PrexStorage, private user?: PrexUser, private signer?: PrexSigner ) {} async submit({ token, amount, amountPerWithdrawal, maxAmountPerAddress, expiry, coolTime, name, coordinate, sender: senderParam, registerPath, }: { token: Address; amount: bigint; amountPerWithdrawal: bigint; maxAmountPerAddress?: bigint; expiry: bigint; coolTime?: bigint; name: string; coordinate?: Hex; enableEncryption?: boolean; sender?: Address; registerPath?: string; }) { if (!this.user || !this.signer) { throw new PrexSDKError('not_initialized', 'User not initialized'); } const sender = senderParam || this.user.address; const chainId = this.signer.chainId; const { secret, publicKey } = generateSecret(); const deadline = this.getDeadline(); const finalCoolTime = coolTime !== undefined ? coolTime : 60n * 60n * 24n * 365n; const finalMaxAmountPerAddress = maxAmountPerAddress !== undefined ? maxAmountPerAddress : amountPerWithdrawal; const submitRequest = new TokenDistributeSubmitRequest( { token, amount, publicKey, amountPerWithdrawal, expiry, name, dispatcher: TOKEN_DISTRIBUTOR, sender: sender, nonce: await this.client.updatePermit2Nonce(TOKEN_DISTRIBUTOR, sender), deadline, cooltime: finalCoolTime, maxAmountPerAddress: finalMaxAmountPerAddress, additionalValidator: zeroAddress, additionalData: '0x', coordinate: coordinate ? await encryptByTmpSecret(secret, coordinate) : zeroHash, }, chainId, PERMIT2_MAP[chainId] ); const { domain, types, message, primaryType } = submitRequest.permitData(); const signature = await this.signer.signTypedData( { domain, types, message, primaryType, }, sender ); const id = submitRequest.hash().toLowerCase(); // encode the secret to base64 const encodedSecret = serializeDistributionSecret({ type: 'secret', secret, }); await this.setSecret(id, encodedSecret); const shortCode = DistributeAction.generateShortCode(); await this.setShortCode(id, shortCode); if (registerPath) { // if registerPath is provided, we need to register the secret to server const { success } = await this._callAPIRegisterRealTimeSecret( registerPath, { distributionId: id, secret: encodedSecret, encodedRequest: submitRequest.serialize(), shortCode, chainId, } ); if (!success) { throw new Error('Failed to register real time secret to server'); } } await this.apiService.distributeSubmit( submitRequest.serialize(), signature ); await this.client.fetchBalance(token); return { id, secret: encodedSecret, shortCode, }; } async generateSubSecret(id: Hex) { const secret = await this.getSecret(id); if (!secret) { throw new Error('Secret not found'); } const parsedSecret = parseDistributionSecret(secret); if (parsedSecret.type !== 'secret') { throw new Error('Sub secret already exists'); } const { secret: subSecret, publicKey: subPublicKey } = generateSecret(); const expiry = await this.getExpiryOfRequest(id); const nonce = hexToBigInt(keccak256(subPublicKey)); const deadline = expiry; const subSig = await DistributeAction.generateWithdrawSignature( parsedSecret.secret, TOKEN_DISTRIBUTOR, nonce, deadline, subPublicKey ); return { id, secret: serializeDistributionSecret({ type: 'pre_generated', subSig, subSecret, }), }; } async deposit({ amount, requestId, sender: senderParam, }: { amount: bigint; requestId: Hex; sender?: Address; }) { if (!this.user || !this.signer) { throw new PrexSDKError('not_initialized', 'User not initialized'); } const sender = senderParam || this.user.address; const token = await this.getTokenOfRequest(requestId); const deadline = this.getDeadline(); const depositRequest = new TokenDistributeDepositRequest( { token: token, amount, dispatcher: TOKEN_DISTRIBUTOR, sender: sender, nonce: this.client.getPermit2Nonce(), deadline, requestId, }, this.apiService.chainId, PERMIT2_MAP[this.apiService.chainId] ); const { domain, types, message } = depositRequest.permitData(); const signature = await this.signer.signTypedData( { domain, types, message, primaryType: 'PermitWitnessTransferFrom', }, sender ); await this.apiService.distributeDeposit( depositRequest.serialize(), signature ); await this.client.fetchBalance(token); } async cancel({ requestId, from }: { requestId: Hex; from?: Address }) { await this.client.executeOperation( { address: TOKEN_DISTRIBUTOR, abi: TokenDistributorAbi, functionName: 'cancel', args: [requestId], }, from ); } async getTokenOfRequest(id: Hex): Promise
{ const request = await getDistributeRequest( this.client.evmChainClient, TOKEN_DISTRIBUTOR, id ); return request[4]; } async getExpiryOfRequest(id: Hex): Promise { const request = await getDistributeRequest( this.client.evmChainClient, TOKEN_DISTRIBUTOR, id ); return request[7]; } async getCoordinateOfRequest(id: Hex): Promise { const request = await getDistributeRequest( this.client.evmChainClient, TOKEN_DISTRIBUTOR, id ); return request[12]; } async getRequest( id: Hex, { secret, coordinate: coordinateFromServer, }: { secret?: Hex; coordinate?: Hex; } ): Promise { const request = await getDistributeRequest( this.client.evmChainClient, TOKEN_DISTRIBUTOR, id ); const infoMap = this.user ? await getDistributionInfoMap( this.client.evmChainClient, TOKEN_DISTRIBUTOR, id, this.user.address ) : null; function getStatus(status: number): DistributionRequestStatus { if (status === 1) { return 'PENDING'; } else if (status === 2) { return 'COMPLETED'; } else if (status === 3) { return 'CANCELLED'; } return 'EMPTY'; } const encryptedCoordinate = request[12]; let coordinate: Hex = zeroHash; if (encryptedCoordinate !== zeroHash) { if (secret) { coordinate = await decryptByTmpSecret(secret, encryptedCoordinate); } else if (coordinateFromServer) { coordinate = coordinateFromServer; } else { throw new Error('Coordinate not found'); } } else { coordinate = zeroHash; } return { id, amount: request[0], amountPerWithdrawal: request[1], cooltime: request[2], maxAmountPerAddress: request[3], token: request[4], publicKey: request[5], sender: request[6], expiry: request[7], status: getStatus(request[8]), name: request[9], coordinate: coordinate, userStatus: infoMap ? { lastDistributedAt: infoMap[0], amount: infoMap[1], } : undefined, }; } async prepareSecret(_params: { path: string; auth?: string; requestId: Hex; shortCode: string; recipient?: Address; }) { if (!this.user) { throw new PrexSDKError('not_initialized', 'User not initialized'); } const coordinate = await this.getCoordinateOfRequest(_params.requestId); const expiry = await this.getExpiryOfRequest(_params.requestId); try { const secret = await this._callAPIGenerateRealTimeSecret(_params.path, { distributionId: _params.requestId, shortCode: _params.shortCode, recipient: _params.recipient || this.user.address, coordinate, deadline: expiry.toString(), }, _params.auth); return { secret: secret.secret, }; } catch (error) { const normalizedError = normalizeErrorFn( 'Failed to generate real time secret' )(error); throw new PrexSDKError('generate_secret_failed', normalizedError.message); } } async prepareReceive(params: { requestId: Hex; secret: string; recipient?: Address; }) { const parsedSecret = parseDistributionSecret(params.secret); this._prepareReceive({ ...parsedSecret, distributionId: params.requestId, recipient: params.recipient, }); return this.getRequest(params.requestId, { secret: parsedSecret.type === 'secret' ? parsedSecret.secret : undefined, coordinate: parsedSecret.type === 'real_time' ? parsedSecret.coordinate : undefined, }); } async _prepareReceive(params: DistributionSecretParams) { if (!this.user || !this.signer) { throw new PrexSDKError('not_initialized', 'User not initialized'); } const expiry = await this.getExpiryOfRequest(params.distributionId); const deadline = expiry; const recipient = params.recipient || this.user.address; if (params.type === 'pre_generated') { const subPublicKey = privateKeyToAddress(params.subSecret); const nonce = hexToBigInt(keccak256(subPublicKey)); const sig = await DistributeAction.generateWithdrawSignature( params.subSecret, TOKEN_DISTRIBUTOR, nonce, deadline, recipient ); await this.storage.setItemToSessionStorage( this.getKeyForParams(params.distributionId), { sig: params.subSig, nonce, deadline, recipient, subPublicKey, subSig: sig, } ); } else if (params.type === 'real_time') { const sig = params.sig; const nonce = params.nonce; await this.storage.setItemToSessionStorage( this.getKeyForParams(params.distributionId), { sig, nonce, deadline: expiry, recipient, subPublicKey: zeroAddress, subSig: '0x', } ); } else if (params.type === 'secret') { const nonce = DistributeAction.generateNonce(); const sig = await DistributeAction.generateWithdrawSignature( params.secret, TOKEN_DISTRIBUTOR, nonce, deadline, recipient ); await this.storage.setItemToSessionStorage( this.getKeyForParams(params.distributionId), { sig, nonce, deadline, recipient, subPublicKey: zeroAddress, subSig: '0x', } ); } else { throw new Error('Invalid secret type'); } } async receive({ requestId }: { requestId: Hex }) { if (!this.user || !this.signer) { throw new PrexSDKError('not_initialized', 'User not initialized'); } const params = await this.storage.getItemFromSessionStorage<{ sig: Hex; nonce: string; deadline: string; recipient: Address; subPublicKey: Hex; subSig: Hex; }>(this.getKeyForParams(requestId)); if (!params) { throw new Error('Params not found'); } const { sig, nonce, deadline, recipient, subPublicKey, subSig } = params; const token = await this.getTokenOfRequest(requestId); await this.apiService.distributeWithdraw({ requestId, sig, nonce: nonce, deadline: deadline.toString(), recipient, subPublicKey, subSig, }); await this.client.fetchBalance(token); } async queryRequests( query?: { user: Address }, pagingOptions?: PagingOptions ) { if (!this.user) { throw new PrexSDKError('not_initialized', 'User not initialized'); } const requests = await queryTokenDistributeRequests( this.apiService, query || { user: this.user.address }, pagingOptions?.offset || 0, pagingOptions?.limit || 10 ); return Promise.all( requests.map(async (request) => ({ ...request, secret: await this.getSecret(request.id), shortCode: await this.getShortCode(request.id), })) ) as Promise; } static generateNonce() { const randomKey = generateKey(); return hexToBigInt(randomKey); } static generateShortCode() { return hexToBase64(generateShortCode()); } static async generateWithdrawSignature( secret: Hex, dispatcher: Address, nonce: bigint, deadline: bigint, recipient: Address ) { return await signMessage({ privateKey: secret, message: { raw: keccak256( encodeAbiParameters( [ { type: 'address' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'address' }, ], [dispatcher, nonce, deadline, recipient] ) ), }, }); } private getDeadline() { return BigInt(Math.floor(Date.now() / 1000) + DEADLINE_UNTIL); } private getKeyForParams(id: Hex) { return `distribute-params-${id}`; } private async getSecret(id: string) { return this.storage.getItem('secret-' + id.toLowerCase()); } private async setSecret(id: string, secret: string) { this.storage.setItem('secret-' + id.toLowerCase(), secret); } private async getShortCode(id: string) { return this.storage.getItem('short-code-' + id.toLowerCase()); } private async setShortCode(id: string, shortCode: string) { this.storage.setItem('short-code-' + id.toLowerCase(), shortCode); } async _callAPIRegisterRealTimeSecret( path: string, data: RegisterServerGeneratedSecretInput ) { const res = await this._post(path, data); return res as { success: boolean; }; } async _callAPIGenerateRealTimeSecret( path: string, data: ServerGeneratedSecretInput, auth?: string ) { const res = await this._post(path, data, auth); return res as { secret: Hex; }; } async _post(path: string, data: any, auth?: string) { const headers = auth ? { 'Content-Type': 'application/json', 'Authorization': `Bearer ${auth}` } : { 'Content-Type': 'application/json', } as HeadersInit; const res = await retryFetch( path, { method: 'POST', mode: 'cors', headers, body: JSON.stringify(data), }, { retries: 3, } ); if (res.status >= 500 && res.status < 600) { throw new Error('Server Error'); } return await res.json(); } } export async function validateDistributionAndSecret( registerRequest: RegisterServerGeneratedSecretInput ) { const chainId = registerRequest.chainId; const request = TokenDistributeSubmitRequest.parse( registerRequest.encodedRequest, chainId, PERMIT2_MAP[chainId] ); const hash = request.hash().toLowerCase(); if (hash !== registerRequest.distributionId.toLowerCase()) { return false; } const parsedSecret = parseDistributionSecret(registerRequest.secret); if (parsedSecret.type !== 'secret') { return false; } const account = privateKeyToAccount(parsedSecret.secret); if (!isAddressEqual(account.address, request.params.publicKey)) { return false; } return true; } export async function createServerGeneratedSecret( secret: string, { recipient, coordinate, deadline }: ServerGeneratedSecretInput ) { const nonce = DistributeAction.generateNonce(); const parsedSecret = parseDistributionSecret(secret); if (parsedSecret.type !== 'secret') { throw new Error('Invalid secret type'); } const sig = await DistributeAction.generateWithdrawSignature( parsedSecret.secret, TOKEN_DISTRIBUTOR, nonce, BigInt(deadline), recipient ); return serializeDistributionSecret({ type: 'real_time', sig, nonce, coordinate: coordinate === zeroHash ? zeroHash : await decryptByTmpSecret(parsedSecret.secret, coordinate), }); }