PLAYER, WALLET, AND CONTROL 02 · BROWSER-HELD KEY

The Game Wallet is a key in this browser, not an encrypted vault

NiceChunk can generate or import one Solana keypair and keep its complete Base58 secret in this site's browser localStorage. That secret is full signing authority for the same Solana address on every cluster, not a NiceChunk-only permission. The current client targets Devnet: use a new dedicated low-balance wallet and Devnet SOL only, never import a main wallet, and understand backup, same-origin access, silent replacement, direct signing, and the limits of Disconnect and deletion before funding it.

24 min read
In a teaching scene generated from the canonical NiceChunk villagers and a real Chunk.js hillside reference, the girl prepares a pickaxe at an open timber camp while one closed wooden box stays on her shelf and the boy waits outside.
The ordinary closed box represents a browser-held signing secret, and the girl's tool represents the actions it can authorize. They are teaching metaphors rather than game wallet objects or state indicators; this image proves no encryption, completed backup, private-key safety, signature, balance, or chain success.
PLAYER QUESTION How is the built-in Game Wallet stored, backed up, used to sign across Solana clusters, disconnected, deleted, and recovered?
Learning path
Player, wallet, and control · Step 2 of 15
Audience
Players with no programming or blockchain background who want fewer wallet popups
Current network
Client target: Solana Devnet · the secret key itself is not cluster-specific
Reviewed
20 July 2026 · current working tree, 15 focused key tests, a built-browser wallet-flow audit, and a dedicated login import audit
Built-in Game Wallet
A Solana keypair created or imported by NiceChunk and held by this browser origin. It is separate from an external wallet extension.
Keypair
A mathematically related public address and signing secret. The public half identifies the wallet; the secret half can authorize actions.
Wallet provider
The JavaScript interface that supplies a public key and signing methods. The built-in provider reconstructs the local Keypair; an external provider asks a separate wallet app or extension.
Solana cluster
A separate Solana network such as Devnet or Mainnet. A private key is not marked Devnet-only: it derives the same public address and signing authority on every cluster.
Lamport
The smallest unit of SOL. One SOL is one billion lamports, so the current 100,000,000-lamport login minimum equals 0.1 SOL.
PlayerSession PDA
A PDA, or program-derived address, is an on-chain record controlled by a Solana program. PlayerSession describes who may perform supported player actions and until when. In local Game Wallet mode its session authority is the owner itself, not a separate temporary key.
Public address
The Base58 Solana address shown for funding and public account lookup. It is safe to inspect publicly but can link activity and balances.
Private or secret key
The secret bytes that let software sign as this wallet. NiceChunk stores the complete 64-byte keypair secret under a localStorage key named secretKey.
32-byte seed
A lower-level key source that is allowed only inside an explicitly typed NiceChunk seed record. A bare 32-byte Base58 value or JSON array is rejected because it is indistinguishable from a public address; it is not a 12-word or 24-word recovery phrase.
64-byte secret key
The other accepted import form and the form emitted by a Solana Keypair. The current code Base58-encodes these 64 bytes for storage and backup.
Base58
A text encoding that avoids visually confusing characters. It is not encryption, a password, or protection from scripts that can read the text.
JSON secret-key array
A pasted array of exactly 64 integers, each from 0 through 255. The importer validates every element before conversion; JSON-quoted Base58 text is accepted too, while generic secretKey objects are rejected to avoid confusing owner keys with temporary session or encryption keys.
localStorage
Persistent key-value storage for one browser origin. Same-origin JavaScript can read it, and clearing site data can erase it.
Browser origin
The browser's identity boundary for one site address and protocol. Pages on the same origin normally share localStorage; another path on that site is not a separate wallet vault.
Same-origin script
JavaScript running with access to the same site origin. Such code can read this wallet's stored Base58 secret; localStorage does not isolate it like a wallet extension.
RPC
The network service the client asks for a balance, account data, a recent blockhash, transaction submission, and confirmation status. One RPC response is an observation, not a backup or signing authority.
Transaction bytes
The exact serialized request containing instructions, accounts, fee payer, and a recent blockhash. A signature authorizes those bytes; it does not guarantee that the network accepts them.
Confirmed and finalized
Two Solana confidence levels. This reviewed signing path waits for confirmed; finalized is a stronger later state, so the page does not use the words interchangeably.
Backup confirmation
A login-page checkbox for a newly created wallet. It records no durable proof and cannot know whether the player actually saved a usable backup.
Confirmed balance
The RPC balance observed with Solana's confirmed commitment. It is a snapshot, not finalized proof, a spending reserve, or a promise that every future action can pay.
Disconnect
Clearing the selected wallet binding and asking an extension provider to disconnect when one exists. It does not delete the built-in Game Wallet secret.
Delete and recover
Deleting means removing this browser record or clearing site data; it does not erase every copy or refund a balance. Recovery means reconstructing the same keypair from valid secret material. The reviewed client exposes no server-backed recovery path, so do not assume support can restore it.

Key points

Base58 text is stored in plain browser storage

Creation and import both end with a complete 64-byte secret encoded as Base58 under nicechunk.localGameWallet.secretKey. No encryption or password layer is applied.

Back up before funding or replacing

The full login page asks creators to confirm a backup before continuing, but the checkbox is only self-attestation. Create and Import can silently replace an existing local record.

It is full account authority across clusters

The signer does not restrict itself to NiceChunk instructions. The same secret controls the same address on Devnet and Mainnet; the 0.1 SOL gate, action bits, and current interface are not permission caps.

Disconnect, Delete, and Recover are different

Disconnect clears session binding fields. Deleting site data removes only browser records, not balances or every copied key. If the last valid secret is lost, assets can remain on chain but become permanently inaccessible.

STORAGE AND IMPORT BOUNDARY

Creation and import both end as one browser-held Base58 secret

Create generates a Solana Keypair. Import accepts a complete 64-byte secret as Base58 text, the same text wrapped as a JSON string, or a strict JSON array of exactly 64 byte integers. An advanced compatibility path accepts a 32-byte seed only as {"type":"nicechunk-game-wallet-seed","version":1,"seed":"<Base58-encoded 32-byte seed>"}; NiceChunk backups do not emit this record. Bare 32-byte values are rejected because a Solana public address has the same decoded length. Whichever route succeeds, NiceChunk writes the derived public address, the complete 64-byte secret encoded as Base58, the current millisecond timestamp, and a created-or-imported source label into four separate localStorage entries.

Those entries are plain origin storage, not an encrypted wallet file. Base58 changes the alphabet, not who can read the value. A same-origin script can request the secret record, and clearing this site's data can remove the only browser copy. The source label is editable browser data used for interface behavior, not cryptographic provenance. For an imported wallet, createdAt means when this browser stored it, not when that key first existed.

The key has no NiceChunk-only or Devnet-only flag. Solana derives the same address from the same secret on Devnet and Mainnet. Importing a main-wallet secret into this Devnet-targeted page therefore exposes the authority that same key has over its Mainnet address, tokens, accounts, and other permissions. Use a newly created dedicated Game Wallet and Devnet SOL only; do not send Mainnet SOL to this flow.

The importer now rejects input longer than 8,192 characters before parsing and validates JSON byte arrays before Uint8Array conversion: every item must be an integer from 0 through 255 and the complete secret must contain exactly 64 bytes. It accepts a JSON-quoted Base58 key but rejects generic secretKey or privateKey objects because other NiceChunk browser records use those names for temporary session or encryption material. Recovery phrases, hexadecimal, Base64, labels, internal whitespace, zero-width characters, OCR substitutions, and other unsupported characters are rejected rather than silently repaired. A different complete and internally consistent private key remains structurally valid, so the importer cannot know it was pasted by mistake; the displayed address must be compared with the backup address.

Create and Import call the same storage routine without checking for an existing record or asking permission to replace it. Four keys are written one after another rather than atomically. A second action can silently overwrite an unbacked wallet, and a storage failure between writes can leave a partial or mismatched record. Reading the record requires an address and secret but does not derive the address from the secret and compare them. Later provider construction derives its public key from the secret, so a corrupted stored address and stored secret can describe different wallets.

Stored secret Base58 of the complete 64-byte Keypair secret

An explicitly typed 32-byte seed record is expanded to a Keypair first, so storage still receives the resulting 64-byte secret. Bare 32-byte values are rejected.

Stored metadata Address, timestamp, and created/imported label

They are four independent localStorage values with no schema version, atomic commit, or authenticated relationship.

JSON validation Strict 64-byte integer array

The parser rejects non-integers, values outside 0 through 255, nested or generic objects, and every bare length other than 64 before Keypair construction.

Replacement behavior Last successful Create or Import wins

There is no overwrite confirmation or automatic export of the previous secret.

Cluster boundary The secret is not Devnet-only

The current page targets Devnet, but the same secret controls the same public address wherever that key has authority, including Mainnet.

In a teaching scene generated from the canonical villagers and a real Chunk.js hillside reference, the girl places one closed wooden box on an exposed shelf inside an open timber shelter while the boy watches from outside.
The open shelter and ordinary shelf represent storage available to code running in the same site origin; the closed box represents the secret. They are not literal browser objects, and the scene proves no encryption, isolation, completed backup, private-key safety, signature, balance, or chain success.

BACKUP AND FUNDING BOUNDARY

Verify a recoverable backup before sending even a small balance

After the full login page creates a wallet, it shows the public address and complete Base58 private-key backup, clears the confirmation checkbox, and blocks Continue until the player checks it. The page does not verify a clipboard copy, test a restored key, download a file, or persist the confirmation. A creator can check the box without saving anything. When an unbound created record is shown again later, the secret can be displayed again and the checkbox is reset.

The Copy private key button writes the value to the operating system clipboard and reports success or failure. It does not read the clipboard back, verify the reconstructed address, or clear the clipboard later. Clipboard history, clipboard synchronization, and other applications can retain copied text. On a shared browser profile, another person who can use the same origin may also reach the saved record. localStorage is not a reliable cross-device backup: changing device, browser, browser profile, site origin, privacy mode, or site-data settings can make the wallet unavailable, while some profile-backup or synchronization systems may copy browser data in ways this client does not verify.

Imported records take another branch: the full login page blanks the private-key field, hides the backup controls, and skips the backup checkbox because the player supplied the key. That does not remove the imported secret from localStorage. The source label controlling this interface branch is local data rather than proof that a safe external backup still exists.

The full login Continue path then asks the configured RPC for the stored address's confirmed SOL balance. It requires at least 100,000,000 lamports, or 0.1 SOL, before it stores the wallet session and advances. This is a minimum gate, not an exact required deposit, maximum balance, escrow, spending cap, fee guarantee, or rule that constrains where later transactions can send SOL. A three-decimal formatter can even display a value just below the threshold as 0.100 SOL while the integer comparison still rejects it.

