Upload an encrypted file
Encrypt a file locally with AES-256-GCM, gate decryption on live onchain roles, and verify with a full decrypt round trip.
Same lab, same three-call upload β but the bytes are AES-256-GCM encrypted locally before they leave your machine, and decryption is gated on live onchain state. Encryption is a first-class flow, not an appendix: reach for it whenever the file is confidential and access should follow the lab's roles.
Steps 1β3 are identical to Create a lab and upload a public file β get a service token, mint, register. Pick up here with oclId, labAccountAddress, account and serviceToken already in hand (or with any lab you already hold a role on).
Conceptually: the backend hands you a one-shot data encryption key (DEK) in two forms β plaintext, and wrapped by the key custodian. You encrypt with the plaintext copy, throw it away, and store the wrapped copy in the file's metadata alongside the conditions under which the custodian may unwrap it again. Full model: Data Privacy & Access.
Step 4a: Get a DEK
const dekResult = await graphql(`
mutation {
generateDataEncryptionKey {
plaintextDEK
encryptedDek
encryptionSystem
error { code message requestId retryable details }
}
}
`);
assertOk(dekResult.generateDataEncryptionKey, "generateDataEncryptionKey");
const { plaintextDEK, encryptedDek, encryptionSystem } = dekResult.generateDataEncryptionKey;Expected response:
encryptionSystem is backend-set β echo it verbatim in Step 4d, never hardcode "kms". Key custody is on a path to threshold cryptography, and echoing the value is what keeps your integration working across that change.
Requires authentication (service token or Privy session), so Step 1 must have run.
Step 4b: Encrypt locally
The plaintext DEK is now unreferenced; don't log it, don't persist it, don't send it anywhere. Web Crypto's AES-GCM appends the 16-byte authentication tag to the ciphertext, which is what the decrypt side expects.
Step 4c: Write the access conditions
accessControlConditions is a JSON-stringified array of predicates the backend re-evaluates against live chain state every time someone asks to decrypt. Two recipes cover almost everything.
Owner only β only the LabNFT owner (and authorised signers of its Token Bound Account, so a Safe's signers resolve through) can decrypt:
Owner or Contributor or Viewer β the recipe to use when the lab has a team, and the one the contributor agent in Agent access needs. hasRole is hierarchical (ROLE_VIEWER = 1; a Contributor and the Owner both pass a Viewer check), and the explicit isAuthorizedSignerForTba branch keeps the Owner covered even if conditions are ever evaluated against a non-canonical chain:
labAccountAddress here is the Lab's own OCL account, not the owner's wallet β passing the owner's address instead evaluates to false and silently locks everyone out of the file (the three wallets). :userAddress is substituted with the authenticated caller's wallet at evaluation time. Pass "2" instead of "1" to exclude Viewers. Evaluation walks the array left to right and short-circuits (or stops at the first true); any RPC error fails closed and the DEK is not released. The full condition grammar, including EvmBasicCondition, is on Data Privacy & Access.
Step 4d: Upload the ciphertext
The same three calls as the public upload, with the ciphertext in place of the raw file and encryptionMetadata attached on finish.
If it fails:
error.code
What happened
Fix
VALIDATION_FAILED, reason: INVALID_CONDITIONS
accessControlConditions isn't a valid stringified condition array
It is a JSON string, not an object. Check functionAbi is complete and returnValueTest present
VALIDATION_FAILED, reason: INVALID_ACCESS_LEVEL
PUBLIC on an encrypted file
Use HOLDERS or ADMIN
UNAUTHORIZED on generateDataEncryptionKey
No write role on the lab
Owner or Contributor required
UPSTREAM_UNAVAILABLE, "Path is occupied" on finish
A file already exists at that path β usually a re-run against the same lab
Not retryable despite the code. Pick a new path, or send ref (the previous datasetId) instead to add a version
NOT_FOUND on the first call after a mint
The mint is not indexed yet, even though createLab succeeded
Retry with withIndexerLagRetry β see Step 4 of Create a lab and upload a public file
Step 5: Verify by decrypting it
The real test is a round trip: ask the backend to unwrap the DEK, decrypt, and compare hashes. decryptDataKey re-evaluates the file's conditions against live chain state and your token's wallet, so a success here proves the gate works.
Expected: the hashes match and recovered equals your original file byte-for-byte.
If it fails:
error.code
What happened
Fix
UNAUTHORIZED
Your wallet does not satisfy the file's conditions
Check the role with the public listLabMembers(oclId) query. After a fresh grant, allow for indexer/chain lag and retry
FAILED_PRECONDITION, reason: LEGACY_ENCRYPTION
The file predates onchain-verified envelope encryption
Not decryptable through this mutation; use the original encryption client
FAILED_PRECONDITION, reason: NOT_ENCRYPTED / MISSING_DEK
The file has no encryptionMetadata
You're pointing at a public file
UPSTREAM_UNAVAILABLE
Condition evaluation could not reach the chain RPC
Fails closed by design. retryable: true
Web Crypto throws OperationError
Wrong IV, or ciphertext truncated
Use the iv returned by decryptDataKey, and pass the whole downloaded body including the trailing GCM tag
Complete script
Steps 1β3 are verbatim from Create a lab and upload a public file; this script carries them so it runs standalone.
Usage:
Next
Let an agent decrypt and contribute too
Run it against mainnet
How conditions are evaluated, in depth
Last updated
Was this helpful?