@shroud-fi/payments 0.1.1 → 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 +7 -4
- package/src/amount-privacy.ts +59 -0
- package/src/constants.ts +12 -0
- package/src/errors.ts +86 -0
- package/src/index.ts +62 -0
- package/src/payments.ts +319 -0
- package/src/send-to-wallet.ts +144 -0
- package/src/sweep.ts +313 -0
- package/src/types.ts +30 -0
- package/tsconfig.json +9 -0
- package/dist/cjs/amount-privacy.d.ts.map +0 -1
- package/dist/cjs/amount-privacy.js.map +0 -1
- package/dist/cjs/constants.d.ts.map +0 -1
- package/dist/cjs/constants.js.map +0 -1
- package/dist/cjs/errors.d.ts.map +0 -1
- package/dist/cjs/errors.js.map +0 -1
- package/dist/cjs/index.d.ts.map +0 -1
- package/dist/cjs/index.js.map +0 -1
- package/dist/cjs/payments.d.ts.map +0 -1
- package/dist/cjs/payments.js.map +0 -1
- package/dist/cjs/send-to-wallet.d.ts.map +0 -1
- package/dist/cjs/send-to-wallet.js.map +0 -1
- package/dist/cjs/sweep.d.ts.map +0 -1
- package/dist/cjs/sweep.js.map +0 -1
- package/dist/cjs/types.d.ts.map +0 -1
- package/dist/cjs/types.js.map +0 -1
- package/dist/esm/amount-privacy.d.ts.map +0 -1
- package/dist/esm/amount-privacy.js.map +0 -1
- package/dist/esm/constants.d.ts.map +0 -1
- package/dist/esm/constants.js.map +0 -1
- package/dist/esm/errors.d.ts.map +0 -1
- package/dist/esm/errors.js.map +0 -1
- package/dist/esm/index.d.ts.map +0 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/payments.d.ts.map +0 -1
- package/dist/esm/payments.js.map +0 -1
- package/dist/esm/send-to-wallet.d.ts.map +0 -1
- package/dist/esm/send-to-wallet.js.map +0 -1
- package/dist/esm/sweep.d.ts.map +0 -1
- package/dist/esm/sweep.js.map +0 -1
- package/dist/esm/types.d.ts.map +0 -1
- package/dist/esm/types.js.map +0 -1
- package/dist/tsconfig.cjs.tsbuildinfo +0 -1
- package/dist/tsconfig.esm.tsbuildinfo +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shroud-fi/payments",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
36
|
-
"@shroud-fi/transport": "0.1.
|
|
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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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';
|
package/src/payments.ts
ADDED
|
@@ -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
|
+
}
|