The in-game Play wallet panel is a separate path and bypasses both full-login gates. Create immediately writes the wallet and applies a wallet session while merely telling the player to save and fund it. Use local game wallet also applies a session without checking a backup box, source label, or 0.1 SOL balance. That bypass does not make an unfunded transaction succeed and does not bypass any on-chain instruction validation; it only means the login-page safeguards are not universal.

Created-wallet checkbox A temporary self-attestation

It is reset whenever the vault is shown and does not prove that a backup exists or is usable.

Imported-wallet branch Secret hidden and checkbox bypassed

The secret remains stored even though the full login page does not show it in that branch.

0.1 SOL Current full-login minimum

It is a confirmed balance comparison against 100,000,000 lamports, not a cap, reserve, escrow, or protocol authorization rule.

Play wallet panel Applies the session without those two gates

It exposes separate Create and Use local wallet actions that do not enforce the backup checkbox or minimum-balance comparison.

Clipboard and device boundary Copying and persistence are outside the checkbox

The client does not clear clipboard history or guarantee storage survives privacy mode, profile changes, site-data cleanup, or device changes.

  1. Use only a dedicated low-balance Game Wallet

    The interface warns against importing a main wallet, but code cannot recognize one. Keep valuable assets and long-lived authority elsewhere.

  2. Save the exact secret before funding

    Store the complete Base58 value through a trusted offline backup process. Do not send it through chat, a cloud form, or support; screenshots and synchronized clipboard history can also leak or replicate it.

  3. Verify the backup without exposing it

    Use a separate trusted recovery environment to confirm that the saved material reconstructs the same public address. Do not use Import in the currently stored wallet as a test because Import can overwrite that record.

  4. Transfer only the amount you choose to risk

    Use Devnet SOL only. The current login gate needs at least 0.1 SOL, but it imposes no upper limit and does not limit the key's authority. Low balance is risk reduction advice, not enforcement.

  5. Do not press Create or Import casually

    Either operation can replace the existing stored key without confirmation. Back up the current wallet and verify the intended address first.

In a teaching scene generated from the canonical villagers and a real Chunk.js hillside reference, the girl places one plain closed wooden box in a dry stone recess while a second closed box remains under the distant camp shelter and the boy stands between them.
The two separated closed boxes represent an original secret and a separate recovery copy. They are teaching metaphors, not instructions to duplicate a physical game item; this scene cannot verify that a backup is correct or safe, that no copy leaked, that SOL moved, or that any signature or chain action succeeded.

SIGNING AUTHORITY BOUNDARY

Fewer popups means the browser-held key can sign directly

When NiceChunk needs the local provider, it reads the stored Base58 secret, reconstructs the Keypair, and returns a provider marked connected. Its connect method simply returns the derived public key. Its transaction methods call partialSign with that Keypair for one transaction or every transaction in a supplied list. There is no external-wallet confirmation prompt in those methods, and disconnect is a no-op.

Those transaction methods do not inspect whether the bytes are a NiceChunk mining action. A caller that reaches the signer can ask the key to sign any compatible transaction supplied to it, including transfers, token operations, or owner-authorized instructions, subject to the normal Solana programs and account rules in that transaction. The current UI, 0.1 SOL minimum, PlayerSession action bits, and low-balance advice do not narrow what the owner secret itself can authorize.

The current chain adapter also recognizes this provider and can send keypair-signed transactions directly. For the local Game Wallet's player-session setup, the owner public key and session-authority public key are the same key. This avoids funding and storing a second temporary signer, but it also means the Game Wallet secret remains the direct signing authority. Calling the record a session does not turn that long-lived owner key into a server-held session or a narrower extension approval.

The PlayerSession PDA can expire or limit the supported player instructions that read it, but that expiry does not rotate, delete, or neutralize the long-lived owner secret. By contrast, the external-wallet path can use a different temporary session key. Local mode returns the Game Wallet Keypair itself and marks that it is not using a separate Gameplay Session signer.

This does not mean the key can make every attempted game action succeed. The provider implements a small transaction interface based on partialSign, while the chain adapter contains additional local-key branches. Prepared instructions still face RPC availability, fees, account ownership, PDA derivation, program checks, cooldowns, capacity rules, and confirmation failure. The local key supplies a signature; it does not prove the instruction was correct or accepted.

The stored record and provider derive identity differently after corruption. Login balance and binding use the separately stored address without verifying it against the secret. Provider construction derives the public key from the secret, and the chain provider selector later requires the bound session address to equal that derived public key. A mismatch can therefore let login inspect or bind one address but later make the local signer unavailable. It is an integrity failure, not a safe address migration feature.

What signs The reconstructed browser-held Keypair

The secret is decoded each time a provider or direct local Keypair is requested.

What the player sees No extension approval popup for these local methods

The convenience depends on trusting the same-origin application code that prepares transaction bytes and asks the Keypair to sign them.

What a signature proves That this key signed exact transaction bytes

It does not prove correct program behavior, sufficient balance, RPC acceptance, confirmed status, or finalized state.

Address integrity Not checked when the four-field record is read

A safe load would derive the public key from the secret and require it to equal the stored address before using either.

Signer scope Full authority of that Solana account key

