Integrator documentation
ValiBlocks provides an on-chain proof-of-existence registry for clinical trial and life sciences data. Your application hashes patient or study data locally — no personal data leaves your system — and submits only the digest to the smart contract. The contract stores an immutable, timestamped record that any authorised party can use to verify the data is unchanged at any point in the future.
ValGuard contract on-chain.recordId you supply. Each record holds an ordered list of versions.1 = "SHA-256"). Each version records which algorithm produced its digest, so older records remain verifiable even if your algorithm changes.ValGuard address and ABI.storeHash call requires a small ETH fee (the current storageFee is readable from the contract).A trial is the top-level namespace for your study records. The wallet creating the trial becomes its admin and is automatically authorised to submit records.
const valguard = new ethers.Contract(VALGUARD_ADDRESS, VALGUARD_ABI, signer);
const tx = await valguard.createTrial();
const receipt = await tx.wait();
const event = receipt.events.find(e => e.event === 'TrialCreated');
const trialId = event.args.trialId; // bytes32
trialId securely in your system. It cannot be recovered without the original transaction. It is intentionally non-guessable to prevent enumeration by third parties.
Only authorised addresses may submit or amend records in a trial. The trial admin and the ValiBlocks contract owner can both grant and revoke access.
// Grant access to a site investigator wallet
await valguard.authorizeAddress(trialId, siteWalletAddress);
// Revoke access when no longer needed
await valguard.revokeAddress(trialId, siteWalletAddress);
The trial admin's own address cannot be revoked. Multiple addresses from different organisations may all be authorised to the same trial.
For verification to work, the same data must always produce the same digest. JSON objects do not guarantee key ordering, and numbers may be represented differently across systems. You must canonicalise before hashing.
The canonical form is: keys sorted alphabetically at every level of nesting, whitespace removed, numbers in a consistent normalised form (per RFC 8259).
// Canonicalise then hash — this is the correct approach
function hashDataPoint(jsonObject) {
const canonical = canonicalise(JSON.stringify(jsonObject));
return ethers.utils.keccak256(ethers.utils.toUtf8Bytes(canonical));
}
ethers.utils.hashMessage() — it prepends an Ethereum signing prefix to the input before hashing. The on-chain digest will not match a plain hash of the same data, making independent verification fail.
A reference canonicalisation implementation in JavaScript:
function canonicalise(json) {
const strs = [];
return reorderProperties(
strs,
json
.replace(/"(?:[^\\"]|\\.)*"/g, x => { strs.push(x); return `"@${strs.length-1}"` })
.replace(/[\x00-\x20]+/g, '')
.replace(/([+\-]?)(?=[\d.])(\d*)(?:[.](\d*))?(?:[eE]([+\-]?\d+))?(?=[,\]\}]|$)/g, canonNumber)
).replace(/"@(\d+)"/g, (_, i) => JSON.stringify(JSON.parse(strs[i])));
}
function canonNumber(_, sign, integer, fraction, mantissa) {
if (sign !== '-') sign = '';
integer = integer || '0';
fraction = fraction || '';
mantissa = (+mantissa || 0) + integer.length - 1;
let digits = (integer + fraction).replace(/^(?:0(?!$))+/, s => (mantissa -= s.length, '')) || '0';
if (digits === '0') mantissa = 0;
if (-26 >= mantissa || mantissa >= 26) {
const afterdot = digits.substring(1).replace(/0+$/, '');
return sign + digits[0] + (afterdot ? `.${afterdot}` : '') + 'e' + mantissa;
}
return mantissa < 0
? sign + '0.' + '0'.repeat(-mantissa - 1) + digits
: mantissa + 1 < digits.length
? sign + digits.substring(0, mantissa + 1) + '.' + digits.substring(mantissa + 1)
: sign + digits + '0'.repeat(mantissa - digits.length + 1);
}
function reorderProperties(strs, s) {
const tokens = [];
const pat = /[{}\[\],]/g;
for (let m; (m = pat.exec(s));) tokens.push(m);
if (!tokens.length) return s;
const memo = [];
function key(i) { return memo[i] ?? (memo[i] = JSON.parse(strs[i])); }
function reorder(l, r) {
const { 0: tok, index } = tokens[l];
let start = l, valStart = null, depth, cur, ranges = [];
for (depth = 1, cur = l + 1; cur < r; ++cur) {
switch (tokens[cur][0]) {
case '{': case '[': if (depth === 1) valStart = cur; ++depth; break;
case ']': case '}': --depth; break;
case ',': if (depth === 1) { ranges.push([start, valStart, cur]); start = cur; valStart = null; }
}
}
ranges.push([start, valStart, r]);
ranges = ranges.map(([s, v, e]) => v === null
? str.substring(tokens[s].index + 1, tokens[e].index)
: str.substring(tokens[s].index + 1, tokens[v].index) + reorder(v, e - 1));
if (tok === '{') ranges.sort((a, b) => { const ak = key(/^"@(\d+)":/.exec(a)[1]), bk = key(/^"@(\d+)":/.exec(b)[1]); return ak < bk ? -1 : ak === bk ? 0 : 1; });
return tok + ranges.join(',') + tokens[r][0];
}
var str = s;
return reorder(0, tokens.length - 1);
}
Submit the digest to the contract using storeHash. You supply:
bytes32 ID from Step 1.bytes32 digest from Step 3.1 for SHA-256). Must be registered in the contract's algorithm registry.recordId. Gaps are allowed — you may use sequential integers, dates (20250401), or any other monotonically increasing scheme.const storageFee = await valguard.storageFee();
await valguard.storeHash(
trialId, // bytes32
recordId, // uint256 — your local identifier
digest, // bytes32 — from Step 3
1, // algorithmId (1 = SHA-256)
amendmentVersion, // uint256 — strictly increasing per recordId
{ value: storageFee }
);
storageFee, the excess is automatically refunded in the same transaction. Query storageFee() before each call or cache it — the owner may update the fee over time.
To prove a data point is unchanged, re-canonicalise and re-hash the current data and compare it against the on-chain record. No special permissions are required — anyone with the trialId and recordId can query the contract.
// Retrieve the latest version of a record
const version = await valguard.getLatestVersion(trialId, recordId);
// Or retrieve a specific amendment version
const version = await valguard.getVersion(trialId, recordId, amendmentVersion);
// Re-hash the data you want to verify
const currentDigest = hashDataPoint(dataToVerify);
if (version.hash === currentDigest) {
const date = new Date(version.timestamp.toNumber() * 1000);
console.log('Verified. Recorded at:', date.toISOString());
console.log('Submitted by:', version.submitter);
console.log('Algorithm ID:', version.algorithmId.toNumber());
} else {
console.log('Verification failed — digest does not match on-chain record.');
}
The version object contains:
| Field | Type | Description |
|---|---|---|
hash | bytes32 | The stored digest |
algorithmId | uint256 | Algorithm used to produce the digest |
amendmentVersion | uint256 | Your supplied version number |
submitter | address | Wallet that submitted this version |
timestamp | uint256 | Block timestamp at submission (Unix seconds) |
Every version ever submitted for a record is preserved in order. You can retrieve the complete chain of custody for any data point.
// Full ordered history — oldest first
const history = await valguard.getFullHistory(trialId, recordId);
history.forEach(v => {
const date = new Date(v.timestamp.toNumber() * 1000);
console.log(
`v${v.amendmentVersion} | ${date.toISOString()} | by ${v.submitter} | algo ${v.algorithmId}`
);
});
// Count of versions (useful before paginating a large history)
const count = await valguard.getVersionCount(trialId, recordId);
Because each version records the submitter's wallet address, the history provides a complete, tamper-evident chain of custody showing who amended a record and when — without requiring any centralised audit system.
The contract maintains an on-chain registry mapping numeric IDs to algorithm descriptions. This lets verifiers determine exactly which algorithm produced a stored digest without relying on off-chain documentation.
// Look up the description for an algorithm ID
const description = await valguard.algorithms(1); // "SHA-256"
Registered algorithms (current defaults):
| ID | Algorithm |
|---|---|
1 | SHA-256 |
2 | SHA-3-256 |
New algorithm IDs may be added over time as standards evolve. Existing IDs are immutable — once registered, a description never changes. This means any digest stored against ID 1 will always be verifiable as SHA-256, even decades later.
| Function | Who can call | Description |
|---|---|---|
createTrial() |
PMAToken holders | Creates a new trial. Returns a non-guessable bytes32 trial ID. |
authorizeAddress(trialId, addr) |
Trial admin, contract owner | Grants addr permission to submit records in the trial. |
revokeAddress(trialId, addr) |
Trial admin, contract owner | Removes submission permission. Cannot revoke the trial admin. |
storeHash(trialId, recordId, hash, algorithmId, amendmentVersion) |
Authorised addresses | Stores a digest. Payable — must include storageFee in ETH. Excess is refunded. |
| Function | Returns | Description |
|---|---|---|
getLatestVersion(trialId, recordId) |
RecordVersion | Most recently submitted version of a record. |
getVersion(trialId, recordId, amendmentVersion) |
RecordVersion | A specific amendment version of a record. |
getVersionCount(trialId, recordId) |
uint256 | Total number of versions stored for a record. |
getFullHistory(trialId, recordId) |
RecordVersion[] | Complete ordered amendment history, oldest first. |
algorithms(uint256 id) |
string | Description of a registered algorithm ID. |
storageFee() |
uint256 | Current fee in wei required per storeHash call. |
For contract addresses, PMAToken access, or integration support: