Passport Pallet
pallet-pilier-dpp stores digital product passports: one head record per product, plus an
append-only table of lifecycle events under it. It does not store evidence files itself — see
the Documents Pallet for that — and it does not interpret the bytes of a
passport's own body; the shape of those bytes is a convention the schema (see the
Registry Pallet) describes, not something this pallet enforces beyond a
maximum length.
The record's key
A passport is stored under two values, both supplied by the caller on every call:
- Company registration number — the identifier the Registry Pallet granted to a project, giving it the right to write passports under that name.
- GS1 identifier — the product identifier under the GS1 standard (for example a GTIN).
Neither value is validated for a specific format by this pallet: it only checks that each fits
its length ceiling (32 bytes for the company registration number, 128 bytes for the GS1
identifier). The worked example below uses DOCS-EXAMPLE-0001 and 00000000000017 — both
deliberately fictional, not any real company's registration number or a real, globally issued
GS1 identifier (see "Worked example" below for why).
The head record
Reading dpp.heads(companyRegistrationNumber, gs1Id) returns a HeadRecord, encoded in this
field order:
| Field | Type | Meaning |
|---|---|---|
schema_id | u32 | Which schema (from the registry pallet) the body bytes below are meant to be read against. |
project_id | u32 | The project that published this record. |
published_at | block number (u32) | The block this version was written in. |
version | u32 | Starts at 1 on publish_head, incremented by every republish_head. |
previous_body_fingerprint | Option<Hash> (32 bytes when present) | None for version 1; on every later version, the hash of the body the previous version carried — proof of what that body was, without this pallet keeping a copy of it. |
body | Vec<u8>, up to 4096 bytes | The passport's own data, encoded as the schema named by schema_id describes. |
Encoding rule for the body
This pallet stores body as an opaque byte string; it never parses it. The schema referenced by
schema_id is what fixes its layout. Category 0 (the one this page's worked example uses) is
encoded with the SCALE codec
— the same encoding the chain itself uses everywhere — as this Rust structure (from the pallet's
own test suite, pallets/dpp/src/tests.rs):
struct CompositionLine {
fibre: Vec<u8>, // e.g. b"cotton"
percentage_bps: u16, // basis points (hundredths of a percent); a schema's composition lines sum to 10 000
}
struct PassportRecordV1 {
count: u32,
composition: Vec<CompositionLine>,
composition_source: u8, // 0 = computed, 1 = hand-entered
certificate_fingerprints: Vec<[u8; 32]>, // fingerprints registered in the Documents Pallet
}
SCALE-encoding this structure by hand, byte for byte:
- Every fixed-width integer (
u8,u16,u32,u64) is little-endian, with no length prefix. - Every
Vec<T>(Vec<u8>,Vec<CompositionLine>,Vec<[u8; 32]>) starts with a "compact" length prefix — for any count under 64 (true of every value in this example) that prefix is one byte, equal tocount << 2, i.e. the count shifted left two bits — followed by the elements one after another with no separators. - A fixed-size array (
[u8; 32]) has no length prefix at all: it is exactly 32 raw bytes. - A struct is simply its fields, encoded in declared order, concatenated — no field names, no struct-level length prefix.
Lifecycle events (below) use a second structure, LifecycleEventV1, encoded by the same four
rules.
Calls
publish_head(company_registration_number, gs1_id, schema_id, body, file_fingerprints)
Publishes a passport's first version. Fails with:
NoPermissionForRegistrationNumber— the caller's project does not currently hold the right to write undercompany_registration_number(see the Registry Pallet).SchemaNotFound—schema_iddoes not name a schema registered in the registry pallet.FileNotRegistered— one offile_fingerprintsis not registered in the Documents Pallet. Note thatfile_fingerprintsis only checked, never stored: this pallet keeps justbody, on the assumption that whatever references to these files matter are already insidebodyunder the schema's own convention.RecordAlreadyExists— a head record already exists at this key; callrepublish_headinstead.
Charges the current PublicationPrice (see "Price" below) before writing anything, and refunds
the standard transaction fee on success.
republish_head(company_registration_number, gs1_id, schema_id, body, file_fingerprints)
The only way to change an already-published passport. Fails under the same conditions as
publish_head, plus RecordNotFound if no head record exists yet at this key. Computes and
stores the hash of the previous body (in the new record's previous_body_fingerprint and in
the HeadReplaced event) rather than keeping the previous body itself, and increments version.
Charged the same way as publish_head.
append_event(company_registration_number, gs1_id, body)
Appends one lifecycle event at the next free index under this key. Fails with
NoPermissionForRegistrationNumber (as above) or RecordNotFound if publish_head has not
been called for this key yet. There is no call that changes or removes an already-appended
event — the event table only ever grows. Charged the same PublicationPrice as publish_head.
set_price(new_price)
Changes PublicationPrice. Restricted to the council (AdminOrigin); an ordinary account
cannot call this.
The event table
Reading dpp.events(companyRegistrationNumber, gs1Id, index) returns an EventRecord:
| Field | Type | Meaning |
|---|---|---|
body | Vec<u8>, up to 128 bytes | The event's own data, unparsed by this pallet, encoded as the schema's own event convention describes (see LifecycleEventV1 below). |
recorded_at | block number (u32) | The block this pallet itself observed while handling the append_event call. |
dpp.eventCounts(companyRegistrationNumber, gs1Id) returns how many events exist at this key —
indices 0 through count - 1 are all readable.
A recorded event cannot be edited or removed, by design: the pallet declares no call that does
either. recorded_at is the block the chain itself attests to, which is deliberately not the
same thing as any timestamp that might be carried inside body — the latter is whatever the
publisher chose to write, unverified by this pallet.
Category 0's event convention, LifecycleEventV1:
struct LifecycleEventV1 {
event_type: u8, // 0=created, 1=shipped, 2=received, 3=sold, 4=end-of-life
occurred_at_unix_ms: u64,
gs1_event_hash: [u8; 32], // a GS1 EPCIS event's own canonical hash, computed off-chain
}
Evidence files
A passport's body cites evidence files (certificates, lab reports) by their content fingerprint,
not by an address. What such a fingerprint proves, how it is registered, and how its address is
assembled all live on the Documents Pallet page — this pallet only checks,
through the DocumentsAccess trait, that a cited fingerprint is registered there; it keeps no
file table of its own.
Price
PublicationPrice is a single value in this pallet's own on-chain storage, read fresh on every
publish_head and append_event call — it does not depend on the size of body. Read the
value currently in force yourself, rather than trusting a number on any documentation page to
stay current after the next council vote:
const { ApiPromise, WsProvider } = require('@polkadot/api');
const api = await ApiPromise.create({ provider: new WsProvider('wss://rpc.pilier.dev') });
console.log((await api.query.dpp.publicationPrice()).toHuman());
Changed only by a council vote through set_price — never by a runtime upgrade.
Who can write
Only a project the Registry Pallet has granted a company registration number to may publish or update a passport under that name, and today the registry pallet has granted that right to exactly one project, Validité. This is not a temporary bootstrap restriction being lifted gradually — it is the pallet's whole write model: a registration number maps to at most one project at a time, and only the council can grant or revoke it. There is no path to open write access without a council decision to grant one.
Worked example
This is a real, permanent record on the live testnet, published specifically to be this page's
example — not a real customer's data. Both identifiers are intentionally fictional: the company
registration number DOCS-EXAMPLE-0001 names no real company, and the GS1 identifier
00000000000017 is a GTIN-14 built on the company prefix 0000000, which GS1's own published
prefix table designates for "Restricted Circulation Numbers within a company" — i.e. explicitly
not a globally issued, real product identifier.
The published body, 113 bytes, is a PassportRecordV1 for a fictional textile: count 500,
composition cotton 6000 bps / polyester 2500 bps / elastane 500 bps / viscose 1000 bps
(composition_source 0, i.e. computed), and two certificate fingerprints. Its full bytes, and
the two events appended after it, are published as verification vectors in the
passport-reader repository, for anyone who
wants to decode them by hand against the rules above.
Read it back yourself against the open RPC endpoint, with no signing key required:
const { ApiPromise, WsProvider } = require('@polkadot/api');
async function main() {
const api = await ApiPromise.create({ provider: new WsProvider('wss://rpc.pilier.dev') });
const crn = 'DOCS-EXAMPLE-0001';
const gs1Id = '00000000000017';
const head = await api.query.dpp.heads(crn, gs1Id);
console.log('head:', head.toString());
const eventCount = await api.query.dpp.eventCounts(crn, gs1Id);
console.log('event count:', eventCount.toHuman());
for (let i = 0; i < eventCount.toNumber(); i++) {
const event = await api.query.dpp.events(crn, gs1Id, i);
console.log(`event ${i}:`, event.toString());
}
await api.disconnect();
}
main().catch(console.error);
Running this returns the head record's body as a hex string starting 0xf401... — decoding
those bytes by the rules above, without looking at this pallet's source code, is exactly the
check this documentation is meant to survive.
The passport-reader repository has a working
decoder for both PassportRecordV1 and LifecycleEventV1, the verification vectors this example
is drawn from, and a small command-line viewer that reads a passport straight off the live chain
and renders it as HTML.