PUBLIC URL TEXT AND CANDIDATE REGION
An invite link tells the browser what to try; it does not prove who issued it
The current parser reads a referrer from ref, invite, or inviter; a Guardian identifier from guardian or guardianId; and a Region from guardianRegion, region, a coordinate-looking Guardian value, or separate guardianX and guardianY fields. These are ordinary URL query parameters. A player, chat app, redirector, or copied link can change them before the page opens. There is no signature over the URL, no nonce, no expiry, no server-issued capability, and no contract instruction that authenticates those strings merely because the page received them.
There is also a concrete empty-query boundary. parseInviteParams first defaults guardianId to genesis. Its hasInvite expression then tests that already-defaulted value, so hasInvite becomes true even when the URL supplied no invite fields. Separately, parseGuardianRegionParams converts two missing coordinate strings with Number(""), producing integer zero for each and returning Region 0:0. The focused tests deliberately record this current behavior. It should be understood as an implementation defect or fallback leak, not evidence that every ordinary visit was invited by Genesis.
Character creation calculates a candidate spawn whether or not a valid referrer exists. It prefers the parsed Region, otherwise tries to resolve the Guardian identifier from the public registry with a 2.5-second timeout, and otherwise uses Region 0:0. For Region r, the helper chooses the center Chunk of a 100-by-100 Region, multiplies by the 16-block Chunk size, asks the current world generator for surface height, and places Y 1.01 above that surface. This is deterministic browser math over the configuration it loaded; it is absent from the character transaction.
Only later, after the character transaction returns, does the page treat ref as a proposed inviter wallet and try a separate InviteIndex append. A malformed public key, missing first page, self-invite, rejected wallet request, program error, or RPC failure can make that attempt remain pending or failed while the character already exists. The browser also stores a local invite receipt label, but that label is not an InviteIndex field and cannot turn an edited URL into an inviter-signed statement.
They are editable URL text and may be copied, shortened, reordered, or replaced without an inviter signature.
Defaulting genesis and converting empty coordinate strings to zero currently leak an invite-looking fallback into a plain visit.
The candidate uses the Region center and a locally calculated surface height.
No inviter signature, secret token, nonce, expiry, or chain receipt is embedded or verified by this parser.
- Read the hints
The browser normalizes whichever supported query names are present and carries them across login and character-creation redirects.
- Calculate a candidate
The browser resolves or falls back to a Region, computes its center, and calculates a terrain-height-based position locally.
- Approve separate actions
The owner first approves character creation. If a valid referrer remains, the invited wallet is then asked to approve a different invite-append transaction.
- Judge each result separately
A character signature, invite signature, local receipt, and applied Play start are four different observations; one does not silently stand in for the others.
GLOBAL NAME LOOKUP AND HISTORICAL OCCUPANCY
UsernameIndex makes a name unique, but it neither tracks invitations nor releases old names
A proposed nonempty name is normalized and hashed before its account address is derived. Only ASCII uppercase letters are lowered, so Alice and aLiCe share one hash and one UsernameIndex. The PDA seeds contain the fixed player-name-v1 prefix and the 32-byte hash; they do not contain the owner wallet. That makes the address global within the Player Program rather than one private name slot per player. A confirmed lookup can report that the account is missing or already owned by this wallet, but only an accepted transaction creates or validates the record.
The account is exactly 256 bytes. Its header and fixed fields store magic, version, bump, initialized state, owner, PlayerProfile, GlobalConfig, the 32-byte canonical hash, display-name byte length, created slot, and updated slot. The final 96 bytes hold the original accepted spelling padded with zeros. The contract accepts at most 32 characters from underscore, ASCII letters and digits, or U+4E00 through U+9FFF, so 32 three-byte Chinese characters fit that 96-byte stored spelling region.
When the expected UsernameIndex already belongs to the Player Program, the program validates the hash, GlobalConfig, owner, and PlayerProfile and returns. It does not rewrite the original spelling or updated slot. If the same address names a different owner or profile, the instruction fails with UsernameAlreadyTaken. This is why the index is useful for uniqueness, but it also means the record is historical ownership evidence rather than a guaranteed mirror of the player's current display fields.
Changing a Profile name writes the new name and then ensures the new UsernameIndex inside the same atomic instruction. If the later index check fails, Solana rolls the entire instruction back. If it succeeds, however, there is no close or release of the earlier index. Clearing the Profile name likewise omits an index account and leaves every earlier name address untouched. A wallet can therefore occupy several names it used over time. UsernameIndex contains no inviter, invited-wallet row, Guardian, Region, spawn, reward, or status field, and it cannot substitute for InviteIndex.
The finalized Devnet rent query observed 2,672,640 lamports, or 0.002672640 SOL, for 256 bytes on 16 July 2026. The payer funds a new index or a shortfall during creation, but the current Player Program exposes no UsernameIndex close instruction. Calling that balance a refundable deposit would promise a recovery path that the implemented contract does not provide.
The owner is stored inside the account but is not one of the name PDA seeds.
The last 96 bytes retain the original display spelling; the hash uses the canonical spelling.
No current tag automatically releases, transfers, or closes the earlier UsernameIndex.
A finalized point-in-time Devnet rent-exempt minimum, not a transaction fee or automatic refund.
A UsernameIndex cannot prove an invite, append signature, candidate Region, or reward status.
PAGED PUBLIC RECORDS, SIGNERS, AND LIMITS
The invited wallet signs one append into the inviter's page; the inviter does not sign every row
InviteIndex has a different seed, address formula, byte layout, signer, and purpose from UsernameIndex. One page is derived from invite-index-v1, the inviter's 32-byte public key, and a little-endian u32 page number. Its 128-byte header stores identity and page metadata. Sixty-four fixed 40-byte records follow, each containing exactly one 32-byte invited wallet and one 8-byte registration slot. The account contains no name, Guardian, Region, candidate spawn, local receipt, reward entitlement, claim state, transfer amount, or inviter signature per row.
The inviter normally opens page zero first. Tag 10 requires a writable signing payer, but only page zero requires payer and inviter to be the same address. For pages above zero, any payer can initialize the supplied inviter's correct PDA. The instruction also returns early after validating a page already owned by the Player Program. It never checks that the preceding page exists or is full before independently creating a higher page.
Tag 11 makes the invited wallet the required writable signer and rejects only invited equal to inviter. The inviter account is read as a public address and does not sign. If a requested page above zero is missing, the invited wallet can fund and create it only after the program validates that the immediately preceding page is full. But that continuity check sits entirely inside the missing-page branch. A page pre-created through tag 10 is already program-owned, so append skips the preceding-page test and writes to that page. Continuous 0, 1, 2 paging is therefore the normal client plan, not an unconditional contract invariant.
The append routine reads count, rejects a full page, writes the invited key and slot at offset 128 plus count times 40, increments count, and updates the page slot. It never searches this page or any other page for the wallet. The current browser first reads pages and performs a best-effort duplicate check, but that read and append are separate network operations, two concurrent attempts can race, and rows outside its scan are invisible to that check. The contract prevents self-invite but does not prevent duplicate registration.
Current invite registration and alternate-panel call paths request 16 page addresses, and the reader stops at the first missing, invalid, or not-full page. That means the alternate invite panel can expose at most 1,024 fetched rows, even though fetchInviteIndexPages can accept a larger request of up to 64 pages. After 16 full fetched pages, the registration path constructs page index 16 and can append there, but its next scan still covers only indexes 0 through 15. It keeps selecting index 16 without seeing its count; once that hidden page fills, the current rollover path cannot naturally choose page 17. Gaps and pre-created high pages can also stay invisible because the reader stops early.
At the finalized Devnet snapshot, the Player Program had one observed InviteIndex page at HMbG3jiBpBfFEQmggb5gGyvXhsQzTuCy3zttNSXCpiX. Page zero contained two different wallets in two rows, used 2 of 64 slots, held exactly 19,599,360 lamports, and had no later page through the audit's derived page-63 check. That observation is useful evidence of current state, not proof that duplicates or gaps are impossible. The deployed ELF matched the retained 172,880-byte local artifact, while dirty source still cannot be called reproducibly deployed merely from that match.
Each row is only an invited public key plus registration slot.
The inviter is supplied as a non-signing address for each append.
The client attempts a prior read-and-check, but the program's append itself does not enforce uniqueness.
The helper stops early at a gap or partial page and cannot see index 16 in its default read.
Finalized Devnet minimum for 2,688 bytes at the recorded query, with no InviteIndex close path implemented.
A dated snapshot at finalized slots, not a lifetime count or a uniqueness guarantee.
- Inviter opens page zero
The current first-page helper asks the inviter wallet to sign and fund its page-zero PDA before invitees can append.
- Invitee reads visible pages
The browser fetches up to sixteen predictable page addresses, stops at the first boundary, and checks the fetched rows for its own wallet.
- Invitee approves append
The invited wallet signs tag 11. If normal rollover must create a missing page, that same invited wallet also funds the new page.
- Readers reconstruct meaning
A reader may separately fetch Appearance to display a name, but the fixed InviteIndex row itself remains just wallet plus slot.
LOCAL DRAFT, CURRENT STARTUP, AND POSITION AUTHORITY
Character creation saves a candidate spawn, but current Play never puts it into the startup choice
After the owner-signed character transaction returns, character creation writes ordinary character cache fields and calls storePendingInviteSpawn with the candidate position, yaw, camera pitch, Region, referrer, and Guardian identifier. That helper uses a wallet-scoped nicechunk.inviteSpawn.v1.<wallet> key, adds storedAt, catches any localStorage exception, and returns no success value. A full, blocked, disabled, or privacy-restricted storage implementation can therefore discard the draft while the page continues. The candidate is not a Player Program account and is not part of tag 8.
A consumer function exists in src/player/inviteSpawn.js. It reads the wallet-scoped draft, removes it before parsing, and returns either the stored position or a recalculated Region center. It has no age or expiry check. Its catch block calls localStorage.removeItem again outside another nested guard, so a storage implementation that throws on both read and removal can still let the cleanup exception escape. The focused tests record single-use behavior, malformed cleanup, and this double-failure boundary.
Defining a consumer does not prove that the active game calls it. The current production entry is play/main.js. Its imports include its own saved-position helper and pose parser, but not consumePendingInviteSpawn. At module startup it chooses queryPose, otherwise the wallet-agnostic saved Play position, otherwise a fixed Genesis center. Direct x, y, and z query values can then override axes. A forced Guardian-start flag skips the saved position but still falls through to the hard-coded Region-zero default. The pending invite-spawn key never appears in that selection.
The dated production capture at https://nicechunk.com/play/ used the then-current production runtime, the nicechunk-mainnet-001 world seed, and explicit X 800 and Z 800 at the Genesis center. The retained image is a real game-canvas capture, not generated teaching art. A later current-runtime browser recheck at 15:03:35 UTC on 16 July 2026 stored a sentinel pending-spawn value before boot; that value remained afterward, the page exposed no invite panel, three canvases were present, and no console or page error was observed. Those dated observations match the source boundary but do not prove what every future build will do.
Play later starts an asynchronous Profile and Appearance refresh. That can update identity presentation and provide public Profile position data to the interface, but the audited startup does not teleport the already-created player object to that fetched Profile. Normal local movement uses a different saved-position key, and current chain position writes are gated to accepted resource-mine saves. An invite append, local receipt, Profile refresh, and actual position are therefore separate state transitions.
Wallet-scoped best-effort draft written after character confirmation.
A different wallet-agnostic local position selected before the game boots.
Pending invite spawn is absent; per-axis x, y, and z query parameters can override the resulting coordinates.
Half of 100 Chunks multiplied by 16 blocks for both axes.
A runtime that does call the consumer can accept an old draft unless another layer clears it.
An alternate src/main.js path consumes it, but that is not the reviewed production Play entry.
READ THE IMPLEMENTATION
Derive each address, inspect each signer, and keep browser state outside contract claims
The formulas and exact source excerpts below turn the visual lesson into reproducible checks. They show how URL text becomes an unauthenticated hint, how names and invite pages derive different PDAs, how fixed bytes are laid out, why duplicate and page-continuity claims have limits, and why the current Play startup cannot apply a draft it never reads.
Canonical name and UsernameIndex address
canonical(n) = ASCII-lowercase(n); h(n) = SHA256(UTF8(canonical(n))); UsernameIndex(n) = PDA_Player(["player-name-v1", h(n)])The owner wallet is not a seed, so everyone derives the same address for Alice and alice. Ownership is checked in the stored bytes when the instruction executes.
- n
- The trimmed proposed player name.
- ASCII-lowercase
- Change only ASCII A through Z to a through z; leave other UTF-8 characters unchanged.
- PDA_Player
- Solana program-address derivation under the Player Program ID.
UsernameIndex fixed storage
256 bytes = 160 bytes header-and-fields + 96 bytes original display-name regionThe account also stores the canonical 32-byte hash in the fixed region. The original spelling is retained separately and is not automatically rewritten on a same-index validation.
- 160
- Magic, version, bump, state, three public keys, name hash, length, slots, and padding.
- 96
- Maximum UTF-8 bytes retained for the accepted display spelling.
Invite page address
InvitePage(inviter, i) = PDA_Player(["invite-index-v1", inviter32, u32LE(i)])Changing either the inviter or page number selects a different predictable account. A predictable address does not prove the page exists, is continuous, or was opened by the inviter.
- inviter32
- The inviter's 32-byte Solana public key.
- u32LE(i)
- The page number encoded as four little-endian bytes.
InviteIndex page and row layout
pageBytes = 128 + 64 × 40 = 2,688; rowOffset(k) = 128 + 40k, for 0 ≤ k < 64Every row is one 32-byte wallet plus one 8-byte registration slot. The illustration's wooden cubbies are not a countable storage diagram; this formula and the source constants are the technical boundary.
- 128
- Fixed page header.
- 40
- One invited-wallet and registration-slot record.
- k
- The zero-based row selected by the current count.
Normal contiguous paging convention
page(k) = floor(k / 64); row(k) = k mod 64This is the intended client organization when pages are created only during rollover. Tag 10 can pre-create a higher page without proving prior pages are full, so this formula is not an unconditional current contract invariant.
- k
- A conceptual zero-based entry number in a gap-free ledger.
- mod
- Remainder after division by 64.
Candidate Region-center position
x = (regionX × 100 + 50) × 16; z = (regionY × 100 + 50) × 16; y = surfaceHeight(x, z) + 1.01The 100 comes from Guardian Region size in Chunks and 16 from Chunk size in blocks. This is local candidate math; no InviteIndex row stores these coordinates.
- regionX, regionY
- Integer Guardian Region coordinates.
- surfaceHeight
- The current browser world generator's terrain-height result at that X and Z.
Current production Play startup
spawnState = queryPose ?? savedPlayPosition ?? Genesis(800, 800); pendingInviteSpawn is not an operandDirect x, y, and z query parameters can then override axes. The Profile refresh starts later and does not retroactively change this startup expression.
- ??
- Choose the first value that is not null or undefined.
- savedPlayPosition
- The separate nicechunk.chunkjs.playable.position.v2 browser record.
Observed Devnet storage minima
R(256) = 2,672,640 lamports; R(2,688) = 19,599,360 lamports; 1 SOL = 1,000,000,000 lamportsThese are finalized point-in-time minimum-balance queries. They are not transaction fees, reward values, or promises that a player can close the account and recover the balance.
- R(L)
- Observed minimum balance for an account of L bytes.
- lamport
- The smallest native SOL unit.
The parser defaults genesis before it computes hasInvite
JavaScriptsrc/player/inviteSpawn.jsexport function parseInviteParams(input = typeof window !== "undefined" ? window.location.search : "") {
const params = input instanceof URLSearchParams ? input : new URLSearchParams(String(input || ""));
const referrer = firstParam(params, [inviteRefParam, "invite", "inviter"]);
const guardianId = firstParam(params, [inviteGuardianParam, "guardianId"]) || "genesis";
const region = parseGuardianRegionParams(params);
return {
referrer: normalizeParam(referrer),
guardianId: normalizeParam(guardianId) || "genesis",
region,
hasInvite: Boolean(normalizeParam(referrer) || normalizeParam(guardianId) || region),
};
}
guardianId is already genesis when the URL contains no Guardian field, so the Boolean expression is true on an empty query. Nothing in this function verifies an inviter signature or capability.
- 1-4
Normalize URL input, read public referrer aliases, and replace a missing Guardian with genesis.
- 5-11
Return normalized fields; the already-nonempty default makes hasInvite true even without supplied invite text.
Missing coordinate strings currently become Region zero
JavaScriptsrc/player/inviteSpawn.jsfunction parseGuardianRegionParams(params) {
const explicit = firstParam(params, [inviteGuardianRegionParam, "region"]);
const parsedExplicit = parseRegionPair(explicit);
if (parsedExplicit) return parsedExplicit;
const guardian = firstParam(params, [inviteGuardianParam, "guardianId"]);
const parsedGuardian = parseRegionPair(guardian);
if (parsedGuardian) return parsedGuardian;
const x = Number(firstParam(params, ["guardianX", "regionX"]));
const y = Number(firstParam(params, ["guardianY", "regionY"]));
if (Number.isInteger(x) && Number.isInteger(y)) return { regionX: x, regionY: y };
return null;
}
firstParam returns an empty string for each missing coordinate. JavaScript Number converts that empty string to zero, and zero is an integer, so a plain visit currently returns Region 0:0.
- 1-7
Prefer explicit pair text, then a coordinate-looking Guardian value.
- 8-11
Convert separate coordinate fields; the missing-string conversion is the empty-query defect.
The candidate spawn is local Region-center math
JavaScriptsrc/player/inviteSpawn.jsexport function guardianSpawnStateForRegion(region, { surfaceHeight, yaw = Math.PI * 0.25, cameraPitch = -0.42 } = {}) {
const normalized = normalizeGuardianRegion(region) ?? genesisGuardianRegion();
const centerChunkX = normalized.regionX * GUARDIAN_REGION_SIZE + GUARDIAN_REGION_SIZE / 2;
const centerChunkZ = normalized.regionY * GUARDIAN_REGION_SIZE + GUARDIAN_REGION_SIZE / 2;
const x = centerChunkX * chunkSize;
const z = centerChunkZ * chunkSize;
const ground = typeof surfaceHeight === "function" ? surfaceHeight(x, z) : 0;
const y = Number.isFinite(ground) ? ground + 1.01 : 1.01;
return {
position: { x, y, z },
yaw,
cameraPitch,
guardianRegion: normalized,
};
}
The helper returns an ordinary JavaScript object from public Region input and local terrain math. It does not read an InviteIndex, sign a transaction, or update PlayerProfile position.
- 1-6
Normalize or fall back to Genesis, choose the Region center in Chunks, then convert to block coordinates.
- 7-14
Resolve local ground height and return position plus camera hints.
The client hashes only ASCII case before deriving the name PDA
JavaScriptsrc/chain/nicechunkChain.jsasync function canonicalPlayerNameHash(playerName) {
const { normalized } = encodePlayerName(playerName);
const canonical = normalized.replace(/[A-Z]/g, (char) => char.toLowerCase());
const bytes = Buffer.from(canonical, "utf8");
const subtle = globalThis.crypto?.subtle;
if (!subtle) throw new Error("WebCrypto SHA-256 is unavailable.");
const digest = await subtle.digest("SHA-256", bytes);
return Buffer.from(new Uint8Array(digest));
}
The browser and program use the same narrow case rule. SHA-256 produces the 32-byte seed, but hashing does not reserve an account or reveal who owns it.
- 1-4
Validate and trim the name, lower only ASCII uppercase letters, and encode UTF-8 bytes.
- 5-8
Require WebCrypto, calculate SHA-256, and return the digest bytes used for PDA derivation.
The name PDA has no owner seed
JavaScriptsrc/chain/nicechunkChain.jsexport async function deriveUsernameIndexPdaForName(playerName) {
const nameHash = await canonicalPlayerNameHash(playerName);
return PublicKey.findProgramAddressSync(
[Buffer.from(usernameIndexSeed), nameHash],
playerProgramId,
);
}
Every caller derives the same Player Program address from the fixed seed and canonical name hash. The owner relationship must therefore be validated from account contents.
- 1-2
Calculate the canonical name hash.
- 3-6
Derive under the Player Program with only the fixed name seed and hash.
An existing UsernameIndex must match owner and Profile
Rustprograms/nicechunk_player/src/state.rs pub fn validate_owner_or_available(
data: &[u8],
owner: &Pubkey,
player_profile: &Pubkey,
global_config: &Pubkey,
name_hash: &[u8; 32],
) -> ProgramResult {
if data.len() != Self::LEN || data[0..8] != USERNAME_INDEX_MAGIC {
return Err(NicechunkPlayerError::InvalidUsernameIndexData.into());
}
if read_u16(data, 8) != USERNAME_INDEX_VERSION {
return Err(NicechunkPlayerError::InvalidUsernameIndexData.into());
}
if &data[Self::NAME_HASH_OFFSET..Self::NAME_HASH_OFFSET + 32] != name_hash {
return Err(NicechunkPlayerError::InvalidUsernameIndexData.into());
}
if &data[Self::GLOBAL_CONFIG_OFFSET..Self::GLOBAL_CONFIG_OFFSET + 32]
!= global_config.as_ref()
{
return Err(NicechunkPlayerError::InvalidUsernameIndexData.into());
}
if &data[Self::OWNER_OFFSET..Self::OWNER_OFFSET + 32] != owner.as_ref() {
return Err(NicechunkPlayerError::UsernameAlreadyTaken.into());
}
if &data[Self::PLAYER_PROFILE_OFFSET..Self::PLAYER_PROFILE_OFFSET + 32]
!= player_profile.as_ref()
{
return Err(NicechunkPlayerError::UsernameAlreadyTaken.into());
}
Ok(())
}
A program-owned index is accepted only when its format, hash, configuration, owner, and Profile relationship all match. The function validates; it does not rewrite spelling, release a prior index, or inspect invitations.
- 1-16
Check the fixed account identity, version, canonical hash, and GlobalConfig.
- 17-29
Treat a different stored owner or PlayerProfile as taken; accept only the matching relationship.
Changing Profile name ensures a new index but closes no old index
Rustprograms/nicechunk_player/src/lib.rs let clock = Clock::get()?;
let mut data = player_profile.try_borrow_mut_data()?;
PlayerProfile::write_name(&mut data, player_name, clock.slot)?;
drop(data);
if let (Some(system_program_account), Some(username_index)) =
(system_program_account, username_index)
{
ensure_username_index_current(
authority,
username_index,
system_program_account,
program_id,
authority.key,
player_profile.key,
global_config.key,
player_name,
&clock,
)?;
}
Ok(())
The instruction writes the current Profile name and then ensures the supplied new-name index. Solana rolls both back if the later check fails. On success, this path never receives or closes the earlier UsernameIndex, so its lamports and ownership remain.
- 1-4
Write the proposed current name and update slot in the borrowed Profile data.
- 5-18
For a nonempty name, create or validate only the supplied current-name index.
- 19
Return without any earlier-name account or close operation in this instruction.
Invite pages have a fixed 64-by-40-byte record area
Rustprograms/nicechunk_player/src/state.rspub const INVITE_INDEX_MAGIC: [u8; 8] = *b"NCKINV01";
pub const INVITE_INDEX_VERSION: u16 = 1;
pub const INVITE_INDEX_SEED: &[u8] = b"invite-index-v1";
pub const INVITE_INDEX_CAPACITY: usize = 64;
pub const INVITE_INDEX_HEADER_LEN: usize = 128;
pub const INVITE_INDEX_RECORD_LEN: usize = 40;
The constants define the identity and exact storage math. A visual cabinet is only a metaphor; the implemented page is 128 header bytes plus 64 fixed records.
- 1-3
Name the on-chain format, version, and PDA seed.
- 4-6
Fix capacity, header length, and record length used by pack, validate, and append.
Append writes the next wallet and slot without deduplication
Rustprograms/nicechunk_player/src/state.rs pub fn append(dst: &mut [u8], invited: &Pubkey, updated_slot: u64) -> ProgramResult {
if dst.len() != Self::LEN || dst[0..8] != INVITE_INDEX_MAGIC {
return Err(NicechunkPlayerError::InvalidInviteIndexData.into());
}
let count = read_u16(dst, Self::COUNT_OFFSET) as usize;
if count >= INVITE_INDEX_CAPACITY {
return Err(NicechunkPlayerError::InviteIndexPageFull.into());
}
let offset = Self::RECORDS_OFFSET + count * INVITE_INDEX_RECORD_LEN;
dst[offset..offset + 32].copy_from_slice(invited.as_ref());
dst[offset + 32..offset + 40].copy_from_slice(&updated_slot.to_le_bytes());
dst[Self::COUNT_OFFSET..Self::COUNT_OFFSET + 2]
.copy_from_slice(&((count + 1) as u16).to_le_bytes());
dst[Self::UPDATED_SLOT_OFFSET..Self::UPDATED_SLOT_OFFSET + 8]
.copy_from_slice(&updated_slot.to_le_bytes());
Ok(())
}
After format and capacity checks, append writes directly at the count-selected offset. No line scans the current or other pages for the invited public key.
- 1-8
Validate page identity, read count, and stop only when capacity is exhausted.
- 9-15
Write the 32-byte wallet and 8-byte slot, increment count, and update the page slot.
- 16-17
Return success without a duplicate-wallet branch.
Tag 10 restricts page zero but can independently open higher pages
Rustprograms/nicechunk_player/src/lib.rs if !payer.is_signer || !payer.is_writable {
return Err(NicechunkPlayerError::InvalidPayer.into());
}
if !invite_index.is_writable {
return Err(NicechunkPlayerError::InvalidWritableAccount.into());
}
if page_index == 0 && payer.key != inviter.key {
return Err(NicechunkPlayerError::InviteFirstPageRequiresInviter.into());
}
require_key_eq(
system_program_account.key,
&system_program::ID,
NicechunkPlayerError::InvalidSystemProgram,
)?;
require_key_eq(
global_config.owner,
&NICECHUNK_CORE_PROGRAM_ID,
NicechunkPlayerError::InvalidGlobalConfigOwner,
)?;
let (expected_invite_index, bump) = invite_index_pda(program_id, inviter.key, page_index);
require_key_eq(
invite_index.key,
&expected_invite_index,
NicechunkPlayerError::InvalidInviteIndexPda,
)?;
if invite_index.owner == program_id {
let data = invite_index.try_borrow_data()?;
return InviteIndex::validate(&data, inviter.key, global_config.key, page_index);
}
The payer signs, and page zero alone binds payer to inviter. For a correct higher-page PDA, this excerpt has no previous-page check. An already-created page is simply validated and returned.
- 1-9
Require a signing payer and writable page; require inviter self-funding only for page zero.
- 10-26
Validate system, config owner, and the inviter-plus-page PDA.
- 27-30
If the page already exists under the Player Program, validate it without any continuity test.
Tag 11 requires the invited wallet—not the inviter—to sign
Rustprograms/nicechunk_player/src/lib.rs if !invited.is_signer || !invited.is_writable {
return Err(NicechunkPlayerError::InvalidPayer.into());
}
if invited.key == inviter.key {
return Err(NicechunkPlayerError::InvalidInviteSelf.into());
}
if !invite_index.is_writable {
return Err(NicechunkPlayerError::InvalidWritableAccount.into());
}
The wallet being recorded must sign and can pay for rollover. The inviter is compared as a public key for self-invite but is not required to be a signer in this account check.
- 1-3
Require the invited account to sign and be writable.
- 4-9
Reject self-invite and require the target page to be writable.
The previous-full rule runs only when the target page is missing
Rustprograms/nicechunk_player/src/lib.rs if invite_index.owner != program_id {
if page_index == 0 {
return Err(NicechunkPlayerError::InviteFirstPageRequiresInviter.into());
}
if accounts.len() != 6 {
return Err(NicechunkPlayerError::InvitePreviousPageRequired.into());
}
let previous_invite_index = next_account_info(account_info_iter)?;
let previous_page_index = page_index.saturating_sub(1);
let (expected_previous, _) = invite_index_pda(program_id, inviter.key, previous_page_index);
require_key_eq(
previous_invite_index.key,
&expected_previous,
NicechunkPlayerError::InvalidInviteIndexPda,
)?;
require_key_eq(
previous_invite_index.owner,
program_id,
NicechunkPlayerError::InvalidInviteIndexData,
)?;
let previous_data = previous_invite_index.try_borrow_data()?;
if !InviteIndex::is_full(
&previous_data,
inviter.key,
global_config.key,
previous_page_index,
)
.map_err(|_| NicechunkPlayerError::InvalidInviteIndexData)?
{
return Err(NicechunkPlayerError::InvitePreviousPageNotFull.into());
}
drop(previous_data);
if invite_index.owner != &system_program::ID || invite_index.data_len() != 0 {
return Err(NicechunkPlayerError::InvalidSystemAccount.into());
}
create_or_allocate_invite_index_pda(
invited,
invite_index,
system_program_account,
program_id,
inviter.key,
page_index,
bump,
)?;
let clock = Clock::get()?;
{
let mut data = invite_index.try_borrow_mut_data()?;
InviteIndex::pack_empty(
&mut data,
&InviteIndexInitArgs {
bump,
inviter: inviter.key,
global_config: global_config.key,
page_index,
created_slot: clock.slot,
},
)?;
}
}
let clock = Clock::get()?;
let mut data = invite_index.try_borrow_mut_data()?;
InviteIndex::validate(&data, inviter.key, global_config.key, page_index)?;
InviteIndex::append(&mut data, invited.key, clock.slot)
Normal rollover checks and funds a missing higher page only after the prior page is full. If tag 10 already made the target Player-owned, the outer if is false and execution jumps to validate and append, bypassing continuity.
- 1-31
For a missing page, forbid page zero, demand the previous page, validate it, and require it to be full.
- 32-58
Require an untouched System account, let the invited wallet fund it, and pack the new page.
- 61-64
For both new and already-existing pages, validate the target and append; an existing high page reached this point without the prior-full branch.
The browser reader scans a bounded contiguous prefix
JavaScriptsrc/chain/nicechunkChain.jsexport async function fetchInviteIndexPages(inviterAddress, { maxPages = 16 } = {}) {
if (!inviterAddress) return { inviter: "", pages: [], entries: [], capacity: inviteIndexCapacity };
const inviter = typeof inviterAddress === "string" ? new PublicKey(inviterAddress) : inviterAddress;
const pageCount = Math.max(1, Math.min(64, Math.floor(Number(maxPages) || 16)));
const pageKeys = Array.from({ length: pageCount }, (_value, index) => deriveInviteIndexPda(inviter, index)[0]);
const accounts = await getNicechunkConnection().getMultipleAccountsInfo(pageKeys, "confirmed");
const pages = [];
const entries = [];
let sawMissingAfterData = false;
for (let index = 0; index < pageKeys.length; index += 1) {
const account = accounts[index];
if (!account?.data?.length || !account.owner.equals(playerProgramId)) {
if (pages.length) sawMissingAfterData = true;
if (!pages.length || sawMissingAfterData) break;
continue;
}
const page = decodeInviteIndex(account.data, {
publicKey: pageKeys[index],
inviter,
pageIndex: index,
programId: account.owner,
});
if (!page) break;
pages.push(page);
for (const entry of page.entries) entries.push(entry);
if (page.count < inviteIndexCapacity) break;
}
The default request derives only indexes 0 through 15 and stops at a missing, invalid, or not-full page. A valid page beyond that stopping point can exist without appearing in the returned ledger.
- 1-6
Clamp the requested count and fetch one predictable prefix at confirmed.
- 7-16
Walk in order and stop as soon as a missing boundary is observed.
- 17-26
Decode, accumulate, and stop again when the current page is not full.
Client deduplication is a read-before-write convenience
JavaScriptsrc/chain/nicechunkChain.js const current = await fetchInviteIndexPages(inviter, { maxPages: 16 });
if (!current.pages.length) {
return { submitted: false, reason: "invite-first-page-required", inviter: inviter.toBase58() };
}
const invitedWallet = provider.publicKey.toBase58();
if (current.entries.some((entry) => String(entry.invitedWallet || entry.wallet || "") === invitedWallet)) {
return {
submitted: false,
reason: "already-registered",
invited: invitedWallet,
inviter: inviter.toBase58(),
capacity: inviteIndexCapacity,
};
}
let targetPage = current.pages.find((page) => page.count < inviteIndexCapacity);
if (!targetPage) {
const nextIndex = current.pages.length;
const [publicKey] = deriveInviteIndexPda(inviter, nextIndex);
targetPage = { pageIndex: nextIndex, publicKey: publicKey.toBase58(), count: 0 };
}
The browser checks only rows returned by the bounded read, then separately submits later. This avoids common repeats but cannot provide atomic or protocol-wide uniqueness, and nextIndex is based only on the number of visible pages.
- 1-4
Read at most sixteen pages and require a visible first page.
- 5-14
Return early when the invited wallet appears in the fetched prefix.
- 15-20
Use a visible partial page or derive the next index from visible page count.
The decoder invents the display status registered
JavaScriptsrc/chain/nicechunkChain.js const entries = [];
for (let index = 0; index < count; index += 1) {
const offset = inviteIndexHeaderLength + index * inviteIndexRecordLength;
const invited = new PublicKey(data.subarray(offset, offset + 32)).toBase58();
entries.push({
invitedWallet: invited,
wallet: invited,
pageIndex: storedPageIndex,
index,
createdSlot: data.readBigUInt64LE(offset + 32).toString(),
status: "registered",
});
}
The bytes provide the wallet and slot. status is a JavaScript label attached during decoding, not a field that the program packed or a reward state that the account can enforce.
- 1-4
For each stored count, calculate the fixed record offset and read the public key.
- 5-12
Build a display object and add a registered label that was not stored in the row bytes.
Pending-spawn storage is best effort and consumption is single use
JavaScriptsrc/player/inviteSpawn.jsexport function storePendingInviteSpawn(walletAddress, payload) {
const wallet = normalizeParam(walletAddress);
if (!wallet || !payload) return;
try {
localStorage.setItem(inviteSpawnStorageKey(wallet), JSON.stringify({
...payload,
storedAt: Date.now(),
}));
} catch {
// Spawn still works from the chain PDA if local storage is unavailable.
}
}
export function consumePendingInviteSpawn(walletAddress, { surfaceHeight } = {}) {
const wallet = normalizeParam(walletAddress);
if (!wallet) return null;
const key = inviteSpawnStorageKey(wallet);
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
localStorage.removeItem(key);
const parsed = JSON.parse(raw);
const state = parsed?.position
? {
position: parsed.position,
yaw: Number.isFinite(Number(parsed.yaw)) ? Number(parsed.yaw) : Math.PI * 0.25,
cameraPitch: Number.isFinite(Number(parsed.cameraPitch)) ? Number(parsed.cameraPitch) : -0.42,
guardianRegion: normalizeGuardianRegion(parsed.guardianRegion),
}
: guardianSpawnStateForRegion(parsed?.guardianRegion, { surfaceHeight });
return state;
} catch {
localStorage.removeItem(key);
return null;
}
}
Saving catches failures and returns no persistence signal. A caller that uses the consumer removes before parsing and performs no storedAt age check; cleanup can itself throw if storage removal fails inside the catch.
- 1-12
Attempt a wallet-scoped JSON write with storedAt, then silently continue on failure.
- 14-31
Read once, remove immediately, parse a stored position or recalculate from Region, and return it.
- 32-35
Try removal again on failure; the second removal is not protected by another nested catch.
Current Play chooses query, its own saved position, or Genesis
JavaScriptplay/main.jsconst queryPose = parsePoseText(params.get("pose") || "", { minViewDistance: 2, maxViewDistance: PLAYABLE_MAX_VIEW_DISTANCE });
const PLAYABLE_WORLD_SEED = "nicechunk-mainnet-001";
const PLAYABLE_TEXTURE_TILE_SIZE = 32;
const viewDistance = clampInt(Number(params.get("view")) || queryPose?.viewDistance || DEFAULT_PLAY_VIEW_DISTANCE, 2, PLAYABLE_MAX_VIEW_DISTANCE);
const meshBudgetMs = clampInt(Number(params.get("budget")) || DEFAULT_MESH_BUDGET_MS, 2, 14);
const POSITION_STORAGE_KEY = "nicechunk.chunkjs.playable.position.v2";
const POSITION_SAVE_INTERVAL_MS = 650;
const FRAME_MINIMAP_UPDATE_MS = 250;
const FRAME_ACTION_HIT_UPDATE_MS = 90;
const forceGuardianSpawn = params.get("guardianSpawn") === "1" || params.get("spawn") === "guardian";
const defaultGuardianSpawn = {
worldX: (GENESIS_GUARDIAN_REGION_SIZE_CHUNKS / 2) * 16,
worldY: undefined,
worldZ: (GENESIS_GUARDIAN_REGION_SIZE_CHUNKS / 2) * 16,
localOffsetX: 0.5,
localOffsetY: 0,
localOffsetZ: 0.5,
controlYaw: Math.PI * 0.25,
avatarYaw: Math.PI * 0.25,
cameraPitch: -0.42,
};
const savedSpawn = (queryPose || hasSpawnParam(params) || forceGuardianSpawn) ? null : loadSavedPlayerPosition({ storageKey: POSITION_STORAGE_KEY, seed: PLAYABLE_WORLD_SEED });
const spawnState = queryPose ?? savedSpawn ?? defaultGuardianSpawn;
const spawnX = spawnCoord(params, "x", spawnState?.worldX ?? 0);
const spawnYOverride = spawnCoordOrNull(params, "y", spawnState?.worldY);
const spawnZ = spawnCoord(params, "z", spawnState?.worldZ ?? 0);
const spawnFlightEnabled = Boolean(spawnState?.flightEnabled || params.get("fly") === "1" || params.get("flight") === "1");
The active startup expression never references the wallet-scoped pending invite key or its consumer. Genesis X and Z resolve to 50 times 16, or 800, when no higher-priority state wins.
- 1-10
Parse optional pose and runtime controls, then define a separate Play position key and forced-default flag.
- 11-21
Construct the fixed Region-zero center and camera defaults.
- 22-27
Skip saved state for explicit queries or forced default, choose query then saved then Genesis, and finally apply axis and flight query values.
IMPLEMENTATION EVIDENCE
Where these claims come from
Each claim is intentionally scoped to a concrete implementation path. These references are for verification, not decoration.
src/player/inviteSpawn.js
Defines editable invite parameter parsing, the current empty-query Region-zero behavior, Guardian Region center math, wallet-scoped pending-spawn and receipt keys, best-effort saving, single-use consumption, and the no-expiry and cleanup boundaries.
player_creat/player_creat.js
Carries invite hints through redirects, calculates a candidate before the character call, writes browser caches after character confirmation, separately attempts InviteIndex registration, and redirects even when invite registration did not submit.
login/login.js
Attempts the same separate invite registration for an already-created character and stores a browser receipt whose pending or registered label is not an InviteIndex field.
src/chain/nicechunkChain.js
Derives UsernameIndex and InviteIndex PDAs, performs confirmed name and paged invite reads, decodes fixed layouts, applies bounded client deduplication, initializes page zero, constructs the invited-wallet append, and exposes point-in-time rent queries.
src/play/features/profile/invitePanel.js
Contains the alternate profile invite panel that creates share links, initializes page zero, requests at most sixteen InviteIndex pages, paginates fetched entries, and labels values for display; this panel is not present in the audited current production Play entry.
play/index.html
Defines the current production Play interface audited for invite-panel presence; the expected profileInviteOpen and profileInvitePanel elements are absent.
play/main.js
Is the current production Play entry, selects query pose, separate saved Play position, or Genesis before boot, starts Profile refresh later, and neither imports nor calls consumePendingInviteSpawn.
play/position-persistence.js
Reads and writes the distinct wallet-agnostic saved Play position, validates version and world seed, throttles writes, and catches ordinary storage failures.
src/main.js
Contains an alternate startup path that does consume the pending invite spawn, demonstrating why a consumer's existence elsewhere must not be attributed to play/main.js.
programs/nicechunk_player/src/lib.rs
Routes name, invite-page initialization, and invite append tags; verifies account addresses and signers; requires the inviter for page zero; allows independent higher-page initialization; checks previous-full only while append creates a missing page; and exposes no UsernameIndex or InviteIndex close path.
programs/nicechunk_player/src/state.rs
Defines exact name and invite seeds, lengths, layouts, validation, fixed 64-row page capacity, wallet-plus-slot append, narrow accepted name range, and absence of duplicate scanning in InviteIndex append.
programs/nicechunk_player/src/errors.rs
Defines explicit self-invite, first-page, previous-page, full-page, invalid-data, and name-taken errors while providing no duplicate-registration error.
target/deploy/nicechunk_player.so
The retained 172,880-byte artifact matched the finalized Devnet ProgramData ELF hash in the dated audit; that byte match does not prove dirty source reproducibility.
docs/audits/names-invites-and-spawn-devnet-2026-07-16.json
Records finalized Devnet program identity, deployed ELF comparison, rent minima, the complete observed InviteIndex scan, derived page-zero-through-63 continuity check, two current rows, source hashes, production runtime boundary, and explicit interpretation limits.
play/tests/names-invites-and-spawn.test.mjs
Locks seven current local behaviors: empty-query fallback, Region priority and serialization, center math, wallet-scoped storage, single consumption, malformed cleanup, and the cleanup rethrow when both storage read and removal fail.
docs/visual-provenance/names-invites-and-spawn.json
Records canonical villager models, real Chunk.js hillside, real Genesis capture, baked-material reference, selected imagegen outputs, prompts, hashes, dimensions, review decisions, and the limits of every visual metaphor.
public/media/vox/chr_peasant_guy_blackhair.ncm
Provides the canonical NiceChunk villager boy model used as an identity reference for all generated teaching images.
public/media/vox/chr_peasant_girl_orangehair.ncm
Provides the canonical NiceChunk villager girl model used as an identity reference for all generated teaching images.