@fairblock/stabletrust 2.0.11 → 2.0.12
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 +3 -3
- package/src/confidential-client.js +21 -7
- package/src/constants.js +21 -9
- package/src/controlled-confidential-client.js +265 -0
- package/src/fee-aware-clients.js +430 -0
- package/src/ibe.js +36 -9
- package/src/index.d.ts +2 -0
- package/src/index.js +10 -2
- package/src/withdrawal-fees.d.ts +83 -0
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fairblock/stabletrust",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.12",
|
|
4
4
|
"description": "SDK for confidential transfers using homomorphic encryption and zero-knowledge proofs",
|
|
5
5
|
"main": "src/index.js",
|
|
6
|
-
"types": "src/
|
|
6
|
+
"types": "src/withdrawal-fees.d.ts",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"sideEffects": [
|
|
9
9
|
"**/*.wasm"
|
|
10
10
|
],
|
|
11
11
|
"exports": {
|
|
12
12
|
".": {
|
|
13
|
-
"types": "./src/
|
|
13
|
+
"types": "./src/withdrawal-fees.d.ts",
|
|
14
14
|
"import": "./src/index.js",
|
|
15
15
|
"default": "./src/index.js"
|
|
16
16
|
},
|
|
@@ -46,6 +46,7 @@ export class ConfidentialTransferClient {
|
|
|
46
46
|
* @param {string} [options.ipfsUploadUrl] - StableTrust IPFS upload endpoint for off-chain proof uploads
|
|
47
47
|
* @param {string} [options.ipfsApiKey] - Optional bearer token for the StableTrust IPFS upload endpoint
|
|
48
48
|
* @param {string} [options.apiBaseUrl] - StableTrust backend API base URL for request persistence
|
|
49
|
+
* @param {string} [options.applicationName] - App name stored on request rows (default: "SDK")
|
|
49
50
|
*/
|
|
50
51
|
constructor(rpcUrl, contractAddressOrChainId, chainId, options = {}) {
|
|
51
52
|
// Validate required config
|
|
@@ -94,6 +95,7 @@ export class ConfidentialTransferClient {
|
|
|
94
95
|
ipfsUploadUrl: ipfsOptions.ipfsUploadUrl || null,
|
|
95
96
|
ipfsApiKey: ipfsOptions.ipfsApiKey || null,
|
|
96
97
|
apiBaseUrl: ipfsOptions.apiBaseUrl || null,
|
|
98
|
+
applicationName: ipfsOptions.applicationName || "SDK",
|
|
97
99
|
};
|
|
98
100
|
|
|
99
101
|
// WASM will be auto-initialized on first use
|
|
@@ -126,7 +128,7 @@ export class ConfidentialTransferClient {
|
|
|
126
128
|
const payload = {
|
|
127
129
|
chainKey: CHAIN_KEY_BY_ID[this.config.chainId] || String(this.config.chainId),
|
|
128
130
|
chainId: this.config.chainId,
|
|
129
|
-
applicationName:
|
|
131
|
+
applicationName: this.config.applicationName,
|
|
130
132
|
...data,
|
|
131
133
|
};
|
|
132
134
|
createRequest(payload, apiBaseUrl).catch((err) => {
|
|
@@ -869,11 +871,9 @@ export class ConfidentialTransferClient {
|
|
|
869
871
|
throw new Error("Transfer transaction failed");
|
|
870
872
|
}
|
|
871
873
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
// IBE-encrypt randomness then persist both records (fire-and-forget, after finalization)
|
|
874
|
+
// IBE-encrypt randomness and start persisting both records immediately after
|
|
875
|
+
// the EVM request transaction is mined. This is intentionally independent of
|
|
876
|
+
// waitForFinalization so the backend record can exist while the action is pending.
|
|
877
877
|
const txHash = receipt.hash;
|
|
878
878
|
const requestId = await this._resolveRequestId(receipt, senderAddress, [
|
|
879
879
|
"TransferRequested",
|
|
@@ -897,7 +897,12 @@ export class ConfidentialTransferClient {
|
|
|
897
897
|
senderEnc.queuedEncrypted && receiverEnc.queuedEncrypted
|
|
898
898
|
? {
|
|
899
899
|
queuedEncryptedSenderRandomness: senderEnc.queuedEncrypted,
|
|
900
|
-
|
|
900
|
+
queuedEncryptedSenderRandomnessId:
|
|
901
|
+
senderEnc.queuedEncryptedId,
|
|
902
|
+
queuedEncryptedReceiverRandomness:
|
|
903
|
+
receiverEnc.queuedEncrypted,
|
|
904
|
+
queuedEncryptedReceiverRandomnessId:
|
|
905
|
+
receiverEnc.queuedEncryptedId,
|
|
901
906
|
}
|
|
902
907
|
: {};
|
|
903
908
|
|
|
@@ -910,7 +915,9 @@ export class ConfidentialTransferClient {
|
|
|
910
915
|
status: "pending",
|
|
911
916
|
details: transferDetails,
|
|
912
917
|
encryptedSenderRandomness: senderEnc.encrypted,
|
|
918
|
+
encryptedSenderRandomnessId: senderEnc.encryptedId,
|
|
913
919
|
encryptedReceiverRandomness: receiverEnc.encrypted,
|
|
920
|
+
encryptedReceiverRandomnessId: receiverEnc.encryptedId,
|
|
914
921
|
...queuedFields,
|
|
915
922
|
});
|
|
916
923
|
|
|
@@ -923,16 +930,23 @@ export class ConfidentialTransferClient {
|
|
|
923
930
|
status: "pending",
|
|
924
931
|
details: transferDetails,
|
|
925
932
|
encryptedReceiverRandomness: receiverEnc.encrypted,
|
|
933
|
+
encryptedReceiverRandomnessId: receiverEnc.encryptedId,
|
|
926
934
|
...(receiverEnc.queuedEncrypted
|
|
927
935
|
? {
|
|
928
936
|
queuedEncryptedReceiverRandomness:
|
|
929
937
|
receiverEnc.queuedEncrypted,
|
|
938
|
+
queuedEncryptedReceiverRandomnessId:
|
|
939
|
+
receiverEnc.queuedEncryptedId,
|
|
930
940
|
}
|
|
931
941
|
: {}),
|
|
932
942
|
});
|
|
933
943
|
})
|
|
934
944
|
.catch(() => {});
|
|
935
945
|
|
|
946
|
+
if (waitForFinalization) {
|
|
947
|
+
await this._waitForGlobalState(senderAddress, "transfer");
|
|
948
|
+
}
|
|
949
|
+
|
|
936
950
|
return receipt;
|
|
937
951
|
} catch (error) {
|
|
938
952
|
const message = error?.message ?? String(error);
|
package/src/constants.js
CHANGED
|
@@ -12,9 +12,13 @@ const APPLY_PENDING_SIGNATURE = "applyPending()";
|
|
|
12
12
|
export const TRANSFER_CONFIDENTIAL_SIGNATURE = "transferConfidential(address,address,bytes,bool)";
|
|
13
13
|
export const WITHDRAW_CONFIDENTIAL_SIGNATURE = "withdraw(address,uint256,bytes,bool)";
|
|
14
14
|
export const FEE_TOKEN_SIGNATURE = "feeToken()";
|
|
15
|
+
export const FEE_ACCOUNT_SIGNATURE = "feeAccount()";
|
|
15
16
|
export const NON_ANONYMOUS_TRANSFER_FEE_SIGNATURE = "nonAnonymousTransferFee()";
|
|
17
|
+
export const NON_ANONYMOUS_WITHDRAW_FEE_SIGNATURE = "nonAnonymousWithdrawFee()";
|
|
18
|
+
export const NON_ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE = "nonAnonymousWithdrawFeePpm()";
|
|
16
19
|
export const ANONYMOUS_IPFS_TRANSFER_FEE_SIGNATURE = "anonymousIpfsTransferFee()";
|
|
17
20
|
export const ANONYMOUS_INLINE_TRANSFER_FEE_SIGNATURE = "anonymousInlineTransferFee()";
|
|
21
|
+
export const ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE = "anonymousWithdrawFeePpm()";
|
|
18
22
|
|
|
19
23
|
export const CONTRACT_ABI = [
|
|
20
24
|
`function ${CREATE_CONFIDENTIAL_ACCOUNT_SIGNATURE} external`,
|
|
@@ -23,21 +27,29 @@ export const CONTRACT_ABI = [
|
|
|
23
27
|
`function ${GET_AVAILABLE_SIGNATURE} external view returns (bytes c1, bytes c2)`,
|
|
24
28
|
`function ${GET_PENDING_SIGNATURE} external view returns (bytes c1, bytes c2)`,
|
|
25
29
|
`function ${TRANSFER_CONFIDENTIAL_SIGNATURE} external payable`,
|
|
26
|
-
`function ${WITHDRAW_CONFIDENTIAL_SIGNATURE} external`,
|
|
30
|
+
`function ${WITHDRAW_CONFIDENTIAL_SIGNATURE} external payable`,
|
|
27
31
|
`function ${APPLY_PENDING_SIGNATURE} external`,
|
|
28
32
|
`function ${FEE_TOKEN_SIGNATURE} external view returns (address)`,
|
|
33
|
+
`function ${FEE_ACCOUNT_SIGNATURE} external view returns (address)`,
|
|
29
34
|
`function ${NON_ANONYMOUS_TRANSFER_FEE_SIGNATURE} external view returns (uint256)`,
|
|
35
|
+
`function ${NON_ANONYMOUS_WITHDRAW_FEE_SIGNATURE} external view returns (uint256)`,
|
|
36
|
+
`function ${NON_ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE} external view returns (uint256)`,
|
|
30
37
|
`function ${ANONYMOUS_IPFS_TRANSFER_FEE_SIGNATURE} external view returns (uint256)`,
|
|
31
38
|
`function ${ANONYMOUS_INLINE_TRANSFER_FEE_SIGNATURE} external view returns (uint256)`,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"event
|
|
39
|
+
`function ${ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE} external view returns (uint256)`,
|
|
40
|
+
|
|
41
|
+
// Current ConfidentialMirror Diamond events. ResultCode is a Solidity enum
|
|
42
|
+
// and therefore appears as uint8 in the ABI.
|
|
43
|
+
"event CreateAccountRequested(address indexed ownerAddr, uint256 indexed txId, bytes elgamalPubkey)",
|
|
44
|
+
"event CreateAccountProcessed(address indexed ownerAddr, uint256 indexed txId, bool ok, uint8 code, bytes elgamalPubkey)",
|
|
45
|
+
"event DepositRequested(address indexed ownerAddr, uint256 indexed txId, address indexed token, uint256 plainAmount, bytes availableC1, bytes availableC2)",
|
|
46
|
+
"event DepositProcessed(address indexed ownerAddr, uint256 indexed txId, bool ok, uint8 code, address token, bytes newAvailC1, bytes newAvailC2, uint64 pendingCreditCounter)",
|
|
47
|
+
"event TransferRequested(address indexed sender, address indexed recipient, uint256 indexed txId, address token, bytes proof, bytes senderPubkey, bytes recipientPubkey, bytes senderCurrC1, bytes senderCurrC2, bool offchainZKP)",
|
|
48
|
+
"event TransferProcessed(address indexed sender, address indexed recipient, uint256 indexed txId, bool ok, uint8 code, address token, bytes senderNewAvailC1, bytes senderNewAvailC2, bytes recipientNewPendingC1, bytes recipientNewPendingC2, uint64 senderPendingCreditCounter, uint64 recipientPendingCreditCounter)",
|
|
37
49
|
"event ApplyPendingRequested(address indexed ownerAddr, uint256 indexed txId)",
|
|
38
|
-
"event ApplyPendingProcessed(address indexed ownerAddr, uint256 indexed txId, bool ok,
|
|
39
|
-
"event WithdrawRequested(address indexed ownerAddr, uint256 indexed txId, address token, uint256 plainAmount, bytes proof,
|
|
40
|
-
"event WithdrawProcessed(address indexed ownerAddr, uint256 indexed txId, bool ok,
|
|
50
|
+
"event ApplyPendingProcessed(address indexed ownerAddr, uint256 indexed txId, bool ok, uint8 code, address[] tokens, bytes[] newAvailC1, bytes[] newAvailC2, uint64 pendingCreditCounter)",
|
|
51
|
+
"event WithdrawRequested(address indexed ownerAddr, uint256 indexed txId, address token, uint256 plainAmount, bytes proof, bytes userPubkey, bytes userCurrC1, bytes userCurrC2, bool offchainZKP)",
|
|
52
|
+
"event WithdrawProcessed(address indexed ownerAddr, uint256 indexed txId, bool ok, uint8 code, address token, uint256 plainAmount, bytes newAvailC1, bytes newAvailC2, uint64 pendingCreditCounter)",
|
|
41
53
|
];
|
|
42
54
|
|
|
43
55
|
export const ERC20_ABI = [
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { ethers } from "ethers";
|
|
2
|
+
import { ConfidentialTransferClient } from "./confidential-client.js";
|
|
3
|
+
import { encodeTransferProof, encodeWithdrawProof, toContractScale } from "./utils.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ConfidentialTransferClient variant for orchestrators that need explicit
|
|
7
|
+
* ownership of pending-balance application and transaction lifecycle.
|
|
8
|
+
*
|
|
9
|
+
* Backwards compatibility is preserved by keeping autoApplyPending=true by
|
|
10
|
+
* default. CTS sets it to false so applyPending remains an explicit workload
|
|
11
|
+
* action rather than being hidden inside deposit/transfer/withdraw helpers.
|
|
12
|
+
*/
|
|
13
|
+
export class ControlledConfidentialTransferClient extends ConfidentialTransferClient {
|
|
14
|
+
constructor(rpcUrl, contractAddressOrChainId, chainId, options = {}) {
|
|
15
|
+
const resolvedOptions =
|
|
16
|
+
typeof chainId === "object" && chainId !== null ? chainId : options || {};
|
|
17
|
+
|
|
18
|
+
super(rpcUrl, contractAddressOrChainId, chainId, options);
|
|
19
|
+
this.autoApplyPending = resolvedOptions.autoApplyPending !== false;
|
|
20
|
+
const configuredSafetyBps = Number(resolvedOptions.gasLimitSafetyBps ?? 2500);
|
|
21
|
+
if (!Number.isFinite(configuredSafetyBps) || configuredSafetyBps < 0) {
|
|
22
|
+
throw new Error("gasLimitSafetyBps must be a non-negative finite number");
|
|
23
|
+
}
|
|
24
|
+
this.gasLimitSafetyBps = BigInt(Math.trunc(configuredSafetyBps));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async _applyPendingIfNeeded(wallet, privateKey, tokenAddress, actionLabel) {
|
|
28
|
+
if (!this.autoApplyPending) return false;
|
|
29
|
+
await super._applyPendingIfNeeded(wallet, privateKey, tokenAddress, actionLabel);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Estimate gas immediately before broadcast and add configurable headroom.
|
|
35
|
+
* Some public RPCs return estimates with too little margin for state that can
|
|
36
|
+
* change between estimation and execution. Passing an explicit padded limit
|
|
37
|
+
* prevents otherwise-valid CTS operations from failing with OutOfGas.
|
|
38
|
+
*/
|
|
39
|
+
async _submitWithGasSafety(contract, methodName, args = [], overrides = {}) {
|
|
40
|
+
const method = contract[methodName];
|
|
41
|
+
if (typeof method !== "function" || typeof method.estimateGas !== "function") {
|
|
42
|
+
throw new Error(`Contract method ${methodName} is unavailable`);
|
|
43
|
+
}
|
|
44
|
+
if (overrides.gasLimit !== undefined) {
|
|
45
|
+
return await method(...args, overrides);
|
|
46
|
+
}
|
|
47
|
+
const estimate = await method.estimateGas(...args, overrides);
|
|
48
|
+
const gasLimit =
|
|
49
|
+
(estimate * (10000n + this.gasLimitSafetyBps) + 9999n) / 10000n;
|
|
50
|
+
return await method(...args, { ...overrides, gasLimit });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Submit account creation and return immediately after broadcast. The caller
|
|
55
|
+
* owns receipt waiting and contract-state reconciliation.
|
|
56
|
+
*/
|
|
57
|
+
async submitCreateAccount(wallet) {
|
|
58
|
+
if (!wallet) throw new Error("Wallet is required");
|
|
59
|
+
const address = await wallet.getAddress();
|
|
60
|
+
const keys = await this._deriveKeys(wallet);
|
|
61
|
+
const account = await this.getAccountInfo(address);
|
|
62
|
+
if (account.exists) return { created: false, keys, tx: null, account };
|
|
63
|
+
|
|
64
|
+
const contract = this.contract.connect(wallet);
|
|
65
|
+
const tx = await this._submitWithGasSafety(
|
|
66
|
+
contract,
|
|
67
|
+
"createConfidentialAccount",
|
|
68
|
+
[Buffer.from(keys.publicKey, "base64")],
|
|
69
|
+
);
|
|
70
|
+
return { created: true, keys, tx, account };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Submit a deposit without performing an implicit ERC-20 approval. This is
|
|
75
|
+
* intentional for orchestrators: approval is a separate auditable operation.
|
|
76
|
+
*/
|
|
77
|
+
async submitDeposit(wallet, tokenAddress, amount) {
|
|
78
|
+
if (!wallet) throw new Error("Wallet is required");
|
|
79
|
+
if (!ethers.isAddress(tokenAddress)) throw new Error(`Invalid token address: ${tokenAddress}`);
|
|
80
|
+
const rawAmount = BigInt(amount);
|
|
81
|
+
if (rawAmount <= 0n) throw new Error("Amount must be greater than 0");
|
|
82
|
+
|
|
83
|
+
const address = await wallet.getAddress();
|
|
84
|
+
const token = this._getTokenContract(tokenAddress);
|
|
85
|
+
const [decimals, balance, allowance] = await Promise.all([
|
|
86
|
+
token.decimals(),
|
|
87
|
+
token.balanceOf(address),
|
|
88
|
+
token.allowance(address, this.config.contractAddress),
|
|
89
|
+
]);
|
|
90
|
+
if (balance < rawAmount) {
|
|
91
|
+
throw new Error(`Insufficient token balance. Required: ${rawAmount}, Available: ${balance}`);
|
|
92
|
+
}
|
|
93
|
+
if (allowance < rawAmount) {
|
|
94
|
+
throw new Error(`Insufficient token allowance. Required: ${rawAmount}, Available: ${allowance}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const plainAmount = toContractScale(rawAmount, decimals);
|
|
98
|
+
return await this._submitWithGasSafety(
|
|
99
|
+
this.contract.connect(wallet),
|
|
100
|
+
"deposit",
|
|
101
|
+
[tokenAddress, plainAmount],
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Submit a normal-to-normal confidential transfer after proof generation. */
|
|
106
|
+
async submitTransfer(wallet, recipientAddress, tokenAddress, amount, options = {}) {
|
|
107
|
+
if (!wallet) throw new Error("Sender wallet is required");
|
|
108
|
+
if (!ethers.isAddress(recipientAddress)) throw new Error(`Invalid recipient address: ${recipientAddress}`);
|
|
109
|
+
if (!ethers.isAddress(tokenAddress)) throw new Error(`Invalid token address: ${tokenAddress}`);
|
|
110
|
+
const rawAmount = BigInt(amount);
|
|
111
|
+
if (rawAmount <= 0n) throw new Error("Transfer amount must be greater than 0");
|
|
112
|
+
|
|
113
|
+
const offchainZKP = this._resolveOffchainZKP(options);
|
|
114
|
+
const token = this._getTokenContract(tokenAddress);
|
|
115
|
+
const tokenDecimals = await token.decimals();
|
|
116
|
+
const transferAmount = toContractScale(rawAmount, tokenDecimals);
|
|
117
|
+
const senderAddress = await wallet.getAddress();
|
|
118
|
+
|
|
119
|
+
const [senderKeys, recipientAccount] = await Promise.all([
|
|
120
|
+
this._deriveKeys(wallet),
|
|
121
|
+
this.getAccountInfo(recipientAddress),
|
|
122
|
+
]);
|
|
123
|
+
if (!senderKeys?.privateKey) throw new Error("Failed to derive sender keys");
|
|
124
|
+
if (!recipientAccount.exists || !recipientAccount.finalized) {
|
|
125
|
+
throw new Error(`Recipient account does not exist or is not finalized. Address: ${recipientAddress}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let recipientPublicKey = recipientAccount.elgamalPubkey;
|
|
129
|
+
if (!recipientPublicKey) throw new Error("Recipient public key is required");
|
|
130
|
+
if (typeof recipientPublicKey === "string" && recipientPublicKey.startsWith("0x")) {
|
|
131
|
+
recipientPublicKey = Buffer.from(recipientPublicKey.slice(2), "hex").toString("base64");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const balance = await this.getConfidentialBalance(senderAddress, senderKeys.privateKey, tokenAddress);
|
|
135
|
+
const available = balance.available.amount;
|
|
136
|
+
if (!balance.available.ciphertext) throw new Error("Current balance ciphertext is required");
|
|
137
|
+
if (available < rawAmount) {
|
|
138
|
+
throw new Error(`Insufficient balance. Required: ${rawAmount}, Available: ${available}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const currentBalanceContractScale =
|
|
142
|
+
(BigInt(available) * 100n) / 10n ** BigInt(tokenDecimals);
|
|
143
|
+
const wasm = await this._getWasm();
|
|
144
|
+
const proof = JSON.parse(
|
|
145
|
+
wasm.generate_transfer_proof(
|
|
146
|
+
JSON.stringify({
|
|
147
|
+
current_balance_ciphertext: balance.available.ciphertext,
|
|
148
|
+
current_balance: Number(currentBalanceContractScale),
|
|
149
|
+
transfer_amount: Number(transferAmount),
|
|
150
|
+
source_keypair: senderKeys.privateKey,
|
|
151
|
+
destination_pubkey: recipientPublicKey,
|
|
152
|
+
}),
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
if (!proof.success) {
|
|
156
|
+
throw new Error(`Proof generation failed: ${proof.error || "Unknown error"}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const encodedProof = ethers.getBytes(encodeTransferProof(proof.data));
|
|
160
|
+
const proofArg = await this._resolveContractProofArg(
|
|
161
|
+
encodedProof,
|
|
162
|
+
"transfer-proof.bin",
|
|
163
|
+
offchainZKP,
|
|
164
|
+
);
|
|
165
|
+
const overrides = await this._prepareNonAnonymousTransferFee(wallet);
|
|
166
|
+
return await this._submitWithGasSafety(
|
|
167
|
+
this.contract.connect(wallet),
|
|
168
|
+
"transferConfidential",
|
|
169
|
+
[recipientAddress, tokenAddress, proofArg, offchainZKP],
|
|
170
|
+
overrides,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Submit an explicit applyPending transaction and return the tx response. */
|
|
175
|
+
async submitApplyPending(wallet) {
|
|
176
|
+
if (!wallet) throw new Error("Wallet is required");
|
|
177
|
+
return await this._submitWithGasSafety(
|
|
178
|
+
this.contract.connect(wallet),
|
|
179
|
+
"applyPending",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Submit a withdrawal after proof generation and return immediately. */
|
|
184
|
+
async submitWithdraw(wallet, tokenAddress, amount, options = {}) {
|
|
185
|
+
if (!wallet) throw new Error("Wallet is required");
|
|
186
|
+
if (!ethers.isAddress(tokenAddress)) throw new Error(`Invalid token address: ${tokenAddress}`);
|
|
187
|
+
const rawAmount = BigInt(amount);
|
|
188
|
+
if (rawAmount <= 0n) throw new Error("Withdrawal amount must be greater than 0");
|
|
189
|
+
|
|
190
|
+
const offchainZKP = this._resolveOffchainZKP(options);
|
|
191
|
+
const token = this._getTokenContract(tokenAddress);
|
|
192
|
+
const tokenDecimals = await token.decimals();
|
|
193
|
+
const withdrawAmount = toContractScale(rawAmount, tokenDecimals);
|
|
194
|
+
const keys = await this._deriveKeys(wallet);
|
|
195
|
+
if (!keys?.privateKey) throw new Error("Failed to derive keys");
|
|
196
|
+
|
|
197
|
+
const address = await wallet.getAddress();
|
|
198
|
+
const balance = await this.getConfidentialBalance(address, keys.privateKey, tokenAddress);
|
|
199
|
+
if (!balance.available.ciphertext) throw new Error("Current balance ciphertext is required");
|
|
200
|
+
if (balance.available.amount < rawAmount) {
|
|
201
|
+
throw new Error(`Insufficient balance. Required: ${rawAmount}, Available: ${balance.available.amount}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const currentBalanceContractScale =
|
|
205
|
+
(BigInt(balance.available.amount) * 100n) / 10n ** BigInt(tokenDecimals);
|
|
206
|
+
const account = await this.getAccountInfo(address);
|
|
207
|
+
const txID = BigInt(account.txId ?? 0n);
|
|
208
|
+
const wasm = await this._getWasm();
|
|
209
|
+
const proof = JSON.parse(
|
|
210
|
+
wasm.generate_withdraw_proof(
|
|
211
|
+
JSON.stringify({
|
|
212
|
+
current_balance_ciphertext: balance.available.ciphertext,
|
|
213
|
+
current_balance: Number(currentBalanceContractScale),
|
|
214
|
+
withdraw_amount: Number(withdrawAmount),
|
|
215
|
+
keypair: keys.privateKey,
|
|
216
|
+
nonce: Number(txID + 1n),
|
|
217
|
+
}),
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
if (!proof.success) {
|
|
221
|
+
throw new Error(`Withdrawal proof generation failed: ${proof.error || "Unknown error"}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const encodedProof = ethers.getBytes(encodeWithdrawProof(proof.data));
|
|
225
|
+
const proofArg = await this._resolveContractProofArg(
|
|
226
|
+
encodedProof,
|
|
227
|
+
"withdraw-proof.bin",
|
|
228
|
+
offchainZKP,
|
|
229
|
+
);
|
|
230
|
+
return await this._submitWithGasSafety(
|
|
231
|
+
this.contract.connect(wallet),
|
|
232
|
+
"withdraw",
|
|
233
|
+
[tokenAddress, withdrawAmount, proofArg, offchainZKP],
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Compatibility helper that keeps the older wait-for-receipt behavior.
|
|
239
|
+
*/
|
|
240
|
+
async createAccount(wallet, options = {}) {
|
|
241
|
+
const { waitForFinalization = true } = options;
|
|
242
|
+
const submitted = await this.submitCreateAccount(wallet);
|
|
243
|
+
if (!submitted.created) {
|
|
244
|
+
let account = submitted.account;
|
|
245
|
+
if (waitForFinalization && !account.finalized) {
|
|
246
|
+
await this._waitForGlobalState(await wallet.getAddress(), "account creation");
|
|
247
|
+
account = await this.getAccountInfo(await wallet.getAddress());
|
|
248
|
+
}
|
|
249
|
+
return { created: false, keys: submitted.keys, receipt: null, account };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const receipt = await submitted.tx.wait();
|
|
253
|
+
if (!receipt || receipt.status === 0) throw new Error("Account creation transaction failed");
|
|
254
|
+
if (waitForFinalization) {
|
|
255
|
+
await this._waitForGlobalState(await wallet.getAddress(), "account creation");
|
|
256
|
+
}
|
|
257
|
+
const account = await this.getAccountInfo(await wallet.getAddress());
|
|
258
|
+
return { created: true, keys: submitted.keys, receipt, account };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Public explicit applyPending entry point preserving receipt-wait behavior. */
|
|
262
|
+
async applyPending(wallet, options = {}) {
|
|
263
|
+
return await this._applyPending(wallet, options);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { ethers } from "ethers";
|
|
2
|
+
import { ConfidentialTransferClient as BaseConfidentialTransferClient } from "./confidential-client.js";
|
|
3
|
+
import { ControlledConfidentialTransferClient as BaseControlledConfidentialTransferClient } from "./controlled-confidential-client.js";
|
|
4
|
+
import { AnonymousTransferClient as BaseAnonymousTransferClient } from "./anonymous-client.js";
|
|
5
|
+
import { encodeWithdrawProof, toContractScale, validateAnonymousAccountId } from "./utils.js";
|
|
6
|
+
import {
|
|
7
|
+
TRANSFER_CONFIDENTIAL_SIGNATURE,
|
|
8
|
+
WITHDRAW_CONFIDENTIAL_SIGNATURE,
|
|
9
|
+
FEE_TOKEN_SIGNATURE,
|
|
10
|
+
FEE_ACCOUNT_SIGNATURE,
|
|
11
|
+
NON_ANONYMOUS_TRANSFER_FEE_SIGNATURE,
|
|
12
|
+
NON_ANONYMOUS_WITHDRAW_FEE_SIGNATURE,
|
|
13
|
+
NON_ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE,
|
|
14
|
+
ANONYMOUS_IPFS_TRANSFER_FEE_SIGNATURE,
|
|
15
|
+
ANONYMOUS_INLINE_TRANSFER_FEE_SIGNATURE,
|
|
16
|
+
ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE,
|
|
17
|
+
} from "./constants.js";
|
|
18
|
+
|
|
19
|
+
export const WITHDRAW_FEE_PPM_DENOMINATOR = 1_000_000n;
|
|
20
|
+
|
|
21
|
+
function calculatePercentageFee(grossAmount, ppm) {
|
|
22
|
+
const gross = BigInt(grossAmount);
|
|
23
|
+
const rate = BigInt(ppm);
|
|
24
|
+
if (rate < 0n || rate >= WITHDRAW_FEE_PPM_DENOMINATOR) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Withdrawal fee PPM must be between 0 and ${WITHDRAW_FEE_PPM_DENOMINATOR - 1n}`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return (gross * rate) / WITHDRAW_FEE_PPM_DENOMINATOR;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function grossAfterContractScale(rawAmount, tokenDecimals) {
|
|
33
|
+
const scaled = toContractScale(rawAmount, tokenDecimals);
|
|
34
|
+
const divisor = 10n ** BigInt(tokenDecimals);
|
|
35
|
+
return (scaled * divisor) / 100n;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const nonAnonymousFeeMethods = {
|
|
39
|
+
_assertLatestContractAbi() {
|
|
40
|
+
const requiredSignatures = [
|
|
41
|
+
TRANSFER_CONFIDENTIAL_SIGNATURE,
|
|
42
|
+
WITHDRAW_CONFIDENTIAL_SIGNATURE,
|
|
43
|
+
FEE_TOKEN_SIGNATURE,
|
|
44
|
+
FEE_ACCOUNT_SIGNATURE,
|
|
45
|
+
NON_ANONYMOUS_TRANSFER_FEE_SIGNATURE,
|
|
46
|
+
NON_ANONYMOUS_WITHDRAW_FEE_SIGNATURE,
|
|
47
|
+
NON_ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE,
|
|
48
|
+
ANONYMOUS_IPFS_TRANSFER_FEE_SIGNATURE,
|
|
49
|
+
ANONYMOUS_INLINE_TRANSFER_FEE_SIGNATURE,
|
|
50
|
+
ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE,
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const missingSignatures = requiredSignatures.filter(
|
|
54
|
+
(signature) => !this.contract.interface.getFunction(signature),
|
|
55
|
+
);
|
|
56
|
+
if (missingSignatures.length > 0) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Installed @fairblock/stabletrust ABI is stale or incomplete. Missing ${missingSignatures.join(", ")}. Reinstall/update the SDK.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
async getFeeAccount() {
|
|
64
|
+
try {
|
|
65
|
+
return ethers.getAddress(await this.contract.feeAccount());
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw new Error(`Failed to get fee account: ${error.message}`);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async getNonAnonymousWithdrawFee() {
|
|
72
|
+
try {
|
|
73
|
+
return await this.contract.nonAnonymousWithdrawFee();
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new Error(`Failed to get non-anonymous withdrawal fixed fee: ${error.message}`);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async getNonAnonymousWithdrawFeePpm() {
|
|
80
|
+
try {
|
|
81
|
+
return await this.contract.nonAnonymousWithdrawFeePpm();
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw new Error(`Failed to get non-anonymous withdrawal percentage fee: ${error.message}`);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
async getAnonymousWithdrawFeePpm() {
|
|
88
|
+
try {
|
|
89
|
+
return await this.contract.anonymousWithdrawFeePpm();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new Error(`Failed to get anonymous withdrawal percentage fee: ${error.message}`);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async _prepareNonAnonymousWithdrawFee(wallet) {
|
|
96
|
+
const [feeTokenRaw, feeAmountRaw] = await Promise.all([
|
|
97
|
+
this.contract.feeToken(),
|
|
98
|
+
this.contract.nonAnonymousWithdrawFee(),
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
const feeToken = ethers.getAddress(feeTokenRaw);
|
|
102
|
+
const feeAmount = BigInt(feeAmountRaw);
|
|
103
|
+
if (feeAmount === 0n) return { value: 0n };
|
|
104
|
+
|
|
105
|
+
if (feeToken === ethers.ZeroAddress) {
|
|
106
|
+
return { value: feeAmount };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const senderAddress = await wallet.getAddress();
|
|
110
|
+
const feeTokenContract = this._getTokenContract(feeToken);
|
|
111
|
+
const [balance, allowance] = await Promise.all([
|
|
112
|
+
feeTokenContract.balanceOf(senderAddress),
|
|
113
|
+
feeTokenContract.allowance(senderAddress, this.config.contractAddress),
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
if (balance < feeAmount) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Insufficient withdrawal fee token balance. Required: ${feeAmount}, available: ${balance}, fee token: ${feeToken}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (allowance < feeAmount) {
|
|
123
|
+
const approveTx = await feeTokenContract
|
|
124
|
+
.connect(wallet)
|
|
125
|
+
.approve(this.config.contractAddress, ethers.MaxUint256);
|
|
126
|
+
const approveReceipt = await approveTx.wait();
|
|
127
|
+
if (!approveReceipt || approveReceipt.status === 0) {
|
|
128
|
+
throw new Error("Withdrawal fee token approval failed");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { value: 0n };
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async getWithdrawalFeeQuote(tokenAddress, amount) {
|
|
136
|
+
if (!ethers.isAddress(tokenAddress)) {
|
|
137
|
+
throw new Error(`Invalid token address: ${tokenAddress}`);
|
|
138
|
+
}
|
|
139
|
+
const rawAmount = BigInt(amount);
|
|
140
|
+
if (rawAmount <= 0n) throw new Error("Withdrawal amount must be greater than 0");
|
|
141
|
+
|
|
142
|
+
const token = this._getTokenContract(tokenAddress);
|
|
143
|
+
const [tokenDecimals, fixedFee, fixedFeeToken, percentageFeePpm] = await Promise.all([
|
|
144
|
+
token.decimals(),
|
|
145
|
+
this.getNonAnonymousWithdrawFee(),
|
|
146
|
+
this.getFeeToken(),
|
|
147
|
+
this.getNonAnonymousWithdrawFeePpm(),
|
|
148
|
+
]);
|
|
149
|
+
const grossAmount = grossAfterContractScale(rawAmount, tokenDecimals);
|
|
150
|
+
const estimatedPercentageFee = calculatePercentageFee(grossAmount, percentageFeePpm);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
grossAmount,
|
|
154
|
+
fixedFee: BigInt(fixedFee),
|
|
155
|
+
fixedFeeToken,
|
|
156
|
+
percentageFeePpm: BigInt(percentageFeePpm),
|
|
157
|
+
estimatedPercentageFee,
|
|
158
|
+
withdrawalToken: ethers.getAddress(tokenAddress),
|
|
159
|
+
estimatedNetAmount: grossAmount - estimatedPercentageFee,
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export class ConfidentialTransferClient extends BaseConfidentialTransferClient {
|
|
165
|
+
_assertLatestContractAbi() {
|
|
166
|
+
return nonAnonymousFeeMethods._assertLatestContractAbi.call(this);
|
|
167
|
+
}
|
|
168
|
+
getFeeAccount() { return nonAnonymousFeeMethods.getFeeAccount.call(this); }
|
|
169
|
+
getNonAnonymousWithdrawFee() { return nonAnonymousFeeMethods.getNonAnonymousWithdrawFee.call(this); }
|
|
170
|
+
getNonAnonymousWithdrawFeePpm() { return nonAnonymousFeeMethods.getNonAnonymousWithdrawFeePpm.call(this); }
|
|
171
|
+
getAnonymousWithdrawFeePpm() { return nonAnonymousFeeMethods.getAnonymousWithdrawFeePpm.call(this); }
|
|
172
|
+
_prepareNonAnonymousWithdrawFee(wallet) { return nonAnonymousFeeMethods._prepareNonAnonymousWithdrawFee.call(this, wallet); }
|
|
173
|
+
getWithdrawalFeeQuote(tokenAddress, amount) { return nonAnonymousFeeMethods.getWithdrawalFeeQuote.call(this, tokenAddress, amount); }
|
|
174
|
+
|
|
175
|
+
async withdraw(wallet, tokenAddress, amount, options = {}) {
|
|
176
|
+
const { waitForFinalization = true } = options;
|
|
177
|
+
const offchainZKP = this._resolveOffchainZKP(options);
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
if (!wallet) throw new Error("Wallet is required");
|
|
181
|
+
if (!tokenAddress || !ethers.isAddress(tokenAddress)) {
|
|
182
|
+
throw new Error(`Invalid token address: ${tokenAddress}`);
|
|
183
|
+
}
|
|
184
|
+
if (!amount || BigInt(amount) <= 0n) {
|
|
185
|
+
throw new Error("Withdrawal amount must be greater than 0");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const tokenContract = this._getTokenContract(tokenAddress);
|
|
189
|
+
const tokenDecimals = await tokenContract.decimals();
|
|
190
|
+
const withdrawAmount = toContractScale(amount, tokenDecimals);
|
|
191
|
+
|
|
192
|
+
const derivedKeys = await this._deriveKeys(wallet);
|
|
193
|
+
if (!derivedKeys?.privateKey) throw new Error("Failed to derive keys");
|
|
194
|
+
|
|
195
|
+
const address = await wallet.getAddress();
|
|
196
|
+
let balanceSummary = await this.getConfidentialBalance(
|
|
197
|
+
address,
|
|
198
|
+
derivedKeys.privateKey,
|
|
199
|
+
tokenAddress,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
if (balanceSummary.available.amount < BigInt(amount)) {
|
|
203
|
+
await this._applyPendingIfNeeded(
|
|
204
|
+
wallet,
|
|
205
|
+
derivedKeys.privateKey,
|
|
206
|
+
tokenAddress,
|
|
207
|
+
"withdraw",
|
|
208
|
+
);
|
|
209
|
+
balanceSummary = await this.getConfidentialBalance(
|
|
210
|
+
address,
|
|
211
|
+
derivedKeys.privateKey,
|
|
212
|
+
tokenAddress,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const currentBalanceCiphertext = balanceSummary.available.ciphertext;
|
|
217
|
+
const currentBalance = balanceSummary.available.amount;
|
|
218
|
+
if (!currentBalanceCiphertext) {
|
|
219
|
+
throw new Error("Current balance ciphertext is required. Did you call getConfidentialBalance()?");
|
|
220
|
+
}
|
|
221
|
+
if (balanceSummary.amount < BigInt(amount)) {
|
|
222
|
+
throw new Error(`Insufficient balance. Required: ${amount}, Total: ${balanceSummary.amount}`);
|
|
223
|
+
}
|
|
224
|
+
if (currentBalance === undefined || currentBalance < BigInt(amount)) {
|
|
225
|
+
throw new Error(`Insufficient balance. Required: ${amount}, Available: ${currentBalance}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const currentBalanceContractScale =
|
|
229
|
+
(BigInt(currentBalance) * 100n) / 10n ** BigInt(tokenDecimals);
|
|
230
|
+
const accountInfo = await this.getAccountInfo(address);
|
|
231
|
+
const txId = BigInt(accountInfo.txId ?? 0n);
|
|
232
|
+
|
|
233
|
+
const wasm = await this._getWasm();
|
|
234
|
+
const proof = JSON.parse(
|
|
235
|
+
wasm.generate_withdraw_proof(
|
|
236
|
+
JSON.stringify({
|
|
237
|
+
current_balance_ciphertext: currentBalanceCiphertext,
|
|
238
|
+
current_balance: Number(currentBalanceContractScale),
|
|
239
|
+
withdraw_amount: Number(withdrawAmount),
|
|
240
|
+
keypair: derivedKeys.privateKey,
|
|
241
|
+
nonce: Number(txId + 1n),
|
|
242
|
+
}),
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
if (!proof.success) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`Withdrawal proof generation failed: ${proof.error || "Unknown error. Check your balance and amount."}`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const encodedProof = ethers.getBytes(encodeWithdrawProof(proof.data));
|
|
252
|
+
const withdrawZkpArg = await this._resolveContractProofArg(
|
|
253
|
+
encodedProof,
|
|
254
|
+
"withdraw-proof.bin",
|
|
255
|
+
offchainZKP,
|
|
256
|
+
);
|
|
257
|
+
const txOverrides = await this._prepareNonAnonymousWithdrawFee(wallet);
|
|
258
|
+
|
|
259
|
+
const tx = await this.contract.connect(wallet).withdraw(
|
|
260
|
+
tokenAddress,
|
|
261
|
+
BigInt(withdrawAmount),
|
|
262
|
+
withdrawZkpArg,
|
|
263
|
+
offchainZKP,
|
|
264
|
+
txOverrides,
|
|
265
|
+
);
|
|
266
|
+
const receipt = await tx.wait();
|
|
267
|
+
if (!receipt || receipt.status === 0) throw new Error("Withdrawal transaction failed");
|
|
268
|
+
|
|
269
|
+
if (waitForFinalization) {
|
|
270
|
+
await this._waitForGlobalState(address, "withdraw");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const requestId = await this._resolveRequestId(receipt, address, [
|
|
274
|
+
"WithdrawRequested",
|
|
275
|
+
"WithdrawProcessed",
|
|
276
|
+
]);
|
|
277
|
+
const tokenSymbol = await tokenContract.symbol();
|
|
278
|
+
this._postRequest({
|
|
279
|
+
requestId,
|
|
280
|
+
userAddress: address.toLowerCase(),
|
|
281
|
+
operationKind: 4,
|
|
282
|
+
type: "withdraw",
|
|
283
|
+
transactionHash: receipt.hash,
|
|
284
|
+
status: "pending",
|
|
285
|
+
details: {
|
|
286
|
+
token: tokenSymbol,
|
|
287
|
+
tokenAddress,
|
|
288
|
+
amount: withdrawAmount.toString(),
|
|
289
|
+
amountInWei: true,
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
return receipt;
|
|
294
|
+
} catch (error) {
|
|
295
|
+
const message = error?.message ?? String(error);
|
|
296
|
+
if (
|
|
297
|
+
message.includes("Insufficient balance") ||
|
|
298
|
+
message.includes("fee token balance") ||
|
|
299
|
+
message.includes("proof generation failed")
|
|
300
|
+
) {
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
throw new Error(`Failed to withdraw: ${message}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export class ControlledConfidentialTransferClient extends BaseControlledConfidentialTransferClient {
|
|
309
|
+
_assertLatestContractAbi() {
|
|
310
|
+
return nonAnonymousFeeMethods._assertLatestContractAbi.call(this);
|
|
311
|
+
}
|
|
312
|
+
getFeeAccount() { return nonAnonymousFeeMethods.getFeeAccount.call(this); }
|
|
313
|
+
getNonAnonymousWithdrawFee() { return nonAnonymousFeeMethods.getNonAnonymousWithdrawFee.call(this); }
|
|
314
|
+
getNonAnonymousWithdrawFeePpm() { return nonAnonymousFeeMethods.getNonAnonymousWithdrawFeePpm.call(this); }
|
|
315
|
+
getAnonymousWithdrawFeePpm() { return nonAnonymousFeeMethods.getAnonymousWithdrawFeePpm.call(this); }
|
|
316
|
+
_prepareNonAnonymousWithdrawFee(wallet) { return nonAnonymousFeeMethods._prepareNonAnonymousWithdrawFee.call(this, wallet); }
|
|
317
|
+
getWithdrawalFeeQuote(tokenAddress, amount) { return nonAnonymousFeeMethods.getWithdrawalFeeQuote.call(this, tokenAddress, amount); }
|
|
318
|
+
|
|
319
|
+
async _submitWithGasSafety(contract, methodName, args = [], overrides = {}) {
|
|
320
|
+
let resolvedOverrides = overrides;
|
|
321
|
+
if (methodName === "withdraw" && overrides.value === undefined) {
|
|
322
|
+
const feeOverrides = await this._prepareNonAnonymousWithdrawFee(contract.runner);
|
|
323
|
+
resolvedOverrides = { ...feeOverrides, ...overrides };
|
|
324
|
+
}
|
|
325
|
+
return await super._submitWithGasSafety(
|
|
326
|
+
contract,
|
|
327
|
+
methodName,
|
|
328
|
+
args,
|
|
329
|
+
resolvedOverrides,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export class AnonymousTransferClient extends BaseAnonymousTransferClient {
|
|
335
|
+
async getFeeAccount() {
|
|
336
|
+
try {
|
|
337
|
+
const data = await this._fetch("GET", "/v1/views/fee-account");
|
|
338
|
+
const raw = data.result ?? data;
|
|
339
|
+
const addr = typeof raw === "string" ? raw : raw.feeAccount ?? raw;
|
|
340
|
+
return ethers.getAddress(addr);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
throw new Error(`Failed to get fee account: ${error.message}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async getNonAnonymousWithdrawFee() {
|
|
347
|
+
try {
|
|
348
|
+
const data = await this._fetch("GET", "/v1/views/fees/non-anonymous-withdraw-fixed");
|
|
349
|
+
return BigInt(data.result ?? data);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
throw new Error(`Failed to get non-anonymous withdrawal fixed fee: ${error.message}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async getNonAnonymousWithdrawFeePpm() {
|
|
356
|
+
try {
|
|
357
|
+
const data = await this._fetch("GET", "/v1/views/fees/non-anonymous-withdraw-ppm");
|
|
358
|
+
return BigInt(data.result ?? data);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
throw new Error(`Failed to get non-anonymous withdrawal percentage fee: ${error.message}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async getAnonymousWithdrawFeePpm() {
|
|
365
|
+
try {
|
|
366
|
+
const data = await this._fetch("GET", "/v1/views/fees/anonymous-withdraw-ppm");
|
|
367
|
+
return BigInt(data.result ?? data);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
throw new Error(`Failed to get anonymous withdrawal percentage fee: ${error.message}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async _assertSufficientWithdrawPrepaidFees(accountId, offchainZKP) {
|
|
374
|
+
validateAnonymousAccountId(accountId, "accountId");
|
|
375
|
+
const fixedFeePromise = offchainZKP
|
|
376
|
+
? this.getAnonymousIpfsWithdrawFee()
|
|
377
|
+
: this.getAnonymousInlineWithdrawFee();
|
|
378
|
+
const [feeToken, fixedFee] = await Promise.all([
|
|
379
|
+
this.getFeeToken(),
|
|
380
|
+
fixedFeePromise,
|
|
381
|
+
]);
|
|
382
|
+
if (fixedFee === 0n) return;
|
|
383
|
+
|
|
384
|
+
const balance = await this.getPrepaidFeeBalance(accountId, feeToken);
|
|
385
|
+
if (balance < fixedFee) {
|
|
386
|
+
const path = offchainZKP ? "IPFS" : "inline";
|
|
387
|
+
throw new Error(
|
|
388
|
+
`Insufficient prepaid fee balance for anonymous account "${accountId}" (${path} withdrawal). ` +
|
|
389
|
+
`Required: ${fixedFee} (raw units of fee token ${feeToken}), available: ${balance}. ` +
|
|
390
|
+
`The success-only percentage withdrawal fee is not part of this prepaid requirement.`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async getAnonymousWithdrawalFeeQuote(tokenAddress, amount, { offchainZKP = false } = {}) {
|
|
396
|
+
if (!ethers.isAddress(tokenAddress)) {
|
|
397
|
+
throw new Error(`Invalid token address: ${tokenAddress}`);
|
|
398
|
+
}
|
|
399
|
+
const rawAmount = BigInt(amount);
|
|
400
|
+
if (rawAmount <= 0n) throw new Error("Withdrawal amount must be greater than 0");
|
|
401
|
+
|
|
402
|
+
const fixedFeePromise = offchainZKP
|
|
403
|
+
? this.getAnonymousIpfsWithdrawFee()
|
|
404
|
+
: this.getAnonymousInlineWithdrawFee();
|
|
405
|
+
const [tokenDecimals, fixedFee, fixedFeeToken, percentageFeePpm] = await Promise.all([
|
|
406
|
+
this._fetchTokenDecimals(tokenAddress),
|
|
407
|
+
fixedFeePromise,
|
|
408
|
+
this.getFeeToken(),
|
|
409
|
+
this.getAnonymousWithdrawFeePpm(),
|
|
410
|
+
]);
|
|
411
|
+
const grossAmount = grossAfterContractScale(rawAmount, tokenDecimals);
|
|
412
|
+
const estimatedPercentageFee = calculatePercentageFee(grossAmount, percentageFeePpm);
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
grossAmount,
|
|
416
|
+
fixedFee: BigInt(fixedFee),
|
|
417
|
+
fixedFeeToken,
|
|
418
|
+
percentageFeePpm: BigInt(percentageFeePpm),
|
|
419
|
+
estimatedPercentageFee,
|
|
420
|
+
withdrawalToken: ethers.getAddress(tokenAddress),
|
|
421
|
+
estimatedNetAmount: grossAmount - estimatedPercentageFee,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async withdraw(authWallet, accountId, params, options = {}) {
|
|
426
|
+
const offchainZKP = Boolean(params?.offchainZKP ?? false);
|
|
427
|
+
await this._assertSufficientWithdrawPrepaidFees(accountId, offchainZKP);
|
|
428
|
+
return await super.withdraw(authWallet, accountId, params, options);
|
|
429
|
+
}
|
|
430
|
+
}
|
package/src/ibe.js
CHANGED
|
@@ -10,6 +10,16 @@ function extractPublicKey(pubkeyObj) {
|
|
|
10
10
|
return pubkeyObj?.public_key || pubkeyObj?.pubkey || null;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Build IBE identity: address (lowercase) + chain public key.
|
|
15
|
+
* @param {string} userAddress
|
|
16
|
+
* @param {string} publicKey
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
function buildIbeId(userAddress, publicKey) {
|
|
20
|
+
return userAddress.toLowerCase() + publicKey;
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
async function getIbePublicKeys() {
|
|
14
24
|
if (cachedIbePublicKeys) return cachedIbePublicKeys;
|
|
15
25
|
|
|
@@ -40,20 +50,37 @@ async function getIbePublicKeys() {
|
|
|
40
50
|
* Always encrypts under the active pubkey; also encrypts under the queued
|
|
41
51
|
* pubkey when the chain reports one (backup for key rotation).
|
|
42
52
|
*
|
|
43
|
-
*
|
|
53
|
+
* Identity is address.toLowerCase() + chain pk (active or queued).
|
|
54
|
+
*
|
|
55
|
+
* @param {string} userAddress - Wallet address used as part of the IBE identity
|
|
44
56
|
* @param {string} randomnessBase64 - Base64-encoded randomness
|
|
45
|
-
* @returns {Promise<{
|
|
46
|
-
*
|
|
57
|
+
* @returns {Promise<{
|
|
58
|
+
* encrypted: string,
|
|
59
|
+
* encryptedId: string,
|
|
60
|
+
* queuedEncrypted: string | null,
|
|
61
|
+
* queuedEncryptedId: string | null
|
|
62
|
+
* }>}
|
|
63
|
+
* Hex-encoded IBE ciphertexts plus the identities used for each
|
|
47
64
|
*/
|
|
48
65
|
export async function encryptRandomness(userAddress, randomnessBase64) {
|
|
49
66
|
const { active, queued } = await getIbePublicKeys();
|
|
50
|
-
const id = userAddress.toLowerCase();
|
|
51
67
|
const randomnessBuffer = Buffer.from(randomnessBase64, "base64");
|
|
52
68
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
// Active ciphertext → identity address.toLowerCase() + activePk
|
|
70
|
+
const encryptedId = buildIbeId(userAddress, active);
|
|
71
|
+
const encrypted = await timelockEncrypt(encryptedId, active, randomnessBuffer);
|
|
72
|
+
|
|
73
|
+
// Queued ciphertext → identity address.toLowerCase() + queuedPk
|
|
74
|
+
let queuedEncrypted = null;
|
|
75
|
+
let queuedEncryptedId = null;
|
|
76
|
+
if (queued) {
|
|
77
|
+
queuedEncryptedId = buildIbeId(userAddress, queued);
|
|
78
|
+
queuedEncrypted = await timelockEncrypt(
|
|
79
|
+
queuedEncryptedId,
|
|
80
|
+
queued,
|
|
81
|
+
randomnessBuffer,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
57
84
|
|
|
58
|
-
return { encrypted, queuedEncrypted };
|
|
85
|
+
return { encrypted, encryptedId, queuedEncrypted, queuedEncryptedId };
|
|
59
86
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -554,6 +554,8 @@ declare module "@fairblock/stabletrust" {
|
|
|
554
554
|
ipfsApiKey?: string;
|
|
555
555
|
/** StableTrust backend API base URL for persisting request rows. Defaults to the production API. */
|
|
556
556
|
apiBaseUrl?: string;
|
|
557
|
+
/** App name stored on request rows. Defaults to "SDK". */
|
|
558
|
+
applicationName?: string;
|
|
557
559
|
}
|
|
558
560
|
|
|
559
561
|
/**
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
1
|
+
export {
|
|
2
|
+
ConfidentialTransferClient,
|
|
3
|
+
ControlledConfidentialTransferClient,
|
|
4
|
+
AnonymousTransferClient,
|
|
5
|
+
WITHDRAW_FEE_PPM_DENOMINATOR,
|
|
6
|
+
} from "./fee-aware-clients.js";
|
|
3
7
|
export { deriveKeys, decryptCiphertext, combineCiphertext } from "./crypto.js";
|
|
4
8
|
export {
|
|
5
9
|
encodeTransferProof,
|
|
@@ -13,9 +17,13 @@ export {
|
|
|
13
17
|
TRANSFER_CONFIDENTIAL_SIGNATURE,
|
|
14
18
|
WITHDRAW_CONFIDENTIAL_SIGNATURE,
|
|
15
19
|
FEE_TOKEN_SIGNATURE,
|
|
20
|
+
FEE_ACCOUNT_SIGNATURE,
|
|
16
21
|
NON_ANONYMOUS_TRANSFER_FEE_SIGNATURE,
|
|
22
|
+
NON_ANONYMOUS_WITHDRAW_FEE_SIGNATURE,
|
|
23
|
+
NON_ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE,
|
|
17
24
|
ANONYMOUS_IPFS_TRANSFER_FEE_SIGNATURE,
|
|
18
25
|
ANONYMOUS_INLINE_TRANSFER_FEE_SIGNATURE,
|
|
26
|
+
ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE,
|
|
19
27
|
STABLETRUST_CONTRACTS_BY_CHAIN_ID,
|
|
20
28
|
getStabletrustContractAddress,
|
|
21
29
|
} from "./constants.js";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/// <reference path="./index.d.ts" />
|
|
2
|
+
|
|
3
|
+
declare module "@fairblock/stabletrust" {
|
|
4
|
+
export interface WithdrawalFeeQuote {
|
|
5
|
+
/** Gross withdrawn-token amount after the SDK's 2-decimal contract-scale normalization. */
|
|
6
|
+
grossAmount: bigint;
|
|
7
|
+
/** Fixed request fee, charged even if the later callback fails. */
|
|
8
|
+
fixedFee: bigint;
|
|
9
|
+
/** Token used for the fixed request fee. ethers.ZeroAddress means native currency. */
|
|
10
|
+
fixedFeeToken: string;
|
|
11
|
+
/** Success-only withdrawal percentage fee rate, in parts per million. */
|
|
12
|
+
percentageFeePpm: bigint;
|
|
13
|
+
/** Estimated success-only percentage fee, denominated in the withdrawn token. */
|
|
14
|
+
estimatedPercentageFee: bigint;
|
|
15
|
+
/** Token being withdrawn. */
|
|
16
|
+
withdrawalToken: string;
|
|
17
|
+
/** Estimated amount delivered on a successful callback. */
|
|
18
|
+
estimatedNetAmount: bigint;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AnonymousWithdrawalQuoteOptions {
|
|
22
|
+
/** Selects the IPFS/offchainZKP fixed withdrawal fee when true; inline fee otherwise. */
|
|
23
|
+
offchainZKP?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ConfidentialTransferClient {
|
|
27
|
+
/** Get the global fee recipient. */
|
|
28
|
+
getFeeAccount(): Promise<string>;
|
|
29
|
+
/** Get the fixed non-anonymous withdrawal request fee. */
|
|
30
|
+
getNonAnonymousWithdrawFee(): Promise<bigint>;
|
|
31
|
+
/** Get the success-only non-anonymous withdrawal rate in PPM. */
|
|
32
|
+
getNonAnonymousWithdrawFeePpm(): Promise<bigint>;
|
|
33
|
+
/** Get the shared anonymous success-only withdrawal rate in PPM. */
|
|
34
|
+
getAnonymousWithdrawFeePpm(): Promise<bigint>;
|
|
35
|
+
/** Quote fixed and percentage fees for a non-anonymous withdrawal. */
|
|
36
|
+
getWithdrawalFeeQuote(
|
|
37
|
+
tokenAddress: string,
|
|
38
|
+
amount: bigint | string | number,
|
|
39
|
+
): Promise<WithdrawalFeeQuote>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ControlledConfidentialTransferClient {
|
|
43
|
+
/** Get the global fee recipient. */
|
|
44
|
+
getFeeAccount(): Promise<string>;
|
|
45
|
+
/** Get the fixed non-anonymous withdrawal request fee. */
|
|
46
|
+
getNonAnonymousWithdrawFee(): Promise<bigint>;
|
|
47
|
+
/** Get the success-only non-anonymous withdrawal rate in PPM. */
|
|
48
|
+
getNonAnonymousWithdrawFeePpm(): Promise<bigint>;
|
|
49
|
+
/** Get the shared anonymous success-only withdrawal rate in PPM. */
|
|
50
|
+
getAnonymousWithdrawFeePpm(): Promise<bigint>;
|
|
51
|
+
/** Quote fixed and percentage fees for a non-anonymous withdrawal. */
|
|
52
|
+
getWithdrawalFeeQuote(
|
|
53
|
+
tokenAddress: string,
|
|
54
|
+
amount: bigint | string | number,
|
|
55
|
+
): Promise<WithdrawalFeeQuote>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AnonymousTransferClient {
|
|
59
|
+
/** Get the global fee recipient through Fairycloak. */
|
|
60
|
+
getFeeAccount(): Promise<string>;
|
|
61
|
+
/** Get the fixed non-anonymous withdrawal request fee through Fairycloak. */
|
|
62
|
+
getNonAnonymousWithdrawFee(): Promise<bigint>;
|
|
63
|
+
/** Get the non-anonymous success-only withdrawal rate in PPM through Fairycloak. */
|
|
64
|
+
getNonAnonymousWithdrawFeePpm(): Promise<bigint>;
|
|
65
|
+
/** Get the shared anonymous success-only withdrawal rate in PPM through Fairycloak. */
|
|
66
|
+
getAnonymousWithdrawFeePpm(): Promise<bigint>;
|
|
67
|
+
/**
|
|
68
|
+
* Quote an anonymous withdrawal. The fixed fee is the path-specific prepaid
|
|
69
|
+
* request fee; the percentage fee is success-only and comes from the withdrawn token.
|
|
70
|
+
*/
|
|
71
|
+
getAnonymousWithdrawalFeeQuote(
|
|
72
|
+
tokenAddress: string,
|
|
73
|
+
amount: bigint | string | number,
|
|
74
|
+
options?: AnonymousWithdrawalQuoteOptions,
|
|
75
|
+
): Promise<WithdrawalFeeQuote>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const WITHDRAW_FEE_PPM_DENOMINATOR: bigint;
|
|
79
|
+
export const FEE_ACCOUNT_SIGNATURE: string;
|
|
80
|
+
export const NON_ANONYMOUS_WITHDRAW_FEE_SIGNATURE: string;
|
|
81
|
+
export const NON_ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE: string;
|
|
82
|
+
export const ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE: string;
|
|
83
|
+
}
|