@shroud-fi/payments 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shroud-fi/payments",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Sender-side stealth payment construction for ShroudFi. ETH + ERC-20 unlinkable payments on Base via EIP-5564 announcements.",
5
5
  "keywords": [
6
6
  "shroudfi",
@@ -27,13 +27,16 @@
27
27
  }
28
28
  },
29
29
  "files": [
30
- "dist"
30
+ "dist",
31
+ "src",
32
+ "tsconfig.json",
33
+ "README.md"
31
34
  ],
32
35
  "dependencies": {
33
36
  "@noble/curves": "^1.6.0",
34
37
  "@noble/hashes": "^1.5.0",
35
- "@shroud-fi/core": "0.1.2",
36
- "@shroud-fi/transport": "0.1.3"
38
+ "@shroud-fi/core": "0.1.3",
39
+ "@shroud-fi/transport": "0.1.4"
37
40
  },
38
41
  "peerDependencies": {
39
42
  "viem": "^2.21.0"
@@ -0,0 +1,59 @@
1
+ import type { Address } from 'viem';
2
+ import { AmountPrivacyNotSupportedError } from './errors.js';
3
+
4
+ export interface ShieldResult {
5
+ readonly txHash: string;
6
+ readonly noteId?: string;
7
+ }
8
+
9
+ export interface PrivateTransferResult {
10
+ readonly txHash: string;
11
+ readonly recipient: Address;
12
+ }
13
+
14
+ export interface UnshieldResult {
15
+ readonly txHash: string;
16
+ }
17
+
18
+ export interface AmountPrivacyProvider {
19
+ readonly name: string;
20
+ shield(token: Address, amount: bigint): Promise<ShieldResult>;
21
+ privateTransfer(to: Address, amount: bigint, token: Address): Promise<PrivateTransferResult>;
22
+ unshield(amount: bigint, token: Address): Promise<UnshieldResult>;
23
+ grantViewAccess(viewer: Address): Promise<void>;
24
+ estimateGas(token: Address, amount: bigint): Promise<bigint>;
25
+ }
26
+
27
+ export class NullAmountPrivacyProvider implements AmountPrivacyProvider {
28
+ readonly name = 'null';
29
+
30
+ async shield(_token?: Address, _amount?: bigint): Promise<ShieldResult> {
31
+ throw new AmountPrivacyNotSupportedError('shield');
32
+ }
33
+
34
+ async privateTransfer(
35
+ _to?: Address,
36
+ _amount?: bigint,
37
+ _token?: Address,
38
+ ): Promise<PrivateTransferResult> {
39
+ throw new AmountPrivacyNotSupportedError('privateTransfer');
40
+ }
41
+
42
+ async unshield(_amount?: bigint, _token?: Address): Promise<UnshieldResult> {
43
+ throw new AmountPrivacyNotSupportedError('unshield');
44
+ }
45
+
46
+ async grantViewAccess(_viewer?: Address): Promise<void> {
47
+ throw new AmountPrivacyNotSupportedError('grantViewAccess');
48
+ }
49
+
50
+ async estimateGas(_token?: Address, _amount?: bigint): Promise<bigint> {
51
+ throw new AmountPrivacyNotSupportedError('estimateGas');
52
+ }
53
+ }
54
+
55
+ export function assertSupported(provider: AmountPrivacyProvider): void {
56
+ if (provider.name === 'null') {
57
+ throw new AmountPrivacyNotSupportedError(provider.name);
58
+ }
59
+ }
@@ -0,0 +1,12 @@
1
+ import type { Address } from 'viem';
2
+
3
+ export const DEFAULT_GAS_MULTIPLIER = 120n;
4
+
5
+ export const ETH_SENTINEL = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' as const satisfies Address;
6
+
7
+ export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const satisfies Address;
8
+
9
+ export const SCHEME_ID = 1n;
10
+
11
+ export const ERC20_TRANSFER_GAS_ESTIMATE = 65_000n;
12
+ export const ETH_TRANSFER_GAS_ESTIMATE = 21_000n;
package/src/errors.ts ADDED
@@ -0,0 +1,86 @@
1
+ export class PaymentError extends Error {
2
+ override readonly name: string = 'PaymentError';
3
+ constructor(message: string) {
4
+ super(message);
5
+ }
6
+ }
7
+
8
+ export class SweepError extends Error {
9
+ override readonly name: string = 'SweepError';
10
+ constructor(message: string) {
11
+ super(message);
12
+ }
13
+ }
14
+
15
+ export class InvalidRecipientError extends PaymentError {
16
+ override readonly name: string = 'InvalidRecipientError';
17
+ constructor() {
18
+ super('Invalid recipient meta-address');
19
+ }
20
+ }
21
+
22
+ export class InsufficientBalanceError extends SweepError {
23
+ override readonly name: string = 'InsufficientBalanceError';
24
+ constructor() {
25
+ super('Insufficient balance to cover gas');
26
+ }
27
+ }
28
+
29
+ export class ZeroAmountError extends PaymentError {
30
+ override readonly name: string = 'ZeroAmountError';
31
+ constructor() {
32
+ super('Amount must be greater than zero');
33
+ }
34
+ }
35
+
36
+ export class AmountPrivacyNotSupportedError extends Error {
37
+ override readonly name: string = 'AmountPrivacyNotSupportedError';
38
+ constructor(method: string) {
39
+ super(`Amount privacy not supported: ${method}`);
40
+ }
41
+ }
42
+
43
+ export class MissingWalletClientError extends PaymentError {
44
+ override readonly name: string = 'MissingWalletClientError';
45
+ constructor() {
46
+ super('Transport has no walletClient configured');
47
+ }
48
+ }
49
+
50
+ export class StealthPaymentContractNotConfiguredError extends PaymentError {
51
+ override readonly name: string = 'StealthPaymentContractNotConfiguredError';
52
+ constructor() {
53
+ super('ShroudFiStealth contract address not configured');
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Thrown when a sender tries to pay a wallet that has not yet registered a
59
+ * stealth meta-address in the canonical ERC-6538 registry. The recipient must
60
+ * onboard first (publish their (S, B) pubkeys) before stealth payments are
61
+ * possible.
62
+ *
63
+ * Privacy note: the offending wallet address is NOT placed in `.message`. It
64
+ * is exposed via the `wallet` property so callers can show a useful UI hint
65
+ * without leaking the address into logs that only capture `.message`.
66
+ */
67
+ export class RecipientNotOnboardedError extends PaymentError {
68
+ override readonly name: string = 'RecipientNotOnboardedError';
69
+ readonly wallet: `0x${string}`;
70
+ constructor(wallet: `0x${string}`) {
71
+ super('Recipient wallet has not registered a stealth meta-address');
72
+ this.wallet = wallet;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Thrown when the bytes returned by `stealthMetaAddressOf` are not the
78
+ * expected 66 raw bytes (33 spending pubkey + 33 viewing pubkey) with valid
79
+ * compressed-point prefixes. Indicates registry corruption or off-spec usage.
80
+ */
81
+ export class MalformedMetaAddressError extends PaymentError {
82
+ override readonly name: string = 'MalformedMetaAddressError';
83
+ constructor() {
84
+ super('Registry returned a malformed stealth meta-address');
85
+ }
86
+ }
package/src/index.ts ADDED
@@ -0,0 +1,62 @@
1
+ // Public surface — @shroud-fi/payments
2
+ // Phase 1: stealth payment construction + sweep (self-funded).
3
+ // Sweep timing (log-normal delay, destination rotation) lives in @shroud-fi/scanning (P3).
4
+ // Relayer-funded sweep + EIP-2612 permit lives in the relayer service (P5).
5
+
6
+ // Functions
7
+ export {
8
+ sendETHPayment,
9
+ sendERC20Payment,
10
+ prepareStealthPayment,
11
+ } from './payments.js';
12
+ export {
13
+ sweepETH,
14
+ sweepERC20,
15
+ } from './sweep.js';
16
+ export {
17
+ sendToWallet,
18
+ lookupMetaAddress,
19
+ } from './send-to-wallet.js';
20
+ export {
21
+ NullAmountPrivacyProvider,
22
+ assertSupported,
23
+ } from './amount-privacy.js';
24
+
25
+ // Types
26
+ export type {
27
+ SendOptions,
28
+ PaymentReceipt,
29
+ SweepReceipt,
30
+ PreparedStealthPayment,
31
+ } from './types.js';
32
+ export type { SendToWalletAsset } from './send-to-wallet.js';
33
+ export type {
34
+ AmountPrivacyProvider,
35
+ ShieldResult,
36
+ PrivateTransferResult,
37
+ UnshieldResult,
38
+ } from './amount-privacy.js';
39
+
40
+ // Constants
41
+ export {
42
+ DEFAULT_GAS_MULTIPLIER,
43
+ ETH_SENTINEL,
44
+ ZERO_ADDRESS,
45
+ SCHEME_ID,
46
+ ETH_TRANSFER_GAS_ESTIMATE,
47
+ ERC20_TRANSFER_GAS_ESTIMATE,
48
+ } from './constants.js';
49
+
50
+ // Errors
51
+ export {
52
+ PaymentError,
53
+ SweepError,
54
+ InvalidRecipientError,
55
+ InsufficientBalanceError,
56
+ ZeroAmountError,
57
+ AmountPrivacyNotSupportedError,
58
+ MissingWalletClientError,
59
+ StealthPaymentContractNotConfiguredError,
60
+ RecipientNotOnboardedError,
61
+ MalformedMetaAddressError,
62
+ } from './errors.js';
@@ -0,0 +1,319 @@
1
+ /**
2
+ * ShroudFi Payments - Stealth Payment Construction
3
+ *
4
+ * Builds and submits stealth payments through the ShroudFiStealth contract.
5
+ * Uses generateStealthAddress from @shroud-fi/core for derivation.
6
+ *
7
+ * Privacy invariants enforced:
8
+ * - No private key material in any error message
9
+ * - No plaintext amounts in any error message
10
+ * - No console.log/warn/error anywhere
11
+ * - Non-custodial: only constructs transactions, never holds funds
12
+ */
13
+
14
+ import type { Address, Hash, Hex, PublicClient, TransactionReceipt } from 'viem';
15
+ import { decodeEventLog } from 'viem';
16
+ import { generateStealthAddress } from '@shroud-fi/core';
17
+ import type { StealthMetaAddress } from '@shroud-fi/core';
18
+ import { ShroudFiStealthAbi } from '@shroud-fi/transport';
19
+ import type { ShroudFiTransport } from '@shroud-fi/transport';
20
+ import type {
21
+ PaymentReceipt,
22
+ PreparedStealthPayment,
23
+ SendOptions,
24
+ } from './types.js';
25
+ import {
26
+ MissingWalletClientError,
27
+ ZeroAmountError,
28
+ InvalidRecipientError,
29
+ PaymentError,
30
+ } from './errors.js';
31
+ import { DEFAULT_GAS_MULTIPLIER } from './constants.js';
32
+
33
+ /**
34
+ * Encode the view tag (0-255) as a single byte (bytes1) for the contract call.
35
+ * Returns a `0x${string}` of exactly 4 chars (e.g. "0x07", "0xff").
36
+ */
37
+ function viewTagToBytes1(viewTag: number): `0x${string}` {
38
+ return `0x${viewTag.toString(16).padStart(2, '0')}` as `0x${string}`;
39
+ }
40
+
41
+ /**
42
+ * Convert a Uint8Array ephemeral pubkey to a 0x-prefixed hex string.
43
+ */
44
+ function ephemeralPubKeyToHex(bytes: Uint8Array): Hex {
45
+ let hex = '0x';
46
+ for (const b of bytes) {
47
+ hex += b.toString(16).padStart(2, '0');
48
+ }
49
+ return hex as Hex;
50
+ }
51
+
52
+ /**
53
+ * Prepare stealth payment material from a recipient meta-address.
54
+ * Pure helper - does not submit any transaction.
55
+ *
56
+ * @throws InvalidRecipientError if the meta-address is malformed or invalid.
57
+ */
58
+ export function prepareStealthPayment(
59
+ metaAddress: StealthMetaAddress,
60
+ ): PreparedStealthPayment {
61
+ let result;
62
+ try {
63
+ result = generateStealthAddress(metaAddress);
64
+ } catch {
65
+ // Wrap all core errors as InvalidRecipientError - never leak key bytes.
66
+ throw new InvalidRecipientError();
67
+ }
68
+ return {
69
+ stealthAddress: result.stealthAddress,
70
+ ephemeralPubKey: ephemeralPubKeyToHex(result.ephemeralPubKey),
71
+ viewTag: result.viewTag,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Decode the StealthPayment event from a transaction receipt.
77
+ * Returns a PaymentReceipt with the on-chain stealth address + token + block.
78
+ *
79
+ * @throws PaymentError if the StealthPayment log cannot be found / decoded.
80
+ */
81
+ async function parsePaymentReceipt(
82
+ txHash: Hash,
83
+ publicClient: PublicClient,
84
+ ephemeralPubKey: Hex,
85
+ viewTag: number,
86
+ expectedToken: Address,
87
+ ): Promise<PaymentReceipt> {
88
+ const receipt: TransactionReceipt =
89
+ await publicClient.getTransactionReceipt({ hash: txHash });
90
+
91
+ for (const log of receipt.logs) {
92
+ try {
93
+ const decoded = decodeEventLog({
94
+ abi: ShroudFiStealthAbi,
95
+ data: log.data,
96
+ topics: log.topics,
97
+ eventName: 'StealthPayment',
98
+ });
99
+ if (decoded.eventName === 'StealthPayment') {
100
+ const args = decoded.args as {
101
+ stealthAddress: Address;
102
+ token: Address;
103
+ amount: bigint;
104
+ };
105
+ // expectedToken is informational - we trust the event token.
106
+ void expectedToken;
107
+ void args.amount; // Never expose amount via this return - already in receipt.
108
+ return {
109
+ txHash,
110
+ stealthAddress: args.stealthAddress,
111
+ ephemeralPubKey,
112
+ viewTag,
113
+ blockNumber: receipt.blockNumber,
114
+ token: args.token,
115
+ };
116
+ }
117
+ } catch {
118
+ // Not a StealthPayment log - try next.
119
+ continue;
120
+ }
121
+ }
122
+ throw new PaymentError('StealthPayment event not found in receipt');
123
+ }
124
+
125
+ /**
126
+ * Send native ETH to a stealth address derived from `metaAddress`.
127
+ *
128
+ * Privacy notes:
129
+ * - The contract emits a StealthPayment event with the stealth address.
130
+ * - The amount is on-chain by necessity (no native ETH amount privacy).
131
+ * - Caller pays gas - relayer abstraction is a separate concern.
132
+ *
133
+ * @throws MissingWalletClientError if transport has no walletClient configured.
134
+ * @throws ZeroAmountError if valueWei is 0n.
135
+ * @throws InvalidRecipientError if the meta-address is invalid.
136
+ */
137
+ export async function sendETHPayment(
138
+ transport: ShroudFiTransport,
139
+ contractAddress: Address,
140
+ metaAddress: StealthMetaAddress,
141
+ valueWei: bigint,
142
+ options?: SendOptions,
143
+ ): Promise<PaymentReceipt> {
144
+ if (valueWei === 0n) {
145
+ throw new ZeroAmountError();
146
+ }
147
+ const wallet = transport.walletClient;
148
+ if (!wallet) {
149
+ throw new MissingWalletClientError();
150
+ }
151
+ const account = wallet.account;
152
+ if (!account) {
153
+ throw new MissingWalletClientError();
154
+ }
155
+
156
+ const prepared = prepareStealthPayment(metaAddress);
157
+ const viewTagByte = viewTagToBytes1(prepared.viewTag);
158
+
159
+ const gasMultiplier = options?.gasMultiplier ?? DEFAULT_GAS_MULTIPLIER;
160
+ let gas: bigint | undefined;
161
+ try {
162
+ const estimated = await transport.publicClient.estimateContractGas({
163
+ address: contractAddress,
164
+ abi: ShroudFiStealthAbi,
165
+ functionName: 'sendETH',
166
+ args: [prepared.stealthAddress, prepared.ephemeralPubKey, viewTagByte],
167
+ value: valueWei,
168
+ account: account.address,
169
+ });
170
+ // Ceil-division — match sweep.ts so multiplier never under-shoots gas
171
+ gas = (estimated * gasMultiplier + 99n) / 100n;
172
+ } catch {
173
+ // Gas estimation can fail in tests / on mocks - fall through without gas
174
+ // override; walletClient will estimate on send.
175
+ gas = undefined;
176
+ }
177
+
178
+ const writeArgs: Record<string, unknown> = {
179
+ address: contractAddress,
180
+ abi: ShroudFiStealthAbi,
181
+ functionName: 'sendETH',
182
+ args: [prepared.stealthAddress, prepared.ephemeralPubKey, viewTagByte],
183
+ value: valueWei,
184
+ account,
185
+ chain: transport.chain,
186
+ };
187
+ if (gas !== undefined) writeArgs['gas'] = gas;
188
+ if (options?.maxFeePerGas !== undefined) writeArgs['maxFeePerGas'] = options.maxFeePerGas;
189
+ if (options?.maxPriorityFeePerGas !== undefined)
190
+ writeArgs['maxPriorityFeePerGas'] = options.maxPriorityFeePerGas;
191
+ if (options?.nonce !== undefined) writeArgs['nonce'] = options.nonce;
192
+
193
+ let txHash: Hash;
194
+ try {
195
+ // viem's writeContract has a complex union signature - cast at the boundary.
196
+ txHash = (await (wallet.writeContract as (a: unknown) => Promise<Hash>)(
197
+ writeArgs,
198
+ )) as Hash;
199
+ } catch (err) {
200
+ // Re-wrap without leaking amount or key material.
201
+ if (err instanceof PaymentError) throw err;
202
+ throw new PaymentError('Failed to submit stealth ETH payment');
203
+ }
204
+
205
+ try {
206
+ await transport.publicClient.waitForTransactionReceipt({ hash: txHash });
207
+ } catch {
208
+ throw new PaymentError('Failed to confirm stealth ETH payment');
209
+ }
210
+
211
+ return parsePaymentReceipt(
212
+ txHash,
213
+ transport.publicClient,
214
+ prepared.ephemeralPubKey,
215
+ prepared.viewTag,
216
+ '0x0000000000000000000000000000000000000000' as Address,
217
+ );
218
+ }
219
+
220
+ /**
221
+ * Send an ERC-20 token to a stealth address derived from `metaAddress`.
222
+ *
223
+ * **Pre-approval required:** the caller MUST have already approved
224
+ * `contractAddress` to spend at least `amount` of `token` from the sender's
225
+ * account. v1 does not include an approve flow.
226
+ *
227
+ * @throws MissingWalletClientError if transport has no walletClient configured.
228
+ * @throws ZeroAmountError if amount is 0n.
229
+ * @throws InvalidRecipientError if the meta-address is invalid.
230
+ */
231
+ export async function sendERC20Payment(
232
+ transport: ShroudFiTransport,
233
+ contractAddress: Address,
234
+ metaAddress: StealthMetaAddress,
235
+ token: Address,
236
+ amount: bigint,
237
+ options?: SendOptions,
238
+ ): Promise<PaymentReceipt> {
239
+ if (amount === 0n) {
240
+ throw new ZeroAmountError();
241
+ }
242
+ const wallet = transport.walletClient;
243
+ if (!wallet) {
244
+ throw new MissingWalletClientError();
245
+ }
246
+ const account = wallet.account;
247
+ if (!account) {
248
+ throw new MissingWalletClientError();
249
+ }
250
+
251
+ const prepared = prepareStealthPayment(metaAddress);
252
+ const viewTagByte = viewTagToBytes1(prepared.viewTag);
253
+
254
+ const gasMultiplier = options?.gasMultiplier ?? DEFAULT_GAS_MULTIPLIER;
255
+ let gas: bigint | undefined;
256
+ try {
257
+ const estimated = await transport.publicClient.estimateContractGas({
258
+ address: contractAddress,
259
+ abi: ShroudFiStealthAbi,
260
+ functionName: 'sendERC20',
261
+ args: [
262
+ prepared.stealthAddress,
263
+ token,
264
+ amount,
265
+ prepared.ephemeralPubKey,
266
+ viewTagByte,
267
+ ],
268
+ account: account.address,
269
+ });
270
+ // Ceil-division — match sweep.ts so multiplier never under-shoots gas
271
+ gas = (estimated * gasMultiplier + 99n) / 100n;
272
+ } catch {
273
+ gas = undefined;
274
+ }
275
+
276
+ const writeArgs: Record<string, unknown> = {
277
+ address: contractAddress,
278
+ abi: ShroudFiStealthAbi,
279
+ functionName: 'sendERC20',
280
+ args: [
281
+ prepared.stealthAddress,
282
+ token,
283
+ amount,
284
+ prepared.ephemeralPubKey,
285
+ viewTagByte,
286
+ ],
287
+ account,
288
+ chain: transport.chain,
289
+ };
290
+ if (gas !== undefined) writeArgs['gas'] = gas;
291
+ if (options?.maxFeePerGas !== undefined) writeArgs['maxFeePerGas'] = options.maxFeePerGas;
292
+ if (options?.maxPriorityFeePerGas !== undefined)
293
+ writeArgs['maxPriorityFeePerGas'] = options.maxPriorityFeePerGas;
294
+ if (options?.nonce !== undefined) writeArgs['nonce'] = options.nonce;
295
+
296
+ let txHash: Hash;
297
+ try {
298
+ txHash = (await (wallet.writeContract as (a: unknown) => Promise<Hash>)(
299
+ writeArgs,
300
+ )) as Hash;
301
+ } catch (err) {
302
+ if (err instanceof PaymentError) throw err;
303
+ throw new PaymentError('Failed to submit stealth ERC-20 payment');
304
+ }
305
+
306
+ try {
307
+ await transport.publicClient.waitForTransactionReceipt({ hash: txHash });
308
+ } catch {
309
+ throw new PaymentError('Failed to confirm stealth ERC-20 payment');
310
+ }
311
+
312
+ return parsePaymentReceipt(
313
+ txHash,
314
+ transport.publicClient,
315
+ prepared.ephemeralPubKey,
316
+ prepared.viewTag,
317
+ token,
318
+ );
319
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * sendToWallet — ergonomic agent-facing helper.
3
+ *
4
+ * Pay an EVM wallet by its plain address. The helper:
5
+ * 1. Queries the canonical ERC-6538 Registry for the recipient's stealth
6
+ * meta-address (scheme 1, secp256k1 + view tag).
7
+ * 2. Throws `RecipientNotOnboardedError` if the recipient has not registered.
8
+ * 3. Decodes the 66-byte payload into a `StealthMetaAddress`.
9
+ * 4. Resolves the chain's `ShroudFiStealth` contract.
10
+ * 5. Dispatches to `sendETHPayment` or `sendERC20Payment`.
11
+ *
12
+ * Senders never have to know about meta-address formats, contract addresses,
13
+ * or scheme IDs — they pass `0xRecipient`, an asset, and an amount.
14
+ *
15
+ * Privacy invariants (see CLAUDE.md):
16
+ * - No console.* anywhere.
17
+ * - No plaintext amount in any error message.
18
+ * - The recipient wallet is NOT placed in `.message` strings; it lives on
19
+ * `RecipientNotOnboardedError.wallet` so log scrapers picking up `.message`
20
+ * do not capture it.
21
+ */
22
+
23
+ import type { Address, Hex, PublicClient } from 'viem';
24
+ import { hexToBytes, isAddress } from 'viem';
25
+ import type { StealthMetaAddress } from '@shroud-fi/core';
26
+ import {
27
+ ERC6538_REGISTRY,
28
+ ERC6538RegistryAbi,
29
+ getShroudFiStealth,
30
+ } from '@shroud-fi/transport';
31
+ import type { ShroudFiTransport } from '@shroud-fi/transport';
32
+ import { sendETHPayment, sendERC20Payment } from './payments.js';
33
+ import type { PaymentReceipt, SendOptions } from './types.js';
34
+ import {
35
+ InvalidRecipientError,
36
+ MalformedMetaAddressError,
37
+ RecipientNotOnboardedError,
38
+ } from './errors.js';
39
+ import { SCHEME_ID, ZERO_ADDRESS } from './constants.js';
40
+
41
+ // Numeric form for the `StealthMetaAddress.schemeId` field (which is `number`).
42
+ // Keep in sync with payments' `SCHEME_ID` bigint (registry call arg).
43
+ const STEALTH_SCHEME_ID_NUM = 1;
44
+
45
+ /**
46
+ * Asset selector. `'ETH'` sends native ETH; `{ token: 0x... }` sends an
47
+ * ERC-20 (caller must have pre-approved `ShroudFiStealth` to spend the
48
+ * amount from the sender's account — same constraint as `sendERC20Payment`).
49
+ */
50
+ export type SendToWalletAsset = 'ETH' | { readonly token: Address };
51
+
52
+ const COMPRESSED_KEY_LENGTH = 33;
53
+ const META_ADDRESS_PAYLOAD_LENGTH = COMPRESSED_KEY_LENGTH * 2;
54
+
55
+ /**
56
+ * Look up a wallet's stealth meta-address in the canonical ERC-6538 registry.
57
+ * Public helper — useful for UIs that want to show "this wallet is onboarded"
58
+ * without dispatching a payment.
59
+ *
60
+ * @returns the decoded meta-address, or `null` if the wallet is not registered.
61
+ * @throws MalformedMetaAddressError if the registry bytes are not the expected
62
+ * 66-byte (33 + 33) compressed-key payload.
63
+ */
64
+ export async function lookupMetaAddress(
65
+ transport: ShroudFiTransport,
66
+ recipientWallet: Address,
67
+ ): Promise<StealthMetaAddress | null> {
68
+ if (!isAddress(recipientWallet) || recipientWallet === ZERO_ADDRESS) {
69
+ throw new InvalidRecipientError();
70
+ }
71
+ const raw = (await (
72
+ transport.publicClient as PublicClient
73
+ ).readContract({
74
+ address: ERC6538_REGISTRY,
75
+ abi: ERC6538RegistryAbi,
76
+ functionName: 'stealthMetaAddressOf',
77
+ args: [recipientWallet, SCHEME_ID],
78
+ })) as Hex;
79
+
80
+ // Empty bytes → not registered. viem returns '0x' for empty.
81
+ if (raw === '0x' || raw.length <= 2) return null;
82
+
83
+ const bytes = hexToBytes(raw);
84
+ if (bytes.length !== META_ADDRESS_PAYLOAD_LENGTH) {
85
+ throw new MalformedMetaAddressError();
86
+ }
87
+ const spendingPubKey = bytes.slice(0, COMPRESSED_KEY_LENGTH);
88
+ const viewingPubKey = bytes.slice(COMPRESSED_KEY_LENGTH);
89
+ const sPrefix = spendingPubKey[0];
90
+ const vPrefix = viewingPubKey[0];
91
+ if (sPrefix !== 0x02 && sPrefix !== 0x03) {
92
+ throw new MalformedMetaAddressError();
93
+ }
94
+ if (vPrefix !== 0x02 && vPrefix !== 0x03) {
95
+ throw new MalformedMetaAddressError();
96
+ }
97
+ return {
98
+ spendingPubKey,
99
+ viewingPubKey,
100
+ schemeId: STEALTH_SCHEME_ID_NUM,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Send a payment to a plain EVM wallet address. The helper performs the
106
+ * Registry lookup → stealth derivation → on-chain dispatch in one call.
107
+ *
108
+ * @throws RecipientNotOnboardedError if the recipient has not registered.
109
+ * @throws MalformedMetaAddressError if the registry payload is corrupt.
110
+ * @throws InvalidRecipientError if `recipientWallet` is zero / not a valid address,
111
+ * or if the meta-address fails curve validation downstream.
112
+ * @throws ZeroAmountError if `amount` is 0n.
113
+ * @throws MissingWalletClientError if transport has no walletClient configured.
114
+ * @throws UnknownChainError if the transport's chain has no ShroudFiStealth deployment.
115
+ */
116
+ export async function sendToWallet(
117
+ transport: ShroudFiTransport,
118
+ recipientWallet: Address,
119
+ asset: SendToWalletAsset,
120
+ amount: bigint,
121
+ options?: SendOptions,
122
+ ): Promise<PaymentReceipt> {
123
+ if (!isAddress(recipientWallet) || recipientWallet === ZERO_ADDRESS) {
124
+ throw new InvalidRecipientError();
125
+ }
126
+ const meta = await lookupMetaAddress(transport, recipientWallet);
127
+ if (meta === null) {
128
+ throw new RecipientNotOnboardedError(recipientWallet);
129
+ }
130
+
131
+ const stealthContract = getShroudFiStealth(transport.chain.id);
132
+
133
+ if (asset === 'ETH') {
134
+ return sendETHPayment(transport, stealthContract, meta, amount, options);
135
+ }
136
+ return sendERC20Payment(
137
+ transport,
138
+ stealthContract,
139
+ meta,
140
+ asset.token,
141
+ amount,
142
+ options,
143
+ );
144
+ }
package/src/sweep.ts ADDED
@@ -0,0 +1,313 @@
1
+ import type { Address, Hex } from 'viem';
2
+ import { encodeFunctionData } from 'viem';
3
+ import { privateKeyToAccount } from 'viem/accounts';
4
+ import { ERC20Abi } from '@shroud-fi/transport';
5
+ import type { ShroudFiTransport } from '@shroud-fi/transport';
6
+ import type { SweepReceipt, SendOptions } from './types.js';
7
+ import { SweepError, InsufficientBalanceError } from './errors.js';
8
+ import {
9
+ DEFAULT_GAS_MULTIPLIER,
10
+ ETH_SENTINEL,
11
+ ZERO_ADDRESS,
12
+ ETH_TRANSFER_GAS_ESTIMATE,
13
+ ERC20_TRANSFER_GAS_ESTIMATE,
14
+ } from './constants.js';
15
+
16
+ /**
17
+ * Fetch EIP-1559 fee suggestions with a safe fallback. viem's
18
+ * `estimateFeesPerGas` returns the recommended {maxFeePerGas, maxPriorityFeePerGas}
19
+ * given the current base fee; on chains where the method 404s we fall back to
20
+ * the legacy `getGasPrice` + a small priority tip so the tx remains valid.
21
+ *
22
+ * Crucially we DON'T set maxPriorityFeePerGas == maxFeePerGas — Geth-derived
23
+ * RPCs reject EIP-1559 transactions where the priority cap equals the total
24
+ * cap and the base fee is non-zero (effective tip becomes 0, but the producer
25
+ * still gates on `priority < maxFee - baseFee`). Splitting them keeps Base
26
+ * Sepolia + mainnet RPCs from returning TransactionRejectedRpcError (-32003).
27
+ */
28
+ async function suggestEip1559Fees(
29
+ transport: ShroudFiTransport,
30
+ options: SendOptions | undefined,
31
+ ): Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }> {
32
+ if (
33
+ options?.maxFeePerGas !== undefined &&
34
+ options.maxPriorityFeePerGas !== undefined
35
+ ) {
36
+ return {
37
+ maxFeePerGas: options.maxFeePerGas,
38
+ maxPriorityFeePerGas: options.maxPriorityFeePerGas,
39
+ };
40
+ }
41
+ try {
42
+ const fees = await transport.publicClient.estimateFeesPerGas();
43
+ return {
44
+ maxFeePerGas: options?.maxFeePerGas ?? fees.maxFeePerGas,
45
+ maxPriorityFeePerGas:
46
+ options?.maxPriorityFeePerGas ?? fees.maxPriorityFeePerGas,
47
+ };
48
+ } catch {
49
+ const legacy = await transport.publicClient.getGasPrice();
50
+ const priority = options?.maxPriorityFeePerGas ?? legacy / 10n;
51
+ const fee = options?.maxFeePerGas ?? legacy * 2n;
52
+ return { maxFeePerGas: fee, maxPriorityFeePerGas: priority };
53
+ }
54
+ }
55
+
56
+ /**
57
+ * L2 chains (Base, Optimism, etc.) deduct an L1 data-posting fee from the
58
+ * sender's balance on top of the L2 gas. The sweep formula `balance - L2gas`
59
+ * doesn't account for it and the RPC rejects the broadcast as underfunded.
60
+ *
61
+ * We reserve a small portion of the swept value to cover the L1 fee plus
62
+ * fluctuation headroom. Magnitude: keep min(balance * 2%, 0.0001 ETH).
63
+ */
64
+ function l2DataFeeReserve(balance: bigint): bigint {
65
+ const percent = balance / 50n; // 2 %
66
+ const cap = 100_000_000_000_000n; // 0.0001 ETH
67
+ return percent < cap ? percent : cap;
68
+ }
69
+
70
+ /**
71
+ * Apply gas multiplier (basis-of-100 percent). Default 120n = 120% headroom.
72
+ * Performs ceil-division to avoid undershooting fees.
73
+ */
74
+ function applyGasMultiplier(gas: bigint, multiplier: bigint): bigint {
75
+ // ceil((gas * multiplier) / 100)
76
+ return (gas * multiplier + 99n) / 100n;
77
+ }
78
+
79
+ /**
80
+ * Compute the required ETH gas reserve for a transaction.
81
+ *
82
+ * No amounts are logged or surfaced in errors — privacy invariant #5.
83
+ */
84
+ async function resolveGasCost(
85
+ transport: ShroudFiTransport,
86
+ gasEstimate: bigint,
87
+ options: SendOptions | undefined,
88
+ ): Promise<bigint> {
89
+ const multiplier = options?.gasMultiplier ?? DEFAULT_GAS_MULTIPLIER;
90
+ const adjustedGas = applyGasMultiplier(gasEstimate, multiplier);
91
+
92
+ const feePerGas =
93
+ options?.maxFeePerGas ?? (await transport.publicClient.getGasPrice());
94
+
95
+ return adjustedGas * feePerGas;
96
+ }
97
+
98
+ /**
99
+ * Sweep the entire ETH balance from a stealth address to a destination.
100
+ *
101
+ * @param transport - ShroudFi transport (publicClient must reach the target chain)
102
+ * @param stealthPrivateKey - one-time stealth address private key. Treat as
103
+ * sensitive: caller MUST NOT log, persist, or transmit this value. The SDK
104
+ * does not retain it after the call returns.
105
+ * @param destination - final address that receives swept ETH
106
+ * @param options - optional fee/nonce overrides
107
+ *
108
+ * @remarks Phase 1: self-funded sweep. The stealth address pays its own gas in ETH,
109
+ * which creates a gas-funding correlation vector documented in research (Umbra deanonymization
110
+ * heuristic H2). This will be addressed in Phase 5 by a gasless relayer flow.
111
+ *
112
+ * @remarks Balance/nonce/gas-price are queried separately before signing. Callers running
113
+ * sweeps concurrently against the same stealth address should pass an explicit `nonce` in
114
+ * options to avoid races. Stack traces of thrown errors should NOT be logged by callers —
115
+ * they may contain RPC payload metadata.
116
+ */
117
+ export async function sweepETH(
118
+ transport: ShroudFiTransport,
119
+ stealthPrivateKey: Hex,
120
+ destination: Address,
121
+ options?: SendOptions,
122
+ ): Promise<SweepReceipt> {
123
+ if (!destination || destination === ZERO_ADDRESS) {
124
+ throw new SweepError('Invalid destination');
125
+ }
126
+
127
+ const account = privateKeyToAccount(stealthPrivateKey);
128
+
129
+ const balance = await transport.publicClient.getBalance({
130
+ address: account.address,
131
+ });
132
+ if (balance === 0n) {
133
+ throw new InsufficientBalanceError();
134
+ }
135
+
136
+ const gasCost = await resolveGasCost(
137
+ transport,
138
+ ETH_TRANSFER_GAS_ESTIMATE,
139
+ options,
140
+ );
141
+
142
+ const l2Reserve = l2DataFeeReserve(balance);
143
+
144
+ if (balance <= gasCost + l2Reserve) {
145
+ throw new InsufficientBalanceError();
146
+ }
147
+
148
+ const valueToSend = balance - gasCost - l2Reserve;
149
+
150
+ const chain = transport.chain;
151
+ const nonce =
152
+ options?.nonce ??
153
+ (await transport.publicClient.getTransactionCount({
154
+ address: account.address,
155
+ }));
156
+
157
+ const { maxFeePerGas, maxPriorityFeePerGas } = await suggestEip1559Fees(
158
+ transport,
159
+ options,
160
+ );
161
+
162
+ const serializedTransaction = await account.signTransaction({
163
+ to: destination,
164
+ value: valueToSend,
165
+ gas: ETH_TRANSFER_GAS_ESTIMATE,
166
+ maxFeePerGas,
167
+ maxPriorityFeePerGas,
168
+ nonce,
169
+ chainId: chain.id,
170
+ type: 'eip1559',
171
+ });
172
+
173
+ let txHash: Hex;
174
+ try {
175
+ txHash = await transport.publicClient.sendRawTransaction({
176
+ serializedTransaction,
177
+ });
178
+ } catch (err) {
179
+ // Privacy: surface only the error class name + a structured tag; never
180
+ // include the serialized transaction (which contains the signature) or
181
+ // the raw RPC error payload (which may quote bytes).
182
+ const tag = err instanceof Error ? err.name : 'unknown';
183
+ throw new SweepError(`sweep broadcast failed (${tag})`);
184
+ }
185
+
186
+ const receipt = await transport.publicClient.waitForTransactionReceipt({
187
+ hash: txHash,
188
+ });
189
+
190
+ const swept: SweepReceipt = {
191
+ txHash,
192
+ destination,
193
+ token: ETH_SENTINEL,
194
+ blockNumber: receipt.blockNumber,
195
+ };
196
+ return swept;
197
+ }
198
+
199
+ /**
200
+ * Sweep the entire ERC20 balance from a stealth address to a destination.
201
+ *
202
+ * The stealth address must already hold enough ETH to pay gas for the
203
+ * ERC20 transfer. Funding it from another account links the two — see
204
+ * privacy notes below.
205
+ *
206
+ * @param transport - ShroudFi transport
207
+ * @param stealthPrivateKey - one-time stealth private key (see sweepETH note)
208
+ * @param token - ERC20 contract address
209
+ * @param destination - final address that receives swept tokens
210
+ * @param options - optional fee/nonce overrides
211
+ *
212
+ * @remarks Phase 1: self-funded sweep. The stealth address pays its own gas in ETH,
213
+ * which creates a gas-funding correlation vector documented in research (Umbra deanonymization
214
+ * heuristic H2). This will be addressed in Phase 5 by a gasless relayer flow.
215
+ *
216
+ * @remarks Balance/nonce/gas-price are queried separately before signing. Callers running
217
+ * sweeps concurrently against the same stealth address should pass an explicit `nonce` in
218
+ * options to avoid races. Stack traces of thrown errors should NOT be logged by callers —
219
+ * they may contain RPC payload metadata.
220
+ */
221
+ export async function sweepERC20(
222
+ transport: ShroudFiTransport,
223
+ stealthPrivateKey: Hex,
224
+ token: Address,
225
+ destination: Address,
226
+ options?: SendOptions,
227
+ ): Promise<SweepReceipt> {
228
+ if (!destination || destination === ZERO_ADDRESS) {
229
+ throw new SweepError('Invalid destination');
230
+ }
231
+ if (!token || token === ZERO_ADDRESS) {
232
+ throw new SweepError('Invalid token');
233
+ }
234
+
235
+ const account = privateKeyToAccount(stealthPrivateKey);
236
+
237
+ const tokenBalance = (await transport.publicClient.readContract({
238
+ address: token,
239
+ abi: ERC20Abi,
240
+ functionName: 'balanceOf',
241
+ args: [account.address],
242
+ })) as bigint;
243
+
244
+ if (tokenBalance === 0n) {
245
+ throw new InsufficientBalanceError();
246
+ }
247
+
248
+ const ethBalance = await transport.publicClient.getBalance({
249
+ address: account.address,
250
+ });
251
+
252
+ const gasCost = await resolveGasCost(
253
+ transport,
254
+ ERC20_TRANSFER_GAS_ESTIMATE,
255
+ options,
256
+ );
257
+
258
+ if (ethBalance < gasCost) {
259
+ throw new InsufficientBalanceError();
260
+ }
261
+
262
+ const data = encodeFunctionData({
263
+ abi: ERC20Abi,
264
+ functionName: 'transfer',
265
+ args: [destination, tokenBalance],
266
+ });
267
+
268
+ const chain = transport.chain;
269
+ const nonce =
270
+ options?.nonce ??
271
+ (await transport.publicClient.getTransactionCount({
272
+ address: account.address,
273
+ }));
274
+
275
+ const { maxFeePerGas, maxPriorityFeePerGas } = await suggestEip1559Fees(
276
+ transport,
277
+ options,
278
+ );
279
+
280
+ const serializedTransaction = await account.signTransaction({
281
+ to: token,
282
+ value: 0n,
283
+ data,
284
+ gas: ERC20_TRANSFER_GAS_ESTIMATE,
285
+ maxFeePerGas,
286
+ maxPriorityFeePerGas,
287
+ nonce,
288
+ chainId: chain.id,
289
+ type: 'eip1559',
290
+ });
291
+
292
+ let txHash: Hex;
293
+ try {
294
+ txHash = await transport.publicClient.sendRawTransaction({
295
+ serializedTransaction,
296
+ });
297
+ } catch (err) {
298
+ const tag = err instanceof Error ? err.name : 'unknown';
299
+ throw new SweepError(`sweep broadcast failed (${tag})`);
300
+ }
301
+
302
+ const receipt = await transport.publicClient.waitForTransactionReceipt({
303
+ hash: txHash,
304
+ });
305
+
306
+ const swept: SweepReceipt = {
307
+ txHash,
308
+ destination,
309
+ token,
310
+ blockNumber: receipt.blockNumber,
311
+ };
312
+ return swept;
313
+ }
package/src/types.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { Address, Hash, Hex } from 'viem';
2
+
3
+ export interface SendOptions {
4
+ readonly gasMultiplier?: bigint;
5
+ readonly maxFeePerGas?: bigint;
6
+ readonly maxPriorityFeePerGas?: bigint;
7
+ readonly nonce?: number;
8
+ }
9
+
10
+ export interface PaymentReceipt {
11
+ readonly txHash: Hash;
12
+ readonly stealthAddress: Address;
13
+ readonly ephemeralPubKey: Hex;
14
+ readonly viewTag: number;
15
+ readonly blockNumber: bigint;
16
+ readonly token: Address;
17
+ }
18
+
19
+ export interface SweepReceipt {
20
+ readonly txHash: Hash;
21
+ readonly destination: Address;
22
+ readonly token: Address;
23
+ readonly blockNumber: bigint;
24
+ }
25
+
26
+ export interface PreparedStealthPayment {
27
+ readonly stealthAddress: Address;
28
+ readonly ephemeralPubKey: Hex;
29
+ readonly viewTag: number;
30
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist/esm",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*.ts"],
8
+ "exclude": ["dist", "test", "node_modules"]
9
+ }