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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docker/backend.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ COPY --chown=nestjs:nodejs --from=builder /app/packages/backend/prisma.config.ts
# Copy assets folder
COPY --chown=nestjs:nodejs --from=builder /app/packages/backend/assets ./packages/backend/assets

# Copy one-off maintenance scripts (run via `yarn <script>` from Cloud Run Jobs).
COPY --chown=nestjs:nodejs --from=builder /app/packages/backend/scripts ./packages/backend/scripts

USER nestjs

WORKDIR /app/packages/backend
Expand Down
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- [ZK Authentication](zk-authentication.md)
- [zkVerify, Horizen & Base Integration](zkverify-horizen-integration.md)
- [Gasless USDC Deposits (x402)](x402-deposits.md)
- [Stealth Payments (Umbra)](stealth-payments.md)
- [PolyPay for AI Agents](llms-txt-for-agents.md)
- [Architecture](architecture.md)
- [Developer Documentation](developer-documentation/README.md)
Expand Down
60 changes: 60 additions & 0 deletions docs/stealth-payments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Stealth Payments (Umbra)

PolyPay multisigs on Base mainnet can send **ETH privately** using the [Umbra stealth payment protocol](https://app.umbra.cash/). Each send generates a one-time stealth address on the fly, so there's no on-chain link between the sender's multisig and the recipient's real wallet. The recipient withdraws later via the Umbra app.

## Scope

What's supported today:

- **Single transfer**, **ETH only**, **Base mainnet only**
- Recipient must be pre-registered on the Umbra `StealthKeyRegistry` (one-time, via [app.umbra.cash](https://app.umbra.cash))
- Sender uses the standard PolyPay Transfer flow with the "Send privately" toggle enabled

What's intentionally **not** supported:

| Feature | Status | Reason |
|---|---|---|
| Batch stealth sends | ❌ | A multisig `execute` targets a single contract per call. The current PolyPay batch flow routes through the multisig's own `batchTransfer`, which can't be combined with `UmbraBatchSend` in a single execute — we chose not to ship the alternative |
| USDC and other ERC20 stealth | ❌ | `UmbraBatchSend` pulls ERC20 via `safeTransferFrom`, requiring the multisig to first approve `UmbraBatchSend` as spender. We didn't build the one-time approval flow |
| Base Sepolia / Horizen | ❌ | Umbra protocol is only deployed on Base mainnet (among PolyPay's supported chains) |
| In-app stealth key registration | ❌ | Recipients register directly via `app.umbra.cash` so PolyPay holds no relayer key for stealth |

## Sender flow

1. Open **Transfer** on a Base-mainnet multisig.
2. Pick the recipient, enter the ETH amount.
3. Tick **Send privately (stealth)**. The toggle only appears when the recipient is registered on Umbra; otherwise PolyPay shows a hint asking you to share `app.umbra.cash` with them first. A small line under the toggle displays the current Umbra protocol toll the multisig will pay on top of the amount.
4. Submit and proceed with the normal multisig voting flow. Cosigners see a **Private** badge on the transaction so they know it routes through Umbra rather than directly to the recipient.
5. After enough approvals, the transaction executes. If the recipient is also a PolyPay user and has their wallet connected, the transaction row shows them a hint (green dot on the recipient pill + an Open Umbra button in the expanded detail). Otherwise the recipient will need to be told separately to check `app.umbra.cash` — PolyPay does not notify them off-platform.

## Recipient setup (one-time)

Stealth keys are registered on the Umbra `StealthKeyRegistry` once per wallet. PolyPay does not host this flow — recipients use Umbra directly:

1. Sidebar in PolyPay shows a **Receive privately** card whenever the current account is on Base mainnet and a wallet is connected. The card icon and label reflect the wallet's current state:
- Not registered yet — violet shield, label "Receive privately"
- Already registered — green shield, label "Private receive ready"
2. Clicking opens a modal that points to [app.umbra.cash](https://app.umbra.cash) and explains the steps. PolyPay does not write to the registry; the recipient signs and submits the registration tx themselves (~$0.01 gas on Base).
3. After registering, the recipient returns to PolyPay and clicks **I've completed setup**. PolyPay re-reads the registry on chain. The modal switches to a success state showing the wallet address (with a copy button to share with senders), and the sidebar card flips to the green "Private receive ready" state so the user always has a one-click handle to revisit their setup.

## Recipient withdrawal

PolyPay does not implement stealth withdrawal — recipients use Umbra. There are two ways to get to the right place:

- **From PolyPay** — if the connected wallet matches the recipient address of an executed stealth transaction, the transaction row shows a small green dot on the recipient pill. Expanding the row reveals an **Open Umbra ↗** button that opens `app.umbra.cash` directly.
- **From anywhere else** — just visit [app.umbra.cash](https://app.umbra.cash). PolyPay isn't required to withdraw.

Either way, the flow on Umbra is:

1. Connect the same wallet that was used during setup.
2. Sign once — Umbra's app scans the `Announcement` events on its contract and finds the payments addressed to you.
3. Withdraw each payment using whatever options Umbra offers — typically a hosted relayer that covers gas (with a fee taken from the withdrawn amount), or a self-paid withdrawal if you already hold ETH on the stealth address. Refer to Umbra's own docs for current details.

## Cost

| Cost | Paid by | Notes |
|---|---|---|
| Stealth payment amount | Multisig | The amount the sender intends the recipient to receive |
| Umbra protocol toll | Multisig | `umbra.toll()` read live on each propose. The multisig sends `amount + toll` ETH to `UmbraBatchSend`; Umbra keeps the toll, the recipient receives the amount. Currently ~0.00003 ETH on Base |
| Execute gas | Relayer | PolyPay's relayer wallet covers the on-chain `multisig.execute(...)` gas, same as any other PolyPay transaction |
| Withdrawal gas (recipient side) | Umbra relayer or recipient | Umbra typically offers a hosted relayer that takes a fee from the withdrawn token, or self-paid withdrawal if the stealth address has ETH. Exact mechanics live on Umbra's side |
20 changes: 20 additions & 0 deletions docs/zkverify-horizen-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ PolyPay uses multiple blockchain layers for privacy-preserving multisig operatio

> **"Destination Chain"** refers to the EVM chain where the multisig account is deployed — either **Horizen** (L3, chain ID 26514) or **Base** (L2, chain ID 8453). The user selects the destination chain when creating an account, and all subsequent operations for that account happen on the same chain.

## Network support matrix

Features available per destination chain. The ZK private multisig core works on every supported chain; integrations on top of it are EVM-ecosystem features that only some chains have.

| Feature | Horizen Mainnet | Horizen Testnet | Base Mainnet | Base Sepolia |
|---|---|---|---|---|
| ZK private multisig (transfer, batch, signer mgmt, threshold) | ✅ | ✅ | ✅ | ✅ |
| ETH transfers (native) | ✅ | ✅ | ✅ | ✅ |
| ZEN transfers (bridged ERC20) | ✅ | ✅ | ✅ | ✅ |
| USDC transfers (bridged ERC20) | ✅ | ✅ | ✅ | ✅ |
| Gasless USDC deposit via x402 | — | — | ✅ | ✅ |
| Stealth payments (Umbra) | — | — | ✅ | — |
| Native gas token | ETH | ETH | ETH | ETH |

Notes:

- **x402 gasless deposits** ride on EIP-3009 USDC, available on Base where USDC is canonical.
- **Stealth payments** rely on the Umbra protocol, which (among PolyPay's supported chains) is only deployed on Base mainnet. See [Stealth Payments](stealth-payments.md).
- All four chains are EVM L2/L3 networks that use ETH for gas — there is no native ZEN gas token on any of them in PolyPay's current deployments.

## Blockchain Classification

| Action | Blockchain | Description |
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"test:e2e:staging": "jest --config ./test/jest-staging-e2e.json",
"report": "ts-node scripts/generate-analytics-report.ts"
"report": "ts-node scripts/generate-analytics-report.ts",
"wipe:horizen-testnet": "tsx scripts/wipe-horizen-testnet.ts"
},
"dependencies": {
"@aztec/bb.js": "0.84.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "transactions" ADD COLUMN "stealth_data" TEXT;
1 change: 1 addition & 0 deletions packages/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ model Transaction {
signerData String? @map("signer_data")
newThreshold Int? @map("new_threshold")
batchData String? @map("batch_data")
stealthData String? @map("stealth_data")
createdBy String @map("created_by")
threshold Int
txHash String? @map("tx_hash")
Expand Down
158 changes: 158 additions & 0 deletions packages/backend/scripts/wipe-horizen-testnet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* One-off cleanup: wipe all PolyPay data tied to Horizen testnet
* (chainId 2651420) so accounts created against the old zkVerify contract
* stop showing up after the contract address migration.
*
* Run: yarn wipe:horizen-testnet
* GCP job command: cd packages/backend && yarn install --frozen-lockfile && yarn wipe:horizen-testnet
*
* Safety:
* - Refuses to run when APP_NETWORK=mainnet (wrong environment).
* - Wraps deletions in a single Prisma transaction; either all rows go
* or none do, so a partial run can't leave the DB in a half-wiped state.
* - User accounts, login_history, notifications, and batch_items are
* intentionally preserved — they're not tied to a specific chain and
* deleting them would log users out / drop unrelated state.
*/
import 'dotenv/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/generated/prisma/client';

const HORIZEN_TESTNET_CHAIN_ID = 2651420;
const DRY_RUN = process.argv.includes('--dry-run');

async function main() {
if (process.env.APP_NETWORK === 'mainnet') {
throw new Error(
'Refusing to run: APP_NETWORK=mainnet. This script only targets Horizen testnet data.',
);
}

const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL is not set');
}
const adapter = new PrismaPg({ connectionString });
const prisma = new PrismaClient({ adapter });
const chainId = HORIZEN_TESTNET_CHAIN_ID;

console.log(
`[wipe-horizen-testnet] mode=${DRY_RUN ? 'DRY-RUN' : 'DELETE'} chainId=${chainId}`,
);

try {
const accounts = await prisma.account.findMany({
where: { chainId },
select: { id: true },
});
const accountIds = accounts.map((a) => a.id);

const contacts = accountIds.length
? await prisma.contact.findMany({
where: { accountId: { in: accountIds } },
select: { id: true },
})
: [];
const contactIds = contacts.map((c) => c.id);

const txIds = (
await prisma.transaction.findMany({
where: { chainId },
select: { txId: true },
})
).map((t) => t.txId);

const voteCount = txIds.length
? await prisma.vote.count({ where: { txId: { in: txIds } } })
: 0;
const signerCount = accountIds.length
? await prisma.accountSigner.count({
where: { accountId: { in: accountIds } },
})
: 0;
const reservedNonceCount = await prisma.reservedNonce.count({
where: { chainId },
});
const batchItemRefCount = contactIds.length
? await prisma.batchItem.count({
where: { contactId: { in: contactIds } },
})
: 0;

console.log('[wipe-horizen-testnet] candidates', {
accounts: accountIds.length,
contacts: contactIds.length,
transactions: txIds.length,
votes: voteCount,
account_signers: signerCount,
reserved_nonces: reservedNonceCount,
batch_items_to_unlink: batchItemRefCount,
});

if (DRY_RUN) {
console.log('[wipe-horizen-testnet] dry-run, no rows deleted.');
return;
}

if (accountIds.length === 0 && txIds.length === 0 && reservedNonceCount === 0) {
console.log('[wipe-horizen-testnet] nothing to delete, exiting.');
return;
}

await prisma.$transaction(async (tx) => {
// BatchItem.contact has no onDelete cascade — Postgres will reject the
// Account delete (which cascades Contact) if any batch item still
// points to a contact we're about to remove. Null the link first.
if (contactIds.length) {
const { count } = await tx.batchItem.updateMany({
where: { contactId: { in: contactIds } },
data: { contactId: null },
});
console.log(`[wipe] unlinked batch_items.contactId: ${count}`);
}

if (txIds.length) {
const { count } = await tx.vote.deleteMany({
where: { txId: { in: txIds } },
});
console.log(`[wipe] deleted votes: ${count}`);
}

{
const { count } = await tx.transaction.deleteMany({
where: { chainId },
});
console.log(`[wipe] deleted transactions: ${count}`);
}

if (accountIds.length) {
const { count } = await tx.accountSigner.deleteMany({
where: { accountId: { in: accountIds } },
});
console.log(`[wipe] deleted account_signers: ${count}`);
}

{
const { count } = await tx.reservedNonce.deleteMany({
where: { chainId },
});
console.log(`[wipe] deleted reserved_nonces: ${count}`);
}

// Account deletion cascades Contact, ContactGroup, ContactGroupEntry.
{
const { count } = await tx.account.deleteMany({ where: { chainId } });
console.log(`[wipe] deleted accounts (cascades contacts/groups): ${count}`);
}
});

console.log('[wipe-horizen-testnet] done.');
} finally {
await prisma.$disconnect();
}
}

main().catch((err) => {
console.error('[wipe-horizen-testnet] fatal', err);
process.exit(1);
});
19 changes: 19 additions & 0 deletions packages/backend/src/transaction/transaction-executor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,25 @@ export class TransactionExecutorService {
value: string;
data: string;
} {
// Stealth transactions ship the exact (to, value, data) the proposer
// hashed and proved. Re-deriving it from semantic fields is impossible
// (ephemeral entropy is client-side only) and any drift would make the
// contract's hash check fail. Always use the stored payload verbatim.
if (transaction.stealthData) {
try {
const parsed = JSON.parse(transaction.stealthData) as {
to: string;
value: string;
data: string;
};
return { to: parsed.to, value: parsed.value, data: parsed.data };
} catch (err) {
throw new BadRequestException(
`Corrupt stealthData for txId ${transaction.txId}: ${(err as Error).message}`,
);
}
}

switch (transaction.type) {
case TxType.TRANSFER:
if (transaction?.tokenAddress) {
Expand Down
34 changes: 34 additions & 0 deletions packages/backend/src/transaction/transaction.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export class TransactionService {
createdBy: userCommitment,
status: TxStatus.PENDING,
batchData,
stealthData: dto.stealthData ?? null,
},
});

Expand Down Expand Up @@ -586,6 +587,10 @@ export class TransactionService {
// ============ Private Methods ============

private validateTransactionDto(dto: CreateTransactionDto) {
if (dto.stealthData !== undefined && dto.stealthData !== null) {
this.validateStealthData(dto.stealthData);
}

switch (dto.type) {
case TxType.TRANSFER:
if (!dto.to || !dto.value) {
Expand Down Expand Up @@ -643,6 +648,35 @@ export class TransactionService {
}
}

// stealthData is opaque to most of the system but the executor will submit
// it verbatim, so reject malformed payloads early. We don't validate the
// calldata target against a contract allowlist here because the proposer's
// ZK proof already binds (nonce, to, value, data); a wrong target only
// wastes the proposer's own gas reservation.
private validateStealthData(raw: string) {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new BadRequestException('stealthData must be a JSON string');
}
if (!parsed || typeof parsed !== 'object') {
throw new BadRequestException('stealthData must be a JSON object');
}
const { to, value, data } = parsed as Record<string, unknown>;
if (typeof to !== 'string' || !/^0x[a-fA-F0-9]{40}$/.test(to)) {
throw new BadRequestException('stealthData.to must be a 0x address');
}
if (typeof value !== 'string' || !/^[0-9]+$/.test(value)) {
throw new BadRequestException(
'stealthData.value must be a decimal string',
);
}
if (typeof data !== 'string' || !/^0x[a-fA-F0-9]*$/.test(data)) {
throw new BadRequestException('stealthData.data must be hex-encoded');
}
}

/**
* Check if transaction should be marked as FAILED
* Query totalSigners realtime from account.signers
Expand Down
Loading
Loading