Skip to main content

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:

FieldTypeMeaning
schema_idu32Which schema (from the registry pallet) the body bytes below are meant to be read against.
project_idu32The project that published this record.
published_atblock number (u32)The block this version was written in.
versionu32Starts at 1 on publish_head, incremented by every republish_head.
previous_body_fingerprintOption<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.
bodyVec<u8>, up to 4096 bytesThe 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:

  1. Every fixed-width integer (u8, u16, u32, u64) is little-endian, with no length prefix.
  2. 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 to count << 2, i.e. the count shifted left two bits — followed by the elements one after another with no separators.
  3. A fixed-size array ([u8; 32]) has no length prefix at all: it is exactly 32 raw bytes.
  4. 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 under company_registration_number (see the Registry Pallet).
  • SchemaNotFound — schema_id does not name a schema registered in the registry pallet.
  • FileNotRegistered — one of file_fingerprints is not registered in the Documents Pallet. Note that file_fingerprints is only checked, never stored: this pallet keeps just body, on the assumption that whatever references to these files matter are already inside body under the schema's own convention.
  • RecordAlreadyExists — a head record already exists at this key; call republish_head instead.

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:

FieldTypeMeaning
bodyVec<u8>, up to 128 bytesThe event's own data, unparsed by this pallet, encoded as the schema's own event convention describes (see LifecycleEventV1 below).
recorded_atblock 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.