Skip to content

Commit

Permalink
Add support for sign_transfer (#45)
Browse files Browse the repository at this point in the history
* Add support for sign_transfer

* docs(changeset): Support sign_transfer for NEAR

* Support sign transfer

* Cleanup

* Remove this

* Remove this

* Update README

* README cleanup

* Update changelog

* Formatting
  • Loading branch information
r-near authored Jan 29, 2025
1 parent 0e1307d commit ecbbe1d
Show file tree
Hide file tree
Showing 9 changed files with 189 additions and 19 deletions.
22 changes: 22 additions & 0 deletions .changeset/thirty-spies-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"omni-bridge-sdk": minor
---

feat(near): Implement end-to-end transfer signing flow

### Added

- `signTransfer` method in NearBridgeClient to authorize transfers after initialization
- New event types (`InitTransferEvent`, `SignTransferEvent`) for tracking NEAR transfer lifecycle
- Automatic storage deposit handling for token contracts interacting with the locker

### Changed

- `initTransfer` on NEAR now returns structured event data instead of raw tx hash
- Updated transfer flow documentation with NEAR-specific examples
- Unified BigInt handling across EVM/Solana clients for consistency

### Breaking Changes

- NEAR `initTransfer` return type changed from `string` to `InitTransferEvent`
- NEAR transfers now require explicit `signTransfer` call after initialization
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ const transfer = {

// Initiate transfer on source chain
const result = await omniTransfer(account, transfer);
console.log(`Transfer initiated with txId: ${result.txId}`);

// Sign transfer on NEAR
const nearClient = getClient(ChainKind.Near, account);
const { signature } = await nearClient.signTransfer(result, "sender.near");

// 3. Monitor status
let status;
Expand Down Expand Up @@ -116,7 +119,7 @@ Use `omniTransfer` to start the transfer on the source chain:

```typescript
const result = await omniTransfer(wallet, transfer);
// Returns: { txId: string, nonce: bigint }
// Returns: { txId: string, nonce: bigint } or InitTransferEvent for NEAR
```

### 2. Status Monitoring
Expand Down
4 changes: 2 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { Account as NearAccount } from "near-api-js"
import { EvmBridgeClient } from "./clients/evm"
import { NearBridgeClient } from "./clients/near"
import { SolanaBridgeClient } from "./clients/solana"
import { ChainKind, type OmniTransferMessage } from "./types"
import { ChainKind, type InitTransferEvent, type OmniTransferMessage } from "./types"

type Client = EvmBridgeClient | NearBridgeClient | SolanaBridgeClient

export async function omniTransfer(
wallet: EthWallet | NearAccount | SolWallet,
transfer: OmniTransferMessage,
): Promise<string> {
): Promise<string | InitTransferEvent> {
let client: Client | null = null
if (wallet instanceof NearAccount) {
client = new NearBridgeClient(wallet)
Expand Down
4 changes: 2 additions & 2 deletions src/clients/evm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,9 @@ export class EvmBridgeClient {
): Promise<string> {
// Convert the transfer message to EVM-compatible format
const bridgeDeposit: BridgeDeposit = {
destination_nonce: transferMessage.destination_nonce,
destination_nonce: BigInt(transferMessage.destination_nonce),
origin_chain: Number(transferMessage.transfer_id.origin_chain),
origin_nonce: transferMessage.transfer_id.origin_nonce,
origin_nonce: BigInt(transferMessage.transfer_id.origin_nonce),
token_address: this.extractEvmAddress(transferMessage.token_address),
amount: BigInt(transferMessage.amount),
recipient: this.extractEvmAddress(transferMessage.recipient),
Expand Down
119 changes: 114 additions & 5 deletions src/clients/near.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ import {
EvmVerifyProofArgsSchema,
type FinTransferArgs,
FinTransferArgsSchema,
type InitTransferEvent,
type LogMetadataArgs,
type LogMetadataEvent,
type OmniAddress,
type OmniTransferMessage,
ProofKind,
type SignTransferArgs,
type SignTransferEvent,
type U128,
type WormholeVerifyProofArgs,
WormholeVerifyProofArgsSchema,
Expand All @@ -34,6 +37,7 @@ const GAS = {
BIND_TOKEN: BigInt(3e14), // 3 TGas
INIT_TRANSFER: BigInt(3e14), // 3 TGas
FIN_TRANSFER: BigInt(3e14), // 3 TGas
SIGN_TRANSFER: BigInt(3e14), // 3 TGas
STORAGE_DEPOSIT: BigInt(1e14), // 1 TGas
} as const

Expand All @@ -46,6 +50,7 @@ const DEPOSIT = {
LOG_METADATA: BigInt(2e23), // 0.2 NEAR
DEPLOY_TOKEN: BigInt(4e24), // 4 NEAR
BIND_TOKEN: BigInt(2e23), // 0.2 NEAR
SIGN_TRANSFER: BigInt(5e23), // 0.5 NEAR
INIT_TRANSFER: BigInt(1), // 1 yoctoNEAR
FIN_TRANSFER: BigInt(1), // 1 yoctoNEAR
} as const
Expand All @@ -58,7 +63,7 @@ type StorageDeposit = {
available: bigint
} | null

interface TransferMessage {
interface InitTransferMessageArgs {
receiver_id: AccountId
memo: string | null
amount: string
Expand Down Expand Up @@ -266,15 +271,19 @@ export class NearBridgeClient {
* @param recipient - Recipient's Omni address on the destination chain where tokens will be minted
* @param amount - Amount of NEP-141 tokens to transfer
* @throws {Error} If token address is not on NEAR chain
* @returns Promise resolving to transaction hash
* @returns Promise resolving to InitTransferEvent
*/

async initTransfer(transfer: OmniTransferMessage): Promise<string> {
async initTransfer(transfer: OmniTransferMessage): Promise<InitTransferEvent> {
if (getChain(transfer.tokenAddress) !== ChainKind.Near) {
throw new Error("Token address must be on NEAR")
}
const tokenAddress = transfer.tokenAddress.split(":")[1]

// First, check if the FT has the token locker contract registered for storage
await this.storageDepositForToken(tokenAddress)

// Now do the storage deposit dance for the locker itself
const { regBalance, initBalance, storage } = await this.getBalances()
const requiredBalance = regBalance + initBalance
const existingBalance = storage?.available ?? BigInt(0)
Expand All @@ -295,7 +304,7 @@ export class NearBridgeClient {
fee: transfer.fee.toString(),
native_token_fee: transfer.nativeFee.toString(),
}
const args: TransferMessage = {
const args: InitTransferMessageArgs = {
receiver_id: this.lockerAddress,
amount: transfer.amount.toString(),
memo: null,
Expand All @@ -309,7 +318,73 @@ export class NearBridgeClient {
attachedDeposit: DEPOSIT.INIT_TRANSFER,
})

return tx.transaction.hash
// Parse event from transaction logs
const event = tx.receipts_outcome
.flatMap((receipt) => receipt.outcome.logs)
.find((log) => log.includes("InitTransferEvent"))

if (!event) {
throw new Error("InitTransferEvent not found in transaction logs")
}
return JSON.parse(event).InitTransferEvent
}

/**
* Signs transfer using the token locker
* @param initTransferEvent - Transfer event of the previously-initiated transfer
* @param feeRecipient - Address of the fee recipient, can be the original sender or a relayer
* @returns Promise resolving to the transaction hash
*/
async signTransfer(
initTransferEvent: InitTransferEvent,
feeRecipient: AccountId,
): Promise<SignTransferEvent> {
const MAX_POLLING_ATTEMPTS = 60 // 60 seconds timeout
const POLLING_INTERVAL = 1000 // 1 second between attempts

const args: SignTransferArgs = {
transfer_id: {
origin_chain: "Near",
origin_nonce: initTransferEvent.transfer_message.origin_nonce,
},
fee_recipient: feeRecipient,
fee: {
fee: initTransferEvent.transfer_message.fee.fee,
native_fee: initTransferEvent.transfer_message.fee.native_fee,
},
}

// Need to use signTransaction due to NEAR API limitations around timeouts
// @ts-expect-error: Account.signTransaction is protected but necessary here
const [txHash, signedTx] = await this.wallet.signTransaction(this.lockerAddress, [
functionCall("sign_transfer", args, GAS.SIGN_TRANSFER, DEPOSIT.SIGN_TRANSFER),
])

const provider = this.wallet.connection.provider
let outcome = await provider.sendTransactionAsync(signedTx)

// Poll for transaction execution
let attempts = 0
while (outcome.final_execution_status !== "EXECUTED" && attempts < MAX_POLLING_ATTEMPTS) {
await new Promise((resolve) => setTimeout(resolve, POLLING_INTERVAL))
outcome = await provider.txStatus(txHash, this.wallet.accountId, "INCLUDED")
attempts++
}

if (attempts >= MAX_POLLING_ATTEMPTS) {
throw new Error(`Transaction polling timed out after ${MAX_POLLING_ATTEMPTS} seconds`)
}

// Parse event from transaction logs
const event = outcome.receipts_outcome
.flatMap((receipt) => receipt.outcome.logs)
.find((log) => log.includes("SignTransferEvent"))

if (!event) {
throw new Error("SignTransferEvent not found in transaction logs")
}

return JSON.parse(event).SignTransferEvent
}

/**
Expand Down Expand Up @@ -449,4 +524,38 @@ export class NearBridgeClient {
throw error
}
}

/// Performs a storage deposit on behalf of the token_locker so that the tokens can be transferred to the locker. To be called once for each NEP-141
private async storageDepositForToken(tokenAddress: string): Promise<string> {
const storage = await this.wallet.viewFunction({
contractId: tokenAddress,
methodName: "storage_balance_of",
args: {
account_id: this.lockerAddress,
},
})
if (storage === null) {
// Check how much is required
const bounds = await this.wallet.viewFunction({
contractId: tokenAddress,
methodName: "storage_balance_bounds",
args: {
account_id: this.lockerAddress,
},
})
const requiredAmount = BigInt(bounds.min)

const tx = await this.wallet.functionCall({
contractId: tokenAddress,
methodName: "storage_deposit",
args: {
account_id: this.lockerAddress,
},
gas: GAS.STORAGE_DEPOSIT,
attachedDeposit: requiredAmount,
})
return tx.transaction.hash
}
return storage
}
}
4 changes: 2 additions & 2 deletions src/clients/solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,13 +336,13 @@ export class SolanaBridgeClient {
): Promise<string> {
// Convert the payload into the expected format
const payload: DepositPayload = {
destination_nonce: transferMessage.destination_nonce,
destination_nonce: BigInt(transferMessage.destination_nonce),
transfer_id: {
origin_chain: transferMessage.transfer_id.origin_chain,
origin_nonce: transferMessage.transfer_id.origin_nonce,
},
token: this.extractSolanaAddress(transferMessage.token_address),
amount: transferMessage.amount,
amount: BigInt(transferMessage.amount),
recipient: this.extractSolanaAddress(transferMessage.recipient),
fee_recipient: transferMessage.fee_recipient ?? "",
}
Expand Down
27 changes: 27 additions & 0 deletions src/types/events.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { OmniAddress } from "./common"
import type { TransferMessagePayload } from "./evm"
import type { MPCSignature } from "./mpc"

export interface MetadataPayload {
Expand All @@ -8,7 +10,32 @@ export interface MetadataPayload {
token: string
}

interface Fee {
fee: string
native_fee: string
}

interface TransferMessage {
origin_nonce: number
token: OmniAddress
amount: string
recipient: OmniAddress
fee: Fee
sender: OmniAddress
msg: string
destination_nonce: number
}

export interface LogMetadataEvent {
metadata_payload: MetadataPayload
signature: MPCSignature
}

export interface InitTransferEvent {
transfer_message: TransferMessage
}

export interface SignTransferEvent {
signature: MPCSignature
message_payload: TransferMessagePayload
}
11 changes: 5 additions & 6 deletions src/types/evm.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { ChainKind } from "./chain"
import type { Nonce, OmniAddress, U128 } from "./common"
import type { Nonce, OmniAddress } from "./common"

export enum PayloadType {
TransferMessage = "TransferMessage",
Expand All @@ -8,8 +7,8 @@ export enum PayloadType {
}

export interface TransferId {
origin_chain: ChainKind
origin_nonce: Nonce
origin_chain: string
origin_nonce: number
}

// bridge deposit structure for evm chains
Expand All @@ -25,10 +24,10 @@ export type BridgeDeposit = {

export type TransferMessagePayload = {
prefix: PayloadType
destination_nonce: Nonce
destination_nonce: string
transfer_id: TransferId
token_address: OmniAddress
amount: U128
amount: string
recipient: OmniAddress
fee_recipient: string | null // NEAR AccountId or null
}
10 changes: 10 additions & 0 deletions src/types/locker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BorshSchema, borshSerialize } from "borsher"
import type { ChainKind } from "./chain"
import { ChainKindSchema } from "./chain"
import type { AccountId } from "./common"
import type { TransferId } from "./evm"

// StorageDepositAction type
export type StorageDepositAction = {
Expand Down Expand Up @@ -29,6 +30,15 @@ export const FinTransferArgsSchema = BorshSchema.Struct({
prover_args: BorshSchema.Vec(BorshSchema.u8),
})

export type SignTransferArgs = {
transfer_id: TransferId
fee_recipient: AccountId
fee: {
fee: string
native_fee: string
}
}

// ClaimFeeArgs type
export type ClaimFeeArgs = {
chain_kind: ChainKind
Expand Down

0 comments on commit ecbbe1d

Please sign in to comment.