Smart Contract
The Logbook protocol is implemented as a Move module — a smart contract written in the Move programming language — on the Sui blockchain.
#What is a Smart Contract?
A smart contract is a program that runs on the blockchain. Unlike traditional software that runs on a company's servers, smart contracts:
- Execute automatically: Once deployed, the code runs exactly as written — the rules change only through a public upgrade (see Versions and Upgrades below)
- Are transparent: Anyone can read the code and verify what it does
- Are trustless: You don't need to trust the developer — you can verify the logic yourself
- Are permanent: Once deployed, the contract exists as long as the blockchain exists
#Why Sui is Different
Most blockchains (Ethereum, Solana, etc.) have significant limitations:
#Traditional Blockchains
- Limited data storage: Storing data is extremely expensive, so most apps store minimal on-chain data
- Simple logic only: Complex programs are costly and slow to execute
- Account-based model: Data is tied to accounts, making it hard to work with individual objects
#Sui's Advantages
Sui was designed from the ground up to solve these problems:
1. Object-Centric Model Every piece of data is an "object" with its own ID. In Logbook:
- Each campaign is an object
- Each response is its own object attached to the campaign
- You can reference any specific piece of data by its ID
- Full question text
- All answer options
- Every individual response
- Vote counts (for campaigns with public answers)
3. Move Language Sui uses Move, a language designed specifically for blockchain with:
- Built-in safety guarantees (no reentrancy attacks, no overflow bugs)
- Resource-oriented programming (assets can't be accidentally destroyed or duplicated)
- Clear ownership model (every object is owned by one address or shared, like a campaign, and only the contract's code decides who may change it)
- Campaign creation and question setup happen in one transaction
- Response submission and vote counting happen together (when answers are public)
- Either everything succeeds, or nothing changes
#What This Means for Logbook
Because of Sui's unique features, Logbook can:
- Store every response permanently on-chain (not just a hash)
- Execute voting logic in the smart contract itself
- Let anyone verify public results by reading the blockchain directly, and let everyone allowed to decrypt verify private ones
- Offer a web2-like experience (Google login, sponsored transactions) with web3 guarantees
#Contract Address
On Sui Testnet:
- Package:
0xd01326351d78a1664ff4ae9ec748d4cb2c02201e5c26f56afd2ef27e3c276a72, the address of all types and the Seal namespace; it never changes with upgrades - Latest version:
0x8f23c4b89f5f11715c4faf0babe02591c5d5221a7bdb68cccfe0d4ab3baa8d41, where functions are called (the same id until the first upgrade) - Config:
0x9c503b179b5dda4c5900e29aae9b47347adab3ff596a4589ffedb29a9a3e6bc8, shared; holds the version every function checks - Registry:
0x2513a4b1a9b8edb01d4e3d66bc0c3bab1e6fcbab9e5dd88b6b67505e142c5cbf, shared; the parent every campaign id is derived from
GET /api/v1. The package's code is public on-chain: a Sui explorer such as Suiscan shows its modules.
#Core Objects
#Campaign
The main shared object representing a survey or poll:
- id: Derived from the Registry, the creator's address and a random nonce (
campaign_id_for(registry, creator, nonce)), so a client knows it before the campaign exists. A creator and nonce pair can be used once, ever: even after a deletion the id is never reused - creator, nonce: Who created the campaign, and the nonce its id was derived from
- sponsor: Who paid the gas of the creation, if anyone. Every later change by the creator must be paid the same way (see Functions), so storage is always refunded to whoever paid for it
- title, description: Plaintext for open campaigns, encrypted otherwise
- questions: Vector of Question objects (text and options are encrypted for non-open campaigns)
- access: Who can open the campaign — 0 (anyone), 1 (password), 2 (address list)
- visibility: Who can read answers — 0 (everyone), 1 (everyone with access), 2 (voters), 3 (creator), 4 (after the end date)
- allowlist, allowlist_size: Table of addresses for access = 2, and its size
- access_epoch: Grows whenever an address leaves the list; answers shared by an audience are encrypted for the current epoch
- content_version: Grows with every edit; a response names the version its answers refer to
- responses: Table of Response objects keyed by respondent address; total_responses counts them
- created_at, updated_at, end_time: Timestamps (ms)
- content_key: The campaign content key, encrypted with Seal (released to everyone with access; the creator recovers it this way)
- link_key: The content key wrapped with the campaign password (password campaigns only)
- link_pubkey: The ed25519 public key whose signatures prove the password (password campaigns only)
#Question
Stored within a Campaign:
- question_type: 0 (single), 1 (multiple), 2 (text)
- text: The question text
- required: Whether the question is required
- options: Vector of answer options
- allow_other: Whether an "Other" answer is allowed
- option_votes, other_votes, text_answers: Counts kept by the contract when answers are public (zero otherwise)
#Response
A participant's submission, stored as a child object of the campaign so the campaign object keeps its size:
- respondent: Address of the participant
- timestamp: Submission timestamp
- answers: Plaintext answers (question index → answer), only when answers are visible to everyone
- sealed: Encrypted answers for every other visibility (a Seal encrypted object, or content-key ciphertext for password campaigns whose answers everyone with access may read)
- key_wrap: Password campaigns only — the content key sealed for this respondent, so they can reopen the campaign on another device
- access_epoch: The access epoch the answers were encrypted for
#Registry, Config and AdminCap
- Registry (shared): The parent every campaign id is derived from; it counts the campaigns created
- Config (shared): The package version every function accepts
- AdminCap: Held by the publisher;
migrateneeds it to move the Config to a new package version
#Seal Access Policy
Every Seal identity (without the package prefix) starts with the campaign id, followed by a tag and a suffix:
| Tag | Protects | Suffix |
|---|---|---|
| 0 | The campaign content key | none |
| 1 | Answers shared by an audience, and the reports of an address-list campaign whose answers everyone with access reads | the access epoch (u64, little endian) |
| 2 | One respondent's answers, and their copy of the content key | the respondent's address |
| 3 | Keys of reports about the results | none |
seal_approve function for the requesting address. Because the campaign id comes first, only the campaign a key belongs to can grant it: no other campaign, not even one with an earlier end date, can open a secret ballot early.
"Access" below means: the creator; anyone, on an open campaign; an address on the list; or, on a password campaign, whoever presents a proof of the password for their own address.
- Content key (tag 0): released to everyone with access (open campaigns have none)
- Shared answers (tag 1), for the current or an earlier access epoch: on "voters only" campaigns to the creator and to anyone who has responded; on address-list campaigns whose answers everyone with access may read, to the creator and the addresses currently on the list, and to a respondent removed from the list for the epochs up to that of its own response (its own answer, and answers that were all sent before its removal). Password campaigns with that setting use the content key instead of Seal. The reports about the results of such an address-list campaign (AI analysis, results certificate) are sealed the same way, for the access epoch they were made in
- One respondent's answers (tag 2): always released to that respondent; on "only creator" campaigns also to the creator; on "after the end date" campaigns to everyone with access once
end_timehas passed, and before that to nobody else, the creator included - Report keys (tag 3): released to whoever may read the results: voters and the creator on "voters only" campaigns, the creator on "only creator" campaigns, the creator and (after the end) everyone with access on "after the end date" campaigns, and everyone with access otherwise. The exception is an address-list campaign whose answers everyone with access reads: its reports are sealed under tag 1 for the current access epoch (see above), because the tag 3 identity has no epoch and a key fetched once would open every later report. The contract releases tag 3 of such a campaign to the creator only (since version 3)
logbook:link-access:v1 || campaign id || the sender's address. A proof works for that one address, so passing it on lets nobody else in.
Removing an address from the list starts a new access epoch. Shared answers submitted afterwards, and reports made afterwards on a campaign whose answers everyone with access reads, are encrypted for the new epoch, and an address that is no longer on the list gets no keys as a list member. What it decrypted before it keeps, and the content key (tag 0) does not change: with the copy it fetched while listed it can still read the title, questions and documents, also ones changed after its removal. A removed address that has already responded to a "voters only" campaign still reads the answers as a voter.
The contract never sees plaintext: encryption and decryption happen in the browser.
#Functions
The functions below are public and can be called from a programmable transaction, except seal_approve, an entry function for the key servers; the TxContext argument is left out. Each one first checks the version in the Config and aborts with code 6 if this package version has been retired.
The creator's functions (update_campaign, delete_campaign, update_allowlist, set_documents, set_document_storage, attach_report, detach_report) must be paid the way the campaign was created: sponsored by the same sponsor if its creation was sponsored, with the creator's own gas if it was not (code 18 otherwise). Storage these calls add or free is then always paid for and refunded to the same account, so nobody can have a sponsor pay for storage and collect the refund on their own gas. A sponsor that changes its key keeps the old one for the campaigns it sponsored.
#create_campaign
Creates a new campaign on-chain, optionally with the documents it is about.
create_campaign(config, registry, nonce, title, description, question_types, question_texts, question_required, question_allow_other, all_options, options_per_question, access, visibility, allowlist, content_key, link_key, link_pubkey, end_time, doc_names, doc_mimes, doc_sizes, doc_content_hashes, doc_salts, doc_stored_hashes, doc_storages, doc_blob_ids, doc_end_epochs, clock)
Parameters:
- config: &Config, registry: &mut Registry
- nonce: address (with the sender, determines the campaign id)
- title, description: String (encrypted client-side unless access = 0)
- question_types: vector<u8>, question_texts: vector<String>, question_required: vector<bool>, question_allow_other: vector<bool>
- all_options: vector<String>, options_per_question: vector<u64> (the options of all questions, flattened)
- access: u8, visibility: u8 (an open campaign cannot use visibility 1)
- allowlist: vector<address> (access = 2 only, at least one address)
- content_key: vector<u8> (Seal encrypted, empty for open campaigns)
- link_key: vector<u8> (password-wrapped key) and link_pubkey: vector<u8> (32 bytes), password campaigns only
- end_time: u64 (ms, in the future, at most 3653 days ahead)
- doc_names … doc_end_epochs: one entry per document in each vector; empty vectors for none
- clock: &Clock
#update_campaign
update_campaign(config, campaign, title, description, question_types, question_texts, question_required, question_allow_other, all_options, options_per_question, end_time, clock)
Replaces title, description, questions and end time (creator only, only while the campaign has no responses and before its end: code 3 after it). The new end time must be in the future and at most 3653 days ahead. Access, visibility, keys and the address list stay as they are. Increments content_version.
#delete_campaign
delete_campaign(config, campaign, clock)
Removes a campaign without responses, with its documents and reports (creator only, also after its end). The address list must be empty (code 21): remove every address with update_allowlist in the same transaction first, as the Logbook clients do, so the storage of the list entries is refunded too. One transaction removes up to about 900 addresses (Sui loads every removed entry, and about 1000 objects per transaction). Like every creator function it is paid the way the campaign was created, so the storage rebate goes back to whoever paid for the storage.
#update_allowlist
update_allowlist(config, campaign, add, remove, clock)
Adds and removes addresses (add, remove: vector<address>) of an address-list campaign in one call, creator only, at any time, also after the end. Removing anyone starts a new access epoch.
#submit_response
submit_response(config, campaign, content_version, access_epoch, question_indices, answers, sealed, key_wrap, access_proof, clock)
Parameters:
- content_version: u64, access_epoch: u64 (the campaign's values the answers were prepared for)
- question_indices: vector<u64>, answers: vector<String> (plaintext, only when visibility = 0)
- sealed: vector<u8> (encrypted answers, at most 16 KiB, required for every other visibility)
- key_wrap: vector<u8> (password campaigns only: the content key sealed for the respondent)
- access_proof: vector<u8> (password campaigns: the 64-byte proof of the password; empty otherwise)
- The campaign has not ended
- content_version and access_epoch are the campaign's current ones: a response prepared before an edit, or before an address was removed from the list, is refused, and the client prepares it again
- The sender has not responded yet
- The sender has access: on the list for address-list campaigns, a valid proof of the password for password campaigns
- Plaintext answers only when answers are public, encrypted answers otherwise
- Single choice: "2", or "other:<text>"
- Multiple choice: option indexes in strictly increasing order, "0,2,5", optionally followed by ",other:<text>", or "other:<text>" alone; the text runs to the end of the answer, commas included
- Text: any non-empty string
- Option indexes are plain decimals, without signs or leading zeros
#seal_approve
seal_approve(id, config, campaign, access_proof, clock)
An entry function that Seal key servers simulate before releasing the key for identity id. It aborts unless the policy above allows the sender. access_proof is the proof of the password on password campaigns, empty otherwise.
#set_documents / set_document_storage
set_documents(config, campaign, doc_names, doc_mimes, doc_sizes, doc_content_hashes, doc_salts, doc_stored_hashes, doc_storages, doc_blob_ids, doc_end_epochs, clock)
set_document_storage(config, campaign, index, storage, blob_id, walrus_end_epoch)
A campaign commits to up to 10 files (the app currently limits this to 5): name, type, size, a SHA-256 commitment to the file, the SHA-256 of the stored bytes, and where the file is stored (a Walrus blob id, or the Logbook cache). The commitment is written in the creation transaction; set_documents can replace the documents only while the campaign has no responses and has not ended, and increments content_version. set_document_storage only updates where the file lives (also after the end) and can never change a hash. Open campaigns commit to the plain SHA-256 of the file; private campaigns commit to SHA-256("logbook:document:v1" || salt || file) with a random 32-byte salt, which is stored encrypted with the campaign content key, like the file name. A private document also hides its size and type: the stored file is padded (to a power of two of at least 4 KiB, up to the upload limit), so size holds the padded size and mime is empty, and the real name, type and size are encrypted together in name. Documents from before this (format v1) show their exact size and type, with only the name encrypted.
#attach_report / detach_report
attach_report(config, campaign, kind, storage, blob_id, content_hash, size, encrypted, walrus_end_epoch, clock)
detach_report(config, campaign, kind, clock)
The creator records a pointer to a document stored off-chain: its kind (0 AI analysis, 1 results certificate), where it is stored (a Walrus blob id, or the Logbook cache), the SHA-256 of the stored bytes, size and whether it is encrypted. Reports of campaigns with restricted access or visibility must be encrypted. Stored as dynamic fields of the campaign, one per kind.
#migrate
migrate(admin_cap, config)
Called in an upgraded package with a higher version: it moves the Config to that version, and from then on the functions of older package versions abort.
#Versions and Upgrades
Every function, seal_approve included, checks the version stored in the shared Config against the version of its own package. After an upgrade that raises the version, migrate moves the Config forward, and the functions of older package versions stop working: a fix cannot be bypassed by calling the old code.
During the beta the package is upgradeable, and the UpgradeCap that allows upgrades is held by Logbook. An upgrade is a public transaction on Sui, but it can change any rule of the contract, including the Seal policy that decides who may decrypt what. Until that changes, the holder of the UpgradeCap is part of what you trust.
#Limits
- Up to 100 questions per campaign and 100 options per question
- One plaintext answer: at most 4096 bytes; encrypted answers: at most 16 KiB per response
- Up to 10 documents per campaign
#Known Limitations
- The first response freezes a campaign. From the first response on, the questions and documents can no longer be changed and the campaign can no longer be deleted. Anyone can respond to an open campaign right after it is created (a bot watching for new campaigns needs seconds), so its creator may never get to edit it. To keep editing after publishing, create the campaign with a password: nobody without the link can respond, so it stays editable until you share the link.
- The content key does not change. Questions, documents and the title of a private campaign are encrypted with one key that is fixed at creation. An address removed from the list (or anyone who knew the password) keeps the copy it fetched, and can read the content also after you change it. Answers and reports follow the access epoch; the content does not. Rotating the key would break every share link and every respondent's copy, so it is not done; put content that a removed address must not see into a new campaign.
- Every creation goes through one object. Each new campaign writes the shared Registry its id is derived from, so all creations are ordered through that object. When the network is congested, or someone floods it with creations at a higher gas price, some creations are delayed or cancelled; a cancelled creation claimed nothing and can be sent again. Responses and all other functions never touch the Registry.
#Error Codes
A failed transaction names the abort code of the function that stopped it:
| Code | Name | Meaning |
|---|---|---|
| 0 | EAlreadyResponded | The sender has already responded |
| 1 | EInvalidAnswer | A plaintext answer breaks the answer rules, or a required question is unanswered |
| 2 | ENotAllowed | The sender has no access: not on the address list, or no valid proof of the password |
| 3 | ECampaignEnded | The campaign has ended: no more responses, edits or document changes |
| 4 | ENotCreator | Only the creator can do this |
| 5 | EInvalidQuestion | A question index does not exist or is out of order |
| 6 | EWrongVersion | This package version has been retired |
| 7 | EInvalidMode | Invalid combination of access and visibility, or an address-list change on another kind of campaign |
| 8 | EHasResponses | The campaign has responses, so it can no longer be edited or deleted |
| 9 | EInvalidCampaign | Invalid campaign data: title, questions, options, keys or address list |
| 10 | EInvalidPayload | The response is not in the form this campaign expects |
| 11 | ENoAccess | Seal policy: this key is not released to the sender |
| 12 | EInvalidIdentity | Seal policy: the identity is malformed |
| 13 | EInvalidReport | Invalid report pointer, or an unencrypted report of a private campaign |
| 14 | ENoReport | There is no report of this kind |
| 15 | EInvalidDocument | Invalid document data |
| 16 | EContentChanged | The campaign was edited after the response was prepared |
| 17 | EStaleAccessEpoch | An address was removed from the list after the response was prepared |
| 18 | EWrongSponsor | A creator function paid differently from the creation: a sponsored campaign needs the same sponsor, one the creator paid for needs the creator's own gas |
| 19 | EInvalidEndTime | The end time must be in the future and at most 3653 days ahead |
| 20 | ENotUpgraded | Nothing to migrate: the Config already has this version |
| 21 | EAllowlistNotEmpty | A campaign is deleted only with an empty address list: remove every address first, in the same transaction |
#View Functions
Read-only functions to query campaign data:
creator,nonce,sponsor,access,visibility,end_time,total_responses,content_version,access_epoch,allowlist_sizehas_responded(campaign, address),is_allowlisted(campaign, address)questions,option_votes,other_votes,text_answersdocuments,document_content_hash,document_blob_id,has_report(campaign, kind),report(campaign, kind),report_content_hashregistry_campaigns(registry),config_version(config),campaign_id_for(registry, creator, nonce)- Constants:
access_open(),access_link(),access_allowlist(),vis_everyone(),vis_access(),vis_voters(),vis_creator(),vis_after_end(),tag_content(),tag_shared(),tag_own(),tag_reports(),report_ai_analysis(),report_results_certificate(),storage_walrus(),storage_cache()
#Events
The contract emits events for tracking all operations on the blockchain. These events are used for efficient querying and indexing.
#CampaignCreated
Emitted when a new campaign is created.campaign_id: Unique ID of the campaigncreator: Address of the campaign creatoraccess: Who can open the campaignvisibility: Who can read the answersend_time: Campaign end timestamptimestamp: When the event occurred
#CampaignUpdated
Emitted when a campaign is updated (before any responses).campaign_id: Unique ID of the campaigncreator: Address of the campaign creatorcontent_version: The version after the updateend_time: The end time after the updatetimestamp: When the update occurred
#CampaignDeleted
Emitted when a campaign is deleted (only possible with zero responses).campaign_id: Unique ID of the deleted campaigncreator: Address of the campaign creatortimestamp: When the deletion occurred
#ResponseSubmitted
Emitted when a participant submits a response.campaign_id: Campaign that received the responsecreator: Campaign creator's addressrespondent: Address of the participantresponse_id: ID of the Response objectresponse_index: Index of this response (0-based)timestamp: When the response was submitted
#AllowlistChanged
Emitted when the address list changes.campaign_id: The campaignadded,removed: The addresses actually added and removedsize: Size of the list after the changeaccess_epoch: The access epoch after the changetimestamp: When the change happened
#DocumentsSet / DocumentsSetV2
Emitted together when a campaign commits to documents or replaces them (DocumentsSetV2 since version 3).campaign_id,creator,count(number of documents),timestamp- DocumentsSetV2 also carries
content_version: the version the documents belong to
#DocumentStorageSet
Emitted when the creator records where a document's file is stored now (since version 3). The commitment does not change.campaign_id,creator,index,storage,blob_id,walrus_end_epoch(no timestamp: the function takes no clock; use the checkpoint's)
#ConfigMigrated
Emitted bymigrate (since version 3).
from_version,to_version
#ReportAttached / ReportDetached
Emitted when the creator attaches or removes a report.campaign_id,creator,kind,timestamp- ReportAttached also carries
storage,blob_idandcontent_hash