@fairblock/stabletrust 2.0.6 → 2.0.10
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/README.md +185 -74
- package/examples/anonymous-flow.js +42 -31
- package/package.json +10 -3
- package/src/anonymous-client.js +565 -105
- package/src/backend.js +19 -0
- package/src/confidential-client.js +497 -82
- package/src/constants.js +36 -9
- package/src/ibe.js +59 -0
- package/src/index.d.ts +85 -18
- package/src/index.js +6 -0
- package/src/utils.js +169 -62
- package/src/vendor/ts-ibe/age/age-encrypt-decrypt.js +127 -0
- package/src/vendor/ts-ibe/age/age-reader-writer.js +94 -0
- package/src/vendor/ts-ibe/age/armor.js +52 -0
- package/src/vendor/ts-ibe/age/no-op-encdec.js +72 -0
- package/src/vendor/ts-ibe/age/stream-cipher.js +99 -0
- package/src/vendor/ts-ibe/age/utils-crypto.js +60 -0
- package/src/vendor/ts-ibe/age/utils.js +59 -0
- package/src/vendor/ts-ibe/crypto/ibe.js +198 -0
- package/src/vendor/ts-ibe/crypto/utils.js +50 -0
- package/src/vendor/ts-ibe/index.js +87 -0
- package/src/vendor/ts-ibe/types/index.js +2 -0
- package/src/vendor/ts-ibe/version.js +2 -0
- package/src/wasm-loader.js +3 -3
- package/pkg/README.md +0 -310
package/src/anonymous-client.js
CHANGED
|
@@ -4,8 +4,9 @@ import {
|
|
|
4
4
|
encodeTransferProof,
|
|
5
5
|
encodeWithdrawProof,
|
|
6
6
|
validateAnonymousAccountId,
|
|
7
|
+
uploadBytesToIpfs,
|
|
7
8
|
} from "./utils.js";
|
|
8
|
-
import { getStabletrustContractAddress } from "./constants.js";
|
|
9
|
+
import { ERC20_ABI, getStabletrustContractAddress } from "./constants.js";
|
|
9
10
|
|
|
10
11
|
// EIP-712 domain for anonymous operations (LibAnonAuth domain name)
|
|
11
12
|
const ANON_DOMAIN_NAME = "ConfidentialMirrorAnonymous";
|
|
@@ -23,6 +24,18 @@ const ADD_FEES_INTERFACE = new ethers.Interface([
|
|
|
23
24
|
"function addFees(string accountId, address token, uint256 amount, uint256 authNonce, uint256 deadline, bytes signature) external payable",
|
|
24
25
|
]);
|
|
25
26
|
|
|
27
|
+
const WITHDRAW_FEES_INTERFACE = new ethers.Interface([
|
|
28
|
+
"function withdrawFees(string accountId, address token, address destination, uint256 amount, uint256 authNonce, uint256 deadline, bytes signature) external",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const WITHDRAW_ALL_FEES_INTERFACE = new ethers.Interface([
|
|
32
|
+
"function withdrawAllFees(string accountId, address destination, uint256 authNonce, uint256 deadline, bytes signature) external",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const CREATE_ACCOUNT_INTERFACE = new ethers.Interface([
|
|
36
|
+
"function createAnonymousConfidentialAccount(string accountId, bytes elgamalPubkey, address[] authSigners, bytes32 clientRequestId, address feePayer, address feeToken, uint256 initialFeeDeposit, uint256 deadline, bytes signature) external payable",
|
|
37
|
+
]);
|
|
38
|
+
|
|
26
39
|
/**
|
|
27
40
|
* Internal helper to parse values as BigInt, handling hex strings and null/undefined.
|
|
28
41
|
* @private
|
|
@@ -61,8 +74,14 @@ export class AnonymousTransferClient {
|
|
|
61
74
|
* @param {string} config.rpcUrl - EVM JSON-RPC endpoint (used for on-chain reads and raw-tx signing)
|
|
62
75
|
* @param {string} [config.diamondAddress] - Optional override for the diamond contract address
|
|
63
76
|
* @param {string} [config.apiKey] - Optional Fairycloak API key
|
|
77
|
+
* @param {string} [config.ipfsUploadUrl] - StableTrust IPFS upload endpoint used when `offchainZKP: true`
|
|
78
|
+
* @param {string} [config.ipfsApiKey] - Optional bearer token for the StableTrust IPFS upload endpoint
|
|
79
|
+
* @param {string} [config.ipfsGatewayUrl] - Optional read gateway base URL, e.g. "https://ipfs.example.com"
|
|
80
|
+
* @param {boolean} [config.enableNativeFeeToken=false] - Opt-in flag for native-ETH fee tokens. When the
|
|
81
|
+
* protocol fee token is native ETH (`address(0)`), account creation uses a user-funded raw tx. This is
|
|
82
|
+
* kept **disabled by default**
|
|
64
83
|
*/
|
|
65
|
-
constructor({ fairycloakUrl, diamondAddress, chainId, rpcUrl, apiKey } = {}) {
|
|
84
|
+
constructor({ fairycloakUrl, diamondAddress, chainId, rpcUrl, apiKey, ipfsUploadUrl, ipfsApiKey, ipfsGatewayUrl, enableNativeFeeToken = false } = {}) {
|
|
66
85
|
if (!fairycloakUrl) throw new Error("fairycloakUrl is required");
|
|
67
86
|
if (!chainId) throw new Error("chainId is required");
|
|
68
87
|
if (!rpcUrl) throw new Error("rpcUrl is required");
|
|
@@ -80,6 +99,10 @@ export class AnonymousTransferClient {
|
|
|
80
99
|
this.chainId = Number(chainId);
|
|
81
100
|
this.rpcUrl = rpcUrl;
|
|
82
101
|
this.apiKey = apiKey || null;
|
|
102
|
+
this.ipfsUploadUrl = ipfsUploadUrl || null;
|
|
103
|
+
this.ipfsApiKey = ipfsApiKey || null;
|
|
104
|
+
this.ipfsGatewayUrl = ipfsGatewayUrl ? ipfsGatewayUrl.replace(/\/$/, "") : null;
|
|
105
|
+
this.enableNativeFeeToken = Boolean(enableNativeFeeToken);
|
|
83
106
|
|
|
84
107
|
this.provider = new ethers.JsonRpcProvider(rpcUrl);
|
|
85
108
|
this._wasmModule = null;
|
|
@@ -169,11 +192,7 @@ export class AnonymousTransferClient {
|
|
|
169
192
|
if (!this._tokenDecimals) this._tokenDecimals = {};
|
|
170
193
|
const key = tokenAddress.toLowerCase();
|
|
171
194
|
if (this._tokenDecimals[key] !== undefined) return this._tokenDecimals[key];
|
|
172
|
-
const contract = new ethers.Contract(
|
|
173
|
-
tokenAddress,
|
|
174
|
-
["function decimals() view returns (uint8)"],
|
|
175
|
-
this.provider,
|
|
176
|
-
);
|
|
195
|
+
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, this.provider);
|
|
177
196
|
const decimals = Number(await contract.decimals());
|
|
178
197
|
this._tokenDecimals[key] = decimals;
|
|
179
198
|
return decimals;
|
|
@@ -193,7 +212,13 @@ export class AnonymousTransferClient {
|
|
|
193
212
|
*/
|
|
194
213
|
async _toContractScale(tokenAddress, rawAmount) {
|
|
195
214
|
const decimals = await this._fetchTokenDecimals(tokenAddress);
|
|
196
|
-
|
|
215
|
+
const divisor = 10n ** BigInt(decimals);
|
|
216
|
+
if ((rawAmount * 100n) % divisor !== 0n) {
|
|
217
|
+
console.warn(
|
|
218
|
+
`Amount ${rawAmount} is not fully representable in contract scale (2 decimals); the sub-cent remainder will be dropped.`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return (rawAmount * 100n) / divisor;
|
|
197
222
|
}
|
|
198
223
|
|
|
199
224
|
// ─────────────────────── on-chain reads ────────────────────────
|
|
@@ -310,9 +335,14 @@ export class AnonymousTransferClient {
|
|
|
310
335
|
);
|
|
311
336
|
if (!ciphertext) return { amount: 0, ciphertext: null };
|
|
312
337
|
const wasm = await this._getWasm();
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
338
|
+
let result;
|
|
339
|
+
try {
|
|
340
|
+
result = JSON.parse(wasm.decrypt_ciphertext(ciphertext, elGamalPrivateKey));
|
|
341
|
+
} catch (e) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
`Decryption failed. Is the elGamalPrivateKey correct for this accountId? (${e?.message ?? e})`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
316
346
|
return { amount: result.decrypted_amount ?? 0, ciphertext };
|
|
317
347
|
}
|
|
318
348
|
|
|
@@ -518,25 +548,95 @@ export class AnonymousTransferClient {
|
|
|
518
548
|
);
|
|
519
549
|
}
|
|
520
550
|
|
|
551
|
+
// ───────────────── off-chain ZKP (IPFS) helpers ─────────────────
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Upload ABI-encoded proof bytes to the configured StableTrust IPFS upload endpoint for an
|
|
555
|
+
* off-chain ZKP transfer and return the bare CID. With off-chain ZKP, only this CID
|
|
556
|
+
* (<=128 bytes) is placed on-chain;
|
|
557
|
+
* Fairyport later fetches the full proof from IPFS and verifies it off-chain.
|
|
558
|
+
*
|
|
559
|
+
* @param {string} proofHex - "0x…" ABI-encoded proof hex (from generateTransferProof)
|
|
560
|
+
* @returns {Promise<string>} bare CID (no "ipfs://" prefix)
|
|
561
|
+
* @private
|
|
562
|
+
*/
|
|
563
|
+
async _uploadProofToIpfs(proofHex) {
|
|
564
|
+
if (!proofHex) throw new Error("cannot upload an empty proof to IPFS");
|
|
565
|
+
const bytes = ethers.getBytes(proofHex);
|
|
566
|
+
const cid = await uploadBytesToIpfs(bytes, "anon-transfer-proof.bin", {
|
|
567
|
+
uploadUrl: this.ipfsUploadUrl,
|
|
568
|
+
apiKey: this.ipfsApiKey,
|
|
569
|
+
});
|
|
570
|
+
if (!cid) {
|
|
571
|
+
throw new Error(
|
|
572
|
+
"IPFS upload returned no CID (check STABLETRUST_IPFS_UPLOAD_URL / STABLETRUST_IPFS_API_KEY)",
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
return String(cid).trim();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Resolve a transfer/withdraw `proof` payload and its EIP-712 `proofHash`, for `offchainZKP`.
|
|
580
|
+
*
|
|
581
|
+
* - `offchainZKP=false` (inline proof, default): the payload is the proof hex and
|
|
582
|
+
* `proofHash = keccak256(proof bytes)` - the original architecture, unchanged.
|
|
583
|
+
* - `offchainZKP=true` (off-chain ZKP): the payload is a bare IPFS CID and
|
|
584
|
+
* `proofHash = keccak256(utf8 bytes of the CID)`. This matches the Fairycloak relay and the
|
|
585
|
+
* on-chain contract, which store the CID and verify the proof off-chain.
|
|
586
|
+
*
|
|
587
|
+
* @param {Object} p
|
|
588
|
+
* @param {boolean} p.offchainZKP
|
|
589
|
+
* @param {string} [p.proofHex] - "0x…" proof hex (auto mode, or inline manual mode)
|
|
590
|
+
* @param {string} [p.cid] - pre-uploaded bare CID (manual mode + offchainZKP)
|
|
591
|
+
* @returns {Promise<{proofPayload: string, proofHash: string}>}
|
|
592
|
+
* @private
|
|
593
|
+
*/
|
|
594
|
+
async _resolveProofPayload({ offchainZKP, proofHex, cid }) {
|
|
595
|
+
if (!offchainZKP) {
|
|
596
|
+
const hex = proofHex.startsWith("0x") ? proofHex : `0x${proofHex}`;
|
|
597
|
+
return { proofPayload: hex, proofHash: ethers.keccak256(hex) };
|
|
598
|
+
}
|
|
599
|
+
const resolvedCid = (cid ?? (await this._uploadProofToIpfs(proofHex)))
|
|
600
|
+
.toString()
|
|
601
|
+
.trim()
|
|
602
|
+
.replace(/^ipfs:\/\//i, "");
|
|
603
|
+
if (!resolvedCid) {
|
|
604
|
+
throw new Error("off-chain ZKP transfer requires a non-empty IPFS CID");
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
proofPayload: resolvedCid,
|
|
608
|
+
proofHash: ethers.keccak256(ethers.toUtf8Bytes(resolvedCid)),
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
521
612
|
// ─────────────────── Fairycloak operations ─────────────────────
|
|
522
613
|
|
|
523
614
|
/**
|
|
524
615
|
* Create a new anonymous account via Fairycloak.
|
|
525
616
|
*
|
|
526
|
-
*
|
|
617
|
+
* Two submission modes, chosen automatically by the active fee token:
|
|
618
|
+
* - **ERC-20 fee token** (default): the relay submits `createAnonymousConfidentialAccount`
|
|
619
|
+
* and pays gas; the fee payer approves the diamond so it can pull the ERC-20 deposit.
|
|
620
|
+
* - **Native ETH fee token** (`feeToken == address(0)`): ETH can't be pulled with
|
|
621
|
+
* `transferFrom` and the contract requires `msg.sender == feePayer`, so the fee payer
|
|
622
|
+
* signs and funds the payable tx itself (`msg.value == initialFeeDeposit`) and the raw
|
|
623
|
+
* bytes are sent to `/v1/anonymous/accounts/raw-tx`. `authWallet` pays the gas.
|
|
527
624
|
*
|
|
528
|
-
* @param {ethers.Wallet|ethers.Signer} authWallet - Initial auth signer
|
|
625
|
+
* @param {ethers.Wallet|ethers.Signer} authWallet - Initial auth signer (also funds the tx for native fees)
|
|
529
626
|
* @param {string} accountId - Caller-chosen account ID. Must be a non-empty, case-sensitive,
|
|
530
627
|
* alphanumeric string of at most 20 characters.
|
|
531
628
|
* @param {string} elgamalPublicKey - ElGamal public key as base64 or "0x"-prefixed hex (32 bytes)
|
|
532
629
|
* @param {Object} [options]
|
|
533
630
|
* @param {number} [options.deadlineOffset=3600] - Deadline in seconds from now
|
|
631
|
+
* @param {bigint|string|number} [options.initialFeeDeposit] - Override the initial deposit (defaults to the contract minimum)
|
|
632
|
+
* @param {bigint} [options.gasLimit=1000000n] - Gas limit for the native-fee raw tx (unused for ERC-20)
|
|
534
633
|
* @returns {Promise<{request_id:string, tx_hash:string, status:string, action:string}>}
|
|
535
634
|
* @throws {Error} If `accountId` doesn't follow the anonymous account ID rules (see class docs).
|
|
536
635
|
*/
|
|
537
636
|
async createAccount(authWallet, accountId, elgamalPublicKey, options = {}) {
|
|
538
637
|
validateAnonymousAccountId(accountId, "accountId");
|
|
539
|
-
const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } =
|
|
638
|
+
const { deadlineOffset = DEFAULT_DEADLINE_OFFSET, gasLimit = 1000000n } =
|
|
639
|
+
options;
|
|
540
640
|
try {
|
|
541
641
|
const authAddress = await authWallet.getAddress();
|
|
542
642
|
|
|
@@ -548,12 +648,36 @@ export class AnonymousTransferClient {
|
|
|
548
648
|
const clientRequestId = ethers.hexlify(ethers.randomBytes(32));
|
|
549
649
|
const deadline = this._makeDeadline(deadlineOffset);
|
|
550
650
|
|
|
551
|
-
//
|
|
651
|
+
// Prepaid-fee funding (fees-for-all-ops)
|
|
652
|
+
// The relayer submits create... the fee payer must fund an initial deposit
|
|
653
|
+
// in the active fee token and approve the diamond so it can pull it into
|
|
654
|
+
// escrow. The create fee is charged from it and the remainder becomes the
|
|
655
|
+
// account's prepaid balance once creation finalises.
|
|
656
|
+
const [feeToken, minInitialDeposit, createFee] = await Promise.all([
|
|
657
|
+
this.getFeeToken(),
|
|
658
|
+
this.getAnonymousMinimumInitialFeeDeposit(),
|
|
659
|
+
this.getAnonymousCreateAccountFee(),
|
|
660
|
+
]);
|
|
661
|
+
const feePayer = authAddress;
|
|
662
|
+
const initialFeeDeposit =
|
|
663
|
+
options.initialFeeDeposit != null
|
|
664
|
+
? BigInt(options.initialFeeDeposit)
|
|
665
|
+
: minInitialDeposit;
|
|
666
|
+
if (initialFeeDeposit < minInitialDeposit)
|
|
667
|
+
throw new Error(
|
|
668
|
+
`initialFeeDeposit (${initialFeeDeposit}) is below the contract minimum (${minInitialDeposit})`,
|
|
669
|
+
);
|
|
670
|
+
if (initialFeeDeposit < createFee)
|
|
671
|
+
throw new Error(
|
|
672
|
+
`initialFeeDeposit (${initialFeeDeposit}) is below the create-account fee (${createFee})`,
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
const isNative = feeToken === ethers.ZeroAddress;
|
|
676
|
+
|
|
677
|
+
// EIP-712 AnonymousCreate signature - identical for both fee-token modes
|
|
678
|
+
// (the digest already binds feePayer/feeToken/initialFeeDeposit).
|
|
552
679
|
const initialSignersHash = ethers.keccak256(
|
|
553
|
-
ethers.AbiCoder.defaultAbiCoder().encode(
|
|
554
|
-
["address[]"],
|
|
555
|
-
[[authAddress]],
|
|
556
|
-
),
|
|
680
|
+
ethers.AbiCoder.defaultAbiCoder().encode(["address[]"], [[authAddress]]),
|
|
557
681
|
);
|
|
558
682
|
const elgamalPubkeyHash = ethers.keccak256(elgamalHex);
|
|
559
683
|
|
|
@@ -564,6 +688,9 @@ export class AnonymousTransferClient {
|
|
|
564
688
|
{ name: "initialSignersHash", type: "bytes32" },
|
|
565
689
|
{ name: "elgamalPubkeyHash", type: "bytes32" },
|
|
566
690
|
{ name: "clientRequestId", type: "bytes32" },
|
|
691
|
+
{ name: "feePayer", type: "address" },
|
|
692
|
+
{ name: "feeToken", type: "address" },
|
|
693
|
+
{ name: "initialFeeDeposit", type: "uint256" },
|
|
567
694
|
{ name: "deadline", type: "uint256" },
|
|
568
695
|
],
|
|
569
696
|
};
|
|
@@ -572,16 +699,97 @@ export class AnonymousTransferClient {
|
|
|
572
699
|
initialSignersHash,
|
|
573
700
|
elgamalPubkeyHash,
|
|
574
701
|
clientRequestId,
|
|
702
|
+
feePayer,
|
|
703
|
+
feeToken,
|
|
704
|
+
initialFeeDeposit,
|
|
575
705
|
deadline: BigInt(deadline),
|
|
576
706
|
};
|
|
577
707
|
|
|
578
708
|
const signature = await authWallet.signTypedData(domain, types, value);
|
|
579
709
|
|
|
710
|
+
if (isNative) {
|
|
711
|
+
// Native-ETH fee-token support is implemented but gated off by default.
|
|
712
|
+
if (!this.enableNativeFeeToken)
|
|
713
|
+
throw new Error(
|
|
714
|
+
"Active fee token is native ETH (address 0), but native fee-token support is disabled. " +
|
|
715
|
+
"Construct the client with { enableNativeFeeToken: true } to opt in, or configure an ERC-20 fee token.",
|
|
716
|
+
);
|
|
717
|
+
// Native ETH fee token: user-funded raw-tx submission
|
|
718
|
+
// ETH can't be pulled with transferFrom and the contract requires
|
|
719
|
+
// msg.sender == feePayer, so the fee payer signs and funds the payable
|
|
720
|
+
// createAnonymousConfidentialAccount tx (msg.value == initialFeeDeposit);
|
|
721
|
+
// Fairycloak validates + broadcasts the raw bytes unchanged.
|
|
722
|
+
if (initialFeeDeposit > 0n) {
|
|
723
|
+
const nativeBalance = await this.provider.getBalance(feePayer);
|
|
724
|
+
if (nativeBalance < initialFeeDeposit)
|
|
725
|
+
throw new Error(
|
|
726
|
+
`Insufficient native balance for initial deposit. Required: ${initialFeeDeposit}, available: ${nativeBalance}`,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
const calldata = CREATE_ACCOUNT_INTERFACE.encodeFunctionData(
|
|
730
|
+
"createAnonymousConfidentialAccount",
|
|
731
|
+
[
|
|
732
|
+
accountId,
|
|
733
|
+
elgamalHex,
|
|
734
|
+
[authAddress],
|
|
735
|
+
clientRequestId,
|
|
736
|
+
feePayer,
|
|
737
|
+
feeToken,
|
|
738
|
+
initialFeeDeposit,
|
|
739
|
+
BigInt(deadline),
|
|
740
|
+
signature,
|
|
741
|
+
],
|
|
742
|
+
);
|
|
743
|
+
const signedTx = await this._signRawDiamondTx(
|
|
744
|
+
authWallet,
|
|
745
|
+
calldata,
|
|
746
|
+
initialFeeDeposit,
|
|
747
|
+
gasLimit,
|
|
748
|
+
);
|
|
749
|
+
return await this._fetch("POST", "/v1/anonymous/accounts/raw-tx", {
|
|
750
|
+
raw_tx: signedTx,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ERC-20 fee token: managed (relayer-submitted) creation
|
|
755
|
+
// The fee payer funds an initial deposit in the fee token and approves the
|
|
756
|
+
// diamond so it can pull it into escrow; the relay submits + pays gas.
|
|
757
|
+
if (initialFeeDeposit > 0n) {
|
|
758
|
+
const walletWithProvider = authWallet.connect
|
|
759
|
+
? authWallet.connect(this.provider)
|
|
760
|
+
: authWallet;
|
|
761
|
+
const tokenContract = new ethers.Contract(
|
|
762
|
+
feeToken,
|
|
763
|
+
ERC20_ABI,
|
|
764
|
+
walletWithProvider,
|
|
765
|
+
);
|
|
766
|
+
const [balance, allowance] = await Promise.all([
|
|
767
|
+
tokenContract.balanceOf(feePayer),
|
|
768
|
+
tokenContract.allowance(feePayer, this.diamondAddress),
|
|
769
|
+
]);
|
|
770
|
+
if (balance < initialFeeDeposit)
|
|
771
|
+
throw new Error(
|
|
772
|
+
`Insufficient fee-token balance for initial deposit. Required: ${initialFeeDeposit}, available: ${balance} (token ${feeToken})`,
|
|
773
|
+
);
|
|
774
|
+
if (allowance < initialFeeDeposit) {
|
|
775
|
+
const approveTx = await tokenContract.approve(
|
|
776
|
+
this.diamondAddress,
|
|
777
|
+
ethers.MaxUint256,
|
|
778
|
+
);
|
|
779
|
+
const receipt = await approveTx.wait();
|
|
780
|
+
if (!receipt || receipt.status === 0)
|
|
781
|
+
throw new Error("Fee token approval failed");
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
580
785
|
return await this._fetch("POST", "/v1/anonymous/accounts", {
|
|
581
786
|
account_id: accountId,
|
|
582
787
|
elgamal_pubkey: elgamalHex,
|
|
583
788
|
auth_signers: [authAddress],
|
|
584
789
|
client_request_id: clientRequestId,
|
|
790
|
+
fee_payer: feePayer,
|
|
791
|
+
fee_token: feeToken,
|
|
792
|
+
initial_fee_deposit: initialFeeDeposit.toString(),
|
|
585
793
|
deadline: String(deadline),
|
|
586
794
|
signature,
|
|
587
795
|
});
|
|
@@ -802,11 +1010,7 @@ export class AnonymousTransferClient {
|
|
|
802
1010
|
// 1. Approve if needed
|
|
803
1011
|
const tokenContract = new ethers.Contract(
|
|
804
1012
|
tokenAddress,
|
|
805
|
-
|
|
806
|
-
"function balanceOf(address) view returns (uint256)",
|
|
807
|
-
"function allowance(address,address) view returns (uint256)",
|
|
808
|
-
"function approve(address,uint256) returns (bool)",
|
|
809
|
-
],
|
|
1013
|
+
ERC20_ABI,
|
|
810
1014
|
walletWithProvider,
|
|
811
1015
|
);
|
|
812
1016
|
|
|
@@ -956,6 +1160,7 @@ export class AnonymousTransferClient {
|
|
|
956
1160
|
elGamalPrivateKey,
|
|
957
1161
|
amount,
|
|
958
1162
|
destinationPublicKey,
|
|
1163
|
+
offchainZKP = false,
|
|
959
1164
|
},
|
|
960
1165
|
options = {},
|
|
961
1166
|
) {
|
|
@@ -967,9 +1172,16 @@ export class AnonymousTransferClient {
|
|
|
967
1172
|
if (!ethers.isAddress(token))
|
|
968
1173
|
throw new Error(`Invalid token address: ${token}`);
|
|
969
1174
|
|
|
1175
|
+
// `proofHex` holds the "0x…" proof (supplied or auto-generated below).
|
|
1176
|
+
const manualProof = Boolean(proof);
|
|
970
1177
|
let proofHex;
|
|
971
|
-
|
|
972
|
-
|
|
1178
|
+
let cid;
|
|
1179
|
+
if (manualProof) {
|
|
1180
|
+
if (offchainZKP) {
|
|
1181
|
+
cid = proof;
|
|
1182
|
+
} else {
|
|
1183
|
+
proofHex = proof.startsWith("0x") ? proof : `0x${proof}`;
|
|
1184
|
+
}
|
|
973
1185
|
} else {
|
|
974
1186
|
if (!elGamalPrivateKey)
|
|
975
1187
|
throw new Error("Either proof or elGamalPrivateKey must be provided");
|
|
@@ -1017,11 +1229,17 @@ export class AnonymousTransferClient {
|
|
|
1017
1229
|
});
|
|
1018
1230
|
}
|
|
1019
1231
|
|
|
1020
|
-
await this._assertSufficientPrepaidFees(accountId);
|
|
1232
|
+
await this._assertSufficientPrepaidFees(accountId, offchainZKP);
|
|
1021
1233
|
|
|
1022
1234
|
const authNonce = await this.getAuthNonce(accountId);
|
|
1023
1235
|
const deadline = this._makeDeadline(deadlineOffset);
|
|
1024
|
-
|
|
1236
|
+
|
|
1237
|
+
// Resolve the proof payload (inline hex vs off-chain IPFS CID) and its EIP-712 hash.
|
|
1238
|
+
const { proofPayload, proofHash } = await this._resolveProofPayload({
|
|
1239
|
+
offchainZKP,
|
|
1240
|
+
proofHex,
|
|
1241
|
+
cid,
|
|
1242
|
+
});
|
|
1025
1243
|
|
|
1026
1244
|
const domain = this._buildDomain();
|
|
1027
1245
|
const types = {
|
|
@@ -1030,6 +1248,7 @@ export class AnonymousTransferClient {
|
|
|
1030
1248
|
{ name: "recipient", type: "address" },
|
|
1031
1249
|
{ name: "token", type: "address" },
|
|
1032
1250
|
{ name: "proofHash", type: "bytes32" },
|
|
1251
|
+
{ name: "offchainZKP", type: "bool" },
|
|
1033
1252
|
{ name: "authNonce", type: "uint256" },
|
|
1034
1253
|
{ name: "deadline", type: "uint256" },
|
|
1035
1254
|
],
|
|
@@ -1039,6 +1258,7 @@ export class AnonymousTransferClient {
|
|
|
1039
1258
|
recipient,
|
|
1040
1259
|
token,
|
|
1041
1260
|
proofHash,
|
|
1261
|
+
offchainZKP,
|
|
1042
1262
|
authNonce,
|
|
1043
1263
|
deadline: BigInt(deadline),
|
|
1044
1264
|
};
|
|
@@ -1049,7 +1269,8 @@ export class AnonymousTransferClient {
|
|
|
1049
1269
|
sender_id: accountId,
|
|
1050
1270
|
recipient,
|
|
1051
1271
|
token,
|
|
1052
|
-
proof:
|
|
1272
|
+
proof: proofPayload,
|
|
1273
|
+
offchain_zkp: offchainZKP,
|
|
1053
1274
|
auth_nonce: String(authNonce),
|
|
1054
1275
|
deadline: String(deadline),
|
|
1055
1276
|
signature,
|
|
@@ -1093,6 +1314,7 @@ export class AnonymousTransferClient {
|
|
|
1093
1314
|
elGamalPrivateKey,
|
|
1094
1315
|
amount,
|
|
1095
1316
|
destinationPublicKey,
|
|
1317
|
+
offchainZKP = false,
|
|
1096
1318
|
},
|
|
1097
1319
|
options = {},
|
|
1098
1320
|
) {
|
|
@@ -1103,9 +1325,17 @@ export class AnonymousTransferClient {
|
|
|
1103
1325
|
if (!ethers.isAddress(token))
|
|
1104
1326
|
throw new Error(`Invalid token address: ${token}`);
|
|
1105
1327
|
|
|
1328
|
+
// In manual mode + offchainZKP the caller passes a CID (not proof hex),, otherwise
|
|
1329
|
+
// `proofHex` holds the "0x…" proof
|
|
1330
|
+
const manualProof = Boolean(proof);
|
|
1106
1331
|
let proofHex;
|
|
1107
|
-
|
|
1108
|
-
|
|
1332
|
+
let cid;
|
|
1333
|
+
if (manualProof) {
|
|
1334
|
+
if (offchainZKP) {
|
|
1335
|
+
cid = proof;
|
|
1336
|
+
} else {
|
|
1337
|
+
proofHex = proof.startsWith("0x") ? proof : `0x${proof}`;
|
|
1338
|
+
}
|
|
1109
1339
|
} else {
|
|
1110
1340
|
if (!elGamalPrivateKey)
|
|
1111
1341
|
throw new Error("Either proof or elGamalPrivateKey must be provided");
|
|
@@ -1153,11 +1383,16 @@ export class AnonymousTransferClient {
|
|
|
1153
1383
|
});
|
|
1154
1384
|
}
|
|
1155
1385
|
|
|
1156
|
-
await this._assertSufficientPrepaidFees(senderAccountId);
|
|
1386
|
+
await this._assertSufficientPrepaidFees(senderAccountId, offchainZKP);
|
|
1157
1387
|
|
|
1158
1388
|
const authNonce = await this.getAuthNonce(senderAccountId);
|
|
1159
1389
|
const deadline = this._makeDeadline(deadlineOffset);
|
|
1160
|
-
|
|
1390
|
+
|
|
1391
|
+
const { proofPayload, proofHash } = await this._resolveProofPayload({
|
|
1392
|
+
offchainZKP,
|
|
1393
|
+
proofHex,
|
|
1394
|
+
cid,
|
|
1395
|
+
});
|
|
1161
1396
|
|
|
1162
1397
|
const domain = this._buildDomain();
|
|
1163
1398
|
const types = {
|
|
@@ -1166,6 +1401,7 @@ export class AnonymousTransferClient {
|
|
|
1166
1401
|
{ name: "recipientId", type: "string" },
|
|
1167
1402
|
{ name: "token", type: "address" },
|
|
1168
1403
|
{ name: "proofHash", type: "bytes32" },
|
|
1404
|
+
{ name: "offchainZKP", type: "bool" },
|
|
1169
1405
|
{ name: "authNonce", type: "uint256" },
|
|
1170
1406
|
{ name: "deadline", type: "uint256" },
|
|
1171
1407
|
],
|
|
@@ -1175,6 +1411,7 @@ export class AnonymousTransferClient {
|
|
|
1175
1411
|
recipientId: String(recipientId),
|
|
1176
1412
|
token,
|
|
1177
1413
|
proofHash,
|
|
1414
|
+
offchainZKP,
|
|
1178
1415
|
authNonce,
|
|
1179
1416
|
deadline: BigInt(deadline),
|
|
1180
1417
|
};
|
|
@@ -1185,7 +1422,8 @@ export class AnonymousTransferClient {
|
|
|
1185
1422
|
sender_id: senderAccountId,
|
|
1186
1423
|
recipient_id: recipientId,
|
|
1187
1424
|
token,
|
|
1188
|
-
proof:
|
|
1425
|
+
proof: proofPayload,
|
|
1426
|
+
offchain_zkp: offchainZKP,
|
|
1189
1427
|
auth_nonce: String(authNonce),
|
|
1190
1428
|
deadline: String(deadline),
|
|
1191
1429
|
signature,
|
|
@@ -1262,6 +1500,8 @@ export class AnonymousTransferClient {
|
|
|
1262
1500
|
* @param {bigint|string|number} params.plainAmount - Withdrawal amount in token units
|
|
1263
1501
|
* @param {string} [params.proof] - Pre-computed ZK proof ("0x..." hex)
|
|
1264
1502
|
* @param {string} [params.elGamalPrivateKey] - ElGamal private key (base64) for auto-proof
|
|
1503
|
+
* @param {boolean} [params.offchainZKP=false] - When true, upload the proof to IPFS and put
|
|
1504
|
+
* only its CID on-chain (needs `ipfsUploadUrl`/`ipfsApiKey` config or the StableTrust IPFS env vars)
|
|
1265
1505
|
* @param {Object} [options]
|
|
1266
1506
|
* @param {number} [options.deadlineOffset=3600]
|
|
1267
1507
|
* @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
|
|
@@ -1275,6 +1515,7 @@ export class AnonymousTransferClient {
|
|
|
1275
1515
|
plainAmount,
|
|
1276
1516
|
proof,
|
|
1277
1517
|
elGamalPrivateKey,
|
|
1518
|
+
offchainZKP = false,
|
|
1278
1519
|
},
|
|
1279
1520
|
options = {},
|
|
1280
1521
|
) {
|
|
@@ -1309,9 +1550,16 @@ export class AnonymousTransferClient {
|
|
|
1309
1550
|
const authNonce = accountInfo.authNonce;
|
|
1310
1551
|
const txId = accountInfo.txId;
|
|
1311
1552
|
|
|
1553
|
+
// In manual mode + offchainZKP the caller passes a CID (not proof hex)
|
|
1554
|
+
const manualProof = Boolean(proof);
|
|
1312
1555
|
let proofHex;
|
|
1313
|
-
|
|
1314
|
-
|
|
1556
|
+
let cid;
|
|
1557
|
+
if (manualProof) {
|
|
1558
|
+
if (offchainZKP) {
|
|
1559
|
+
cid = proof;
|
|
1560
|
+
} else {
|
|
1561
|
+
proofHex = proof.startsWith("0x") ? proof : `0x${proof}`;
|
|
1562
|
+
}
|
|
1315
1563
|
} else {
|
|
1316
1564
|
if (!elGamalPrivateKey)
|
|
1317
1565
|
throw new Error("Either proof or elGamalPrivateKey must be provided");
|
|
@@ -1336,7 +1584,13 @@ export class AnonymousTransferClient {
|
|
|
1336
1584
|
});
|
|
1337
1585
|
}
|
|
1338
1586
|
const deadline = this._makeDeadline(deadlineOffset);
|
|
1339
|
-
|
|
1587
|
+
|
|
1588
|
+
// Resolve the proof payload (inline hex vs off-chain IPFS CID) and its EIP-712 hash.
|
|
1589
|
+
const { proofPayload, proofHash } = await this._resolveProofPayload({
|
|
1590
|
+
offchainZKP,
|
|
1591
|
+
proofHex,
|
|
1592
|
+
cid,
|
|
1593
|
+
});
|
|
1340
1594
|
|
|
1341
1595
|
const domain = this._buildDomain();
|
|
1342
1596
|
const types = {
|
|
@@ -1346,6 +1600,7 @@ export class AnonymousTransferClient {
|
|
|
1346
1600
|
{ name: "token", type: "address" },
|
|
1347
1601
|
{ name: "plainAmount", type: "uint256" },
|
|
1348
1602
|
{ name: "proofHash", type: "bytes32" },
|
|
1603
|
+
{ name: "offchainZKP", type: "bool" },
|
|
1349
1604
|
{ name: "authNonce", type: "uint256" },
|
|
1350
1605
|
{ name: "deadline", type: "uint256" },
|
|
1351
1606
|
],
|
|
@@ -1356,6 +1611,7 @@ export class AnonymousTransferClient {
|
|
|
1356
1611
|
token,
|
|
1357
1612
|
plainAmount: withdrawAmountContractScale,
|
|
1358
1613
|
proofHash,
|
|
1614
|
+
offchainZKP,
|
|
1359
1615
|
authNonce,
|
|
1360
1616
|
deadline: BigInt(deadline),
|
|
1361
1617
|
};
|
|
@@ -1367,7 +1623,8 @@ export class AnonymousTransferClient {
|
|
|
1367
1623
|
destination,
|
|
1368
1624
|
token,
|
|
1369
1625
|
plain_amount: String(withdrawAmountContractScale),
|
|
1370
|
-
proof:
|
|
1626
|
+
proof: proofPayload,
|
|
1627
|
+
offchain_zkp: offchainZKP,
|
|
1371
1628
|
auth_nonce: String(authNonce),
|
|
1372
1629
|
deadline: String(deadline),
|
|
1373
1630
|
signature,
|
|
@@ -1438,24 +1695,149 @@ export class AnonymousTransferClient {
|
|
|
1438
1695
|
}
|
|
1439
1696
|
|
|
1440
1697
|
/**
|
|
1441
|
-
*
|
|
1698
|
+
* Fixed fee charged when creating an anonymous account.
|
|
1442
1699
|
*
|
|
1443
|
-
*
|
|
1444
|
-
|
|
1700
|
+
* @returns {Promise<bigint>} Fee amount in raw fee-token units
|
|
1701
|
+
*/
|
|
1702
|
+
async getAnonymousCreateAccountFee() {
|
|
1703
|
+
try {
|
|
1704
|
+
const data = await this._fetch("GET", "/v1/views/fees/anonymous-create");
|
|
1705
|
+
return _parseBigInt(data.result ?? data);
|
|
1706
|
+
} catch (e) {
|
|
1707
|
+
throw new Error(`Failed to get anonymous create-account fee: ${e.message}`);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* Minimum initial fee deposit required at account creation.
|
|
1445
1713
|
*
|
|
1446
|
-
* @returns {Promise<bigint>}
|
|
1714
|
+
* @returns {Promise<bigint>} Amount in raw fee-token units
|
|
1715
|
+
*/
|
|
1716
|
+
async getAnonymousMinimumInitialFeeDeposit() {
|
|
1717
|
+
try {
|
|
1718
|
+
const data = await this._fetch(
|
|
1719
|
+
"GET",
|
|
1720
|
+
"/v1/views/fees/anonymous-minimum-initial-deposit",
|
|
1721
|
+
);
|
|
1722
|
+
return _parseBigInt(data.result ?? data);
|
|
1723
|
+
} catch (e) {
|
|
1724
|
+
throw new Error(
|
|
1725
|
+
`Failed to get anonymous minimum initial fee deposit: ${e.message}`,
|
|
1726
|
+
);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/**
|
|
1731
|
+
* Fixed fee charged for an auth-key update.
|
|
1732
|
+
*
|
|
1733
|
+
* @returns {Promise<bigint>} Fee amount in raw fee-token units
|
|
1734
|
+
*/
|
|
1735
|
+
async getAnonymousUpdateKeysFee() {
|
|
1736
|
+
try {
|
|
1737
|
+
const data = await this._fetch(
|
|
1738
|
+
"GET",
|
|
1739
|
+
"/v1/views/fees/anonymous-update-keys",
|
|
1740
|
+
);
|
|
1741
|
+
return _parseBigInt(data.result ?? data);
|
|
1742
|
+
} catch (e) {
|
|
1743
|
+
throw new Error(`Failed to get anonymous update-keys fee: ${e.message}`);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* Fixed fee charged for an apply-pending operation.
|
|
1749
|
+
*
|
|
1750
|
+
* @returns {Promise<bigint>} Fee amount in raw fee-token units
|
|
1751
|
+
*/
|
|
1752
|
+
async getAnonymousApplyPendingFee() {
|
|
1753
|
+
try {
|
|
1754
|
+
const data = await this._fetch("GET", "/v1/views/fees/anonymous-apply");
|
|
1755
|
+
return _parseBigInt(data.result ?? data);
|
|
1756
|
+
} catch (e) {
|
|
1757
|
+
throw new Error(`Failed to get anonymous apply-pending fee: ${e.message}`);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/**
|
|
1762
|
+
* Fixed fee charged for an IPFS-proof withdraw.
|
|
1763
|
+
*
|
|
1764
|
+
* @returns {Promise<bigint>} Fee amount in raw fee-token units
|
|
1765
|
+
*/
|
|
1766
|
+
async getAnonymousIpfsWithdrawFee() {
|
|
1767
|
+
try {
|
|
1768
|
+
const data = await this._fetch(
|
|
1769
|
+
"GET",
|
|
1770
|
+
"/v1/views/fees/anonymous-withdraw-ipfs",
|
|
1771
|
+
);
|
|
1772
|
+
return _parseBigInt(data.result ?? data);
|
|
1773
|
+
} catch (e) {
|
|
1774
|
+
throw new Error(`Failed to get anonymous IPFS withdraw fee: ${e.message}`);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
/**
|
|
1779
|
+
* Fixed fee charged for an inline-proof withdraw.
|
|
1780
|
+
*
|
|
1781
|
+
* @returns {Promise<bigint>} Fee amount in raw fee-token units
|
|
1447
1782
|
*/
|
|
1448
|
-
async
|
|
1783
|
+
async getAnonymousInlineWithdrawFee() {
|
|
1449
1784
|
try {
|
|
1450
|
-
const data = await this._fetch(
|
|
1785
|
+
const data = await this._fetch(
|
|
1786
|
+
"GET",
|
|
1787
|
+
"/v1/views/fees/anonymous-withdraw-inline",
|
|
1788
|
+
);
|
|
1789
|
+
return _parseBigInt(data.result ?? data);
|
|
1790
|
+
} catch (e) {
|
|
1791
|
+
throw new Error(
|
|
1792
|
+
`Failed to get anonymous inline withdraw fee: ${e.message}`,
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
/**
|
|
1798
|
+
* Get the fixed fee used by all non-anonymous confidential transfers.
|
|
1799
|
+
*
|
|
1800
|
+
* @returns {Promise<bigint>} Fee amount in raw fee-token units
|
|
1801
|
+
*/
|
|
1802
|
+
async getNonAnonymousTransferFee() {
|
|
1803
|
+
try {
|
|
1804
|
+
const data = await this._fetch("GET", "/v1/views/fees/non-anonymous");
|
|
1451
1805
|
return _parseBigInt(data.result ?? data);
|
|
1452
1806
|
} catch (e) {
|
|
1453
|
-
throw new Error(`Failed to get fee
|
|
1807
|
+
throw new Error(`Failed to get non-anonymous transfer fee: ${e.message}`);
|
|
1454
1808
|
}
|
|
1455
1809
|
}
|
|
1456
1810
|
|
|
1457
1811
|
/**
|
|
1458
|
-
* Get the
|
|
1812
|
+
* Get the fixed fee used by anonymous transfers with proofs stored on IPFS.
|
|
1813
|
+
*
|
|
1814
|
+
* @returns {Promise<bigint>} Fee amount in raw fee-token units
|
|
1815
|
+
*/
|
|
1816
|
+
async getAnonymousIpfsTransferFee() {
|
|
1817
|
+
try {
|
|
1818
|
+
const data = await this._fetch("GET", "/v1/views/fees/anonymous-ipfs");
|
|
1819
|
+
return _parseBigInt(data.result ?? data);
|
|
1820
|
+
} catch (e) {
|
|
1821
|
+
throw new Error(`Failed to get anonymous IPFS transfer fee: ${e.message}`);
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
/**
|
|
1826
|
+
* Get the fixed fee used by anonymous transfers with proofs submitted inline.
|
|
1827
|
+
*
|
|
1828
|
+
* @returns {Promise<bigint>} Fee amount in raw fee-token units
|
|
1829
|
+
*/
|
|
1830
|
+
async getAnonymousInlineTransferFee() {
|
|
1831
|
+
try {
|
|
1832
|
+
const data = await this._fetch("GET", "/v1/views/fees/anonymous-inline");
|
|
1833
|
+
return _parseBigInt(data.result ?? data);
|
|
1834
|
+
} catch (e) {
|
|
1835
|
+
throw new Error(`Failed to get anonymous inline transfer fee: ${e.message}`);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/**
|
|
1840
|
+
* Get the prepaid fee balance for an anonymous account and token (in raw fee-token units).
|
|
1459
1841
|
*
|
|
1460
1842
|
* @param {string} accountId
|
|
1461
1843
|
* @param {string} token - ERC-20 token address (use `getFeeToken()` to get the active fee token)
|
|
@@ -1481,23 +1863,25 @@ export class AnonymousTransferClient {
|
|
|
1481
1863
|
* Throws a descriptive error if the balance is insufficient.
|
|
1482
1864
|
* @private
|
|
1483
1865
|
*/
|
|
1484
|
-
async _assertSufficientPrepaidFees(accountId) {
|
|
1866
|
+
async _assertSufficientPrepaidFees(accountId, offchainZKP) {
|
|
1867
|
+
const feePromise = offchainZKP
|
|
1868
|
+
? this.getAnonymousIpfsTransferFee()
|
|
1869
|
+
: this.getAnonymousInlineTransferFee();
|
|
1485
1870
|
const [feeToken, feeAmount] = await Promise.all([
|
|
1486
1871
|
this.getFeeToken(),
|
|
1487
|
-
|
|
1872
|
+
feePromise,
|
|
1488
1873
|
]);
|
|
1489
|
-
if (feeAmount === 0n) return; // no fee configured
|
|
1490
|
-
|
|
1491
|
-
if (feeToken === ethers.ZeroAddress) return; // fee token not set
|
|
1874
|
+
if (feeAmount === 0n) return; // no fee configured for this path
|
|
1492
1875
|
|
|
1493
1876
|
const balance = await this.getPrepaidFeeBalance(accountId, feeToken);
|
|
1494
1877
|
if (balance < feeAmount) {
|
|
1878
|
+
const path = offchainZKP ? "IPFS" : "inline";
|
|
1495
1879
|
throw new Error(
|
|
1496
|
-
`Insufficient prepaid fee balance for anonymous account "${accountId}". ` +
|
|
1880
|
+
`Insufficient prepaid fee balance for anonymous account "${accountId}" (${path} transfer). ` +
|
|
1497
1881
|
`Required: ${feeAmount} (raw units of fee token ${feeToken}), ` +
|
|
1498
1882
|
`available: ${balance}. ` +
|
|
1499
1883
|
`Call depositFees(wallet, accountId, amount) to top up the prepaid fee reserve. ` +
|
|
1500
|
-
`Use getFeeToken()
|
|
1884
|
+
`Use getFeeToken() and the matching anonymous fee getter to check the required amount.`,
|
|
1501
1885
|
);
|
|
1502
1886
|
}
|
|
1503
1887
|
}
|
|
@@ -1519,7 +1903,7 @@ export class AnonymousTransferClient {
|
|
|
1519
1903
|
*
|
|
1520
1904
|
* @param {ethers.Wallet} authWallet - Wallet that owns the tokens and pays gas
|
|
1521
1905
|
* @param {string} accountId - Target anonymous account ID
|
|
1522
|
-
* @param {bigint|string|number} amount - Amount in raw
|
|
1906
|
+
* @param {bigint|string|number} amount - Amount in raw fee-token units (wei for native currency)
|
|
1523
1907
|
* @param {Object} [options]
|
|
1524
1908
|
* @param {number} [options.deadlineOffset=3600]
|
|
1525
1909
|
* @param {bigint} [options.gasLimit=300000n]
|
|
@@ -1534,45 +1918,47 @@ export class AnonymousTransferClient {
|
|
|
1534
1918
|
if (amountBig <= 0n) throw new Error("Amount must be greater than 0");
|
|
1535
1919
|
|
|
1536
1920
|
const feeToken = await this.getFeeToken();
|
|
1537
|
-
|
|
1538
|
-
throw new Error(
|
|
1539
|
-
"No fee token is currently configured on the contract. Contact the protocol operator.",
|
|
1540
|
-
);
|
|
1921
|
+
const isNative = feeToken === ethers.ZeroAddress;
|
|
1541
1922
|
|
|
1542
1923
|
const depositorAddress = await authWallet.getAddress();
|
|
1543
1924
|
const walletWithProvider = authWallet.connect
|
|
1544
1925
|
? authWallet.connect(this.provider)
|
|
1545
1926
|
: authWallet;
|
|
1546
1927
|
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1928
|
+
if (isNative) {
|
|
1929
|
+
// Native currency: check wallet balance, no ERC-20 approve needed
|
|
1930
|
+
const balance = await this.provider.getBalance(depositorAddress);
|
|
1931
|
+
if (balance < amountBig)
|
|
1932
|
+
throw new Error(
|
|
1933
|
+
`Insufficient native balance. Required: ${amountBig}, available: ${balance}`,
|
|
1934
|
+
);
|
|
1935
|
+
} else {
|
|
1936
|
+
// ERC-20: approve if needed
|
|
1937
|
+
const tokenContract = new ethers.Contract(
|
|
1938
|
+
feeToken,
|
|
1939
|
+
ERC20_ABI,
|
|
1940
|
+
walletWithProvider,
|
|
1941
|
+
);
|
|
1557
1942
|
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1943
|
+
const [balance, allowance] = await Promise.all([
|
|
1944
|
+
tokenContract.balanceOf(depositorAddress),
|
|
1945
|
+
tokenContract.allowance(depositorAddress, this.diamondAddress),
|
|
1946
|
+
]);
|
|
1562
1947
|
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1948
|
+
if (balance < amountBig)
|
|
1949
|
+
throw new Error(
|
|
1950
|
+
`Insufficient fee token balance. Required: ${amountBig}, available: ${balance}`,
|
|
1951
|
+
);
|
|
1567
1952
|
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1953
|
+
if (allowance < amountBig) {
|
|
1954
|
+
const approveTx = await tokenContract.approve(
|
|
1955
|
+
this.diamondAddress,
|
|
1956
|
+
ethers.MaxUint256,
|
|
1957
|
+
);
|
|
1958
|
+
const receipt = await approveTx.wait();
|
|
1959
|
+
if (!receipt || receipt.status === 0)
|
|
1960
|
+
throw new Error("Fee token approval failed");
|
|
1961
|
+
}
|
|
1576
1962
|
}
|
|
1577
1963
|
|
|
1578
1964
|
// 2. EIP-712 AnonymousAddFees signature
|
|
@@ -1614,6 +2000,7 @@ export class AnonymousTransferClient {
|
|
|
1614
2000
|
this.provider.getFeeData(),
|
|
1615
2001
|
]);
|
|
1616
2002
|
|
|
2003
|
+
const txValue = isNative ? amountBig : 0n;
|
|
1617
2004
|
let txObj;
|
|
1618
2005
|
if (feeData.maxFeePerGas) {
|
|
1619
2006
|
txObj = {
|
|
@@ -1624,7 +2011,7 @@ export class AnonymousTransferClient {
|
|
|
1624
2011
|
gasLimit,
|
|
1625
2012
|
maxFeePerGas: (feeData.maxFeePerGas * 12n) / 10n,
|
|
1626
2013
|
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? 0n,
|
|
1627
|
-
value:
|
|
2014
|
+
value: txValue,
|
|
1628
2015
|
};
|
|
1629
2016
|
} else {
|
|
1630
2017
|
txObj = {
|
|
@@ -1634,7 +2021,7 @@ export class AnonymousTransferClient {
|
|
|
1634
2021
|
chainId: BigInt(this.chainId),
|
|
1635
2022
|
gasLimit,
|
|
1636
2023
|
gasPrice: (feeData.gasPrice * 11n) / 10n,
|
|
1637
|
-
value:
|
|
2024
|
+
value: txValue,
|
|
1638
2025
|
};
|
|
1639
2026
|
}
|
|
1640
2027
|
|
|
@@ -1657,11 +2044,55 @@ export class AnonymousTransferClient {
|
|
|
1657
2044
|
}
|
|
1658
2045
|
|
|
1659
2046
|
/**
|
|
1660
|
-
*
|
|
2047
|
+
* Build and sign a raw EVM transaction targeting the diamond, for the user-funded
|
|
2048
|
+
* raw-tx flows (withdrawFees / withdrawAllFees). The wallet that signs pays the gas.
|
|
2049
|
+
* Supports both EIP-1559 and legacy fee markets. Nonce/chainId/fees are supplied
|
|
2050
|
+
* explicitly, so `authWallet` does not need to be provider-connected.
|
|
2051
|
+
* @private
|
|
2052
|
+
*/
|
|
2053
|
+
async _signRawDiamondTx(authWallet, calldata, value, gasLimit) {
|
|
2054
|
+
const fromAddress = await authWallet.getAddress();
|
|
2055
|
+
const [nonce, feeData] = await Promise.all([
|
|
2056
|
+
this.provider.getTransactionCount(fromAddress),
|
|
2057
|
+
this.provider.getFeeData(),
|
|
2058
|
+
]);
|
|
2059
|
+
let txObj;
|
|
2060
|
+
if (feeData.maxFeePerGas) {
|
|
2061
|
+
txObj = {
|
|
2062
|
+
to: this.diamondAddress,
|
|
2063
|
+
data: calldata,
|
|
2064
|
+
nonce,
|
|
2065
|
+
chainId: BigInt(this.chainId),
|
|
2066
|
+
gasLimit,
|
|
2067
|
+
maxFeePerGas: (feeData.maxFeePerGas * 12n) / 10n,
|
|
2068
|
+
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? 0n,
|
|
2069
|
+
value,
|
|
2070
|
+
};
|
|
2071
|
+
} else {
|
|
2072
|
+
txObj = {
|
|
2073
|
+
to: this.diamondAddress,
|
|
2074
|
+
data: calldata,
|
|
2075
|
+
nonce,
|
|
2076
|
+
chainId: BigInt(this.chainId),
|
|
2077
|
+
gasLimit,
|
|
2078
|
+
gasPrice: (feeData.gasPrice * 11n) / 10n,
|
|
2079
|
+
value,
|
|
2080
|
+
};
|
|
2081
|
+
}
|
|
2082
|
+
return authWallet.signTransaction(txObj);
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
/**
|
|
2086
|
+
* Withdraw a specific amount from the prepaid fee reserve.
|
|
2087
|
+
*
|
|
2088
|
+
* User-funded raw-transaction model (like deposit/addFees): the anonymous signer
|
|
2089
|
+
* authorises the inner operation via EIP-712, and the same wallet signs and funds
|
|
2090
|
+
* the outer `withdrawFees(...)` EVM transaction (zero ETH value). Fairycloak validates
|
|
2091
|
+
* and broadcasts it unchanged. The wallet pays the gas; no prepaid fee is charged.
|
|
1661
2092
|
*
|
|
1662
2093
|
* Use this to reclaim fees for a specific (possibly historical) fee token.
|
|
1663
2094
|
*
|
|
1664
|
-
* @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer
|
|
2095
|
+
* @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer; also funds the outer tx
|
|
1665
2096
|
* @param {string} accountId
|
|
1666
2097
|
* @param {Object} params
|
|
1667
2098
|
* @param {string} params.token - Fee token address to withdraw
|
|
@@ -1669,6 +2100,7 @@ export class AnonymousTransferClient {
|
|
|
1669
2100
|
* @param {bigint|string|number} params.amount - Amount in raw ERC-20 units
|
|
1670
2101
|
* @param {Object} [options]
|
|
1671
2102
|
* @param {number} [options.deadlineOffset=3600]
|
|
2103
|
+
* @param {bigint} [options.gasLimit=300000n]
|
|
1672
2104
|
* @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
|
|
1673
2105
|
*/
|
|
1674
2106
|
async withdrawFees(
|
|
@@ -1678,7 +2110,8 @@ export class AnonymousTransferClient {
|
|
|
1678
2110
|
options = {},
|
|
1679
2111
|
) {
|
|
1680
2112
|
validateAnonymousAccountId(accountId, "accountId");
|
|
1681
|
-
const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } =
|
|
2113
|
+
const { deadlineOffset = DEFAULT_DEADLINE_OFFSET, gasLimit = 300000n } =
|
|
2114
|
+
options;
|
|
1682
2115
|
try {
|
|
1683
2116
|
if (!ethers.isAddress(token))
|
|
1684
2117
|
throw new Error(`Invalid token address: ${token}`);
|
|
@@ -1712,14 +2145,28 @@ export class AnonymousTransferClient {
|
|
|
1712
2145
|
|
|
1713
2146
|
const signature = await authWallet.signTypedData(domain, types, value);
|
|
1714
2147
|
|
|
2148
|
+
// Build + sign the user-funded raw withdrawFees tx (must carry zero ETH value).
|
|
2149
|
+
const calldata = WITHDRAW_FEES_INTERFACE.encodeFunctionData(
|
|
2150
|
+
"withdrawFees",
|
|
2151
|
+
[
|
|
2152
|
+
accountId,
|
|
2153
|
+
token,
|
|
2154
|
+
destination,
|
|
2155
|
+
amountBig,
|
|
2156
|
+
authNonce,
|
|
2157
|
+
BigInt(deadline),
|
|
2158
|
+
signature,
|
|
2159
|
+
],
|
|
2160
|
+
);
|
|
2161
|
+
const signedTx = await this._signRawDiamondTx(
|
|
2162
|
+
authWallet,
|
|
2163
|
+
calldata,
|
|
2164
|
+
0n,
|
|
2165
|
+
gasLimit,
|
|
2166
|
+
);
|
|
2167
|
+
|
|
1715
2168
|
return await this._fetch("POST", "/v1/anonymous/fees/withdraw", {
|
|
1716
|
-
|
|
1717
|
-
token,
|
|
1718
|
-
destination,
|
|
1719
|
-
amount: String(amountBig),
|
|
1720
|
-
auth_nonce: String(authNonce),
|
|
1721
|
-
deadline: String(deadline),
|
|
1722
|
-
signature,
|
|
2169
|
+
raw_tx: signedTx,
|
|
1723
2170
|
});
|
|
1724
2171
|
} catch (e) {
|
|
1725
2172
|
const msg = e?.message ?? String(e);
|
|
@@ -1729,20 +2176,25 @@ export class AnonymousTransferClient {
|
|
|
1729
2176
|
}
|
|
1730
2177
|
|
|
1731
2178
|
/**
|
|
1732
|
-
* Withdraw all prepaid fee balances across all (including historical) fee tokens
|
|
1733
|
-
* The relay pays gas.
|
|
2179
|
+
* Withdraw all prepaid fee balances across all (including historical) fee tokens.
|
|
1734
2180
|
*
|
|
1735
|
-
*
|
|
2181
|
+
* User-funded raw-transaction model (like withdrawFees): the anonymous signer
|
|
2182
|
+
* authorises the drain via EIP-712 and the same wallet signs and funds the outer
|
|
2183
|
+
* `withdrawAllFees(...)` EVM transaction (zero ETH value). The wallet pays the gas.
|
|
2184
|
+
*
|
|
2185
|
+
* @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer; also funds the outer tx
|
|
1736
2186
|
* @param {string} accountId
|
|
1737
2187
|
* @param {Object} params
|
|
1738
2188
|
* @param {string} params.destination - Destination EVM address
|
|
1739
2189
|
* @param {Object} [options]
|
|
1740
2190
|
* @param {number} [options.deadlineOffset=3600]
|
|
2191
|
+
* @param {bigint} [options.gasLimit=300000n]
|
|
1741
2192
|
* @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
|
|
1742
2193
|
*/
|
|
1743
2194
|
async withdrawAllFees(authWallet, accountId, { destination }, options = {}) {
|
|
1744
2195
|
validateAnonymousAccountId(accountId, "accountId");
|
|
1745
|
-
const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } =
|
|
2196
|
+
const { deadlineOffset = DEFAULT_DEADLINE_OFFSET, gasLimit = 300000n } =
|
|
2197
|
+
options;
|
|
1746
2198
|
try {
|
|
1747
2199
|
if (!ethers.isAddress(destination))
|
|
1748
2200
|
throw new Error(`Invalid destination address: ${destination}`);
|
|
@@ -1768,12 +2220,20 @@ export class AnonymousTransferClient {
|
|
|
1768
2220
|
|
|
1769
2221
|
const signature = await authWallet.signTypedData(domain, types, value);
|
|
1770
2222
|
|
|
2223
|
+
// Build + sign the user-funded raw withdrawAllFees tx (must carry zero ETH value).
|
|
2224
|
+
const calldata = WITHDRAW_ALL_FEES_INTERFACE.encodeFunctionData(
|
|
2225
|
+
"withdrawAllFees",
|
|
2226
|
+
[accountId, destination, authNonce, BigInt(deadline), signature],
|
|
2227
|
+
);
|
|
2228
|
+
const signedTx = await this._signRawDiamondTx(
|
|
2229
|
+
authWallet,
|
|
2230
|
+
calldata,
|
|
2231
|
+
0n,
|
|
2232
|
+
gasLimit,
|
|
2233
|
+
);
|
|
2234
|
+
|
|
1771
2235
|
return await this._fetch("POST", "/v1/anonymous/fees/withdraw-all", {
|
|
1772
|
-
|
|
1773
|
-
destination,
|
|
1774
|
-
auth_nonce: String(authNonce),
|
|
1775
|
-
deadline: String(deadline),
|
|
1776
|
-
signature,
|
|
2236
|
+
raw_tx: signedTx,
|
|
1777
2237
|
});
|
|
1778
2238
|
} catch (e) {
|
|
1779
2239
|
const msg = e?.message ?? String(e);
|