TWO PAYER ROLES, THREE KINDS OF SOL MOVEMENT
Name the transaction fee payer before naming the account-storage payer
A Solana transaction message has one fee payer. NiceChunk's wallet helper explicitly assigns provider.publicKey, while its keypair helper assigns signer.publicKey. In plugin Session setup, the connected owner is the fee payer, the temporary Session key co-signs, and the owner may also transfer ordinary spendable SOL to that key. That transfer is neither a processing fee nor PDA rent.
Inside a program, a writable signer is separately passed as payer for create_account or a rent top-up. The Player setup instruction passes the owner as the PlayerProfile and PlayerSession storage payer. Mining and Building calls are signed and fee-paid by the Session key, and those programs also use that writable Session authority to fund new progress, ChunkBroken, BuildSite, manifest, or shard bytes. Local Game Wallet makes owner and Session authority the same key, so the two labels point to one address there without becoming one conceptual role.
Owner-wallet flows use the connected owner as fee payer when creating a Backpack, executing Smelting, creating or canceling a Market Listing, or buying. That owner is also the storage payer when the instruction creates a Backpack, Smelting progress record, Listing, or missing associated token account. A Market buyer may fund missing seller or treasury associated token accounts before an NCK purchase. Those token-account allocations are governed by the associated-token and Token programs; they are not refundable through a NiceChunk Listing close.
The client function named transactionSolSpendFromMeta measures the payer's positive balance decrease and takes the greater of that decrease and meta.fee. That total can include network fee, transfers, PDA funding, and token-account creation. The feeLamports field is the narrower processing-fee observation.
The same setup transaction may initialize Profile, top up the ordinary Session signer, and create or refresh PlayerSession.
The temporary key can lose spendable SOL to both processing fees and account creation or growth.
The owner is also the program payer where these reviewed instructions create a Backpack, progress record, Listing, or missing token account.
All can lower one address in one transaction while remaining different economic events.
BYTES ARE FIXED; THE LAMPORT MINIMUM IS QUERIED
Account rent is ordinary lamports held at a program-owned address
NiceChunk programs ask Solana Rent for minimum_balance(account length) when they create or enlarge a PDA. The browser onboarding code independently calls getMinimumBalanceForRentExemption for the selected RPC and byte length. That means a source constant such as 184 bytes is stable for this account version, while an exact SOL example is only a point-in-time result for a particular runtime.
A rent-exempt account is not a hidden player wallet. Its lamports belong to the account controlled by its program. The owner or Session key that originally funded it cannot spend those lamports with a normal System Program transfer. Return requires the owning program to expose and accept a close instruction.
A resize can require only the missing difference. PlayerEquipment and ChunkBroken use current account lamports and the new byte length to calculate a top-up before reallocating. A failed atomic transaction does not leave a successfully enlarged account behind, but a successfully enlarged account keeps the additional lamports until an applicable close path exists.
The onboarding constant NETWORK_FEE_SOL = 0.000005 is presentation policy, not a permanent Solana fee quote. Exact processing cost comes from transaction metadata after an accepted transaction; exact rent comes from a runtime rent query before creation or from the account's current lamports afterward.
Owner-funded, deterministic by owner, and no Player close instruction.
Owner-funded during setup; expiry does not release its lamports.
Equipment has no close; Appearance can be closed only by the configured treasury.
The reviewed Player dispatch provides creation and append paths but no release or close.
Backpack and Listing have no close; cancellation restores the listed item but retains Listing lamports.
It starts at capacity 64, grows by 64 up to 2,048, and the Session payer funds each required rent top-up.
Only uploading manifest and created shard accounts have the reviewed cancellation close path; BuildSite remains.
REJECTION, FAILURE, AND UNKNOWN ARE DIFFERENT
Use four outcome classes before saying a fee was charged or refunded
Local validation can stop before a transaction exists, and a player can reject a wallet prompt before signing. Those paths do not execute on chain. NiceChunk sends raw transactions with skipPreflight: false, so a preflight simulation error also does not demonstrate an accepted on-chain transaction or an on-chain fee.
After the cluster accepts a signed transaction, its status can contain an error. Solana atomicity rolls back the program's account writes, transfers, account creation, and reallocations from that transaction. The processing work was still performed, so the network fee may remain charged. NiceChunk's polling helper preserves the signature inside TransactionStatusError instead of converting the result into a local-only failure.
A timeout, expired blockheight, browser crash, or transport error can leave the outcome unknown. The browser message alone cannot establish success, failure, or refund. Preserve the signature when one exists, query its status on the correct cluster, inspect meta.err and meta.fee, and read the affected accounts before retrying.
Blind retry is especially risky for setup or upload flows. The first transaction may already have funded a Session signer or allocated an account even if the browser did not receive confirmation. A new key or revision can then strand a second ordinary balance or second set of account lamports.
No program state change is possible without a signed, submitted transaction.
Do not infer an on-chain fee solely from the browser error.
Use the preserved signature and transaction metadata.
Do not retry a fund or create operation only because the UI timed out.
CREATION DOES NOT IMPLY PLAYER-CONTROLLED CLOSURE
Profile, Session, Equipment, indexes, and Backpack persist under their programs
PlayerProfile is a 773-byte PDA derived from the owner. The owner funds creation and can reconstruct the address after browser cache loss. There is no Player close tag, so retaining the owner key restores write authority but not a rent-refund path. Losing the owner key leaves public data readable and deterministic, but it removes the ability to authorize ordinary Profile updates.
PlayerSession is a separate 184-byte PDA derived from both owner and Session authority. Setup requires both signatures and uses the owner as payer. Expiry only causes Session-aware consumers to reject later actions; it does not close the PDA, return rent, revoke the ordinary key, or transfer that key's SOL. A new temporary key produces a different PDA and cannot sign for the old key's balance.
PlayerEquipment is 7,040 bytes and may be created or reallocated with owner funds. PlayerAppearance is 9,612 bytes and is covered by the next chapter's treasury-only close. UsernameIndex is 256 bytes and each InviteIndex page is 2,688 bytes. The current Player dispatch has no Equipment, Session, Profile, UsernameIndex, or InviteIndex close path; changing a name does not release the prior index allocation.
Backpack is an 8,048-byte PDA derived from owner plus backpack ID. The owner funds it, and the unified Backpack instruction map exposes no close. Normal browser recovery first checks its local cache, then reads the equipped Backpack pointer from PlayerProfile. That recovers an equipped Backpack when the owner and Profile remain available; it does not enumerate an unknown unequipped backpack ID.
Owner private key is still required for writes; no close exists.
Owner can create a replacement Session but cannot sweep an old signer address or close the old Session PDA.
Data can be found again; its stored rent is not player-withdrawable.
Allocated pages and prior indexes remain under Player Program control.
The normal fallback does not reconstruct an unknown unequipped ID.
ITEM RETURN IS NOT AN ACCOUNT REFUND
Listing cancellation restores the item but retains rent; Appearance can close only to treasury
Creating a 216-byte Market Listing uses the seller as signer, fee payer in the browser flow, and Listing storage payer. The item is removed from its source custody and stored in the Listing. Cancel requires the same seller, an active Listing, a valid destination Backpack path, the Listing address, and the Market authority. The program restores the exact item, marks the Listing canceled, and decrements the seller's active count. It does not clear the Listing data or transfer the Listing's lamports.
Buying has a different custody outcome but the same account-persistence result. The buyer pays the purchase transaction fee and, for NCK, may create missing seller or treasury associated token accounts. Market moves the item into the buyer's Backpack and marks the Listing sold. Both sold and canceled Listing records remain allocated with their lamports because the current Market dispatch exposes no Listing-close instruction.
PlayerAppearance reverses the intuitive payer rule. The player's wallet funds its 9,612-byte allocation during upsert, but the stored policy and close instruction reserve reclaim authority for NICECHUNK_TREASURY_AUTHORITY. The signer and recipient must both be that treasury. The player owner cannot redirect those lamports to itself through the reviewed Player Program.
A real close returns an account's current lamports, not a separately tracked receipt for the original deposit. Market cancellation is a state transition rather than a close. PlayerAppearance close is itself a transaction with a fee payer, so even its treasury recipient's net wallet change need not equal an earlier estimated rent number.
Requires active state, seller signature, valid Listing PDA, and valid Backpack destination accounts; the canceled Listing remains allocated.
The 216-byte account and its lamports remain under the Market namespace.
Funding the account does not grant the player a reclaim instruction.
A returned account balance is not a promise of zero net cost.
ONLY THE PENDING UPLOAD LAYER CAN BE RECLAIMED
Building cancellation closes uploading shards and manifest, not the BuildSite or active building
Creating a BuildSite allocates a persistent 160-byte account and uses the valid Session authority as instruction payer. Beginning an upload creates a 160-byte manifest; writing each shard creates 64 header bytes plus that shard's payload. The browser estimates missing-account rent at runtime and, in plugin mode, transfers the required total plus a 1,000,000-lamport buffer from owner to Session authority before the Session key pays creation transactions.
Cancellation accepts only the BuildSite's pending revision whose manifest status is UPLOADING. It validates the current Session into an owner, checks that owner against BuildSite and manifest, requires the complete expected shard-account list, closes each shard that already exists, and then closes the manifest. The persistent BuildSite is updated to remove its pending revision but is not closed.
Every closed shard and the manifest transfer their current lamports to the currently supplied valid Session authority, not directly to the owner wallet. A new valid Session for the same owner can authorize cancellation of an older pending upload because Building compares the recovered owner relationship, not equality with the original temporary key. The returned lamports therefore land at the new Session address used for cancellation.
Finalization changes the manifest to active and makes the revision the BuildSite's active building. The cancel instruction rejects non-uploading manifests, and the dispatch exposes no active-manifest, active-shard, or BuildSite close. The current browser invokes cancellation automatically when a pending upload does not match the payload being resumed; it does not expose a general player-facing account sweep.
Created by Session payer and never closed by upload cancellation.
Owner, foundation, revision, status, and shard count must all validate.
All expected addresses must be supplied; existing program-owned shards are closed.
The program does not transfer cancellation proceeds directly to owner.
Active manifest and shards retain their lamports.
WORLD RECORDS AND GOVERNANCE RECORDS ALSO NEED EXPLICIT CLOSE LOGIC
Mining, Core, Guardian, Civilization, and Smelting accounts currently retain their rent
Chunk mining uses the writable Session authority as payer. A missing 128-byte Chunk PlayerProgress PDA is created for the recovered owner, and each affected ChunkBroken PDA starts at 208 bytes, then grows in 192-byte steps as its capacity rises by 64 records. Each growth tops up the account to the runtime minimum before reallocating. The Chunk instruction map has no close path for progress, ChunkBroken, rule tables, or foundation indexes.
Smelting is an owner-wallet flow in the current browser. Its program can create a separate 128-byte PlayerProgress PDA under the Smelting or unified Game program and can initialize a 9,936-byte RecipeTable. Neither the Smelting instruction map nor the unified namespace dispatcher provides a close instruction. A similarly named progress seed under a different program ID derives a different account.
Core accepts only GlobalConfig initialization and creates a 293-byte PDA. Guardian creates a 160-byte Registry and 288-byte Region records through treasury or registration payers. Its update instructions do not include removal with lamport return. The Region status layout contains a removed value, but the reviewed instruction dispatch exposes no close or stake-refund instruction, so a status constant must not be advertised as a reclaim path.
Civilization creates PowerSnapshot, CitizenPower, RuleBook, RuleSignature, RuleTally, and ExecutionReceipt accounts with the payer named by each instruction. Their sizes are 128, 144, 320, 136, 128, and 128 bytes. Publish, sign, tally, execute, snapshot, and settle are lifecycle transitions, not closes. None of the reviewed Civilization dispatch tags drains those PDA lamports.
Across the reviewed Rust tree, the only direct try_borrow_mut_lamports account drains are the two implementations explained in the Appearance and Building chapters. Market cancellation contains no such drain. This is a source-revision statement, not a guarantee that future upgrades can never add or change recovery logic.
Program-scoped progress remains after Session expiry.
Session funds creation and each rent top-up; no close exists.
Owner or configured authority pays creation according to the instruction; no close tag exists.
One initialization instruction and no close.
Registration and updates expose no account close or stake-refund path.
State finalization or execution does not release their account lamports.
RECOVER THE KEY, THE RECORD, OR NEITHER
Logout changes browser state; recovery depends on which private key still exists
Modular Play disconnect clears wallet identity and runtime prefixes, including the stored plugin Session secret, then redirects. It sends no Solana instruction. The login page's Disconnect Wallet action clears only its wallet binding, and the Local Game Wallet provider's disconnect method is a no-op. The Local Game Wallet secret uses separate browser keys and is not removed by plugin Session-prefix cleanup.
The plugin Session secret is Base64 text in localStorage; the Local Game Wallet secret is unencrypted Base58 text. Those encodings are not encryption. If the plugin secret survives and the owner wallet remains available, setup normally reuses the same Keypair and refreshes its PlayerSession. If that only copy is deleted, the owner may establish a new Session but cannot sign an ordinary transfer from the old temporary address.
Browser cache loss does not erase deterministic public accounts. With the owner public key, a client can rederive Profile, Equipment, Appearance, Session candidates when the authority is known, and program-scoped progress. The equipped Backpack can be recovered through Profile. Unknown IDs, transaction signatures, pending revisions, and unequipped Backpacks may require independent records or RPC enumeration beyond the normal browser fallback.
Loss of the Local Game Wallet owner secret is more severe because that key is both owner and Session authority. The public address and chain records remain readable, but NiceChunk cannot derive the private key from them. A backup of the exact valid secret can restore signing control; the address, username, Profile, or support message cannot.
For compromise, stop using the exposed key. An independent plugin Session key can still sign ordinary Solana transfers until its funds move or the attacker moves them; NiceChunk Session expiry only blocks cooperating NiceChunk consumers. If the owner key is compromised, all owner-authorized assets and replacement Sessions are at risk. Never paste any private key into an explorer, form, support chat, or unknown recovery site.
Profile writes and a replacement Session remain possible; absent close paths stay absent.
A valid owner relationship can refresh Session authorization; use a trusted signing environment.
Create a replacement Session; old Session PDA rent also has no close path.
Only an existing valid backup restores signing control.
Cancel can return the item while state remains active, but it does not return Listing lamports.
Cancel returns manifest and shard lamports to that Session authority.
A state label, logout, or new key does not substitute for a close instruction.
VERIFY THE PAYER, ACCOUNT, STATE, AND RECIPIENT
Use formulas and literal source ranges to audit every SOL claim
Each formula belongs to one chapter. Every code excerpt below is copied as a continuous range from the current repository and followed by a plain-language walkthrough. Source behavior is evidence for this revision, not proof that a particular live transaction executed.
Separate one payer's total outflow
payerOutflow = processingFee + ordinaryTransfers + newAccountLamports + rentTopUps + otherInstructionOutflowsThis is an accounting checklist, not a promise that every term appears. A balance delta can be larger than meta.fee because the transaction also moved or deposited lamports.
- processingFee
- The transaction metadata fee.
- ordinaryTransfers
- Spendable SOL moved to another ordinary address, such as a Session authority.
- newAccountLamports
- Lamports placed in newly allocated program or token accounts.
- rentTopUps
- Additional lamports required before an existing account can grow.
Query the current rent minimum
rentMinimum(bytes, cluster, context) = RPC.getMinimumBalanceForRentExemption(bytes)The account length comes from the program version. The lamport result belongs to the selected runtime context and should be timestamped when published.
- bytes
- The account's allocated data length.
- cluster
- The selected Solana network.
- context
- The RPC commitment and observation context.
A resize funds only the missing difference
rentTopUp = max(0, rentMinimum(newBytes) - currentAccountLamports)PlayerEquipment and ChunkBroken implement this shape before reallocating. The resulting lamports stay in the program-owned account.
- newBytes
- The intended allocation after growth.
- currentAccountLamports
- The account's balance before the resize.
Atomic failure boundary
acceptedStatusErr => gameWrites = 0, programTransfers = 0, accountCreates = 0, processingFee may remainThe zeroes describe committed effects from that failed transaction, not balances that existed before it.
- acceptedStatusErr
- A submitted signature whose cluster status contains an execution error.
- may remain
- Read meta.fee instead of assuming either zero or a fixed constant.
A deterministic address does not imply a refund path
dataRecovery = knownSeeds + correctProgramId + readableChain; lamportRecovery = applicableClose + validAuthority + validState + recipientThe two sides answer different questions. Re-deriving a Profile or Backpack address does not let the wallet spend its PDA lamports.
- knownSeeds
- Owner, IDs, revisions, coordinates, or other derivation inputs.
- applicableClose
- A close instruction that accepts this exact account type and state.
Market cancellation retains Listing lamports
listingLamportsAfterCancel = listingLamportsBeforeCancel; sellerReturnFromListing = 0Market restores the stored item, marks the Listing canceled, and decrements active count without clearing data or draining the Listing account.
- listingLamportsAfterCancel
- The retained Listing account balance after its state changes to canceled.
- sellerReturnFromListing
- Lamports transferred out of the Listing account to the seller by cancellation; currently zero.
Pending upload cancellation return
sessionReturn = uploadingManifestLamports + sum(existingUploadingShardLamports)BuildSite lamports and active-revision account lamports are excluded because this cancellation does not close them.
- sessionReturn
- Lamports credited to the current valid Session authority.
- existingUploadingShardLamports
- Balances of expected shard accounts that are already owned by Building.
ChunkBroken allocation growth
chunkBrokenBytes(capacity) = 16 + 3 × capacity, where capacity ∈ {64, 128, ..., 2048}Initial length is 208 bytes and maximum length is 6,160 bytes. Each successful growth can require a rent top-up paid by the Session authority.
- 16
- Fixed ChunkBroken header bytes.
- 3
- Bytes per packed broken-block record.
Ordinary key-balance recovery rule
canMoveSignerSOL(address) = usablePrivateKey(address) AND acceptedTransferAn owner relationship, Session PDA, username, public address, or support request cannot replace the private key for an ordinary System Program transfer.
- usablePrivateKey
- Signing material that can be loaded safely and actually matches the address.
- acceptedTransfer
- A signed transfer that reaches successful chain execution.
Wallet and keypair helpers assign different fee payers
JavaScriptsrc/chain/nicechunkChain.jsasync function signAndSendWalletTransaction(provider, transaction, conn = getNicechunkConnection(), extraSigners = []) {
transaction.feePayer = provider.publicKey;
if (typeof conn.prepareTransaction === "function") {
await conn.prepareTransaction(transaction, { commitment: "confirmed" });
} else {
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash("confirmed");
transaction.recentBlockhash = blockhash;
transaction.lastValidBlockHeight = lastValidBlockHeight;
}
for (const signer of extraSigners) transaction.partialSign(signer);
if (typeof provider.signTransaction === "function") {
const signed = await provider.signTransaction(transaction);
const signature = await sendRawTransactionWithLogs(conn, signed.serialize(), "wallet");
await confirmTransactionByHttpPolling(conn, {
signature,
blockhash: transaction.recentBlockhash,
lastValidBlockHeight: transaction.lastValidBlockHeight,
}, "confirmed");
return signature;
}
if (typeof provider.signAndSendTransaction !== "function") {
throw new Error("Wallet does not support transaction signing.");
}
const result = await provider.signAndSendTransaction(transaction);
const signature = typeof result === "string" ? result : result?.signature;
if (!signature) throw new Error("Wallet did not return a transaction signature.");
await confirmTransactionByHttpPolling(conn, {
signature,
blockhash: transaction.recentBlockhash,
lastValidBlockHeight: transaction.lastValidBlockHeight,
}, "confirmed");
return signature;
}
async function signAndSendKeypairTransaction(signer, transaction, conn = getNicechunkConnection()) {
transaction.feePayer = signer.publicKey;
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash("confirmed");
transaction.recentBlockhash = blockhash;
transaction.lastValidBlockHeight = lastValidBlockHeight;
transaction.sign(signer);
const signature = await sendRawTransactionWithLogs(conn, transaction.serialize(), "keypair");
await confirmTransactionByHttpPolling(conn, { signature, blockhash, lastValidBlockHeight }, "confirmed");
return signature;
}
The outer helper makes the wallet provider or supplied Keypair the fee payer. An extra signer can co-sign without becoming fee payer.
- Lines 1–12
The wallet public key is assigned as fee payer before blockhash preparation and any extra partial signatures.
- Lines 13–36
The wallet signs, sends, and waits for confirmed commitment.
- Lines 38–46
The keypair path instead assigns the supplied signer as fee payer and signs directly.
Fresh plugin Session setup can combine transfer, storage creation, and two signers
JavaScriptsrc/chain/nicechunkChain.js const keypair = stored?.keypair ?? Keypair.generate();
const expiresAt = nowSeconds + sessionDurationSeconds;
const [playerProfile] = derivePlayerProfilePda(owner);
const [playerSession] = derivePlayerSessionPda(owner, keypair.publicKey);
const tx = new Transaction();
const [profileAccount, sessionAccount] = await conn.getMultipleAccountsInfo(
[playerProfile, keypair.publicKey],
"confirmed",
);
const sessionBalance = accountLamports(sessionAccount);
if (!profileAccount?.data?.length) {
tx.add(createInitializePlayerInstruction(owner, playerProfile, ""));
}
const targetLamports = sessionBalance < minimumSessionFundingLamports
? getConfiguredGameplaySessionFundingLamports(owner)
: sessionBalance;
if (sessionBalance < targetLamports) {
tx.add(SystemProgram.transfer({
fromPubkey: owner,
toPubkey: keypair.publicKey,
lamports: targetLamports - sessionBalance,
}));
}
tx.add(createOrRefreshPlayerSessionInstruction({
owner,
sessionAuthority: keypair.publicKey,
playerProfile,
playerSession,
expiresAt,
}));
await signAndSendWalletTransaction(provider, tx, conn, [keypair]);
One owner-fee-paid transaction may initialize Profile, top up the ordinary temporary address, and create or refresh its Session PDA. The keypair is an extra signer.
- Lines 1–10
Reuse or generate the Session key, derive its accounts, and read Profile existence plus ordinary authority balance.
- Lines 12–24
Optionally add Profile initialization and an owner-to-authority System Program transfer.
- Lines 25–33
Add Session setup and send with the wallet as fee payer plus the Session key as co-signer.
The onboarding client asks the selected RPC for rent
JavaScriptplay/play-onboarding.js const promise = fetch(rpcUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "getMinimumBalanceForRentExemption",
params: [bytes, { commitment: "processed" }],
}),
The client sends the byte length to the configured RPC rather than deriving a permanent SOL number from source constants.
- Lines 1–3
Send a JSON-RPC POST request.
- Lines 4–9
Request the rent-exempt minimum for this byte length at processed commitment.
PlayerSession creation asks Solana Rent and uses the owner payer
Rustprograms/nicechunk_player/src/lib.rs let rent = Rent::get()?;
let lamports = rent.minimum_balance(PlayerSession::LEN);
if player_session.lamports() == 0 {
let create = system_instruction::create_account(
payer.key,
player_session.key,
lamports,
PlayerSession::LEN as u64,
program_id,
);
invoke_signed(
&create,
&[
payer.clone(),
player_session.clone(),
system_program_account.clone(),
],
&[&[
PLAYER_SESSION_SEED,
payer.key.as_ref(),
session_authority.as_ref(),
&[bump],
]],
)?;
return Ok(());
}
The program queries rent for the fixed 184-byte layout and places that amount in the Session PDA using payer.key.
- Lines 1–2
Read the current Rent sysvar and calculate the minimum for PlayerSession::LEN.
- Lines 4–10
Build account creation using the instruction payer as funding source.
- Lines 11–25
Sign for the PDA with owner and Session-authority derivation seeds.
Submission requests preflight
JavaScriptsrc/chain/nicechunkChain.jsasync function sendRawTransactionWithLogs(conn, serializedTransaction, context) {
try {
return await conn.sendRawTransaction(serializedTransaction, { skipPreflight: false });
} catch (error) {
await attachSendTransactionLogs(error, conn, context);
throw error;
}
}
A send error on this path is accompanied by preflight-enabled submission. It does not by itself demonstrate accepted execution.
- Lines 1–3
Submit the signed bytes with preflight enabled.
- Lines 4–7
Attach available diagnostic logs and preserve the failure.
Confirmed polling treats status.err as a signed transaction failure
JavaScriptsrc/chain/nicechunkChain.js while (true) {
const statusResponse = await conn.getSignatureStatuses([signature]);
const status = statusResponse?.value?.[0] ?? null;
if (status?.err) throw createTransactionStatusError(signature, status.err);
if (hasReachedSignatureCommitment(status, commitment)) {
return {
context: statusResponse.context,
value: { err: null },
};
}
The helper checks the submitted signature and keeps successful commitment distinct from a status that contains an execution error.
- Lines 1–4
Poll the signature and throw a status error that retains the signature when execution failed.
- Lines 5–10
Return success only after the requested commitment is reached without an error.
The client exposes actual meta.fee separately from total payer decrease
JavaScriptsrc/chain/nicechunkChain.jsfunction transactionSolSpendFromMeta(transaction, payerKey, signature) {
const meta = transaction?.meta;
const preBalances = meta?.preBalances;
const postBalances = meta?.postBalances;
if (!Array.isArray(preBalances) || !Array.isArray(postBalances)) return null;
const accountKeys = transactionAccountKeys(transaction);
let payerIndex = accountKeys.findIndex((key) => key === payerKey);
if (payerIndex < 0) payerIndex = 0;
const pre = Number(preBalances[payerIndex]);
const post = Number(postBalances[payerIndex]);
const balanceDelta = Number.isFinite(pre) && Number.isFinite(post) ? Math.max(0, pre - post) : 0;
const fee = Number(meta?.fee);
const lamports = Math.max(balanceDelta, Number.isFinite(fee) ? fee : 0);
if (!Number.isFinite(lamports) || lamports <= 0) return null;
return {
signature,
payer: payerKey,
lamports: Math.floor(lamports),
feeLamports: Number.isFinite(fee) ? Math.floor(fee) : null,
};
}
lamports is a broad payer-decrease measure; feeLamports is the actual processing-fee field when transaction metadata is available.
- Lines 1–8
Find the payer inside the transaction balance arrays.
- Lines 9–13
Calculate positive payer decrease and read meta.fee separately.
- Lines 14–20
Return both the broad spend number and the narrower fee field.
Player dispatch exposes one Appearance close and no other account close
Rustprograms/nicechunk_player/src/lib.rs match tag {
0 => initialize_player(program_id, accounts, payload),
1 => update_position(program_id, accounts, payload),
3 => set_backpack_style(program_id, accounts, payload),
4 => create_or_refresh_player_session(program_id, accounts, payload),
5 => set_equipped_backpack(program_id, accounts),
6 => add_forging_xp(program_id, accounts, payload),
7 => set_player_name(program_id, accounts, payload),
8 => upsert_player_appearance(program_id, accounts, payload),
9 => close_player_appearance(program_id, accounts),
10 => initialize_invite_index_page(program_id, accounts, payload),
11 => append_invite_registration(program_id, accounts, payload),
13 => transfer_equipment_slot(program_id, accounts, payload),
14 => swap_equipment_slots(program_id, accounts, payload),
15 => consume_equipment_durability(program_id, accounts, payload),
16 => release_equipment_to_market(program_id, accounts, payload),
_ => Err(NicechunkPlayerError::InvalidInstruction.into()),
}
The complete dispatch exposes Appearance close at tag 9, equipment-to-Market release at tag 16, and no Profile, Session, Equipment, UsernameIndex, or InviteIndex close.
- Lines 1–8
Dispatch ordinary Player initialization, position, Backpack style, Session setup, equipment selection, XP, name, and Appearance updates.
- Lines 9–12
Expose the sole account-close handler at tag 9 for PlayerAppearance, followed by invitation-index operations that do not close accounts.
- Lines 13–17
Handle equipment transfer, swap, durability, and Market release, then reject unknown tags without another Player account close.
Backpack dispatch contains no Backpack close
Rustprograms/nicechunk_backpack/src/lib.rs match tag {
0 => initialize_backpack(program_id, accounts, payload),
1 => append_mined_resource(program_id, accounts, payload),
2 => remove_resource(program_id, accounts, payload),
3 => append_market_resource(program_id, accounts, payload),
4 => remove_resources(program_id, accounts, payload),
5 => append_smelting_item(program_id, accounts, payload),
6 => append_mined_resources_batch(program_id, accounts, payload),
7 => Err(NicechunkBackpackError::UnverifiedForgeInstructionDisabled.into()),
8 => forge_equipment_with_material_verification(program_id, accounts, payload),
9 => issue_blueprint(program_id, accounts, payload),
10 => transfer_backpack_item_to_equipment(program_id, accounts, payload),
11 => transfer_equipment_item_to_backpack(program_id, accounts, payload),
12 => configure_material_physics(program_id, accounts, payload),
13 => record_mining_action(program_id, accounts, payload),
14 => consume_smelting_resources(program_id, accounts, payload),
_ => Err(NicechunkBackpackError::InvalidInstruction.into()),
}
}
The unified Game program routes a namespace to this dispatch. It initializes and mutates Backpack custody but exposes no account close.
- Lines 1–8
Initialization and inventory append or removal actions are listed.
- Lines 9–15
Forging, blueprint, custody transfers, material physics, mining snapshots, and quantity-aware smelting consumption complete the map without a close tag.
Cancellation marks the Listing but does not close it
Rustprograms/nicechunk_market/src/lib.rs let clock = Clock::get()?;
{
let mut data = listing.try_borrow_mut_data()?;
ListingAccount::mark_canceled(&mut data, clock.slot, clock.unix_timestamp)?;
}
decrement_market_user_active(market_user, seller.key, clock.slot)?;
Ok(())
After restoring the item, cancellation marks the Listing and decrements seller active count. It does not clear data, drain lamports, or close the account, so rent remains allocated.
- Lines 1–6
Read chain time, borrow Listing data, and persist the canceled state with its slot and timestamp.
- Lines 7–8
Decrease the seller's active Listing count and return without draining or closing the Listing account.
Purchase marks the Listing sold without closing it
Rustprograms/nicechunk_market/src/lib.rs append_market_slot_to_backpack(
program_id,
market_authority,
buyer,
buyer_backpack,
backpack_program,
material_physics,
&source_slot,
)?;
let clock = Clock::get()?;
{
let mut data = listing.try_borrow_mut_data()?;
ListingAccount::mark_sold(&mut data, buyer.key, clock.slot, clock.unix_timestamp)?;
}
decrement_market_user_active(seller_market_user, seller.key, clock.slot)?;
Ok(())
The item enters the buyer Backpack, the Listing records buyer and sale time, and seller active count decreases. No lamport drain or account close occurs.
- Lines 1–9
Append the escrowed source record to the buyer's validated Backpack before changing Listing status.
- Lines 11–17
Record buyer, sale slot, and sale time, then decrement seller active count without draining or closing the Listing.
Appearance close accepts only treasury and sends treasury the lamports
Rustprograms/nicechunk_player/src/lib.rs if !treasury_authority.is_signer || !treasury_authority.is_writable || !recipient.is_writable {
return Err(NicechunkPlayerError::InvalidTreasuryAuthority.into());
}
require_key_eq(
treasury_authority.key,
&NICECHUNK_TREASURY_AUTHORITY,
NicechunkPlayerError::InvalidTreasuryAuthority,
)?;
require_key_eq(
recipient.key,
treasury_authority.key,
NicechunkPlayerError::InvalidTreasuryAuthority,
)?;
require_key_eq(
appearance.owner,
program_id,
NicechunkPlayerError::InvalidAppearanceOwner,
)?;
{
let data = appearance.try_borrow_data()?;
PlayerAppearance::validate_treasury_authority(&data, treasury_authority.key)?;
let owner = PlayerAppearance::owner(&data)?;
let (expected_appearance, _) =
Pubkey::find_program_address(&[PLAYER_APPEARANCE_SEED, owner.as_ref()], program_id);
require_key_eq(
appearance.key,
&expected_appearance,
NicechunkPlayerError::InvalidAppearancePda,
)?;
}
{
let mut data = appearance.try_borrow_mut_data()?;
data.fill(0);
}
let reclaimed_lamports = appearance.lamports();
**recipient.try_borrow_mut_lamports()? = recipient
.lamports()
.checked_add(reclaimed_lamports)
.ok_or(NicechunkPlayerError::InvalidAppearanceData)?;
**appearance.try_borrow_mut_lamports()? = 0;
Both signing authority and recipient must be the configured treasury. The player's owner key is not accepted as the reclaim recipient.
- Lines 1–19
Require writable treasury signer, treasury recipient equality, and Player ownership of Appearance.
- Lines 21–33
Validate stored treasury policy and the owner-derived Appearance PDA.
- Lines 34–43
Clear data, transfer all current lamports to treasury, and drain Appearance.
Pending upload cancellation closes only shards and manifest to Session authority
Rustprograms/nicechunk_building/src/lib.rs {
let mut data = build_site.try_borrow_mut_data()?;
BuildSiteState::cancel_building(
&mut data,
&context.owner,
global_config.key,
revision,
context.clock.slot,
)?;
}
for account in shard_accounts {
if account.owner == program_id {
close_program_account(account, session_authority)?;
}
}
close_program_account(manifest, session_authority)
}
fn close_program_account(account: &AccountInfo, recipient: &AccountInfo) -> ProgramResult {
let account_lamports = account.lamports();
let recipient_lamports = recipient.lamports();
**recipient.try_borrow_mut_lamports()? = recipient_lamports
.checked_add(account_lamports)
.ok_or(NicechunkBuildingError::InvalidSystemAccount)?;
**account.try_borrow_mut_lamports()? = 0;
account.try_borrow_mut_data()?.fill(0);
Ok(())
}
The BuildSite is updated, each existing program-owned shard is drained, and the manifest is drained. The recipient passed by this instruction is session_authority.
- Lines 1–10
Clear only the pending revision on the persistent BuildSite.
- Lines 11–16
Close existing shards and then the manifest to the current Session authority.
- Lines 19–28
The close helper adds all account lamports to the recipient, zeros lamports, and clears data.
The browser pre-funds missing upload accounts plus a buffer
JavaScriptsrc/chain/nicechunkChain.jsasync function fundBuildingUploadSession(provider, sessionAuthority, accountLengths, conn) {
if (provider.publicKey.equals(sessionAuthority)) return;
const lengths = (accountLengths ?? []).map((value) => Math.max(0, Math.trunc(Number(value) || 0))).filter(Boolean);
const uniqueLengths = [...new Set(lengths)];
const rentValues = await Promise.all(uniqueLengths.map((length) => conn.getMinimumBalanceForRentExemption(length)));
const rentByLength = new Map(uniqueLengths.map((length, index) => [length, rentValues[index]]));
const required = lengths.reduce((sum, length) => sum + (rentByLength.get(length) || 0), 0) + 1_000_000;
const balance = await conn.getBalance(sessionAuthority, "confirmed");
if (balance >= required) return;
const transaction = new Transaction().add(SystemProgram.transfer({
fromPubkey: provider.publicKey,
toPubkey: sessionAuthority,
lamports: required - balance,
}));
await signAndSendWalletTransaction(provider, transaction, conn);
}
Plugin mode estimates rent for missing account lengths and tops the Session authority up to that total plus one million lamports. The buffer is ordinary Session SOL, not PDA rent.
- Lines 1–6
Skip when owner and Session are one address, then query rent once per unique account length.
- Lines 7–9
Sum each account requirement, add the buffer, and compare the ordinary Session balance.
- Lines 10–16
Transfer only the missing amount from owner with an owner-fee-paid transaction.
Chunk creates PlayerProgress with the Session payer
Rustprograms/nicechunk_chunk/src/lib.rs let rent = Rent::get()?;
let lamports = rent.minimum_balance(PLAYER_PROGRESS_LEN);
if player_progress.lamports() == 0 {
let create = system_create_account(
payer.key,
player_progress.key,
lamports,
PLAYER_PROGRESS_LEN as u64,
program_id,
);
invoke_signed(
&create,
&[
payer.clone(),
player_progress.clone(),
system_program_account.clone(),
],
&[seeds],
)?;
} else {
The mining flow passes Session authority as payer into this helper, which creates the 128-byte progress account at the runtime minimum.
- Lines 1–2
Calculate current rent for PLAYER_PROGRESS_LEN.
- Lines 3–10
Create the PDA using payer.key as lamport source.
- Lines 11–19
Invoke with payer, progress, and System Program accounts plus PDA seeds.
The unified Game program routes three active account families by namespace
Rustprograms/nicechunk_game/src/lib.rs let (tag, payload) = instruction_data
.split_first()
.ok_or(NicechunkGameError::InvalidInstruction)?;
match *tag {
NS_BACKPACK => nicechunk_backpack::process_instruction(program_id, accounts, payload),
NS_CHUNK => nicechunk_chunk::process_instruction(program_id, accounts, payload),
NS_SMELTING => nicechunk_smelting::process_instruction(program_id, accounts, payload),
NS_MARKET => nicechunk_market::process_instruction(program_id, accounts, payload),
_ => Err(NicechunkGameError::InvalidInstruction.into()),
}
Current configuration uses this Game program for Backpack, Market, and Smelting. The namespace dispatcher adds no separate close behavior; each module's own dispatch remains decisive.
- Lines 1–3
Read the first byte as a namespace tag.
- Lines 5–10
Delegate to one module or reject an unknown namespace.
Core exposes initialization only
Rustprograms/nicechunk_core/src/lib.rspub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
if instruction_data != [0] {
return Err(NicechunkError::InvalidInstruction.into());
}
initialize_global_config(program_id, accounts)
}
Core accepts only tag 0 GlobalConfig initialization in this revision. There is no update or close dispatch.
- Lines 1–8
Reject every instruction payload other than one byte containing zero.
- Line 10
Run GlobalConfig initialization as the only action.
Guardian dispatch has registration and updates but no close
Rustprograms/nicechunk_guardian/src/lib.rs match tag {
0 => initialize_registry(program_id, accounts),
1 => register_genesis_guardian(program_id, accounts, payload),
2 => register_guardian(program_id, accounts, payload),
5 => update_guardian_endpoint(program_id, accounts, payload),
6 => update_guardian_blueprint(program_id, accounts, payload),
8 => update_guardian_operator(program_id, accounts, payload),
_ => Err(NicechunkGuardianError::InvalidInstruction.into()),
}
Registry and Region records can be created or updated, but no current tag closes them or returns their account lamports.
- Lines 1–4
Initialize registry and register genesis or later Regions.
- Lines 5–8
Update endpoint, blueprint, or operator and reject every other tag.
Civilization lifecycle transitions do not include close
Rustprograms/nicechunk_civilization/src/lib.rs match tag {
0 => publish_rule_book(program_id, accounts, payload),
1 => sign_rule(program_id, accounts, payload),
2 => finalize_rule_tally(program_id, accounts),
3 => execute_rule_receipt(program_id, accounts),
4 => publish_power_snapshot(program_id, accounts, payload),
5 => settle_citizen_power(program_id, accounts, payload),
_ => Err(NicechunkCivilizationError::InvalidInstruction.into()),
}
Publishing, signing, finalizing, executing, and settling all leave their program accounts allocated.
- Lines 1–4
Create a rule, signature, tally, or receipt transition.
- Lines 5–8
Create power records or reject an unrecognized action; no close appears.
Smelting dispatch has recipes and execution but no close
Rustprograms/nicechunk_smelting/src/lib.rs match tag {
0 => initialize_recipe_table(program_id, accounts, payload),
1 => upsert_recipe(program_id, accounts, payload),
2 => execute_smelting(program_id, accounts, payload),
4 => apply_civilization_recipe_receipt(program_id, accounts, payload),
_ => Err(NicechunkSmeltingError::InvalidInstruction.into()),
}
The Smelting map exposes treasury-controlled initialization and upsert, owner execution, and Civilization receipt application. Tag 3 authority transfer is absent, and no branch closes RecipeTable or PlayerProgress accounts.
- Lines 1–4
Initialize or update recipes and execute a player Smelting action.
- Lines 5–6
Apply a Civilization receipt or reject every other tag; no authority-transfer or close branch exists.
Modular logout clears browser prefixes without a chain call
JavaScriptplay/play-auth-session.jsexport function clearWalletSession(storage = globalThis.localStorage) {
for (const key of Object.values(walletSessionKeys)) remove(storage, key);
const keys = [];
try {
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (key) keys.push(key);
}
} catch {
return;
}
for (const key of keys) {
if (runtimeCachePrefixes.some((prefix) => key.startsWith(prefix))) remove(storage, key);
}
}
This function deletes local identity and runtime-cache keys. It constructs no transaction, calls no RPC, and invokes no program close.
- Lines 1–2
Remove saved wallet identity values.
- Lines 3–11
Enumerate browser storage, returning early if enumeration fails.
- Lines 12–14
Remove keys matching plugin Session and related runtime prefixes.
Local Game Wallet disconnect does not delete its secret
JavaScriptsrc/localGameWallet.jsexport function getLocalGameWalletProvider() {
const keypair = loadLocalGameWalletKeypair();
if (!keypair) return null;
return {
isNiceChunkLocalGameWallet: true,
isConnected: true,
publicKey: keypair.publicKey,
async connect() {
return { publicKey: keypair.publicKey };
},
async disconnect() {
return undefined;
},
async signTransaction(transaction) {
transaction.partialSign(keypair);
return transaction;
},
The Local Game Wallet provider stays backed by the same loaded keypair, and disconnect returns without removing it.
- Lines 1–7
Load the persistent key and expose its public address.
- Lines 8–13
Connect returns the address, while disconnect performs no deletion.
- Lines 14–17
The same keypair remains able to sign transactions.
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/chain/nicechunkChain.js
Wallet sends assign provider.publicKey as fee payer; gameplay keypair sends assign signer.publicKey.
sdk/nicechunk-player.ts
PlayerSession setup marks owner writable signer and Session authority signer, separating the storage payer from the co-signer.
public/mainnet.json
Current source configuration declares Devnet and the active Player, Chunk, Building, and unified Game program routes.
play/play-onboarding.js
The browser requests getMinimumBalanceForRentExemption by byte length and keeps the 0.000005 SOL network-fee value as a presentation constant.
programs/nicechunk_player/src/state.rs
Defines the exact current lengths for Profile, Session, Equipment, Appearance, UsernameIndex, and InviteIndex.
programs/nicechunk_chunk/src/state.rs
Defines 128-byte PlayerProgress and ChunkBroken header, record, initial capacity, growth, and maximum capacity constants.
src/chain/nicechunkChain.js
Submission enables preflight, confirmation polls signature status, status errors retain signatures, and transaction metadata exposes both payer decrease and meta.fee.
programs/nicechunk_player/src/lib.rs
Player creates or grows Profile, Session, Equipment, Appearance, UsernameIndex, and InviteIndex accounts while dispatch exposes only Appearance close.
sdk/nicechunk-player.ts
Profile, Appearance, Equipment, and Session addresses are derived from their documented owner and authority seeds.
programs/nicechunk_backpack/src/lib.rs
The owner funds Backpack creation and the Backpack instruction map contains no close.
src/chain/nicechunkChain.js
Normal Backpack recovery first tests local cache and then reads the equipped Backpack pointer from PlayerProfile.
programs/nicechunk_market/src/lib.rs
Seller funds Listing creation; active cancellation restores the item and marks the account canceled without draining lamports, while purchase marks the account sold without closing it.
src/chain/nicechunkChain.js
The Market buyer is wallet fee payer and can fund missing seller or treasury associated token accounts during an NCK purchase.
programs/nicechunk_player/src/lib.rs
PlayerAppearance close requires the configured treasury as signer and recipient and drains the Appearance account only to that treasury.
programs/nicechunk_player/src/state.rs
The Appearance state policy explicitly says the wallet funds rent while the protocol treasury owns reclaim authority.
programs/nicechunk_building/src/building.rs
Defines 160-byte BuildSite, 160-byte manifest, 64-byte shard header, uploading and active states, and bounded payload sizes.
programs/nicechunk_building/src/lib.rs
Session authority funds Building accounts; cancellation validates an uploading pending revision, updates BuildSite, and closes existing shards plus manifest to the current Session authority.
src/chain/nicechunkChain.js
The browser queries upload-account rent, adds a one-million-lamport Session buffer, and invokes cancellation only in its pending-payload mismatch recovery branch.
programs/nicechunk_chunk/src/lib.rs
Session authority funds missing Chunk PlayerProgress, ChunkBroken creation, and later ChunkBroken rent top-ups; dispatch has no close.
programs/nicechunk_game/src/lib.rs
The unified Game dispatcher contains Backpack, Chunk, Smelting, and Market namespaces without adding a separate close path; current source configuration selects it for Backpack, Smelting, and Market while Chunk uses its dedicated program.
programs/nicechunk_core/src/lib.rs
Core accepts only GlobalConfig initialization and exposes no close instruction.
programs/nicechunk_guardian/src/lib.rs
Guardian dispatch creates or updates Registry and Region state without an account close or stake-return instruction.
programs/nicechunk_guardian/src/state.rs
Defines 160-byte Registry and 288-byte Region layouts, including a removed status value that is not itself a close implementation.
programs/nicechunk_civilization/src/lib.rs
Civilization allocates its rule and power PDA families and dispatches publish, sign, tally, execute, snapshot, and settle without close.
programs/nicechunk_civilization/src/state.rs
Defines the exact lengths of PowerSnapshot, CitizenPower, RuleBook, RuleSignature, RuleTally, and ExecutionReceipt.
programs/nicechunk_smelting/src/lib.rs
Smelting creates RecipeTable and program-scoped PlayerProgress accounts but exposes no close tag.
play/play-auth-session.js
Modular logout deletes local wallet identity and Session-related browser prefixes without submitting a chain transaction.
login/login.js
Login-page Disconnect Wallet clears its wallet binding after a best-effort provider disconnect and does not perform Session cleanup or a chain close.
src/localGameWallet.js
Local Game Wallet stores an unencrypted Base58 secret under separate keys and its provider disconnect method is a no-op.
src/chain/nicechunkChain.js
Plugin Session secrets are Base64 browser records; a surviving key is reused, while a missing key leads to a different authority and Session PDA.
programs/nicechunk_player/src/state.rs
Session expiry validation rejects expired actions but does not close the Session account or move the authority's ordinary SOL.