Skip to content

Commit e338925

Browse files
authored
Merge pull request #888 from Bizify1370/feat/stealth-address-index-867
feat(stealth): persist stealth address index for deterministic re-derivation
2 parents cdc49c6 + a18f84a commit e338925

6 files changed

Lines changed: 244 additions & 0 deletions

File tree

backend/src/services/subscription-service.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import logger from "../config/logger";
88
import { DatabaseTransaction } from "../utils/transaction";
99
import SERVICE_CATEGORIES from "../../services/service-categories";
1010
import { validateCursor, encodeCursor } from "../utils/pagination";
11+
import { deriveStealthAddress } from "../../../shared/src/crypto/stealth-derive";
1112
import type {
1213
Subscription,
1314
SubscriptionCreateInput,
@@ -38,6 +39,25 @@ export class SubscriptionService {
3839
): Promise<SubscriptionSyncResult> {
3940
return await DatabaseTransaction.execute(async (client) => {
4041
try {
42+
// Determine the next stealth derivation index for this user
43+
const { data: indexRow } = await client
44+
.from("subscriptions")
45+
.select("stealth_index")
46+
.eq("user_id", userId)
47+
.order("stealth_index", { ascending: false })
48+
.limit(1)
49+
.single();
50+
51+
const stealthIndex = indexRow ? (indexRow.stealth_index as number) + 1 : 0;
52+
53+
// Derive stealth address when a meta-address is available
54+
const metaAddress = process.env.STEALTH_META_ADDRESS;
55+
let stealthAddress: string | null = null;
56+
if (metaAddress) {
57+
// subscriptionId is not yet known; we will update after insert
58+
// Store null initially and patch below once we have the row id
59+
}
60+
4161
const { data: subscription, error: dbError } = await client
4262
.from("subscriptions")
4363
.insert({
@@ -57,6 +77,8 @@ export class SubscriptionService {
5777
visibility: input.visibility || "private",
5878
tags: input.tags || [],
5979
email_account_id: input.email_account_id || null,
80+
stealth_index: stealthIndex,
81+
stealth_address: null,
6082
updated_at: new Date().toISOString(),
6183
})
6284
.select()
@@ -66,6 +88,16 @@ export class SubscriptionService {
6688
throw new Error(`Database error: ${dbError.message}`);
6789
}
6890

91+
// Now that we have the subscription id, derive and persist the stealth address
92+
if (metaAddress) {
93+
stealthAddress = deriveStealthAddress(metaAddress, subscription.id, stealthIndex);
94+
await client
95+
.from("subscriptions")
96+
.update({ stealth_address: stealthAddress })
97+
.eq("id", subscription.id);
98+
subscription.stealth_address = stealthAddress;
99+
}
100+
69101
// Attempt blockchain sync (non-blocking)
70102
let blockchainResult;
71103
let syncStatus: "synced" | "partial" | "failed" = "synced";
@@ -878,6 +910,38 @@ if (error) {
878910
return data || [];
879911
}
880912

913+
/**
914+
* Recover all stealth addresses for a user by re-deriving them from their
915+
* stored indices. Useful for wallet recovery without on-chain scanning.
916+
*
917+
* For each subscription with a stealth_index, computes:
918+
* Address = HMAC-SHA256(metaAddress, `${subscription.id}:${stealth_index}`)
919+
*
920+
* @param userId - Owner whose subscriptions to re-derive.
921+
* @param metaAddress - The user's stealth meta-address (wallet-level secret).
922+
* @returns Array of { subscriptionId, stealthIndex, stealthAddress }.
923+
*/
924+
async recoverStealthAddresses(
925+
userId: string,
926+
metaAddress: string,
927+
): Promise<{ subscriptionId: string; stealthIndex: number; stealthAddress: string }[]> {
928+
const { data: rows, error } = await supabase
929+
.from("subscriptions")
930+
.select("id, stealth_index")
931+
.eq("user_id", userId)
932+
.order("stealth_index", { ascending: true });
933+
934+
if (error) {
935+
throw new Error(`Failed to fetch subscriptions for recovery: ${error.message}`);
936+
}
937+
938+
return (rows ?? []).map((row) => ({
939+
subscriptionId: row.id as string,
940+
stealthIndex: row.stealth_index as number,
941+
stealthAddress: deriveStealthAddress(metaAddress, row.id as string, row.stealth_index as number),
942+
}));
943+
}
944+
881945
/**
882946
* Auto-tag a subscription with a category based on its name.
883947
* Uses keyword mapping from SERVICE_CATEGORIES lookup table.

backend/src/types/subscription.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export interface Subscription {
3131
last_interaction_at: string | null;
3232
last_renewal_attempt_at?: string | null;
3333
failure_count?: number;
34+
stealth_index: number;
35+
stealth_address: string | null;
3436
}
3537

3638
export interface SubscriptionCreateInput {
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { deriveStealthAddress } from '../../shared/src/crypto/stealth-derive';
2+
3+
// ── deriveStealthAddress unit tests ──────────────────────────────────────────
4+
5+
describe('deriveStealthAddress', () => {
6+
const META = 'test-meta-address';
7+
const SUB_ID = 'sub-abc-123';
8+
9+
it('returns a 64-char hex string', () => {
10+
expect(deriveStealthAddress(META, SUB_ID, 0)).toMatch(/^[0-9a-f]{64}$/);
11+
});
12+
13+
it('is deterministic', () => {
14+
expect(deriveStealthAddress(META, SUB_ID, 0)).toBe(deriveStealthAddress(META, SUB_ID, 0));
15+
});
16+
17+
it('different indices produce different addresses', () => {
18+
expect(deriveStealthAddress(META, SUB_ID, 0)).not.toBe(deriveStealthAddress(META, SUB_ID, 1));
19+
});
20+
21+
it('different subscription IDs produce different addresses', () => {
22+
expect(deriveStealthAddress(META, 'sub-111', 0)).not.toBe(deriveStealthAddress(META, 'sub-222', 0));
23+
});
24+
25+
it('different meta-addresses produce different addresses', () => {
26+
expect(deriveStealthAddress('meta-A', SUB_ID, 0)).not.toBe(deriveStealthAddress('meta-B', SUB_ID, 0));
27+
});
28+
29+
it('100 consecutive indices are all unique (no collisions)', () => {
30+
const addrs = Array.from({ length: 100 }, (_, i) => deriveStealthAddress(META, SUB_ID, i));
31+
expect(new Set(addrs).size).toBe(100);
32+
});
33+
34+
it('throws RangeError for negative index', () => {
35+
expect(() => deriveStealthAddress(META, SUB_ID, -1)).toThrow(RangeError);
36+
});
37+
38+
it('throws RangeError for non-integer index', () => {
39+
expect(() => deriveStealthAddress(META, SUB_ID, 1.5)).toThrow(RangeError);
40+
});
41+
});
42+
43+
// ── Stealth index assignment in subscription-service tests ───────────────────
44+
45+
jest.mock('../src/config/database', () => ({ supabase: { from: jest.fn() } }));
46+
jest.mock('../src/config/logger', () => ({
47+
default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
48+
__esModule: true,
49+
}));
50+
jest.mock('../src/services/blockchain-service', () => ({
51+
blockchainService: { syncSubscription: jest.fn() },
52+
}));
53+
jest.mock('../src/services/analytics-service', () => ({
54+
analyticsService: { checkBudgetThreshold: jest.fn() },
55+
}));
56+
jest.mock('../src/services/referral-service', () => ({
57+
referralService: { markConverted: jest.fn() },
58+
}));
59+
jest.mock('../src/services/renewal-cooldown-service', () => ({
60+
renewalCooldownService: { checkCooldown: jest.fn(), recordRenewalAttempt: jest.fn() },
61+
}));
62+
jest.mock('../src/utils/transaction');
63+
64+
import { subscriptionService } from '../src/services/subscription-service';
65+
import { DatabaseTransaction } from '../src/utils/transaction';
66+
import { blockchainService } from '../src/services/blockchain-service';
67+
import { analyticsService } from '../src/services/analytics-service';
68+
import { referralService } from '../src/services/referral-service';
69+
70+
describe('SubscriptionService — stealth index assignment', () => {
71+
const USER_ID = 'user-xyz';
72+
const BASE = { name: 'Netflix', price: 15.99, billing_cycle: 'monthly' as const };
73+
74+
beforeEach(() => {
75+
// resetMocks:true wipes implementations — restore each test
76+
(analyticsService.checkBudgetThreshold as jest.Mock).mockResolvedValue(undefined);
77+
(referralService.markConverted as jest.Mock).mockResolvedValue(undefined);
78+
(blockchainService.syncSubscription as jest.Mock).mockResolvedValue({ success: true });
79+
delete process.env.STEALTH_META_ADDRESS;
80+
});
81+
82+
function setupTransaction(
83+
indexRow: { stealth_index: number } | null,
84+
insertedSub: Record<string, unknown>,
85+
) {
86+
(DatabaseTransaction.execute as jest.Mock).mockImplementation(
87+
async (fn: (c: any) => Promise<any>) => {
88+
let call = 0;
89+
return fn({
90+
from: () => {
91+
call++;
92+
if (call === 1) {
93+
return {
94+
select: () => ({ eq: () => ({ order: () => ({ limit: () => ({ single: () => Promise.resolve({ data: indexRow, error: null }) }) }) }) }),
95+
};
96+
}
97+
if (call === 2) {
98+
return {
99+
insert: () => ({ select: () => ({ single: () => Promise.resolve({ data: insertedSub, error: null }) }) }),
100+
};
101+
}
102+
// call === 3: UPDATE stealth_address
103+
return { update: () => ({ eq: () => Promise.resolve({ error: null }) }) };
104+
},
105+
});
106+
},
107+
);
108+
}
109+
110+
it('assigns stealth_index 0 when no subscriptions exist', async () => {
111+
setupTransaction(null, { id: 's1', user_id: USER_ID, name: 'Netflix', stealth_index: 0, stealth_address: null });
112+
const { subscription } = await subscriptionService.createSubscription(USER_ID, BASE);
113+
expect(subscription.stealth_index).toBe(0);
114+
});
115+
116+
it('assigns stealth_index = max + 1', async () => {
117+
setupTransaction({ stealth_index: 2 }, { id: 's2', user_id: USER_ID, name: 'Netflix', stealth_index: 3, stealth_address: null });
118+
const { subscription } = await subscriptionService.createSubscription(USER_ID, BASE);
119+
expect(subscription.stealth_index).toBe(3);
120+
});
121+
122+
it('derives and persists stealth_address when STEALTH_META_ADDRESS is set', async () => {
123+
process.env.STEALTH_META_ADDRESS = 'secret-meta';
124+
setupTransaction(null, { id: 'sub-stealth', user_id: USER_ID, name: 'Netflix', stealth_index: 0, stealth_address: null });
125+
const { subscription } = await subscriptionService.createSubscription(USER_ID, BASE);
126+
expect(subscription.stealth_address).toBe(deriveStealthAddress('secret-meta', 'sub-stealth', 0));
127+
});
128+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { createHmac } from "crypto";
2+
3+
/**
4+
* Derives a deterministic stealth address for a subscription.
5+
*
6+
* Address = HMAC-SHA256(meta_address, `${subscriptionId}:${index}`)
7+
*
8+
* Properties:
9+
* - Same inputs always produce the same address (deterministic).
10+
* - Different indices produce different addresses (no collisions across subscriptions).
11+
* - On wallet recovery, iterate index 0..N to regenerate all addresses.
12+
*
13+
* @param metaAddress - The user's stealth meta-address (wallet-level secret).
14+
* @param subscriptionId - The subscription's unique identifier.
15+
* @param index - The per-subscription derivation index (starts at 0).
16+
* @returns A hex-encoded 32-byte stealth address string.
17+
*/
18+
export function deriveStealthAddress(
19+
metaAddress: string,
20+
subscriptionId: string,
21+
index: number,
22+
): string {
23+
if (index < 0 || !Number.isInteger(index)) {
24+
throw new RangeError(`stealth_index must be a non-negative integer, got ${index}`);
25+
}
26+
return createHmac("sha256", metaAddress)
27+
.update(`${subscriptionId}:${index}`)
28+
.digest("hex");
29+
}

shared/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,6 @@ export * from './sentry';
4040

4141
// Soroban contract interfaces (backend ↔ contract compatibility)
4242
export * from './soroban-contract-interfaces';
43+
44+
// Stealth address deterministic derivation
45+
export * from './crypto/stealth-derive';
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- Migration: Add stealth address derivation index to subscriptions
2+
-- Issue: #867 - Persist stealth address index for deterministic re-derivation
3+
4+
ALTER TABLE subscriptions
5+
ADD COLUMN IF NOT EXISTS stealth_index INTEGER NOT NULL DEFAULT 0,
6+
ADD COLUMN IF NOT EXISTS stealth_address TEXT;
7+
8+
COMMENT ON COLUMN subscriptions.stealth_index IS
9+
'Per-subscription derivation index. Address = HMAC-SHA256(meta_address, subscription_id:stealth_index).';
10+
11+
COMMENT ON COLUMN subscriptions.stealth_address IS
12+
'Derived stealth address for this subscription (hex-encoded). Re-derivable from stealth_index.';
13+
14+
-- Ensure no two subscriptions for the same user share the same index.
15+
-- (user_id, stealth_index) must be unique to prevent collisions.
16+
CREATE UNIQUE INDEX IF NOT EXISTS subscriptions_user_stealth_index_unique
17+
ON subscriptions (user_id, stealth_index)
18+
WHERE stealth_address IS NOT NULL;

0 commit comments

Comments
 (0)