TARGET, TWO ADDRESSES, ONE PDA
Start by naming four different money objects
A funding target is only a number saved in this browser. In the current modular Play source, the HUD text “Session: X SOL” is built from state.sessionLamports, which is that locally saved target; it is not an RPC balance for either the owner or the session authority. The funding panel clamps the selected value to at least 0.1 SOL, converts it to lamports, stores it under an owner-scoped key, and later labels a known value “Current session funding target.” Saving or displaying this number creates no transaction and proves no address received anything.
The owner balance and session-authority balance are ordinary address balances. In plugin mode they normally belong to different public keys: the owner approves setup and a possible transfer, while the session authority later pays fees for supported gameplay transactions. A wallet HUD reading the connected owner cannot be silently reused as evidence for the separate authority, and an authority status read cannot prove what remains in the owner wallet.
The PlayerSession PDA is a fourth account. The Player Program asks Solana Rent for the minimum needed by a 184-byte data account and uses the owner payer to create or top up that PDA allocation. The retained Devnet snapshot observed 2,171,520 lamports per captured PDA at its dated context, but did not query either spendable address. Treat that number as dated Devnet rent evidence, not a universal current balance.
No public address owns this number and saving it sends no SOL.
That visible X is local presentation state, not an owner or session-authority getBalance result.
The Play HUD requests getBalance for walletAddress at confirmed commitment.
Plugin status reads the stored Keypair's public address; it can differ from owner.
A separate program-owned data account rather than spendable authority funds.
BELOW THE FLOOR ONLY
The reviewed helper tops up toward a target only after balance falls below 0.1 SOL
The configured target is clamped to at least 100,000,000 lamports. The chain module first looks for an owner-specific browser value, then the default value, and finally falls back to the minimum when the selected number is missing, invalid, or too small. This selection still describes policy, not funds.
For a stored plugin Session, fundGameplaySessionIfNeeded immediately returns when the authority has at least the 0.1 SOL minimum. Only a lower balance reaches target selection. If that lower balance is also below the selected target, the owner is asked to transfer target minus balance. The result is a refill trigger, not continuous target maintenance.
For example, with target 0.50 SOL: an authority balance read at confirmed commitment of 0.06 SOL produces a 0.44 SOL requested difference, while a balance of 0.11 SOL produces no automatic top-up in this helper. In reviewed plugin setup, the owner is transaction fee payer and funds a newly allocated PlayerSession PDA. Supported later gameplay calls can instead use the session authority as fee payer and can spend its balance on fees or action-created accounts. Step 2.13 will inventory those fee and rent paths precisely.
Funding still grants no gameplay permission. A funded authority must also present a PlayerSession that passes the relevant consumer's owner, relationship, expiry, and allowed-action checks. Step 2.11 owns that permission mask and explains why the stored maxActions and actionCount fields are not a currently enforced spending or action ceiling.
Repository client policy and retained static-production literal, not a contract field.
Only when current balance is below both the minimum-entry branch and selected target.
A higher stored target is not filled by the reviewed top-up helper.
The owner pays reviewed plugin setup fees and Session-PDA allocation; Step 2.13 separates every later payer and recovery path.
Funding does not set allowedActions or bypass the consumer checks explained in Step 2.11.
TWO FUNDING SHAPES
Plugin Session separates the small signer purse; Local Game Wallet does not
In plugin mode, owner and sessionAuthority normally differ. The client can transfer SOL from the owner to the separate authority and applies both signatures to setup. Later supported calls can use that authority as transaction fee payer. This arrangement keeps the configured gameplay funds at a separate address, but it is separation rather than isolation: PlayerSession does not cap the balance or technically restrict what the ordinary authority key can sign.
Local Game Wallet uses one persistent browser Keypair as owner and sessionAuthority. The chain module's Local-mode status object reports configuredFundingLamports and minimumFundingLamports as zero to mean that a separate-session top-up policy is not applicable. Those zeros do not mean the address balance is zero, do not overwrite a browser target displayed elsewhere, and do not turn that target into a balance. The same address balance fills both owner and authority roles, while PlayerSession PDA rent remains separate.
The Local Game Wallet private key is stored as Base58 text in browser storage. Plugin Session stores a different Base64-encoded private key under an owner-scoped Session key. Neither encoding is encryption. In plain language, begin every funding decision by asking whether the game is using two addresses or the same address twice; do not rely on a generic label such as “session wallet.”
A distinct authority can receive a modest top-up and sign supported calls.
One persistent key controls the full address balance and fills both setup roles.
This means separate-session funding is not applicable in that status object, not that the wallet balance or every browser target is zero.
Equal owner and authority keys do not merge the program-owned Session PDA into the wallet.
SIX DIFFERENT TIME QUESTIONS
Five highlighted browser windows schedule work; Solana Clock decides contract acceptance
The client requests expiresAt as browser now plus 28,800 seconds, or eight hours. It treats a copied plugin expiry as reusable only when more than 900 seconds remain. Those are browser calculations and can be wrong when the device clock, stored value, or account state is wrong. Setup independently rejects expiresAt at or before Clock.unix_timestamp.
Three highlighted caches answer narrower questions. The Local Game Wallet ready cache can bypass a network account read for five minutes and return a newly calculated now-plus-eight-hours value; it does not rewrite the record. The Session-status source API can return a cached snapshot for sixty seconds unless its force option is used, and the Play owner-balance HUD reuses a balance fetched at confirmed commitment for thirty seconds. None extends authorization.
These are the five Session and funding windows highlighted in this guide, not every timer in the client. The same source also contains a 12-second balance-request abort, a separate 60-second confirmation fallback for a branch without finite block-height information, and shorter polling intervals. A shared number of seconds does not give two timers the same job.
At actual use, audited Chunk, Building, and Backpack branches obtain Clock and reject when expiresAt is less than or equal to chain time. Therefore an optimistic browser countdown can coexist with a contract rejection, and an expired-looking browser copy can be stale while a refreshed chain record exists. The deciding test is the record read plus the consumer's chain-time gate, not one displayed countdown.
Client default of 28,800 seconds, not a contract maximum.
A browser safety margin of 900 seconds.
Can skip an RPC Session read and return a synthetic expiry.
A presentation/read cache, not authorization lifetime.
A wallet HUD policy, not Session refresh or chain time.
Authoritative setup and consumer comparison inside the programs.
Request aborts and transaction polling do not become Session duration, refresh skew, or chain time.
SAME KEY OR DIFFERENT PDA
A readable stored key is normally reused; a missing or invalid record creates a new authority
Plugin mode first tries to open the browser record saved for the connected owner. The saved private bytes must rebuild the public address recorded beside them, and the copied expiry must be a usable number. When those checks succeed, even a near-expiry path keeps the same plugin Keypair and asks setup to use the same authority. The same owner and authority select the same deterministic PlayerSession address.
When that plugin record is missing, malformed, inaccessible, contains invalid private bytes, or rebuilds a different public address, the loader returns no reusable key. Plugin setup then generates a new Keypair. That different authority selects a different PlayerSession PDA and leaves any old authority balance and old PDA untouched. The implementation cannot rediscover private bytes that the browser can no longer read.
Local Game Wallet has a different boundary: it does not generate a separate replacement session authority in this flow. It reloads the persistent owner Keypair and selects the owner-owner Session PDA. On a cache miss, an account fetched at confirmed commitment is reused only when it also passes the client's browser-time 15-minute check; this is still not a consumer's chain-time verdict. If the Local Game Wallet key is unavailable or does not match the owner, the flow reports that wallet unavailable rather than silently creating a new session key. Rotation and refresh are different operations, and neither is revocation.
Near-expiry setup still uses stored.keypair, so PDA selection remains the same.
Only the plugin path generates a new authority and therefore selects a different PDA.
The flow has no separate session-key rotation branch and reports unavailable when the persistent key cannot be used.
No old Session account or balance is an input to the new PDA transaction.
CHAIN FIRST, BROWSER COPY SECOND
Setup has an atomic chain transaction followed by a non-atomic browser secret write
Fresh plugin setup builds one Solana transaction. Depending on existing state, that transaction can initialize a Profile, transfer the authority's target difference, and create or refresh PlayerSession. Solana treats those instructions atomically: if one executed instruction fails, this transaction does not keep only the transfer while rolling back the later Session instruction. Step 2.13 will separate the exact fee and rent charges inside and around that transaction.
The browser lifecycle has another boundary. The wallet helper signs, broadcasts, and returns after RPC reports the transaction at confirmed commitment. Confirmed is stronger than processed, but it is not the same status label as finalized. Only after the helper observes confirmed does setup call the separate browser-storage function. That later localStorage.setItem operation is outside the Solana transaction, so unavailable storage, quota failure, an exception, or a page close can still leave funds and a PDA without a durable browser copy of the private key.
Transport or RPC failure can also make the client result uncertain after bytes were submitted. An error message alone proves neither rejection nor success. If wallet activity or the error report exposes a transaction signature, an ordinary player can inspect that public signature with a trusted explorer on the selected Solana network. A trusted explorer needs no private key.
The reviewed normal modular Play UI does not guarantee that it will show the attempted session-authority address or canonical PDA, and it provides no PDA-derivation or session-balance sweep button. If the signature or required public identifiers are unavailable, the result remains unknown. Do not approve the same setup repeatedly in the hope that one attempt works; retain non-secret wallet activity and error details and use an official support path instead.
These chain instructions succeed or roll back together.
The separate browser write starts after the helper observes confirmed; this page does not relabel that observation finalized.
The reviewed helper accepts confirmed or finalized status when asked for confirmed commitment.
Do not equate a missing client confirmation with a rejected transaction.
Possible when the first secret was not stored and a new Keypair is generated.
No normal PDA-derivation or session SOL sweep control is exposed by the reviewed modular Play UI.
VERIFY ADDRESS, NETWORK, FRESHNESS, PURPOSE
A stale or surprising SOL number needs a fixed verification order
First identify the number's purpose. A panel target came from browser storage; the account HUD reads the connected owner; plugin Session status reads the stored authority; PDA lamports belong to the data account. If the wallet mode is Local Game Wallet, owner and authority addresses match, but target and PDA rent still remain separate.
Second verify context: copy the full public address that is actually visible, confirm the selected cluster and RPC endpoint, and note whether the read uses confirmed commitment. When the interface offers a visible refresh, use it instead of trusting a 30-second HUD cache or 60-second Session status cache. The source API has a force option, but the reviewed normal modular Play UI does not expose a guaranteed player-facing force control. The HUD may deliberately preserve a prior known balance as stale when refresh fails, which is useful presentation but not a fresh chain fact.
Third use only the public evidence you actually have. If wallet activity or an error report exposes a signature, inspect it with a trusted explorer on the same network, then inspect a known authority address. Deriving and reading the canonical PlayerSession PDA requires the public owner and authority identifiers and tooling that normal modular Play does not guarantee. If those identifiers are missing, stop at unknown rather than filling the gap with a browser target or another approval. A balance proves lamports at one address, not a usable pass; a nonempty PDA proves an account exists, not that a consumer will accept it.
Name the object before comparing numbers.
A shortened label can hide that owner and authority differ.
A valid balance on the wrong network still answers the wrong question.
Signature, authority, PDA, and chain-time checks prove different facts; missing identifiers leave the outcome unknown.
The source API can force a status read, but the reviewed normal modular UI does not expose every diagnostic operation.
RECOVERY DEPENDS ON THE KEY
Expiry, logout, lost secrets, and compromised owners require different responses
If only an independent plugin session key is exposed, stop using that key. Chain expiry stops the old record in the audited Session-aware consumers, but anyone holding the private key can still race to move remaining SOL. Those lamports may be movable only when a safe, usable key copy still exists; movement is not guaranteed, and the reviewed normal Play UI provides no session-balance sweep button. Never paste the private key into an unknown website, explorer, form, or support chat.
If the only private-key copy is lost or successfully deleted, the public address, balance, and PDA can remain while the normal browser flow can no longer sign for them. Reconnecting in plugin mode may generate a new authority and rent-funded PDA, but cannot recover the old private key or automatically transfer old SOL. The Player Program dispatch exposes create or refresh and no reviewed Session close, revoke, deactivate, or rent-refund handler. Step 2.13 will inventory which other account types do have exact close or recovery paths.
If the owner wallet or Local Game Wallet key is compromised, old-Session expiry is not a complete stop-loss because that key can approve future setup; Local Game Wallet can satisfy both roles itself. Stop approving new requests and follow the trusted wallet provider's incident procedure. Logout only attempts local prefix deletion, can fail silently, can reach other owner suffixes on the same origin, and changes no chain state. Any attempt to move assets can race an attacker, so this page does not promise recovery.
Anyone with the private key may still race to move remaining SOL, and expiry performs no sweep.
Address and PDA can remain without a usable secret.
The long-lived key can approve or satisfy future setup roles.
No Session revoke, PDA close, rent refund, or authority-balance sweep.
Current Play has no sweep button, and private keys must never be pasted into an unknown site or chat.
VERIFY EACH DECISION
Match every balance, timer, retry, and recovery claim to its actual source
Each formula is tagged to one chapter and translated into plain language. Every source excerpt is copied as one continuous literal range and includes a walkthrough. Repository source, dated static-production bytes, and retained Devnet observations remain separately labeled so a client default is never promoted into a permanent chain fact.
Four separate money objects
moneyContext = { browserTarget, ownerBalance(owner), authorityBalance(sessionAuthority), rentLamports(PlayerSessionPDA) }The braces group questions, not funds. A value from one member cannot be used as evidence for another without the addresses and account type matching.
- browserTarget
- A local policy value that creates no transaction.
- ownerBalance
- Spendable lamports at the owner public key.
- authorityBalance
- Spendable lamports at the gameplay signer public key.
- rentLamports
- Lamports held by the separate program-owned data account.
SOL and lamport conversion
lamports = SOL × 1,000,000,000; 0.1 SOL = 100,000,000 lamportsThe conversion changes units only. It does not identify which account owns the resulting lamports or prove they were transferred.
Configured target selection
selected = ownerTarget when present, otherwise defaultTarget; target = floor(selected) when finite and at least 100,000,000, otherwise 100,000,000The browser selects one saved value rather than merging two values. It then accepts only a finite number at or above the minimum. This is browser policy, not an on-chain Session field.
Current top-up branch
topUp(balance,target) = balance < 0.1 SOL ? max(0, target − balance) : 0This operation order explains why a balance above the minimum is not automatically raised to a larger target.
Worked low-balance example
target 0.50 SOL − balance 0.06 SOL = requested top-up 0.44 SOLThis arithmetic illustrates the source branch only. A request still needs owner approval, successful execution, and a fresh balance read before it becomes evidence of funds received.
Plugin wallet funding shape
pluginMode: ownerAddress differs from authorityAddress; each address has its own balanceThe two balances belong to different addresses; this does not claim statistical independence. A transfer can move lamports between them, but a Session record does not merge ownership or cap the authority key.
Local Game Wallet funding shape
localMode: owner = sessionAuthority ⇒ ownerBalance = authorityBalance for the same address; PDA rent remains separateEquality here is address identity, not a safety guarantee. The one browser key controls the full address balance and both setup roles.
Requested browser expiry
requestedExpiresAt = floor(Date.now() / 1000) + 28,800This is a client request. The Player Program accepts any supplied expiry that is strictly in the future at chain execution; it does not impose an eight-hour maximum.
Plugin reuse safety window
localReuseTimeOK = storedExpiresAt > browserNow + 900 secondsPassing this copied-time predicate does not validate the Session record's full contents or guarantee a later consumer accepts it.
Cache windows are not authorization
age(ready result) < 300s; age(Session status) < 60s; age(owner-balance result) < 30sEach inequality decides whether the browser may reuse local data. None changes expiresAt in the public account.
Authoritative chain-time rule
setupOrUseTimeOK = expiresAt > Clock.unix_timestampEquality is already expired. Consumers can still reject other identity, ownership, or permission conditions even when this comparison passes.
Same-key PDA selection
P_session = PDA(PlayerProgram,["session",owner,authority]); same owner + same authority ⇒ same addressSelecting the same address does not guarantee refresh succeeds; stored relationships and current Profile checks still apply.
Replacement-key PDA selection
authority_new ≠ authority_old ⇒ P_session,new ≠ P_session,old; old state unchangedThe new setup transaction has no automatic old-account close, SOL sweep, rent refund, or revocation side effect.
Atomic chain portion
chainCommit = atomic(optional Profile init + optional authority transfer + Session create/refresh)If that transaction executes with an instruction error, its state changes roll back together. This does not make later browser storage atomic with it.
Two-system persistence boundary
helper observes confirmed → browser attempts localStorage.setItem(private key); the chain transaction is not atomic with the browser writeConfirmed is not relabeled finalized here. A browser-storage failure after the helper's confirmed observation can leave a funded authority and public PDA without a durable browser key copy.
Unknown result is not failure
after-submit client error: possible result = confirmed, rejected, blockhash-expired, or still unknown; do not retry blindlyThis is a list of possible diagnostic states, not a probability claim. A public signature can narrow the list; when the normal UI provides no signature or authority identifier, the honest result remains unknown.
A balance statement needs four coordinates
balanceClaim = (fullAddress, cluster, commitment, observedAt)Without those coordinates, two correct values can appear contradictory because they describe different keys, networks, or cache ages.
Evidence order after setup
when public identifiers exist: inspect signature → inspect known authority → inspect derived Session PDA; missing identifiers → result stays unknownThe order does not mean one successful step proves all later steps. Normal modular Play does not guarantee every identifier or a PDA tool, and a confirmed transfer can fund an authority whose Session is missing, expired, or rejected.
Independent Session-key expiry boundary
independent authority expires on chain → audited Session access stops; key control and any remaining SOL do not move automaticallyRemaining SOL may be movable only when a safe usable key copy still exists, but movement is not guaranteed and can race an attacker. Expiry does not transfer the balance anywhere.
Logout effect boundary
logoutEffect = bestEffortLocalDeletion + redirect; chainMutation = 0The zero refers to the reviewed logout handler's chain transactions. Storage deletion can also fail and is not verified.
Owner compromise exceeds one expiry
ownerSecretCompromised ⇒ attacker can authorize future setup; oldSessionExpiry ≠ complete containmentLocal Game Wallet is the broadest version because the same compromised key fills owner and sessionAuthority roles.
The Play panel saves a target and names its boundary
JavaScriptplay/play-chain-session.js elements.sessionFundingForm?.addEventListener("submit", (event) => {
event.preventDefault();
const sol = Math.max(MINIMUM_SESSION_SOL, Number(elements.sessionFundingAmount?.value) || MINIMUM_SESSION_SOL);
state.sessionLamports = Math.trunc(sol * LAMPORTS_PER_SOL);
saveSessionLamports(state.walletAddress, state.sessionLamports);
saveSessionAcknowledged(state.walletAddress);
closeSessionPanel(true);
render();
appendChainEvent(`Session funding target set to ${formatSol(state.sessionLamports)} SOL for ${ownerLabel()}.`);
setStatus(`Session funding target set to ${formatSol(state.sessionLamports)} SOL. It is a top-up target, not a spending cap or authorization limit.`);
});
Submitting this form clamps and stores a browser number, updates presentation, and explicitly calls it a target. This continuous range contains no transfer or RPC balance read.
- Lines 1–4
Turn the selected SOL policy into an integer lamport target no lower than 0.1 SOL.
- Lines 5–8
Save owner-scoped browser preferences and redraw the panel without constructing a transaction.
- Lines 9–11
Tell the player that the value is a top-up target rather than a cap or authorization limit.
The modular Play Session HUD renders the local target
JavaScriptplay/play-chain-session.js const sessionText = state.sessionLamports > 0 ? `Session: ${formatSol(state.sessionLamports)} SOL` : "Session: not funded";
const rpcText = state.rpcMode === "custom"
? ui("main.profile.rpcCustom", "Custom Devnet RPC")
: state.rpcMode === "helius"
? ui("main.profile.rpcHelius", "Helius RPC")
: ui("main.profile.rpcPublic", "Public RPC");
if (elements.accountName) elements.accountName.textContent = identity.name || profile.name || "Local Miner";
if (elements.accountLevel) elements.accountLevel.textContent = `Lv. ${levelFromProfile(profile)}`;
if (elements.accountTitle) elements.accountTitle.textContent = identity.title || chainModeLabel(state.chainMode);
renderWalletBalance();
if (elements.accountSessionBalance) elements.accountSessionBalance.textContent = sessionText;
The visible Session text is formatted directly from state.sessionLamports, while the owner wallet balance is rendered by a separate function. This is literal evidence that “Session: X SOL” is the local target presentation rather than either RPC balance.
- Line 1
Build the visible Session label directly from the in-memory sessionLamports target or show not funded when it is zero.
- Lines 2–7
Select Public, Helius, or Custom Devnet RPC presentation, then prepare name, level, and title without changing the Session target value.
- Lines 11–12
Render the owner wallet balance separately, then place the prebuilt target-derived Session text in its own HUD element.
The owner HUD asks getBalance for walletAddress
JavaScriptplay/play-chain-session.js body: JSON.stringify({
jsonrpc: "2.0",
id: "nicechunk-wallet-balance",
method: "getBalance",
params: [walletAddress, { commitment: "confirmed" }],
}),
signal: controller?.signal,
cache: "no-store",
The Play balance request is tied to walletAddress and confirmed commitment. In plugin mode that connected owner address can differ from the stored session authority.
- Lines 1–5
Build one JSON-RPC getBalance request for the connected wallet address at confirmed commitment.
- Lines 6–8
Attach the request-abort signal and disable HTTP response caching; the separate in-memory UI cache still exists.
PlayerSession allocation calculates separate rent
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,
);
The Player Program calculates rent for the fixed Session data length and creates the PDA with that lamport allocation. This is not a transfer to sessionAuthority.
- Lines 1–2
Ask the chain Rent sysvar for the minimum balance of the PlayerSession allocation.
- Lines 4–11
When the PDA has no lamports, build a payer-funded account creation for the program-owned data account.
The public setter clamps the target to the client minimum
JavaScriptsrc/chain/nicechunkChain.jsexport function setConfiguredGameplaySessionFundingSol(value, owner = null) {
const parsed = Number(value);
const lamports = Number.isFinite(parsed)
? Math.max(minimumSessionFundingLamports, Math.ceil(parsed * lamportsPerSol))
: minimumSessionFundingLamports;
if (hasLocalStorage()) localStorage.setItem(sessionFundingStorageKey(owner), String(lamports));
return lamports / lamportsPerSol;
}
The setter normalizes a browser preference and stores it when localStorage exists. It neither reads nor changes the authority address balance.
- Lines 1–5
Convert finite SOL input to rounded-up lamports and clamp it to the 0.1 SOL minimum.
- Lines 6–7
Save the target under the selected suffix and return the normalized SOL display value.
Owner-specific target falls back to default then minimum
JavaScriptsrc/chain/nicechunkChain.jsfunction getConfiguredGameplaySessionFundingLamports(owner = null) {
if (!hasLocalStorage()) return minimumSessionFundingLamports;
const ownerValue = localStorage.getItem(sessionFundingStorageKey(owner));
const defaultValue = localStorage.getItem(sessionFundingStorageKey(null));
const parsed = Number(ownerValue ?? defaultValue);
return Number.isFinite(parsed) && parsed >= minimumSessionFundingLamports
? Math.floor(parsed)
: minimumSessionFundingLamports;
}
The nullish operator selects the owner value when present and otherwise the default; the final branch rejects invalid or below-minimum values.
- Lines 1–4
Use the minimum without storage, otherwise read owner-specific and default browser keys.
- Lines 5–8
Select one value and accept it only when finite and at least the minimum.
The top-up helper checks the minimum before the target
JavaScriptsrc/chain/nicechunkChain.jsasync function fundGameplaySessionIfNeeded(provider, sessionAuthority, sessionBalance, conn) {
if (sessionBalance >= minimumSessionFundingLamports) return;
const targetLamports = getConfiguredGameplaySessionFundingLamports(provider.publicKey);
if (sessionBalance >= targetLamports) return;
const lamports = targetLamports - sessionBalance;
if (lamports <= 0) return;
const tx = new Transaction();
tx.add(SystemProgram.transfer({
fromPubkey: provider.publicKey,
toPubkey: sessionAuthority,
lamports,
}));
await signAndSendWalletTransaction(provider, tx, conn);
}
Operation order is decisive: a balance at or above 0.1 SOL returns before a higher target is considered. A lower balance may request the positive difference from owner to authority.
- Lines 1–4
Exit at the minimum threshold; only a lower balance loads and compares the configured target.
- Lines 5–6
Calculate and validate a strictly positive difference.
- Lines 7–13
Build the owner-to-authority System transfer and ask the wallet helper to submit it.
Local Game Wallet status has one address and no separate target
JavaScriptsrc/chain/nicechunkChain.jsfunction createLocalGameWalletStatus(owner, balanceLamports = null, expiresAt = null) {
const ownerKey = owner?.toBase58?.() ?? String(owner ?? "");
const normalizedBalance = Number.isFinite(balanceLamports) ? Math.floor(balanceLamports) : null;
return {
walletAvailable: true,
owner: ownerKey,
acknowledged: true,
configuredFundingLamports: 0,
minimumFundingLamports: 0,
balanceLamports: normalizedBalance,
balanceSol: normalizedBalance === null ? null : normalizedBalance / lamportsPerSol,
publicKey: ownerKey,
sessionAuthority: ownerKey,
expiresAt,
usesGameplaySession: false,
walletMode: "localGameWallet",
};
}
The status object uses ownerKey for owner, publicKey, and sessionAuthority, while reporting zero for the separate-session funding policy fields. Zero target does not mean zero wallet balance.
- Lines 1–3
Normalize the one Local Game Wallet address and an optional fresh balance.
- Lines 4–10
Report no separate funding target while retaining the actual one-address balance.
- Lines 11–17
Put the same key in public and authority roles and label the wallet mode explicitly.
Local Game Wallet fills both setup roles with owner
JavaScriptsrc/chain/nicechunkChain.js tx.add(createOrRefreshPlayerSessionInstruction({
owner,
sessionAuthority: owner,
playerProfile,
playerSession,
expiresAt,
}));
await signAndSendKeypairTransaction(keypair, tx, conn);
The same public key occupies owner and sessionAuthority, and the persistent Local Game Wallet Keypair signs the transaction directly.
- Lines 1–7
Construct Session setup with owner repeated in the authority role.
- Line 9
Sign and submit using that same long-lived browser Keypair.
The Local Game Wallet secret is plain Base58 text
JavaScriptsrc/localGameWallet.jsfunction storeLocalGameWalletKeypair(keypair, source = "created") {
const address = keypair.publicKey.toBase58();
const secretKey = bs58.encode(keypair.secretKey);
const createdAt = String(Date.now());
if (!hasLocalStorage()) throw new Error("Browser local storage is unavailable.");
localStorage.setItem(localGameWalletKeys.address, address);
localStorage.setItem(localGameWalletKeys.secretKey, secretKey);
Base58 changes representation but does not encrypt the full long-lived key. Its funding exposure therefore follows the one owner-and-authority address.
- Lines 1–4
Derive the address, reversible Base58 secret text, and browser creation time.
- Lines 5–7
Require localStorage and save both public address and private signing bytes.
Repository policy declares duration, skew, and cache windows
JavaScriptsrc/chain/nicechunkChain.jsconst sessionDurationSeconds = 8 * 60 * 60;
const sessionRefreshSkewSeconds = 15 * 60;
const lamportsPerSol = 1_000_000_000;
const minimumSessionFundingLamports = 100_000_000;
const sessionMinimumMiningLamports = 8_000_000;
const sessionAllowedActions = (1 << 1) | (1 << 2);
const sessionMaxActions = 10_000;
const miningComputeUnitLimit = 1_400_000;
const fallbackTransactionFeeLamports = 5_000;
const transactionConfirmationPollMs = 500;
const transactionBlockHeightPollMs = 1_000;
const transactionConfirmationTimeoutMs = 60_000;
const treeFellMaxChunkCount = 4;
const treeFellLeafRadius = 2;
const supportCollapseMaxOnChainBlocks = 48;
const bulkMiningModeDebug = 1;
const chunkDeltaCacheTtlMs = 60_000;
const gameplaySessionStatusCacheTtlMs = 60_000;
const gameplaySessionReadyCacheTtlMs = 5 * 60_000;
The relevant declarations set eight-hour duration, fifteen-minute skew, sixty-second status cache, and five-minute ready cache. The nearby sixty-second confirmation fallback is a different policy and must not be confused with Session lifetime.
- Lines 1–4
Declare requested duration, refresh skew, SOL conversion, and funding minimum.
- Lines 5–16
Declare unrelated action, fee, polling, and gameplay constants that share the same module.
- Lines 17–19
Declare independent sixty-second Session status and five-minute readiness cache lifetimes.
Play declares a separate thirty-second owner-balance cache
JavaScriptplay/play-chain-session.jsconst LAMPORTS_PER_SOL = 1_000_000_000;
const MINIMUM_SESSION_SOL = 0.1;
const BALANCE_CACHE_TTL_MS = 30_000;
const BALANCE_REFRESH_INTERVAL_MS = 30_000;
const BALANCE_REQUEST_TIMEOUT_MS = 12_000;
let localWalletModulePromise = null;
let localWalletModuleManifestPromise = null;
const walletBalanceCache = new Map();
The HUD's thirty-second cache and refresh interval are independent of the chain module's Session status and readiness caches. The twelve-second HTTP abort is yet another timer.
- Lines 1–2
Declare UI unit conversion and the displayed minimum target.
- Lines 3–5
Set the owner-balance cache, refresh interval, and request-abort policies.
- Lines 6–8
Keep module-loading state and balance-cache data in separate in-memory values.
The Player Program compares requested expiry with chain Clock
Rustprograms/nicechunk_player/src/lib.rs let clock = Clock::get()?;
if expires_at <= clock.unix_timestamp {
return Err(NicechunkPlayerError::InvalidInstruction.into());
}
Setup accepts only a strictly future expiry at instruction execution. Browser Date.now and cache age are not used inside this contract decision.
- Line 1
Read Solana's Clock sysvar during the program instruction.
- Lines 2–4
Reject an expiry equal to or earlier than the chain Unix timestamp.
Stored plugin records fail closed to no reusable key
JavaScriptsrc/chain/nicechunkChain.jsfunction loadStoredGameplaySession(owner) {
try {
if (!hasLocalStorage()) return null;
const raw = localStorage.getItem(sessionStorageKey(owner));
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed?.secretKey || !Number.isFinite(parsed.expiresAt)) return null;
const secretKey = base64ToBytes(parsed.secretKey);
const keypair = Keypair.fromSecretKey(secretKey);
if (parsed.publicKey && keypair.publicKey.toBase58() !== parsed.publicKey) return null;
return { keypair, expiresAt: Number(parsed.expiresAt) };
} catch {
return null;
}
}
Missing storage, malformed JSON, invalid fields, invalid secret bytes, or a mismatching public key all leave the caller with null rather than an accessible old Keypair.
- Lines 1–5
Require readable localStorage and an owner-scoped serialized record.
- Lines 6–10
Decode JSON and secret bytes, then reject an optional saved-public-key mismatch.
- Lines 11–14
Return the recovered key and expiry, or null for any thrown decoding or storage error.
The setup path reuses stored keypair or generates one replacement
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();
A decoded stored record keeps its exact authority even when the fast reuse branch was not taken. Only null storage causes Keypair.generate and therefore a different authority seed.
- Line 1
Prefer the recovered Keypair and generate only when no usable stored record exists.
- Lines 2–5
Request a new browser expiry and derive Profile and Session addresses from owner and selected key.
Local Game Wallet always derives Session from owner twice
JavaScriptsrc/chain/nicechunkChain.jsasync function getOrCreateLocalGameWalletGameplaySession(provider) {
const owner = provider.publicKey;
const keypair = getLocalGameWalletKeypair();
if (!keypair?.publicKey?.equals?.(owner)) {
throw new Error("Local game wallet is unavailable.");
}
const nowSeconds = Math.floor(Date.now() / 1000);
const expiresAt = nowSeconds + sessionDurationSeconds;
if (isGameplaySessionReadyCached(owner, owner)) {
return { keypair, expiresAt, localGameWallet: true };
}
const conn = getNicechunkConnection();
const [playerProfile] = derivePlayerProfilePda(owner);
const [playerSession] = derivePlayerSessionPda(owner, owner);
This branch requires the persistent Local Game Wallet key to match owner, queries readiness with owner in both positions, and derives the same-key PDA. It has no separate authority generator.
- Lines 1–6
Load the persistent key and refuse to continue if its public key differs from the provider owner.
- Lines 8–12
Compute browser time and allow the five-minute same-key ready shortcut.
- Lines 14–16
On a cache miss, derive the Profile and owner-owner Session addresses for RPC checks.
Fresh plugin setup places funding and Session setup in one transaction
JavaScriptsrc/chain/nicechunkChain.js 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,
}));
The optional Profile initialization, optional target difference transfer, and create-or-refresh instruction are appended to the same transaction object before submission.
- Lines 1–3
Add Profile initialization only when the fetched Profile has no data.
- Lines 4–13
Select target by the minimum-entry rule and append an owner-to-new-authority transfer only for a positive shortfall.
- Lines 14–20
Append Session setup for the exact selected authority and expiry to that same transaction.
Browser secret persistence happens after the wallet helper returns
JavaScriptsrc/chain/nicechunkChain.js await signAndSendWalletTransaction(provider, tx, conn, [keypair]);
const session = { keypair, expiresAt };
storeGameplaySession(owner, session);
markGameplaySessionReady(owner, keypair.publicKey, targetLamports);
The wallet helper first observes its requested confirmed commitment. Only then does the source call browser-storage persistence and mark the in-memory ready cache, making the private-key copy a second commit outside the transaction; confirmed is not relabeled finalized.
- Line 1
Apply the extra authority signature, owner approval, broadcast, and helper observation at confirmed commitment before returning.
- Lines 2–3
Assemble the local browser record and attempt to persist its private key after that confirmed observation.
- Line 4
Mark readiness only after storeGameplaySession returns without throwing.
The wallet helper broadcasts before polling confirmed status
JavaScriptsrc/chain/nicechunkChain.js 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;
}
In this provider branch the raw transaction is already submitted before status polling completes. The helper asks for confirmed, which can accept a confirmed or finalized status but does not require finalized specifically. A later error therefore needs reconciliation rather than an assumption that nothing landed.
- Lines 1–3
Obtain the owner signature and broadcast serialized transaction bytes, receiving a signature string.
- Lines 4–8
Poll that signature and blockhash lifetime while explicitly requesting confirmed commitment rather than finalized.
- Line 9
Return the signature only after the helper's confirmed predicate succeeds; this wording does not upgrade it to finalized.
The normal modular error feed shows a reason, not every recovery identifier
JavaScriptplay/play-chain-session.js } catch (error) {
state.chainMode = "adapter-error";
const reason = readableError(error);
logSubmissionFailure(action, pending, {
stage: "exception",
reason,
error,
});
state.chainResults.set(pending.txId, { reason });
const rolledBack = action === "mine" ? controls.rollbackTx?.(pending.txId) : null;
if (!rolledBack) {
appendSubmissionState(
action,
pending,
`${pending.txId} chain adapter error: ${reason}.`,
"error",
{ reason },
);
}
render();
}
The player-facing event text contains the local pending ID and readable reason. A separate structured console report may contain more when the error carries it, but this visible branch does not guarantee a signature, attempted authority, derived PDA, or sweep control.
- Lines 1–8
Convert the thrown value into a readable reason and send richer details to the separate structured failure logger.
- Lines 9–18
Store and display the reason around local rollback state without promising a transaction signature or authority address in the visible message.
- Lines 19–20
Render the updated error state; this range exposes no PDA derivation or session-balance sweep action.
Session status can return a sixty-second cached snapshot
JavaScriptsrc/chain/nicechunkChain.js const owner = provider.publicKey;
const ownerKey = owner.toBase58();
const cached = gameplaySessionStatusCache.get(ownerKey);
if (!force && cached && Date.now() - cached.loadedAt < gameplaySessionStatusCacheTtlMs) {
return { ...cached.status };
}
The status cache is owner-keyed and bypassed by force. Without force, a valid cached object can be returned without a new balance or Session read.
- Lines 1–3
Normalize the connected owner and retrieve its in-memory Session-status entry.
- Lines 4–6
Return a copy when force is false and the entry remains within its declared TTL.
A failed HUD refresh can preserve the previous value as stale
JavaScriptplay/play-chain-session.js .catch((error) => {
if (requestSerial === state.walletBalanceRequestSerial) {
state.walletBalanceStatus = hadBalance ? "stale" : "error";
renderWalletBalance();
console.warn("NiceChunk wallet balance refresh failed", error);
}
return { ok: false, reason: readableError(error) };
})
When a previous balance exists, a refresh failure marks it stale instead of clearing it. The number can remain useful on screen but is not a fresh RPC observation.
- Lines 1–3
Handle only the newest request and choose stale when an earlier known balance exists.
- Lines 4–5
Redraw that state and record the refresh error in the console.
- Lines 7–8
Return a structured failure without changing it into a successful fresh read.
Plugin Session status reads the stored authority address
JavaScriptsrc/chain/nicechunkChain.js let balanceLamports = null;
try {
balanceLamports = await getNicechunkConnection().getBalance(stored.keypair.publicKey, "confirmed");
} catch (error) {
reportRpcError(error, "session-balance");
throw error;
}
const status = {
walletAvailable: true,
owner: ownerKey,
acknowledged: hasAcknowledgedGameplaySessionFunding(owner),
configuredFundingLamports,
minimumFundingLamports: minimumSessionFundingLamports,
balanceLamports,
balanceSol: balanceLamports / lamportsPerSol,
publicKey: stored.keypair.publicKey.toBase58(),
expiresAt: stored.expiresAt,
usesGameplaySession: true,
walletMode: "pluginWallet",
};
This read targets stored.keypair.publicKey, then returns owner and authority-related fields together. The object must not be interpreted as one merged wallet.
- Lines 1–7
Read the stored authority's balance at confirmed commitment or surface the RPC failure.
- Lines 9–14
Keep the owner identity and configured browser funding policy as separate status fields.
- Lines 15–21
Attach the authority balance, copied expiry, public key, and explicit plugin mode.
The reviewed Player dispatch has create or refresh but no Session kill tag
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()),
}
Tag 4 creates or refreshes PlayerSession. Tags 13-15 manage equipment and durability; tag 16 releases equipment to Market. None closes a Session, while tag 9 closes Appearance only.
- Lines 1–6
The first dispatch group initializes and updates Player state, with tag 4 as the sole PlayerSession create-or-refresh entry.
- Lines 7–12
Name, appearance, and invitation operations occupy their own tags; tag 9 closes only PlayerAppearance.
- Lines 13–14
Tags 13 and 14 transfer one equipment slot or swap two equipment slots without adding a PlayerSession lifecycle instruction.
- Lines 15–17
Durability and Market release complete the current map, after which every unknown tag is rejected without a PlayerSession close path.
Logout scans every matching runtime prefix on this origin
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);
}
}
Cleanup first attempts identity fields and then every matching prefix without restricting the owner suffix. Enumeration failure exits early, and this function sends no chain transaction.
- Lines 1–2
Attempt removal of the fixed wallet-identity browser keys.
- Lines 3–11
Enumerate storage keys, but abandon the prefix pass when enumeration throws.
- Lines 12–15
Attempt removal for every recognized runtime prefix across all suffixes on the origin.
Per-key cleanup errors are swallowed
JavaScriptplay/play-auth-session.jsfunction remove(storage, key) {
try {
storage?.removeItem?.(key);
} catch {
// Logout still redirects even when browser storage is unavailable.
}
}
The helper does not read back the key or report removal failure. Logout can continue even when a secret or target remains in browser storage.
- Lines 1–3
Attempt one optional localStorage removal for the supplied key.
- Lines 4–7
Catch and ignore an exception so redirect flow continues without a deletion guarantee.
IMPLEMENTATION EVIDENCE
Where these claims come from
Each claim is intentionally scoped to a concrete implementation path. These references are for verification, not decoration.
play/play-chain-session.js
Modular Play stores an owner-scoped funding target, renders `Session: X SOL` directly from state.sessionLamports, and separately requests the connected walletAddress balance at confirmed commitment.
src/chain/nicechunkChain.js
Plugin Session status requests getBalance for stored.keypair.publicKey, so its authority balance can describe a different address from the Play owner HUD.
programs/nicechunk_player/src/lib.rs
PlayerSession allocation calculates Rent::minimum_balance for PlayerSession::LEN and uses the owner payer to create or fund the separate PDA.
docs/audits/player-sessions-devnet-2026-07-18.json
The retained confirmed Devnet snapshot records 2,171,520 lamports as the observed rent minimum for its 184-byte Session accounts and explicitly does not provide authority balances.
src/chain/nicechunkChain.js
The target getter uses ownerValue ?? defaultValue, applies the 100,000,000-lamport floor, and the funding helper checks that minimum before loading a higher target.
play/play-chain-session.js
The visible form clamps its stored SOL target to at least 0.1 and tells the player that this value is not a cap or authorization limit.
programs/nicechunk_player/src/lib.rs
PlayerSession setup separately validates allowed_actions, future chain-time expiry, signer roles, and account relationships, so transferring SOL cannot by itself grant a consumer permission.
docs/audits/player-sessions-production-2026-07-18.json
The retained 2026-07-19 UTC static production byte audit records a 100,000,000-lamport minimum, owner-then-default lookup, and its fixed-GET privacy boundary.
docs/audits/player-sessions-derived-2026-07-18.json
The deterministic Player Sessions audit separates the client funding policy from ordinary signing capability and from PlayerSession record fields.
src/chain/nicechunkChain.js
Plugin setup uses a separate selected Keypair when available, while Local Game Wallet setup passes owner as sessionAuthority and reports one address in its status.
src/localGameWallet.js
Local Game Wallet persists its long-lived secret bytes as a reversible Base58 string under separate browser storage keys rather than an encrypted Session vault.
programs/nicechunk_player/src/lib.rs
The Player Program requires owner and session_authority signer roles; one matching public key can occupy both roles in the Local Game Wallet transaction.
docs/audits/player-sessions-devnet-2026-07-18.json
The dated Devnet snapshot observed five owner-equals-authority and five distinct-authority Session records without reading or possessing any corresponding secrets.
src/chain/nicechunkChain.js
Repository source declares the 28,800-second duration, 900-second skew, 60-second Session status cache, and five-minute Local Game Wallet readiness cache.
play/play-chain-session.js
Play independently declares a 30-second connected-wallet balance cache and refresh interval plus a 12-second RPC request abort.
programs/nicechunk_player/src/lib.rs
Session setup reads Clock and rejects expires_at less than or equal to clock.unix_timestamp, without using a browser cache timer.
programs/nicechunk_chunk/src/lib.rs
The audited Chunk action context reads the Solana Clock and passes Clock.unix_timestamp into PlayerSession validation at actual instruction use.
src/chain/nicechunkChain.js
loadStoredGameplaySession returns a Keypair only after storage, JSON, secret, expiry, and optional public-key checks; setup otherwise selects Keypair.generate.
sdk/nicechunk-player.ts
The SDK derives the canonical Session address from the ordered literal session seed, owner public key, and session-authority public key.
programs/nicechunk_player/src/state.rs
PlayerSession refresh validates the stored owner, authority, Profile, and configuration relationships before rewriting selected fields on the same account.
docs/audits/player-sessions-devnet-2026-07-18.json
One retained owner had three canonical Session PDAs in the dated snapshot, showing coexistence but not proving simultaneous usability or why the keys were created.
src/chain/nicechunkChain.js
Fresh plugin setup appends optional Profile initialization, optional owner-to-authority funding, and Session setup to one Transaction before calling the wallet helper.
src/chain/nicechunkChain.js
The wallet helper broadcasts and explicitly polls at confirmed commitment before getOrCreateGameplaySession calls storeGameplaySession; its predicate can accept confirmed or finalized status but does not require finalized specifically.
docs/audits/player-sessions-derived-2026-07-18.json
The retained derived audit records browser-secret storage and chain Session state as separate lifecycle surfaces rather than one atomic record.
docs/audits/player-sessions-production-2026-07-18.json
The static live-byte audit records storage field names and setup defaults but sent no transaction and read no browser storage, so it cannot prove any player's setup outcome.
play/play-chain-session.js
The reviewed modular Play exception branch renders a pending ID and readable reason while richer fields go to a separate structured logger; the visible message does not guarantee a signature, authority address, PDA derivation, or sweep control.
play/play-chain-submission-log.js
The structured failure report can extract a transaction signature only when an error, result, or pending record actually carries one, so the documentation must preserve an explicit signature-unavailable outcome.
play/play-chain-session.js
The connected-wallet HUD caches by RPC URL plus wallet address, marks a previously known balance stale after refresh failure, and uses confirmed getBalance requests.
src/chain/nicechunkChain.js
getGameplaySessionStatus supports a source-level force option and otherwise may return a 60-second cached owner status; the reviewed normal modular Play UI does not thereby guarantee a player-facing force or PDA tool.
src/rpcConfig.js
Runtime RPC configuration resolves the selected cluster and endpoint separately from any public address, making network context part of a balance claim.
scripts/capture-player-sessions-devnet.mjs
The retained Devnet capture pins shared context and block time while explicitly separating Session account observation from unqueried authority balances and private keys.
play/play-chain-session.js
The reviewed modular Play UI implements an owner-wallet balance refresh and readable failure presentation but does not expose a guaranteed player control for source-level Session force reads, PDA derivation, or authority-balance sweeping.
programs/nicechunk_player/src/lib.rs
The complete reviewed Player dispatch currently reaches tag 16: equipment transfer is tag 13, slot swap is tag 14, session-authorized durability consumption is tag 15, and equipment release to Market is tag 16. Tag 4 remains Session create-or-refresh, and no tag closes, revokes, deactivates, or refunds PlayerSession.
play/play-auth-session.js
Logout attempts identity removal and a prefix-wide runtime-key scan, can exit on enumeration failure, swallows per-key removal exceptions, and exposes no chain revoke or session-authority SOL sweep.
src/localGameWallet.js
The persistent Local Game Wallet private key uses storage keys outside the plugin Session prefix, so plugin Session cleanup is not owner-key recovery.
docs/audits/player-sessions-devnet-2026-07-18.json
All ten retained Session accounts were expired relative to their captured confirmed context time while the public accounts and their rent lamports still existed.