⚙️Technologies
Tech that made it Possible
This page mentions how we used the Different technologies like , ZKlogin , Seal and Walrus . The decryption only happens when the NFT owner/minter adds the wallet address to the allowlist , otherwise it would show that the key is not available error in the console .
1. Seal Encryption & Decryption
When the user tries to mint an NFT , on clicking the final publish button , We encrypt the obfuscated image (its tiles and coordinates) using Sui Seal, upload the ciphertext to Walrus, then allow authorized users to fetch and decrypt. In the frontend, the flow looks like:
const nonce = Uint8Array.from([1, 2, 3, 4, 5]);
const encryptionid = toHex(new Uint8Array([...allowbytes, ...nonce]));
const fileData = toUint8array(string);
const { encryptedObject: encryptedBytes } = await sealnewclient.encrypt({
threshold: 2,
packageId: packagid,
id: encryptionid,
data: fileData,
});
console.log("Encrypted bytes:", encryptedBytes);
// encryptedBytes → to be uploaded to Walrus
Nonce & Encryption ID
We combine
allowbytes
(the buyer’s allowlist aka the allowlist_id from the contract) with a smallnonce
to form a uniqueencryptionid
.encryptionid
is the key under which Seal will store and fetch the encryption keys.
sealnewclient.encrypt({ … })
threshold: 2
means at least 2 key shares are required to decrypt.packageId: packagid
points to our on-chain Seal package.id: encryptionid
is the unique identifier for this encryption session.data: fileData
is the raw Uint8Array of the obfuscated image tiles + coordinates.encryptedBytes
is the resulting ciphertext blob that we then upload to Walrus.
Decryption & Key Fetching Flow
const session_key = new SessionKey({
address: '<buyer-zklogin-address>',
packageId: packageid,
ttlMin: 20,
client : <TESTNET_NODE_URL>
// the client has to be added for it to work for a zklogin address
});
signPersonalMessage(
{ message: session_key.getPersonalMessage() },
{
onSuccess: async (result: { signature: string }) => {
await session_key.setPersonalMessageSignature(result.signature);
// 1. Call the allowlist’s moveCall to approve decryption
tx.moveCall({
target: `${packagid}::allowlist::seal_approve`,
arguments: [
tx.pure.vector('u8', fromHex(encryptionid)),
tx.object(allowlistobject)
],
});
// 2. Build the transaction bytes (no broadcast)
const txbytes = await tx.build({ client, onlyTransactionKind: true });
// 3. Fetch the decryption keys for `encryptionid`
const fetching_keys = await sealnewclient.fetchKeys({
ids: [encryptionid],
txBytes: txbytes,
sessionKey: session_key,
threshold: 2
});
console.log("Fetched keys:", fetching_keys);
// 4. Decrypt the ciphertext
const decrypteddata = await sealnewclient.decrypt({
data: encryptedBytes,
sessionKey: session_key,
txBytes: txbytes,
});
// Persist session key for later use
set('sessionKey', session_key.export());
console.log("Decrypted data:", decrypteddata);
},
onError: (error: Error) => {
console.error("Error signing personal message:", error);
}
}
);
signPersonalMessage
Prompts the buyer’s wallet to sign
session_key.getPersonalMessage()
.
Move Call to
seal_approve
We invoke the on-chain function
allowlist::seal_approve
(in our Allowlist contract) with:encryptionid
(as a byte-vector)allowlistobject
(the object ID of the buyer’s Allowlist entry).
This Move call ensures that the buyer’s address is now permitted to decrypt the ciphertext.
sealnewclient.fetchKeys({ … })
Passes in
ids: [encryptionid]
,txBytes
, andsessionKey
.Seal returns the decryption key shares once the on-chain approval is confirmed.
sealnewclient.decrypt({ … })
Finally decrypts
encryptedBytes
(the obfuscated image tiles + coordinates) using the fetched keys and the samesessionKey
.decrypteddata
is now the original obfuscated payload, ready for client-side reconstruction into the full image.
2. ZKlogin Integration via Enoki
Instead of using the native ZKlogin SDK (which gave us serialization/deserialization errors), we opted for Enoki Wallets—a drop-in wrapper that handles the OAuth flow and returns a ZKlogin-compatible address. In our React app:
function RegisterEnokiWallets() {
const { client, network } = useSuiClientContext();
useEffect(() => {
if (!isEnokiNetwork(network)) return;
const { unregister } = registerEnokiWallets({
apiKey: 'ENOKI_API_KEY',
providers: {
google: {
clientId: 'OAUTH_CLIENT_ID',
},
// additional providers (Facebook, Twitch) can be added here
},
client,
network,
});
return unregister;
}, [client, network]);
return null;
}
registerEnokiWallets({ … })
Registers Google (and optionally Facebook, Twitch) as identity providers.
Returns a React hook (
unregister
) to clean up listeners on unmount.Once invoked, users can click “Login with Google” (or another provider) to obtain a zk-anonymous address.
This address is exactly what we pass into
new SessionKey({ address: '<enoki-address>', … })
for Seal.
3. Walrus Upload & Download
This is the standard API that has been used everywhere in the app . The flow goes like this that after encryption, we push the ciphertext to Walrus via a standard HTTP API. On the backend (Node.js / Express):
const AGGREGATOR = "https://aggregator.walrus-testnet.walrus.space";
const PUBLISHER = "https://publisher.walrus-testnet.walrus.space";
const app = express();
app.use(cors());
app.use(bodyParser.json({ limit: '50mb' }));
async function upload(fileBuffer) {
try {
const url = `${PUBLISHER}/v1/blobs`;
const response = await axios({
method: 'put',
url: url,
data: fileBuffer,
headers: { 'Content-Type': 'application/octet-stream' }
});
const jsonResponse = response.data;
console.log(response);
if (jsonResponse.alreadyCertified) {
return jsonResponse.alreadyCertified.blobId;
}
return jsonResponse.newlyCreated.blobObject.blobId;
} catch (error) {
console.error(`Error uploading file: ${error.message}`);
throw error;
}
}
async function get(blobId, savePath) {
try {
const url = `${AGGREGATOR}/v1/blobs/${blobId}`;
const response = await axios({
method: 'get',
url: url,
responseType: 'arraybuffer'
});
await require('fs').promises.writeFile(savePath, response.data);
return true;
} catch (error) {
console.error(`Error downloading blob: ${error.message}`);
return false;
}
}
upload(fileBuffer)
Sends a
PUT
request toPUBLISHER/v1/blobs
withfileBuffer
(the ciphertext from Seal).If the blob is already stored, Walrus returns
alreadyCertified.blobId
; otherwise, it returnsnewlyCreated.blobObject.blobId
.We store that
blobId
on-chain (in our NFT or Allowlist contract).
get(blobId, savePath)
Fetches the raw ciphertext via
GET AGGREGATOR/v1/blobs/${blobId}
as an ArrayBuffer.Saves the response locally (for backend processing or caching).
In the frontend, instead of writing to disk, we’d fetch the ArrayBuffer and pass it to
sealnewclient.decrypt(...)
.
Last updated