diff --git a/README.md b/README.md index e432e01..40be82f 100644 --- a/README.md +++ b/README.md @@ -61,14 +61,14 @@ console.log(`Status: ${status}`); // 'pending' | 'completed' | 'failed' #### Core Transfer Interface - [ ] Base OmniTransfer interface - - [ ] EVM - - [ ] initTransfer - - [ ] finalizeTransfer - - [ ] NEAR + - [x] EVM - [x] initTransfer - - [ ] finalizeTransfer + - [x] finalizeTransfer + - [x] NEAR + - [x] initTransfer + - [x] finalizeTransfer - [ ] Solana - - [ ] initTransfer + - [x] initTransfer - [ ] finalizeTransfer #### Query Functions diff --git a/src/deployer/evm.ts b/src/deployer/evm.ts index c1673c0..ad7c316 100644 --- a/src/deployer/evm.ts +++ b/src/deployer/evm.ts @@ -1,5 +1,13 @@ import { ethers } from "ethers" -import type { ChainKind, MPCSignature, OmniAddress, TokenMetadata, U128 } from "../types" +import type { + BridgeDeposit, + ChainKind, + MPCSignature, + OmniAddress, + TokenMetadata, + TransferMessagePayload, + U128, +} from "../types" import { getChain } from "../utils" // Type helpers for EVM chains @@ -193,4 +201,50 @@ export class EVMDeployer { ) } } + + /** + * Finalizes a transfer on the EVM chain by minting/unlocking tokens. + * @param transferMessage - The transfer message payload from NEAR + * @param signature - MPC signature authorizing the transfer + * @returns Promise resolving to the transaction hash + */ + async finalizeTransfer( + transferMessage: TransferMessagePayload, + signature: MPCSignature, + ): Promise { + // Convert the transfer message to EVM-compatible format + const bridgeDeposit: BridgeDeposit = { + destination_nonce: transferMessage.destination_nonce, + origin_chain: Number(transferMessage.transfer_id.origin_chain), + origin_nonce: transferMessage.transfer_id.origin_nonce, + token_address: this.extractEvmAddress(transferMessage.token_address), + amount: BigInt(transferMessage.amount), + recipient: this.extractEvmAddress(transferMessage.recipient), + fee_recipient: transferMessage.fee_recipient ?? "", + } + + try { + const tx = await this.factory.finTransfer(signature.toBytes(true), bridgeDeposit) + const receipt = await tx.wait() + return receipt.hash + } catch (error) { + throw new Error( + `Failed to finalize transfer: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } + } + + /** + * Helper method to extract EVM address from OmniAddress + * @param omniAddress - The OmniAddress to extract from + * @returns The EVM address + */ + private extractEvmAddress(omniAddress: OmniAddress): string { + const chain = getChain(omniAddress) + const [_, address] = omniAddress.split(":") + if (!ChainUtils.isEVMChain(chain)) { + throw new Error(`Invalid EVM address: ${omniAddress}`) + } + return address + } } diff --git a/src/deployer/near.ts b/src/deployer/near.ts index f54b9e3..f3c0a97 100644 --- a/src/deployer/near.ts +++ b/src/deployer/near.ts @@ -3,11 +3,14 @@ import type { Account } from "near-api-js" import { type AccountId, type BindTokenArgs, + BindTokenArgsSchema, ChainKind, type DeployTokenArgs, DeployTokenArgsSchema, type EvmVerifyProofArgs, EvmVerifyProofArgsSchema, + type FinTransferArgs, + FinTransferArgsSchema, type LogMetadataArgs, type OmniAddress, ProofKind, @@ -27,6 +30,7 @@ const GAS = { DEPLOY_TOKEN: BigInt(1.2e14), // 1.2 TGas BIND_TOKEN: BigInt(3e14), // 3 TGas INIT_TRANSFER: BigInt(3e14), // 3 TGas + FIN_TRANSFER: BigInt(3e14), // 3 TGas STORAGE_DEPOSIT: BigInt(1e14), // 1 TGas } as const @@ -40,6 +44,7 @@ const DEPOSIT = { DEPLOY_TOKEN: BigInt(4e24), // 4 NEAR BIND_TOKEN: BigInt(2e23), // 0.2 NEAR INIT_TRANSFER: BigInt(1), // 1 yoctoNEAR + FIN_TRANSFER: BigInt(1), // 1 yoctoNEAR } as const /** @@ -67,11 +72,15 @@ interface InitTransferMessage { * Interface representing the results of various balance queries * @property regBalance - Required balance for account registration * @property initBalance - Required balance for initializing transfers + * @property finBalance - Required balance for finalizing transfers + * @property bindBalance - Required balance for binding tokens * @property storage - Current storage deposit balance information */ interface BalanceResults { regBalance: bigint initBalance: bigint + finBalance: bigint + bindBalance: bigint storage: StorageDeposit } @@ -203,10 +212,11 @@ export class NearDeployer { chain_kind: sourceChain, prover_args: proverArgsSerialized, } + const serializedArgs = borshSerialize(BindTokenArgsSchema, args) const tx = await this.wallet.functionCall({ contractId: this.lockerAddress, methodName: "bind_token", - args, + args: serializedArgs, gas: GAS.BIND_TOKEN, attachedDeposit: DEPOSIT.BIND_TOKEN, }) @@ -276,6 +286,81 @@ export class NearDeployer { } } + /** + * Finalizes a cross-chain token transfer on NEAR by processing the transfer proof and managing storage deposits. + * Supports both Wormhole VAA and EVM proof verification for transfers from supported chains. + * + * @param token - The token identifier on NEAR where transferred tokens will be minted + * @param account - The recipient account ID on NEAR + * @param storageDepositAmount - Amount of NEAR tokens for storage deposit (in yoctoNEAR) + * @param sourceChain - The originating chain of the transfer + * @param vaa - Optional Wormhole Verified Action Approval containing transfer information + * @param evmProof - Optional proof data for transfers from EVM-compatible chains + * + * @throws {Error} When neither VAA nor EVM proof is provided + * @throws {Error} When EVM proof is provided for non-EVM chains (only valid for Ethereum, Arbitrum, or Base) + * + * @returns Promise resolving to the finalization transaction hash + * + */ + async finalizeTransfer( + token: string, + account: string, + storageDepositAmount: U128, + sourceChain: ChainKind, + vaa?: string, + evmProof?: EvmVerifyProofArgs, + ): Promise { + if (!vaa && !evmProof) { + throw new Error("Must provide either VAA or EVM proof") + } + if (evmProof) { + if ( + sourceChain !== ChainKind.Eth && + sourceChain !== ChainKind.Arb && + sourceChain !== ChainKind.Base + ) { + throw new Error("EVM proof is only valid for Ethereum, Arbitrum, or Base") + } + } + let proverArgsSerialized: Uint8Array = new Uint8Array(0) + if (vaa) { + const proverArgs: WormholeVerifyProofArgs = { + proof_kind: ProofKind.DeployToken, + vaa: vaa, + } + proverArgsSerialized = borshSerialize(WormholeVerifyProofArgsSchema, proverArgs) + } else if (evmProof) { + const proverArgs: EvmVerifyProofArgs = { + proof_kind: ProofKind.DeployToken, + proof: evmProof.proof, + } + proverArgsSerialized = borshSerialize(EvmVerifyProofArgsSchema, proverArgs) + } + + const args: FinTransferArgs = { + chain_kind: sourceChain, + storage_deposit_actions: [ + { + token_id: token, + account_id: account, + storage_deposit_amount: storageDepositAmount, + }, + ], + prover_args: proverArgsSerialized, + } + const serializedArgs = borshSerialize(FinTransferArgsSchema, args) + + const tx = await this.wallet.functionCall({ + contractId: this.lockerAddress, + methodName: "finalize_transfer", + args: serializedArgs, + gas: GAS.FIN_TRANSFER, + attachedDeposit: DEPOSIT.FIN_TRANSFER, + }) + return tx.transaction.hash + } + /** * Retrieves various balance information for the current account * @private @@ -284,23 +369,38 @@ export class NearDeployer { */ private async getBalances(): Promise { try { - const [regBalanceStr, initBalanceStr, storage] = await Promise.all([ - this.wallet.viewFunction({ - contractId: this.lockerAddress, - methodName: "required_balance_for_account", - }), - this.wallet.viewFunction({ - contractId: this.lockerAddress, - methodName: "required_balance_for_init_transfer", - }), - this.wallet.viewFunction({ - contractId: this.lockerAddress, - methodName: "storage_balance_of", - args: { - account_id: this.wallet.accountId, - }, - }), - ]) + const [regBalanceStr, initBalanceStr, finBalanceStr, bindBalanceStr, storage] = + await Promise.all([ + this.wallet.viewFunction({ + contractId: this.lockerAddress, + methodName: "required_balance_for_account", + }), + this.wallet.viewFunction({ + contractId: this.lockerAddress, + methodName: "required_balance_for_init_transfer", + }), + this.wallet.viewFunction({ + contractId: this.lockerAddress, + methodName: "required_balance_for_fin_transfer", + args: { + account_id: this.wallet.accountId, + }, + }), + this.wallet.viewFunction({ + contractId: this.lockerAddress, + methodName: "required_balance_for_bind_token", + args: { + account_id: this.wallet.accountId, + }, + }), + this.wallet.viewFunction({ + contractId: this.lockerAddress, + methodName: "storage_balance_of", + args: { + account_id: this.wallet.accountId, + }, + }), + ]) // Convert storage balance to bigint let convertedStorage = null @@ -314,6 +414,8 @@ export class NearDeployer { return { regBalance: BigInt(regBalanceStr), initBalance: BigInt(initBalanceStr), + finBalance: BigInt(finBalanceStr), + bindBalance: BigInt(bindBalanceStr), storage: convertedStorage, } } catch (error) { diff --git a/src/types/common.ts b/src/types/common.ts index 774f903..367e1c6 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -1,6 +1,5 @@ export type U128 = bigint export type Nonce = bigint -export type TransferId = string export type AccountId = string export type Fee = bigint export type OmniAddress = diff --git a/src/types/evm.ts b/src/types/evm.ts new file mode 100644 index 0000000..7431303 --- /dev/null +++ b/src/types/evm.ts @@ -0,0 +1,34 @@ +import type { ChainKind } from "./chain" +import type { Nonce, OmniAddress, U128 } from "./common" + +export enum PayloadType { + TransferMessage = "TransferMessage", + Metadata = "Metadata", + ClaimNativeFee = "ClaimNativeFee", +} + +export interface TransferId { + origin_chain: ChainKind + origin_nonce: Nonce +} + +// bridge deposit structure for evm chains +export type BridgeDeposit = { + destination_nonce: Nonce + origin_chain: number // u8 in rust + origin_nonce: Nonce + token_address: string // evm address + amount: bigint // uint128 in solidity + recipient: string // evm address + fee_recipient: string +} + +export type TransferMessagePayload = { + prefix: PayloadType + destination_nonce: Nonce + transfer_id: TransferId + token_address: OmniAddress + amount: U128 + recipient: OmniAddress + fee_recipient: string | null // NEAR AccountId or null +} diff --git a/src/types/index.ts b/src/types/index.ts index fc5af04..4454181 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,3 +4,4 @@ export * from "./locker" export * from "./omni" export * from "./prover" export * from "./mpc" +export * from "./evm" diff --git a/src/types/prover.ts b/src/types/prover.ts index 8f8e421..3e04396 100644 --- a/src/types/prover.ts +++ b/src/types/prover.ts @@ -1,5 +1,5 @@ import { BorshSchema } from "borsher" -import type { AccountId, Fee, Nonce, OmniAddress, TransferId, U128 } from "./common" +import type { AccountId, Fee, Nonce, OmniAddress, U128 } from "./common" export enum ProofKind { InitTransfer = 0, @@ -38,7 +38,7 @@ export const InitTransferMessageSchema = BorshSchema.Struct({ }) export type FinTransferMessage = { - transfer_id: TransferId + transfer_id: string fee_recipient: AccountId amount: U128 emitter_address: OmniAddress diff --git a/tests/chains/near.test.ts b/tests/chains/near.test.ts index 0c184fe..6c00c9b 100644 --- a/tests/chains/near.test.ts +++ b/tests/chains/near.test.ts @@ -1,7 +1,7 @@ import type { Account } from "near-api-js" import { beforeEach, describe, expect, it, vi } from "vitest" import { NearDeployer } from "../../src/deployer/near" -import { ChainKind } from "../../src/types" +import { ChainKind, ProofKind } from "../../src/types" // Mock the entire borsher module vi.mock("borsher", () => ({ @@ -13,7 +13,6 @@ vi.mock("borsher", () => ({ Struct: vi.fn().mockReturnValue({}), Option: vi.fn().mockReturnValue({}), Vec: vi.fn().mockReturnValue({}), - // Add any other schema types you're using }, })) @@ -102,16 +101,103 @@ describe("NearDeployer", () => { expect(mockWallet.functionCall).toHaveBeenCalledWith({ contractId: mockLockerAddress, methodName: "bind_token", - args: { - chain_kind: destinationChain, - prover_args: expect.any(Uint8Array), - }, + args: Uint8Array.from([1, 2, 3]), gas: BigInt(3e14), attachedDeposit: BigInt(2e23), }) expect(txHash).toBe(mockTxHash) }) }) + describe("finalizeTransfer", () => { + const mockToken = "test-token.near" + const mockAccount = "recipient.near" + const mockStorageDeposit = BigInt(1000000000000000000000000) + const mockVaa = "mock-vaa" + const mockEvmProof = { + proof_kind: ProofKind.FinTransfer, + proof: { + log_index: BigInt(1), + log_entry_data: new Uint8Array([1, 2, 3]), + receipt_index: BigInt(0), + receipt_data: new Uint8Array([4, 5, 6]), + header_data: new Uint8Array([7, 8, 9]), + proof: [new Uint8Array([10, 11, 12])], + }, + } + + it("should throw error if neither VAA nor EVM proof is provided", async () => { + await expect( + deployer.finalizeTransfer(mockToken, mockAccount, mockStorageDeposit, ChainKind.Near), + ).rejects.toThrow("Must provide either VAA or EVM proof") + }) + + it("should throw error if EVM proof is provided for non-EVM chain", async () => { + await expect( + deployer.finalizeTransfer( + mockToken, + mockAccount, + mockStorageDeposit, + ChainKind.Near, + undefined, + mockEvmProof, + ), + ).rejects.toThrow("EVM proof is only valid for Ethereum, Arbitrum, or Base") + }) + + it("should call finalize_transfer with VAA correctly", async () => { + const txHash = await deployer.finalizeTransfer( + mockToken, + mockAccount, + mockStorageDeposit, + ChainKind.Sol, + mockVaa, + ) + + expect(mockWallet.functionCall).toHaveBeenCalledWith({ + contractId: mockLockerAddress, + methodName: "finalize_transfer", + args: expect.any(Uint8Array), + gas: BigInt(3e14), + attachedDeposit: BigInt(1), + }) + expect(txHash).toBe(mockTxHash) + }) + + it("should call finalize_transfer with EVM proof correctly", async () => { + const txHash = await deployer.finalizeTransfer( + mockToken, + mockAccount, + mockStorageDeposit, + ChainKind.Eth, + undefined, + mockEvmProof, + ) + + expect(mockWallet.functionCall).toHaveBeenCalledWith({ + contractId: mockLockerAddress, + methodName: "finalize_transfer", + args: expect.any(Uint8Array), + gas: BigInt(3e14), + attachedDeposit: BigInt(1), + }) + expect(txHash).toBe(mockTxHash) + }) + + it("should handle errors from functionCall", async () => { + const error = new Error("NEAR finalize transfer error") + mockWallet.functionCall = vi.fn().mockRejectedValue(error) + + await expect( + deployer.finalizeTransfer( + mockToken, + mockAccount, + mockStorageDeposit, + ChainKind.Sol, + mockVaa, + ), + ).rejects.toThrow("NEAR finalize transfer error") + }) + }) describe("error handling", () => { it("should propagate errors from functionCall", async () => {