@soteria1/sdk 0.1.0
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/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/confidential/confidential.d.ts +142 -0
- package/dist/confidential/confidential.js +266 -0
- package/dist/confidential/index.d.ts +1 -0
- package/dist/confidential/index.js +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +24 -0
- package/dist/pool/crypto.d.ts +17 -0
- package/dist/pool/crypto.js +79 -0
- package/dist/pool/index.d.ts +4 -0
- package/dist/pool/index.js +4 -0
- package/dist/pool/note.d.ts +22 -0
- package/dist/pool/note.js +66 -0
- package/dist/pool/pdas.d.ts +54 -0
- package/dist/pool/pdas.js +109 -0
- package/dist/pool/prover.d.ts +44 -0
- package/dist/pool/prover.js +73 -0
- package/dist/shielded/index.d.ts +5 -0
- package/dist/shielded/index.js +5 -0
- package/dist/shielded/instruction.d.ts +20 -0
- package/dist/shielded/instruction.js +50 -0
- package/dist/shielded/keypair.d.ts +23 -0
- package/dist/shielded/keypair.js +72 -0
- package/dist/shielded/note.d.ts +22 -0
- package/dist/shielded/note.js +32 -0
- package/dist/shielded/prover.d.ts +46 -0
- package/dist/shielded/prover.js +90 -0
- package/dist/shielded/scan.d.ts +21 -0
- package/dist/shielded/scan.js +28 -0
- package/dist/stealth/index.d.ts +2 -0
- package/dist/stealth/index.js +2 -0
- package/dist/stealth/scanner.d.ts +20 -0
- package/dist/stealth/scanner.js +31 -0
- package/dist/stealth/stealth.d.ts +46 -0
- package/dist/stealth/stealth.js +119 -0
- package/dist/zk/index.d.ts +2 -0
- package/dist/zk/index.js +2 -0
- package/dist/zk/merkle.d.ts +36 -0
- package/dist/zk/merkle.js +92 -0
- package/dist/zk/prover.d.ts +50 -0
- package/dist/zk/prover.js +87 -0
- package/package.json +65 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Soteria
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @soteria1/sdk
|
|
2
|
+
|
|
3
|
+
Client SDK for **Soteria** — privacy primitives for Solana. Runs in the browser
|
|
4
|
+
(keys never leave the device) or Node.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install @soteria1/sdk
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Modules
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { shielded, pool, zk, stealth, confidential } from "@soteria1/sdk";
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
| Module | What it does |
|
|
17
|
+
|--------|--------------|
|
|
18
|
+
| **`shielded`** | Hidden-amount UTXO payments: deposit any amount, pay anyone (amounts encrypted), change + multi-recipient, scan for incoming notes. |
|
|
19
|
+
| **`pool`** | Fixed-denomination compliant privacy pool (ZK deposit/withdraw, association set). |
|
|
20
|
+
| **`zk`** | ZK selective disclosure: prove set membership without revealing identity; Poseidon Merkle tree. |
|
|
21
|
+
| **`stealth`** | Dual-key stealth addresses (one-time receive addresses). |
|
|
22
|
+
| **`confidential`** | Token-2022 confidential-transfer helpers (hidden amounts with an auditor key). |
|
|
23
|
+
|
|
24
|
+
## Example — a private payment (shielded)
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { shielded } from "@soteria1/sdk";
|
|
28
|
+
|
|
29
|
+
// derive a recoverable shielded identity from a wallet signature
|
|
30
|
+
const me = await shielded.deriveShieldedKeypair(signature);
|
|
31
|
+
const address = shielded.encodeShieldedAddress(me); // share this to get paid
|
|
32
|
+
|
|
33
|
+
// build a transfer proof (amounts hidden, change handled)
|
|
34
|
+
const tx = await shielded.buildTransaction({
|
|
35
|
+
inputs, outputs, spendKeypair: me,
|
|
36
|
+
extAmount: 0n, fee: 5000n, recipient, relayer, root,
|
|
37
|
+
wasmPath: "/transaction.wasm", zkeyPath: "/transaction_final.zkey",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// find notes paid to you
|
|
41
|
+
const mine = await shielded.scanOutputs(records, me);
|
|
42
|
+
const total = shielded.balance(mine);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Proof artifacts (`transaction.wasm`, `transaction_final.zkey`) come from the
|
|
46
|
+
project's trusted setup (`scripts/setup-transaction.sh`); serve them statically.
|
|
47
|
+
|
|
48
|
+
## ⚠️ Status
|
|
49
|
+
|
|
50
|
+
Experimental. The ZK circuits are **unaudited** and the trusted setups are
|
|
51
|
+
single-contributor (dev). **Do not use with real funds** without a professional
|
|
52
|
+
audit and a real multi-party ceremony.
|
|
53
|
+
|
|
54
|
+
MIT licensed.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confidential amounts via the Token-2022 Confidential Transfer extension.
|
|
3
|
+
*
|
|
4
|
+
* Hides transfer AMOUNTS and balances (not the transfer graph) using twisted
|
|
5
|
+
* ElGamal encryption + ZK proofs verified on-chain by Solana's ZK ElGamal Proof
|
|
6
|
+
* program. A mint-level `auditor` ElGamal key can decrypt every amount for
|
|
7
|
+
* compliance — wire it in via `createMint({ auditorElGamalPubkey })`.
|
|
8
|
+
*
|
|
9
|
+
* This module uses the @solana/kit (web3.js v2) stack internally because the
|
|
10
|
+
* confidential-transfer instruction + proof helpers ship for it. Callers work
|
|
11
|
+
* with plain values (base58 addresses, bigint amounts) and the zk-sdk key
|
|
12
|
+
* objects; no kit knowledge is required.
|
|
13
|
+
*
|
|
14
|
+
* Proofs that exceed transaction size (transfer, withdraw) are verified into
|
|
15
|
+
* dedicated context-state accounts, created and torn down automatically by the
|
|
16
|
+
* underlying instruction-plan helpers.
|
|
17
|
+
*/
|
|
18
|
+
import { type Address, type KeyPairSigner, type TransactionSigner } from "@solana/kit";
|
|
19
|
+
import { AeKey, ElGamalKeypair } from "@solana/zk-sdk/bundler";
|
|
20
|
+
export { AeKey, ElGamalKeypair };
|
|
21
|
+
/** Encode a 32-byte ElGamal public key as a base58 Address. */
|
|
22
|
+
export declare function elGamalPubkeyToAddress(keypair: ElGamalKeypair): Address;
|
|
23
|
+
/** Signs an arbitrary message and returns a 64-byte ed25519 signature. */
|
|
24
|
+
export type SignMessage = (message: Uint8Array) => Promise<Uint8Array> | Uint8Array;
|
|
25
|
+
export interface AccountKeys {
|
|
26
|
+
elgamalKeypair: ElGamalKeypair;
|
|
27
|
+
aesKey: AeKey;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Deterministically derive an account's confidential keys from the owner's
|
|
31
|
+
* signature over a domain-separated message, bound to `(owner, mint)`.
|
|
32
|
+
*
|
|
33
|
+
* The same `(sign, owner, mint)` always yields the same keys, so an owner can
|
|
34
|
+
* recover them from their wallet alone — nothing secret is ever stored or
|
|
35
|
+
* transmitted, and the binding prevents key reuse across mints. In the browser,
|
|
36
|
+
* pass a wallet adapter's `signMessage`; on a server, sign with the owner's
|
|
37
|
+
* ed25519 secret key.
|
|
38
|
+
*/
|
|
39
|
+
export declare function deriveAccountKeys(params: {
|
|
40
|
+
sign: SignMessage;
|
|
41
|
+
owner: Address;
|
|
42
|
+
mint: Address;
|
|
43
|
+
}): Promise<AccountKeys>;
|
|
44
|
+
export interface ConfidentialClientConfig {
|
|
45
|
+
/** Solana JSON-RPC HTTP endpoint, e.g. https://api.devnet.solana.com */
|
|
46
|
+
rpcUrl: string;
|
|
47
|
+
/** Fee payer; signs and pays for every transaction. */
|
|
48
|
+
payer: KeyPairSigner;
|
|
49
|
+
}
|
|
50
|
+
export interface CreateMintParams {
|
|
51
|
+
mint: KeyPairSigner;
|
|
52
|
+
decimals: number;
|
|
53
|
+
mintAuthority: TransactionSigner;
|
|
54
|
+
/** Auditor ElGamal pubkey (compliance). Pass null to run without an auditor. */
|
|
55
|
+
auditorElGamalPubkey?: Address | null;
|
|
56
|
+
/** When true, accounts may transact immediately without authority approval. */
|
|
57
|
+
autoApproveNewAccounts?: boolean;
|
|
58
|
+
}
|
|
59
|
+
export interface ConfigureAccountParams {
|
|
60
|
+
owner: TransactionSigner;
|
|
61
|
+
mint: Address;
|
|
62
|
+
elgamalKeypair: ElGamalKeypair;
|
|
63
|
+
aesKey: AeKey;
|
|
64
|
+
maximumPendingBalanceCreditCounter?: number | bigint;
|
|
65
|
+
}
|
|
66
|
+
export interface DepositParams {
|
|
67
|
+
token: Address;
|
|
68
|
+
mint: Address;
|
|
69
|
+
authority: TransactionSigner;
|
|
70
|
+
amount: bigint;
|
|
71
|
+
decimals: number;
|
|
72
|
+
}
|
|
73
|
+
export interface ApplyPendingParams {
|
|
74
|
+
token: Address;
|
|
75
|
+
authority: TransactionSigner;
|
|
76
|
+
elgamalKeypair: ElGamalKeypair;
|
|
77
|
+
aesKey: AeKey;
|
|
78
|
+
}
|
|
79
|
+
export interface TransferParams {
|
|
80
|
+
source: Address;
|
|
81
|
+
destination: Address;
|
|
82
|
+
mint: Address;
|
|
83
|
+
authority: TransactionSigner;
|
|
84
|
+
amount: bigint;
|
|
85
|
+
sourceElgamalKeypair: ElGamalKeypair;
|
|
86
|
+
sourceAesKey: AeKey;
|
|
87
|
+
auditorElGamalPubkey?: Address;
|
|
88
|
+
}
|
|
89
|
+
export interface WithdrawParams {
|
|
90
|
+
token: Address;
|
|
91
|
+
mint: Address;
|
|
92
|
+
authority: TransactionSigner;
|
|
93
|
+
amount: bigint;
|
|
94
|
+
decimals: number;
|
|
95
|
+
elgamalKeypair: ElGamalKeypair;
|
|
96
|
+
aesKey: AeKey;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A confidential-transfer client bound to one RPC endpoint and fee payer.
|
|
100
|
+
* Each method submits the on-chain transaction(s) for one lifecycle step.
|
|
101
|
+
*/
|
|
102
|
+
export declare class ConfidentialClient {
|
|
103
|
+
private readonly rpc;
|
|
104
|
+
private readonly payer;
|
|
105
|
+
private readonly planner;
|
|
106
|
+
private readonly executor;
|
|
107
|
+
constructor(config: ConfidentialClientConfig);
|
|
108
|
+
/** Poll signature status until confirmed (WebSocket-free). */
|
|
109
|
+
private confirm;
|
|
110
|
+
private run;
|
|
111
|
+
/** Fetch a decoded token account, retrying transient RPC failures. */
|
|
112
|
+
private fetchTokenAccount;
|
|
113
|
+
/** Associated token address for an owner on a given mint. */
|
|
114
|
+
associatedTokenAddress(owner: Address, mint: Address): Promise<Address>;
|
|
115
|
+
/** Create a Token-2022 mint with the confidential-transfer extension + auditor. */
|
|
116
|
+
createMint(params: CreateMintParams): Promise<Address>;
|
|
117
|
+
/**
|
|
118
|
+
* Configure a token account for confidential transfers (creates the ATA,
|
|
119
|
+
* reallocates, configures, and verifies the pubkey-validity proof).
|
|
120
|
+
* Returns the associated token address.
|
|
121
|
+
*/
|
|
122
|
+
configureAccount(params: ConfigureAccountParams): Promise<Address>;
|
|
123
|
+
/** Mint public (plaintext) tokens to a token account. */
|
|
124
|
+
mintTo(params: {
|
|
125
|
+
mint: Address;
|
|
126
|
+
token: Address;
|
|
127
|
+
mintAuthority: TransactionSigner;
|
|
128
|
+
amount: bigint;
|
|
129
|
+
}): Promise<void>;
|
|
130
|
+
/** Move a public balance into the encrypted pending balance. */
|
|
131
|
+
deposit(params: DepositParams): Promise<void>;
|
|
132
|
+
/** Move the encrypted pending balance into the spendable available balance. */
|
|
133
|
+
applyPending(params: ApplyPendingParams): Promise<void>;
|
|
134
|
+
/** Confidentially transfer an amount between two accounts (amount hidden). */
|
|
135
|
+
transfer(params: TransferParams): Promise<void>;
|
|
136
|
+
/** Move an encrypted available balance back to a public (plaintext) balance. */
|
|
137
|
+
withdraw(params: WithdrawParams): Promise<void>;
|
|
138
|
+
/** The plaintext (public) token amount visible on-chain. */
|
|
139
|
+
getPublicAmount(token: Address): Promise<bigint>;
|
|
140
|
+
/** Decrypt an account's available balance with its owner's AES key. */
|
|
141
|
+
decryptAvailableBalance(token: Address, aesKey: AeKey): Promise<bigint>;
|
|
142
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confidential amounts via the Token-2022 Confidential Transfer extension.
|
|
3
|
+
*
|
|
4
|
+
* Hides transfer AMOUNTS and balances (not the transfer graph) using twisted
|
|
5
|
+
* ElGamal encryption + ZK proofs verified on-chain by Solana's ZK ElGamal Proof
|
|
6
|
+
* program. A mint-level `auditor` ElGamal key can decrypt every amount for
|
|
7
|
+
* compliance — wire it in via `createMint({ auditorElGamalPubkey })`.
|
|
8
|
+
*
|
|
9
|
+
* This module uses the @solana/kit (web3.js v2) stack internally because the
|
|
10
|
+
* confidential-transfer instruction + proof helpers ship for it. Callers work
|
|
11
|
+
* with plain values (base58 addresses, bigint amounts) and the zk-sdk key
|
|
12
|
+
* objects; no kit knowledge is required.
|
|
13
|
+
*
|
|
14
|
+
* Proofs that exceed transaction size (transfer, withdraw) are verified into
|
|
15
|
+
* dedicated context-state accounts, created and torn down automatically by the
|
|
16
|
+
* underlying instruction-plan helpers.
|
|
17
|
+
*/
|
|
18
|
+
import { createSolanaRpc, createTransactionMessage, createTransactionPlanExecutor, createTransactionPlanner, getAddressDecoder, getAddressEncoder, getBase64EncodedWireTransaction, getSignatureFromTransaction, pipe, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, signTransactionMessageWithSigners, singleInstructionPlan, some, } from "@solana/kit";
|
|
19
|
+
import { TOKEN_2022_PROGRAM_ADDRESS, extension, fetchToken, findAssociatedTokenPda, getConfidentialDepositInstruction, getCreateMintInstructionPlan, getMintToInstruction, } from "@solana-program/token-2022";
|
|
20
|
+
import { getApplyConfidentialPendingBalanceInstructionFromToken, getConfidentialTransferInstructionPlan, getConfidentialWithdrawInstructionPlan, getCreateConfidentialTransferAccountInstructionPlan, } from "@solana-program/token-2022/confidential";
|
|
21
|
+
import { AeCiphertext, AeKey, ElGamalKeypair } from "@solana/zk-sdk/bundler";
|
|
22
|
+
export { AeKey, ElGamalKeypair };
|
|
23
|
+
/** Encode a 32-byte ElGamal public key as a base58 Address. */
|
|
24
|
+
export function elGamalPubkeyToAddress(keypair) {
|
|
25
|
+
return getAddressDecoder().decode(keypair.pubkey().toBytes());
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Deterministically derive an account's confidential keys from the owner's
|
|
29
|
+
* signature over a domain-separated message, bound to `(owner, mint)`.
|
|
30
|
+
*
|
|
31
|
+
* The same `(sign, owner, mint)` always yields the same keys, so an owner can
|
|
32
|
+
* recover them from their wallet alone — nothing secret is ever stored or
|
|
33
|
+
* transmitted, and the binding prevents key reuse across mints. In the browser,
|
|
34
|
+
* pass a wallet adapter's `signMessage`; on a server, sign with the owner's
|
|
35
|
+
* ed25519 secret key.
|
|
36
|
+
*/
|
|
37
|
+
export async function deriveAccountKeys(params) {
|
|
38
|
+
const encode = getAddressEncoder();
|
|
39
|
+
const seed = new Uint8Array([
|
|
40
|
+
...encode.encode(params.owner),
|
|
41
|
+
...encode.encode(params.mint),
|
|
42
|
+
]);
|
|
43
|
+
const elgamalKeypair = ElGamalKeypair.fromSignature(await params.sign(Uint8Array.from(ElGamalKeypair.signerMessage(seed))));
|
|
44
|
+
const aesKey = AeKey.fromSignature(await params.sign(Uint8Array.from(AeKey.signerMessage(seed))));
|
|
45
|
+
return { elgamalKeypair, aesKey };
|
|
46
|
+
}
|
|
47
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
48
|
+
/** Retry transient RPC failures (rate limits, network blips) with backoff. */
|
|
49
|
+
async function withRetry(fn, attempts = 8) {
|
|
50
|
+
let delay = 500;
|
|
51
|
+
let lastErr;
|
|
52
|
+
for (let i = 0; i < attempts; i++) {
|
|
53
|
+
try {
|
|
54
|
+
return await fn();
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
lastErr = err;
|
|
58
|
+
const msg = String(err?.message ?? err);
|
|
59
|
+
const retriable = /429|Too Many Requests|fetch failed|ENOTFOUND|ETIMEDOUT|50[023]|socket|network/i.test(msg);
|
|
60
|
+
if (!retriable || i === attempts - 1)
|
|
61
|
+
throw err;
|
|
62
|
+
await sleep(delay);
|
|
63
|
+
delay = Math.min(delay * 2, 8000);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
throw lastErr;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A confidential-transfer client bound to one RPC endpoint and fee payer.
|
|
70
|
+
* Each method submits the on-chain transaction(s) for one lifecycle step.
|
|
71
|
+
*/
|
|
72
|
+
export class ConfidentialClient {
|
|
73
|
+
constructor(config) {
|
|
74
|
+
const { rpcUrl, payer } = config;
|
|
75
|
+
this.payer = payer;
|
|
76
|
+
this.rpc = createSolanaRpc(rpcUrl);
|
|
77
|
+
this.planner = createTransactionPlanner({
|
|
78
|
+
createTransactionMessage: () => pipe(createTransactionMessage({ version: 0 }), (m) => setTransactionMessageFeePayerSigner(payer, m)),
|
|
79
|
+
});
|
|
80
|
+
// Confirm via RPC polling rather than a WebSocket subscription: devnet WS
|
|
81
|
+
// is flaky under the transfer's parallel proof sends, and polling works
|
|
82
|
+
// identically in Node and the browser.
|
|
83
|
+
this.executor = createTransactionPlanExecutor({
|
|
84
|
+
executeTransactionMessage: async (_ctx, message) => {
|
|
85
|
+
const { value: blockhash } = await withRetry(() => this.rpc.getLatestBlockhash().send());
|
|
86
|
+
const signed = await signTransactionMessageWithSigners(setTransactionMessageLifetimeUsingBlockhash(blockhash, message));
|
|
87
|
+
const signature = getSignatureFromTransaction(signed);
|
|
88
|
+
const wire = getBase64EncodedWireTransaction(signed);
|
|
89
|
+
await withRetry(() => this.rpc
|
|
90
|
+
.sendTransaction(wire, {
|
|
91
|
+
encoding: "base64",
|
|
92
|
+
preflightCommitment: "confirmed",
|
|
93
|
+
})
|
|
94
|
+
.send());
|
|
95
|
+
await this.confirm(signature);
|
|
96
|
+
return signature;
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** Poll signature status until confirmed (WebSocket-free). */
|
|
101
|
+
async confirm(signature, timeoutMs = 60000) {
|
|
102
|
+
const deadline = Date.now() + timeoutMs;
|
|
103
|
+
while (Date.now() < deadline) {
|
|
104
|
+
const { value } = await withRetry(() => this.rpc
|
|
105
|
+
.getSignatureStatuses([
|
|
106
|
+
signature,
|
|
107
|
+
])
|
|
108
|
+
.send());
|
|
109
|
+
const status = value[0];
|
|
110
|
+
if (status) {
|
|
111
|
+
if (status.err) {
|
|
112
|
+
throw new Error(`transaction ${signature} failed: ${JSON.stringify(status.err)}`);
|
|
113
|
+
}
|
|
114
|
+
if (status.confirmationStatus === "confirmed" ||
|
|
115
|
+
status.confirmationStatus === "finalized") {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
await sleep(1500);
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`timed out confirming transaction ${signature}`);
|
|
122
|
+
}
|
|
123
|
+
async run(plan) {
|
|
124
|
+
await this.executor(await this.planner(plan));
|
|
125
|
+
}
|
|
126
|
+
/** Fetch a decoded token account, retrying transient RPC failures. */
|
|
127
|
+
async fetchTokenAccount(token) {
|
|
128
|
+
return (await withRetry(() => fetchToken(this.rpc, token))).data;
|
|
129
|
+
}
|
|
130
|
+
/** Associated token address for an owner on a given mint. */
|
|
131
|
+
async associatedTokenAddress(owner, mint) {
|
|
132
|
+
const [pda] = await findAssociatedTokenPda({
|
|
133
|
+
owner,
|
|
134
|
+
tokenProgram: TOKEN_2022_PROGRAM_ADDRESS,
|
|
135
|
+
mint,
|
|
136
|
+
});
|
|
137
|
+
return pda;
|
|
138
|
+
}
|
|
139
|
+
/** Create a Token-2022 mint with the confidential-transfer extension + auditor. */
|
|
140
|
+
async createMint(params) {
|
|
141
|
+
const { mint, decimals, mintAuthority, auditorElGamalPubkey = null, autoApproveNewAccounts = true, } = params;
|
|
142
|
+
await this.run(getCreateMintInstructionPlan({
|
|
143
|
+
payer: this.payer,
|
|
144
|
+
newMint: mint,
|
|
145
|
+
decimals,
|
|
146
|
+
mintAuthority,
|
|
147
|
+
extensions: [
|
|
148
|
+
extension("ConfidentialTransferMint", {
|
|
149
|
+
authority: some(mintAuthority.address),
|
|
150
|
+
autoApproveNewAccounts,
|
|
151
|
+
auditorElgamalPubkey: auditorElGamalPubkey
|
|
152
|
+
? some(auditorElGamalPubkey)
|
|
153
|
+
: null,
|
|
154
|
+
}),
|
|
155
|
+
],
|
|
156
|
+
}));
|
|
157
|
+
return mint.address;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Configure a token account for confidential transfers (creates the ATA,
|
|
161
|
+
* reallocates, configures, and verifies the pubkey-validity proof).
|
|
162
|
+
* Returns the associated token address.
|
|
163
|
+
*/
|
|
164
|
+
async configureAccount(params) {
|
|
165
|
+
const { owner, mint, elgamalKeypair, aesKey } = params;
|
|
166
|
+
await this.run(await getCreateConfidentialTransferAccountInstructionPlan({
|
|
167
|
+
payer: this.payer,
|
|
168
|
+
owner,
|
|
169
|
+
mint,
|
|
170
|
+
rpc: this.rpc,
|
|
171
|
+
elgamalKeypair,
|
|
172
|
+
aesKey,
|
|
173
|
+
maximumPendingBalanceCreditCounter: params.maximumPendingBalanceCreditCounter,
|
|
174
|
+
}));
|
|
175
|
+
return this.associatedTokenAddress(owner.address, mint);
|
|
176
|
+
}
|
|
177
|
+
/** Mint public (plaintext) tokens to a token account. */
|
|
178
|
+
async mintTo(params) {
|
|
179
|
+
await this.run(singleInstructionPlan(getMintToInstruction({
|
|
180
|
+
mint: params.mint,
|
|
181
|
+
token: params.token,
|
|
182
|
+
mintAuthority: params.mintAuthority,
|
|
183
|
+
amount: params.amount,
|
|
184
|
+
})));
|
|
185
|
+
}
|
|
186
|
+
/** Move a public balance into the encrypted pending balance. */
|
|
187
|
+
async deposit(params) {
|
|
188
|
+
await this.run(singleInstructionPlan(getConfidentialDepositInstruction({
|
|
189
|
+
token: params.token,
|
|
190
|
+
mint: params.mint,
|
|
191
|
+
authority: params.authority,
|
|
192
|
+
amount: params.amount,
|
|
193
|
+
decimals: params.decimals,
|
|
194
|
+
})));
|
|
195
|
+
}
|
|
196
|
+
/** Move the encrypted pending balance into the spendable available balance. */
|
|
197
|
+
async applyPending(params) {
|
|
198
|
+
const account = await this.fetchTokenAccount(params.token);
|
|
199
|
+
await this.run(singleInstructionPlan(getApplyConfidentialPendingBalanceInstructionFromToken({
|
|
200
|
+
token: params.token,
|
|
201
|
+
tokenAccount: account,
|
|
202
|
+
authority: params.authority,
|
|
203
|
+
elgamalSecretKey: params.elgamalKeypair.secret(),
|
|
204
|
+
aesKey: params.aesKey,
|
|
205
|
+
})));
|
|
206
|
+
}
|
|
207
|
+
/** Confidentially transfer an amount between two accounts (amount hidden). */
|
|
208
|
+
async transfer(params) {
|
|
209
|
+
const sourceAccount = await this.fetchTokenAccount(params.source);
|
|
210
|
+
const destinationAccount = await this.fetchTokenAccount(params.destination);
|
|
211
|
+
await this.run(await getConfidentialTransferInstructionPlan({
|
|
212
|
+
sourceToken: params.source,
|
|
213
|
+
mint: params.mint,
|
|
214
|
+
destinationToken: params.destination,
|
|
215
|
+
sourceTokenAccount: sourceAccount,
|
|
216
|
+
destinationTokenAccount: destinationAccount,
|
|
217
|
+
auditorElgamalPubkey: params.auditorElGamalPubkey,
|
|
218
|
+
authority: params.authority,
|
|
219
|
+
amount: params.amount,
|
|
220
|
+
sourceElgamalKeypair: params.sourceElgamalKeypair,
|
|
221
|
+
aesKey: params.sourceAesKey,
|
|
222
|
+
payer: this.payer,
|
|
223
|
+
rpc: this.rpc,
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
/** Move an encrypted available balance back to a public (plaintext) balance. */
|
|
227
|
+
async withdraw(params) {
|
|
228
|
+
const account = await this.fetchTokenAccount(params.token);
|
|
229
|
+
await this.run(await getConfidentialWithdrawInstructionPlan({
|
|
230
|
+
token: params.token,
|
|
231
|
+
mint: params.mint,
|
|
232
|
+
tokenAccount: account,
|
|
233
|
+
authority: params.authority,
|
|
234
|
+
amount: params.amount,
|
|
235
|
+
decimals: params.decimals,
|
|
236
|
+
elgamalKeypair: params.elgamalKeypair,
|
|
237
|
+
aesKey: params.aesKey,
|
|
238
|
+
payer: this.payer,
|
|
239
|
+
rpc: this.rpc,
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
/** The plaintext (public) token amount visible on-chain. */
|
|
243
|
+
async getPublicAmount(token) {
|
|
244
|
+
return (await this.fetchTokenAccount(token)).amount;
|
|
245
|
+
}
|
|
246
|
+
/** Decrypt an account's available balance with its owner's AES key. */
|
|
247
|
+
async decryptAvailableBalance(token, aesKey) {
|
|
248
|
+
const account = await this.fetchTokenAccount(token);
|
|
249
|
+
// `extensions` is a kit Option<Extension[]>; unwrap defensively (shape is
|
|
250
|
+
// dynamic on-chain data, so we read it untyped).
|
|
251
|
+
const raw = account.extensions;
|
|
252
|
+
const list = Array.isArray(raw)
|
|
253
|
+
? raw
|
|
254
|
+
: raw && raw.__option === "Some"
|
|
255
|
+
? raw.value
|
|
256
|
+
: [];
|
|
257
|
+
const ct = list.find((e) => e.__kind === "ConfidentialTransferAccount");
|
|
258
|
+
if (!ct) {
|
|
259
|
+
throw new Error("account is not configured for confidential transfers");
|
|
260
|
+
}
|
|
261
|
+
const cipher = AeCiphertext.fromBytes(Uint8Array.from(ct.decryptableAvailableBalance));
|
|
262
|
+
if (!cipher)
|
|
263
|
+
throw new Error("failed to decode available-balance ciphertext");
|
|
264
|
+
return aesKey.decrypt(cipher);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./confidential.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./confidential.js";
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Soteria SDK — privacy primitives for Solana.
|
|
3
|
+
*
|
|
4
|
+
* zk Selective disclosure: prove membership/eligibility without
|
|
5
|
+
* revealing which identity. (mainnet-ready)
|
|
6
|
+
* stealth One-time receive addresses so a main wallet isn't exposed.
|
|
7
|
+
* (mainnet-ready)
|
|
8
|
+
* confidential Token-2022 confidential amounts with an auditor key.
|
|
9
|
+
* (verified end-to-end on devnet via ConfidentialClient)
|
|
10
|
+
* pool Compliant fixed-denomination privacy pool: ZK deposit/withdraw
|
|
11
|
+
* that severs the on-chain link, gated by an association set and
|
|
12
|
+
* auditable via a curated root. (path C — needs setup-pool.sh)
|
|
13
|
+
* shielded Hidden-amount UTXO pool (Option B): arbitrary amounts, partial
|
|
14
|
+
* spends, change, multi-recipient — values stay encrypted.
|
|
15
|
+
* (needs setup-transaction.sh; verified on devnet)
|
|
16
|
+
*
|
|
17
|
+
* Every privacy feature keeps a disclosure/audit path: the pool is gated by an
|
|
18
|
+
* association set rather than being an unconditional tumbler.
|
|
19
|
+
*/
|
|
20
|
+
export * as zk from "./zk/index.js";
|
|
21
|
+
export * as stealth from "./stealth/index.js";
|
|
22
|
+
export * as confidential from "./confidential/index.js";
|
|
23
|
+
export * as pool from "./pool/index.js";
|
|
24
|
+
export * as shielded from "./shielded/index.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Soteria SDK — privacy primitives for Solana.
|
|
3
|
+
*
|
|
4
|
+
* zk Selective disclosure: prove membership/eligibility without
|
|
5
|
+
* revealing which identity. (mainnet-ready)
|
|
6
|
+
* stealth One-time receive addresses so a main wallet isn't exposed.
|
|
7
|
+
* (mainnet-ready)
|
|
8
|
+
* confidential Token-2022 confidential amounts with an auditor key.
|
|
9
|
+
* (verified end-to-end on devnet via ConfidentialClient)
|
|
10
|
+
* pool Compliant fixed-denomination privacy pool: ZK deposit/withdraw
|
|
11
|
+
* that severs the on-chain link, gated by an association set and
|
|
12
|
+
* auditable via a curated root. (path C — needs setup-pool.sh)
|
|
13
|
+
* shielded Hidden-amount UTXO pool (Option B): arbitrary amounts, partial
|
|
14
|
+
* spends, change, multi-recipient — values stay encrypted.
|
|
15
|
+
* (needs setup-transaction.sh; verified on devnet)
|
|
16
|
+
*
|
|
17
|
+
* Every privacy feature keeps a disclosure/audit path: the pool is gated by an
|
|
18
|
+
* association set rather than being an unconditional tumbler.
|
|
19
|
+
*/
|
|
20
|
+
export * as zk from "./zk/index.js";
|
|
21
|
+
export * as stealth from "./stealth/index.js";
|
|
22
|
+
export * as confidential from "./confidential/index.js";
|
|
23
|
+
export * as pool from "./pool/index.js";
|
|
24
|
+
export * as shielded from "./shielded/index.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive a recipient's X25519 receiving keypair from a 32+ byte seed — e.g. the
|
|
3
|
+
* bytes of a wallet signature over a fixed message, so the identity is
|
|
4
|
+
* recoverable from the wallet alone and nothing is ever stored.
|
|
5
|
+
*/
|
|
6
|
+
export declare function receiveKeypairFromSeed(seed: Uint8Array): {
|
|
7
|
+
priv: Uint8Array;
|
|
8
|
+
pub: Uint8Array;
|
|
9
|
+
};
|
|
10
|
+
/** Shareable encoding of a receiving public key (a "private-payment address"). */
|
|
11
|
+
export declare function encodeReceiveAddress(pub: Uint8Array): string;
|
|
12
|
+
export declare function decodeReceiveAddress(s: string): Uint8Array;
|
|
13
|
+
/** Encrypt a note string to a recipient's receiving public key. */
|
|
14
|
+
export declare function encryptNote(noteStr: string, recipientPub: Uint8Array): Promise<string>;
|
|
15
|
+
export declare function isEncryptedNote(s: string): boolean;
|
|
16
|
+
/** Decrypt an encrypted note with the recipient's receiving private key. */
|
|
17
|
+
export declare function decryptNote(blobStr: string, recipientPriv: Uint8Array): Promise<string>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { x25519 } from "@noble/curves/ed25519";
|
|
2
|
+
import { hkdf } from "@noble/hashes/hkdf";
|
|
3
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
4
|
+
// Encrypt a pool note to a recipient so a claim link can travel over a public
|
|
5
|
+
// channel: only the holder of the matching private key can decrypt it. ECIES =
|
|
6
|
+
// ephemeral X25519 ECDH -> HKDF-SHA256 -> AES-256-GCM. No extra deps (WebCrypto
|
|
7
|
+
// is present in the browser and in Node 20+).
|
|
8
|
+
const ENC_PREFIX = "soteria-enc-v1";
|
|
9
|
+
function b64url(bytes) {
|
|
10
|
+
let s = "";
|
|
11
|
+
for (const b of bytes)
|
|
12
|
+
s += String.fromCharCode(b);
|
|
13
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
14
|
+
}
|
|
15
|
+
function unb64url(s) {
|
|
16
|
+
const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
17
|
+
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
|
|
18
|
+
const bin = atob(b64 + pad);
|
|
19
|
+
const out = new Uint8Array(bin.length);
|
|
20
|
+
for (let i = 0; i < bin.length; i++)
|
|
21
|
+
out[i] = bin.charCodeAt(i);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
// A concrete ArrayBuffer copy, so the WebCrypto BufferSource types don't trip on
|
|
25
|
+
// the ArrayBufferLike/SharedArrayBuffer generic.
|
|
26
|
+
function buf(u) {
|
|
27
|
+
return u.slice().buffer;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Derive a recipient's X25519 receiving keypair from a 32+ byte seed — e.g. the
|
|
31
|
+
* bytes of a wallet signature over a fixed message, so the identity is
|
|
32
|
+
* recoverable from the wallet alone and nothing is ever stored.
|
|
33
|
+
*/
|
|
34
|
+
export function receiveKeypairFromSeed(seed) {
|
|
35
|
+
const priv = sha256(seed); // 32 bytes; X25519 clamps internally
|
|
36
|
+
return { priv, pub: x25519.getPublicKey(priv) };
|
|
37
|
+
}
|
|
38
|
+
/** Shareable encoding of a receiving public key (a "private-payment address"). */
|
|
39
|
+
export function encodeReceiveAddress(pub) {
|
|
40
|
+
return b64url(pub);
|
|
41
|
+
}
|
|
42
|
+
export function decodeReceiveAddress(s) {
|
|
43
|
+
const pub = unb64url(s.trim());
|
|
44
|
+
if (pub.length !== 32)
|
|
45
|
+
throw new Error("invalid private-payment address");
|
|
46
|
+
return pub;
|
|
47
|
+
}
|
|
48
|
+
async function aesKey(shared) {
|
|
49
|
+
const raw = hkdf(sha256, shared, undefined, "soteria-pool-note", 32);
|
|
50
|
+
return crypto.subtle.importKey("raw", buf(raw), "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
51
|
+
}
|
|
52
|
+
/** Encrypt a note string to a recipient's receiving public key. */
|
|
53
|
+
export async function encryptNote(noteStr, recipientPub) {
|
|
54
|
+
const eph = x25519.utils.randomPrivateKey();
|
|
55
|
+
const ephPub = x25519.getPublicKey(eph);
|
|
56
|
+
const shared = x25519.getSharedSecret(eph, recipientPub);
|
|
57
|
+
const key = await aesKey(shared);
|
|
58
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
59
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, buf(new TextEncoder().encode(noteStr))));
|
|
60
|
+
const blob = new Uint8Array(32 + 12 + ct.length);
|
|
61
|
+
blob.set(ephPub, 0);
|
|
62
|
+
blob.set(iv, 32);
|
|
63
|
+
blob.set(ct, 44);
|
|
64
|
+
return `${ENC_PREFIX}:${b64url(blob)}`;
|
|
65
|
+
}
|
|
66
|
+
export function isEncryptedNote(s) {
|
|
67
|
+
return s.trim().startsWith(ENC_PREFIX + ":");
|
|
68
|
+
}
|
|
69
|
+
/** Decrypt an encrypted note with the recipient's receiving private key. */
|
|
70
|
+
export async function decryptNote(blobStr, recipientPriv) {
|
|
71
|
+
const blob = unb64url(blobStr.trim().slice(ENC_PREFIX.length + 1));
|
|
72
|
+
const ephPub = blob.slice(0, 32);
|
|
73
|
+
const iv = blob.slice(32, 44);
|
|
74
|
+
const ct = blob.slice(44);
|
|
75
|
+
const shared = x25519.getSharedSecret(recipientPriv, ephPub);
|
|
76
|
+
const key = await aesKey(shared);
|
|
77
|
+
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: buf(iv) }, key, buf(ct));
|
|
78
|
+
return new TextDecoder().decode(pt);
|
|
79
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pool note. Whoever holds (nullifier, secret) can withdraw the deposit whose
|
|
3
|
+
* commitment = Poseidon(nullifier, secret). Losing it loses the funds — there is
|
|
4
|
+
* no server-side copy.
|
|
5
|
+
*/
|
|
6
|
+
export interface Note {
|
|
7
|
+
poolId: bigint;
|
|
8
|
+
nullifier: bigint;
|
|
9
|
+
secret: bigint;
|
|
10
|
+
}
|
|
11
|
+
/** Generate a fresh note for a pool. */
|
|
12
|
+
export declare function randomNote(poolId: bigint | number): Note;
|
|
13
|
+
/** Note commitment = Poseidon(nullifier, secret) — the tree leaf. */
|
|
14
|
+
export declare function commitment(note: Note): Promise<bigint>;
|
|
15
|
+
/** nullifierHash = Poseidon(nullifier) — revealed at withdraw, public. */
|
|
16
|
+
export declare function nullifierHash(note: Note): Promise<bigint>;
|
|
17
|
+
/** Serialize a note to the backup string the user must save to claim funds. */
|
|
18
|
+
export declare function encodeNote(note: Note): string;
|
|
19
|
+
/** Parse a backup string back into a note. */
|
|
20
|
+
export declare function decodeNote(s: string): Note;
|
|
21
|
+
/** 32-byte big-endian encoding of a field element (tree leaf / PDA seed). */
|
|
22
|
+
export declare function toBytes32(v: bigint): Uint8Array;
|