Skip to content
Closed
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
1 change: 1 addition & 0 deletions .github/workflows/frontend-prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ jobs:
--build-arg NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }} \
--build-arg NEXT_PUBLIC_NETWORK=${{ vars.NEXT_PUBLIC_NETWORK }} \
--build-arg NEXT_PUBLIC_FEATURE_X402_DEPOSIT=${{ vars.NEXT_PUBLIC_FEATURE_X402_DEPOSIT }} \
--build-arg NEXT_PUBLIC_ENABLE_MOBILE_BLOCK=${{ vars.NEXT_PUBLIC_ENABLE_MOBILE_BLOCK }} \
-f docker/frontend.Dockerfile \
-t ${GCP_LOCATION}-docker.pkg.dev/${GCP_PROJECT_ID}/${GCP_REPOSITORY}/${GCP_IMAGE}:${SHORT_SHA} \
.
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/frontend-staging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ jobs:
--build-arg NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }} \
--build-arg NEXT_PUBLIC_NETWORK=${{ vars.NEXT_PUBLIC_NETWORK }} \
--build-arg NEXT_PUBLIC_FEATURE_X402_DEPOSIT=${{ vars.NEXT_PUBLIC_FEATURE_X402_DEPOSIT }} \
--build-arg NEXT_PUBLIC_ENABLE_MOBILE_BLOCK=${{ vars.NEXT_PUBLIC_ENABLE_MOBILE_BLOCK }} \
-f docker/frontend.Dockerfile \
-t ${GCP_LOCATION}-docker.pkg.dev/${GCP_PROJECT_ID}/${GCP_REPOSITORY}/${GCP_IMAGE}:${SHORT_SHA} \
.
Expand Down
2 changes: 2 additions & 0 deletions docker/frontend.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,13 @@ COPY packages/nextjs ./packages/nextjs
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_NETWORK
ARG NEXT_PUBLIC_FEATURE_X402_DEPOSIT
ARG NEXT_PUBLIC_ENABLE_MOBILE_BLOCK
ARG STANDALONE=true

ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_NETWORK=$NEXT_PUBLIC_NETWORK
ENV NEXT_PUBLIC_FEATURE_X402_DEPOSIT=$NEXT_PUBLIC_FEATURE_X402_DEPOSIT
ENV NEXT_PUBLIC_ENABLE_MOBILE_BLOCK=$NEXT_PUBLIC_ENABLE_MOBILE_BLOCK
ENV STANDALONE=$STANDALONE

