Integrate Phantom Keys
Phantom keys are the secret material that links a commit to its reveal. They encode all the cryptographic inputs needed to generate a zero-knowledge proof and claim committed tokens. This guide covers programmatic generation, export formats, and import methods.
Anatomy of a Phantom Key
A phantom key contains the following fields:
| Field | Type | Description |
|---|---|---|
secret | bigint | Random scalar in the BN254 field. Primary secret for the commitment. |
nullifierSecret | bigint | Random scalar. Used to derive the nullifier that prevents double-reveals. |
blinding | bigint | Random scalar. Provides additional entropy to the commitment hash. |
tokenIdHash | bigint | Poseidon2 hash of the token address. Identifies which token is committed. |
amount | bigint | The committed token amount in smallest units (e.g., aghost for GHOST). |
commitment | bigint | The Poseidon7 hash of all fields. Stored on-chain in the Merkle tree. |
leafIndex | uint32 | The index of the commitment in the on-chain Merkle tree. |
quantumSecret | string | A 256-bit random hex string reserved for future quantum-resistant upgrades. |
policyId | address | The address of the policy contract (or 0x0 for no policy). |
policyParamsHash | bigint | keccak256 hash of the ABI-encoded policy parameters. |
Step 1: Generate Random Secrets
All secrets must be random scalars within the BN254 field. The field prime is:
p = 21888242871839275222246405745257275088548364400416034343698204186575808495617
Generate each secret as a cryptographically random 254-bit value reduced modulo p:
import crypto from "crypto";
const BN254_FIELD_PRIME = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
function randomFieldElement() {
// Generate 32 random bytes (256 bits), then reduce mod p
const buf = crypto.randomBytes(32);
const value = BigInt("0x" + buf.toString("hex"));
return value % BN254_FIELD_PRIME;
}
const secret = randomFieldElement();
const nullifierSecret = randomFieldElement();
const blinding = randomFieldElement();
For the quantumSecret, generate a full 256-bit random hex string:
const quantumSecret = "0x" + crypto.randomBytes(32).toString("hex");
Step 2: Compute tokenIdHash
The tokenIdHash identifies the token within the commitment. It is computed as a Poseidon hash with 2 inputs (Poseidon2, using the T3 circuit):
import { buildPoseidon } from "circomlibjs";
const poseidon = await buildPoseidon();
// tokenIdHash = Poseidon2(tokenAddress, 0)
// Convert the token address to a BigInt
const tokenAddress = BigInt("0x908aA11Dc9F2e2C3F69892acaDE112e831c0a14a");
const tokenIdHash = poseidon.F.toObject(poseidon([tokenAddress, 0n]));
console.log("tokenIdHash:", tokenIdHash.toString());
For native GHOST, use the NativeAssetHandler address (0xA0bA5389b07BAdDAaE89B8560849774Bf015acc3) as the token address.
Step 3: Compute the Commitment
The commitment is a Poseidon hash with 7 inputs (Poseidon7, using the T8 circuit):
// commitment = Poseidon7(secret, nullifierSecret, blinding, tokenIdHash, amount, policyId, policyParamsHash)
const policyId = 0n; // No policy
const policyParamsHash = 0n; // No policy params
const commitment = poseidon.F.toObject(
poseidon([
secret,
nullifierSecret,
blinding,
tokenIdHash,
amount,
policyId,
policyParamsHash,
])
);
console.log("commitment:", commitment.toString());
The resulting commitment is a single field element that is submitted on-chain during the commit transaction and inserted into the Merkle tree.
Step 4: Assemble the Phantom Key
Combine all values into the ghostchain-v2 key format:
const phantomKey = {
version: "ghostchain-v2",
token: tokenAddress.toString(),
seed: null, // Not used in v2; reserved for HD derivation
secret: secret.toString(),
nullifierSecret: nullifierSecret.toString(),
blinding: blinding.toString(),
amount: amount.toString(),
commitment: commitment.toString(),
leafIndex: null, // Set after the commit transaction confirms
tokenIdHash: tokenIdHash.toString(),
quantumSecret: quantumSecret,
policyId: "0x0000000000000000000000000000000000000000",
policyParamsHash: policyParamsHash.toString(),
policyParams: null,
};
After the commit transaction confirms and the commitment is inserted into the Merkle tree, update the leafIndex:
// Parse the leafIndex from the Commit event
const receipt = await commitTx.wait();
const commitEvent = receipt.logs.find(/* parse CommitmentInserted event */);
phantomKey.leafIndex = commitEvent.args.leafIndex;
Step 5: Export the Phantom Key
Phantom keys can be exported in three formats for different use cases.
JSON Export
The canonical format. Used for clipboard copy, file storage, and programmatic import:
const jsonString = JSON.stringify(phantomKey, null, 2);
// Save to file, copy to clipboard, or transmit securely
QR Code Export (PNG)
Encode the JSON as a QR code for scanning with the Specter webapp or a mobile app:
import QRCode from "qrcode";
// Generate a QR code as a PNG buffer
const pngBuffer = await QRCode.toBuffer(JSON.stringify(phantomKey), {
errorCorrectionLevel: "M",
type: "png",
width: 512,
margin: 2,
});
// Write to file
import fs from "fs";
fs.writeFileSync("phantom-key.png", pngBuffer);
For large phantom keys (e.g., those with policy parameters), the QR code may become dense. Use error correction level "L" and increase the width to maintain scannability.
Numeric Export (Fallback)
For environments where QR scanning and JSON pasting are not available (e.g., paper backup, phone input), export the key as a compact numeric string. This encodes the essential fields as a hyphen-separated decimal string:
function exportNumeric(key) {
// Encode essential fields as decimal strings separated by hyphens
const parts = [
key.secret,
key.nullifierSecret,
key.blinding,
key.amount,
key.leafIndex ?? "0",
key.tokenIdHash,
key.commitment,
];
return parts.join("-");
}
const numericKey = exportNumeric(phantomKey);
console.log(numericKey);
// "12345678901234567890-98765432109876543210-..."
The numeric format omits the quantumSecret, policyId, and policyParams fields. It is suitable only for simple, policy-free commitments.
Step 6: Import Phantom Keys
JSON Import
Parse the JSON string and validate all required fields:
function importPhantomKey(jsonString) {
const key = JSON.parse(jsonString);
if (key.version !== "ghostchain-v2") {
throw new Error(`Unsupported key version: ${key.version}`);
}
// Convert string fields back to BigInt
return {
...key,
secret: BigInt(key.secret),
nullifierSecret: BigInt(key.nullifierSecret),
blinding: BigInt(key.blinding),
amount: BigInt(key.amount),
commitment: BigInt(key.commitment),
tokenIdHash: BigInt(key.tokenIdHash),
policyParamsHash: BigInt(key.policyParamsHash),
};
}
QR Code Import
Use a QR scanner library to decode the image, then parse as JSON:
import jsQR from "jsqr";
function importFromQR(imageData, width, height) {
const code = jsQR(imageData, width, height);
if (!code) throw new Error("No QR code found in image");
return importPhantomKey(code.data);
}
NFC Import
For NFC-enabled hardware cards (NTAG 424 DNA), the phantom key is stored in the card's NDEF payload. Use the Web NFC API or a native NFC library to read the card:
if ("NDEFReader" in window) {
const reader = new NDEFReader();
await reader.scan();
reader.onreading = (event) => {
for (const record of event.message.records) {
if (record.recordType === "text") {
const decoder = new TextDecoder();
const jsonString = decoder.decode(record.data);
const key = importPhantomKey(jsonString);
console.log("Imported from NFC:", key.commitment.toString());
}
}
};
}
Security Best Practices
- Never transmit phantom keys over insecure channels. A phantom key is equivalent to a private key for the committed tokens. Anyone with the key can reveal the tokens.
- Delete keys after reveal. Once tokens are revealed, the nullifier prevents double-spending, but the key still contains sensitive cryptographic material.
- Use hardware-backed storage (NFC cards, secure enclaves) for high-value commitments.
- Validate commitments. After importing a key, recompute the commitment from the secret fields and verify it matches the stored
commitmentvalue before attempting a reveal.
Reference: Token ID Hashes
Pre-computed tokenIdHash values for deployed tokens:
| Token | Address | tokenIdHash |
|---|---|---|
| GHOST | 0xA0bA5389b07BAdDAaE89B8560849774Bf015acc3 | Poseidon2(address, 0) |
| gLABS | 0x062f8a68f6386c1b448b3379abd369825bec9aa2 | Poseidon2(address, 0) |
| gUSDC | 0x65c9091a6A45Db302a343AF460657C298FAA222D | Poseidon2(address, 0) |
| gWETH | 0x923295a3e3bE5eDe29Fc408A507dA057ee044E81 | Poseidon2(address, 0) |
| gVIRTUAL | 0xaF12d2f962179274f243986604F97b961a4f4Cfc | Poseidon2(address, 0) |
Compute the exact numeric value using the code in Step 2, substituting each token's address.