From 34647bb1b724b4d9b9cc47799c999c98c8526150 Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Thu, 2 Jul 2026 10:58:14 +0700 Subject: [PATCH 1/2] docs research --- docs/research/circle-arc-exploration.md | 101 ++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/research/circle-arc-exploration.md diff --git a/docs/research/circle-arc-exploration.md b/docs/research/circle-arc-exploration.md new file mode 100644 index 0000000..e1eb91b --- /dev/null +++ b/docs/research/circle-arc-exploration.md @@ -0,0 +1,101 @@ +# Circle Integration & Arc Deployment — PolyPay Assessment + +**Goal:** decide whether PolyPay can (a) deploy on Circle's **Arc** L1 and (b) integrate any Circle +product with real code. Exploration only, not production. + +## 1. Can PolyPay deploy on Arc? + +`MetaMultiSigWallet.sol` uses only standard EVM opcodes, so the contract itself is portable to any +EVM chain. But the **private** app cannot run on Arc, and Arc has no production target yet: + +| Blocker | Detail | Source | +|---|---|---| +| **zkVerify not on Arc** | PolyPay verifies every signer proof on-chain by calling zkVerify's aggregation contract (`MetaMultiSigWallet.sol:236-253`, `_verifyProof`). zkVerify's aggregation contract is deployed only on Ethereum Sepolia, Base, Horizen, Arbitrum, OP, Apechain, EDU (+ testnets) — **Arc is not on the list**. On Arc `_verifyProof` has no contract to call, so `execute()` cannot verify proofs. | [zkVerify supported chain](https://docs.zkverify.io/architecture/contract-addresses) | +| **Arc's own privacy (APS) can't substitute** | Verified July 2026 from live docs: *"Privacy features are on the roadmap and not yet available on Arc."* Corroborated by Circle's June 2026 "Arc Privacy" announcement, described as *"a proposed design, not a shipping product."* | [Arc opt-in-privacy](https://docs.arc.io/arc/concepts/opt-in-privacy), [crypto.news, Jun 2026](https://crypto.news/circle-unveils-arc-privacy-to-bring-confidential-smart-contracts-to-institutions/) | +| **USDC-as-gas breaks the relayer** | Arc uses USDC as the native gas token. PolyPay's relayer executes txs paying gas in **native ETH** (`relayer-wallet.service.ts`), every chain is defined `nativeCurrency: ETH` (`scaffold.config.ts:12,34`), and price fetch special-cases ETH (`fetchPriceFromUniswap.ts:22-32`). The relayer would need USDC for gas and the ETH assumptions reworked. | [Arc connect-to-arc](https://docs.arc.io/arc/references/connect-to-arc), code | +| **No Arc mainnet** | Verified July 2026: Arc is still public testnet only — the live docs list a testnet chain id (`5042002`) with no mainnet endpoints. Mainnet beta is targeted for summer 2026 but not yet live. Any Arc deploy today is testnet-only. | [Arc connect-to-arc](https://docs.arc.io/arc/references/connect-to-arc), [Phemex: mainnet summer 2026](https://phemex.com/news/article/circle-unveils-arc-blockchain-whitepaper-mainnet-launch-set-for-summer-2026-82817) | + +**Note:** "no Kurier on Arc" is *not* the real blocker. Kurier is Horizen Labs' off-chain REST API +to zkVerify (`zkverify.service.ts:57-60`) and is chain-agnostic. The hard dependency is the +**on-chain zkVerify aggregation contract**, which Arc lacks. + +### Alternative: keep privacy on Arc without Kurier/zkVerify + +PolyPay's circuit is Noir/UltraHonk. Barretenberg can emit a **standalone Solidity verifier** for +that circuit (`bb write_solidity_verifier` → `Verifier.sol`) +([Barretenberg docs](https://barretenberg.aztec.network/docs/how_to_guides/how-to-solidity-verifier/)). +So PolyPay could drop Kurier + zkVerify on Arc and instead: + +- deploy the generated UltraHonk `Verifier.sol` on Arc, +- rework `_verifyProof` (`MetaMultiSigWallet.sol:236-253`) to call that verifier directly with the + full proof instead of `verifyProofAggregation`, +- have the frontend send the full proof to the relayer (no Kurier submit/poll). + +This **keeps signer privacy** — same circuit, same commitments + nullifier — with no dependency on +Horizen Labs. + +**Does the verifier run on Arc?** The generated Honk verifier requires the `ecAdd`, `ecMul`, +`ecPairing` and `modexp` precompiles ([Barretenberg docs](https://barretenberg.aztec.network/docs/how_to_guides/how-to-solidity-verifier/)). +Arc targets the **Osaka** EVM baseline, and its documented EVM differences remove only +PREVRANDAO / SELFDESTRUCT / EIP-4788 / blob-tx behaviour — the BN254 and modexp precompiles are +**not** among the removed or changed items ([Arc EVM differences](https://docs.arc.io/arc/references/evm-differences)), +and Arc advertises full EVM compatibility. So the verifier should run. Arc publishes no explicit +precompile list, so the one definitive check is to deploy `Verifier.sol` on the live Arc testnet and +verify a proof — a short test, not a blocker. + +Trade-offs: + +- **Higher on-chain cost** — a full Honk verification per tx, versus zkVerify's amortized aggregate + (cheap verification is zkVerify's whole point). On Arc that gas is paid in USDC. +- **Contract fork** of `_verifyProof` plus a verifier deploy. +- **Depends on the stalled UltraHonk migration** — PolyPay's bb.js/contract UltraHonk upgrade is + still pending (`docs/adr/002-ultrahonk-migration.md`); the direct-verifier VK must match the + proving stack. + +**Verdict:** a **private** PolyPay on Arc is feasible **only as a testnet demo** (Arc has no zkVerify +and no live mainnet), via one of two unlocks: (a) Horizen Labs deploys zkVerify on Arc — cheapest +verification, but a partner ask; or (b) PolyPay ships the standalone UltraHonk verifier above — in +our control, at higher gas + engineering cost. Do not target Arc for production until its mainnet +exists. + +## 2. Why PolyPay's privacy is chain-bound (context for the blocker) + +Signer anonymity has two layers: + +- **Commitments** — the wallet stores `poseidonHash2(secret, secret)` per signer, never addresses + (`MetaMultiSigWallet.sol:46`, `packages/shared/src/crypto/identity.ts:16-37`). +- **ZK membership proof** — a Noir circuit proves a signer knows the secret and signed the tx + (`packages/nextjs/public/circuit/src/main.nr:51-67`), verified **on the destination chain** via + zkVerify. This is what ties privacy to a chain: it needs `zkVerifyAddress` + `poseidonT3Address` + per chain (`packages/shared/src/contracts/contracts-config.ts:1-64`). + +Privacy is therefore **not** Horizen-exclusive — it already works on Base and Arbitrum because +zkVerify is deployed there. It fails on any chain that lacks the zkVerify contract. + +## 3. Circle products worth integrating (no Arc needed) + +Runnable on PolyPay's existing chains, higher value than an Arc deploy. Today PolyPay has **no** +CCTP / Circle Wallets / Circle Paymaster / ERC-4337 code; the only Circle-adjacent pieces are +Coinbase CDP as an x402 facilitator and Circle's native USDC addresses (`x402.service.ts`, +`packages/shared/src/constants/token.ts`). Product list per [circle.com/developer](https://www.circle.com/developer). + +| Product | What it does, and the feature PolyPay would gain | Verdict | +|---|---|---| +| **CCTP** | Circle's cross-chain USDC transfer: it burns USDC on one chain and mints native USDC on another (no wrapped tokens, always 1:1). **Feature:** anyone can fund a PolyPay multisig from a different chain and the money lands as real USDC. Works for multisigs on **Base** and **Arbitrum**, but **not Horizen** (Horizen is not a CCTP chain), so it can't fund the primary deployment ([CCTP domains](https://developers.circle.com/cctp/concepts/supported-chains-and-domains)). | **Best fit**, but Base/Arbitrum only. Real code, mainnet-ready. | +| **Gateway** | You deposit USDC once into a Circle Gateway contract and get a single "unified balance" spendable on any supported chain in under 500 ms, without bridging each time — non-custodial, so funds move only with your signature ([Circle Gateway](https://www.circle.com/gateway)). **Feature:** an org keeps its treasury as one USDC pool and funds multisigs on whichever chain a payroll runs on. Live on **Base + Arbitrum** (not Horizen); Circle says Arc is planned. | Good fit for treasury UX, but a bigger build than CCTP. | + +Not pursued (documented so the decision is clear): **EURC** — Circle's euro stablecoin is live only on +Base among PolyPay's chains, not Horizen or Arbitrum ([Circle supported chains](https://developers.circle.com/w3s/supported-blockchains-and-currencies)), +so euro payroll would be single-chain and inconsistent. **Paymaster** — PolyPay users already pay no +gas (relayer + x402). **Wallets** — Circle-hosted login would replace PolyPay's own wallet and +ZK-identity model. + +## 4. Conclusion + +1. **Arc:** demo-only, not production. Arc has no live mainnet (testnet only; mainnet targeted + "summer 2026", no exact date) and no zkVerify. Privacy on an Arc testnet demo is possible either + by asking Horizen Labs to deploy zkVerify on Arc, or by shipping a standalone UltraHonk + `Verifier.sol` (see section 1) — the latter is in our control but costs more gas + a contract fork. +2. **Best Circle integration is CCTP** — fund a multisig cross-chain with native USDC (Base/Arbitrum + only, no ZK rework). **Gateway** is a larger follow-up for treasury UX. Neither reaches Horizen, + so both are scoped to PolyPay's Base/Arbitrum deployments. From 68dd52cdf8277ab56e50242322948d89b57da14b Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Thu, 2 Jul 2026 22:33:21 +0700 Subject: [PATCH 2/2] feat(arc): Circle Arc (non-private ECDSA multisig) integration [WIP] Contract: MetaMultiSigWalletArc (ECDSA, parameterized nonce + usedNonces); Arc testnet deploy/test scripts. Verified live on Arc testnet. Backend (viem): arc module - wallet-signature nonce auth (arc-jwt), account creation + relayer deploy, transaction propose/approve/execute + relayer execute; additive Prisma fields (chainType, address, signature); addresses stored lowercased. Frontend: Arc in the create-account network chooser (address signers reuse the wizard), unified sidebar (one account list + network chooser + nav, Arc badged non-private), dashboard/transfer reuse via chainType branch, Arc USDC token/icon, /arc routes bypass the ZK gate. Docs: exploration report + design spec + implementation plan. WIP: dashboard transaction list still uses an Arc panel; deep-branching the ZK TransactionRow for Arc is deferred (tracked for follow-up). --- docs/research/circle-arc-exploration.md | 43 +- .../2026-07-02-arc-multisig-integration.md | 618 ++++++++++++++++++ ...6-07-02-arc-multisig-integration-design.md | 128 ++++ packages/backend/package.json | 5 + .../migration.sql | 16 + packages/backend/prisma/schema.prisma | 8 +- packages/backend/src/app.module.ts | 2 + .../arc/arc-account/arc-account.controller.ts | 54 ++ .../src/arc/arc-account/arc-account.module.ts | 14 + .../arc-account/arc-account.service.spec.ts | 129 ++++ .../arc/arc-account/arc-account.service.ts | 135 ++++ .../arc-account/dto/create-arc-account.dto.ts | 33 + .../src/arc/arc-auth/arc-auth.controller.ts | 56 ++ .../src/arc/arc-auth/arc-auth.guard.ts | 5 + .../src/arc/arc-auth/arc-auth.module.ts | 30 + .../src/arc/arc-auth/arc-auth.service.spec.ts | 39 ++ .../src/arc/arc-auth/arc-auth.service.ts | 40 ++ .../src/arc/arc-auth/arc-jwt.strategy.ts | 27 + .../src/arc/arc-auth/dto/arc-login.dto.ts | 11 + .../src/arc/arc-auth/dto/arc-nonce.dto.ts | 7 + .../arc-transaction.controller.ts | 173 +++++ .../arc-transaction/arc-transaction.module.ts | 14 + .../arc-transaction.service.spec.ts | 354 ++++++++++ .../arc-transaction.service.ts | 351 ++++++++++ .../dto/approve-arc-transaction.dto.ts | 7 + .../dto/create-arc-transaction.dto.ts | 33 + .../arc-transaction/signature.util.spec.ts | 64 ++ .../src/arc/arc-transaction/signature.util.ts | 39 ++ packages/backend/src/arc/arc.module.ts | 11 + .../relayer-wallet/relayer-wallet.service.ts | 155 +++++ .../contracts/MetaMultiSigWalletArc.sol | 209 ++++++ packages/hardhat/hardhat.config.ts | 6 + packages/hardhat/scripts/deployArc.ts | 53 ++ packages/hardhat/scripts/testArc.ts | 53 ++ packages/nextjs/app/arc/[accountId]/page.tsx | 9 + packages/nextjs/app/arc/create/page.tsx | 166 +++++ .../nextjs/components/Arc/ArcAccountPanel.tsx | 178 +++++ .../Dashboard/DashboardContainer.tsx | 12 + .../components/NewAccount/ChooseNetwork.tsx | 10 +- .../NewAccount/NewAccountContainer.tsx | 71 +- .../NewAccount/SignersConfirmations.tsx | 35 +- .../nextjs/components/Sidebar/AccountItem.tsx | 9 +- .../components/Sidebar/ArcAccountItem.tsx | 65 ++ .../Sidebar/NetworkChooserSidebar.tsx | 4 +- .../nextjs/components/Sidebar/Sidebar.tsx | 11 +- .../components/Transfer/TransferContainer.tsx | 33 +- .../nextjs/hooks/app/arc/useArcAccounts.ts | 18 + packages/nextjs/hooks/app/arc/useArcAuth.ts | 52 ++ .../hooks/app/arc/useArcCreateAccount.ts | 33 + .../nextjs/hooks/app/arc/useArcTransfer.ts | 116 ++++ packages/nextjs/hooks/app/index.ts | 1 + .../nextjs/hooks/app/useCommitmentGuard.ts | 15 +- packages/nextjs/hooks/app/useInitializeApp.ts | 35 +- packages/nextjs/lib/form/schemas.ts | 32 +- packages/nextjs/scaffold.config.ts | 29 +- packages/nextjs/services/api/apiClient.ts | 19 +- packages/nextjs/services/api/arcApi.ts | 156 +++++ packages/nextjs/services/api/index.ts | 2 + packages/nextjs/services/store/index.ts | 1 + .../services/store/useArcIdentityStore.ts | 31 + packages/nextjs/utils/network.ts | 6 + packages/nextjs/utils/signer.ts | 21 + packages/shared/src/constants/chains.ts | 8 + packages/shared/src/contracts/index.ts | 1 + .../src/contracts/metaMultiSigWalletArc.ts | 418 ++++++++++++ packages/shared/src/types/account.ts | 3 + 66 files changed, 4466 insertions(+), 56 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-02-arc-multisig-integration.md create mode 100644 docs/superpowers/specs/2026-07-02-arc-multisig-integration-design.md create mode 100644 packages/backend/prisma/migrations/20260702105237_arc_ecdsa_fields/migration.sql create mode 100644 packages/backend/src/arc/arc-account/arc-account.controller.ts create mode 100644 packages/backend/src/arc/arc-account/arc-account.module.ts create mode 100644 packages/backend/src/arc/arc-account/arc-account.service.spec.ts create mode 100644 packages/backend/src/arc/arc-account/arc-account.service.ts create mode 100644 packages/backend/src/arc/arc-account/dto/create-arc-account.dto.ts create mode 100644 packages/backend/src/arc/arc-auth/arc-auth.controller.ts create mode 100644 packages/backend/src/arc/arc-auth/arc-auth.guard.ts create mode 100644 packages/backend/src/arc/arc-auth/arc-auth.module.ts create mode 100644 packages/backend/src/arc/arc-auth/arc-auth.service.spec.ts create mode 100644 packages/backend/src/arc/arc-auth/arc-auth.service.ts create mode 100644 packages/backend/src/arc/arc-auth/arc-jwt.strategy.ts create mode 100644 packages/backend/src/arc/arc-auth/dto/arc-login.dto.ts create mode 100644 packages/backend/src/arc/arc-auth/dto/arc-nonce.dto.ts create mode 100644 packages/backend/src/arc/arc-transaction/arc-transaction.controller.ts create mode 100644 packages/backend/src/arc/arc-transaction/arc-transaction.module.ts create mode 100644 packages/backend/src/arc/arc-transaction/arc-transaction.service.spec.ts create mode 100644 packages/backend/src/arc/arc-transaction/arc-transaction.service.ts create mode 100644 packages/backend/src/arc/arc-transaction/dto/approve-arc-transaction.dto.ts create mode 100644 packages/backend/src/arc/arc-transaction/dto/create-arc-transaction.dto.ts create mode 100644 packages/backend/src/arc/arc-transaction/signature.util.spec.ts create mode 100644 packages/backend/src/arc/arc-transaction/signature.util.ts create mode 100644 packages/backend/src/arc/arc.module.ts create mode 100644 packages/hardhat/contracts/MetaMultiSigWalletArc.sol create mode 100644 packages/hardhat/scripts/deployArc.ts create mode 100644 packages/hardhat/scripts/testArc.ts create mode 100644 packages/nextjs/app/arc/[accountId]/page.tsx create mode 100644 packages/nextjs/app/arc/create/page.tsx create mode 100644 packages/nextjs/components/Arc/ArcAccountPanel.tsx create mode 100644 packages/nextjs/components/Sidebar/ArcAccountItem.tsx create mode 100644 packages/nextjs/hooks/app/arc/useArcAccounts.ts create mode 100644 packages/nextjs/hooks/app/arc/useArcAuth.ts create mode 100644 packages/nextjs/hooks/app/arc/useArcCreateAccount.ts create mode 100644 packages/nextjs/hooks/app/arc/useArcTransfer.ts create mode 100644 packages/nextjs/services/api/arcApi.ts create mode 100644 packages/nextjs/services/store/useArcIdentityStore.ts create mode 100644 packages/shared/src/contracts/metaMultiSigWalletArc.ts diff --git a/docs/research/circle-arc-exploration.md b/docs/research/circle-arc-exploration.md index e1eb91b..4a2f06a 100644 --- a/docs/research/circle-arc-exploration.md +++ b/docs/research/circle-arc-exploration.md @@ -58,6 +58,33 @@ verification, but a partner ask; or (b) PolyPay ships the standalone UltraHonk v our control, at higher gas + engineering cost. Do not target Arc for production until its mainnet exists. +### Non-private option: a plain multisig on Arc testnet + +If the goal is only to have a **live contract address on Arc testnet** — a "we can build on Arc" +milestone, not a product integration — this is easy and separate from everything above. + +- The ZK `MetaMultiSigWallet.sol` can't be reused: its `execute()` hard-requires zkVerify proofs + (`MetaMultiSigWallet.sol:86-105`), with no toggle. A demo needs a **standard ECDSA multisig** + (signers = addresses, `execute` verifies `ecrecover` signatures — the scaffold-eth pattern the + wallet was forked from before its ZK conversion). +- No zkVerify, Kurier, Poseidon, or relayer needed (privacy off → signers submit directly). Deploy + with a viem/ethers script; gas is paid in testnet USDC from [faucet.circle.com](https://faucet.circle.com). +- Scope: a contract address plus a basic transfer / batch demo. Effort ~1 day. +- Caveat: a generic multisig on Arc carries **no PolyPay differentiation** (no signer privacy) — it + is a presence milestone, not a feature. + +Implemented in this branch: `packages/hardhat/contracts/MetaMultiSigWalletArc.sol` (same +transfer/batch surface, ECDSA instead of ZK), the `arcTestnet` network in `hardhat.config.ts`, and +standalone `packages/hardhat/scripts/deployArc.ts` + `scripts/testArc.ts`. Deploy with +`yarn hardhat run scripts/deployArc.ts --network arcTestnet` from a funded Arc testnet account. + +**Verified live on Arc testnet** (chainId 5042002): deployed at +[`0xE51f56D21b4d58f336C15c71D5B4A3F040EEd9fa`](https://testnet.arcscan.app/address/0xE51f56D21b4d58f336C15c71D5B4A3F040EEd9fa), +and a 1-of-1 `execute(nonce, to, value, data, signatures)` (owner signs → transfer out) succeeded, +confirming signature verification and execution work with USDC as the gas token. The wallet +parameterizes the nonce (with a `usedNonces` mapping) so off-chain signatures stay valid regardless +of execution ordering. + ## 2. Why PolyPay's privacy is chain-bound (context for the blocker) Signer anonymity has two layers: @@ -74,10 +101,11 @@ zkVerify is deployed there. It fails on any chain that lacks the zkVerify contra ## 3. Circle products worth integrating (no Arc needed) -Runnable on PolyPay's existing chains, higher value than an Arc deploy. Today PolyPay has **no** -CCTP / Circle Wallets / Circle Paymaster / ERC-4337 code; the only Circle-adjacent pieces are -Coinbase CDP as an x402 facilitator and Circle's native USDC addresses (`x402.service.ts`, -`packages/shared/src/constants/token.ts`). Product list per [circle.com/developer](https://www.circle.com/developer). +Runnable on PolyPay's existing chains, higher value than an Arc deploy. The only Circle product +already in the app is **USDC itself** — Circle's native USDC token contracts, whose EIP-3009 support +powers the x402 gasless deposit (`packages/shared/src/constants/token.ts`). There is no CCTP / Circle +Wallets / Circle Paymaster / ERC-4337 code. (Note: x402 and its facilitators — Coinbase CDP, PayAI — +are Coinbase / third-party, **not** Circle products.) Product list per [circle.com/developer](https://www.circle.com/developer). | Product | What it does, and the feature PolyPay would gain | Verdict | |---|---|---| @@ -93,9 +121,10 @@ ZK-identity model. ## 4. Conclusion 1. **Arc:** demo-only, not production. Arc has no live mainnet (testnet only; mainnet targeted - "summer 2026", no exact date) and no zkVerify. Privacy on an Arc testnet demo is possible either - by asking Horizen Labs to deploy zkVerify on Arc, or by shipping a standalone UltraHonk - `Verifier.sol` (see section 1) — the latter is in our control but costs more gas + a contract fork. + "summer 2026", no exact date) and no zkVerify. For a quick "present on Arc" milestone, deploy a + plain ECDSA multisig on Arc testnet (no privacy, ~1 day — see section 1). A *private* Arc testnet + demo is also possible but heavier: either ask Horizen Labs to deploy zkVerify on Arc, or ship a + standalone UltraHonk `Verifier.sol` (in our control, higher gas + a contract fork). 2. **Best Circle integration is CCTP** — fund a multisig cross-chain with native USDC (Base/Arbitrum only, no ZK rework). **Gateway** is a larger follow-up for treasury UX. Neither reaches Horizen, so both are scoped to PolyPay's Base/Arbitrum deployments. diff --git a/docs/superpowers/plans/2026-07-02-arc-multisig-integration.md b/docs/superpowers/plans/2026-07-02-arc-multisig-integration.md new file mode 100644 index 0000000..11f3bbd --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-arc-multisig-integration.md @@ -0,0 +1,618 @@ +# Arc Multisig Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a staging user create a non-private ECDSA multisig on Circle's Arc testnet and run one USDC transfer through it (propose → approve → execute) in the existing UI. + +**Architecture:** A parallel Arc module (Approach B). The ZK auth/account/transaction/proof code paths are NOT modified. Arc uses SIWE login (JWT keyed by address), an ECDSA multisig (`MetaMultiSigWalletArc`, already deployed + tested), and the relayer to submit deploy + execute. Shared surfaces are additive-only: Prisma migration, chain config, a dashboard union query. + +**Tech Stack:** NestJS 11, Prisma 7 (PostgreSQL), Next.js 15/React 19, wagmi 2 / viem 2, OpenZeppelin 5 ECDSA (on-chain). The backend/relayer uses **viem** (NOT ethers) — all Arc web3 (signature verify, deploy, execute) uses viem. Auth is a wallet-signature nonce challenge verified with viem `verifyMessage`. + +## Global Constraints + +- Arc testnet chainId: `5042002`. RPC: `https://rpc.testnet.arc.network`. Explorer: `https://testnet.arcscan.app`. +- On Arc, USDC is the NATIVE token (18 decimals). USDC transfers are native value transfers, not ERC20 calls. +- Deployed demo contract (reference): `0x41D925843a192F859cf14aba4789744b1e58eB15`. +- Chain routing: a chain is `ecdsa` when it has no zkVerify config (Arc), else `zk`. +- Do NOT modify existing ZK modules: `auth`, `account`, `transaction`, `zkverify`, or the frontend hooks `useAuthProof`, `useGenerateProof`, `useIdentityStore`. +- The relayer wallet (`RELAYER_WALLET_KEY`) must hold Arc testnet USDC (native gas) before deploy/execute work on Arc. Fund via `https://faucet.circle.com`. +- Signatures for `execute` MUST be sorted ascending by recovered signer address (contract enforces uniqueness/order via `uint160(recovered) > uint160(lastSigner)`). +- Signers sign `getTransactionHash(nonce, to, value, data)` as an EIP-191 personal message (`personal_sign` / viem `signMessage` over the 32 raw bytes). +- No emojis in code/commits. Commits in English. Do not add AI co-author trailer. + +--- + +## File Structure + +**Shared (`packages/shared`)** +- Create `src/contracts/metaMultiSigWalletArc.ts` — ABI + bytecode const. +- Modify `src/constants/chains.ts` — add Arc testnet id + `chainType` helper. + +**Backend (`packages/backend/src`)** +- Modify `prisma/schema.prisma` — additive fields + migration. +- Create `arc/arc-auth/` — SIWE nonce + login, `ArcJwtStrategy`, `ArcAuthGuard`. +- Create `arc/arc-account/` — create Arc multisig. +- Create `arc/arc-transaction/` — propose/approve/execute. +- Modify `relayer-wallet/relayer-wallet.service.ts` — add `deployArcAccount`, `executeArcTransaction`. +- Create `arc/arc.module.ts` — wires the three Arc sub-modules; imported by `app.module.ts`. + +**Frontend (`packages/nextjs`)** +- Modify `scaffold.config.ts` — add Arc testnet to `targetNetworks`. +- Create `hooks/app/arc/useArcAuth.ts`, `useArcCreateAccount.ts`, `useArcTransfer.ts`. +- Create `services/api/arcApi.ts`. +- Create `app/arc/create/page.tsx`, and an Arc transfer view. +- Modify the dashboard account list to union Arc accounts + badge. + +--- + +## Task 1: Shared — Arc contract artifact + chain type + +**Files:** +- Create: `packages/shared/src/contracts/metaMultiSigWalletArc.ts` +- Modify: `packages/shared/src/constants/chains.ts` +- Test: `packages/shared/src/constants/__tests__/chains.spec.ts` + +**Interfaces:** +- Produces: `ARC_TESTNET_CHAIN_ID = 5042002`; `getChainType(chainId: number): "zk" | "ecdsa"`; `META_MULTISIG_ARC_ABI`, `META_MULTISIG_ARC_BYTECODE`. + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/shared/src/constants/__tests__/chains.spec.ts +import { getChainType, ARC_TESTNET_CHAIN_ID } from "../chains"; + +describe("getChainType", () => { + it("returns ecdsa for Arc testnet", () => { + expect(getChainType(ARC_TESTNET_CHAIN_ID)).toBe("ecdsa"); + }); + it("returns zk for Horizen testnet", () => { + expect(getChainType(2651420)).toBe("zk"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn workspace @polypay/shared test chains.spec` +Expected: FAIL (`getChainType` / `ARC_TESTNET_CHAIN_ID` not exported). + +- [ ] **Step 3: Add the chain type helper** + +Add to `packages/shared/src/constants/chains.ts` (alongside the existing `CHAIN_IDS`): + +```typescript +export const ARC_TESTNET_CHAIN_ID = 5042002; + +// A chain is "ecdsa" (non-private) when it has no zkVerify deployment (Arc). All +// currently-supported chains except Arc are "zk". +export function getChainType(chainId: number): "zk" | "ecdsa" { + return chainId === ARC_TESTNET_CHAIN_ID ? "ecdsa" : "zk"; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn workspace @polypay/shared test chains.spec` +Expected: PASS. + +- [ ] **Step 5: Add the Arc contract ABI + bytecode** + +Extract from the compiled artifact `packages/hardhat/artifacts/contracts/MetaMultiSigWalletArc.sol/MetaMultiSigWalletArc.json` (run `yarn workspace @polypay/hardhat compile` first if missing). Create `packages/shared/src/contracts/metaMultiSigWalletArc.ts`: + +```typescript +// ABI + bytecode copied from the compiled MetaMultiSigWalletArc artifact. +// Regenerate by recompiling the hardhat package if the contract changes. +export const META_MULTISIG_ARC_ABI = [/* paste artifact.abi */] as const; +export const META_MULTISIG_ARC_BYTECODE = "0x..."; /* paste artifact.bytecode */ +``` + +Export it from the shared contracts barrel (mirror how `METAMULTISIG_ABI` is exported in `packages/shared/src/contracts/`). + +- [ ] **Step 6: Commit** + +```bash +git add packages/shared/src/contracts/metaMultiSigWalletArc.ts packages/shared/src/constants/chains.ts packages/shared/src/constants/__tests__/chains.spec.ts +git commit -m "feat(shared): add Arc chain type + MetaMultiSigWalletArc artifact" +``` + +--- + +## Task 2: Backend — Prisma additive migration + +**Files:** +- Modify: `packages/backend/prisma/schema.prisma` +- Migration: `packages/backend/prisma/migrations//migration.sql` + +**Interfaces:** +- Produces: `Account.chainType`, `AccountSigner.address`, `User.address`, and a `signature` field on the transaction vote model. + +- [ ] **Step 1: Edit the schema (additive only)** + +In `packages/backend/prisma/schema.prisma`: +- On `Account`: add `chainType String @default("zk")`. +- On `AccountSigner`: add `address String?`. +- On `User`: add `address String? @unique`, and change `commitment` to nullable (`commitment String? @unique`). +- On the transaction vote model (the model holding per-signer votes/proofs): add `signature String?`. + +- [ ] **Step 2: Generate the migration** + +Run: `yarn workspace @polypay/backend prisma migrate dev --name arc_ecdsa_fields` +Expected: a new migration is created and applies cleanly to the dev DB; `prisma generate` runs. + +- [ ] **Step 3: Verify existing ZK rows unaffected** + +Run: `yarn workspace @polypay/backend prisma studio` (or a quick query) and confirm existing `Account` rows now have `chainType = "zk"` and `User.commitment` still populated. + +- [ ] **Step 4: Commit** + +```bash +git add packages/backend/prisma/schema.prisma packages/backend/prisma/migrations +git commit -m "feat(backend): additive Arc/ECDSA fields on Account, AccountSigner, User, vote" +``` + +--- + +## Task 3: Backend — Arc wallet-signature auth (nonce challenge) + +**Files:** +- Create: `packages/backend/src/arc/arc-auth/arc-auth.service.ts`, `arc-auth.controller.ts`, `arc-auth.module.ts`, `dto/arc-login.dto.ts`, `dto/arc-nonce.dto.ts` +- Create: `packages/backend/src/arc/arc-auth/arc-jwt.strategy.ts`, `arc-auth.guard.ts` +- Test: `packages/backend/src/arc/arc-auth/arc-auth.service.spec.ts` + +**Interfaces:** +- Consumes: existing `JwtService`; ethers v6 `verifyMessage`, `randomBytes`, `hexlify`. +- Produces: `POST /arc/auth/nonce { address } -> { nonce }`; `POST /arc/auth/login { address, signature } -> { accessToken }` with JWT payload `{ sub: address(lowercased), chainType: "ecdsa" }`. `ArcAuthGuard` protecting Arc routes. Login message format: `` `PolyPay Arc login: ${nonce}` ``. + +- [ ] **Step 1: Write the failing test** + +```typescript +// arc-auth.service.spec.ts +import { Wallet } from "ethers"; +import { ArcAuthService } from "./arc-auth.service"; +import { JwtService } from "@nestjs/jwt"; + +describe("ArcAuthService", () => { + const jwt = { sign: (p: any) => `token:${p.sub}` } as unknown as JwtService; + let service: ArcAuthService; + beforeEach(() => (service = new ArcAuthService(jwt))); + + it("issues a token keyed by the recovered address when the nonce message is signed", async () => { + const signer = Wallet.createRandom(); + const nonce = service.getNonce(signer.address); + const signature = await signer.signMessage(`PolyPay Arc login: ${nonce}`); + const res = await service.login(signer.address, signature); + expect(res.accessToken).toBe(`token:${signer.address.toLowerCase()}`); + }); + + it("rejects a signature from a different signer", async () => { + const signer = Wallet.createRandom(); + const other = Wallet.createRandom(); + const nonce = service.getNonce(signer.address); + const signature = await other.signMessage(`PolyPay Arc login: ${nonce}`); + await expect(service.login(signer.address, signature)).rejects.toThrow(); + }); + + it("rejects reuse of a consumed nonce", async () => { + const signer = Wallet.createRandom(); + const nonce = service.getNonce(signer.address); + const signature = await signer.signMessage(`PolyPay Arc login: ${nonce}`); + await service.login(signer.address, signature); + await expect(service.login(signer.address, signature)).rejects.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn workspace @polypay/backend test arc-auth.service` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement the service** + +```typescript +// arc-auth.service.ts +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { verifyMessage, randomBytes, hexlify } from "ethers"; + +// In-memory nonce store, keyed by lowercased address. Single-instance staging demo only; +// a multi-instance deployment would move this to Redis/DB. Nonces are one-time use. +@Injectable() +export class ArcAuthService { + private readonly nonces = new Map(); + + constructor(private readonly jwt: JwtService) {} + + getNonce(address: string): string { + const nonce = hexlify(randomBytes(16)); + this.nonces.set(address.toLowerCase(), nonce); + return nonce; + } + + async login(address: string, signature: string): Promise<{ accessToken: string }> { + const key = address.toLowerCase(); + const nonce = this.nonces.get(key); + if (!nonce) throw new UnauthorizedException("No nonce issued for this address"); + + const recovered = verifyMessage(`PolyPay Arc login: ${nonce}`, signature).toLowerCase(); + if (recovered !== key) throw new UnauthorizedException("Invalid signature"); + + this.nonces.delete(key); // one-time use + const accessToken = this.jwt.sign({ sub: key, chainType: "ecdsa" }); + return { accessToken }; + } +} +``` + +(Persisting the `User(address)` row happens in Task 4's account creation; login only authenticates.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn workspace @polypay/backend test arc-auth.service` +Expected: PASS. + +- [ ] **Step 5: Add controller, strategy, guard, module** + +`arc-auth.controller.ts` exposes `POST /arc/auth/nonce` (body `{ address }`) and `POST /arc/auth/login` (body `{ address, signature }`). `arc-jwt.strategy.ts` mirrors the existing `JwtStrategy` but validates the `chainType === "ecdsa"` claim and returns `{ address: payload.sub }` (does not look up by commitment). `ArcAuthGuard extends AuthGuard("arc-jwt")`. `arc-auth.module.ts` imports `JwtModule` (reuse existing config/secret) and exports the service + guard. + +- [ ] **Step 6: Commit** + +```bash +git add packages/backend/src/arc/arc-auth +git commit -m "feat(backend): Arc wallet-signature auth (nonce challenge, arc-jwt strategy/guard)" +``` + +--- + +## Task 4: Backend — Arc account creation + relayer deploy + +**Files:** +- Modify: `packages/backend/src/relayer-wallet/relayer-wallet.service.ts` +- Create: `packages/backend/src/arc/arc-account/arc-account.service.ts`, `arc-account.controller.ts`, `arc-account.module.ts`, `dto/create-arc-account.dto.ts` +- Test: `packages/backend/src/arc/arc-account/arc-account.service.spec.ts`, `relayer-wallet.service.spec.ts` (extend) + +**Interfaces:** +- Consumes: `META_MULTISIG_ARC_ABI`, `META_MULTISIG_ARC_BYTECODE`, `ARC_TESTNET_CHAIN_ID` from `@polypay/shared`; `ArcAuthGuard`. +- Produces: `RelayerWalletService.deployArcAccount(owners: string[], threshold: number, chainId: number): Promise` (returns wallet address). `POST /arc/accounts { owners, threshold, chainId } -> { address }`. + +- [ ] **Step 1: Write the failing relayer test** + +```typescript +// relayer-wallet.service.spec.ts (add) +it("deployArcAccount deploys MetaMultiSigWalletArc with [chainId, owners, threshold]", async () => { + const owners = ["0x1111111111111111111111111111111111111111", "0x2222222222222222222222222222222222222222"]; + const addr = await service.deployArcAccount(owners, 2, 5042002); + expect(addr).toMatch(/^0x[0-9a-fA-F]{40}$/); +}); +``` + +Use the existing test setup pattern in the relayer spec (mock provider/wallet or a local hardhat node). If the suite deploys against a local node, deploy the Arc contract there with chainId arg 5042002 (the constructor takes chainId as data, not the network id, so a local node is fine). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn workspace @polypay/backend test relayer-wallet.service` +Expected: FAIL (`deployArcAccount` not a function). + +- [ ] **Step 3: Implement `deployArcAccount` (viem — mirror existing `deployAccount`)** + +The relayer uses **viem**, not ethers. FIRST read the existing `deployAccount` method in `relayer-wallet.service.ts` and copy its exact walletClient/publicClient/account construction (chain object, transport, account source). Then add a sibling method changing only the abi/bytecode/args: + +```typescript +import { META_MULTISIG_ARC_ABI, META_MULTISIG_ARC_BYTECODE } from "@polypay/shared"; + +async deployArcAccount(owners: string[], threshold: number, chainId: number): Promise { + // build walletClient + publicClient + account exactly as deployAccount does for this chainId + const hash = await walletClient.deployContract({ + abi: META_MULTISIG_ARC_ABI, + bytecode: META_MULTISIG_ARC_BYTECODE as `0x${string}`, + args: [BigInt(chainId), owners as `0x${string}`[], BigInt(threshold)], + account, + chain, + }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (!receipt.contractAddress) throw new Error("Arc wallet deploy: no contractAddress in receipt"); + return receipt.contractAddress; +} +``` + +Do NOT introduce ethers. Match the exact client/account/chain construction the existing viem `deployAccount` uses. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn workspace @polypay/backend test relayer-wallet.service` +Expected: PASS. + +- [ ] **Step 5: Write the failing account-service test** + +```typescript +// arc-account.service.spec.ts +it("creates an Arc account: deploys, persists Account(ecdsa)+signers+users", async () => { + const owners = ["0xAAaAAA...40hex", "0xBBbBBB...40hex"]; + const relayer = { deployArcAccount: jest.fn().mockResolvedValue("0xWALLET") }; + const prisma = makePrismaMock(); + const service = new ArcAccountService(relayer as any, prisma as any); + const res = await service.create({ owners, threshold: 2, chainId: 5042002 }); + expect(res.address).toBe("0xWALLET"); + expect(prisma.account.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ chainType: "ecdsa" }) })); +}); +``` + +- [ ] **Step 6: Run test to verify it fails, implement, verify pass** + +Implement `ArcAccountService.create(dto)`: validate `owners` are checksummed addresses and `1 <= threshold <= owners.length` and `getChainType(chainId) === "ecdsa"`; call `relayer.deployArcAccount`; persist `Account({ chainType: "ecdsa", address, threshold, chainId })`, one `AccountSigner({ address })` per owner, and upsert `User({ address })` per owner. Controller `POST /arc/accounts` guarded by `ArcAuthGuard`. +Run: `yarn workspace @polypay/backend test arc-account.service` → PASS. + +- [ ] **Step 7: Commit** + +```bash +git add packages/backend/src/arc/arc-account packages/backend/src/relayer-wallet +git commit -m "feat(backend): Arc account creation + relayer deployArcAccount" +``` + +--- + +## Task 5: Backend — Arc transaction propose/approve/execute + relayer execute + +**Files:** +- Modify: `packages/backend/src/relayer-wallet/relayer-wallet.service.ts` +- Create: `packages/backend/src/arc/arc-transaction/arc-transaction.service.ts`, `arc-transaction.controller.ts`, `arc-transaction.module.ts`, `dto/*.ts` +- Create: `packages/backend/src/arc/arc-transaction/signature.util.ts` +- Test: `signature.util.spec.ts`, `arc-transaction.service.spec.ts`, extend relayer spec + +**Interfaces:** +- Consumes: `META_MULTISIG_ARC_ABI`, `ArcAuthGuard`. +- Produces: `recoverArcSigner(walletAddress, chainId, nonce, to, value, data, signature): Promise` (async — viem recover is async); `RelayerWalletService.executeArcTransaction(walletAddress, to, value, data, signatures[], chainId)`; endpoints `POST /arc/transactions`, `POST /arc/transactions/:id/approve`, `POST /arc/transactions/:id/execute`. + +- [ ] **Step 1: Write the failing signature-util test** + +```typescript +// signature.util.spec.ts +import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; +import { encodePacked, keccak256 } from "viem"; +import { recoverArcSigner } from "./signature.util"; + +it("recovers the signer of an EIP-191 signed tx hash", async () => { + const account = privateKeyToAccount(generatePrivateKey()); + const wallet = "0x41D925843a192F859cf14aba4789744b1e58eB15"; + const [chainId, nonce, to, value, data] = [5042002, 0, account.address, 10n, "0x"] as const; + const txHash = keccak256( + encodePacked( + ["address", "uint256", "uint256", "address", "uint256", "bytes"], + [wallet, BigInt(chainId), BigInt(nonce), to, value, data], + ), + ); + const signature = await account.signMessage({ message: { raw: txHash } }); + const recovered = await recoverArcSigner(wallet, chainId, nonce, to, value, data, signature); + expect(recovered.toLowerCase()).toBe(account.address.toLowerCase()); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn workspace @polypay/backend test signature.util` +Expected: FAIL. + +- [ ] **Step 3: Implement `recoverArcSigner`** + +```typescript +// signature.util.ts +import { encodePacked, keccak256, recoverMessageAddress, type Hex } from "viem"; + +export async function recoverArcSigner( + wallet: string, + chainId: number, + nonce: number, + to: string, + value: bigint, + data: string, + signature: string, +): Promise { + const txHash = keccak256( + encodePacked( + ["address", "uint256", "uint256", "address", "uint256", "bytes"], + [wallet as Hex, BigInt(chainId), BigInt(nonce), to as Hex, value, data as Hex], + ), + ); + // The signer signs the 32 raw bytes as a personal message; the contract recovers over + // toEthSignedMessageHash(txHash), which viem's recoverMessageAddress with { raw } reproduces. + return await recoverMessageAddress({ message: { raw: txHash }, signature: signature as Hex }); +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn workspace @polypay/backend test signature.util` +Expected: PASS. + +- [ ] **Step 5: Implement `executeArcTransaction` (relayer) + failing test** + +Test: with 2 signatures over the same txHash, `executeArcTransaction` sorts them ascending by recovered address and calls `execute(to, value, data, sorted)`. + +```typescript +// relayer-wallet.service.ts (add — viem; mirror the existing execute path's client/account/chain setup) +async executeArcTransaction( + walletAddress: string, to: string, value: bigint, data: string, signatures: string[], chainId: number, +): Promise { + // build walletClient + publicClient + account + chain exactly as the existing execute/deploy methods do + const nonce = (await publicClient.readContract({ + address: walletAddress as `0x${string}`, abi: META_MULTISIG_ARC_ABI, functionName: "nonce", + })) as bigint; + // contract requires signatures strictly ascending by recovered signer address + const withAddr = await Promise.all( + signatures.map(async s => ({ + s, + a: (await recoverArcSigner(walletAddress, chainId, Number(nonce), to, value, data, s)).toLowerCase(), + })), + ); + withAddr.sort((x, y) => (x.a < y.a ? -1 : x.a > y.a ? 1 : 0)); + const hash = await walletClient.writeContract({ + address: walletAddress as `0x${string}`, abi: META_MULTISIG_ARC_ABI, functionName: "execute", + args: [to as `0x${string}`, value, data as `0x${string}`, withAddr.map(x => x.s as `0x${string}`)], + account, chain, + }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + return receipt.transactionHash; +} +``` + +Run: `yarn workspace @polypay/backend test relayer-wallet.service` → PASS. + +- [ ] **Step 6: Implement `ArcTransactionService` + failing tests** + +Behaviors to test: +- `propose(dto, address)` — creates a `Transaction(accountId, to, value, data, chainId)` and stores the first vote `{ signature }`; rejects if `recoverArcSigner(...)` is not an account owner. +- `approve(id, { signature }, address)` — recovers signer, requires it to be an owner, rejects duplicate signer (dedupe by recovered address), stores the vote. +- `execute(id)` — loads the transaction + votes; if collected valid signatures `>= account.threshold`, calls `relayer.executeArcTransaction(...)` and marks executed; else throws `BadRequestException("threshold not met")`. + +Implement, then run: `yarn workspace @polypay/backend test arc-transaction.service` → PASS. + +- [ ] **Step 7: Controller + module** + +`arc-transaction.controller.ts` (guarded by `ArcAuthGuard`) exposes the three routes. `arc-transaction.module.ts` provides the service + imports the relayer module. Create `arc/arc.module.ts` importing `ArcAuthModule`, `ArcAccountModule`, `ArcTransactionModule`; import `ArcModule` in `app.module.ts`. + +- [ ] **Step 8: Commit** + +```bash +git add packages/backend/src/arc packages/backend/src/relayer-wallet packages/backend/src/app.module.ts +git commit -m "feat(backend): Arc transaction propose/approve/execute + relayer executeArcTransaction" +``` + +--- + +## Task 6: Frontend — Arc chain config + SIWE login hook + API client + +**Files:** +- Modify: `packages/nextjs/scaffold.config.ts` +- Create: `packages/nextjs/services/api/arcApi.ts` +- Create: `packages/nextjs/hooks/app/arc/useArcAuth.ts` +- Test: `packages/nextjs/hooks/app/arc/__tests__/useArcAuth.test.ts` + +**Interfaces:** +- Produces: Arc testnet in `targetNetworks`; `arcApi.getNonce()`, `arcApi.login(message, signature)`, `arcApi.createAccount(dto)`, `arcApi.proposeTx/approveTx/executeTx`; `useArcAuth()` returning `{ login, isAuthenticated }`. + +- [ ] **Step 1: Add Arc testnet to `targetNetworks`** + +In `packages/nextjs/scaffold.config.ts`, define an Arc testnet chain via viem `defineChain` (id 5042002, rpc `https://rpc.testnet.arc.network`, nativeCurrency `{ name: "USD Coin", symbol: "USDC", decimals: 18 }`, explorer `https://testnet.arcscan.app`) and add it to the testnet `targetNetworks` array. + +- [ ] **Step 2: Implement `arcApi.ts`** + +Mirror `services/api/transactionApi.ts` object-export pattern, using `apiClient`. Endpoints: `POST /arc/auth/nonce`, `POST /arc/auth/login`, `POST /arc/accounts`, `POST /arc/transactions`, `POST /arc/transactions/:id/approve`, `POST /arc/transactions/:id/execute`. + +- [ ] **Step 3: Write the failing `useArcAuth` test** + +Test that `login()` builds a SIWE message with the fetched nonce, calls `signMessageAsync`, posts to `arcApi.login`, and stores the returned token. + +- [ ] **Step 4: Implement `useArcAuth`** + +Use wagmi `useSignMessage` + `siwe` `SiweMessage` to build/sign, then `arcApi.login`. Store the JWT in the existing identity/token store under an Arc-scoped key (do NOT overwrite the ZK session token). Run the test → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/nextjs/scaffold.config.ts packages/nextjs/services/api/arcApi.ts packages/nextjs/hooks/app/arc +git commit -m "feat(frontend): Arc testnet config, arcApi client, SIWE login hook" +``` + +--- + +## Task 7: Frontend — create Arc multisig page + +**Files:** +- Create: `packages/nextjs/hooks/app/arc/useArcCreateAccount.ts` +- Create: `packages/nextjs/app/arc/create/page.tsx` +- Test: `packages/nextjs/hooks/app/arc/__tests__/useArcCreateAccount.test.ts` + +**Interfaces:** +- Consumes: `arcApi.createAccount`, `useArcAuth`. +- Produces: `useArcCreateAccount()` → `{ createAccount(owners, threshold), isPending }`. + +- [ ] **Step 1: Failing hook test** — `createAccount(["0x..","0x.."], 2)` calls `arcApi.createAccount({ owners, threshold: 2, chainId: 5042002 })` and returns the new address. +- [ ] **Step 2: Run → fail.** +- [ ] **Step 3: Implement the hook** (React Query `useMutation`, invalidate the accounts list key on success). +- [ ] **Step 4: Run → pass.** +- [ ] **Step 5: Build the page** `app/arc/create/page.tsx`: connect-wallet gate, a form (React Hook Form + Zod) for a dynamic list of owner addresses + a threshold number, calling `useArcCreateAccount`. Mirror `components/NewAccount/NewAccountContainer.tsx` layout but with address inputs instead of commitments. On success, `notification.success` + route to the account view. +- [ ] **Step 6: Commit** + +```bash +git add packages/nextjs/hooks/app/arc packages/nextjs/app/arc/create +git commit -m "feat(frontend): create Arc multisig page + hook" +``` + +--- + +## Task 8: Frontend — Arc transfer flow (propose/approve/execute) + +**Files:** +- Create: `packages/nextjs/hooks/app/arc/useArcTransfer.ts` +- Create: an Arc transfer view under `app/arc/` (or reuse the transfer UI shell with an Arc branch by `chainType`) +- Test: `packages/nextjs/hooks/app/arc/__tests__/useArcTransfer.test.ts` + +**Interfaces:** +- Consumes: `arcApi`, wagmi `useSignMessage`, `META_MULTISIG_ARC_ABI` (to read `getTransactionHash`/`nonce` via viem `usePublicClient`). +- Produces: `useArcTransfer()` → `{ propose(to, amount), approve(txId), execute(txId) }`. + +**Nonce comes from the backend (parameterized-nonce contract).** The contract's `execute` takes the +nonce as a parameter (with a `usedNonces` mapping), so the proposer must sign over the SAME nonce the +backend will store and later pass to `execute`. Add a backend read `GET /arc/accounts/:accountId/next-nonce` +(in the arc-transaction or arc-account controller) returning the next nonce the backend would assign +(its existing `getNextNonce`). The frontend fetches that nonce before signing. Approvers read the +proposed transaction's `nonce` (returned by the propose response / tx detail) and sign over it. + +- [ ] **Step 1: Failing test for `propose`** + +`propose(to, amount)`: fetch `nonce` from `arcApi.nextNonce(accountId)`, compute +`txHash = keccak256(encodePacked(["address","uint256","uint256","address","uint256","bytes"], [wallet, chainId, nonce, to, amount, "0x"]))` client-side with viem (matching the contract), sign it with `signMessage({ message: { raw: txHash } })`, and post `{ accountId, nonce, to, value: amount, data: "0x", signature }` to `arcApi.proposeTx`. + +- [ ] **Step 2: Run → fail.** +- [ ] **Step 3: Implement the hook.** Note: on Arc, USDC is native, so `to` = recipient, `value` = amount, `data` = `"0x"` (no ERC20 encoding). Sign the 32-byte tx hash as a raw message (matches the contract's `toEthSignedMessageHash`). The proposer/approver must sign over the backend-provided nonce (not a contract-read nonce — the contract no longer exposes an auto-increment counter). +- [ ] **Step 4: Run → pass.** +- [ ] **Step 5: Build the transfer view** — recipient + amount form; a proposed-tx view listing collected approvals vs threshold; an Execute button (enabled at threshold) that calls `arcApi.executeTx`. Use `notification` + `formatErrorMessage`. +- [ ] **Step 6: Commit** + +```bash +git add packages/nextjs/hooks/app/arc packages/nextjs/app/arc +git commit -m "feat(frontend): Arc transfer propose/approve/execute flow" +``` + +--- + +## Task 9: Frontend — dashboard union + non-private badge + +**Files:** +- Modify: the dashboard account-list component + its data hook (`hooks/api/useAccount*`) +- Test: extend the account-list hook test + +**Interfaces:** +- Consumes: existing ZK accounts query + a new Arc accounts query (`GET /arc/accounts` for the logged-in address — add this read endpoint in Task 4's controller if not present). + +- [ ] **Step 1: Failing test** — the account-list hook returns the union of ZK accounts and Arc accounts, each tagged with `chainType`. +- [ ] **Step 2: Run → fail.** +- [ ] **Step 3: Implement** the union in the accounts hook (keep the two sources separate queries; merge in the hook). +- [ ] **Step 4: Run → pass.** +- [ ] **Step 5: Render the badge** — in the account card, when `chainType === "ecdsa"`, show a "Non-private (signers public)" badge. +- [ ] **Step 6: Commit** + +```bash +git add packages/nextjs +git commit -m "feat(frontend): union Arc accounts into dashboard with non-private badge" +``` + +--- + +## Task 10: Manual staging E2E + relayer funding + +- [ ] **Step 1:** Fund the staging relayer wallet with Arc testnet USDC from `https://faucet.circle.com`. +- [ ] **Step 2:** On staging: SIWE-login with an Arc wallet, create a 2-of-2 Arc multisig. +- [ ] **Step 3:** Fund the new multisig with a little Arc testnet USDC. +- [ ] **Step 4:** Propose a transfer, approve with the second owner, execute. +- [ ] **Step 5:** Confirm the transfer on `https://testnet.arcscan.app` and that the account/tx show in the dashboard with the non-private badge. + +--- + +## Self-Review + +**Spec coverage:** data model (T2), SIWE auth (T3), arc-account + relayer deploy (T4), arc-transaction + relayer execute (T5), USDC-native transfer (T8 step 3), shared ABI/chain (T1), frontend create/transfer/dashboard (T6-T9), relayer funding ops note (T10). All spec sections map to a task. + +**Placeholder scan:** the only intentional paste-in is the artifact ABI/bytecode (T1 step 5) and viem chain definition (T6 step 1) — both reference the exact source. No "TBD"/"handle edge cases" left. + +**Type consistency:** `recoverArcSigner` signature is identical in T5 step 3, its test, and the relayer sort in T5 step 5. `deployArcAccount(owners, threshold, chainId)` and `executeArcTransaction(walletAddress, to, value, data, signatures, chainId)` are used consistently across relayer + services. `getChainType`/`ARC_TESTNET_CHAIN_ID` from T1 used in T4/T6. diff --git a/docs/superpowers/specs/2026-07-02-arc-multisig-integration-design.md b/docs/superpowers/specs/2026-07-02-arc-multisig-integration-design.md new file mode 100644 index 0000000..e5b298f --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-arc-multisig-integration-design.md @@ -0,0 +1,128 @@ +# Arc Multisig Integration — Design + +**Status:** approved design, pending implementation plan. +**Date:** 2026-07-02. + +## Goal + +Let a user, on the PolyPay staging app, create a **non-private multisig on Circle's Arc testnet** +and run a single USDC transfer through it (propose → approve → execute), fully in the existing UI. +Arc has no zkVerify, so this path drops privacy: signers are plain addresses verified with ECDSA. +This is a staging demo, not production. + +## Scope + +In scope: +- Create an Arc multisig (owner addresses + threshold). +- One USDC transfer end to end: propose, approve to threshold, execute. +- Arc accounts persist and appear in the existing dashboard (badged "non-private"). + +Out of scope (this iteration): batch transfers, add/remove signers, threshold changes, notifications +parity. The demo contract already supports batch on-chain, but no UI/backend wiring for it now. + +## Approach: parallel Arc module (Approach B) + +Add a self-contained Arc path alongside the ZK stack. **The existing ZK controllers, services, and +hooks are not modified** — the only shared surfaces are the Prisma schema (additive migration), the +dashboard account list (union of both sources), and chain/config. This isolates the working ZK +product on staging from regressions. + +Chain routing key: a chain is `ecdsa` when it has no zkVerify config (Arc), else `zk`. Arc testnet +is chainId `5042002`. + +## Already done (this branch) + +- `packages/hardhat/contracts/MetaMultiSigWalletArc.sol` — ECDSA multisig, same transfer/batch + surface as the ZK wallet. Deployed + smoke-tested live on Arc testnet + (`0x41D925843a192F859cf14aba4789744b1e58eB15`). +- `arcTestnet` network in `hardhat.config.ts`; `scripts/deployArc.ts`, `scripts/testArc.ts`. + +## Key fact: USDC is the native token on Arc + +On Arc, USDC IS the native gas token (18 decimals). Sending USDC = a **native value transfer**, not +an ERC20 call. So an Arc transfer is `execute(to, amount, "0x", signatures)` — no ERC20 `transfer` +encoding. This differs from PolyPay's other chains, where USDC is an ERC20. + +## Data model (Prisma, additive — one migration) + +- `Account`: add `chainType String @default("zk")` (`"zk"` | `"ecdsa"`). +- `AccountSigner`: add `address String?` (Arc owner address). ZK rows keep `commitment`. +- `User`: add `address String? @unique`; make `commitment` nullable. An Arc user is an address row. +- Transaction vote model: add `signature String?`. ZK votes keep their proof/nullifier fields. + +All new columns are nullable or defaulted, so existing ZK rows are unaffected. Uniqueness for Arc +users is enforced on `address`; commitment uniqueness stays for ZK users. + +## Backend: `arc/` module + +New module; existing `auth`, `account`, `transaction` modules untouched. + +- **`arc-auth`** — SIWE (EIP-4361) login. `GET /arc/auth/nonce` issues a nonce; `POST /arc/auth/login` + verifies the signed message, upserts the `User` by address, and issues a JWT with `sub = address` + and a `chainType: "ecdsa"` claim. A separate `ArcAuthGuard`/JWT strategy validates these tokens so + the existing `JwtStrategy` (commitment-based) is not touched. +- **`arc-account`** — `POST /arc/accounts` with `{ owners: address[], threshold, chainId }`. Calls the + relayer to deploy `MetaMultiSigWalletArc`, then persists `Account(chainType="ecdsa")`, + `AccountSigner(address)` rows, and owner `User(address)` rows. +- **`arc-transaction`**: + - `POST /arc/transactions` — propose: create the transaction + store the proposer's signature. + - `POST /arc/transactions/:id/approve` — add a signature. + - `POST /arc/transactions/:id/execute` — when collected signatures ≥ threshold, submit on-chain. + - Signature check: recover the signer from `getTransactionHash(nonce, to, value, data)` (EIP-191 + personal message), require it to be a current owner, dedupe by recovered address. + +## On-chain submit: relayer + +The relayer (`relayer-wallet.service.ts`) submits both deploy and execute for Arc, matching the ZK +UX where users never touch the chain directly: + +- Add `deployArcAccount(owners, threshold, chainId)` → deploys `MetaMultiSigWalletArc` (constructor + `[chainId, owners, threshold]`). +- Add `executeArcTransaction(walletAddress, to, value, data, signatures[])` → calls + `execute(to, value, data, signatures)` with signatures sorted ascending by recovered address. + +**Ops note:** the relayer wallet must hold **Arc testnet USDC** (the native gas token) to pay gas on +Arc. Fund it from `https://faucet.circle.com`. viem pays gas from the native balance, so no ETH is +needed; only the display/pricing assumptions elsewhere assume ETH, and those are not on this path. + +## Shared package (`@polypay/shared`) + +- Add `MetaMultiSigWalletArc` ABI + bytecode (alongside the existing `METAMULTISIG_ABI`) for the + relayer deploy. +- Register Arc testnet (chainId 5042002) in the chains config as an `ecdsa` chain (no zkVerify addr). + +## Frontend (parallel Arc flow) + +- Add Arc testnet to `scaffold.config.ts` `targetNetworks` (testnet set). Native currency is USDC. +- **SIWE login hook** for Arc (sign message → `/arc/auth`). +- **Create-multisig page** for Arc: connect wallet, enter owner addresses + threshold, call + `/arc/accounts`. +- **Transfer flow** for Arc: propose (sign the tx hash → `/arc/transactions`), approve (other owners + sign), execute (relayer submits once threshold met). +- **Dashboard**: union ZK accounts + Arc accounts; Arc accounts show a "non-private / signers public" + badge. + +The Arc hooks/pages are separate from the ZK identity/proof hooks (`useAuthProof`, +`useGenerateProof`, `useIdentityStore`); they use wallet signatures, not Poseidon commitments. + +## Error handling + +- Invalid SIWE signature → 401. +- Recovered signer not an owner, or duplicate signer → reject the vote. +- Execute called below threshold → 4xx, no on-chain call. +- Relayer out of Arc USDC → surface a clear "relayer unfunded" error. +- Reuse `formatErrorMessage` / `notification` for user-facing messages. + +## Testing + +- Contract: already smoke-tested live on Arc testnet. +- Backend unit tests: `arc-account` (deploy + persist), `arc-transaction` (signature recovery, + threshold gating, execute), `arc-auth` (SIWE verify). +- Manual staging E2E: create an Arc multisig → propose → approve to threshold → execute a native + USDC transfer; confirm on `testnet.arcscan.app`. + +## Isolation summary + +- No changes to ZK auth, account, transaction, or proof code paths. +- Shared, additive-only changes: Prisma migration, dashboard union query, chain/config, relayer + gains two Arc-specific methods. diff --git a/packages/backend/package.json b/packages/backend/package.json index 6dd4c31..fc8aa75 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -100,6 +100,11 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "moduleNameMapper": { + "^@polypay/shared$": "/../../shared/src/index.ts", + "^@polypay/shared/(.*)$": "/../../shared/src/$1", + "^@/(.*)$": "/$1" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/packages/backend/prisma/migrations/20260702105237_arc_ecdsa_fields/migration.sql b/packages/backend/prisma/migrations/20260702105237_arc_ecdsa_fields/migration.sql new file mode 100644 index 0000000..0517727 --- /dev/null +++ b/packages/backend/prisma/migrations/20260702105237_arc_ecdsa_fields/migration.sql @@ -0,0 +1,16 @@ +-- AlterTable +ALTER TABLE "account_signers" ADD COLUMN "address" TEXT; + +-- AlterTable +ALTER TABLE "accounts" ADD COLUMN "chain_type" TEXT NOT NULL DEFAULT 'zk'; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "address" TEXT, +ALTER COLUMN "commitment" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "votes" ADD COLUMN "signature" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "users_address_key" ON "users"("address"); + diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index 8df45dd..844169e 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -10,7 +10,8 @@ datasource db { model User { id String @id @default(cuid()) - commitment String @unique + commitment String? @unique + address String? @unique createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") accounts AccountSigner[] @@ -30,6 +31,7 @@ model Account { threshold Int chainId Int @default(26514) @map("chain_id") contractVersion Int @default(1) @map("contract_version") + chainType String @default("zk") @map("chain_type") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") signers AccountSigner[] @@ -51,6 +53,7 @@ model AccountSigner { accountId String @map("account_id") isCreator Boolean @default(false) @map("is_creator") displayName String? @map("display_name") + address String? createdAt DateTime @default(now()) @map("created_at") account Account @relation(fields: [accountId], references: [id]) user User @relation(fields: [userId], references: [id]) @@ -115,6 +118,9 @@ model Vote { proofStatus ProofStatus? @map("proof_status") zkVerifyTxHash String? @map("zkverify_tx_hash") + // ECDSA signature (Arc / non-private multisig) + signature String? + aggregationId String? @map("aggregation_id") domainId Int? @map("domain_id") merkleProof String[] @map("merkle_proof") diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index 7c0a2a1..cd303b4 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -24,6 +24,7 @@ import { AdminModule } from './admin/admin.module'; import { ScheduleModule } from '@nestjs/schedule'; import { X402Module } from './x402/x402.module'; import { LlmsTxtModule } from './llms-txt/llms-txt.module'; +import { ArcModule } from './arc/arc.module'; const featureX402 = process.env.FEATURE_X402_DEPOSIT === 'true'; @@ -53,6 +54,7 @@ const featureX402 = process.env.FEATURE_X402_DEPOSIT === 'true'; ScheduleModule.forRoot(), ...(featureX402 ? [X402Module] : []), LlmsTxtModule, + ArcModule, ], }) export class AppModule implements NestModule { diff --git a/packages/backend/src/arc/arc-account/arc-account.controller.ts b/packages/backend/src/arc/arc-account/arc-account.controller.ts new file mode 100644 index 0000000..93140f8 --- /dev/null +++ b/packages/backend/src/arc/arc-account/arc-account.controller.ts @@ -0,0 +1,54 @@ +import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBody, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { ArcAuthGuard } from '../arc-auth/arc-auth.guard'; +import { CurrentUser } from '@/auth/decorators/current-user.decorator'; +import { ArcAccountService } from './arc-account.service'; +import { CreateArcAccountDto } from './dto/create-arc-account.dto'; + +@ApiTags('arc-accounts') +@Controller('arc/accounts') +@UseGuards(ArcAuthGuard) +@ApiBearerAuth('JWT-auth') +export class ArcAccountController { + constructor(private readonly arcAccountService: ArcAccountService) {} + + /** + * Create a new Arc multisig account + * POST /api/arc/accounts + */ + @Post() + @ApiOperation({ + summary: 'Create a new Arc multisig account', + description: + 'Deploys MetaMultiSigWalletArc on the Arc testnet with the given owner addresses and threshold.', + }) + @ApiBody({ type: CreateArcAccountDto }) + @ApiResponse({ status: 201, description: 'Arc account created' }) + @ApiResponse({ status: 400, description: 'Bad request - invalid data' }) + @ApiResponse({ status: 401, description: 'Unauthorized - invalid token' }) + async create(@Body() dto: CreateArcAccountDto) { + return this.arcAccountService.create(dto); + } + + /** + * Get Arc accounts the logged-in address is a signer on + * GET /api/arc/accounts + */ + @Get() + @ApiOperation({ + summary: 'List Arc accounts for the logged-in address', + description: + 'Returns all Arc multisig accounts where the authenticated address is a signer.', + }) + @ApiResponse({ status: 200, description: 'Arc accounts found' }) + @ApiResponse({ status: 401, description: 'Unauthorized - invalid token' }) + async findMine(@CurrentUser() user: { address: string }) { + return this.arcAccountService.findByOwner(user.address); + } +} diff --git a/packages/backend/src/arc/arc-account/arc-account.module.ts b/packages/backend/src/arc/arc-account/arc-account.module.ts new file mode 100644 index 0000000..61a07dc --- /dev/null +++ b/packages/backend/src/arc/arc-account/arc-account.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '@/database/database.module'; +import { RelayerModule } from '@/relayer-wallet/relayer-wallet.module'; +import { ArcAuthModule } from '../arc-auth/arc-auth.module'; +import { ArcAccountController } from './arc-account.controller'; +import { ArcAccountService } from './arc-account.service'; + +@Module({ + imports: [DatabaseModule, RelayerModule, ArcAuthModule], + controllers: [ArcAccountController], + providers: [ArcAccountService], + exports: [ArcAccountService], +}) +export class ArcAccountModule {} diff --git a/packages/backend/src/arc/arc-account/arc-account.service.spec.ts b/packages/backend/src/arc/arc-account/arc-account.service.spec.ts new file mode 100644 index 0000000..7314cc3 --- /dev/null +++ b/packages/backend/src/arc/arc-account/arc-account.service.spec.ts @@ -0,0 +1,129 @@ +import { ArcAccountService } from './arc-account.service'; +import { RelayerService } from '@/relayer-wallet/relayer-wallet.service'; +import { PrismaService } from '@/database/prisma.service'; + +// Minimal in-memory Prisma mock: only the models/methods ArcAccountService +// touches (user.upsert, account.create/findUniqueOrThrow, accountSigner.create, +// $transaction passthrough). Mirrors the arc-auth.service.spec.ts style of +// instantiating the service directly instead of a full Nest TestingModule. +function makePrismaMock() { + const usersByAddress = new Map(); + let accountRow: any; + const signerRows: any[] = []; + let userSeq = 0; + let accountSeq = 0; + + const prisma = { + user: { + upsert: jest.fn(async ({ where, create }: any) => { + const existing = usersByAddress.get(where.address); + if (existing) return existing; + const user = { id: `user-${++userSeq}`, address: create.address }; + usersByAddress.set(user.address, user); + return user; + }), + }, + account: { + create: jest.fn(async ({ data }: any) => { + accountRow = { id: `account-${++accountSeq}`, ...data }; + return accountRow; + }), + findUniqueOrThrow: jest.fn(async () => ({ + ...accountRow, + signers: signerRows, + })), + }, + accountSigner: { + create: jest.fn(async ({ data }: any) => { + const signer = { id: `signer-${signerRows.length + 1}`, ...data }; + signerRows.push(signer); + return signer; + }), + }, + $transaction: jest.fn(async (fn: any) => fn(prisma)), + }; + + return prisma; +} + +describe('ArcAccountService', () => { + const owners = [`0x${'1'.repeat(40)}`, `0x${'2'.repeat(40)}`]; + const ARC_CHAIN_ID = 5042002; + + it('creates an Arc account: deploys, persists Account(ecdsa)+signers+users', async () => { + const relayer = { + deployArcAccount: jest.fn().mockResolvedValue('0xWALLET'), + }; + const prisma = makePrismaMock(); + const service = new ArcAccountService( + relayer as unknown as RelayerService, + prisma as unknown as PrismaService, + ); + + const res = await service.create({ + owners, + threshold: 2, + chainId: ARC_CHAIN_ID, + }); + + expect(res.address).toBe('0xWALLET'); + expect(relayer.deployArcAccount).toHaveBeenCalledWith( + owners, + 2, + ARC_CHAIN_ID, + ); + expect(prisma.account.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ chainType: 'ecdsa' }), + }), + ); + expect(prisma.accountSigner.create).toHaveBeenCalledTimes(2); + expect(prisma.user.upsert).toHaveBeenCalledTimes(2); + }); + + it('rejects an invalid owner address', async () => { + const relayer = { deployArcAccount: jest.fn() }; + const prisma = makePrismaMock(); + const service = new ArcAccountService( + relayer as unknown as RelayerService, + prisma as unknown as PrismaService, + ); + + await expect( + service.create({ + owners: ['not-an-address', owners[1]], + threshold: 1, + chainId: ARC_CHAIN_ID, + }), + ).rejects.toThrow(); + expect(relayer.deployArcAccount).not.toHaveBeenCalled(); + }); + + it('rejects threshold out of range', async () => { + const relayer = { deployArcAccount: jest.fn() }; + const prisma = makePrismaMock(); + const service = new ArcAccountService( + relayer as unknown as RelayerService, + prisma as unknown as PrismaService, + ); + + await expect( + service.create({ owners, threshold: 3, chainId: ARC_CHAIN_ID }), + ).rejects.toThrow(); + expect(relayer.deployArcAccount).not.toHaveBeenCalled(); + }); + + it('rejects a non-ecdsa (zk) chainId', async () => { + const relayer = { deployArcAccount: jest.fn() }; + const prisma = makePrismaMock(); + const service = new ArcAccountService( + relayer as unknown as RelayerService, + prisma as unknown as PrismaService, + ); + + await expect( + service.create({ owners, threshold: 1, chainId: 2651420 }), + ).rejects.toThrow(); + expect(relayer.deployArcAccount).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/arc/arc-account/arc-account.service.ts b/packages/backend/src/arc/arc-account/arc-account.service.ts new file mode 100644 index 0000000..b89fb2e --- /dev/null +++ b/packages/backend/src/arc/arc-account/arc-account.service.ts @@ -0,0 +1,135 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { isAddress } from 'viem'; +import { getChainType } from '@polypay/shared'; +import { PrismaService } from '@/database/prisma.service'; +import { RelayerService } from '@/relayer-wallet/relayer-wallet.service'; +import { CreateArcAccountDto } from './dto/create-arc-account.dto'; + +@Injectable() +export class ArcAccountService { + private readonly logger = new Logger(ArcAccountService.name); + + constructor( + private readonly relayerService: RelayerService, + private readonly prisma: PrismaService, + ) {} + + /** + * Deploy an Arc multisig and persist it. Unlike the ZK account flow, Arc + * signers are plain ECDSA addresses (no commitments), so the Account is + * tagged chainType "ecdsa" and signers/users key off address instead of + * commitment. + */ + async create(dto: CreateArcAccountDto) { + const { owners, threshold, chainId, name } = dto; + + if (getChainType(chainId) !== 'ecdsa') { + throw new BadRequestException( + `chainId ${chainId} is not an Arc (ecdsa) chain`, + ); + } + + if (!owners.every((owner) => isAddress(owner))) { + throw new BadRequestException('All owners must be valid addresses'); + } + + if (threshold < 1 || threshold > owners.length) { + throw new BadRequestException( + 'threshold must be between 1 and the number of owners', + ); + } + + // Persist (and deploy) with lowercased addresses so DB lookups match the JWT + // subject, which is lowercased. Solidity addresses are case-insensitive, so the + // on-chain deploy is unaffected. + const normalizedOwners = owners.map((owner) => owner.toLowerCase()); + + const address = await this.relayerService.deployArcAccount( + normalizedOwners, + threshold, + chainId, + ); + + this.logger.log(`Arc account deployed at ${address}`); + + const account = await this.prisma.$transaction(async (prisma) => { + const users = await Promise.all( + normalizedOwners.map((owner) => + prisma.user.upsert({ + where: { address: owner }, + update: {}, + create: { address: owner }, + }), + ), + ); + + const newAccount = await prisma.account.create({ + data: { + address, + name: name?.trim() || 'Arc Multisig', + threshold, + chainId, + chainType: 'ecdsa', + }, + }); + + await Promise.all( + users.map((user, index) => + prisma.accountSigner.create({ + data: { + userId: user.id, + accountId: newAccount.id, + address: normalizedOwners[index], + }, + }), + ), + ); + + return prisma.account.findUniqueOrThrow({ + where: { id: newAccount.id }, + include: { + signers: { + include: { user: true }, + }, + }, + }); + }); + + this.logger.log(`Created Arc account in DB: ${account.address}`); + + return { + id: account.id, + address: account.address, + threshold: account.threshold, + chainId: account.chainId, + chainType: account.chainType, + signers: account.signers.map((signer) => signer.address), + }; + } + + /** + * List Arc accounts the given address is a signer on. Used by the + * dashboard (Task 6+) via GET /arc/accounts for the logged-in address. + */ + async findByOwner(address: string) { + const accounts = await this.prisma.account.findMany({ + where: { + chainType: 'ecdsa', + signers: { some: { address: address.toLowerCase() } }, + }, + include: { + signers: { include: { user: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return accounts.map((account) => ({ + id: account.id, + address: account.address, + threshold: account.threshold, + chainId: account.chainId, + chainType: account.chainType, + signers: account.signers.map((signer) => signer.address), + })); + } +} diff --git a/packages/backend/src/arc/arc-account/dto/create-arc-account.dto.ts b/packages/backend/src/arc/arc-account/dto/create-arc-account.dto.ts new file mode 100644 index 0000000..65ffb82 --- /dev/null +++ b/packages/backend/src/arc/arc-account/dto/create-arc-account.dto.ts @@ -0,0 +1,33 @@ +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsEthereumAddress, + IsInt, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Min, +} from 'class-validator'; + +export class CreateArcAccountDto { + @IsOptional() + @IsString() + name?: string; + + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(10) + @IsEthereumAddress({ each: true }) + owners: string[]; + + @IsNotEmpty() + @IsInt() + @Min(1) + threshold: number; + + @IsNotEmpty() + @IsNumber() + chainId: number; +} diff --git a/packages/backend/src/arc/arc-auth/arc-auth.controller.ts b/packages/backend/src/arc/arc-auth/arc-auth.controller.ts new file mode 100644 index 0000000..68749b8 --- /dev/null +++ b/packages/backend/src/arc/arc-auth/arc-auth.controller.ts @@ -0,0 +1,56 @@ +import { Controller, Post, Body, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger'; +import { ThrottlerGuard, Throttle } from '@nestjs/throttler'; +import { ArcAuthService } from './arc-auth.service'; +import { ArcNonceDto } from './dto/arc-nonce.dto'; +import { ArcLoginDto } from './dto/arc-login.dto'; + +@ApiTags('arc-auth') +@Controller('arc/auth') +@UseGuards(ThrottlerGuard) +export class ArcAuthController { + constructor(private readonly arcAuthService: ArcAuthService) {} + + /** + * Issue a login nonce for a wallet address + * POST /api/arc/auth/nonce + */ + @Post('nonce') + @Throttle({ default: { ttl: 60_000, limit: 10 } }) + @ApiOperation({ + summary: 'Get a login nonce for an Arc wallet address', + description: + 'Returns a one-time nonce that must be signed (as `PolyPay Arc login: `) and submitted to /arc/auth/login.', + }) + @ApiBody({ type: ArcNonceDto }) + @ApiResponse({ + status: 201, + description: 'Nonce issued', + schema: { type: 'object', properties: { nonce: { type: 'string' } } }, + }) + async nonce(@Body() dto: ArcNonceDto) { + return { nonce: this.arcAuthService.getNonce(dto.address) }; + } + + /** + * Login by proving ownership of an Arc wallet address via signature + * POST /api/arc/auth/login + */ + @Post('login') + @Throttle({ default: { ttl: 60_000, limit: 5 } }) + @ApiOperation({ + summary: 'Login with an Arc wallet signature', + description: + 'Authenticate by signing the nonce previously issued by /arc/auth/nonce. Returns an access token.', + }) + @ApiBody({ type: ArcLoginDto }) + @ApiResponse({ + status: 201, + description: 'Successfully authenticated', + schema: { type: 'object', properties: { accessToken: { type: 'string' } } }, + }) + @ApiResponse({ status: 401, description: 'Invalid signature or nonce' }) + async login(@Body() dto: ArcLoginDto) { + return this.arcAuthService.login(dto.address, dto.signature); + } +} diff --git a/packages/backend/src/arc/arc-auth/arc-auth.guard.ts b/packages/backend/src/arc/arc-auth/arc-auth.guard.ts new file mode 100644 index 0000000..5a8adbc --- /dev/null +++ b/packages/backend/src/arc/arc-auth/arc-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class ArcAuthGuard extends AuthGuard('arc-jwt') {} diff --git a/packages/backend/src/arc/arc-auth/arc-auth.module.ts b/packages/backend/src/arc/arc-auth/arc-auth.module.ts new file mode 100644 index 0000000..6b64ab1 --- /dev/null +++ b/packages/backend/src/arc/arc-auth/arc-auth.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ConfigService } from '@nestjs/config'; +import { CONFIG_KEYS } from '@/config/config.keys'; +import { ArcAuthController } from './arc-auth.controller'; +import { ArcAuthService } from './arc-auth.service'; +import { ArcJwtStrategy } from './arc-jwt.strategy'; +import { ArcAuthGuard } from './arc-auth.guard'; + +@Module({ + imports: [ + PassportModule, + // Reuses the same JWT secret/expiry as the existing ZK auth module - + // Arc tokens are distinguished by the chainType claim, not a separate secret. + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: configService.get(CONFIG_KEYS.JWT_SECRET), + signOptions: { + expiresIn: configService.get(CONFIG_KEYS.JWT_EXPIRES_IN), + }, + }), + }), + ], + controllers: [ArcAuthController], + providers: [ArcAuthService, ArcJwtStrategy, ArcAuthGuard], + exports: [ArcAuthService, ArcAuthGuard], +}) +export class ArcAuthModule {} diff --git a/packages/backend/src/arc/arc-auth/arc-auth.service.spec.ts b/packages/backend/src/arc/arc-auth/arc-auth.service.spec.ts new file mode 100644 index 0000000..396fc53 --- /dev/null +++ b/packages/backend/src/arc/arc-auth/arc-auth.service.spec.ts @@ -0,0 +1,39 @@ +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import { ArcAuthService } from './arc-auth.service'; +import { JwtService } from '@nestjs/jwt'; + +describe('ArcAuthService', () => { + const jwt = { sign: (p: any) => `token:${p.sub}` } as unknown as JwtService; + let service: ArcAuthService; + beforeEach(() => (service = new ArcAuthService(jwt))); + + it('issues a token keyed by the recovered address when the nonce message is signed', async () => { + const signer = privateKeyToAccount(generatePrivateKey()); + const nonce = service.getNonce(signer.address); + const signature = await signer.signMessage({ + message: `PolyPay Arc login: ${nonce}`, + }); + const res = await service.login(signer.address, signature); + expect(res.accessToken).toBe(`token:${signer.address.toLowerCase()}`); + }); + + it('rejects a signature from a different signer', async () => { + const signer = privateKeyToAccount(generatePrivateKey()); + const other = privateKeyToAccount(generatePrivateKey()); + const nonce = service.getNonce(signer.address); + const signature = await other.signMessage({ + message: `PolyPay Arc login: ${nonce}`, + }); + await expect(service.login(signer.address, signature)).rejects.toThrow(); + }); + + it('rejects reuse of a consumed nonce', async () => { + const signer = privateKeyToAccount(generatePrivateKey()); + const nonce = service.getNonce(signer.address); + const signature = await signer.signMessage({ + message: `PolyPay Arc login: ${nonce}`, + }); + await service.login(signer.address, signature); + await expect(service.login(signer.address, signature)).rejects.toThrow(); + }); +}); diff --git a/packages/backend/src/arc/arc-auth/arc-auth.service.ts b/packages/backend/src/arc/arc-auth/arc-auth.service.ts new file mode 100644 index 0000000..b172b3f --- /dev/null +++ b/packages/backend/src/arc/arc-auth/arc-auth.service.ts @@ -0,0 +1,40 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { randomBytes } from 'crypto'; +import { toHex, verifyMessage } from 'viem'; + +// In-memory nonce store, keyed by lowercased address. Single-instance staging demo only; +// a multi-instance deployment would move this to Redis/DB. Nonces are one-time use. +@Injectable() +export class ArcAuthService { + private readonly nonces = new Map(); + + constructor(private readonly jwt: JwtService) {} + + getNonce(address: string): string { + const nonce = toHex(randomBytes(16)); + this.nonces.set(address.toLowerCase(), nonce); + return nonce; + } + + async login( + address: string, + signature: string, + ): Promise<{ accessToken: string }> { + const key = address.toLowerCase(); + const nonce = this.nonces.get(key); + if (!nonce) + throw new UnauthorizedException('No nonce issued for this address'); + + const ok = await verifyMessage({ + address: address as `0x${string}`, + message: `PolyPay Arc login: ${nonce}`, + signature: signature as `0x${string}`, + }); + if (!ok) throw new UnauthorizedException('Invalid signature'); + + this.nonces.delete(key); // one-time use + const accessToken = this.jwt.sign({ sub: key, chainType: 'ecdsa' }); + return { accessToken }; + } +} diff --git a/packages/backend/src/arc/arc-auth/arc-jwt.strategy.ts b/packages/backend/src/arc/arc-auth/arc-jwt.strategy.ts new file mode 100644 index 0000000..31a0e5c --- /dev/null +++ b/packages/backend/src/arc/arc-auth/arc-jwt.strategy.ts @@ -0,0 +1,27 @@ +import { CONFIG_KEYS } from '@/config/config.keys'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; + +// Mirrors JwtStrategy, but Arc users are not persisted as User rows keyed by +// commitment (that only happens in the account-creation flow). The token's +// chainType claim is the source of truth here; no DB lookup is performed. +@Injectable() +export class ArcJwtStrategy extends PassportStrategy(Strategy, 'arc-jwt') { + constructor(configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get(CONFIG_KEYS.JWT_SECRET), + }); + } + + async validate(payload: { sub: string; chainType?: string }) { + if (payload.chainType !== 'ecdsa') { + throw new UnauthorizedException('Not an Arc (ecdsa) token'); + } + + return { address: payload.sub }; + } +} diff --git a/packages/backend/src/arc/arc-auth/dto/arc-login.dto.ts b/packages/backend/src/arc/arc-auth/dto/arc-login.dto.ts new file mode 100644 index 0000000..98929ce --- /dev/null +++ b/packages/backend/src/arc/arc-auth/dto/arc-login.dto.ts @@ -0,0 +1,11 @@ +import { IsEthereumAddress, IsNotEmpty, IsString } from 'class-validator'; + +export class ArcLoginDto { + @IsNotEmpty() + @IsEthereumAddress() + address: string; + + @IsNotEmpty() + @IsString() + signature: string; +} diff --git a/packages/backend/src/arc/arc-auth/dto/arc-nonce.dto.ts b/packages/backend/src/arc/arc-auth/dto/arc-nonce.dto.ts new file mode 100644 index 0000000..c6ae125 --- /dev/null +++ b/packages/backend/src/arc/arc-auth/dto/arc-nonce.dto.ts @@ -0,0 +1,7 @@ +import { IsEthereumAddress, IsNotEmpty } from 'class-validator'; + +export class ArcNonceDto { + @IsNotEmpty() + @IsEthereumAddress() + address: string; +} diff --git a/packages/backend/src/arc/arc-transaction/arc-transaction.controller.ts b/packages/backend/src/arc/arc-transaction/arc-transaction.controller.ts new file mode 100644 index 0000000..222fbfc --- /dev/null +++ b/packages/backend/src/arc/arc-transaction/arc-transaction.controller.ts @@ -0,0 +1,173 @@ +import { + BadRequestException, + Body, + Controller, + Get, + Param, + ParseIntPipe, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { ArcAuthGuard } from '../arc-auth/arc-auth.guard'; +import { CurrentUser } from '@/auth/decorators/current-user.decorator'; +import { ArcTransactionService } from './arc-transaction.service'; +import { CreateArcTransactionDto } from './dto/create-arc-transaction.dto'; +import { ApproveArcTransactionDto } from './dto/approve-arc-transaction.dto'; + +@ApiTags('arc-transactions') +@Controller('arc/transactions') +@UseGuards(ArcAuthGuard) +@ApiBearerAuth('JWT-auth') +export class ArcTransactionController { + constructor(private readonly arcTransactionService: ArcTransactionService) {} + + /** + * Propose a new Arc transaction (signed off-chain by the proposer). + * POST /api/arc/transactions + */ + @Post() + @ApiOperation({ + summary: 'Propose a new Arc multisig transaction', + description: + 'Creates a transaction + first vote from an EIP-191 signature over getTransactionHash(nonce, to, value, data).', + }) + @ApiBody({ type: CreateArcTransactionDto }) + @ApiResponse({ status: 201, description: 'Arc transaction proposed' }) + @ApiResponse({ + status: 400, + description: 'Bad request - invalid data or signer not an owner', + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - invalid token or signature', + }) + async propose( + @CurrentUser() user: { address: string }, + @Body() dto: CreateArcTransactionDto, + ) { + return this.arcTransactionService.propose(dto, user.address); + } + + /** + * Next nonce the backend would assign for a new proposal on this + * account. The proposer must sign over this exact nonce (the contract's + * `execute` takes the nonce as a parameter, checked against `usedNonces`). + * GET /api/arc/transactions/next-nonce?accountId=... + */ + @Get('next-nonce') + @ApiOperation({ + summary: 'Get the next nonce for a new Arc transaction proposal', + }) + @ApiQuery({ + name: 'accountId', + required: true, + description: 'Arc account id', + }) + @ApiResponse({ status: 200, description: 'Next nonce' }) + @ApiResponse({ status: 401, description: 'Unauthorized - invalid token' }) + @ApiResponse({ + status: 403, + description: 'Forbidden - caller is not a signer of this account', + }) + async nextNonce( + @CurrentUser() user: { address: string }, + @Query('accountId') accountId: string, + ) { + if (!accountId) { + throw new BadRequestException('accountId query parameter is required'); + } + const nonce = await this.arcTransactionService.nextNonce( + accountId, + user.address, + ); + return { nonce }; + } + + /** + * List Arc transactions for an account. + * GET /api/arc/transactions?accountId=... + */ + @Get() + @ApiOperation({ + summary: 'List Arc transactions for an account', + }) + @ApiQuery({ + name: 'accountId', + required: true, + description: 'Arc account id', + }) + @ApiResponse({ status: 200, description: 'Arc transactions found' }) + @ApiResponse({ status: 401, description: 'Unauthorized - invalid token' }) + @ApiResponse({ + status: 403, + description: 'Forbidden - caller is not a signer of this account', + }) + async list( + @CurrentUser() user: { address: string }, + @Query('accountId') accountId: string, + ) { + if (!accountId) { + throw new BadRequestException('accountId query parameter is required'); + } + return this.arcTransactionService.getTransactions(accountId, user.address); + } + + /** + * Approve a pending Arc transaction with an additional signature. + * POST /api/arc/transactions/:txId/approve + */ + @Post(':txId/approve') + @ApiOperation({ + summary: 'Approve a pending Arc transaction', + description: + 'Adds a vote from an EIP-191 signature over the same transaction hash.', + }) + @ApiParam({ name: 'txId', type: 'number' }) + @ApiBody({ type: ApproveArcTransactionDto }) + @ApiResponse({ status: 201, description: 'Vote recorded' }) + @ApiResponse({ + status: 400, + description: 'Bad request - already voted or signer not an owner', + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - invalid token or signature', + }) + @ApiResponse({ status: 404, description: 'Transaction not found' }) + async approve( + @CurrentUser() user: { address: string }, + @Param('txId', ParseIntPipe) txId: number, + @Body() dto: ApproveArcTransactionDto, + ) { + return this.arcTransactionService.approve(txId, dto, user.address); + } + + /** + * Execute an Arc transaction on-chain via the relayer once threshold is met. + * POST /api/arc/transactions/:txId/execute + */ + @Post(':txId/execute') + @ApiOperation({ + summary: 'Execute an approved Arc transaction on-chain', + description: + 'Submits the collected signatures to MetaMultiSigWalletArc.execute via the relayer.', + }) + @ApiParam({ name: 'txId', type: 'number' }) + @ApiResponse({ status: 201, description: 'Transaction executed on-chain' }) + @ApiResponse({ status: 400, description: 'Bad request - threshold not met' }) + @ApiResponse({ status: 401, description: 'Unauthorized - invalid token' }) + @ApiResponse({ status: 404, description: 'Transaction not found' }) + async execute(@Param('txId', ParseIntPipe) txId: number) { + return this.arcTransactionService.execute(txId); + } +} diff --git a/packages/backend/src/arc/arc-transaction/arc-transaction.module.ts b/packages/backend/src/arc/arc-transaction/arc-transaction.module.ts new file mode 100644 index 0000000..733c548 --- /dev/null +++ b/packages/backend/src/arc/arc-transaction/arc-transaction.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '@/database/database.module'; +import { RelayerModule } from '@/relayer-wallet/relayer-wallet.module'; +import { ArcAuthModule } from '../arc-auth/arc-auth.module'; +import { ArcTransactionController } from './arc-transaction.controller'; +import { ArcTransactionService } from './arc-transaction.service'; + +@Module({ + imports: [DatabaseModule, RelayerModule, ArcAuthModule], + controllers: [ArcTransactionController], + providers: [ArcTransactionService], + exports: [ArcTransactionService], +}) +export class ArcTransactionModule {} diff --git a/packages/backend/src/arc/arc-transaction/arc-transaction.service.spec.ts b/packages/backend/src/arc/arc-transaction/arc-transaction.service.spec.ts new file mode 100644 index 0000000..1d624cc --- /dev/null +++ b/packages/backend/src/arc/arc-transaction/arc-transaction.service.spec.ts @@ -0,0 +1,354 @@ +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import { encodePacked, keccak256 } from 'viem'; +import { ArcTransactionService } from './arc-transaction.service'; +import { PrismaService } from '@/database/prisma.service'; +import { RelayerService } from '@/relayer-wallet/relayer-wallet.service'; + +const ARC_CHAIN_ID = 5042002; +const WALLET = `0x${'a'.repeat(40)}`; + +async function signArcTx( + signer: ReturnType, + nonce: number, + to: string, + value: string, + data: string, +) { + const txHash = keccak256( + encodePacked( + ['address', 'uint256', 'uint256', 'address', 'uint256', 'bytes'], + [ + WALLET as `0x${string}`, + BigInt(ARC_CHAIN_ID), + BigInt(nonce), + to as `0x${string}`, + BigInt(value), + data as `0x${string}`, + ], + ), + ); + return signer.signMessage({ message: { raw: txHash } }); +} + +// Minimal in-memory Prisma mock covering only the models/methods +// ArcTransactionService touches: account.findUnique, transaction.findFirst/ +// findMany/findUnique/create/update, vote.create/findUnique/count, +// $transaction passthrough. +function makePrismaMock(account: { + id: string; + address: string; + chainId: number; + chainType: string; + threshold: number; + signers: { address: string }[]; +}) { + let txSeq = 0; + const transactions = new Map(); + const votes: any[] = []; + + const prisma: any = { + account: { + findUnique: jest.fn(async ({ where }: any) => { + if (where.id !== account.id) return null; + return { ...account }; + }), + }, + transaction: { + findFirst: jest.fn(async ({ where, orderBy }: any) => { + const rows = [...transactions.values()].filter( + (t) => + t.accountAddress === where.accountAddress && + t.chainId === where.chainId, + ); + if (rows.length === 0) return null; + rows.sort((a, b) => + orderBy.nonce === 'desc' ? b.nonce - a.nonce : a.nonce - b.nonce, + ); + return { nonce: rows[0].nonce }; + }), + findUnique: jest.fn(async ({ where, include }: any) => { + const tx = transactions.get(where.txId); + if (!tx) return null; + const result: any = { ...tx }; + if (include?.account) result.account = { ...account }; + if (include?.votes) + result.votes = votes.filter((v) => v.txId === where.txId); + return result; + }), + findMany: jest.fn(async ({ where, include, orderBy }: any) => { + const rows = [...transactions.values()].filter( + (t) => + t.accountAddress === where.accountAddress && + t.chainId === where.chainId, + ); + rows.sort((a, b) => + orderBy.nonce === 'desc' ? b.nonce - a.nonce : a.nonce - b.nonce, + ); + return rows.map((tx) => { + const result: any = { ...tx }; + if (include?.votes) + result.votes = votes.filter((v) => v.txId === tx.txId); + return result; + }); + }), + create: jest.fn(async ({ data }: any) => { + const tx = { + id: `tx-${txSeq + 1}`, + txId: ++txSeq, + status: 'PENDING', + ...data, + }; + transactions.set(tx.txId, tx); + return tx; + }), + update: jest.fn(async ({ where, data }: any) => { + const tx = transactions.get(where.txId); + Object.assign(tx, data); + return tx; + }), + }, + vote: { + create: jest.fn(async ({ data }: any) => { + const vote = { id: `vote-${votes.length + 1}`, ...data }; + votes.push(vote); + return vote; + }), + findUnique: jest.fn(async ({ where }: any) => { + const { txId, voterCommitment } = where.txId_voterCommitment; + return ( + votes.find( + (v) => v.txId === txId && v.voterCommitment === voterCommitment, + ) ?? null + ); + }), + count: jest.fn( + async ({ where }: any) => + votes.filter( + (v) => + v.txId === where.txId && + (!where.voteType || v.voteType === where.voteType), + ).length, + ), + }, + $transaction: jest.fn(async (fn: any) => fn(prisma)), + }; + + return prisma; +} + +describe('ArcTransactionService', () => { + let ownerA: ReturnType; + let ownerB: ReturnType; + let outsider: ReturnType; + const to = `0x${'b'.repeat(40)}`; + const value = '100'; + + beforeEach(() => { + ownerA = privateKeyToAccount(generatePrivateKey()); + ownerB = privateKeyToAccount(generatePrivateKey()); + outsider = privateKeyToAccount(generatePrivateKey()); + }); + + function makeAccount(threshold: number) { + return { + id: 'account-1', + address: WALLET, + chainId: ARC_CHAIN_ID, + chainType: 'ecdsa', + threshold, + signers: [{ address: ownerA.address }, { address: ownerB.address }], + }; + } + + it('propose: an owner-signed proposal creates a Transaction + first Vote', async () => { + const account = makeAccount(2); + const prisma = makePrismaMock(account); + const relayer = { executeArcTransaction: jest.fn() }; + const service = new ArcTransactionService( + prisma as unknown as PrismaService, + relayer as unknown as RelayerService, + ); + + const signature = await signArcTx(ownerA, 0, to, value, '0x'); + + const res = await service.propose( + { accountId: account.id, to, value, data: '0x', signature }, + ownerA.address.toLowerCase(), + ); + + expect(res.txId).toBe(1); + expect(res.nonce).toBe(0); + expect(prisma.vote.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + voterCommitment: ownerA.address.toLowerCase(), + signature, + }), + }), + ); + }); + + it('propose: a non-owner-signed proposal is rejected', async () => { + const account = makeAccount(2); + const prisma = makePrismaMock(account); + const relayer = { executeArcTransaction: jest.fn() }; + const service = new ArcTransactionService( + prisma as unknown as PrismaService, + relayer as unknown as RelayerService, + ); + + const signature = await signArcTx(outsider, 0, to, value, '0x'); + + await expect( + service.propose( + { accountId: account.id, to, value, data: '0x', signature }, + outsider.address.toLowerCase(), + ), + ).rejects.toThrow(); + expect(prisma.transaction.create).not.toHaveBeenCalled(); + }); + + it('approve: dedupes by recovered signer address', async () => { + const account = makeAccount(2); + const prisma = makePrismaMock(account); + const relayer = { executeArcTransaction: jest.fn() }; + const service = new ArcTransactionService( + prisma as unknown as PrismaService, + relayer as unknown as RelayerService, + ); + + const proposeSig = await signArcTx(ownerA, 0, to, value, '0x'); + await service.propose( + { accountId: account.id, to, value, data: '0x', signature: proposeSig }, + ownerA.address.toLowerCase(), + ); + + const approveSig = await signArcTx(ownerB, 0, to, value, '0x'); + await service.approve( + 1, + { signature: approveSig }, + ownerB.address.toLowerCase(), + ); + + await expect( + service.approve( + 1, + { signature: approveSig }, + ownerB.address.toLowerCase(), + ), + ).rejects.toThrow('Already voted'); + }); + + it('execute: rejects when collected signatures are below threshold', async () => { + const account = makeAccount(2); + const prisma = makePrismaMock(account); + const relayer = { executeArcTransaction: jest.fn() }; + const service = new ArcTransactionService( + prisma as unknown as PrismaService, + relayer as unknown as RelayerService, + ); + + const proposeSig = await signArcTx(ownerA, 0, to, value, '0x'); + await service.propose( + { accountId: account.id, to, value, data: '0x', signature: proposeSig }, + ownerA.address.toLowerCase(), + ); + + await expect(service.execute(1)).rejects.toThrow('threshold not met'); + expect(relayer.executeArcTransaction).not.toHaveBeenCalled(); + }); + + it('execute: calls the relayer once threshold is met and marks the transaction executed', async () => { + const account = makeAccount(2); + const prisma = makePrismaMock(account); + const relayer = { + executeArcTransaction: jest.fn().mockResolvedValue('0xTXHASH'), + }; + const service = new ArcTransactionService( + prisma as unknown as PrismaService, + relayer as unknown as RelayerService, + ); + + const proposeSig = await signArcTx(ownerA, 0, to, value, '0x'); + await service.propose( + { accountId: account.id, to, value, data: '0x', signature: proposeSig }, + ownerA.address.toLowerCase(), + ); + const approveSig = await signArcTx(ownerB, 0, to, value, '0x'); + await service.approve( + 1, + { signature: approveSig }, + ownerB.address.toLowerCase(), + ); + + const res = await service.execute(1); + + expect(relayer.executeArcTransaction).toHaveBeenCalledWith( + WALLET, + 0, + to, + BigInt(value), + '0x', + [proposeSig, approveSig], + ARC_CHAIN_ID, + ); + expect(res.status).toBe('EXECUTED'); + expect(res.txHash).toBe('0xTXHASH'); + }); + + it('getTransactions: rejects a caller who is not a signer of the account (IDOR)', async () => { + const account = makeAccount(2); + const prisma = makePrismaMock(account); + const relayer = { executeArcTransaction: jest.fn() }; + const service = new ArcTransactionService( + prisma as unknown as PrismaService, + relayer as unknown as RelayerService, + ); + + await expect( + service.getTransactions(account.id, outsider.address.toLowerCase()), + ).rejects.toThrow('Caller is not a signer of this account'); + }); + + it('getTransactions: allows a caller who is a signer of the account', async () => { + const account = makeAccount(2); + const prisma = makePrismaMock(account); + const relayer = { executeArcTransaction: jest.fn() }; + const service = new ArcTransactionService( + prisma as unknown as PrismaService, + relayer as unknown as RelayerService, + ); + + await expect( + service.getTransactions(account.id, ownerA.address.toLowerCase()), + ).resolves.toEqual([]); + }); + + it('nextNonce: rejects a caller who is not a signer of the account (IDOR)', async () => { + const account = makeAccount(2); + const prisma = makePrismaMock(account); + const relayer = { executeArcTransaction: jest.fn() }; + const service = new ArcTransactionService( + prisma as unknown as PrismaService, + relayer as unknown as RelayerService, + ); + + await expect( + service.nextNonce(account.id, outsider.address.toLowerCase()), + ).rejects.toThrow('Caller is not a signer of this account'); + }); + + it('nextNonce: allows a caller who is a signer of the account', async () => { + const account = makeAccount(2); + const prisma = makePrismaMock(account); + const relayer = { executeArcTransaction: jest.fn() }; + const service = new ArcTransactionService( + prisma as unknown as PrismaService, + relayer as unknown as RelayerService, + ); + + await expect( + service.nextNonce(account.id, ownerB.address.toLowerCase()), + ).resolves.toBe(0); + }); +}); diff --git a/packages/backend/src/arc/arc-transaction/arc-transaction.service.ts b/packages/backend/src/arc/arc-transaction/arc-transaction.service.ts new file mode 100644 index 0000000..0b08985 --- /dev/null +++ b/packages/backend/src/arc/arc-transaction/arc-transaction.service.ts @@ -0,0 +1,351 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { TxStatus, TxType, VoteType } from '@polypay/shared'; +import { PrismaService } from '@/database/prisma.service'; +import { RelayerService } from '@/relayer-wallet/relayer-wallet.service'; +import { recoverArcSigner } from './signature.util'; +import { CreateArcTransactionDto } from './dto/create-arc-transaction.dto'; +import { ApproveArcTransactionDto } from './dto/approve-arc-transaction.dto'; + +// Arc transactions are native-value only today (see CreateArcTransactionDto), +// so calldata is always empty. +const ARC_NATIVE_TRANSFER_DATA = '0x'; + +type AccountWithSigners = { + id: string; + address: string; + chainId: number; + chainType: string; + threshold: number; + signers: { address: string | null }[]; +}; + +@Injectable() +export class ArcTransactionService { + private readonly logger = new Logger(ArcTransactionService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly relayer: RelayerService, + ) {} + + /** + * Propose an Arc transaction. The proposer signs + * getTransactionHash(nonce, to, value, data) off-chain (EIP-191); the + * recovered address becomes both the owner-membership check and the + * identity stored on the first vote. + */ + async propose(dto: CreateArcTransactionDto, address: string) { + const account = await this.getArcAccount(dto.accountId); + const nonce = await this.getNextNonce(account.address, account.chainId); + + const recovered = await this.recoverAndAuthorize( + account, + nonce, + dto.to, + dto.value, + dto.data, + dto.signature, + address, + ); + + const transaction = await this.prisma.$transaction(async (prisma) => { + const tx = await prisma.transaction.create({ + data: { + nonce, + type: TxType.TRANSFER, + status: TxStatus.PENDING, + accountAddress: account.address, + chainId: account.chainId, + threshold: account.threshold, + to: dto.to, + value: dto.value, + createdBy: recovered, + }, + }); + + // Vote has no dedicated "signer address" column for ECDSA (Arc) votes - + // voterCommitment is reused as the identity key, matching how + // ArcAccountService stores addresses on User.address instead of + // User.commitment for the same reason. + await prisma.vote.create({ + data: { + txId: tx.txId, + voterCommitment: recovered, + voteType: VoteType.APPROVE, + signature: dto.signature, + }, + }); + + return tx; + }); + + this.logger.log( + `Arc transaction proposed: txId=${transaction.txId} account=${account.address} by=${recovered}`, + ); + + return { + txId: transaction.txId, + nonce: transaction.nonce, + status: transaction.status, + }; + } + + /** + * Approve (add a signature to) a pending Arc transaction. + */ + async approve(txId: number, dto: ApproveArcTransactionDto, address: string) { + const transaction = await this.prisma.transaction.findUnique({ + where: { txId }, + include: { account: { include: { signers: true } } }, + }); + + if (!transaction) { + throw new NotFoundException(`Transaction ${txId} not found`); + } + + if (transaction.status === TxStatus.EXECUTED) { + throw new BadRequestException('Transaction already executed'); + } + + const account = transaction.account as unknown as AccountWithSigners; + + const recovered = await this.recoverAndAuthorize( + account, + transaction.nonce, + transaction.to, + transaction.value, + ARC_NATIVE_TRANSFER_DATA, + dto.signature, + address, + ); + + const existingVote = await this.prisma.vote.findUnique({ + where: { txId_voterCommitment: { txId, voterCommitment: recovered } }, + }); + + if (existingVote) { + throw new BadRequestException('Already voted on this transaction'); + } + + await this.prisma.vote.create({ + data: { + txId, + voterCommitment: recovered, + voteType: VoteType.APPROVE, + signature: dto.signature, + }, + }); + + this.logger.log(`Arc vote added for txId: ${txId} by ${recovered}`); + + return { + txId, + status: transaction.status, + approveCount: await this.prisma.vote.count({ + where: { txId, voteType: VoteType.APPROVE }, + }), + threshold: transaction.threshold, + }; + } + + /** + * Execute the transaction on-chain via the relayer once enough valid + * signatures have been collected. + */ + async execute(txId: number) { + const transaction = await this.prisma.transaction.findUnique({ + where: { txId }, + include: { votes: true, account: true }, + }); + + if (!transaction) { + throw new NotFoundException(`Transaction ${txId} not found`); + } + + if (transaction.status === TxStatus.EXECUTED) { + throw new BadRequestException('Transaction already executed'); + } + + const signatures = transaction.votes + .filter((vote) => vote.voteType === VoteType.APPROVE) + .map((vote) => vote.signature) + .filter((signature): signature is string => Boolean(signature)); + + if (signatures.length < transaction.account.threshold) { + throw new BadRequestException('threshold not met'); + } + + const txHash = await this.relayer.executeArcTransaction( + transaction.accountAddress, + transaction.nonce, + transaction.to, + BigInt(transaction.value), + ARC_NATIVE_TRANSFER_DATA, + signatures, + transaction.chainId, + ); + + const updated = await this.prisma.transaction.update({ + where: { txId }, + data: { + status: TxStatus.EXECUTED, + txHash, + executedAt: new Date(), + }, + }); + + this.logger.log(`Arc transaction executed: txId=${txId} txHash=${txHash}`); + + return { + txId: updated.txId, + status: updated.status, + txHash: updated.txHash, + }; + } + + /** + * Next nonce the backend would assign for a fresh proposal on this + * account. Exposed via GET /arc/transactions/next-nonce so the frontend + * can fetch it before signing (the contract's parameterized-nonce + * `execute` requires the proposer to sign over this exact nonce). + */ + async nextNonce(accountId: string, callerAddress: string): Promise { + const account = await this.getArcAccount(accountId); + this.assertMember(account, callerAddress); + return this.getNextNonce(account.address, account.chainId); + } + + /** + * List Arc transactions for an account, most recent first. + */ + async getTransactions(accountId: string, callerAddress: string) { + const account = await this.getArcAccount(accountId); + this.assertMember(account, callerAddress); + + const transactions = await this.prisma.transaction.findMany({ + where: { accountAddress: account.address, chainId: account.chainId }, + include: { votes: true }, + orderBy: { nonce: 'desc' }, + }); + + return transactions.map((tx) => { + const approvals = tx.votes.filter( + (vote) => vote.voteType === VoteType.APPROVE, + ); + + return { + id: tx.txId, + nonce: tx.nonce, + to: tx.to, + value: tx.value, + data: ARC_NATIVE_TRANSFER_DATA, + status: tx.status, + threshold: tx.threshold, + approveCount: approvals.length, + voters: approvals.map((vote) => vote.voterCommitment), + txHash: tx.txHash, + }; + }); + } + + // ============ Private Methods ============ + + private async getArcAccount(accountId: string): Promise { + const account = await this.prisma.account.findUnique({ + where: { id: accountId }, + include: { signers: true }, + }); + + if (!account) { + throw new NotFoundException(`Account ${accountId} not found`); + } + + if (account.chainType !== 'ecdsa') { + throw new BadRequestException( + `Account ${accountId} is not an Arc (ecdsa) account`, + ); + } + + return account as unknown as AccountWithSigners; + } + + /** + * Ensures the authenticated caller is a signer of the account before + * allowing read access to its transactions/nonce (prevents an + * authenticated Arc user from enumerating another account's data by + * guessing accountId). + */ + private assertMember(account: AccountWithSigners, callerAddress: string) { + const isMember = account.signers.some( + (signer) => signer.address?.toLowerCase() === callerAddress.toLowerCase(), + ); + + if (!isMember) { + throw new ForbiddenException('Caller is not a signer of this account'); + } + } + + private async getNextNonce( + accountAddress: string, + chainId: number, + ): Promise { + const last = await this.prisma.transaction.findFirst({ + where: { accountAddress, chainId }, + orderBy: { nonce: 'desc' }, + select: { nonce: true }, + }); + + return (last?.nonce ?? -1) + 1; + } + + /** + * Recovers the signer from an Arc tx signature, requires it to match the + * authenticated caller (defense-in-depth: a caller cannot submit another + * owner's signature under their own JWT), and requires it to be an owner + * of the account. + */ + private async recoverAndAuthorize( + account: AccountWithSigners, + nonce: number, + to: string, + value: string, + data: string, + signature: string, + address: string, + ): Promise { + const recovered = ( + await recoverArcSigner( + account.address, + account.chainId, + nonce, + to, + BigInt(value), + data, + signature, + ) + ).toLowerCase(); + + if (recovered !== address.toLowerCase()) { + throw new UnauthorizedException( + 'Signature does not match authenticated address', + ); + } + + const isOwner = account.signers.some( + (signer) => signer.address?.toLowerCase() === recovered, + ); + + if (!isOwner) { + throw new BadRequestException('Signer is not an owner of this account'); + } + + return recovered; + } +} diff --git a/packages/backend/src/arc/arc-transaction/dto/approve-arc-transaction.dto.ts b/packages/backend/src/arc/arc-transaction/dto/approve-arc-transaction.dto.ts new file mode 100644 index 0000000..ee37aa1 --- /dev/null +++ b/packages/backend/src/arc/arc-transaction/dto/approve-arc-transaction.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class ApproveArcTransactionDto { + @IsNotEmpty() + @IsString() + signature: string; +} diff --git a/packages/backend/src/arc/arc-transaction/dto/create-arc-transaction.dto.ts b/packages/backend/src/arc/arc-transaction/dto/create-arc-transaction.dto.ts new file mode 100644 index 0000000..09dd365 --- /dev/null +++ b/packages/backend/src/arc/arc-transaction/dto/create-arc-transaction.dto.ts @@ -0,0 +1,33 @@ +import { + IsEthereumAddress, + IsIn, + IsNotEmpty, + IsNumberString, + IsString, +} from 'class-validator'; + +// USDC is native on Arc, so a transfer is a plain native-value call +// (to = recipient, value = amount, data = "0x"), not an ERC20 call. +// `data` is only validated (not persisted) since Arc currently supports +// native transfers exclusively - Transaction has no generic calldata column. +export class CreateArcTransactionDto { + @IsNotEmpty() + @IsString() + accountId: string; + + @IsNotEmpty() + @IsEthereumAddress() + to: string; + + @IsNotEmpty() + @IsNumberString() + value: string; + + @IsNotEmpty() + @IsIn(['0x']) + data: string; + + @IsNotEmpty() + @IsString() + signature: string; +} diff --git a/packages/backend/src/arc/arc-transaction/signature.util.spec.ts b/packages/backend/src/arc/arc-transaction/signature.util.spec.ts new file mode 100644 index 0000000..fcbf059 --- /dev/null +++ b/packages/backend/src/arc/arc-transaction/signature.util.spec.ts @@ -0,0 +1,64 @@ +import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts'; +import { encodePacked, keccak256 } from 'viem'; +import { recoverArcSigner } from './signature.util'; + +describe('recoverArcSigner', () => { + it('recovers the signer of an EIP-191 signed tx hash', async () => { + const account = privateKeyToAccount(generatePrivateKey()); + const wallet = '0x41D925843a192F859cf14aba4789744b1e58eB15'; + const [chainId, nonce, to, value, data] = [ + 5042002, + 0, + account.address, + 10n, + '0x', + ] as const; + const txHash = keccak256( + encodePacked( + ['address', 'uint256', 'uint256', 'address', 'uint256', 'bytes'], + [wallet, BigInt(chainId), BigInt(nonce), to, value, data], + ), + ); + const signature = await account.signMessage({ message: { raw: txHash } }); + const recovered = await recoverArcSigner( + wallet, + chainId, + nonce, + to, + value, + data, + signature, + ); + expect(recovered.toLowerCase()).toBe(account.address.toLowerCase()); + }); + + it('does not recover the signer address when a different signature is used', async () => { + const account = privateKeyToAccount(generatePrivateKey()); + const other = privateKeyToAccount(generatePrivateKey()); + const wallet = '0x41D925843a192F859cf14aba4789744b1e58eB15'; + const [chainId, nonce, to, value, data] = [ + 5042002, + 0, + account.address, + 10n, + '0x', + ] as const; + const txHash = keccak256( + encodePacked( + ['address', 'uint256', 'uint256', 'address', 'uint256', 'bytes'], + [wallet, BigInt(chainId), BigInt(nonce), to, value, data], + ), + ); + const signature = await other.signMessage({ message: { raw: txHash } }); + const recovered = await recoverArcSigner( + wallet, + chainId, + nonce, + to, + value, + data, + signature, + ); + expect(recovered.toLowerCase()).not.toBe(account.address.toLowerCase()); + }); +}); diff --git a/packages/backend/src/arc/arc-transaction/signature.util.ts b/packages/backend/src/arc/arc-transaction/signature.util.ts new file mode 100644 index 0000000..ff57a5e --- /dev/null +++ b/packages/backend/src/arc/arc-transaction/signature.util.ts @@ -0,0 +1,39 @@ +import { encodePacked, keccak256, recoverMessageAddress, type Hex } from 'viem'; + +/** + * Recovers the address that signed an Arc multisig transaction. + * + * Mirrors `MetaMultiSigWalletArc.getTransactionHash` on-chain: + * keccak256(abi.encodePacked(address(this), chainId, nonce, to, value, data)) + * Signers sign the resulting 32-byte hash as an EIP-191 personal message + * (`toEthSignedMessageHash` on the contract side); viem's `recoverMessageAddress` + * with `{ raw: txHash }` reproduces that same digest. + */ +export async function recoverArcSigner( + wallet: string, + chainId: number, + nonce: number, + to: string, + value: bigint, + data: string, + signature: string, +): Promise { + const txHash = keccak256( + encodePacked( + ['address', 'uint256', 'uint256', 'address', 'uint256', 'bytes'], + [ + wallet as Hex, + BigInt(chainId), + BigInt(nonce), + to as Hex, + value, + data as Hex, + ], + ), + ); + + return recoverMessageAddress({ + message: { raw: txHash }, + signature: signature as Hex, + }); +} diff --git a/packages/backend/src/arc/arc.module.ts b/packages/backend/src/arc/arc.module.ts new file mode 100644 index 0000000..639427b --- /dev/null +++ b/packages/backend/src/arc/arc.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ArcAuthModule } from './arc-auth/arc-auth.module'; +import { ArcAccountModule } from './arc-account/arc-account.module'; +import { ArcTransactionModule } from './arc-transaction/arc-transaction.module'; + +// Aggregates the Arc (Circle Arc / ECDSA multisig) feature modules so +// app.module.ts only needs a single import. +@Module({ + imports: [ArcAuthModule, ArcAccountModule, ArcTransactionModule], +}) +export class ArcModule {} diff --git a/packages/backend/src/relayer-wallet/relayer-wallet.service.ts b/packages/backend/src/relayer-wallet/relayer-wallet.service.ts index 62f6b13..8dc0584 100644 --- a/packages/backend/src/relayer-wallet/relayer-wallet.service.ts +++ b/packages/backend/src/relayer-wallet/relayer-wallet.service.ts @@ -4,6 +4,7 @@ import { createPublicClient, http, decodeFunctionData, + defineChain, } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import { @@ -13,6 +14,9 @@ import { isStylusChain, getStylusFactoryAddress, METAMULTISIG_STYLUS_FACTORY_ABI, + ARC_TESTNET_CHAIN_ID, + META_MULTISIG_ARC_ABI, + META_MULTISIG_ARC_BYTECODE, } from '@polypay/shared'; import { METAMULTISIG_ABI, METAMULTISIG_BYTECODE } from '@polypay/shared'; import { ConfigService } from '@nestjs/config'; @@ -20,6 +24,25 @@ import { CONFIG_KEYS } from '@/config/config.keys'; import { waitForReceiptWithRetry } from '@/common/utils/retry'; import { SUPPORTED_CHAIN_IDS } from '@/common/constants/campaign'; import { GAS_BUFFER_EXECUTE } from '@/common/constants/timing'; +import { recoverArcSigner } from '@/arc/arc-transaction/signature.util'; + +// Arc testnet has no zkVerify deployment (it's "ecdsa", not "zk" - see +// getChainType in @polypay/shared), so it is intentionally excluded from +// SUPPORTED_CHAIN_IDS / clientsByChainId below. Defined locally until a +// shared chain object exists (the frontend defines its own equivalent via +// viem's defineChain in scaffold.config.ts). +const arcTestnet = defineChain({ + id: ARC_TESTNET_CHAIN_ID, + name: 'Arc Testnet', + nativeCurrency: { name: 'USD Coin', symbol: 'USDC', decimals: 18 }, + rpcUrls: { + default: { http: ['https://rpc.testnet.arc.network'] }, + }, + blockExplorers: { + default: { name: 'Arc Explorer', url: 'https://testnet.arcscan.app' }, + }, + testnet: true, +}); type RelayerChainClient = { chain: any; @@ -90,6 +113,138 @@ export class RelayerService { return client; } + /** + * Deploy MetaMultiSigWalletArc for an Arc account. Owners are plain ECDSA + * addresses (no ZK commitments/proof verifier), so this is a sibling of + * deployAccount rather than a branch inside it - same viem client/account + * construction, different ABI/bytecode/args and no cached per-chain client + * (Arc is not in SUPPORTED_CHAIN_IDS / clientsByChainId). + */ + async deployArcAccount( + owners: string[], + threshold: number, + chainId: number, + ): Promise { + if (chainId !== ARC_TESTNET_CHAIN_ID) { + throw new Error(`Relayer: unsupported Arc chainId ${chainId}`); + } + + // Typed `any` like RelayerChainClient above: with a concrete chain type, + // viem's deployContract overload resolution gets confused by EIP-4844 + // fields and demands a `kzg` param that doesn't apply here. + const chain: any = arcTestnet; + + const publicClient: any = createPublicClient({ + chain, + transport: http(), + }); + + const walletClient: any = createWalletClient({ + account: this.account, + chain, + transport: http(), + }); + + const hash = await walletClient.deployContract({ + abi: META_MULTISIG_ARC_ABI, + bytecode: META_MULTISIG_ARC_BYTECODE as `0x${string}`, + args: [BigInt(chainId), owners as `0x${string}`[], BigInt(threshold)], + account: this.account, + chain, + }); + + this.logger.log( + `Arc deploy tx sent on chain ${chainId} for relayer ${this.account.address}: ${hash}`, + ); + + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + + if (!receipt.contractAddress) { + throw new Error('Arc wallet deploy: no contractAddress in receipt'); + } + + this.logger.log(`Arc wallet deployed at: ${receipt.contractAddress}`); + + return receipt.contractAddress; + } + + /** + * Execute a transaction on MetaMultiSigWalletArc once enough owner + * signatures have been collected. Sibling of deployArcAccount: same + * ad-hoc client construction (Arc is not in clientsByChainId), different + * ABI call. The contract requires signatures strictly ascending by + * recovered signer address, so they are sorted here before submission. + */ + async executeArcTransaction( + walletAddress: string, + nonce: number, + to: string, + value: bigint, + data: string, + signatures: string[], + chainId: number, + ): Promise { + if (chainId !== ARC_TESTNET_CHAIN_ID) { + throw new Error(`Relayer: unsupported Arc chainId ${chainId}`); + } + + const chain: any = arcTestnet; + + const publicClient: any = createPublicClient({ + chain, + transport: http(), + }); + + const walletClient: any = createWalletClient({ + account: this.account, + chain, + transport: http(), + }); + + const withAddr = await Promise.all( + signatures.map(async (s) => ({ + s, + a: ( + await recoverArcSigner( + walletAddress, + chainId, + nonce, + to, + value, + data, + s, + ) + ).toLowerCase(), + })), + ); + withAddr.sort((x, y) => (x.a < y.a ? -1 : x.a > y.a ? 1 : 0)); + + const hash = await walletClient.writeContract({ + address: walletAddress as `0x${string}`, + abi: META_MULTISIG_ARC_ABI, + functionName: 'execute', + args: [ + BigInt(nonce), + to as `0x${string}`, + value, + data as `0x${string}`, + withAddr.map((x) => x.s as `0x${string}`), + ], + account: this.account, + chain, + }); + + this.logger.log(`Arc execute tx sent on chain ${chainId}: ${hash}`); + + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + + if (receipt.status === 'reverted') { + throw new Error(`Arc transaction reverted on-chain. TxHash: ${hash}`); + } + + return receipt.transactionHash; + } + /** * Deploy MetaMultiSigWallet contract */ diff --git a/packages/hardhat/contracts/MetaMultiSigWalletArc.sol b/packages/hardhat/contracts/MetaMultiSigWalletArc.sol new file mode 100644 index 0000000..4253eae --- /dev/null +++ b/packages/hardhat/contracts/MetaMultiSigWalletArc.sol @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +/** + * @title MetaMultiSigWalletArc + * @notice Non-private ECDSA multisig for Circle's Arc network. It keeps the same + * transfer / batch surface as the production ZK `MetaMultiSigWallet`, but signer + * identities are plain addresses verified with `ecrecover` — no zkVerify, Noir, + * Kurier, or Poseidon. Signer identities are therefore PUBLIC. + * + * Arc has no zkVerify contract, so the privacy-preserving wallet cannot run there; + * this variant trades privacy for portability. Use the production wallet (with the + * ZK flow) wherever signer privacy matters. + */ +contract MetaMultiSigWalletArc { + using ECDSA for bytes32; + using MessageHashUtils for bytes32; + + // ============ Events ============ + event Deposit(address indexed sender, uint256 amount, uint256 balance); + event TransactionExecuted( + address indexed executor, + uint256 indexed nonce, + address to, + uint256 value, + bytes data, + bytes result + ); + event Owner(address indexed owner, bool isAdded); + + // ============ State ============ + uint256 public immutable chainId; + uint256 public signaturesRequired; + + // Nonce tracking (prevent replay) + mapping(uint256 => bool) public usedNonces; + + mapping(address => bool) public isOwner; + address[] public owners; + + // ============ Constructor ============ + constructor(uint256 _chainId, address[] memory _owners, uint256 _signaturesRequired) { + require(_signaturesRequired > 0, "Must be non-zero sigs required"); + require(_owners.length > 0, "Need at least 1 signer"); + require(_signaturesRequired <= _owners.length, "Sigs required too high"); + + chainId = _chainId; + signaturesRequired = _signaturesRequired; + + for (uint256 i = 0; i < _owners.length; i++) { + address owner = _owners[i]; + require(owner != address(0), "Invalid owner"); + require(!isOwner[owner], "Owner exists"); + isOwner[owner] = true; + owners.push(owner); + emit Owner(owner, true); + } + } + + // ============ Modifiers ============ + modifier onlySelf() { + require(msg.sender == address(this), "Not Self"); + _; + } + + // ============ Main Execute Function ============ + /** + * @notice Execute a transaction once enough owner signatures are supplied. + * @dev Signatures must be sorted by ascending recovered signer address; this both + * enforces uniqueness (no signer counted twice) and gives a deterministic order. + * Signers sign `getTransactionHash(nonce, ...)` as an EIP-191 personal message. + */ + function execute( + uint256 _nonce, + address to, + uint256 value, + bytes calldata data, + bytes[] calldata signatures + ) external returns (bytes memory) { + require(!usedNonces[_nonce], "Nonce already used"); + require(signatures.length >= signaturesRequired, "Not enough signatures"); + + bytes32 digest = getTransactionHash(_nonce, to, value, data).toEthSignedMessageHash(); + + address lastSigner = address(0); + for (uint256 i = 0; i < signatures.length; i++) { + address recovered = digest.recover(signatures[i]); + require(uint160(recovered) > uint160(lastSigner), "Duplicate or unordered signer"); + require(isOwner[recovered], "Not an owner"); + lastSigner = recovered; + } + + usedNonces[_nonce] = true; + + (bool success, bytes memory result) = to.call{ value: value }(data); + require(success, "Tx failed"); + + emit TransactionExecuted(msg.sender, _nonce, to, value, data, result); + return result; + } + + // ============ Signer Management (self-called via execute) ============ + function addSigner(address newSigner, uint256 newSignaturesRequired) public onlySelf { + require(newSigner != address(0), "Invalid owner"); + require(!isOwner[newSigner], "Owner exists"); + require(newSignaturesRequired > 0, "Must be non-zero sigs required"); + + isOwner[newSigner] = true; + owners.push(newSigner); + signaturesRequired = newSignaturesRequired; + + require(signaturesRequired <= owners.length, "Sigs required too high"); + emit Owner(newSigner, true); + } + + function removeSigner(address oldSigner, uint256 newSignaturesRequired) public onlySelf { + require(isOwner[oldSigner], "Not an owner"); + require(owners.length > 1, "Cannot remove all signers"); + require(newSignaturesRequired > 0, "Must be non-zero sigs required"); + + isOwner[oldSigner] = false; + for (uint256 i = 0; i < owners.length; i++) { + if (owners[i] == oldSigner) { + owners[i] = owners[owners.length - 1]; + owners.pop(); + break; + } + } + signaturesRequired = newSignaturesRequired; + + require(signaturesRequired <= owners.length, "Sigs required too high"); + emit Owner(oldSigner, false); + } + + function updateSignaturesRequired(uint256 newSignaturesRequired) public onlySelf { + require(newSignaturesRequired > 0, "Must be non-zero sigs required"); + require(newSignaturesRequired <= owners.length, "Sigs required too high"); + signaturesRequired = newSignaturesRequired; + } + + // ============ Batch Transfers (self-called via execute) ============ + /** + * @notice Execute multiple native-token transfers in one transaction. + */ + function batchTransfer(address[] calldata recipients, uint256[] calldata amounts) public onlySelf { + require(recipients.length == amounts.length, "Length mismatch"); + require(recipients.length > 0, "Empty batch"); + + for (uint256 i = 0; i < recipients.length; i++) { + require(recipients[i] != address(0), "Invalid recipient"); + (bool success, ) = recipients[i].call{ value: amounts[i] }(""); + require(success, "Transfer failed"); + } + } + + /** + * @notice Execute multiple transfers with mixed token types. + * @param tokenAddresses address(0) = native token, otherwise an ERC20. + */ + function batchTransferMulti( + address[] calldata recipients, + uint256[] calldata amounts, + address[] calldata tokenAddresses + ) public onlySelf { + require(recipients.length == amounts.length, "Length mismatch"); + require(recipients.length == tokenAddresses.length, "Length mismatch"); + require(recipients.length > 0, "Empty batch"); + + for (uint256 i = 0; i < recipients.length; i++) { + require(recipients[i] != address(0), "Invalid recipient"); + + if (tokenAddresses[i] == address(0)) { + (bool success, ) = recipients[i].call{ value: amounts[i] }(""); + require(success, "Native transfer failed"); + } else { + (bool success, bytes memory data) = tokenAddresses[i].call( + abi.encodeWithSignature("transfer(address,uint256)", recipients[i], amounts[i]) + ); + require(success && (data.length == 0 || abi.decode(data, (bool))), "ERC20 transfer failed"); + } + } + } + + // ============ Views ============ + function getTransactionHash( + uint256 _nonce, + address to, + uint256 value, + bytes memory data + ) public view returns (bytes32) { + return keccak256(abi.encodePacked(address(this), chainId, _nonce, to, value, data)); + } + + function getOwners() external view returns (address[] memory) { + return owners; + } + + function getSignersCount() external view returns (uint256) { + return owners.length; + } + + // ============ Receive native token ============ + receive() external payable { + emit Deposit(msg.sender, msg.value, address(this).balance); + } +} diff --git a/packages/hardhat/hardhat.config.ts b/packages/hardhat/hardhat.config.ts index 75b3fd8..95c3f18 100644 --- a/packages/hardhat/hardhat.config.ts +++ b/packages/hardhat/hardhat.config.ts @@ -137,6 +137,12 @@ const config: HardhatUserConfig = { accounts: [deployerPrivateKey], chainId: 26514, }, + // Circle Arc public testnet (USDC is the native gas token). Docs: https://docs.arc.io/arc/references/connect-to-arc + arcTestnet: { + url: process.env.ARC_TESTNET_RPC || "https://rpc.testnet.arc.network", + accounts: [deployerPrivateKey], + chainId: 5042002, + }, }, // Configuration for harhdat-verify plugin etherscan: { diff --git a/packages/hardhat/scripts/deployArc.ts b/packages/hardhat/scripts/deployArc.ts new file mode 100644 index 0000000..8b0cdc6 --- /dev/null +++ b/packages/hardhat/scripts/deployArc.ts @@ -0,0 +1,53 @@ +import { ethers, network } from "hardhat"; + +/** + * Standalone deploy for the Arc multisig (MetaMultiSigWalletArc). + * + * This is intentionally NOT a hardhat-deploy script: `yarn deploy` would also run the + * ZK wallet + Poseidon deployers, which cannot work on Arc (no zkVerify contract there). + * Run this directly instead: + * + * yarn hardhat run scripts/deployArc.ts --network arcTestnet + * + * Requirements: + * - __RUNTIME_DEPLOYER_PRIVATE_KEY set to a funded Arc testnet account + * (gas is paid in USDC; get testnet USDC from https://faucet.circle.com). + * + * Optional env: + * - ARC_OWNERS comma-separated owner addresses (default: the deployer) + * - ARC_SIGS_REQUIRED threshold (default: min(2, owners.length)) + */ +async function main() { + const [deployer] = await ethers.getSigners(); + const chainId = Number(network.config.chainId ?? (await ethers.provider.getNetwork()).chainId); + + const owners = (process.env.ARC_OWNERS ?? deployer.address) + .split(",") + .map(a => a.trim()) + .filter(a => a.length > 0); + + const sigsRequired = process.env.ARC_SIGS_REQUIRED + ? Number(process.env.ARC_SIGS_REQUIRED) + : Math.min(2, owners.length); + + console.log(`Network: ${network.name} (chainId ${chainId})`); + console.log(`Deployer: ${deployer.address}`); + console.log(`Balance: ${ethers.formatEther(await ethers.provider.getBalance(deployer.address))} (native gas token)`); + console.log(`Owners: ${owners.join(", ")}`); + console.log(`Threshold: ${sigsRequired}-of-${owners.length}`); + + const factory = await ethers.getContractFactory("MetaMultiSigWalletArc"); + const wallet = await factory.deploy(chainId, owners, sigsRequired); + await wallet.waitForDeployment(); + + const address = await wallet.getAddress(); + console.log(`\nMetaMultiSigWalletArc deployed to: ${address}`); + if (chainId === 5042002) { + console.log(`Explorer: https://testnet.arcscan.app/address/${address}`); + } +} + +main().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/hardhat/scripts/testArc.ts b/packages/hardhat/scripts/testArc.ts new file mode 100644 index 0000000..e42ece4 --- /dev/null +++ b/packages/hardhat/scripts/testArc.ts @@ -0,0 +1,53 @@ +import { ethers, network } from "hardhat"; + +/** + * Smoke-test the deployed Arc multisig (MetaMultiSigWalletArc). + * + * Assumes a 1-of-1 wallet whose sole owner is the deployer (the default from deployArc.ts). + * It funds the wallet with a little native token, then has the owner sign and `execute` a + * transfer back out — proving signature verification + execution work end to end. + * + * WALLET_ADDRESS=0x... yarn hardhat run scripts/testArc.ts --network arcTestnet + */ +async function main() { + const walletAddress = process.env.WALLET_ADDRESS; + if (!walletAddress) throw new Error("Set WALLET_ADDRESS to the deployed MetaMultiSigWalletArc address"); + + const [owner] = await ethers.getSigners(); + const wallet = await ethers.getContractAt("MetaMultiSigWalletArc", walletAddress); + + const fundAmount = ethers.parseEther("0.02"); + const sendAmount = ethers.parseEther("0.01"); + + console.log(`Network: ${network.name}`); + console.log(`Owner: ${owner.address}`); + console.log(`Wallet: ${walletAddress}`); + + // 1. Fund the wallet with native token so it has something to send. + console.log(`\nFunding wallet with ${ethers.formatEther(fundAmount)}...`); + await (await owner.sendTransaction({ to: walletAddress, value: fundAmount })).wait(); + console.log(`Wallet balance: ${ethers.formatEther(await ethers.provider.getBalance(walletAddress))}`); + + // 2. Owner signs the tx hash for nonce 0 and executes a transfer back to the owner. + // The contract tracks used nonces via a usedNonces mapping (not an auto-increment + // counter), so any nonce not yet consumed works; this smoke test uses 0 for the + // first execution on a freshly deployed wallet. + const nonce = 0; + const to = owner.address; + const data = "0x"; + const txHash = await wallet.getTransactionHash(nonce, to, sendAmount, data); + const signature = await owner.signMessage(ethers.getBytes(txHash)); // EIP-191 personal sign + + console.log(`\nExecuting transfer of ${ethers.formatEther(sendAmount)} back to owner (nonce ${nonce})...`); + const receipt = await (await wallet.execute(nonce, to, sendAmount, data, [signature])).wait(); + console.log(`execute() ok in tx ${receipt?.hash}`); + + console.log(`\nWallet balance after: ${ethers.formatEther(await ethers.provider.getBalance(walletAddress))}`); + console.log(`Wallet usedNonces[0] after: ${await wallet.usedNonces(nonce)}`); + console.log("Multisig execute verified."); +} + +main().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/nextjs/app/arc/[accountId]/page.tsx b/packages/nextjs/app/arc/[accountId]/page.tsx new file mode 100644 index 0000000..db59026 --- /dev/null +++ b/packages/nextjs/app/arc/[accountId]/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { useParams } from "next/navigation"; +import ArcAccountPanel from "~~/components/Arc/ArcAccountPanel"; + +export default function ArcTransferPage() { + const { accountId } = useParams<{ accountId: string }>(); + return ; +} diff --git a/packages/nextjs/app/arc/create/page.tsx b/packages/nextjs/app/arc/create/page.tsx new file mode 100644 index 0000000..eff81a1 --- /dev/null +++ b/packages/nextjs/app/arc/create/page.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useEffect } from "react"; +import { ARC_TESTNET_CHAIN_ID } from "@polypay/shared"; +import { Plus, Trash2 } from "lucide-react"; +import { useFieldArray } from "react-hook-form"; +import { useAccount } from "wagmi"; +import { Form, FormField, FormInput } from "~~/components/form"; +import { Button } from "~~/components/ui/button"; +import { useArcAuth } from "~~/hooks/app/arc/useArcAuth"; +import { useArcCreateAccount } from "~~/hooks/app/arc/useArcCreateAccount"; +import { useAppRouter } from "~~/hooks/app/useRouteApp"; +import { useZodForm } from "~~/hooks/form"; +import { CreateArcAccountFormData, createArcAccountSchema } from "~~/lib/form"; +import { notification } from "~~/utils/scaffold-eth"; + +export default function ArcCreateAccountPage() { + const { address, isConnected } = useAccount(); + const { isAuthenticated, login, isLoading: isLoggingIn } = useArcAuth(); + const { createAccount, isPending } = useArcCreateAccount(); + const { goToDashboard } = useAppRouter(); + + const form = useZodForm({ + schema: createArcAccountSchema, + defaultValues: { + owners: [{ address: address ?? "" }], + threshold: 1, + }, + }); + + const { control, watch, setValue, reset } = form; + const { fields, append, remove } = useFieldArray({ control, name: "owners" }); + const owners = watch("owners"); + + // Pre-fill the first owner with the connected wallet address once available. + useEffect(() => { + if (address && !owners?.[0]?.address) { + setValue("owners.0.address", address); + } + }, [address, owners, setValue]); + + const handleAddOwner = () => append({ address: "" }); + + const handleRemoveOwner = (index: number) => { + if (fields.length <= 1) return; + remove(index); + }; + + const onSubmit = async (data: CreateArcAccountFormData) => { + try { + const account = await createAccount( + data.owners.map(o => o.address), + data.threshold, + ARC_TESTNET_CHAIN_ID, + ); + reset({ owners: [{ address: address ?? "" }], threshold: 1 }); + goToDashboard(); + return account; + } catch { + // Errors are already surfaced via notification in useArcCreateAccount. + } + }; + + if (!isConnected) { + return ( +
+

