diff --git a/src/api/index.ts b/src/api/index.ts index f500492..b77908d 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -16,11 +16,14 @@ import queriesChainsV1 from "./queries/chains/v1"; import queriesConfigsV1 from "./queries/configs/v1"; import queriesNonceMappingsV1 from "./queries/nonce-mappings/v1"; import queriesWithdrawalRequestsV1 from "./queries/withdrawal-requests/v1"; +import queriesWithdrawalRequestsV2 from "./queries/withdrawal-requests/v2"; // Requests import requestsUnlocksV1 from "./requests/unlocks/v1"; import requestsWithdrawalsV1 from "./requests/withdrawals/v1"; +import requestsWithdrawalsV2 from "./requests/withdrawals/v2"; import requestsWithdrawalsSignaturesV1 from "./requests/withdrawals-signatures/v1"; +import requestsWithdrawalsSignaturesV2 from "./requests/withdrawals-signatures/v2"; const endpoints = [ actionsDepositoryDepositsV1, @@ -34,9 +37,12 @@ const endpoints = [ queriesNonceMappingsV1, queriesConfigsV1, queriesWithdrawalRequestsV1, + queriesWithdrawalRequestsV2, requestsUnlocksV1, requestsWithdrawalsV1, + requestsWithdrawalsV2, requestsWithdrawalsSignaturesV1, + requestsWithdrawalsSignaturesV2, ] as Endpoint[]; export const setupEndpoints = (app: FastifyInstance) => { diff --git a/src/api/queries/configs/v1.ts b/src/api/queries/configs/v1.ts index ff7c0b7..933ee7c 100644 --- a/src/api/queries/configs/v1.ts +++ b/src/api/queries/configs/v1.ts @@ -34,7 +34,7 @@ export default { _req: FastifyRequestTypeBox, reply: FastifyReplyTypeBox ) => { - const { walletClient } = await getOnchainAllocator("ethereum"); + const { walletClient } = await getOnchainAllocator(); return reply.status(200).send({ configs: { diff --git a/src/api/queries/withdrawal-requests/v2.ts b/src/api/queries/withdrawal-requests/v2.ts new file mode 100644 index 0000000..235f9bf --- /dev/null +++ b/src/api/queries/withdrawal-requests/v2.ts @@ -0,0 +1,73 @@ +import { Type } from "@fastify/type-provider-typebox"; + +import { + Endpoint, + ErrorResponse, + FastifyReplyTypeBox, + FastifyRequestTypeBox, +} from "../../utils"; +import { + getOnchainAllocator, + getSignatureFromContract, +} from "../../../utils/onchain-allocator"; +import { logger } from "../../../common/logger"; +import { Hex } from "viem"; + +const Schema = { + params: Type.Object({ + payloadId: Type.String({ + description: "The payload id of the withdrawal request", + }), + chainId: Type.String({ + description: "The chain id of the depository", + }), + }), + response: { + 200: Type.Object({ + encodedData: Type.String({ + description: + "The depository payload to be executed on destination chain", + }), + signature: Type.Optional( + Type.String({ + description: + "The sign data hash to be passed to the depository on exeuction", + }) + ), + }), + ...ErrorResponse, + }, +}; + +export default { + method: "GET", + url: "/queries/withdrawal-requests/:payloadId/v2", + schema: Schema, + handler: async ( + req: FastifyRequestTypeBox, + reply: FastifyReplyTypeBox + ) => { + logger.info( + "tracking", + JSON.stringify({ + msg: "Querying if withdrawal exists from the Allocator contract", + data: req.body, + }) + ); + const { contract } = await getOnchainAllocator(); + const encodedData = await contract.read.payloads([ + req.params.payloadId as Hex, + ]); + + const signature = await getSignatureFromContract( + req.params.chainId, + req.params.payloadId, + encodedData + ); + + return reply.status(200).send({ + encodedData, + signature, + }); + }, +} as Endpoint; diff --git a/src/api/requests/withdrawals-signatures/v2.ts b/src/api/requests/withdrawals-signatures/v2.ts new file mode 100644 index 0000000..9a3a911 --- /dev/null +++ b/src/api/requests/withdrawals-signatures/v2.ts @@ -0,0 +1,75 @@ +import { Type } from "@fastify/type-provider-typebox"; + +import { + Endpoint, + ErrorResponse, + FastifyReplyTypeBox, + FastifyRequestTypeBox, + SubmitWithdrawalRequestParamsSchema, +} from "../../utils"; +import { logger } from "../../../common/logger"; +import { RequestHandlerService } from "../../../services/request-handler"; + +const Schema = { + body: Type.Object({ + chainId: Type.String({ + description: "The chain id of the withdrawal", + }), + payloadId: Type.String({ + description: "The payload id of the withdrawal request", + }), + payloadParams: SubmitWithdrawalRequestParamsSchema, + }), + response: { + 200: Type.Object({ + encodedData: Type.String({ + description: + "The depository payload to be executed on destination chain", + }), + signer: Type.String({ + description: "The (MPC) signer address from the allocator", + }), + signature: Type.Optional( + Type.String({ + description: + "The sign data hash to be passed to the depository on exeuction", + }) + ), + }), + ...ErrorResponse, + }, +}; + +export default { + method: "POST", + url: "/requests/withdrawals/signatures/v2", + schema: Schema, + handler: async ( + req: FastifyRequestTypeBox, + reply: FastifyReplyTypeBox + ) => { + logger.info( + "tracking", + JSON.stringify({ + msg: "Executing `withdrawal-signature` request (v2)", + data: req.body, + }) + ); + + const requestHandler = new RequestHandlerService(); + const result = await requestHandler.handleOnChainWithdrawalSignature( + req.body + ); + + logger.info( + "tracking", + JSON.stringify({ + msg: "Executed `withdrawal-signature` request", + data: req.body, + result, + }) + ); + + return reply.status(200).send(result); + }, +} as Endpoint; diff --git a/src/api/requests/withdrawals/v1.ts b/src/api/requests/withdrawals/v1.ts index 86b8edd..8270201 100644 --- a/src/api/requests/withdrawals/v1.ts +++ b/src/api/requests/withdrawals/v1.ts @@ -14,33 +14,6 @@ import { externalError } from "../../../common/error"; import { logger } from "../../../common/logger"; import { RequestHandlerService } from "../../../services/request-handler"; -const SubmitWithdrawalRequestParamsSchema = Type.Object({ - chainId: Type.String({ - description: "The chain id of the allocator", - }), - depository: Type.String({ - description: "The depository address of the allocator", - }), - currency: Type.String({ - description: "The currency to withdraw", - }), - amount: Type.String({ - description: "The amount to withdraw", - }), - spender: Type.String({ - description: "The address of the spender", - }), - receiver: Type.String({ - description: "The address of the receiver on the depository chain", - }), - data: Type.String({ - description: "The data to include in the withdrawal request", - }), - nonce: Type.String({ - description: "The nonce to include in the withdrawal request", - }), -}); - const Schema = { body: Type.Object({ mode: Type.Optional( @@ -66,9 +39,6 @@ const Schema = { description: "Signature attesting the owner authorized this particular withdrawal request", }), - submitWithdrawalRequestParams: Type.Optional( - SubmitWithdrawalRequestParamsSchema - ), additionalData: Type.Optional( Type.Object( { diff --git a/src/api/requests/withdrawals/v2.ts b/src/api/requests/withdrawals/v2.ts new file mode 100644 index 0000000..45d0370 --- /dev/null +++ b/src/api/requests/withdrawals/v2.ts @@ -0,0 +1,151 @@ +import { Type } from "@fastify/type-provider-typebox"; +import { createHash } from "crypto"; +import stringify from "json-stable-stringify"; +import { Address, Hex, verifyMessage } from "viem"; + +import { + Endpoint, + ErrorResponse, + FastifyReplyTypeBox, + FastifyRequestTypeBox, + SubmitWithdrawalRequestParamsSchema, +} from "../../utils"; +import { getChain } from "../../../common/chains"; +import { externalError } from "../../../common/error"; +import { logger } from "../../../common/logger"; +import { RequestHandlerService } from "../../../services/request-handler"; + +const Schema = { + body: Type.Object({ + chainId: Type.String({ + description: "The chain id to withdraw on", + }), + currency: Type.String({ + description: "The address of the currency to withdraw", + }), + amount: Type.String({ + description: "The amount to withdraw", + }), + recipient: Type.String({ + description: "The address of the recipient for the withdrawal proceeds", + }), + spender: Type.String({ + description: + "The address of the spender (usually the withdrawal address)", + }), + owner: Type.String({ + description: "The address of the owner (that triggered the withdrawal)", + }), + ownerChainId: Type.String({ description: "The chain id of the owner" }), + nonce: Type.String({ + description: + "The nonce to be used when submitting the withdrawal request to the allocator", + }), + proofOfWithdrawalAddressBalance: Type.String({ + description: + "The proof that withdrawal addres has funds returned by the oracle", + }), + signature: Type.String({ + description: + "Signature attesting the owner authorized this particular withdrawal request", + }), + additionalData: Type.Optional( + Type.Object( + { + "hyperliquid-vm": Type.Optional( + Type.Object({ + currencyHyperliquidSymbol: Type.String({ + description: "The Hyperliquid symbol for the currency", + }), + }) + ), + }, + { + description: + "Additional data needed for generating the withdrawal request", + } + ) + ), + }), + response: { + 200: Type.Object({ + id: Type.String({ description: "The id of the withdrawal" }), + encodedData: Type.String({ + description: + "The withdrawal data (encoded based on the withdrawing chain's vm type)", + }), + signer: Type.String({ description: "The signer of the withdrawal" }), + submitWithdrawalRequestParams: Type.Optional( + SubmitWithdrawalRequestParamsSchema + ), + signature: Type.Optional( + Type.String({ + description: "The allocator signature for the withdrawal", + }) + ), + }), + ...ErrorResponse, + }, +}; + +export default { + method: "POST", + url: "/requests/withdrawals/v2", + schema: Schema, + handler: async ( + req: FastifyRequestTypeBox, + reply: FastifyReplyTypeBox + ) => { + // make sure we got EVM sig + const signatureVmType = await getChain(req.body.ownerChainId).then( + (c) => c.vmType + ); + if (signatureVmType !== "ethereum-vm") { + throw externalError( + "Only 'ethereum-vm' signatures are supported", + "UNSUPPORTED_SIGNATURE" + ); + } + + // authentify the proof of withdrawal address balance + const hash = createHash("sha256") + .update(stringify(req.body.proofOfWithdrawalAddressBalance)!) + .digest("hex"); + + const isSignatureValid = await verifyMessage({ + address: req.body.owner as Address, + message: { + raw: hash as `0x${string}`, + }, + signature: req.body.signature as Hex, + }); + if (!isSignatureValid) { + throw externalError("Invalid signature", "INVALID_SIGNATURE"); + } + + logger.info( + "tracking", + JSON.stringify({ + msg: "Executing `withdrawal` request (v2)", + request: req.body, + }) + ); + + const requestHandler = new RequestHandlerService(); + + // Extract only the fields expected by the handler (exclude signature which is only for validation) + const { signature: _, ...requestBody } = req.body; + const result = await requestHandler.handleOnChainWithdrawal(requestBody); + + logger.info( + "tracking", + JSON.stringify({ + msg: "Executed `withdrawal` request", + request: req.body, + result, + }) + ); + + return reply.status(200).send(result); + }, +} as Endpoint; diff --git a/src/api/utils.ts b/src/api/utils.ts index faff6ad..257cee3 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -88,3 +88,31 @@ export const buildContinuation = (...components: string[]) => export const splitContinuation = (continuation: string) => Buffer.from(continuation, "base64").toString("ascii").split("_"); + +// schema for allocator submitWithdrawRequest +export const SubmitWithdrawalRequestParamsSchema = Type.Object({ + chainId: Type.String({ + description: "The chain id of the allocator", + }), + depository: Type.String({ + description: "The depository address of the allocator", + }), + currency: Type.String({ + description: "The currency to withdraw", + }), + amount: Type.String({ + description: "The amount to withdraw", + }), + spender: Type.String({ + description: "The address of the spender", + }), + receiver: Type.String({ + description: "The address of the receiver on the depository chain", + }), + data: Type.String({ + description: "The data to include in the withdrawal request", + }), + nonce: Type.String({ + description: "The nonce to include in the withdrawal request", + }), +}); diff --git a/src/services/request-handler/index.ts b/src/services/request-handler/index.ts index bdb5b1d..ba07f74 100644 --- a/src/services/request-handler/index.ts +++ b/src/services/request-handler/index.ts @@ -39,6 +39,7 @@ import { logger } from "../../common/logger"; import { getOnchainAllocator, getSignature, + getSignatureFromContract, handleOneTimeApproval, } from "../../utils/onchain-allocator"; import { config } from "../../config"; @@ -53,6 +54,7 @@ import { saveWithdrawalRequest, } from "../../models/withdrawal-requests"; +// TODO: move to SDK type AdditionalDataBitcoinVm = { allocatorUtxos: { txid: string; vout: number; value: string }[]; relayer: string; @@ -64,6 +66,42 @@ type AdditionalDataHyperliquidVm = { currencyHyperliquidSymbol: string; }; +type AllocatorSubmitRequestParams = { + chainId: string; + currency: string; + amount: string; + recipient: string; + spender?: string; + nonce?: string; + additionalData?: { + "hyperliquid-vm"?: AdditionalDataHyperliquidVm; + }; +}; + +type OnchainWithdrawalRequest = { + data: AllocatorSubmitRequestParams; + result: { + id: string; + encodedData: string; + payloadId: string; + submitWithdrawalRequestParams: PayloadParams; + signer: string; + }; +}; + +type OnChainWithdrawalSignatureRequest = { + data: { + chainId: string; + payloadId: string; + payloadParams: PayloadParams; + }; + result: { + encodedData: string; + signer: string; + signature?: string; + }; +}; + type WithdrawalRequest = { mode?: "offchain" | "onchain"; ownerChainId: string; @@ -550,7 +588,6 @@ export class RequestHandlerService { encodedData, submitWithdrawalRequestParams: payloadParams, signature, - submitWithdrawRequestParams: payloadParams, signer: request.mode === "onchain" ? await getOnchainAllocatorForChain(request.chainId) @@ -558,6 +595,69 @@ export class RequestHandlerService { }; } + public async handleOnChainWithdrawal( + request: OnchainWithdrawalRequest["data"] + ): Promise { + let id: string; + let encodedData: string; + + let payloadId: string | undefined; + let payloadParams: PayloadParams | undefined; + + const chain = await getChain(request.chainId); + switch (chain.vmType) { + case "ethereum-vm": { + ({ id, encodedData, payloadId, payloadParams } = + await this._submitWithdrawRequest(chain, request)); + break; + } + + case "solana-vm": { + ({ id, encodedData, payloadId, payloadParams } = + await this._submitWithdrawRequest(chain, request)); + break; + } + + case "bitcoin-vm": { + throw externalError("Onchain allocator mode not implemented"); + } + + case "hyperliquid-vm": { + const isNativeCurrency = + request.currency === getVmTypeNativeCurrency(chain.vmType); + if (!isNativeCurrency) { + const additionalData = request.additionalData?.["hyperliquid-vm"]; + if (!additionalData) { + throw externalError( + "Additional data is required for generating the withdrawal request" + ); + } + } + + ({ id, encodedData, payloadId, payloadParams } = + await this._submitWithdrawRequest(chain, request)); + + break; + } + + case "tron-vm": { + throw externalError("Onchain allocator mode not implemented"); + } + + default: { + throw externalError("Vm type not implemented"); + } + } + + return { + id, + encodedData, + payloadId, + submitWithdrawalRequestParams: payloadParams, + signer: await getOnchainAllocatorForChain(request.chainId), + }; + } + public async handleWithdrawalSignature(request: WithdrawalSignatureRequest) { const withdrawalRequest = await getWithdrawalRequest(request.id); if (!withdrawalRequest) { @@ -568,10 +668,7 @@ export class RequestHandlerService { } // will throw if withdrawal is not ready - this._withdrawalIsReady( - withdrawalRequest.chainId, - withdrawalRequest.payloadId - ); + this._withdrawalIsReady(withdrawalRequest.payloadId); // Lock the balance (if we don't already have a lock on it) if (!(await getBalanceLock(withdrawalRequest.id))) { @@ -601,11 +698,46 @@ export class RequestHandlerService { // Only trigger the signing process if we don't already have a valid signature const signature = await getSignature(withdrawalRequest.id); if (!signature) { - this._signPayload( - withdrawalRequest.chainId, - withdrawalRequest.payloadParams - ); + this._signPayload(withdrawalRequest.payloadParams); + } + } + + public async handleOnChainWithdrawalSignature( + request: OnChainWithdrawalSignatureRequest["data"] + ): Promise { + // will throw if withdrawal is not ready + this._withdrawalIsReady(request.payloadId); + + // get data from the contract + const { contract } = await getOnchainAllocator(); + const encodedData = await contract.read.payloads([ + request.payloadId as Hex, + ]); + + // get signer address from near MPC + const signer = await getOnchainAllocatorForChain(request.chainId); + + // check if signature already exists + const signature = await getSignatureFromContract( + request.chainId, + request.payloadId, + encodedData + ); + + // if not get one + if (!signature) { + this._signPayload(request.payloadParams); + return { + encodedData, + signer, + }; } + + return { + encodedData, + signature, + signer, + }; } public async handleUnlock(request: UnlockRequest) { @@ -638,52 +770,54 @@ export class RequestHandlerService { private _parseAllocatorPayloadParams( vmType: string, - chain: Chain, - request: WithdrawalRequest, - spender: string + depository: string, + allocatorChainId: string, + currency: string, + amount: string, + recipient: string, + spender: string, + nonce?: string, + additionalData?: { + "hyperliquid-vm"?: AdditionalDataHyperliquidVm; + } ): PayloadParams { + const defaultParams = { + chainId: allocatorChainId!, + depository: depository!.toLowerCase(), + currency: currency.toLowerCase(), + spender: spender.toLowerCase(), + receiver: recipient.toLowerCase(), + amount: amount, + data: "0x", + nonce: nonce || `0x${randomBytes(32).toString("hex")}`, + }; + switch (vmType) { case "ethereum-vm": { - return { - chainId: chain.metadata.allocatorChainId!, - depository: chain.depository!.toLowerCase(), - currency: request.currency.toLowerCase(), - amount: request.amount, - spender: spender.toLowerCase(), - receiver: request.recipient.toLowerCase(), - data: "0x", - nonce: `0x${randomBytes(32).toString("hex")}`, - }; + return defaultParams; } case "solana-vm": { // The "solana-vm" payload builder expects addresses to be hex-encoded const toHexString = (address: string) => new PublicKey(address).toBuffer().toString("hex"); - return { - chainId: chain.metadata.allocatorChainId!, - depository: chain.depository!, + ...defaultParams, currency: - request.currency === getVmTypeNativeCurrency(vmType) + currency === getVmTypeNativeCurrency(vmType) ? "" - : toHexString(request.currency), - amount: request.amount, - spender: spender.toLowerCase(), - receiver: toHexString(request.recipient), - data: "0x", - nonce: `0x${randomBytes(32).toString("hex")}`, + : toHexString(currency), + receiver: toHexString(recipient), }; } case "hyperliquid-vm": { - const isNativeCurrency = - request.currency === getVmTypeNativeCurrency(vmType); + const isNativeCurrency = currency === getVmTypeNativeCurrency(vmType); const currencyDex = - request.currency.slice(34) === "" + currency.slice(34) === "" ? "spot" - : Buffer.from(request.currency.slice(34), "hex").toString("ascii"); + : Buffer.from(currency.slice(34), "hex").toString("ascii"); const data = isNativeCurrency ? encodeAbiParameters([{ type: "uint64" }], [BigInt(Date.now())]) : encodeAbiParameters( @@ -692,19 +826,13 @@ export class RequestHandlerService { ); return { - chainId: chain.metadata.allocatorChainId!, - depository: chain.depository!, + ...defaultParams, currency: isNativeCurrency ? "" : `${ - request.additionalData!["hyperliquid-vm"]! - .currencyHyperliquidSymbol - }:${request.currency.toLowerCase()}`, - amount: request.amount, - spender: spender.toLowerCase(), - receiver: request.recipient.toLowerCase(), + additionalData!["hyperliquid-vm"]!.currencyHyperliquidSymbol + }:${currency.toLowerCase()}`, data, - nonce: `0x${randomBytes(32).toString("hex")}`, }; } @@ -715,27 +843,34 @@ export class RequestHandlerService { } private async _submitWithdrawRequest( - chain: Awaited>, - request: WithdrawalRequest + chain: Chain, + request: AllocatorSubmitRequestParams ): Promise<{ id: string; encodedData: string; payloadId: string; payloadParams: PayloadParams; }> { - const { contract, publicClient, walletClient } = await getOnchainAllocator( - request.chainId - ); + const { contract, publicClient, walletClient } = + await getOnchainAllocator(); + + if (!request.spender) { + request.spender = walletClient.account.address; + } const payloadParams = this._parseAllocatorPayloadParams( chain.vmType, - chain, - request, - walletClient.account.address + chain.depository!, + chain.metadata.allocatorChainId!, + request.currency, + request.amount, + request.recipient, + request.spender, + request.nonce ); // This is needed before being able to submit withdraw requests - await handleOneTimeApproval(request.chainId); + await handleOneTimeApproval(); const txHash = await contract.write.submitWithdrawRequest([ payloadParams as any, @@ -772,8 +907,8 @@ export class RequestHandlerService { }; } - private async _withdrawalIsReady(chainId: string, payloadId: string) { - const { contract, publicClient } = await getOnchainAllocator(chainId); + private async _withdrawalIsReady(payloadId: string) { + const { contract, publicClient } = await getOnchainAllocator(); const payloadTimestamp = await contract.read.payloadTimestamps([ payloadId as Hex, @@ -786,8 +921,8 @@ export class RequestHandlerService { } } - private async _signPayload(chainId: string, payloadParams: PayloadParams) { - const { contract } = await getOnchainAllocator(chainId); + private async _signPayload(payloadParams: PayloadParams) { + const { contract } = await getOnchainAllocator(); // TODO: Once we integrate Bitcoin we might need to make multiple calls await contract.write.signWithdrawPayloadHash([ diff --git a/src/utils/onchain-allocator.ts b/src/utils/onchain-allocator.ts index c470a8d..f3de77d 100644 --- a/src/utils/onchain-allocator.ts +++ b/src/utils/onchain-allocator.ts @@ -85,7 +85,7 @@ const getPayloadBuilder = async (address: string) => { }; }; -export const getOnchainAllocator = async (_chainId: string) => { +export const getOnchainAllocator = async () => { const allocator = config.onchainAllocator; if (!allocator) { throw externalError("Onchain allocator not configured"); @@ -116,12 +116,10 @@ export const getOnchainAllocator = async (_chainId: string) => { }; let _allowanceCache: bigint | undefined; -export const handleOneTimeApproval = async (chainId: string) => { +export const handleOneTimeApproval = async () => { const { walletClient } = await getPublicAndWalletClients(); - const allocator = await getOnchainAllocator(chainId).then( - (a) => a.contract.address - ); + const allocator = await getOnchainAllocator().then((a) => a.contract.address); const wNearContract = getContract({ client: walletClient, @@ -198,7 +196,7 @@ export const getSigner = async (chainId: string) => { } } - const { contract } = await getOnchainAllocator(chainId); + const { contract } = await getOnchainAllocator(); const args = { domain_id: domainId, @@ -255,7 +253,7 @@ export const getSignatureFromContract = async ( ); } - const onchainAllocator = await getOnchainAllocator(chain.id); + const onchainAllocator = await getOnchainAllocator(); const payloadBuilderAddress = await onchainAllocator.contract.read.payloadBuilders([ BigInt(chain.metadata.allocatorChainId),