Logbook Beta on Sui Testnet. Learn more →
Documentation

Learn Logbook

Everything you need to create blockchain-verified surveys, understand the technology, and get the most out of Logbook.

DocsSmart Contract
21 min read

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
Think of it as a vending machine: you put in money, select an item, and the machine gives you exactly what you selected. No human intervention, no exceptions, no "special cases". The rules are the rules.

#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
2. Cheap Storage Sui's storage model makes it economically viable to store real data on-chain. That's why Logbook can store:
  • Full question text
  • All answer options
  • Every individual response
  • Vote counts (for campaigns with public answers)
On Ethereum, this would cost hundreds or thousands of dollars. On Sui, it costs fractions of a cent.

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)
4. Programmable Transactions Sui allows complex multi-step transactions that execute atomically. This means:
  • 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
The same ids are returned by 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)
Documents and reports are dynamic fields of the campaign (see set_documents and attach_report below).

#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; migrate needs 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:

TagProtectsSuffix
0The campaign content keynone
1Answers shared by an audience, and the reports of an address-list campaign whose answers everyone with access readsthe access epoch (u64, little endian)
2One respondent's answers, and their copy of the content keythe respondent's address
3Keys of reports about the resultsnone
Seal key servers release a key only after simulating the contract's 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_time has 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)
Password proof. A password campaign stores an ed25519 public key that the browser derives from the content key, which the password unlocks. Responding, and asking Seal for keys as a password holder, needs a signature by the matching private key over 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)
Checks:
  • 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
Plaintext answers are validated and counted. One answer per question index, question indexes strictly increasing, every required question answered, each answer at most 4096 bytes:
  • 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
Encrypted answers are opaque to the contract. Clients apply the same rules after decryption, and check that the answer was encrypted for this campaign and respondent; an answer that fails is shown as invalid and never counted.

#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:

CodeNameMeaning
0EAlreadyRespondedThe sender has already responded
1EInvalidAnswerA plaintext answer breaks the answer rules, or a required question is unanswered
2ENotAllowedThe sender has no access: not on the address list, or no valid proof of the password
3ECampaignEndedThe campaign has ended: no more responses, edits or document changes
4ENotCreatorOnly the creator can do this
5EInvalidQuestionA question index does not exist or is out of order
6EWrongVersionThis package version has been retired
7EInvalidModeInvalid combination of access and visibility, or an address-list change on another kind of campaign
8EHasResponsesThe campaign has responses, so it can no longer be edited or deleted
9EInvalidCampaignInvalid campaign data: title, questions, options, keys or address list
10EInvalidPayloadThe response is not in the form this campaign expects
11ENoAccessSeal policy: this key is not released to the sender
12EInvalidIdentitySeal policy: the identity is malformed
13EInvalidReportInvalid report pointer, or an unencrypted report of a private campaign
14ENoReportThere is no report of this kind
15EInvalidDocumentInvalid document data
16EContentChangedThe campaign was edited after the response was prepared
17EStaleAccessEpochAn address was removed from the list after the response was prepared
18EWrongSponsorA 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
19EInvalidEndTimeThe end time must be in the future and at most 3653 days ahead
20ENotUpgradedNothing to migrate: the Config already has this version
21EAllowlistNotEmptyA 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_size
  • has_responded(campaign, address), is_allowlisted(campaign, address)
  • questions, option_votes, other_votes, text_answers
  • documents, document_content_hash, document_blob_id, has_report(campaign, kind), report(campaign, kind), report_content_hash
  • registry_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()
The web app reads objects through the Sui full node (gRPC) and history through the indexer (GraphQL).

#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 campaign
  • creator: Address of the campaign creator
  • access: Who can open the campaign
  • visibility: Who can read the answers
  • end_time: Campaign end timestamp
  • timestamp: When the event occurred

#CampaignUpdated

Emitted when a campaign is updated (before any responses).
  • campaign_id: Unique ID of the campaign
  • creator: Address of the campaign creator
  • content_version: The version after the update
  • end_time: The end time after the update
  • timestamp: When the update occurred

#CampaignDeleted

Emitted when a campaign is deleted (only possible with zero responses).
  • campaign_id: Unique ID of the deleted campaign
  • creator: Address of the campaign creator
  • timestamp: When the deletion occurred

#ResponseSubmitted

Emitted when a participant submits a response.
  • campaign_id: Campaign that received the response
  • creator: Campaign creator's address
  • respondent: Address of the participant
  • response_id: ID of the Response object
  • response_index: Index of this response (0-based)
  • timestamp: When the response was submitted

#AllowlistChanged

Emitted when the address list changes.
  • campaign_id: The campaign
  • added, removed: The addresses actually added and removed
  • size: Size of the list after the change
  • access_epoch: The access epoch after the change
  • timestamp: 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 by migrate (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_id and content_hash
These events can be monitored by off-chain services for notifications and indexing.