Connect your wallet to create an Arc multisig account.

+
+ ); + } + + if (!isAuthenticated) { + return ( +
+

Sign in with your wallet to continue.

+ +
+ ); + } + + return ( +
+
+

Create Arc multisig

+

Circle Arc testnet - ECDSA multisig (non-private).

+
+ +
+ Non-private - signer addresses are public on Arc, unlike PolyPay's ZK multisig on other chains. +
+ +
+
+
Owners
+ {fields.map((field, index) => ( +
+
+ name={`owners.${index}.address`}> + {({ field: inputField }) => ( + + )} + +
+ +
+ ))} + + +
+ +
+
Threshold
+
+ name="threshold"> + {({ field: thresholdField }) => ( + thresholdField.onChange(Number(e.target.value))} + name="threshold" + /> + )} + + / out of {owners?.length ?? 0} owners +
+
+ + +
+
+ ); +} diff --git a/packages/nextjs/components/Arc/ArcAccountPanel.tsx b/packages/nextjs/components/Arc/ArcAccountPanel.tsx new file mode 100644 index 0000000..1ba453b --- /dev/null +++ b/packages/nextjs/components/Arc/ArcAccountPanel.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { TxStatus } from "@polypay/shared"; +import { useQuery } from "@tanstack/react-query"; +import { formatUnits } from "viem"; +import { Form, FormField, FormInput } from "~~/components/form"; +import { Button } from "~~/components/ui/button"; +import { useArcAuth } from "~~/hooks/app/arc/useArcAuth"; +import { useArcTransactions, useArcTransfer } from "~~/hooks/app/arc/useArcTransfer"; +import { useZodForm } from "~~/hooks/form"; +import { TransferFormData, transferSchema } from "~~/lib/form"; +import { ArcTransaction, arcApi } from "~~/services/api"; +import { notification } from "~~/utils/scaffold-eth"; + +// Arc accounts have no single-account GET endpoint yet, so we fetch the +// signer's account list and resolve the given accountId against it. +const useArcAccount = (accountId: string, enabled: boolean) => { + const { data: accounts } = useQuery({ + queryKey: ["arcAccounts"], + queryFn: arcApi.getAccounts, + enabled, + }); + + return accounts?.find(account => account.id === accountId); +}; + +/** + * The Arc (ECDSA, non-private) account view: propose / approve / execute a USDC + * transfer. Reused by the dashboard, the transfer page, and the /arc/[id] route + * so Arc accounts share the same surfaces as ZK accounts. + */ +export default function ArcAccountPanel({ + accountId, + transactionsOnly, +}: { + accountId: string; + // Dashboard usage: render only the transaction list (no header/form), since the + // dashboard already has its own header and the transfer form lives on the transfer page. + transactionsOnly?: boolean; +}) { + const { address, isAuthenticated, login, isLoading: isLoggingIn } = useArcAuth(); + + const account = useArcAccount(accountId, isAuthenticated); + const { data: transactions, isLoading: isLoadingTxs } = useArcTransactions(accountId); + const { propose, approve, execute, isProposing, isApproving, isExecuting } = useArcTransfer(); + + const form = useZodForm({ + schema: transferSchema, + defaultValues: { recipient: "", amount: "" }, + }); + + const onSubmit = async (data: TransferFormData) => { + if (!account) return; + try { + await propose(account, data.recipient, data.amount); + form.reset({ recipient: "", amount: "" }); + } catch { + // Errors are already surfaced via notification in useArcTransfer. + } + }; + + const handleApprove = async (tx: ArcTransaction) => { + if (!account) return; + try { + await approve(account, tx); + } catch { + // Errors are already surfaced via notification in useArcTransfer. + } + }; + + const handleExecute = async (tx: ArcTransaction) => { + try { + await execute(tx); + } catch { + // Errors are already surfaced via notification in useArcTransfer. + } + }; + + if (!isAuthenticated) { + return ( +
+

