Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
- [ZK Authentication](zk-authentication.md)
- [zkVerify & Horizen Integration](zkverify-horizen-integration.md)
- [Architecture](architecture.md)
- [Cross-Chain Transfers](cross-chain-transfers.md)
- [Developer Documentation](developer-documentation/README.md)
- [Getting Started](developer-documentation/getting-started.md)
- [API Documentation](developer-documentation/api-documentation.md)
- [Database Connection Guide](developer-documentation/database-connection-guide.md)
- [Circuit Code Walkthrough](developer-documentation/circuit-code-walkthrough.md)
- [Cross-Chain Bridge Implementation](developer-documentation/cross-chain-bridge-implementation.md)
44 changes: 44 additions & 0 deletions docs/cross-chain-transfers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Cross-Chain Transfers

PolyPay supports bridging tokens between **Horizen** and **Base** networks directly from the multisig wallet. Cross-chain transfers go through the same privacy-preserving approval flow as regular transfers -- signers approve with ZK proofs, and the relayer executes on-chain.

### Supported Routes

| Token | Base → Horizen | Horizen → Base | Bridge |
|-------|:-:|:-:|--------|
| ETH | Yes | No | OP Stack native bridge |
| ZEN | Yes | Yes | LayerZero OFT |
| USDC | Yes (mainnet) | Yes (mainnet) | Stargate V2 (LayerZero) |

ETH can only be bridged from Base to Horizen using the OP Stack native bridge. The reverse direction is not supported because Horizen is an optimistic rollup and ETH withdrawals require a challenge period that does not fit the instant transfer model.

