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:
constnonce=Uint8Array.from([1,2,3,4,5]);constencryptionid=toHex(newUint8Array([...allowbytes,...nonce]));constfileData=toUint8array(string);const{encryptedObject:encryptedBytes}=awaitsealnewclient.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 small nonce to form a unique encryptionid.
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
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, and sessionKey.
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 same sessionKey.
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:
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):
upload(fileBuffer)
Sends a PUT request to PUBLISHER/v1/blobs with fileBuffer (the ciphertext from Seal).
If the blob is already stored, Walrus returns alreadyCertified.blobId; otherwise, it returns newlyCreated.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(...).