The partial-sign method itself does not enforce a NiceChunk-only instruction list, a SOL cap, or a Devnet-only boundary.

Session expiry Does not expire the owner secret

An on-chain PlayerSession record can expire while the same browser-held owner Keypair remains able to sign owner-authorized transactions.

In a teaching scene generated from the canonical villagers and a real Chunk.js hillside reference, the girl raises one pickaxe before one intact stone block while the boy waits aside and one closed wooden box remains on the camp shelf behind her.
The girl acting with her own tool represents the browser-held owner key signing one game action without an extension popup; it does not imply that the key is limited to that action. The intact stone freezes the moment before any result, and the scene proves no valid signature, sufficient balance, accepted transaction, confirmed state, or finalized state.

LIFECYCLE BOUNDARY

Disconnect clears a session; deletion removes only this browser record

The login-page Disconnect button tries to disconnect detected extension providers and then clears the selected wallet address, wallet name, and wallet-bound timestamp. The Play logout path similarly treats provider disconnect as best effort, clears identity and selected runtime caches, and returns to login. The built-in local provider's own disconnect method returns without changing anything. None of these paths deletes the four nicechunk.localGameWallet fields.

The local wallet module does export clearLocalGameWallet, which removes the address, secret, timestamp, and source fields. Current repository search finds no reviewed runtime or product control that calls it, and neither wallet panel presents a Delete or Forget Game Wallet button. A player can disconnect and later see or reuse the same saved local record. Clearing browser site data can delete it, but that broad browser action may remove other local NiceChunk state too.

Deletion is not revocation, transfer, refund, or guaranteed secure erasure. Disconnecting, clearing site data, or deleting the local record does not move out SOL, tokens, or other assets; undo completed transactions; erase public history; close program accounts; remove browser or clipboard copies elsewhere; or necessarily remove a Keypair already held in live JavaScript memory. If the deleted record was the last valid secret, the on-chain balance and assets remain at the address but may become permanently inaccessible.

If the secret was exposed, anyone with a copy may still sign. From a trusted environment, create a fresh wallet and move remaining value and authority rather than relying on Disconnect or local deletion. Removing one browser copy cannot make another person's copy private again.

Recovery is deterministic key reconstruction, not an account reset. A correct complete 64-byte secret in accepted Base58, JSON-string, or strict JSON-array form recreates the corresponding Keypair, and an explicitly typed NiceChunk seed record can expand a deliberate 32-byte seed. Bare 32-byte values are rejected so a public address cannot silently become a different wallet seed. Always verify the recovered public address before treating the wallet as restored. If every valid copy is lost after site data is cleared or a silent overwrite, the reviewed client exposes no server-backed recovery path; players should not assume NiceChunk support can restore the secret.

Disconnect removes Selected connection and session fields

Exact cleanup differs between login and Play, but neither path invokes the local Game Wallet clear function.

Current wallet-only delete UI None

The clear function exists in source but has no reviewed runtime or product caller and no player-facing button.

Browser site-data clearing Can remove the only local secret copy

It can also remove unrelated local game state and cannot revoke a secret copied elsewhere.

Reviewed client recovery path No path exposed by the reviewed client

Recovery depends on valid secret material that reconstructs the same public address; do not assume support can restore a lost secret.

  1. To stop using the wallet in the current session

    Use Disconnect or logout. Expect the local binding to clear, but expect the built-in secret to remain available in this browser.

  2. To delete the browser copy

    First verify a safe backup and the intended address, then move or account for any remaining balance. The current product has no dedicated Delete control; clearing site data is broader than wallet-only deletion and is not guaranteed secure erasure.

  3. To recover the same wallet

    Import the trusted backup and verify that the derived public address exactly matches the address you recorded before sending or signing anything.

  4. To respond to exposure

    Assume copied secret material remains usable. Create a fresh wallet through a trusted path and move remaining assets or authorities; Disconnect cannot rotate the key.

  5. To avoid accidental replacement

    Do not use Create or Import as an experiment while a funded local wallet is stored. There is no current overwrite confirmation or automatic previous-key recovery.

In a teaching scene generated from the canonical villagers and a real Chunk.js hillside reference, the girl and boy walk away from an open timber camp while one plain closed wooden box remains on its exposed shelf and an intact stone block remains beside it.
Walking away while the box remains represents Disconnect leaving the browser-held secret in place; the unchanged stone represents shared history that local cleanup cannot erase. These are teaching metaphors, not product controls or proof of deletion, recovery, revocation, balance, signature, or chain state.

FOLLOW THE KEY

Eleven excerpts follow the secret from storage to signing and cleanup

The formulas separate one login-page gate from the key's much wider authority. The verbatim excerpts show creation, strict import routing, clearing, direct provider signing, created-versus-imported presentation, the backup and balance gate, the Play bypass, clipboard copying, session cleanup, owner-equals-session authority, confirmed submission, and the stored-address integrity check.

Current full-login Continue gate

continueLogin = has(record.address) AND (source = imported OR backupBox = checked) AND confirmedBalance(record.address) >= 100,000,000 lamports

This is browser-interface policy, not a Solana program rule. The checkbox does not prove a backup, imported records bypass it, and the expression does not verify that record.address matches the public key derived from the stored secret. The threshold is a minimum rather than a cap or reserved budget. The Play wallet panel applies a local session without evaluating this formula.