ZEN and USDC use [LayerZero](https://layerzero.network) for bidirectional transfers between both chains.

### How It Works

1. **Select destination chain** -- When creating a transfer, choose the target network from the chain selector. If only same-chain is available for that token, the selector is hidden.
2. **Approve** -- Other signers review and approve the cross-chain transfer exactly like a regular transfer. The destination chain is displayed in the transaction details.
3. **Execute** -- Once the approval threshold is met, any signer can trigger execution. The relayer submits the transaction on-chain, which calls the bridge contract to deliver tokens to the recipient on the destination chain.

### Contract Version Requirement

Cross-chain transfers require **MetaMultiSigWallet contract version 2** or higher. Version 2 introduces the `approveAndCall` function, which atomically approves a token and calls a bridge contract in a single multisig transaction. Accounts on version 1 will not see the chain selector in the UI.

### Fees

Cross-chain transfers involve two types of fees:

| Fee | Paid in | Applies to | Description |
|-----|---------|------------|-------------|
| LayerZero messaging fee | ETH | ZEN, USDC | Covers cross-chain message delivery. Paid from the wallet's ETH balance. |
| Stargate protocol fee | USDC (deducted from amount) | USDC only | ~0.06% fee charged by Stargate V2. The recipient receives slightly less than the sent amount. |

ETH bridging via OP Stack does not have an explicit bridge fee beyond the normal transaction gas on Base.

### Limitations

* Batch transfers do not support cross-chain. Each cross-chain transfer must be submitted individually.
* USDC bridging is only available on mainnet (no testnet OFT contracts deployed).
* ETH cannot be bridged from Horizen to Base.

For technical details on the bridge implementation, see [Cross-Chain Bridge Implementation](developer-documentation/cross-chain-bridge-implementation.md).
89 changes: 89 additions & 0 deletions docs/developer-documentation/cross-chain-bridge-implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Cross-Chain Bridge Implementation

Technical reference for cross-chain transfers in PolyPay.

## Architecture Overview

Cross-chain transfers reuse `TxType.TRANSFER` with extra metadata (`destChainId`, `bridgeFee`, `bridgeMinAmount`). Three actors must produce **byte-identical calldata** so that ZK proofs match the on-chain txHash.

```mermaid
sequenceDiagram
participant Creator as Creator (Frontend)
participant Backend as Backend (NestJS)
participant Voter as Voter (Frontend)
participant Contract as Smart Contract

Creator->>Creator: buildBridgeParams() -> (to, value, data)
Creator->>Contract: getTransactionHash(nonce, to, value, data) -> txHash
Creator->>Creator: generateProof(txHash)
Creator->>Backend: createTransaction(DTO with bridgeFee, bridgeMinAmount)

Voter->>Voter: buildBridgeTransactionParams() using stored fields
Voter->>Contract: getTransactionHash() -> same txHash
Voter->>Backend: approve(proof)

Backend->>Backend: buildBridgeExecuteParams() using stored fields
Backend->>Contract: execute(nonce, to, value, data, zkProofs)
Contract->>Contract: recompute txHash, verify proofs, call bridge
```

## Bridge Routes

| Token | Direction | Mechanism | OFT Contract | Notes |
|-------|-----------|-----------|-------------|-------|
| ETH | Base -> Horizen | OP Stack native bridge | N/A | Reverse excluded (7-day fraud proof) |
| ZEN | Base <-> Horizen | LayerZero | Adapter on Base, OFT on Horizen | Bidirectional, testnet supported |
| USDC | Base <-> Horizen | Stargate V2 (LayerZero) | Adapter on both chains | Mainnet only, ~0.06% protocol fee |

Route availability is determined by `getAvailableDestChains()` in `bridge.ts`. Cross-chain requires contract version >= 2 (`isCrossChainEnabled()`).

## Encoding Logic

The OFT contract entry `type` determines how the multisig calls the bridge:

| OFT Type | execute() params | When used |
|----------|-----------------|-----------|
| `"oft"` | `to = OFT address`, `value = bridgeFee`, `data = encodeLzSend(...)` | Token IS the OFT (e.g., ZEN on Horizen) |
| `"adapter"` | `to = wallet (self-call)`, `value = 0`, `data = encodeApproveAndCall(...)` | Token separate from OFT, needs allowance first |

For adapters, `approveAndCall` (added in MetaMultiSigWallet v2, `onlySelf` modifier) atomically approves the token and calls `OFT.send()`. The `callValue` parameter forwards ETH from the wallet's own balance to pay the LayerZero fee.

For Stargate OFTs (`stargate: true` in config), `oftCmd` is set to `"0x01"` (taxi mode) for immediate delivery. Standard OFTs use empty `oftCmd`.

## Fees and Slippage

| Field | What it is | Source | Paid in | Stored in DB |
|-------|-----------|--------|---------|--------------|
| `bridgeFee` | LayerZero messaging fee | `quoteSend()` (one-time, by creator) | ETH from wallet balance | Yes |
| `bridgeMinAmount` | Min tokens recipient must receive | `removeDust()` (standard OFT) or `quoteOFT().amountReceivedLD` (Stargate) | N/A (threshold) | Yes |
| Stargate protocol fee | ~0.06% deducted from transfer amount | Implicit in `quoteOFT()` result | USDC (deducted from amount) | No (embedded in `bridgeMinAmount`) |

Standard OFTs (ZEN) are 1:1 transfers with no price impact. `removeDust()` strips precision bits lost during LayerZero's shared-decimals (6) conversion -- only relevant when local decimals > 6 (e.g., 18 for ZEN).

Stargate OFTs (USDC) charge a protocol fee, so `minAmountLD` must come from `quoteOFT()` rather than `removeDust()` to avoid `Stargate_SlippageTooHigh` reverts.

## txHash Consistency

All three actors (creator, voter, backend) must encode identical `(to, value, data)` so the smart contract's recomputed txHash matches the ZK proofs. Non-deterministic values are stored in DB and reused:

| Field | Why stored | Fallback if null |
|-------|-----------|-----------------|
| `bridgeFee` | LZ fee changes over time | `0` |
| `bridgeMinAmount` | Stargate fee is non-deterministic | `removeDust(amount, decimals)` |

Deterministic values (`dstEid`, `oftCmd`) are derived at runtime from config.

## Database Fields

| Column | Type | Description |
|--------|------|-------------|
| `destChainId` | `Int?` | Destination chain ID. Null = same-chain. |
| `bridgeFee` | `String?` | LZ native fee in wei. |
| `bridgeMinAmount` | `String?` | Min received amount in token's smallest unit. |

## External References

- [LayerZero V2 OFT Documentation](https://docs.layerzero.network/v2/developers/evm/oft/quickstart)
- [Stargate V2 Developer Docs](https://stargateprotocol.gitbook.io/stargate/v2-developer-docs)
- [OP Stack Standard Bridge](https://docs.optimism.io/app-developers/bridging/standard-bridge)
- [Horizen Documentation](https://docs.horizen.io)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "transactions" ADD COLUMN "bridge_fee" TEXT,
ADD COLUMN "dest_chain_id" INTEGER;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "transactions" ADD COLUMN "bridge_min_amount" TEXT;
3 changes: 3 additions & 0 deletions packages/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ model Transaction {
signerData String? @map("signer_data")
newThreshold Int? @map("new_threshold")
batchData String? @map("batch_data")
destChainId Int? @map("dest_chain_id")
bridgeFee String? @map("bridge_fee")
bridgeMinAmount String? @map("bridge_min_amount")
createdBy String @map("created_by")
threshold Int
txHash String? @map("tx_hash")
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/account/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
CreateAccountDto,
CreateAccountBatchDto,
UpdateAccountDto,
CROSS_CHAIN_MIN_CONTRACT_VERSION,
} from '@polypay/shared';
import { RelayerService } from '@/relayer-wallet/relayer-wallet.service';
import { EventsService } from '@/events/events.service';
Expand Down Expand Up @@ -86,6 +87,7 @@ export class AccountService {
name: dto.name,
threshold: dto.threshold,
chainId: dto.chainId,
contractVersion: CROSS_CHAIN_MIN_CONTRACT_VERSION,
},
});

Expand Down Expand Up @@ -235,6 +237,7 @@ export class AccountService {
name: dto.name,
threshold: dto.threshold,
chainId: deployment.chainId,
contractVersion: CROSS_CHAIN_MIN_CONTRACT_VERSION,
},
});

Expand Down Expand Up @@ -339,6 +342,7 @@ export class AccountService {
name: account.name,
threshold: account.threshold,
chainId: account.chainId,
contractVersion: account.contractVersion,
createdAt: account.createdAt,
signers: account.signers.map((as) => ({
commitment: as.user.commitment,
Expand Down Expand Up @@ -369,6 +373,7 @@ export class AccountService {
name: account.name,
threshold: account.threshold,
chainId: account.chainId,
contractVersion: account.contractVersion,
createdAt: account.createdAt,
signers: account.signers.map((as) => ({
commitment: as.user.commitment,
Expand Down
37 changes: 37 additions & 0 deletions packages/backend/src/relayer-wallet/relayer-wallet.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,43 @@ export class RelayerService {
} catch (e) {
// Not a batchTransferMulti call, continue
}

// Try decode approveAndCall (cross-chain bridge via OFT Adapter)
try {
const decoded = decodeFunctionData({
abi: [
{
name: 'approveAndCall',
type: 'function',
inputs: [
{ name: 'token', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'approveAmount', type: 'uint256' },
{ name: 'callTarget', type: 'address' },
{ name: 'callValue', type: 'uint256' },
{ name: 'callData', type: 'bytes' },
],
},
],
data: data as `0x${string}`,
});

if (decoded.functionName === 'approveAndCall') {
const tokenAddress = (decoded.args[0] as string).toLowerCase();
const approveAmount = decoded.args[2] as bigint;
const callValue = decoded.args[4] as bigint;

erc20Requirements[tokenAddress] =
(erc20Requirements[tokenAddress] || 0n) + approveAmount;
requiredBalance = requiredBalance + callValue;

this.logger.log(
`ApproveAndCall detected. Token: ${tokenAddress}, Amount: ${approveAmount}, LZ fee: ${callValue}`,
);
}
} catch (e) {
// Not an approveAndCall, continue
}
}

// Check ETH balance
Expand Down
Loading