Sign in with your wallet to continue.

+ +
+ ); + } + + if (!account) { + return ( +
+

Loading Arc account...

+
+ ); + } + + return ( +
+ {!transactionsOnly && ( + <> +
+

Arc transfer

+

+ {account.address} - threshold {account.threshold} / {account.signers.length} +

+ + Non-private - signer addresses are public + +
+ +
+ name="recipient"> + {({ field }) => } + + name="amount"> + {({ field }) => } + + + + + )} + +
+
Transactions
+ + {isLoadingTxs &&

Loading transactions...

} + {!isLoadingTxs && transactions?.length === 0 && ( +

No transactions yet.

+ )} + + {transactions?.map(tx => { + const isPending = tx.status === TxStatus.PENDING; + const hasVoted = !!address && tx.voters.some(voter => voter.toLowerCase() === address.toLowerCase()); + const thresholdMet = tx.approveCount >= tx.threshold; + + return ( +
+
+ Nonce {tx.nonce} - To {tx.to} - {formatUnits(BigInt(tx.value), 18)} USDC +
+
+ Status: {tx.status} - Signatures: {tx.approveCount} / {tx.threshold} +
+
+ + +
+
+ ); + })} +
+
+ ); +} diff --git a/packages/nextjs/components/Dashboard/DashboardContainer.tsx b/packages/nextjs/components/Dashboard/DashboardContainer.tsx index 995b8e7..37f474b 100644 --- a/packages/nextjs/components/Dashboard/DashboardContainer.tsx +++ b/packages/nextjs/components/Dashboard/DashboardContainer.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from "react"; import Image from "next/image"; +import ArcAccountPanel from "../Arc/ArcAccountPanel"; import { Skeleton } from "../ui/skeleton"; import InfoCardContainer from "./InfoCardContainer"; import { TransactionRow, convertToRowData } from "./TransactionRow"; @@ -61,6 +62,17 @@ export default function DashboardContainer() { ); + // Arc (ECDSA) accounts use the Arc panel (address signers, ECDSA transfers) in place + // of the ZK dashboard, reusing the same page shell. + if (currentAccount?.chainType === "ecdsa") { + return ( +
+
+ +
+ ); + } + return (
diff --git a/packages/nextjs/components/NewAccount/ChooseNetwork.tsx b/packages/nextjs/components/NewAccount/ChooseNetwork.tsx index 192e0f2..282377d 100644 --- a/packages/nextjs/components/NewAccount/ChooseNetwork.tsx +++ b/packages/nextjs/components/NewAccount/ChooseNetwork.tsx @@ -2,7 +2,7 @@ import React from "react"; import Image from "next/image"; -import { CHAIN_IDS } from "@polypay/shared"; +import { ARC_TESTNET_CHAIN_ID, CHAIN_IDS } from "@polypay/shared"; import { getDefaultChainId, getNetworkMeta } from "~~/utils/network"; import { notification } from "~~/utils/scaffold-eth"; @@ -35,10 +35,16 @@ const ChooseNetwork: React.FC = ({ { chainId: ARBITRUM_ONE, fallbackChainId: ARBITRUM_SEPOLIA }, ].map(n => { // If we are on testnet env, use testnet ids instead - const chainId = isTestnet ? n.fallbackChainId : n.chainId; + const chainId: number = isTestnet ? n.fallbackChainId : n.chainId; return { chainId, meta: getNetworkMeta(chainId) }; }); + // Arc is a non-private (ECDSA) testnet-only network. Selecting it routes to the Arc create + // flow; it cannot be combined with the ZK chains above (different contract + signer model). + if (isTestnet) { + networks.push({ chainId: ARC_TESTNET_CHAIN_ID, meta: getNetworkMeta(ARC_TESTNET_CHAIN_ID) }); + } + const isSelected = (chainId: number) => selectedChainIds.includes(chainId); const guardAction = (action: () => void) => { diff --git a/packages/nextjs/components/NewAccount/NewAccountContainer.tsx b/packages/nextjs/components/NewAccount/NewAccountContainer.tsx index 3371400..9123fc9 100644 --- a/packages/nextjs/components/NewAccount/NewAccountContainer.tsx +++ b/packages/nextjs/components/NewAccount/NewAccountContainer.tsx @@ -7,9 +7,12 @@ import ChooseNetwork from "./ChooseNetwork"; import SignersConfirmations from "./SignersConfirmations"; import StatusContainer from "./StatusContainer"; import SuccessScreen from "./SuccessScreen"; -import { Account } from "@polypay/shared"; +import { ARC_TESTNET_CHAIN_ID, Account } from "@polypay/shared"; import { useWalletClient } from "wagmi"; +import { arcAccountToAccount } from "~~/components/Sidebar/ArcAccountItem"; import { useCreateAccount, useCreateAccountBatch } from "~~/hooks/api"; +import { useArcAuth } from "~~/hooks/app/arc/useArcAuth"; +import { useArcCreateAccount } from "~~/hooks/app/arc/useArcCreateAccount"; import { useZodForm } from "~~/hooks/form"; import { CreateAccountFormData, createAccountSchema } from "~~/lib/form"; import { useAccountStore } from "~~/services/store"; @@ -17,7 +20,7 @@ import { useIdentityStore } from "~~/services/store/useIdentityStore"; import { notifyError } from "~~/utils/errorHandler"; import { getDefaultChainId } from "~~/utils/network"; import { notification } from "~~/utils/scaffold-eth"; -import { getValidSigners } from "~~/utils/signer"; +import { getValidArcSigners, getValidSigners } from "~~/utils/signer"; export default function NewAccountContainer() { const { commitment } = useIdentityStore(); @@ -27,10 +30,17 @@ export default function NewAccountContainer() { const { mutateAsync: createAccount, isPending: isCreatingSingle } = useCreateAccount(); const { mutateAsync: createAccountBatch, isPending: isCreatingBatch } = useCreateAccountBatch(); + // Arc (non-private, ECDSA) path: same wizard UI, but signers are addresses. + const { login: arcLogin } = useArcAuth(); + const { createAccount: createArcAccount, isPending: isCreatingArc } = useArcCreateAccount(); + const [currentStep, setCurrentStep] = useState(1); const [selectedChainIds, setSelectedChainIds] = useState([]); const [createdAccounts, setCreatedAccounts] = useState(null); + const walletAddress = walletClient?.account?.address; + const isArc = selectedChainIds.length === 1 && selectedChainIds[0] === ARC_TESTNET_CHAIN_ID; + const form = useZodForm({ schema: createAccountSchema, defaultValues: { @@ -56,6 +66,26 @@ export default function NewAccountContainer() { }; const handleCreateAccount = async () => { + // Arc path: address signers, ECDSA, no ZK commitment. Requires a one-time Arc wallet sign-in. + if (isArc) { + try { + // Always refresh the Arc session so create never uses a stale/expired token + // (the persisted isAuthenticated flag can outlive the JWT / a backend restart). + const ok = await arcLogin(); + if (!ok) return; + const owners = getValidArcSigners(formData.signers).map(s => s.commitment); + const arcAccount = await createArcAccount(owners, formData.threshold, ARC_TESTNET_CHAIN_ID, formData.name); + // Same success flow as ZK: show the success screen, then the dashboard. + const asAccount = arcAccountToAccount({ ...arcAccount, name: formData.name }); + setCreatedAccounts([asAccount]); + setCurrentAccount(asAccount); + setCurrentStep(4); + } catch (err: any) { + notifyError(err, "Failed to create Arc account"); + } + return; + } + if (!commitment) { notification.error("You need to have a membership ID to create an account."); return; @@ -121,12 +151,22 @@ export default function NewAccountContainer() { } }, [commitment, form]); + // Seed the first signer with the connected wallet address (Arc) or the ZK commitment. + // Arc reuses the `commitment` form field to carry an address. + useEffect(() => { + if (isArc && walletAddress) { + form.setValue("signers.0.commitment", walletAddress); + } else if (!isArc && commitment) { + form.setValue("signers.0.commitment", commitment); + } + }, [isArc, walletAddress, commitment, form]); + // Validation - const validSigners = getValidSigners(formData.signers); + const validSigners = isArc ? getValidArcSigners(formData.signers) : getValidSigners(formData.signers); const isNameValid = formData.name.trim().length > 0; const isSignersValid = validSigners.length >= 1 && formData.threshold >= 1 && formData.threshold <= validSigners.length; - const isCreating = isCreatingSingle || isCreatingBatch; + const isCreating = isCreatingSingle || isCreatingBatch || isCreatingArc; const EarthBackground = (
@@ -168,11 +208,20 @@ export default function NewAccountContainer() { selectedChainIds={selectedChainIds} hasCommitment={!!commitment} isWalletConnected={!!walletClient?.account} - onToggleChain={chainId => - setSelectedChainIds(prev => - prev.includes(chainId) ? prev.filter(id => id !== chainId) : [...prev, chainId], - ) - } + onToggleChain={chainId => { + if (chainId === ARC_TESTNET_CHAIN_ID) { + // Arc is exclusive: selecting it clears any ZK chains. + setSelectedChainIds(prev => (prev.length === 1 && prev[0] === chainId ? [] : [chainId])); + } else { + // Selecting a ZK chain drops Arc if it was selected. + setSelectedChainIds(prev => { + const withoutArc = prev.filter(id => id !== ARC_TESTNET_CHAIN_ID); + return withoutArc.includes(chainId) + ? withoutArc.filter(id => id !== chainId) + : [...withoutArc, chainId]; + }); + } + }} onNextStep={() => setCurrentStep(2)} /> )} @@ -185,7 +234,9 @@ export default function NewAccountContainer() { isValid={isNameValid} /> )} - {currentStep === 3 && } + {currentStep === 3 && ( + + )}
diff --git a/packages/nextjs/components/NewAccount/SignersConfirmations.tsx b/packages/nextjs/components/NewAccount/SignersConfirmations.tsx index 89942b8..dd8302e 100644 --- a/packages/nextjs/components/NewAccount/SignersConfirmations.tsx +++ b/packages/nextjs/components/NewAccount/SignersConfirmations.tsx @@ -6,15 +6,17 @@ import { UseFormReturn, useFieldArray } from "react-hook-form"; import { Tooltip, TooltipContent, TooltipTrigger } from "~~/components/ui/tooltip"; import { useIdentityStore } from "~~/services/store/useIdentityStore"; import { IAccountFormData } from "~~/types/form/account"; -import { isDuplicateCommitment, isValidCommitment } from "~~/utils/signer"; +import { isDuplicateArcSigner, isDuplicateCommitment, isValidArcSigner, isValidCommitment } from "~~/utils/signer"; interface SignersConfirmationsProps { className?: string; form: UseFormReturn; onGoBack: () => void; + // Arc (ECDSA) accounts: signers are addresses, not ZK commitments. + isArc?: boolean; } -const SignersConfirmations: React.FC = ({ className, form, onGoBack }) => { +const SignersConfirmations: React.FC = ({ className, form, onGoBack, isArc }) => { const { commitment: myCommitment } = useIdentityStore(); const { register, watch, setValue } = form; const { fields, append, remove } = useFieldArray({ @@ -25,6 +27,11 @@ const SignersConfirmations: React.FC = ({ className, const signers = watch("signers"); const threshold = watch("threshold"); + const signerPlaceholder = isArc ? "Signer address (0x...)" : "Signer membership ID"; + const listHint = isArc + ? "The wallet addresses added below are the account signers; they approve future transactions. Signer addresses are public on Arc." + : "Those membership IDs added to the signers list below will play an important role in approving future transactions as team members."; + const handleAddSigner = () => { append({ name: "", commitment: "" }); }; @@ -32,7 +39,9 @@ const SignersConfirmations: React.FC = ({ className, const handleRemoveSigner = (index: number) => { if (fields.length <= 1) return; - // Dont allow to remove my commitment + // Never remove the first signer (the creator: ZK commitment or connected Arc address) + if (index === 0) return; + // Dont allow to remove my commitment (ZK) if (signers[index]?.commitment === myCommitment) return; remove(index); @@ -68,16 +77,16 @@ const SignersConfirmations: React.FC = ({ className, {/* Signers list */}
Account signers
- - Those membership IDs added to the signers list below will play an important role in approving future - transactions as team members. - + {listHint} {fields.map((field, index) => { const isFirstSigner = index === 0; - const hasDuplicate = !isFirstSigner && isDuplicateCommitment(signers, index); + const hasDuplicate = + !isFirstSigner && (isArc ? isDuplicateArcSigner(signers, index) : isDuplicateCommitment(signers, index)); const hasInvalidFormat = - !isFirstSigner && signers[index]?.commitment?.trim() && !isValidCommitment(signers[index]?.commitment); + !isFirstSigner && + signers[index]?.commitment?.trim() && + !(isArc ? isValidArcSigner(signers[index]?.commitment) : isValidCommitment(signers[index]?.commitment)); return (
@@ -97,7 +106,7 @@ const SignersConfirmations: React.FC = ({ className, type="text" {...register(`signers.${index}.commitment`)} disabled - placeholder="Signer membership ID" + placeholder={signerPlaceholder} className={`h-input flex-1 px-4 py-3 rounded-[16px] border bg-gray-50 text-base focus:outline-none opacity-60 cursor-not-allowed`} /> @@ -106,7 +115,9 @@ const SignersConfirmations: React.FC = ({ className, sideOffset={8} className="max-w-[500px] bg-[#444444] rounded-md px-4 py-1.5 text-white text-xs leading-5" > - This is your membership ID. + + {isArc ? "This is your connected wallet address." : "This is your membership ID."} + You can't edit or delete it, but you can add a name to easily distinguish it from others. @@ -117,7 +128,7 @@ const SignersConfirmations: React.FC = ({ className, )} diff --git a/packages/nextjs/components/Sidebar/AccountItem.tsx b/packages/nextjs/components/Sidebar/AccountItem.tsx index 68d5175..abb2a26 100644 --- a/packages/nextjs/components/Sidebar/AccountItem.tsx +++ b/packages/nextjs/components/Sidebar/AccountItem.tsx @@ -34,6 +34,8 @@ export default function AccountItem({ onToggleExpand, }: AccountItemProps) { const avatarSrc = getAccountAvatar(account, allAccounts); + // Arc (ECDSA) accounts reuse this item but hide ZK-only actions (name edit hits the ZK API). + const isArc = account.chainType === "ecdsa"; const { mutate: updateAccount } = useUpdateAccount(); const { setCurrentAccount, currentAccount } = useAccountStore(); const { openModal } = useModalApp(); @@ -171,7 +173,7 @@ export default function AccountItem({ {account.name} )} - {isSelected && ( + {isSelected && !isArc && ( Edit )} + {isArc && ( + + Non-private + + )}
{/* Address Badge */} diff --git a/packages/nextjs/components/Sidebar/ArcAccountItem.tsx b/packages/nextjs/components/Sidebar/ArcAccountItem.tsx new file mode 100644 index 0000000..10a16ae --- /dev/null +++ b/packages/nextjs/components/Sidebar/ArcAccountItem.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { useRouter } from "next/navigation"; +import { Account } from "@polypay/shared"; +import { Wallet } from "lucide-react"; +import { ArcAccount } from "~~/services/api"; +import { useAccountStore } from "~~/services/store"; +import { formatAddress } from "~~/utils/format"; + +interface ArcAccountItemProps { + account: ArcAccount; +} + +// Adapt an Arc account to the shared Account shape used by the dashboard/transfer. +export const arcAccountToAccount = (account: ArcAccount): Account => ({ + id: account.id, + address: account.address, + name: account.name ?? "Arc Multisig", + threshold: account.threshold, + chainId: account.chainId, + contractVersion: 0, + createdAt: "", + updatedAt: "", + chainType: "ecdsa", + signers: account.signers.map(address => ({ commitment: address, isCreator: false })), +}); + +// Arc accounts are ECDSA multisigs (chainType === "ecdsa"): signer addresses are +// public on-chain. Selecting one sets it as the current account and lands on the +// dashboard, which branches its data/actions by chainType. +export default function ArcAccountItem({ account }: ArcAccountItemProps) { + const { setCurrentAccount } = useAccountStore(); + const router = useRouter(); + + const handleSelect = () => { + setCurrentAccount(arcAccountToAccount(account)); + router.push("/dashboard"); + }; + + return ( + + ); +} diff --git a/packages/nextjs/components/Sidebar/NetworkChooserSidebar.tsx b/packages/nextjs/components/Sidebar/NetworkChooserSidebar.tsx index fa17887..0cb52c0 100644 --- a/packages/nextjs/components/Sidebar/NetworkChooserSidebar.tsx +++ b/packages/nextjs/components/Sidebar/NetworkChooserSidebar.tsx @@ -2,7 +2,7 @@ import React from "react"; import Image from "next/image"; -import { Account } from "@polypay/shared"; +import { ARC_TESTNET_CHAIN_ID, Account } from "@polypay/shared"; import { useSidebarStore } from "~~/services/store"; import { getDefaultChainId, getNetworkMeta } from "~~/utils/network"; @@ -13,7 +13,7 @@ interface NetworkChooserSidebarProps { } const NETWORKS_MAINNET = [26514, 8453, 42161]; -const NETWORKS_TESTNET = [2651420, 84532, 421614]; +const NETWORKS_TESTNET = [2651420, 84532, 421614, ARC_TESTNET_CHAIN_ID]; export default function NetworkChooserSidebar({ isOpen, accounts, onSelectNetwork }: NetworkChooserSidebarProps) { const { selectedNetworkChainId } = useSidebarStore(); diff --git a/packages/nextjs/components/Sidebar/Sidebar.tsx b/packages/nextjs/components/Sidebar/Sidebar.tsx index fa64c30..b349f3e 100644 --- a/packages/nextjs/components/Sidebar/Sidebar.tsx +++ b/packages/nextjs/components/Sidebar/Sidebar.tsx @@ -1,14 +1,16 @@ "use client"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import Image from "next/image"; import { usePathname, useRouter } from "next/navigation"; import AccountSidebar from "./AccountSidebar"; +import { arcAccountToAccount } from "./ArcAccountItem"; import ManageAccountsSidebar from "./ManageAccountsSidebar"; import NetworkChooserSidebar from "./NetworkChooserSidebar"; import { useSwitchChain, useWalletClient } from "wagmi"; import Routes from "~~/configs/routes.config"; import { useMyAccounts } from "~~/hooks"; +import { useArcAccounts } from "~~/hooks/app/arc/useArcAccounts"; import { useModalApp } from "~~/hooks/app/useModalApp"; import { useAppRouter } from "~~/hooks/app/useRouteApp"; import { useAccountStore, useIdentityStore, useSidebarStore } from "~~/services/store"; @@ -158,7 +160,12 @@ const SectionItem = ({ export default function Sidebar() { const { openModal } = useModalApp(); const router = useAppRouter(); - const { data: accounts = [], isLoading: isLoadingAccounts } = useMyAccounts(); + const { data: zkAccounts = [], isLoading: isLoadingAccounts } = useMyAccounts(); + const { data: arcAccounts = [] } = useArcAccounts(); + // Arc accounts are unified into the same list as ZK accounts (converted to the shared + // Account shape). Everything downstream - the list, network chooser, selection, nav + // gating - treats them the same; only chainType distinguishes them. + const accounts = useMemo(() => [...zkAccounts, ...arcAccounts.map(arcAccountToAccount)], [zkAccounts, arcAccounts]); const { commitment } = useIdentityStore(); const { data: walletClient } = useWalletClient(); const { currentAccount, setCurrentAccount } = useAccountStore(); diff --git a/packages/nextjs/components/Transfer/TransferContainer.tsx b/packages/nextjs/components/Transfer/TransferContainer.tsx index a20224b..debc146 100644 --- a/packages/nextjs/components/Transfer/TransferContainer.tsx +++ b/packages/nextjs/components/Transfer/TransferContainer.tsx @@ -9,6 +9,7 @@ import { TokenPillPopover } from "~~/components/popovers/TokenPillPopover"; import { Spinner } from "~~/components/ui/Spinner"; import { useMetaMultiSigWallet, useTransferTransaction } from "~~/hooks"; import { useCreateBatchItem } from "~~/hooks/api"; +import { useArcTransfer } from "~~/hooks/app/arc/useArcTransfer"; import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens"; import { useTokenBalances } from "~~/hooks/app/useTokenBalance"; import { useZodForm } from "~~/hooks/form"; @@ -26,9 +27,14 @@ export default function TransferContainer() { const { mutateAsync: createBatchItem } = useCreateBatchItem(); const { commitment } = useIdentityStore(); + // Arc's native token is USDC (not ETH) - reuse the same token pill but relabel + reicon it. + const isArc = selectedAccount?.chainType === "ecdsa"; + const arcNativeToken = { ...nativeEth, symbol: "USDC", icon: "/token/usdc.svg" }; + useEffect(() => { - setSelectedToken(nativeEth); - }, [nativeEth]); + setSelectedToken(isArc ? arcNativeToken : nativeEth); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nativeEth, isArc]); const metaMultiSigWallet = useMetaMultiSigWallet(); const { balances, isLoading: isLoadingBalances } = useTokenBalances( @@ -79,7 +85,25 @@ export default function TransferContainer() { const isNativeETH = selectedToken.address === nativeEth.address; + // Arc (ECDSA) accounts: same transfer form, but propose via ECDSA signature (no ZK proof). + const { propose: arcPropose, isProposing: isProposingArc } = useArcTransfer(); + const handleTransfer = async (data: TransferFormData) => { + if (isArc && selectedAccount) { + const arcAccount = { + id: selectedAccount.id, + address: selectedAccount.address, + name: selectedAccount.name, + threshold: selectedAccount.threshold, + chainId: selectedAccount.chainId, + chainType: "ecdsa", + signers: selectedAccount.signers.map(s => s.commitment), + }; + await arcPropose(arcAccount, data.recipient, data.amount); + form.reset(); + setSelectedContactId(null); + return; + } await transfer({ recipient: data.recipient, amount: data.amount, @@ -158,7 +182,10 @@ export default function TransferContainer() { } })(); - const canSubmit = isAmountValid && !!watchedRecipient && !hasInsufficientBalance && !isLoading; + // Arc reads balance from a different (ECDSA) contract; skip the ZK balance gate for it + // (the on-chain execute enforces funds anyway). + const canSubmit = + isAmountValid && !!watchedRecipient && (isArc || !hasInsufficientBalance) && !isLoading && !isProposingArc; return (
diff --git a/packages/nextjs/hooks/app/arc/useArcAccounts.ts b/packages/nextjs/hooks/app/arc/useArcAccounts.ts new file mode 100644 index 0000000..38f3125 --- /dev/null +++ b/packages/nextjs/hooks/app/arc/useArcAccounts.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useArcAuth } from "./useArcAuth"; +import { arcAccountKeys } from "./useArcCreateAccount"; +import { useQuery } from "@tanstack/react-query"; +import { arcApi } from "~~/services/api"; + +// List of the caller's Arc (ECDSA) multisig accounts. Reuses arcAccountKeys +// from useArcCreateAccount.ts so account creation invalidates this query. +export const useArcAccounts = () => { + const { isAuthenticated } = useArcAuth(); + + return useQuery({ + queryKey: arcAccountKeys.all, + queryFn: arcApi.getAccounts, + enabled: isAuthenticated, + }); +}; diff --git a/packages/nextjs/hooks/app/arc/useArcAuth.ts b/packages/nextjs/hooks/app/arc/useArcAuth.ts new file mode 100644 index 0000000..ee9727b --- /dev/null +++ b/packages/nextjs/hooks/app/arc/useArcAuth.ts @@ -0,0 +1,52 @@ +import { useCallback, useState } from "react"; +import { useAccount, useSignMessage } from "wagmi"; +import { arcApi } from "~~/services/api"; +import { useArcIdentityStore } from "~~/services/store"; +import { formatErrorMessage } from "~~/utils/formatError"; + +// Arc login is a nonce-challenge signature flow, separate from the ZK proof +// login in useAuth.ts: fetch a one-time nonce for the connected address, +// sign `PolyPay Arc login: ` (must match arc-auth.service.ts exactly), +// then exchange the signature for an Arc-scoped JWT (ArcAuthGuard). +export const useArcAuth = () => { + const { address } = useAccount(); + const { signMessageAsync } = useSignMessage(); + const { isAuthenticated, accessToken, setSession, logout: storeLogout } = useArcIdentityStore(); + + const [isLoggingIn, setIsLoggingIn] = useState(false); + const [error, setError] = useState(null); + + const login = useCallback(async (): Promise => { + if (!address) { + setError("Wallet not connected"); + return false; + } + + setIsLoggingIn(true); + setError(null); + + try { + const nonce = await arcApi.getNonce(address); + const signature = await signMessageAsync({ message: `PolyPay Arc login: ${nonce}` }); + const { accessToken: token } = await arcApi.login(address, signature); + + setSession(address, token); + return true; + } catch (err: any) { + setError(formatErrorMessage(err, "Arc login failed")); + return false; + } finally { + setIsLoggingIn(false); + } + }, [address, signMessageAsync, setSession]); + + return { + isAuthenticated, + accessToken, + address, + login, + logout: storeLogout, + isLoading: isLoggingIn, + error, + }; +}; diff --git a/packages/nextjs/hooks/app/arc/useArcCreateAccount.ts b/packages/nextjs/hooks/app/arc/useArcCreateAccount.ts new file mode 100644 index 0000000..2d59cf7 --- /dev/null +++ b/packages/nextjs/hooks/app/arc/useArcCreateAccount.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { arcApi } from "~~/services/api"; +import { formatErrorMessage } from "~~/utils/formatError"; +import { notification } from "~~/utils/scaffold-eth"; + +// Query key for the Arc accounts list, kept here since no Arc accounts-list +// hook exists yet. Future hooks that fetch Arc accounts should reuse this key. +export const arcAccountKeys = { + all: ["arcAccounts"] as const, +}; + +export const useArcCreateAccount = () => { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: arcApi.createAccount, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: arcAccountKeys.all }); + notification.success("Arc multisig account created!"); + }, + onError: err => { + notification.error(formatErrorMessage(err, "Failed to create Arc multisig account")); + }, + }); + + const createAccount = (owners: string[], threshold: number, chainId: number, name?: string) => + mutation.mutateAsync({ owners, threshold, chainId, name }); + + return { + createAccount, + isPending: mutation.isPending, + }; +}; diff --git a/packages/nextjs/hooks/app/arc/useArcTransfer.ts b/packages/nextjs/hooks/app/arc/useArcTransfer.ts new file mode 100644 index 0000000..ad2cb43 --- /dev/null +++ b/packages/nextjs/hooks/app/arc/useArcTransfer.ts @@ -0,0 +1,116 @@ +"use client"; + +import { ARC_TESTNET_CHAIN_ID, META_MULTISIG_ARC_ABI } from "@polypay/shared"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { parseUnits } from "viem"; +import { usePublicClient, useSignMessage } from "wagmi"; +import { ArcAccount, ArcTransaction, arcApi } from "~~/services/api"; +import { useArcIdentityStore } from "~~/services/store"; +import { formatErrorMessage } from "~~/utils/formatError"; +import { notification } from "~~/utils/scaffold-eth"; + +// Arc's contract getTransactionHash/verify are proven to match the backend's +// viem encodePacked hash for identical inputs (verified on Arc testnet), so +// reading the hash straight from the contract and signing it raw is safe - +// no need to recompute it client-side. + +export const arcTransactionKeys = { + all: ["arcTransactions"] as const, + byAccount: (accountId: string) => [...arcTransactionKeys.all, accountId] as const, +}; + +export const useArcTransactions = (accountId: string | undefined) => { + const { isAuthenticated } = useArcIdentityStore(); + + return useQuery({ + queryKey: arcTransactionKeys.byAccount(accountId ?? ""), + queryFn: () => arcApi.getTransactions(accountId as string), + enabled: isAuthenticated && !!accountId, + }); +}; + +export const useArcTransfer = () => { + const queryClient = useQueryClient(); + const publicClient = usePublicClient({ chainId: ARC_TESTNET_CHAIN_ID }); + const { signMessageAsync } = useSignMessage(); + + const readTxHash = async ( + accountAddress: string, + nonce: number, + to: string, + value: bigint, + data: "0x", + ): Promise<`0x${string}`> => { + if (!publicClient) { + throw new Error("Arc RPC client is not available"); + } + return publicClient.readContract({ + address: accountAddress as `0x${string}`, + abi: META_MULTISIG_ARC_ABI, + functionName: "getTransactionHash", + args: [BigInt(nonce), to as `0x${string}`, value, data], + }); + }; + + const invalidate = () => queryClient.invalidateQueries({ queryKey: arcTransactionKeys.all }); + + const proposeMutation = useMutation({ + mutationFn: async ({ account, to, amount }: { account: ArcAccount; to: string; amount: string }) => { + const { nonce } = await arcApi.nextNonce(account.id); + const value = parseUnits(amount, 18); + const txHash = await readTxHash(account.address, nonce, to, value, "0x"); + const signature = await signMessageAsync({ message: { raw: txHash } }); + + return arcApi.proposeTx({ + accountId: account.id, + to, + value: value.toString(), + data: "0x", + signature, + }); + }, + onSuccess: () => { + invalidate(); + notification.success("Arc transaction proposed!"); + }, + onError: err => { + notification.error(formatErrorMessage(err, "Failed to propose Arc transaction")); + }, + }); + + const approveMutation = useMutation({ + mutationFn: async ({ account, tx }: { account: ArcAccount; tx: ArcTransaction }) => { + const txHash = await readTxHash(account.address, tx.nonce, tx.to, BigInt(tx.value), tx.data); + const signature = await signMessageAsync({ message: { raw: txHash } }); + + return arcApi.approveTx(tx.id, { signature }); + }, + onSuccess: () => { + invalidate(); + notification.success("Arc transaction approved!"); + }, + onError: err => { + notification.error(formatErrorMessage(err, "Failed to approve Arc transaction")); + }, + }); + + const executeMutation = useMutation({ + mutationFn: (tx: ArcTransaction) => arcApi.executeTx(tx.id), + onSuccess: () => { + invalidate(); + notification.success("Arc transaction executed!"); + }, + onError: err => { + notification.error(formatErrorMessage(err, "Failed to execute Arc transaction")); + }, + }); + + return { + propose: (account: ArcAccount, to: string, amount: string) => proposeMutation.mutateAsync({ account, to, amount }), + approve: (account: ArcAccount, tx: ArcTransaction) => approveMutation.mutateAsync({ account, tx }), + execute: (tx: ArcTransaction) => executeMutation.mutateAsync(tx), + isProposing: proposeMutation.isPending, + isApproving: approveMutation.isPending, + isExecuting: executeMutation.isPending, + }; +}; diff --git a/packages/nextjs/hooks/app/index.ts b/packages/nextjs/hooks/app/index.ts index bc3aba7..e753e1b 100644 --- a/packages/nextjs/hooks/app/index.ts +++ b/packages/nextjs/hooks/app/index.ts @@ -6,6 +6,7 @@ export * from "./useMobileDetection"; export * from "./useModalApp"; export * from "./useAuthProof"; export * from "./useAuth"; +export * from "./arc/useArcAuth"; export * from "./useCommitmentGuard"; export * from "./transaction/useSignerTransaction"; export * from "./transaction/useTransferTransaction"; diff --git a/packages/nextjs/hooks/app/useCommitmentGuard.ts b/packages/nextjs/hooks/app/useCommitmentGuard.ts index 23b37e4..5a63fa1 100644 --- a/packages/nextjs/hooks/app/useCommitmentGuard.ts +++ b/packages/nextjs/hooks/app/useCommitmentGuard.ts @@ -1,13 +1,20 @@ import { useEffect, useRef } from "react"; +import { usePathname } from "next/navigation"; import { useModalApp } from "./useModalApp"; import { useWalletClient } from "wagmi"; import { useIdentityStore } from "~~/services/store"; import { useDisclaimerStore } from "~~/services/store/disclaimerStore"; +// Arc routes have their own independent auth flow (see hooks/app/arc/useArcAuth.ts) +// and must never have the ZK "generate commitment" modal forced on them. +const ARC_ROUTE_PREFIX = "/arc"; + export const useCommitmentGuard = () => { const { data: walletClient } = useWalletClient(); const { commitment, isAuthenticated } = useIdentityStore(); const { openModal } = useModalApp(); + const pathname = usePathname(); + const isArcRoute = pathname?.startsWith(ARC_ROUTE_PREFIX) ?? false; const hasHydrated = useDisclaimerStore(state => state._hasHydrated); const agreedThisSession = useDisclaimerStore(state => state.agreedThisSession); @@ -21,8 +28,9 @@ export const useCommitmentGuard = () => { if (!hasHydrated) return; // Reset the trigger once the guard no longer wants the modal open - // (commitment created, logged in, or wallet disconnected). - const needsCommitment = walletClient?.account && !commitment && !isAuthenticated; + // (commitment created, logged in, wallet disconnected, or on an Arc route + // which has its own independent auth and must not show the ZK modal). + const needsCommitment = walletClient?.account && !commitment && !isAuthenticated && !isArcRoute; if (!needsCommitment) { hasOpenedRef.current = false; return; @@ -36,7 +44,7 @@ export const useCommitmentGuard = () => { timeoutRef.current = setTimeout(() => { const canProceed = useDisclaimerStore.getState().canProceed(); - if (canProceed && walletClient?.account && !commitment && !isAuthenticated) { + if (canProceed && walletClient?.account && !commitment && !isAuthenticated && !isArcRoute) { hasOpenedRef.current = true; openModal("generateCommitment"); } @@ -52,6 +60,7 @@ export const useCommitmentGuard = () => { walletClient?.account, commitment, isAuthenticated, + isArcRoute, openModal, agreedThisSession, agreedAt, diff --git a/packages/nextjs/hooks/app/useInitializeApp.ts b/packages/nextjs/hooks/app/useInitializeApp.ts index 78fa57d..1159989 100644 --- a/packages/nextjs/hooks/app/useInitializeApp.ts +++ b/packages/nextjs/hooks/app/useInitializeApp.ts @@ -10,8 +10,15 @@ import { ErrorCode, handleError, parseError } from "~~/utils/errorHandler"; // const ROUTES_WITHOUT_ACCOUNT = [Routes.QUEST.path, Routes.LEADERBOARD.path]; const ROUTES_WITHOUT_ACCOUNT: string[] = []; +// Arc routes have their own independent auth (nonce-signature login, see +// hooks/app/arc/useArcAuth.ts) and must never be redirected by the ZK gate. +const ARC_ROUTE_PREFIX = "/arc"; + // Helper function to check if route requires account const requiresAccount = (pathname: string) => { + if (pathname.startsWith(ARC_ROUTE_PREFIX)) { + return false; + } return !ROUTES_WITHOUT_ACCOUNT.includes(pathname as any); }; @@ -27,6 +34,13 @@ export const useInitializeApp = () => { // Prevent race conditions const abortControllerRef = useRef(null); + // Live pathname ref: the effect below intentionally excludes pathname from its + // dependency array (navigating shouldn't re-trigger getMyAccounts()), so any + // redirect decision made after an await must read the current path via this ref + // instead of the pathname captured in the effect's closure. + const pathnameRef = useRef(router.pathname); + pathnameRef.current = router.pathname; + // Mark mounted after first render to make sure hydration is done useEffect(() => { setMounted(true); @@ -50,7 +64,7 @@ export const useInitializeApp = () => { setIsInitialized(true); // Only redirect if on a route that requires account - if (requiresAccount(router.pathname)) { + if (requiresAccount(pathnameRef.current)) { router.goToDashboardNewAccount(); } return; @@ -66,8 +80,11 @@ export const useInitializeApp = () => { } if (accounts && accounts.length > 0) { - // Has accounts - const isCurrentAccountValid = currentAccount && accounts.some(a => a.address === currentAccount.address); + // Has ZK accounts. Keep an Arc (ECDSA) account selected if the user picked one - + // Arc accounts are not part of the ZK account list. + const isCurrentAccountValid = + currentAccount && + (currentAccount.chainType === "ecdsa" || accounts.some(a => a.address === currentAccount.address)); if (!isCurrentAccountValid) { setCurrentAccount(accounts[0]); @@ -76,12 +93,18 @@ export const useInitializeApp = () => { if (router.pathname === Routes.DASHBOARD.subroutes.NEW_ACCOUNT.path) { router.goToDashboard(); } + } else if (currentAccount?.chainType === "ecdsa") { + // No ZK accounts, but the user is on an Arc (ECDSA) account. The ZK account + // count excludes Arc accounts, so keep them here instead of forcing new-account. + if (router.pathname === Routes.DASHBOARD.subroutes.NEW_ACCOUNT.path) { + router.goToDashboard(); + } } else { // No accounts clearCurrentAccount(); // Only redirect if on a route that requires account - if (requiresAccount(router.pathname)) { + if (requiresAccount(pathnameRef.current)) { router.goToDashboardNewAccount(); } } @@ -100,7 +123,7 @@ export const useInitializeApp = () => { clearCurrentAccount(); // Only redirect if on a route that requires account - if (requiresAccount(router.pathname)) { + if (requiresAccount(pathnameRef.current)) { router.goToDashboardNewAccount(); } } else if (appError.code === ErrorCode.NOT_FOUND) { @@ -108,7 +131,7 @@ export const useInitializeApp = () => { clearCurrentAccount(); // Only redirect if on a route that requires account - if (requiresAccount(router.pathname)) { + if (requiresAccount(pathnameRef.current)) { router.goToDashboardNewAccount(); } } else { diff --git a/packages/nextjs/lib/form/schemas.ts b/packages/nextjs/lib/form/schemas.ts index 6501d25..dcc0a68 100644 --- a/packages/nextjs/lib/form/schemas.ts +++ b/packages/nextjs/lib/form/schemas.ts @@ -1,4 +1,5 @@ import { validators } from "./validation"; +import { parseUnits } from "viem"; import { z } from "zod"; // ==================== Contact Book ==================== @@ -40,7 +41,16 @@ export const transferSchema = z.object({ .refine(val => val.startsWith("0x") && val.length === 42, { message: "Invalid address format", }), - amount: z.string(), + amount: z.string().refine( + val => { + try { + return parseUnits(val, 18) > 0n; + } catch { + return false; + } + }, + { message: "Enter a valid positive amount" }, + ), }); export type TransferFormData = z.infer; @@ -70,6 +80,26 @@ export const updateThresholdSchema = z.object({ }); export type UpdateThresholdFormData = z.infer; +// ==================== Arc Multisig ==================== + +export const createArcAccountSchema = z + .object({ + owners: z.array(z.object({ address: validators.ethereumAddress })).min(1, "At least one owner is required"), + threshold: z.number().min(1, "Threshold must be at least 1"), + }) + .refine(data => data.threshold <= data.owners.length, { + message: "Threshold cannot exceed the number of owners", + path: ["threshold"], + }) + .refine( + data => { + const addresses = data.owners.map(o => o.address.toLowerCase()); + return new Set(addresses).size === addresses.length; + }, + { message: "Owner addresses must be unique", path: ["owners"] }, + ); +export type CreateArcAccountFormData = z.infer; + // ==================== Feature Request ==================== export const featureRequestSchema = z.object({ diff --git a/packages/nextjs/scaffold.config.ts b/packages/nextjs/scaffold.config.ts index 7a7c9e3..56247d1 100644 --- a/packages/nextjs/scaffold.config.ts +++ b/packages/nextjs/scaffold.config.ts @@ -1,4 +1,4 @@ -import { NetworkValue } from "@polypay/shared"; +import { ARC_TESTNET_CHAIN_ID, NetworkValue } from "@polypay/shared"; import { defineChain } from "viem"; import * as chains from "viem/chains"; import { RPC_POLLING_INTERVAL } from "~~/constants/timing"; @@ -51,6 +51,31 @@ export const horizenMainnet = defineChain({ testnet: false, }); +// Circle Arc testnet - ECDSA multisig chain (no zkVerify deployment, see getChainType()). +// Duplicated as a local defineChain for the same reason as horizenTestnet above +// (viem version mismatch between packages/shared and packages/nextjs). +export const arcTestnet = defineChain({ + id: ARC_TESTNET_CHAIN_ID, + name: "Arc Testnet", + nativeCurrency: { + name: "USD Coin", + symbol: "USDC", + decimals: 18, + }, + rpcUrls: { + default: { + http: ["https://rpc.testnet.arc.network"], + }, + }, + blockExplorers: { + default: { + name: "Arc Explorer", + url: "https://testnet.arcscan.app", + }, + }, + testnet: true, +}); + export type BaseConfig = { targetNetworks: readonly chains.Chain[]; pollingInterval: number; @@ -71,7 +96,7 @@ const scaffoldConfig = { targetNetworks: process.env.NEXT_PUBLIC_NETWORK === NetworkValue.mainnet ? [horizenMainnet, chains.base, chains.arbitrum] - : [horizenTestnet, chains.baseSepolia, chains.arbitrumSepolia], + : [horizenTestnet, chains.baseSepolia, chains.arbitrumSepolia, arcTestnet], // The interval at which your front-end polls the RPC servers for new data (it has no effect if you only target the local network (default is 4000)) pollingInterval: RPC_POLLING_INTERVAL, // This is ours Alchemy's default API key. diff --git a/packages/nextjs/services/api/apiClient.ts b/packages/nextjs/services/api/apiClient.ts index cc8ed2c..0db2fca 100644 --- a/packages/nextjs/services/api/apiClient.ts +++ b/packages/nextjs/services/api/apiClient.ts @@ -22,9 +22,13 @@ apiClient.interceptors.request.use( config.timeout = API_TIMEOUT_ZK; } - // Add auth header if token exists + // Add auth header if token exists. Skip Arc requests: they use a + // separate Arc JWT attached by the interceptor in arcApi.ts, and since + // axios runs request interceptors in reverse registration order, this + // one (registered first, at import) would otherwise run last and + // overwrite the Arc token with the ZK token. const { accessToken } = useIdentityStore.getState(); - if (accessToken) { + if (accessToken && !config.url?.includes("/api/arc/")) { config.headers.Authorization = AUTHORIZATION_HEADER(accessToken); } @@ -49,8 +53,15 @@ apiClient.interceptors.response.use( async (error: AxiosError) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; - // Auto refresh token on 401 - if (error.response?.status === 401 && originalRequest && !originalRequest._retry) { + // Auto refresh token on 401. Arc requests use a separate Arc JWT/session + // (see arc-auth.store.ts / arcApi.ts) and are not covered by the ZK + // refresh flow, so let Arc 401s propagate to the caller instead. + if ( + error.response?.status === 401 && + originalRequest && + !originalRequest._retry && + !originalRequest.url?.includes("/api/arc/") + ) { // Skip auto-refresh for auth endpoints (prevent loop) if (originalRequest.url?.includes("/auth/")) { const { logout } = useIdentityStore.getState(); diff --git a/packages/nextjs/services/api/arcApi.ts b/packages/nextjs/services/api/arcApi.ts new file mode 100644 index 0000000..ebb33c3 --- /dev/null +++ b/packages/nextjs/services/api/arcApi.ts @@ -0,0 +1,156 @@ +import { useArcIdentityStore } from "../store"; +import { apiClient } from "./apiClient"; +import { TxStatus } from "@polypay/shared"; + +// Circle Arc (ECDSA multisig) endpoints. Routes mirror the backend controllers +// under packages/backend/src/arc/*/*.controller.ts (all behind the global +// "/api" prefix set in main.ts). +const ARC_ENDPOINTS = { + auth: { + nonce: "/api/arc/auth/nonce", + login: "/api/arc/auth/login", + }, + accounts: { + base: "/api/arc/accounts", + }, + transactions: { + base: "/api/arc/transactions", + nextNonce: "/api/arc/transactions/next-nonce", + approve: (txId: number) => `/api/arc/transactions/${txId}/approve`, + execute: (txId: number) => `/api/arc/transactions/${txId}/execute`, + }, +} as const; + +// Arc requests are guarded by ArcAuthGuard (arc-jwt), a separate JWT from the +// ZK session token. The default apiClient interceptor (see apiClient.ts) now +// skips attaching the ZK access token for "/api/arc/*" calls, so this +// interceptor is the one that sets the Authorization header for Arc requests +// with the Arc token (or strips it entirely for the public nonce/login +// endpoints, when no Arc session exists yet). +apiClient.interceptors.request.use(config => { + if (config.url?.includes("/api/arc/")) { + const { accessToken } = useArcIdentityStore.getState(); + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } else { + delete config.headers.Authorization; + } + } + return config; +}); + +export interface ArcAccount { + id: string; + address: string; + name?: string; + threshold: number; + chainId: number; + chainType: string; + signers: string[]; +} + +export interface CreateArcAccountDto { + name?: string; + owners: string[]; + threshold: number; + chainId: number; +} + +export interface ProposeArcTransactionDto { + accountId: string; + to: string; + value: string; + // Arc currently supports native-value transfers only (see + // CreateArcTransactionDto on the backend), so data is always "0x". + data: "0x"; + signature: string; +} + +export interface ApproveArcTransactionDto { + signature: string; +} + +export interface ArcTransaction { + id: number; + nonce: number; + to: string; + value: string; + // Arc currently supports native-value transfers only, so data is always "0x". + data: "0x"; + status: TxStatus; + threshold: number; + approveCount: number; + voters: string[]; + txHash: string | null; +} + +export const arcApi = { + getNonce: async (address: string): Promise => { + const { data } = await apiClient.post<{ nonce: string }>(ARC_ENDPOINTS.auth.nonce, { address }); + return data.nonce; + }, + + login: async (address: string, signature: string): Promise<{ accessToken: string }> => { + const { data } = await apiClient.post<{ accessToken: string }>(ARC_ENDPOINTS.auth.login, { address, signature }); + return data; + }, + + createAccount: async (dto: CreateArcAccountDto): Promise => { + const { data } = await apiClient.post(ARC_ENDPOINTS.accounts.base, dto); + return data; + }, + + getAccounts: async (): Promise => { + const { data } = await apiClient.get(ARC_ENDPOINTS.accounts.base); + return data; + }, + + nextNonce: async (accountId: string): Promise<{ nonce: number }> => { + const { data } = await apiClient.get<{ nonce: number }>(ARC_ENDPOINTS.transactions.nextNonce, { + params: { accountId }, + }); + return data; + }, + + getTransactions: async (accountId: string): Promise => { + const { data } = await apiClient.get(ARC_ENDPOINTS.transactions.base, { + params: { accountId }, + }); + return data; + }, + + proposeTx: async ( + dto: ProposeArcTransactionDto, + ): Promise<{ + txId: number; + nonce: number; + status: TxStatus; + }> => { + const { data } = await apiClient.post(ARC_ENDPOINTS.transactions.base, dto); + return data; + }, + + approveTx: async ( + txId: number, + dto: ApproveArcTransactionDto, + ): Promise<{ + txId: number; + status: TxStatus; + approveCount: number; + threshold: number; + }> => { + const { data } = await apiClient.post(ARC_ENDPOINTS.transactions.approve(txId), dto); + return data; + }, + + executeTx: async ( + txId: number, + ): Promise<{ + txId: number; + status: TxStatus; + txHash: string; + }> => { + const { data } = await apiClient.post(ARC_ENDPOINTS.transactions.execute(txId)); + return data; + }, +}; diff --git a/packages/nextjs/services/api/index.ts b/packages/nextjs/services/api/index.ts index 3f742d2..c07a98d 100644 --- a/packages/nextjs/services/api/index.ts +++ b/packages/nextjs/services/api/index.ts @@ -6,6 +6,8 @@ export { batchItemApi } from "./batchItemApi"; export { transactionApi } from "./transactionApi"; export { notificationApi } from "./notificationApi"; export { authApi } from "./authApi"; +export { arcApi } from "./arcApi"; +export type { ArcAccount, ArcTransaction } from "./arcApi"; export { featureRequestApi } from "./featureRequestApi"; export { queryClient } from "../queryClient"; // Quest + Leaderboard hidden — APIs orphaned. diff --git a/packages/nextjs/services/store/index.ts b/packages/nextjs/services/store/index.ts index 1772612..5a00f9d 100644 --- a/packages/nextjs/services/store/index.ts +++ b/packages/nextjs/services/store/index.ts @@ -1,4 +1,5 @@ export { useIdentityStore } from "./useIdentityStore"; +export { useArcIdentityStore } from "./useArcIdentityStore"; export { useAccountStore } from "./useAccountStore"; export { useSidebarStore } from "./useSidebarStore"; export { useDisclaimerStore } from "./disclaimerStore"; diff --git a/packages/nextjs/services/store/useArcIdentityStore.ts b/packages/nextjs/services/store/useArcIdentityStore.ts new file mode 100644 index 0000000..baf246c --- /dev/null +++ b/packages/nextjs/services/store/useArcIdentityStore.ts @@ -0,0 +1,31 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +// Dedicated store for the Arc (ECDSA multisig) JWT, issued by /arc/auth/login +// and guarded by ArcAuthGuard on the backend. Kept separate from +// useIdentityStore so an Arc login never overwrites the ZK session token. +interface ArcIdentityState { + address: string | null; + accessToken: string | null; + isAuthenticated: boolean; + + setSession: (address: string, accessToken: string) => void; + logout: () => void; +} + +export const useArcIdentityStore = create()( + persist( + set => ({ + address: null, + accessToken: null, + isAuthenticated: false, + + setSession: (address: string, accessToken: string) => set({ address, accessToken, isAuthenticated: true }), + + logout: () => set({ address: null, accessToken: null, isAuthenticated: false }), + }), + { + name: "arc-identity-storage", + }, + ), +); diff --git a/packages/nextjs/utils/network.ts b/packages/nextjs/utils/network.ts index 38a9f74..056f8e3 100644 --- a/packages/nextjs/utils/network.ts +++ b/packages/nextjs/utils/network.ts @@ -40,6 +40,12 @@ const NETWORK_META_BY_CHAIN_ID: Record = { icon: "/token/arbitrum.svg", badge: "/token/arbitrum.svg", }, + // Circle Arc testnet (non-private, USDC-native). No mainnet yet. + 5042002: { + name: "Arc Testnet", + icon: "/token/usdc.svg", + badge: "/token/usdc.svg", + }, }; export const getDefaultChainId = (): number => { diff --git a/packages/nextjs/utils/signer.ts b/packages/nextjs/utils/signer.ts index bdb1ee7..310e938 100644 --- a/packages/nextjs/utils/signer.ts +++ b/packages/nextjs/utils/signer.ts @@ -36,3 +36,24 @@ export const getValidSigners = (signers: T[]): return !isDuplicateCommitment(signers, index); }); }; + +/** + * Arc (ECDSA) signers reuse the `commitment` field to hold an Ethereum ADDRESS. + * Validation is therefore address-based instead of commitment (digit) based. + */ +export const isValidArcSigner = (value: string): boolean => { + return /^0x[a-fA-F0-9]{40}$/.test(value?.trim() ?? ""); +}; + +export const isDuplicateArcSigner = (signers: ISigner[], index: number): boolean => { + const current = signers[index]?.commitment?.trim().toLowerCase(); + if (!current) return false; + return signers.some((signer, i) => i !== index && signer?.commitment?.trim().toLowerCase() === current); +}; + +export const getValidArcSigners = (signers: T[]): T[] => { + return signers.filter((signer, index) => { + if (!isValidArcSigner(signer.commitment)) return false; + return !isDuplicateArcSigner(signers as unknown as ISigner[], index); + }); +}; diff --git a/packages/shared/src/constants/chains.ts b/packages/shared/src/constants/chains.ts index bd0e80e..e7928fd 100644 --- a/packages/shared/src/constants/chains.ts +++ b/packages/shared/src/constants/chains.ts @@ -10,3 +10,11 @@ export const CHAIN_IDS = { } as const; export type SupportedChainId = (typeof CHAIN_IDS)[keyof typeof CHAIN_IDS]; + +export const ARC_TESTNET_CHAIN_ID = 5042002; + +// A chain is "ecdsa" (non-private) when it has no zkVerify deployment (Arc). All +// currently-supported chains except Arc are "zk". +export function getChainType(chainId: number): "zk" | "ecdsa" { + return chainId === ARC_TESTNET_CHAIN_ID ? "ecdsa" : "zk"; +} diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 8628a92..d056019 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -1,3 +1,4 @@ export * from "./MetaMultiSigWallet"; export * from "./MetaMultiSigWalletStylus"; +export * from "./metaMultiSigWalletArc"; export * from "./contracts-config"; diff --git a/packages/shared/src/contracts/metaMultiSigWalletArc.ts b/packages/shared/src/contracts/metaMultiSigWalletArc.ts new file mode 100644 index 0000000..064c292 --- /dev/null +++ b/packages/shared/src/contracts/metaMultiSigWalletArc.ts @@ -0,0 +1,418 @@ +// ABI + bytecode copied from the compiled MetaMultiSigWalletArc artifact. +// Regenerate by recompiling the hardhat package if the contract changes. +export const META_MULTISIG_ARC_ABI = [ + { + inputs: [ + { + internalType: "uint256", + name: "_chainId", + type: "uint256", + }, + { + internalType: "address[]", + name: "_owners", + type: "address[]", + }, + { + internalType: "uint256", + name: "_signaturesRequired", + type: "uint256", + }, + ], + stateMutability: "nonpayable", + type: "constructor", + }, + { + inputs: [], + name: "ECDSAInvalidSignature", + type: "error", + }, + { + inputs: [ + { + internalType: "uint256", + name: "length", + type: "uint256", + }, + ], + name: "ECDSAInvalidSignatureLength", + type: "error", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "s", + type: "bytes32", + }, + ], + name: "ECDSAInvalidSignatureS", + type: "error", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "sender", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "amount", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "balance", + type: "uint256", + }, + ], + name: "Deposit", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "owner", + type: "address", + }, + { + indexed: false, + internalType: "bool", + name: "isAdded", + type: "bool", + }, + ], + name: "Owner", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "executor", + type: "address", + }, + { + indexed: true, + internalType: "uint256", + name: "nonce", + type: "uint256", + }, + { + indexed: false, + internalType: "address", + name: "to", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "value", + type: "uint256", + }, + { + indexed: false, + internalType: "bytes", + name: "data", + type: "bytes", + }, + { + indexed: false, + internalType: "bytes", + name: "result", + type: "bytes", + }, + ], + name: "TransactionExecuted", + type: "event", + }, + { + inputs: [ + { + internalType: "address", + name: "newSigner", + type: "address", + }, + { + internalType: "uint256", + name: "newSignaturesRequired", + type: "uint256", + }, + ], + name: "addSigner", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address[]", + name: "recipients", + type: "address[]", + }, + { + internalType: "uint256[]", + name: "amounts", + type: "uint256[]", + }, + ], + name: "batchTransfer", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address[]", + name: "recipients", + type: "address[]", + }, + { + internalType: "uint256[]", + name: "amounts", + type: "uint256[]", + }, + { + internalType: "address[]", + name: "tokenAddresses", + type: "address[]", + }, + ], + name: "batchTransferMulti", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "chainId", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "_nonce", + type: "uint256", + }, + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + { + internalType: "bytes[]", + name: "signatures", + type: "bytes[]", + }, + ], + name: "execute", + outputs: [ + { + internalType: "bytes", + name: "", + type: "bytes", + }, + ], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "getOwners", + outputs: [ + { + internalType: "address[]", + name: "", + type: "address[]", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "getSignersCount", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "_nonce", + type: "uint256", + }, + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + ], + name: "getTransactionHash", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + name: "isOwner", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + name: "owners", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "oldSigner", + type: "address", + }, + { + internalType: "uint256", + name: "newSignaturesRequired", + type: "uint256", + }, + ], + name: "removeSigner", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "signaturesRequired", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "newSignaturesRequired", + type: "uint256", + }, + ], + name: "updateSignaturesRequired", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + name: "usedNonces", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + stateMutability: "payable", + type: "receive", + }, +] as const; + +export const META_MULTISIG_ARC_BYTECODE = + "0x60a06040523480156200001157600080fd5b5060405162001f1738038062001f178339810160408190526200003491620002fe565b600081116200008a5760405162461bcd60e51b815260206004820152601e60248201527f4d757374206265206e6f6e2d7a65726f2073696773207265717569726564000060448201526064015b60405180910390fd5b6000825111620000dd5760405162461bcd60e51b815260206004820152601660248201527f4e656564206174206c656173742031207369676e657200000000000000000000604482015260640162000081565b8151811115620001305760405162461bcd60e51b815260206004820152601660248201527f5369677320726571756972656420746f6f206869676800000000000000000000604482015260640162000081565b608083905260008181555b8251811015620002c15760008382815181106200015c576200015c620003e7565b6020026020010151905060006001600160a01b0316816001600160a01b031603620001ba5760405162461bcd60e51b815260206004820152600d60248201526c24b73b30b634b21037bbb732b960991b604482015260640162000081565b6001600160a01b03811660009081526002602052604090205460ff1615620002145760405162461bcd60e51b815260206004820152600c60248201526b4f776e65722065786973747360a01b604482015260640162000081565b6001600160a01b0381166000818152600260209081526040808320805460ff1916600190811790915560038054808301825594527fc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b90930180546001600160a01b03191685179055519182527ffe545f48304051c4029eb2da9927daa59da0414b4b084fdceaf2955b609b899e910160405180910390a25080620002b881620003fd565b9150506200013b565b5050505062000425565b634e487b7160e01b600052604160045260246000fd5b80516001600160a01b0381168114620002f957600080fd5b919050565b6000806000606084860312156200031457600080fd5b8351602080860151919450906001600160401b03808211156200033657600080fd5b818701915087601f8301126200034b57600080fd5b815181811115620003605762000360620002cb565b8060051b604051601f19603f83011681018181108582111715620003885762000388620002cb565b60405291825284820192508381018501918a831115620003a757600080fd5b938501935b82851015620003d057620003c085620002e1565b84529385019392850192620003ac565b809750505050505050604084015190509250925092565b634e487b7160e01b600052603260045260246000fd5b6000600182016200041e57634e487b7160e01b600052601160045260246000fd5b5060010190565b608051611acf62000448600039600081816102b6015261066a0152611acf6000f3fe6080604052600436106100e15760003560e01c80636717e41c1161007f578063a0c1deb411610059578063a0c1deb4146102d8578063a0e67e2b146102ed578063ac53a4f91461030f578063ce757d291461033c57600080fd5b80636717e41c1461025457806388d695b2146102845780639a8a0592146102a457600080fd5b80633bad5426116100bb5780633bad5426146101c6578063545a4a3c146101e6578063644512121461021457806365af1bed1461023457600080fd5b8063025e7c27146101275780632f54bf6e146101645780633034a742146101a457600080fd5b36610122576040805134815247602082015233917f90890809c654f11d6e72a28fa60149770a0d11ec6c92319d6ceb2bb0a4ea1a15910160405180910390a2005b600080fd5b34801561013357600080fd5b506101476101423660046113e2565b610352565b6040516001600160a01b0390911681526020015b60405180910390f35b34801561017057600080fd5b5061019461017f366004611417565b60026020526000908152604090205460ff1681565b604051901515815260200161015b565b3480156101b057600080fd5b506101c46101bf3660046113e2565b61037c565b005b3480156101d257600080fd5b506101c46101e1366004611439565b6103eb565b3480156101f257600080fd5b50610206610201366004611479565b610665565b60405190815260200161015b565b34801561022057600080fd5b506101c461022f36600461159a565b6106c2565b34801561024057600080fd5b506101c461024f366004611439565b610a82565b34801561026057600080fd5b5061019461026f3660046113e2565b60016020526000908152604090205460ff1681565b34801561029057600080fd5b506101c461029f366004611634565b610c26565b3480156102b057600080fd5b506102067f000000000000000000000000000000000000000000000000000000000000000081565b3480156102e457600080fd5b50600354610206565b3480156102f957600080fd5b50610302610e0a565b60405161015b91906116a0565b34801561031b57600080fd5b5061032f61032a3660046116ed565b610e6c565b60405161015b91906117ff565b34801561034857600080fd5b5061020660005481565b6003818154811061036257600080fd5b6000918252602090912001546001600160a01b0316905081565b3330146103a45760405162461bcd60e51b815260040161039b90611812565b60405180910390fd5b600081116103c45760405162461bcd60e51b815260040161039b90611834565b6003548111156103e65760405162461bcd60e51b815260040161039b9061186b565b600055565b33301461040a5760405162461bcd60e51b815260040161039b90611812565b6001600160a01b03821660009081526002602052604090205460ff166104615760405162461bcd60e51b815260206004820152600c60248201526b2737ba1030b71037bbb732b960a11b604482015260640161039b565b6003546001106104b35760405162461bcd60e51b815260206004820152601960248201527f43616e6e6f742072656d6f766520616c6c207369676e65727300000000000000604482015260640161039b565b600081116104d35760405162461bcd60e51b815260040161039b90611834565b6001600160a01b0382166000908152600260205260408120805460ff191690555b6003548110156105f957826001600160a01b03166003828154811061051b5761051b61189b565b6000918252602090912001546001600160a01b0316036105e75760038054610545906001906118c7565b815481106105555761055561189b565b600091825260209091200154600380546001600160a01b0390921691839081106105815761058161189b565b9060005260206000200160006101000a8154816001600160a01b0302191690836001600160a01b0316021790555060038054806105c0576105c06118da565b600082815260209020810160001990810180546001600160a01b03191690550190556105f9565b806105f1816118f0565b9150506104f4565b5060008190556003548111156106215760405162461bcd60e51b815260040161039b9061186b565b604051600081526001600160a01b038316907ffe545f48304051c4029eb2da9927daa59da0414b4b084fdceaf2955b609b899e906020015b60405180910390a25050565b6000307f0000000000000000000000000000000000000000000000000000000000000000868686866040516020016106a296959493929190611909565b604051602081830303815290604052805190602001209050949350505050565b3330146106e15760405162461bcd60e51b815260040161039b90611812565b8483146107005760405162461bcd60e51b815260040161039b90611964565b84811461071f5760405162461bcd60e51b815260040161039b90611964565b8461075a5760405162461bcd60e51b815260206004820152600b60248201526a08adae0e8f240c4c2e8c6d60ab1b604482015260640161039b565b60005b85811015610a795760008787838181106107795761077961189b565b905060200201602081019061078e9190611417565b6001600160a01b0316036107d85760405162461bcd60e51b8152602060048201526011602482015270125b9d985b1a59081c9958da5c1a595b9d607a1b604482015260640161039b565b60008383838181106107ec576107ec61189b565b90506020020160208101906108019190611417565b6001600160a01b0316036108ef5760008787838181106108235761082361189b565b90506020020160208101906108389190611417565b6001600160a01b03168686848181106108535761085361189b565b9050602002013560405160006040518083038185875af1925050503d806000811461089a576040519150601f19603f3d011682016040523d82523d6000602084013e61089f565b606091505b50509050806108e95760405162461bcd60e51b815260206004820152601660248201527513985d1a5d99481d1c985b9cd9995c8819985a5b195960521b604482015260640161039b565b50610a67565b6000808484848181106109045761090461189b565b90506020020160208101906109199190611417565b6001600160a01b03168989858181106109345761093461189b565b90506020020160208101906109499190611417565b88888681811061095b5761095b61189b565b6040516001600160a01b039094166024850152602002919091013560448301525060640160408051601f198184030181529181526020820180516001600160e01b031663a9059cbb60e01b179052516109b4919061198d565b6000604051808303816000865af19150503d80600081146109f1576040519150601f19603f3d011682016040523d82523d6000602084013e6109f6565b606091505b5091509150818015610a20575080511580610a20575080806020019051810190610a2091906119a9565b610a645760405162461bcd60e51b8152602060048201526015602482015274115490cc8c081d1c985b9cd9995c8819985a5b1959605a1b604482015260640161039b565b50505b80610a71816118f0565b91505061075d565b50505050505050565b333014610aa15760405162461bcd60e51b815260040161039b90611812565b6001600160a01b038216610ae75760405162461bcd60e51b815260206004820152600d60248201526c24b73b30b634b21037bbb732b960991b604482015260640161039b565b6001600160a01b03821660009081526002602052604090205460ff1615610b3f5760405162461bcd60e51b815260206004820152600c60248201526b4f776e65722065786973747360a01b604482015260640161039b565b60008111610b5f5760405162461bcd60e51b815260040161039b90611834565b6001600160a01b0382166000818152600260205260408120805460ff191660019081179091556003805491820181558083527fc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b90910180546001600160a01b03191690931790925582905554811115610bea5760405162461bcd60e51b815260040161039b9061186b565b604051600181526001600160a01b038316907ffe545f48304051c4029eb2da9927daa59da0414b4b084fdceaf2955b609b899e90602001610659565b333014610c455760405162461bcd60e51b815260040161039b90611812565b828114610c645760405162461bcd60e51b815260040161039b90611964565b82610c9f5760405162461bcd60e51b815260206004820152600b60248201526a08adae0e8f240c4c2e8c6d60ab1b604482015260640161039b565b60005b83811015610e03576000858583818110610cbe57610cbe61189b565b9050602002016020810190610cd39190611417565b6001600160a01b031603610d1d5760405162461bcd60e51b8152602060048201526011602482015270125b9d985b1a59081c9958da5c1a595b9d607a1b604482015260640161039b565b6000858583818110610d3157610d3161189b565b9050602002016020810190610d469190611417565b6001600160a01b0316848484818110610d6157610d6161189b565b9050602002013560405160006040518083038185875af1925050503d8060008114610da8576040519150601f19603f3d011682016040523d82523d6000602084013e610dad565b606091505b5050905080610df05760405162461bcd60e51b815260206004820152600f60248201526e151c985b9cd9995c8819985a5b1959608a1b604482015260640161039b565b5080610dfb816118f0565b915050610ca2565b5050505050565b60606003805480602002602001604051908101604052809291908181526020018280548015610e6257602002820191906000526020600020905b81546001600160a01b03168152600190910190602001808311610e44575b5050505050905090565b60008781526001602052604090205460609060ff1615610ec35760405162461bcd60e51b8152602060048201526012602482015271139bdb98d948185b1c9958591e481d5cd95960721b604482015260640161039b565b600054821015610f0d5760405162461bcd60e51b81526020600482015260156024820152744e6f7420656e6f756768207369676e61747572657360581b604482015260640161039b565b6000610f87610f548a8a8a8a8a8080601f01602080910402602001604051908101604052809392919081815260200183838082843760009201919091525061066592505050565b7f19457468657265756d205369676e6564204d6573736167653a0a3332000000006000908152601c91909152603c902090565b90506000805b848110156110c9576000610ffb878784818110610fac57610fac61189b565b9050602002810190610fbe91906119cb565b8080601f01602080910402602001604051908101604052809392919081815260200183838082843760009201919091525088939250506111dd9050565b9050826001600160a01b0316816001600160a01b03161161105e5760405162461bcd60e51b815260206004820152601d60248201527f4475706c6963617465206f7220756e6f726465726564207369676e6572000000604482015260640161039b565b6001600160a01b03811660009081526002602052604090205460ff166110b55760405162461bcd60e51b815260206004820152600c60248201526b2737ba1030b71037bbb732b960a11b604482015260640161039b565b9150806110c1816118f0565b915050610f8d565b5060008a8152600160208190526040808320805460ff19169092179091555181906001600160a01b038c16908b90611104908c908c90611a12565b60006040518083038185875af1925050503d8060008114611141576040519150601f19603f3d011682016040523d82523d6000602084013e611146565b606091505b5091509150816111845760405162461bcd60e51b8152602060048201526009602482015268151e0819985a5b195960ba1b604482015260640161039b565b8b336001600160a01b03167f54694d3a513688872901507de586f0f7ca96654e1f70ae5d5c477f74bccfd9498d8d8d8d876040516111c6959493929190611a22565b60405180910390a39b9a5050505050505050505050565b6000806000806111ed8686611209565b9250925092506111fd8282611256565b50909150505b92915050565b600080600083516041036112435760208401516040850151606086015160001a61123588828585611313565b95509550955050505061124f565b50508151600091506002905b9250925092565b600082600381111561126a5761126a611a83565b03611273575050565b600182600381111561128757611287611a83565b036112a55760405163f645eedf60e01b815260040160405180910390fd5b60028260038111156112b9576112b9611a83565b036112da5760405163fce698f760e01b81526004810182905260240161039b565b60038260038111156112ee576112ee611a83565b0361130f576040516335e2f38360e21b81526004810182905260240161039b565b5050565b600080807f7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a084111561134e57506000915060039050826113d8565b604080516000808252602082018084528a905260ff891692820192909252606081018790526080810186905260019060a0016020604051602081039080840390855afa1580156113a2573d6000803e3d6000fd5b5050604051601f1901519150506001600160a01b0381166113ce575060009250600191508290506113d8565b9250600091508190505b9450945094915050565b6000602082840312156113f457600080fd5b5035919050565b80356001600160a01b038116811461141257600080fd5b919050565b60006020828403121561142957600080fd5b611432826113fb565b9392505050565b6000806040838503121561144c57600080fd5b611455836113fb565b946020939093013593505050565b634e487b7160e01b600052604160045260246000fd5b6000806000806080858703121561148f57600080fd5b8435935061149f602086016113fb565b925060408501359150606085013567ffffffffffffffff808211156114c357600080fd5b818701915087601f8301126114d757600080fd5b8135818111156114e9576114e9611463565b604051601f8201601f19908116603f0116810190838211818310171561151157611511611463565b816040528281528a602084870101111561152a57600080fd5b82602086016020830137600060208483010152809550505050505092959194509250565b60008083601f84011261156057600080fd5b50813567ffffffffffffffff81111561157857600080fd5b6020830191508360208260051b850101111561159357600080fd5b9250929050565b600080600080600080606087890312156115b357600080fd5b863567ffffffffffffffff808211156115cb57600080fd5b6115d78a838b0161154e565b909850965060208901359150808211156115f057600080fd5b6115fc8a838b0161154e565b9096509450604089013591508082111561161557600080fd5b5061162289828a0161154e565b979a9699509497509295939492505050565b6000806000806040858703121561164a57600080fd5b843567ffffffffffffffff8082111561166257600080fd5b61166e8883890161154e565b9096509450602087013591508082111561168757600080fd5b506116948782880161154e565b95989497509550505050565b6020808252825182820181905260009190848201906040850190845b818110156116e15783516001600160a01b0316835292840192918401916001016116bc565b50909695505050505050565b600080600080600080600060a0888a03121561170857600080fd5b87359650611718602089016113fb565b955060408801359450606088013567ffffffffffffffff8082111561173c57600080fd5b818a0191508a601f83011261175057600080fd5b81358181111561175f57600080fd5b8b602082850101111561177157600080fd5b6020830196508095505060808a013591508082111561178f57600080fd5b5061179c8a828b0161154e565b989b979a50959850939692959293505050565b60005b838110156117ca5781810151838201526020016117b2565b50506000910152565b600081518084526117eb8160208601602086016117af565b601f01601f19169290920160200192915050565b60208152600061143260208301846117d3565b6020808252600890820152672737ba1029b2b63360c11b604082015260600190565b6020808252601e908201527f4d757374206265206e6f6e2d7a65726f20736967732072657175697265640000604082015260600190565b6020808252601690820152750a6d2cee640e4cae2ead2e4cac840e8dede40d0d2ced60531b604082015260600190565b634e487b7160e01b600052603260045260246000fd5b634e487b7160e01b600052601160045260246000fd5b81810381811115611203576112036118b1565b634e487b7160e01b600052603160045260246000fd5b600060018201611902576119026118b1565b5060010190565b60006bffffffffffffffffffffffff19808960601b168352876014840152866034840152808660601b1660548401525083606883015282516119528160888501602087016117af565b91909101608801979650505050505050565b6020808252600f908201526e098cadccee8d040dad2e6dac2e8c6d608b1b604082015260600190565b6000825161199f8184602087016117af565b9190910192915050565b6000602082840312156119bb57600080fd5b8151801515811461143257600080fd5b6000808335601e198436030181126119e257600080fd5b83018035915067ffffffffffffffff8211156119fd57600080fd5b60200191503681900382131561159357600080fd5b8183823760009101908152919050565b6001600160a01b0386168152602081018590526080604082018190528101839052828460a0830137600060a084830101526000601f19601f850116820160a0838203016060840152611a7760a08201856117d3565b98975050505050505050565b634e487b7160e01b600052602160045260246000fdfea26469706673582212204cb9d03210f5a55994928416364164ce6efba6c42a17fa847366c18bd8931caf64736f6c63430008140033"; diff --git a/packages/shared/src/types/account.ts b/packages/shared/src/types/account.ts index 17ab158..b2524b3 100644 --- a/packages/shared/src/types/account.ts +++ b/packages/shared/src/types/account.ts @@ -14,4 +14,7 @@ export interface Account { createdAt: string; updatedAt: string; signers: AccountSigner[]; + // "zk" (default, privacy-preserving) or "ecdsa" (Arc, non-private). Optional so + // existing ZK payloads without the field are still valid. + chainType?: "zk" | "ecdsa"; }