# Build shared and frontend packages
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ PolyPay uses **zero-knowledge proofs** and **multi-chain deployment** (Horizen a

* **Horizen** (mainnet & testnet)
* **Base** (mainnet & Sepolia)
* **Arbitrum Sepolia** (testnet only; account contract is a Rust/WASM port via [Arbitrum Stylus](arbitrum-stylus.md))

### Roadmap

Expand Down
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- [Zero-Knowledge Implementation](zero-knowledge-implementation.md)
- [ZK Authentication](zk-authentication.md)
- [zkVerify, Horizen & Base Integration](zkverify-horizen-integration.md)
- [Arbitrum Stylus Support (testnet)](arbitrum-stylus.md)
- [Gasless USDC Deposits (x402)](x402-deposits.md)
- [PolyPay for AI Agents](llms-txt-for-agents.md)
- [Architecture](architecture.md)
Expand Down
132 changes: 132 additions & 0 deletions docs/arbitrum-stylus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Arbitrum Stylus Support

## Overview

PolyPay supports **Arbitrum Sepolia** (chain ID 421614) as a destination chain
for multisig accounts, in addition to Horizen and Base. Most flows (create
account, deposit / send ETH and USDC, propose / approve / execute transfers
and batch transfers) work the same as on the other chains; **add / remove
signer and update-threshold are currently broken on Arbitrum only** — see the
[caveat below](#add--remove-signer-is-currently-broken).

The only structural difference vs. Horizen / Base is that the account contract
is a Rust/WASM port of `MetaMultiSigWallet` deployed via
[Arbitrum Stylus](https://arbitrum.io/stylus) instead of Solidity, fronted by
a per-account EIP-1167 proxy.

Arbitrum is **testnet only** in PolyPay: zkVerify has a verifier on Arbitrum
Sepolia but not Arbitrum One mainnet, so production use is not possible yet.

## For end users — just pick it in the UI

There is **no setup**. Pick "Arbitrum Sepolia" in the network chooser when
creating an account; everything else works the same as Horizen / Base:

| Step | What happens |
|------|--------------|
| Create account | Backend relayer calls the on-chain factory, which clones a tiny EIP-1167 proxy in front of the shared Stylus implementation and initializes it with your commitment(s). |
| Send / receive ETH or USDC | Plain wallet transfers to the proxy address. ETH and Circle USDC (`0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d`) are the supported tokens. Gasless x402 deposit is **not** supported on Arbitrum (Base only). |
| Propose / approve | Same ZK-proof flow as the other chains; the proof targets zkVerify aggregation domain **4** (Arbitrum Sepolia). |
| Execute | Relayer calls `execute(...)` on the proxy once the aggregation receipt is published. |

Fund the account from any wallet (MetaMask, Rabby, …) by sending Arbitrum
Sepolia ETH to the proxy address shown in the account detail page.

### One thing to be aware of: aggregation cadence

zkVerify publishes aggregation receipts to Arbitrum Sepolia **at most every 6
hours** ([kurier docs][zkv-cadence]). So a freshly-submitted transaction can
sit in "pending aggregation" for hours before `execute()` becomes possible. For
fast end-to-end demos, prefer Horizen Testnet (≈2 min cadence).

[zkv-cadence]: https://testnet.kurier.xyz/docs/FAQ#5-how-long-does-it-take-for-an-aggregation-to-finalize

### Add / remove signer is currently broken

Executing **`add_signer`** (and likely `remove_signer` / `update_threshold`) on
an Arbitrum account reverts on-chain with `WalletError("Tx failed")`. All other
wallet management actions are working:

| Action | Arbitrum Sepolia |
|--------|------------------|
| Create account | ✅ |
| Receive / send ETH, USDC | ✅ |
| Transfer via `execute()` | ✅ |
| Batch transfer via `execute()` | ✅ |
| Add / remove signer, update threshold | ❌ (open issue) |

Use Horizen or Base for accounts that need to change their signer set. See
`packages/stylus/NOTES.md` for the technical write-up and current debugging
leads.

### MetaMask "transaction may fail" warning when sending ETH

MetaMask's Blockaid security checker does not understand Stylus WASM
bytecode and may refuse to broadcast plain ETH transfers to a Stylus-backed
account, even though the on-chain call succeeds. Workaround: disable
"Transaction security alerts" in MetaMask settings, or use Rabby / `cast send`.

## How it works under the hood

The Stylus impl is ~31 KB compressed, above the EVM 24 KB code-size limit, so
`cargo-stylus` fragments it across two contracts on-chain. That makes the
single-bytecode `StylusDeployer.deploy(bytecode, ...)` path unusable for
per-account creation. Instead the architecture splits into three pieces per
chain:

```
Stylus impl (deployed once, ~31 KB WASM)
- holds the contract logic
- its own storage is unused
│ delegatecall on every call
Per-account proxy (~52 bytes EVM bytecode)
- one per PolyPay account
- holds the live storage (signers, nonces, nullifiers)
│ created by
Factory (deployed once)
- createWallet(...) clones a new proxy + calls init() on it
- factory's `implementation` address is immutable
```

Each proxy is a thin custom variant of EIP-1167:

- `calldatasize == 0` → `STOP` (accept plain ETH transfers; required because
the fragmented Stylus loader reverts on empty calldata, which would
otherwise break `addr.transfer(...)` deposits).
- Otherwise → delegatecall to the impl, same as standard EIP-1167.

`execute()` in the impl detects when `to == address(this)` (i.e., a self-call
to `add_signer` / `remove_signer` / `update_threshold` / `batch_transfer*`)
and dispatches to internal Rust helpers directly instead of going through an
EVM `CALL`. This workaround is what makes `batch_transfer` succeed on
Arbitrum despite the suspected Stylus delegatecall + CALL `msg.sender` bug;
`add_signer` still fails through the same path and the root cause is open
(see `packages/stylus/NOTES.md`).

### On-chain addresses (Arbitrum Sepolia, chain 421614)

| Component | Address |
|-----------|---------|
| PoseidonT3 (deterministic) | `0x3333333C0A88F9BE4fd23ed0536F9B6c427e3B93` |
| zkVerify aggregation (proxy) | `0xd007494945580eEb25522c8e0b2fa798B3F0FDE2` |
| Circle USDC | `0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d` |
| Stylus impl | `0x0395b99f3a45bd08d018d3d3060a0e2bf8dc8978` |
| Stylus factory | `0x8F5f249210fFc91a2b1D86828764562f97C9eEdd` |

The impl + factory addresses live in `packages/shared/src/contracts/contracts-config.ts`
(`stylusImplAddress` / `stylusFactoryAddress`); bump them there after a
re-deploy.

## For developers who need to redeploy

If you change `packages/stylus/src/lib.rs` you must redeploy both the impl
(via cargo-stylus) and the factory (it captures the impl address as an
immutable). Then bump the addresses in `contracts-config.ts`.

See `packages/stylus/README.md` for the full step-by-step build + deploy
guide. No env vars are required on the backend; the factory address is
sourced from `@polypay/shared`.
1 change: 1 addition & 0 deletions docs/developer-documentation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ This section covers:
| Horizen | Testnet | 2651420 | [horizen-testnet.explorer.caldera.xyz](https://horizen-testnet.explorer.caldera.xyz/) |
| Base | Mainnet | 8453 | [basescan.org](https://basescan.org/) |
| Base | Sepolia | 84532 | [sepolia.basescan.org](https://sepolia.basescan.org/) |
| Arbitrum | Sepolia (testnet only, Stylus WASM contract) | 421614 | [sepolia.arbiscan.io](https://sepolia.arbiscan.io/) |

## Quick Links

Expand Down
6 changes: 3 additions & 3 deletions docs/how-to-use-polipay-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

## Polypay App (Beta v0.1)

Polypay app beta v0.1 is an initial release of the Polypay platform. It is a web application that allows you to create and manage a multisig account on **Horizen** or **Base** with a focus on privacy. Current version includes the following features:
Polypay app beta v0.1 is an initial release of the Polypay platform. It is a web application that allows you to create and manage a multisig account on **Horizen**, **Base**, or **Arbitrum Sepolia** (testnet) with a focus on privacy. Current version includes the following features:

- Choose between Horizen and Base networks
- Choose between Horizen, Base, and Arbitrum Sepolia networks
- Create and manage a multisig account
- Hide signers(multisig account owners) identities with ZK proofs
- Execute payroll payments: Transfer funds to multiple recipients
Expand All @@ -24,7 +24,7 @@ Polypay app beta v0.1 is an initial release of the Polypay platform. It is a web

1. **Connect Wallet**: Connect your wallet
2. **Generate Identity**: Sign a message to create your secret
3. **Choose Network**: Select Horizen or Base
3. **Choose Network**: Select Horizen, Base, or Arbitrum Sepolia (see [Arbitrum Stylus Support](arbitrum-stylus.md) for current limitations on Arbitrum)
4. **Create/Join Account**: Deploy new multisig or join existing one
4. **Propose Transaction**: Create transfer and generate ZK proof
5. **Sign**: Other signers approve with their ZK proofs
Expand Down
2 changes: 1 addition & 1 deletion docs/llms-txt-for-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ A complete playbook covering the five most common agent flows:
| # | Flow | What the agent can do |
|---|---|---|
| 1 | **Login** | Generate a ZK auth proof and obtain a JWT |
| 2 | **Create multisig account** | Deploy a new PolyPay multisig on Horizen or Base |
| 2 | **Create multisig account** | Deploy a new PolyPay multisig on Horizen, Base, or Arbitrum Sepolia (testnet, see [Arbitrum Stylus Support](arbitrum-stylus.md) for limitations) |
| 3 | **Single transfer** | Propose, vote, and execute a private payroll payment |
| 4 | **Batch transfer** | Propose and execute multi-recipient payroll in one transaction |
| 5 | **Gasless USDC deposit (x402)** | Fund any PolyPay multisig with USDC on Base without holding ETH |
Expand Down
2 changes: 1 addition & 1 deletion docs/zkverify-horizen-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ PolyPay uses multiple blockchain layers for privacy-preserving multisig operatio
- **[Horizen](https://www.horizen.io/)**: EVM-compatible L3 blockchain where multisig accounts (`MetaMultiSigWallet` contracts) are deployed and transactions are executed
- **[Base](https://base.org/)**: EVM-compatible L2 blockchain, also supported as a destination chain for account deployment and transaction execution

> **"Destination Chain"** refers to the EVM chain where the multisig account is deployed — either **Horizen** (L3, chain ID 26514) or **Base** (L2, chain ID 8453). The user selects the destination chain when creating an account, and all subsequent operations for that account happen on the same chain.
> **"Destination Chain"** refers to the EVM chain where the multisig account is deployed — **Horizen** (L3, chain ID 26514), **Base** (L2, chain ID 8453), or **Arbitrum Sepolia** (L2 testnet, chain ID 421614). The user selects the destination chain when creating an account, and all subsequent operations for that account happen on the same chain. Arbitrum support uses a Rust/WASM port of the account contract via [Arbitrum Stylus](arbitrum-stylus.md) — see that page for current scope and limitations.

## Blockchain Classification

Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/common/constants/campaign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export const QUEST_POINTS_ACCOUNT_FIRST_TX = 100;
export const QUEST_POINTS_SUCCESSFUL_TX = 50;

// Supported chain IDs
export const SUPPORTED_CHAIN_IDS = [2651420, 84532, 26514, 8453];
// 421614 = Arbitrum Sepolia (testnet only; account contract is the Stylus port).
export const SUPPORTED_CHAIN_IDS = [2651420, 84532, 26514, 8453, 421614];

// External APIs
export const COINGECKO_API_URL =
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/common/constants/proof.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const DOMAIN_ID_BY_CHAIN_ID = {
2651420: 175, // Horizen testnet
84532: 2, // Base Sepolia
421614: 4, // Arbitrum Sepolia (zkVerify testnet domain)
26514: 3, // Horizen mainnet
8453: 2, // Base mainnet
} as const;
1 change: 0 additions & 1 deletion packages/backend/src/config/config.keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export const CONFIG_KEYS = {
RELAYER_ZK_VERIFY_API_KEY: 'relayer.zkVerifyApiKey',
RELAYER_WALLET_KEY: 'relayer.walletKey',
REWARD_WALLET_KEY: 'relayer.rewardWalletKey',

// Telegram
TELEGRAM_BOT_TOKEN: 'telegram.botToken',
TELEGRAM_CHAT_ID: 'telegram.chatId',
Expand Down
65 changes: 65 additions & 0 deletions packages/backend/src/relayer-wallet/relayer-wallet.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import {
ZERO_ADDRESS,
getChainById,
getContractConfigByChainId,
isStylusChain,
getStylusFactoryAddress,
METAMULTISIG_STYLUS_FACTORY_ABI,
} from '@polypay/shared';
import { METAMULTISIG_ABI, METAMULTISIG_BYTECODE } from '@polypay/shared';
import { ConfigService } from '@nestjs/config';
Expand Down Expand Up @@ -95,6 +98,11 @@ export class RelayerService {
threshold: number,
chainId: number,
): Promise<{ address: string; txHash: string }> {
// Stylus chains (Arbitrum) deploy the Rust/WASM port via StylusDeployer.
if (isStylusChain(chainId)) {
return this.deployStylusAccount(commitments, threshold, chainId);
}

const { chain, walletClient, publicClient, contractConfig } =
this.getChainClient(chainId);

Expand Down Expand Up @@ -134,6 +142,63 @@ export class RelayerService {
};
}

/**
* Deploy a per-account MetaMultiSigWallet on an Arbitrum Stylus chain via an
* EIP-1167 minimal proxy in front of the shared Stylus impl.
*
* Why the proxy: the Stylus impl is ~29 KB compressed (over the 24 KB EVM
* code-size limit) so cargo-stylus fragments it on-chain, which makes
* single-bytecode StylusDeployer deploys unusable for per-account creation.
* The factory clones a tiny EVM proxy whose fallback delegatecalls into the
* impl, then atomically calls `init(...)` on the clone in the same tx so we
* never expose a half-initialized wallet.
*/
private async deployStylusAccount(
commitments: string[],
threshold: number,
chainId: number,
): Promise<{ address: string; txHash: string }> {
const { chain, walletClient, publicClient, contractConfig } =
this.getChainClient(chainId);

const factoryAddress = getStylusFactoryAddress(chainId);

const commitmentsBigInt = commitments.map((c) => BigInt(c));

// Simulate first to capture the proxy address returned by `createWallet`.
const { result: deployedAddress, request } =
await publicClient.simulateContract({
address: factoryAddress,
abi: METAMULTISIG_STYLUS_FACTORY_ABI,
functionName: 'createWallet',
args: [
contractConfig.zkVerifyAddress,
contractConfig.vkHash as `0x${string}`,
contractConfig.poseidonT3Address as `0x${string}`,
BigInt(chain.id),
commitmentsBigInt,
BigInt(threshold),
],
account: this.account,
chain,
});

const txHash = await walletClient.writeContract(request);
this.logger.log(
`Stylus factory createWallet tx sent on chain ${chainId}: ${txHash}`,
);

const receipt = await waitForReceiptWithRetry(publicClient, txHash);
if (receipt.status === 'reverted') {
throw new Error(`Stylus deployment reverted. TxHash: ${txHash}`);
}

const address = deployedAddress as string;
this.logger.log(`Stylus wallet (proxy) deployed at: ${address}`);

return { address, txHash };
}

/**
* Execute transaction on MetaMultiSigWallet
*/
Expand Down
14 changes: 12 additions & 2 deletions packages/backend/src/transaction/transaction-executor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,12 @@ export class TransactionExecutorService {
const signers: SignerData[] = JSON.parse(transaction.signerData);

const account = await tx.account.findUnique({
where: { address: transaction.accountAddress },
where: {
address_chainId: {
address: transaction.accountAddress,
chainId: transaction.chainId,
},
},
});

if (!account) return;
Expand Down Expand Up @@ -345,7 +350,12 @@ export class TransactionExecutorService {
const signers: SignerData[] = JSON.parse(transaction.signerData);

const account = await tx.account.findUnique({
where: { address: transaction.accountAddress },
where: {
address_chainId: {
address: transaction.accountAddress,
chainId: transaction.chainId,
},
},
});

if (!account) return;
Expand Down
Loading
Loading