Guides · Part 4 of 5
Build a sealed PoE
A plain PoE proves that some content existed. A sealed PoE proves the same thing while keeping the content itself secret: you encrypt the bytes to one or more recipient keys, store only the ciphertext, and anchor the record on-chain. Anyone can see that the record exists and verify its structure; only a holder of a matching private key can decrypt the payload. See Sealed PoE for the envelope format and Sealed until claimed for the threat model.
Address your recipients
A recipient is identified by an age-style string. There are two kinds, and the
prefix tells them apart:
age1…— a classical X25519 key (32 bytes).age1pqc…— an X-Wing hybrid key (ML-KEM-768 + X25519, 1216 bytes).
X-Wing (mlkem768x25519) is the default KEM — it stays secure against a
future quantum adversary, and every identity always has an age1pqc… address.
A recipient hands you their string out of band. You decode it back to the raw
public key the sealing helper needs with parseAgeRecipient:
import { parseAgeRecipient } from '@cardanowall/sdk-ts';
const them = parseAgeRecipient('age1pqc…'); // { kem: 'mlkem768x25519', publicKey: Uint8Array }If you hold a 32-byte seed yourself, recipientsFromSeed gives you both of your
own addresses — share one so others can seal to you, and include your own key in
the recipient list to keep read access to what you send:
import { recipientsFromSeed } from '@cardanowall/sdk-ts';
const me = recipientsFromSeed(mySeed); // { age: 'age1…', age1pqc: 'age1pqc…' }Seal and publish
Sealing goes through a gateway, which builds and broadcasts the Cardano transaction and stores the ciphertext for you. Point the client at the gateway you use; the SDK is gateway-agnostic.
publishSealed takes raw recipient public keys, so collect the publicKey from
each parsed address. All recipients must share one KEM — keep age1pqc… keys
together. First lock a price with quote, then publish:
import { Label309Client, parseAgeRecipient } from '@cardanowall/sdk-ts';
const client = new Label309Client({
baseUrl: 'https://your-gateway.example',
apiKey: process.env.CW_API_KEY,
});
const content = new TextEncoder().encode('the secret payload');
const recipients = ['age1pqc…recipient', me.age1pqc].map((r) => parseAgeRecipient(r).publicKey);
const quote = await client.poe.quote({
recordBytes: 512,
recipientCount: recipients.length,
fileBytesTotal: content.length,
});
const result = await client.poe.publishSealed({
content,
recipients,
quoteId: quote.quote_id,
// kem defaults to 'mlkem768x25519' (X-Wing); pass 'x25519' only for age1… keys.
});
console.log(result.tx_hash);The helper encrypts the content, uploads the ciphertext, binds its ar:// URI
and the plaintext hash into a Label 309 record, and submits it. Your seed and the
plaintext never leave your machine in the clear.
With Python
cardanowall-sdk is a byte-for-byte twin — same KEM default, same envelope:
import asyncio
import os
from cardanowall import Label309Client, parse_age_recipient
async def main():
content = b"the secret payload"
recipients = [parse_age_recipient("age1pqc…recipient").public_key]
async with Label309Client(
base_url="https://your-gateway.example",
api_key=os.environ["CW_API_KEY"],
) as client:
quote = await client.poe.quote(
record_bytes=512, recipient_count=len(recipients), file_bytes_total=len(content)
)
result = await client.poe.publish_sealed(
content=content, recipients=recipients, quote_id=quote["quote_id"]
)
print(result["tx_hash"])
asyncio.run(main())With Rust
The cardanowall crate seals through the same gateway client. parse_age_recipient
decodes each address to the raw key, and kem: None keeps the X-Wing default:
use cardanowall::client::{
Label309Client, Label309ClientConfig, PublishSealedInput, QuoteInput,
};
use cardanowall::recipient::parse_age_recipient;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Label309Client::new(Label309ClientConfig {
base_url: Some("https://your-gateway.example".into()),
api_key: std::env::var("CW_API_KEY").ok(),
})?;
let content = b"the secret payload".to_vec();
let recipients = vec![parse_age_recipient("age1pqc…recipient")?.public_key];
let quote = client.poe().quote(&QuoteInput {
record_bytes: 512,
recipient_count: recipients.len() as u64,
file_bytes_total: content.len() as u64,
})?;
let result = client.poe().publish_sealed(&PublishSealedInput {
content,
recipients,
quote_id: quote.quote_id,
hash_alg: None,
kem: None, // defaults to mlkem768x25519 (X-Wing); set Some for x25519
signer: None,
idempotency_key: None,
})?;
println!("{:?}", result.tx_hash);
Ok(())
}The CLI publishes hash-only and Merkle records; sealing is an SDK flow today.
Once the record settles, each recipient discovers it, decrypts the payload with their private key, and recomputes the plaintext hash to close the loop — that's the recipient half of Verify a record.
Seal to yourself too
publishSealed never adds you to the recipient list silently. If you don't include one of your
own keys, you publish a record you can never read back. Include me.age1pqc (or me.age) among
the recipients whenever you want to keep access to what you sent.