record.address
The separately stored Base58 address read from localStorage; this gate does not derive and compare it with the stored secret.
source
The mutable created-or-imported localStorage label used to choose the backup interface branch.
backupBox
The temporary created-wallet checkbox. Checked means only that the player selected it in the current page state.
confirmedBalance
One RPC snapshot at confirmed commitment for the separately stored address.
100,000,000 lamports
Exactly 0.1 SOL, used here as the current full-login minimum and not as an upper limit.

The login minimum is not the key's authority boundary

GameWalletSecret => full signing authority for Address on every Solana cluster; 0.1 SOL gate != spending cap != permission cap

The same secret derives the same address on Devnet and Mainnet. The local provider applies that Keypair to transaction bytes without checking for a NiceChunk-only instruction. Solana programs still validate the submitted transaction, but the browser gate, low-balance advice, session action bits, and current user interface do not reduce the owner key's cryptographic scope.

GameWalletSecret
The complete browser-held Keypair secret, stored as Base58 text and capable of producing owner signatures.
Address
The public key derived from the secret. Cluster choice changes which ledger is queried, not the derived address.
full signing authority
The signer itself does not restrict requests to NiceChunk actions; each transaction remains subject to its normal program and account checks.
0.1 SOL gate
A current full-login Devnet balance minimum, not escrow, an automatic transfer, a spending maximum, or an authorization rule.
!=
Means 'is not the same as.' Interface policy must not be mistaken for a cryptographic permission boundary.

Create, Import, and Clear share one local record

JavaScript src/localGameWallet.js
export function createLocalGameWallet() {
  const keypair = Keypair.generate();
  return storeLocalGameWalletKeypair(keypair, "created");
}

export function importLocalGameWallet(value) {
  const keypair = keypairFromSecretInput(value);
  return storeLocalGameWalletKeypair(keypair, "imported");
}

export function clearLocalGameWallet() {
  if (!hasLocalStorage()) return;
  localStorage.removeItem(localGameWalletKeys.address);
  localStorage.removeItem(localGameWalletKeys.secretKey);
  localStorage.removeItem(localGameWalletKeys.createdAt);
  localStorage.removeItem(localGameWalletKeys.source);
}

Create generates a Keypair and Import parses one, but both immediately call the same storage routine with only a source label changed. Neither checks whether a wallet already exists, so either can replace the record. Clear removes all four Game Wallet fields, but no reviewed login or Play control calls this exported function.

  1. 1-4

    Generate a fresh Solana Keypair and send it directly to the storage routine as created.

  2. 6-9

    Parse the supplied secret material and send the resulting Keypair to the same routine as imported.

  3. 11-17

    The available clear operation removes address, secret, timestamp, and source from browser storage; it does not revoke copies or chain state.

The local provider signs with the reconstructed Keypair

JavaScript src/localGameWallet.js
export 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;
    },
    async signAllTransactions(transactions) {
      for (const transaction of transactions) transaction.partialSign(keypair);
      return transactions;
    },
  };
}

Provider construction first loads the secret-derived Keypair. The returned object is always marked connected, connect just returns the public key, and disconnect changes no state. Transaction methods partial-sign directly with the Keypair without an extension prompt. This small interface does not promise compatibility with every Solana transaction or wallet operation.

  1. 1-3

    Reconstruct the Keypair from stored secret material and refuse to create a provider if loading fails.

  2. 4-10

    Expose a local-provider marker, a fixed connected flag, the derived public key, and a connect result without an external approval step.

  3. 11-13

    Return from disconnect without deleting storage, clearing a binding, or revoking the key.

  4. 14-21

    Apply this Keypair directly to one transaction or each transaction in a list and return the signed object or list.

  5. 22-23

    Finish the provider object and function; no message-signing, event, or delete method is added.

Import routing rejects ambiguous seeds before Keypair construction

JavaScript src/localGameWallet.js
function keypairFromSecretInput(value) {
  const input = String(value || "");
  if (input.length > localGameWalletImportMaxCharacters) {
    throw importError(
      localGameWalletImportErrorCodes.inputTooLarge,
      `Private-key input exceeds ${localGameWalletImportMaxCharacters} characters.`,
    );
  }
  const raw = input.trim();
  if (!raw) throw importError(localGameWalletImportErrorCodes.missing, "Missing private key.");

  const material = startsWithJsonToken(raw)
    ? parseJsonSecretMaterial(raw)
    : parseBase58SecretMaterial(raw);

  if (material.kind === "seed") return Keypair.fromSeed(material.bytes);
  try {
    return Keypair.fromSecretKey(material.bytes);
  } catch (cause) {
    throw importError(
      localGameWalletImportErrorCodes.invalidSecret,
      "The supplied 64-byte value is not a valid Solana secret key.",
      cause,
    );
  }
}

The entry point rejects oversized input before parsing, trims only outer whitespace, routes JSON-looking input to strict structured parsing, and routes other text to Base58 validation. Its helpers require byte integers, reject bare 32-byte values as ambiguous with public addresses, allow a seed only through an explicitly typed NiceChunk seed record, and map malformed input to stable local error codes. A complete 64-byte value still passes through Solana's internal secret/public consistency check before storage.

  1. 1-10

    Bound the raw character count, normalize only outer whitespace, and reject an empty value with a stable import error code.

  2. 12-14

    Route JSON arrays or one quoted Base58 value to structured parsing and plain text to Base58 parsing. Neither branch silently removes or substitutes unsupported characters; a different valid key must be caught by comparing the derived address with the backup address.

  3. 16-16

    Construct from a 32-byte seed only when the helper marked an explicitly typed seed record; bare 32-byte values never reach this line.

  4. 17-26

    Construct the complete 64-byte Keypair, preserve Solana's internal consistency validation, and convert a failure into a non-secret-bearing import error.

