Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
7 changes: 6 additions & 1 deletion packages/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,9 @@ SNAG_RULE_ID="your-snag-rule-id-here"

# x402 gasless USDC deposit (optional)
FEATURE_X402_DEPOSIT=false
X402_FACILITATOR_URL=https://facilitator.payai.network
X402_FACILITATOR_URL=https://facilitator.payai.network

# Stealth payments via Umbra (optional, Base mainnet only)
FEATURE_STEALTH=false
STEALTH_RELAYER_PRIVATE_KEY=
STEALTH_RPC_URL=https://mainnet.base.org
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Add stealth (send privately) flag to batch_items.
ALTER TABLE "batch_items"
ADD COLUMN "send_privately" BOOLEAN NOT NULL DEFAULT false;
21 changes: 11 additions & 10 deletions packages/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -126,16 +126,17 @@ model Vote {
}

model BatchItem {
id String @id @default(cuid())
userId String @map("user_id")
contactId String? @map("contact_id")
recipient String
amount String
tokenAddress String? @map("token_address")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
contact Contact? @relation(fields: [contactId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
userId String @map("user_id")
contactId String? @map("contact_id")
recipient String
amount String
tokenAddress String? @map("token_address")
sendPrivately Boolean @default(false) @map("send_privately")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
contact Contact? @relation(fields: [contactId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([userId])
@@map("batch_items")
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ import { AdminModule } from './admin/admin.module';
import { BalanceAlertModule } from './balance-alert/balance-alert.module';
import { ScheduleModule } from '@nestjs/schedule';
import { X402Module } from './x402/x402.module';
import { StealthModule } from './stealth/stealth.module';

const featureX402 = process.env.FEATURE_X402_DEPOSIT === 'true';
const featureStealth = process.env.FEATURE_STEALTH === 'true';

@Module({
imports: [
Expand Down Expand Up @@ -53,6 +55,7 @@ const featureX402 = process.env.FEATURE_X402_DEPOSIT === 'true';
BalanceAlertModule,
ScheduleModule.forRoot(),
...(featureX402 ? [X402Module] : []),
...(featureStealth ? [StealthModule] : []),
],
})
export class AppModule implements NestModule {
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/batch-item/batch-item.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export class BatchItemService {
amount: dto.amount,
tokenAddress: dto.tokenAddress,
contactId: dto.contactId,
sendPrivately: dto.sendPrivately ?? false,
},
include: {
contact: true,
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/config/config.keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,9 @@ export const CONFIG_KEYS = {
X402_ENABLED: 'x402.enabled',
X402_FACILITATOR_URL: 'x402.facilitatorUrl',
X402_FACILITATOR_BEARER_TOKEN: 'x402.facilitatorBearerToken',

// Stealth payments (Umbra)
STEALTH_ENABLED: 'stealth.enabled',
STEALTH_RELAYER_PRIVATE_KEY: 'stealth.relayerPrivateKey',
STEALTH_RPC_URL: 'stealth.rpcUrl',
} as const;
2 changes: 2 additions & 0 deletions packages/backend/src/config/config.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import relayerConfig from './relayer.config';
import telegramConfig from './telegram.config';
import snagConfig from './snag.config';
import x402Config from './x402.config';
import stealthConfig from './stealth.config';
import { validationSchema } from './env.validation';

@Module({
Expand All @@ -21,6 +22,7 @@ import { validationSchema } from './env.validation';
telegramConfig,
snagConfig,
x402Config,
stealthConfig,
],
envFilePath: ['.env.local', '.env'],
cache: true,
Expand Down
18 changes: 18 additions & 0 deletions packages/backend/src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,22 @@ export const validationSchema = Joi.object({
otherwise: Joi.string().uri().optional(),
}),
X402_FACILITATOR_BEARER_TOKEN: Joi.string().optional().allow(''),

// Stealth payments (optional; module loads only when FEATURE_STEALTH=true)
FEATURE_STEALTH: Joi.string().valid('true', 'false').default('false'),
STEALTH_RELAYER_PRIVATE_KEY: Joi.alternatives().conditional(
'FEATURE_STEALTH',
{
is: 'true',
then: Joi.string()
.pattern(/^0x[a-fA-F0-9]{64}$/)
.required(),
otherwise: Joi.string().optional().allow(''),
},
),
STEALTH_RPC_URL: Joi.alternatives().conditional('FEATURE_STEALTH', {
is: 'true',
then: Joi.string().uri().required(),
otherwise: Joi.string().uri().optional(),
}),
});
7 changes: 7 additions & 0 deletions packages/backend/src/config/stealth.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';

export default registerAs('stealth', () => ({
enabled: process.env.FEATURE_STEALTH === 'true',
relayerPrivateKey: process.env.STEALTH_RELAYER_PRIVATE_KEY ?? '',
rpcUrl: process.env.STEALTH_RPC_URL ?? 'https://mainnet.base.org',
}));
35 changes: 35 additions & 0 deletions packages/backend/src/stealth/stealth.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Base mainnet is the only chain where Umbra is deployed and we operate.
export const STEALTH_CHAIN_ID = 8453;

// ABI fragments — we keep these minimal so we don't ship the full Umbra ABI
// just for two functions.
export const STEALTH_KEY_REGISTRY_ABI = [
{
name: 'stealthKeys',
type: 'function',
stateMutability: 'view',
inputs: [{ name: 'registrant', type: 'address' }],
outputs: [
{ name: 'spendingPubKeyPrefix', type: 'uint256' },
{ name: 'spendingPubKey', type: 'uint256' },
{ name: 'viewingPubKeyPrefix', type: 'uint256' },
{ name: 'viewingPubKey', type: 'uint256' },
],
},
{
name: 'setStealthKeysOnBehalf',
type: 'function',
stateMutability: 'nonpayable',
inputs: [
{ name: 'registrant', type: 'address' },
{ name: 'spendingPubKeyPrefix', type: 'uint256' },
{ name: 'spendingPubKey', type: 'uint256' },
{ name: 'viewingPubKeyPrefix', type: 'uint256' },
{ name: 'viewingPubKey', type: 'uint256' },
{ name: 'v', type: 'uint8' },
{ name: 'r', type: 'bytes32' },
{ name: 's', type: 'bytes32' },
],
outputs: [],
},
] as const;
31 changes: 31 additions & 0 deletions packages/backend/src/stealth/stealth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import {
RegisterStealthKeysDto,
type RegisterStealthKeysResponse,
type StealthRegistrationStatusResponse,
} from '@polypay/shared';
import { StealthService } from './stealth.service';

@Controller('stealth')
@UseGuards(ThrottlerGuard)
export class StealthController {
constructor(private readonly stealthService: StealthService) {}

// Heavier limit on register because it triggers an on-chain tx with our gas.
@Throttle({ default: { ttl: 60_000, limit: 5 } })
@Post('register')
async register(
@Body() dto: RegisterStealthKeysDto,
): Promise<RegisterStealthKeysResponse> {
return this.stealthService.register(dto);
}

@Throttle({ default: { ttl: 60_000, limit: 60 } })
@Get('status/:walletAddress')
async status(
@Param('walletAddress') walletAddress: string,
): Promise<StealthRegistrationStatusResponse> {
return this.stealthService.getStatus(walletAddress);
}
}
9 changes: 9 additions & 0 deletions packages/backend/src/stealth/stealth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { StealthController } from './stealth.controller';
import { StealthService } from './stealth.service';

@Module({
controllers: [StealthController],
providers: [StealthService],
})
export class StealthModule {}
Loading
Loading