Cap Table API
The cap table is not a spreadsheet — it's the live state of on-chain PDAs, read and exposed by the Offering Grain.
Overview
The cap table on Sails.to is not a spreadsheet. It is the live state of the InvestorPosition PDAs on Solana. There is no separate database of ownership records, no CSV export that becomes stale the moment it is generated, no reconciliation step between "the cap table" and "the ledger." They are the same thing. The blockchain is the cap table.
The Offering Grain reads this on-chain state and exposes it through the getCapTable method on its Cap'n Proto OfferingAPI interface. Every query returns the current state of the ledger — not a cached copy, not a periodic sync, but the actual on-chain data at the moment of the request. When a token is minted, transferred, locked in a CrossConversion lockbox, or frozen by a compliance action, the cap table reflects that change immediately because the cap table is that change.
This design eliminates an entire class of operational risk that plagues traditional cap table management: the drift between what the spreadsheet says and what actually happened. On Sails.to, ownership is on-chain, queries are against on-chain state, and every position is independently verifiable by anyone with a Solana RPC endpoint.
InvestorPosition PDA
Every investor's position in an offering is stored as a Program Derived Address — a deterministic on-chain account seeded from the offering ID and the investor's wallet address. The InvestorPosition PDA is the atomic unit of the cap table. One PDA per investor per offering, created when the investor first subscribes and updated with every subsequent event that affects their position.
InvestorPosition PDA
Seeds: ["investor_position", offering_id, wallet]
├── offering_id: Pubkey // The offering this position belongs to
├── wallet: Pubkey // The investor's Solana wallet address
├── balance: u64 // Current token balance (including locked tokens)
├── locked_until: i64 // Lock-up expiration timestamp (0 if unlocked)
├── accreditation_tier: u8 // Investor classification at time of subscription
├── jurisdiction: String // Two-letter country code (ISO 3166-1)
├── position_index: u32 // Sequential index for distribution bitmap
└── created_at: i64 // Timestamp of initial subscription
The PDA address is deterministic — given an offering ID and a wallet address, anyone can derive the PDA address and read the investor's position directly from the blockchain without going through the platform API. This is the verifiability guarantee: an investor can independently confirm their ownership using any Solana explorer or RPC client.
Key Fields
| Field | Type | Description |
|---|---|---|
offering_id |
Pubkey |
The offering this position belongs to. Links the investor to a specific Series within the DAO Series LLC. |
wallet |
Pubkey |
The investor's Solana wallet address. Combined with offering_id, this forms the unique seed pair for the PDA. |
balance |
u64 |
Current token balance. This includes tokens locked in the CrossConversion lockbox — locked tokens still count toward ownership, only the custody rail changes. |
locked_until |
i64 |
Unix timestamp when the lock-up period expires. Transfers are blocked by the Transfer Hook until this timestamp is reached. Set to 0 if no lock-up applies. |
accreditation_tier |
u8 |
The investor's accreditation classification at time of subscription: 1 = Accredited Investor, 2 = Qualified Purchaser, 3 = Institutional. Used by the Transfer Hook to enforce Reg D transfer restrictions. |
jurisdiction |
String |
Two-letter country code recorded from the investor's KYC credential. The Transfer Hook checks this against the offering's allowed jurisdictions whitelist on every transfer. |
The position_index field deserves special attention. It is assigned sequentially when the investor first subscribes and never changes — even if the investor sells all tokens and later reacquires them. This index maps the investor to a specific bit in the claimed_bitmap of DistributionRecord PDAs, enabling efficient tracking of which investors have claimed their distributions for each epoch.
OfferingState PDA
While InvestorPosition PDAs represent individual holdings, the OfferingState PDA provides the aggregate view of an offering — the total picture of supply, ownership, and status. There is exactly one OfferingState PDA per offering, created when the offering is initialized by the Issuer.
OfferingState PDA
Seeds: ["offering_state", series_id]
├── series_id: Text // The Series identifier within the DAO LLC
├── max_supply: u64 // Maximum token supply authorized for this offering
├── minted: u64 // Total tokens minted to date
├── locked_in_crossconv: u64 // Tokens currently locked in CrossConversion lockbox
├── nominal_value: u64 // Nominal value per token (in cents)
├── status: enum // Active, Paused, Closed, Redeemed
├── token_mint: Pubkey // Solana mint address for the security token
├── isin_code: Text // ISIN code (if CrossConversion enabled)
├── compliance_config: Pubkey // Reference to ComplianceConfig PDA
└── created_at: i64 // Offering initialization timestamp
Key Fields
| Field | Type | Description |
|---|---|---|
series_id |
Text |
The Series identifier within the Wyoming DAO Series LLC. Each offering maps 1:1 to a Series — the legal entity wrapper for the asset. |
max_supply |
u64 |
Maximum number of security tokens that can be minted for this offering. Set at initialization, immutable after first investor subscription. |
minted |
u64 |
Total tokens minted to date. The difference max_supply - minted represents remaining capacity. Incremented on every mint_security_token instruction. |
locked_in_crossconv |
u64 |
Tokens currently locked in the CrossConversion lockbox. These tokens are still part of the total supply and still count toward their holders' ownership — only the custody rail has changed from on-chain to bankable via Clearstream. |
nominal_value |
u64 |
Nominal value per token in cents. Used for valuation calculations and K-1 generation. Set at initialization. |
status |
enum |
Active (accepting subscriptions and transfers), Paused (temporarily halted by Trustee), Closed (no new subscriptions), or Redeemed (final redemption complete, tokens burned). |
The supply invariant minted == sum(all InvestorPosition.balance) is enforced at the program level. Every mint instruction increments minted and creates or updates the corresponding InvestorPosition. Every burn decrements both. There is no way for the aggregate to drift from the sum of individual positions — the smart contract makes it structurally impossible.
Cap Table Endpoints
The Cap Table API is exposed through the platform's API gateway at api.sails.to. Authentication uses Solana wallet signature + NFT verification. All endpoints return JSON. The data returned is read directly from on-chain state — there is no intermediate database or cache layer between the API and the blockchain.
REST Endpoints
| Endpoint | Method | Authorization | Description |
|---|---|---|---|
/v1/offerings/:id/cap-table |
GET |
Issuer NFT or Trustee NFT | Returns the full cap table for the offering — every InvestorPosition PDA associated with the offering ID. Includes wallet address, balance, lock-up status, accreditation tier, jurisdiction, and CrossConversion status. Supports pagination via ?page= and ?per_page= query parameters. |
/v1/offerings/:id/cap-table/summary |
GET |
Issuer NFT or Trustee NFT | Returns aggregate cap table metrics: total investors, total minted supply, tokens on-chain vs. tokens in CrossConversion lockbox, ownership concentration (top 10 holders), and jurisdiction breakdown. |
/v1/offerings/:id/cap-table/position/:wallet |
GET |
Issuer NFT, Trustee NFT, or investor wallet signature (own position only) | Returns a single investor's position: balance, ownership percentage, lock-up expiration, accreditation tier, jurisdiction, distribution history, and CrossConversion status for that position. |
/v1/offerings/:id/cap-table/snapshot |
POST |
Issuer NFT or Trustee NFT | Creates an on-demand ownership snapshot. Reads all InvestorPosition PDAs at the current Solana slot and returns a timestamped, immutable record of ownership. See Cap Table Snapshots below. |
Cap'n Proto Interface
The Offering Grain also exposes the cap table through its Cap'n Proto OfferingAPI interface for direct grain-to-grain communication via Powerbox. This is the method used by Investor Grains, Broker Grains, and the Compliance Grain to read cap table data without going through the REST API gateway.
# From sails/offering.capnp
interface OfferingAPI {
getCapTable @1 () -> (investors :List(InvestorPosition));
Returns the full list of InvestorPosition records for this offering.
Each InvestorPosition includes: offering_id, wallet, balance,
locked_until, accreditation_tier, jurisdiction, position_index.
Data is read directly from on-chain PDAs at the time of the call.
}
The getCapTable method returns a List(InvestorPosition) — the same data structure as the REST endpoint, but delivered through the Powerbox capability system. The calling grain must hold a valid capability for the Offering Grain: an OfferingAPI capability (for Issuers and Brokers) or a TrusteeView capability (for Trustees). Investor Grains hold InvestorView capabilities, which only expose their own position — not the full cap table.
Response Format
GET /v1/offerings/:id/cap-table
{
“offering_id”: “series-alpha-2024”,
“series_id”: “series-alpha”,
“snapshot_slot”: 285431200,
“total_investors”: 247,
“total_supply”: 1000000,
“locked_in_crossconv”: 150000,
“investors”: [
{
“wallet”: “7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgHkV”,
“balance”: 5000,
“ownership_percentage”: 0.50,
“locked_until”: “2025-06-15T00:00:00Z”,
“accreditation_tier”: 1,
“jurisdiction”: “US”,
“crossconversion_status”: {
“on_chain”: 3000,
“bankable”: 2000
},
“position_index”: 0
},
{
“wallet”: “9bKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAbC”,
“balance”: 12000,
“ownership_percentage”: 1.20,
“locked_until”: “2025-03-01T00:00:00Z”,
“accreditation_tier”: 2,
“jurisdiction”: “DE”,
“crossconversion_status”: {
“on_chain”: 12000,
“bankable”: 0
},
“position_index”: 1
}
]
}
Cap Table Snapshots
A cap table snapshot is a point-in-time record of all ownership positions for an offering, pulled directly from on-chain state. Snapshots are immutable — once created, they cannot be modified, because they reference a specific Solana slot number. Anyone can independently verify a snapshot by reading the same PDAs at the same slot using an archival RPC node.
Snapshots serve three primary purposes:
- Audit compliance: Auditors require a frozen view of ownership at specific dates — quarter-end, year-end, or the date of a specific event. Snapshots provide this without requiring the auditor to interact with the live ledger.
- Tax season: K-1 generation requires ownership percentages at each distribution epoch. The Compliance Grain uses cap table snapshots to determine each investor’s pro-rata share for tax reporting.
- Regulatory filings: Reg D and Reg S filings require accurate investor counts and ownership breakdowns by jurisdiction and accreditation tier. Snapshots provide this data in an exportable, verifiable format.
Snapshot Lifecycle
| Step | Action | Description |
|---|---|---|
| 1 | Request | An Issuer or Trustee calls POST /v1/offerings/:id/cap-table/snapshot with an optional label parameter (e.g., “Q4-2024-audit”, “year-end-2024”). |
| 2 | On-chain read | The Offering Grain reads all InvestorPosition PDAs for the offering at the current Solana slot. The slot number is recorded as the snapshot’s anchor point. |
| 3 | Hash & store | The snapshot data is hashed (SHA-256) and the hash is stored in the Offering Grain’s journal. The full snapshot data is stored alongside the hash for retrieval. |
| 4 | Export | The snapshot can be exported as JSON or CSV via GET /v1/offerings/:id/cap-table/snapshots/:snapshot_id. The export includes the Solana slot number and the SHA-256 hash for independent verification. |
Snapshot vs. Live Cap Table
The live cap table (via GET /v1/offerings/:id/cap-table) always returns the current state. A snapshot freezes the state at a specific moment. Both read from the same on-chain data — the difference is that a snapshot records the Solana slot number and preserves the data for historical reference. The live cap table is what you query to see who owns what right now. A snapshot is what you hand to an auditor to show who owned what on a specific date.
CrossConversion Impact
When an investor converts their on-chain tokens to bankable securities via CrossConversion, the cap table reflects a change in custody — not a change in ownership. The tokens are locked in the CrossConversion lockbox PDA, and the investor receives an equivalent position in Clearstream identified by the offering’s ISIN code. But the investor’s InvestorPosition PDA still reflects their full balance — locked tokens are included.
How the Cap Table Handles CrossConversion
| Scenario | On-Chain Balance | Lockbox | Bankable | Cap Table Balance |
|---|---|---|---|---|
| Investor holds 5,000 tokens on-chain | 5,000 | 0 | 0 | 5,000 |
| Investor converts 2,000 to bankable | 3,000 | 2,000 | 2,000 | 5,000 |
| Investor converts remaining 3,000 | 0 | 5,000 | 5,000 | 5,000 |
| Investor converts 1,000 back to on-chain | 1,000 | 4,000 | 4,000 | 5,000 |
The cap table provides a unified view regardless of format. An investor who has converted 100% of their tokens to bankable securities still appears on the cap table with their full balance. The crossconversion_status field in the API response breaks down how many tokens are held on-chain versus locked in the CrossConversion lockbox, but the total ownership never changes due to a CrossConversion event.
The supply invariant tokens_locked == isin_outstanding is enforced by the sails_crossconversion program and verified nightly by the reconciliation engine. If the lockbox PDA state and the Clearstream position report ever disagree, the Trustee is alerted immediately and further CrossConversion operations are suspended until the discrepancy is resolved.
Access Control
Cap table access is governed by the NFT role hierarchy. Different participants see different slices of the cap table based on the role NFT they hold. There is no single “cap table permission” — access is granular and role-specific.
| Role | Cap Table Access | Authorization |
|---|---|---|
| Issuer | Full cap table for their own offering(s). All investor positions, balances, jurisdictions, accreditation tiers, CrossConversion status. Can request snapshots and export data. | Issuer NFT linked to the specific offering |
| Trustee | Full cap table for all Series under their jurisdiction. Cross-offering view for audit and compliance purposes. Can request snapshots, trigger reconciliation, and export data for regulatory filings. | Trustee NFT issued by Platform Operator |
| Broker | Positions for investors they placed. A broker sees only the InvestorPosition PDAs for wallets that subscribed through their Broker Grain. No visibility into positions placed by other brokers. |
Broker NFT + placement records in Offering Grain |
| Investor | Their own position only. Token balance, ownership percentage, lock-up status, distribution history, and CrossConversion status for each offering they hold. No visibility into other investors’ positions. | Investor wallet signature + InvestorView Powerbox capability |
| Auditor | Read-only access to full cap table and historical snapshots for specified offerings. Can verify on-chain state, export snapshot data, and cross-reference with distribution records. Cannot modify any state. | Auditor NFT (time-limited, issued by Trustee) |
| Paying Agent | Read-only access to cap table for distribution calculation purposes. Sees balances and position indices needed to execute the waterfall and track claim status. | Paying Agent NFT issued by Trustee |
Powerbox Capability Scoping
Access control is enforced at two layers. First, the REST API gateway verifies the caller’s role NFT before routing the request to the Offering Grain. Second, the Offering Grain’s Cap’n Proto interface enforces capability-based scoping — an InvestorView capability physically cannot return other investors’ data because the interface definition only exposes the requesting investor’s position. This is not a permission check that could be bypassed; it is a structural constraint of the capability system.
Cap Table Access Flow:
Issuer NFT ──► API Gateway ──► Offering Grain
│
getCapTable() → Full List(InvestorPosition)
Investor Wallet ──► API Gateway ──► Investor Grain
│
InvestorView capability
│
Offering Grain
│
Own InvestorPosition only
The on-chain data itself is publicly readable — anyone with a Solana RPC endpoint can read any PDA. The access control layer governs what the platform API exposes, not what the blockchain stores. This is intentional: the cap table’s verifiability guarantee depends on the data being publicly auditable on-chain, while the API layer provides role-appropriate views for operational use.