The full login vault treats created and imported records differently

JavaScript login/login.js
function showGameWalletVault(wallet, { source = wallet?.source || "created" } = {}) {
  gameWalletVaultSource = source === "imported" ? "imported" : "created";
  if (gameWalletAddress) gameWalletAddress.value = wallet.address || "";
  if (gameWalletPrivateKey) gameWalletPrivateKey.value = gameWalletVaultSource === "created" ? wallet.secretKey || "" : "";
  if (gameWalletBackupConfirmed) gameWalletBackupConfirmed.checked = false;
  if (gameWalletBackupFields) gameWalletBackupFields.hidden = gameWalletVaultSource === "imported";
  if (copyGameWalletPrivateKey) copyGameWalletPrivateKey.tabIndex = gameWalletVaultSource === "imported" ? -1 : 0;
  if (continueGameWalletButton) {
    const i18nKey = gameWalletVaultSource === "imported"
      ? "login.gameWallet.continueImportedAction"
      : "login.gameWallet.continueAction";
    continueGameWalletButton.dataset.i18n = i18nKey;
    continueGameWalletButton.textContent = t(i18nKey);
  }
  if (gameWalletBalanceLine) {
    gameWalletBalanceLine.classList.remove("warning");
    gameWalletBalanceLine.textContent = t("login.gameWallet.balanceReady", { address: formatAddress(wallet.address) });
  }
  gameWalletVault?.classList.remove("hidden");
}

The stored source label selects the interface branch. Created shows the returned secret and resets an unchecked confirmation box. Imported blanks the secret field, hides the backup controls, and changes the Continue label, even though the imported secret remains in localStorage. The balance line initially gives an instruction rather than proving a balance.

  1. 1-4

    Normalize every source other than imported to created, show the address, and show the secret only for the created branch.

  2. 5-7

    Reset the confirmation box, hide imported backup fields, and remove the imported private-key copy button from keyboard order.

  3. 8-14

    Choose different Continue text for imported and created interface states.

  4. 15-20

    Reset the balance warning, display an instruction containing the formatted address, and reveal the vault.

The full login gate checks a checkbox and a confirmed minimum

JavaScript login/login.js
async function continueWithGameWallet() {
  continueGameWalletButton.disabled = true;
  statusLine.textContent = t("login.gameWallet.statusCheckingBalance");
  try {
    const [{ getLocalGameWalletRecord }, { getNicechunkConnection }, { PublicKey }] = await Promise.all([
      import("../src/localGameWallet.js"),
      import("../src/chain/nicechunkChain.js"),
      import("@solana/web3.js"),
    ]);
    const record = getLocalGameWalletRecord();
    if (!record?.address) throw new Error("missing-game-wallet");
    const source = record.source === "imported" ? "imported" : gameWalletVaultSource;
    if (source !== "imported" && !gameWalletBackupConfirmed?.checked) {
      statusLine.textContent = t("login.gameWallet.statusBackupRequired");
      gameWalletBackupConfirmed?.focus();
      return;
    }
    const balanceLamports = await getNicechunkConnection().getBalance(new PublicKey(record.address), "confirmed");
    if (balanceLamports < requiredGameWalletLamports) {
      const balanceSol = formatSol(balanceLamports);
      if (gameWalletBalanceLine) {
        gameWalletBalanceLine.classList.add("warning");
        gameWalletBalanceLine.textContent = t("login.gameWallet.balanceRequired", { balance: balanceSol });
      }
      statusLine.textContent = t("login.gameWallet.statusFundingRequired");
      return;
    }
    walletAddress = record.address;
    persistConnectedWallet({ walletAddress, walletName: "NiceChunk Game Wallet" });
    statusLine.textContent = t("login.gameWallet.statusReady");
    await initialize();

The full login flow reads the record without its secret, trusts the stored address, skips backup confirmation for imported source, and queries that address at confirmed commitment. Only the integer balance comparison controls the 0.1 SOL gate. Passing stores a browser connection; it does not cap the wallet, reserve funds, verify an address-secret match, or prove a later chain action.

  1. 1-9

    Disable Continue, announce a balance check, and load the record, connection, and PublicKey dependencies.

  2. 10-17

    Require a stored address and require only created-source interface state to have the temporary checkbox selected. Imported source bypasses it.

  3. 18-26

    Read the separately stored address balance at confirmed commitment and stop below the current lamport threshold.

  4. 27-31

    Persist the stored address as the selected Game Wallet, report ready, and re-enter login initialization.

The Play panel applies a local session without the full login gates

JavaScript play/play-chain-session.js
  async function createLocalWalletLogin() {
    try {
      const module = await loadLocalWalletModule();
      if (typeof module.createLocalGameWallet !== "function") throw new Error("Local wallet module does not expose createLocalGameWallet.");
      const wallet = module.createLocalGameWallet();
      applyWalletSession(wallet.address, "NiceChunk Game Wallet");
      renderWalletPanel(`Created game wallet ${shortAddress(wallet.address)}. Save the private key before funding it.`, wallet);
      setStatus(`Created NiceChunk game wallet ${shortAddress(wallet.address)}. Fund it before real chain mining.`);
      appendChainEvent(`Created local game wallet ${shortAddress(wallet.address)}. Its private key is stored as unencrypted Base58 text for this browser origin.`);
      return wallet;
    } catch (error) {
      setStatus(`Create game wallet failed: ${readableError(error)}.`);
      appendChainEvent(`Create game wallet failed: ${readableError(error)}.`);
      renderWalletPanel(`Create game wallet failed: ${readableError(error)}.`);
      return null;
    }
  }

  async function continueLocalWalletLogin() {
    try {
      const module = await loadLocalWalletModule();
      const wallet = module.getLocalGameWalletRecord?.({ includeSecret: true });
      if (!wallet?.address) {
        renderWalletPanel("No local game wallet exists in this browser. Create one first.");
        setStatus("No local game wallet exists in this browser.");
        return null;
      }
      applyWalletSession(wallet.address, "NiceChunk Game Wallet");
      renderWalletPanel(`Using game wallet ${shortAddress(wallet.address)}.`, wallet);
      setStatus(`Using NiceChunk game wallet ${shortAddress(wallet.address)}.`);
      appendChainEvent(`Using local game wallet ${shortAddress(wallet.address)} for chain session signing.`);
      return wallet;
    } catch (error) {
      setStatus(`Continue game wallet failed: ${readableError(error)}.`);
      appendChainEvent(`Continue game wallet failed: ${readableError(error)}.`);
      renderWalletPanel(`Continue game wallet failed: ${readableError(error)}.`);
      return null;
    }
  }

Play creation immediately applies a wallet session after storage and only displays advice to save and fund. Play continuation requests the record including its secret and applies the session when an address exists. Neither function checks the full-login backup box, imported source branch, address-secret relationship, or 0.1 SOL minimum. On-chain and RPC checks can still reject later actions.

  1. 1-10

    Load the local module, create and store a wallet, immediately apply the session, and display backup and funding advice without enforcing either.

  2. 11-17

    Report creation failures and return null; no previous-wallet restoration is attempted.

  3. 19-27

    Load an existing record including its secret and require only a nonempty address before proceeding.

  4. 28-32

    Apply the stored address as the selected session, reveal the local record to the wallet panel, and announce signing use.

  5. 33-39

    Report continuation failures and return null; there is no backup or balance branch in this function.

Session cleanup removes binding fields, not the Game Wallet record

JavaScript src/walletSession.js
export function clearWalletConnection() {
  localStorage.removeItem(walletSessionKeys.walletAddress);
  localStorage.removeItem(walletSessionKeys.walletName);
  localStorage.removeItem(walletSessionKeys.walletBoundAt);
}

export function clearWalletSession() {
  clearWalletConnection();
  localStorage.removeItem(walletSessionKeys.username);
  clearWalletRuntimeCaches();
}

The selected connection has its own address, name, bound time, username, and wallet-scoped runtime caches. Neither cleanup function refers to nicechunk.localGameWallet.address or nicechunk.localGameWallet.secretKey. Disconnect can therefore end the selected session while the separately stored owner secret remains available to the same browser origin.

  1. 1-5

    Remove the selected wallet address, wallet label, and binding timestamp, then finish connection cleanup.

  2. 7-11

    Full session cleanup also removes the username and wallet-scoped runtime caches, but still does not call clearLocalGameWallet.

Copy writes the secret to the operating-system clipboard

JavaScript login/login.js
async function copyGameWalletField(value, kind) {
  if (!value) return;
  try {
    await navigator.clipboard.writeText(value);
    const message = kind === "privateKey"
      ? t("login.gameWallet.statusPrivateKeyCopied")
      : t("login.gameWallet.statusAddressCopied");
    statusLine.textContent = message;
    showLoginToast(message, "success");
  } catch {
    const message = t("login.gameWallet.statusCopyFailed");
    statusLine.textContent = message;
    showLoginToast(message, "error");
  }
}

The button asks the browser Clipboard API to write the supplied value, reports whether that call failed, and mirrors the same result into the login toast. It does not read the value back, derive an address from a copied secret, clear clipboard history, control clipboard synchronization, or prove that the player created a durable offline backup.

  1. 1-2

    Ignore an empty field rather than writing an empty clipboard value.

  2. 3-9

    Write the exact value to the clipboard, choose address-or-private-key success text, update the status line, and show a success toast.

  3. 10-14

    Display the copy failure in both the status line and the toast when the Clipboard API rejects; no verification or later clipboard cleanup follows.

Local mode makes the owner its own session authority

JavaScript src/chain/nicechunkChain.js
  const tx = new Transaction();
  if (!profileAccount?.data?.length) {
    tx.add(createInitializePlayerInstruction(owner, playerProfile, ""));
  }
  tx.add(createOrRefreshPlayerSessionInstruction({
    owner,
    sessionAuthority: owner,
    playerProfile,
    playerSession,
    expiresAt,
  }));

  await signAndSendKeypairTransaction(keypair, tx, conn);
  const balanceLamports = await conn.getBalance(owner, "confirmed").catch(() => null);
  markGameplaySessionReady(owner, owner, balanceLamports);
  updateGameplaySessionStatusCache(owner, createLocalGameWalletStatus(owner, balanceLamports, expiresAt));
  return { keypair, expiresAt, localGameWallet: true };

The surrounding function has already set owner from the local provider and loaded the same Game Wallet Keypair. This excerpt optionally initializes a missing profile, writes a PlayerSession whose sessionAuthority is the owner itself, signs with that Keypair, and caches readiness under owner, owner. The PDA can limit supported player instructions, but it does not turn the long-lived owner secret into an independent temporary key.

  1. 1-4

    Prepare one transaction and add player-profile initialization only when that public account does not exist.

  2. 5-11

    Create or refresh the PlayerSession with the same public key in both owner and sessionAuthority roles.

  3. 13-17

    Sign with the local Game Wallet Keypair, read the owner's confirmed balance, cache owner-as-authority readiness, and return the same Keypair.

The Keypair pays, signs, sends, and waits for confirmed

JavaScript src/chain/nicechunkChain.js
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 local Keypair becomes the fee payer, receives a recent confirmed blockhash, signs the prepared transaction, and sends its serialized bytes. The client then polls that signature until its confirmed condition or an error. There is no external-wallet popup in this function, and confirmed must not be renamed finalized.

  1. 1-5

    Use the signer address as fee payer and attach a recent blockhash plus its last valid block height.

  2. 6-7

    Apply the owner Keypair signature and send the serialized signed transaction bytes.

  3. 8-10

    Poll the returned signature at confirmed commitment, then return it; this excerpt does not wait for finalized.

The local provider is selected only when the bound address matches

JavaScript src/chain/nicechunkChain.js
async function connectedWalletProvider({ prompt = false } = {}) {
  const storedWallet = localStorage.getItem(storageWalletKey);
  const localGameWallet = getLocalGameWalletProvider();
  if (localGameWallet?.publicKey && storedWallet === localGameWallet.publicKey.toBase58()) {
    return localGameWallet;
  }

The selected wallet address is read separately from the Game Wallet record. Provider construction derives its public key from the stored secret. Only an exact match chooses the local provider. Because the earlier record reader and login balance gate do not perform that comparison, a damaged address field can appear usable during login and then fail this later signer selection.

  1. 1-3

    Read the active browser binding and independently reconstruct a provider from the stored Game Wallet secret.

  2. 4-6

    Return the local signer only when its secret-derived public key exactly equals the active bound address.

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/localGameWallet.js

Defines the four localStorage keys, generated and imported Keypair paths, strict Base58 and JSON validation, ambiguous 32-byte rejection, explicitly typed seed handling, direct signing provider, no-op disconnect, and an exported clear function with no reviewed runtime or product caller.

login/login.js

Shows created secrets, hides imported secrets, resets the backup checkbox, enforces the full-login 100,000,000-lamport confirmed minimum, binds the stored address, and disconnects without clearing the Game Wallet record.

login/index.html

Provides Create and Import controls, the visible backup checkbox and copy fields, low-balance warnings, and Disconnect controls, but no Delete or Forget Game Wallet control.

public/login/locales/en.json

States the player-facing backup, low-balance, imported-wallet, 0.1 SOL, copy, and ready messages used by the full login page.

src/walletSession.js

Separates selected wallet binding keys from the local Game Wallet keys and clears address, name, bound time, username, and selected runtime-cache prefixes without deleting the built-in secret.

src/chain/nicechunkChain.js

Selects the local provider only when its secret-derived public key matches the stored session address, sends local Keypair-signed transactions, and uses the same local key as owner and player-session authority.

play/play-chain-session.js

Exposes separate in-game Create and Use local wallet paths that apply a session without the full-login backup checkbox or balance minimum and shows the returned secret in the Play wallet panel.

play/index.html

Provides Create, Use local wallet, Disconnect, address, and private-key copy controls in Play, with no wallet-only Delete control.

scripts/audit-wallet-flows.mjs

Checks a built local Game Wallet creation flow, the temporary backup checkbox, 99,999,999-versus-100,000,000-lamport boundary, session cleanup, retained four-field record, and redisplayed created secret with deterministic mocked RPC responses; it does not test import, overwrite, mismatch, live funding, transaction signing, or deletion.

scripts/audit-login-game-wallet-import.mjs

Checks the built login page with deterministic throwaway material: ambiguous-address and invalid-character rejection, successful JSON-quoted Base58 import, no secret rendering or request leakage, no expected console warning, and 390-pixel overflow.

docs/wallet-flow-audit.md

Documents that the browser audit creates only an ephemeral isolated test key, never records its secret, uses mocked RPC data, and does not prove live balances, safe backup, transaction signing, submission, confirmation, recovery, or storage isolation.

play/tests/play-auth-session.test.mjs

Tests selected identity and runtime-cache cleanup while preserving unrelated storage, but does not directly exercise the local Game Wallet module or its four keys.

play/tests/local-game-wallet.test.mjs

Directly checks generated and imported key material, Base58 normalization, quoted and strict-array forms, explicitly typed seeds, ambiguous public-address rejection, strict byte validation, invalid-input storage preservation, default secret omission, signing, disconnect retention, and exact removal of the four wallet fields.

play/tests/session-funding-boundary-copy.test.mjs

Prevents the Play funding target from being described as escrow, a transaction spending boundary, or an authorization cap, and requires local-wallet copy to name the unencrypted origin-storage boundary.

package.json

Defines the focused local Game Wallet test command and includes it plus the built browser wallet-flow audit in release validation.