Almost every read starts by working out who you are asking about. That chain has three links, and one of them is harder than it looks.
Wallet ──▶ Profile ──▶ CharacterA wallet is a Solana account someone controls. A profileA player's on-chain identity across Star Atlas. Created by the player, so it cannot be derived from a wallet address. is their on-chain identity across Star Atlas. A characterA profile's presence inside SAGE. Owns fleets and accrues progress; one per profile. is that profile’s presence inside SAGEThe part of Star Atlas where fleets fly, mine, craft, and trade. specifically — the thing that owns fleets and accrues progress.
Getting a character
Section titled “Getting a character”If you already have a profile address, one call gets you the character:
const character = await sage.characters.forProfile(profileAddress);That reads an existing Character; it does not register one. Use the separate
planRegisterCharacter from @aephia/atlas-kit/identity/actions to prepare
explicit registration with a funder. Current Game-2 registration derives and
creates the canonical Character and Career XP budget in the same instruction;
the Game-owned XP configuration must already exist. The explicit on-curve
funder is the sole instruction signer; Profile authority is not inferred from it.
Registration creates Character v0 and reconciled Career XP budget v1 (marker 1).
The qualified initializer allocates 415 Character bytes and 280 budget bytes,
then unlocks only research node 0 at level 1. Final Character storage is
423 + 2*T + 2*C + 10*M bytes, where T/C/M count that node’s modifier research
tags, cargo categories and rare-mineral entries. The planner derives collection
widths using the pinned codecs and rejects a missing starting node.
Absent targets and empty, non-executable System-owned pre-funded targets work;
already initialized Characters or orphaned budgets are rejected, never repaired.
For current rent minima Rc/Rb and target balances c/b, upfront storage is
max(Rc-c,0) + max(Rb-b,0); net storage debit is (Rc-c) + max(Rb-b,0).
Character excess is refunded after budget funding; budget excess remains.
A future refund cannot pay an earlier allocation. The Plan describes both
creation targets, their funding source, the debit and any refund explicitly.
JSON retains finite initialization, identity, pre-funding and storage-ceiling intent. Execution rechecks fresh Game 2, node initialization, Profile/XP identity, target state, current Rent and funding before any signer. Meaningful changes require a fresh Plan; unrelated Game definitions and mutable XP configuration economics are not equality-frozen. Saved registrations missing this intent must be recreated, not migrated.
Transaction fees are separate at planning. Execution queries getFeeForMessage
through SageWriteRpc for the assembled transaction, including any priority-fee
policy. Shared funders pay that applicable fee once and must cover ordered
upfront debits before refunds; final empty-System balances must be zero or
rent-exempt. Distinct registrations may compose, duplicate targets may not.
Unqualified instructions referencing a registration’s funder or creation targets
must execute separately before replanning.
The #621 qualification distinguishes authentic unsigned PTR observations from synthetic local controls. Fresh checks are not atomic on-chain assertions: state, rent and fees can race execution (including a wallet refreshing the blockhash). Simulate before signing and read back both confirmed accounts; submission alone is not proof of creation. No top-up, XP-config creation, legacy fallback, live signing or submission is implicit in planning.
Most guides start from an already registered Profile. For a new player, see Preparing wallet imports for the distinct Character, faction, and local Starbase prerequisites.
The part that surprises people
Section titled “The part that surprises people”You cannot calculate a profile address from a wallet address.
Many Solana accounts have a derivable addressAn address derived deterministically from known inputs, so it can be computed rather than searched for. computed from known inputs, so you can work them out offline. Profiles do not: a profile is created by its owner, and its address is assigned at creation. Nothing about the wallet predicts it.
So going wallet → profile is a search, not a calculation, and the SDK makes you choose how to search rather than guessing:
const sage = createSageClient({ cluster: 'zink-ptr', rpc, discovery: { walletProfiles },});
const wallet = sage.wallets.get(walletAddress);const profiles = await wallet.profiles.all({ strategy: 'provider' });walletProfiles is a provider you supply — a lookup table you maintain, an
indexer, or anything else that can answer “which profiles belong to this
wallet”. Without one, the provider strategy has no way to answer and says so.
If you already know the addresses, skip the provider entirely and pass them:
const profiles = await wallet.profiles.all({ strategy: 'known-addresses', addresses: [profileAddress],});Creating a Profile
Section titled “Creating a Profile”For a new identity, use planCreateProfile from
@aephia/atlas-kit/identity/actions. Supply a fresh signer-created Profile
address, an explicit funder, initial scoped keys, and a key threshold. The SDK
checks that the target is absent; it never derives it from the wallet or creates
it during a read.
The threshold controls protected Profile updates, not every gameplay action. Profile AUTH keys must be nonexpiring and all sign creation, even when the threshold is lower than their count. The new Profile signer authorizes creation; its address gains ongoing authority only through a stored Profile key entry. Keep that signer available if the same address is also an AUTH key. Key permissions belong to their program scope; the same numeric bit in another scope is not Profile AUTH. See planning Profile creation for a minimal explicit example, signer roles, and confirmation checks.
Inspecting permissions and keys
Section titled “Inspecting permissions and keys”Permission masks stay exact bigint values. Scope is explicit when composing
names or interpreting meaning; raw mask union and containment need no scope.
composeProfilePermissions(scope, names) creates a mask from known names;
unionProfilePermissionMasks(masks) combines raw masks without losing
unrecognized bits. inspectProfilePermissions(scope, raw) explains recognized
names and retains unrecognizedBitsRaw. These helpers live in identity and
return immutable values. Inspection objects provide toJSON() with bigint values
as decimal strings; composed masks themselves remain raw bigint values.
The Profile permission vocabulary uses PROFILE_PERMISSION_SCOPE; the 43 pinned
C4 SAGE names use SAGE_PERMISSION_SCOPE. In SAGE scope, bit zero is
manageGame, never Profile authenticate. Only drainSolVault (bit 63) is
recognized in arbitrary other scopes. No helper adds default or all-permissions authority.
containsProfilePermissions(availableRaw, requiredRaw) is numerical bit
containment. A true result does not establish what unknown bits mean or prove
authorization. inspectProfileKeys checks direct entries against an explicit
signer, scope, mask, and evaluation time. It returns all entries with their
original indices and diagnostic reasons, so your application can explain each
candidate before the caller chooses one.
This illustrative example examines the direct Profile AUTH path accepted by initial faction enrollment. Supply a loaded Profile, a caller-selected key index, an explicit evaluation timestamp in Unix seconds, and the declared application context and addresses. Inspecting keys does not select an index or sign a transaction.
import { composeProfilePermissions, inspectProfileKeys, PROFILE_PERMISSION_SCOPE, type ProfileSnapshot,} from '@aephia/atlas-kit/identity';import { planChooseFaction } from '@aephia/atlas-kit/factions/actions';import type { Address, SageContext } from '@aephia/atlas-kit/client';
declare const ctx: SageContext;declare const profile: ProfileSnapshot;declare const authority: Address;declare const at: bigint;declare const chosenKeyIndex: number;
const candidates = inspectProfileKeys(profile, { signer: authority, scope: PROFILE_PERMISSION_SCOPE, permissionsRaw: composeProfilePermissions(PROFILE_PERMISSION_SCOPE, [ 'authenticate', ]), at,});console.log(candidates);const chosen = candidates.find(({ keyIndex }) => keyIndex === chosenKeyIndex);if (chosen?.status === 'match') { const plan = await planChooseFaction(ctx, { faction: 'ustur', authorization: { profile: profile.address, authority, keyIndex: chosen.keyIndex, }, }); console.log(plan.describe());}For at, prefer the bank Clock sysvar’s unixTimestamp obtained through your
application’s RPC/data layer. A local value such as
BigInt(Math.floor(Date.now() / 1000)) is approximate and can disagree with chain
time near expiry. The helper does not fetch Clock; action planners still check
fresh chain time. Load the Profile with getProfile; malformed snapshot input
produces a typed error directing you to that read.
Inspection performs no network reads, refresh, or wall-clock lookup. Negative raw
expiry values mean permanent. A finite expiry is valid at exactly at and
expired when it is less than at. A candidate
is a mismatch for address, scope, expiry, or missing permission bits, with
those reasons retained in diagnostics. If required bits have unrecognized
meanings and the candidate otherwise fits, its status is indeterminate, even
when numerical containment succeeds. Extra available unknown bits remain
visible. Profile AUTH is reported separately and does not override an unrelated
scope requirement. Role chains and certificates require their own validation;
these helpers do not evaluate delegated authorization.
The example matches an explicit Profile AUTH requirement. The faction planner independently applies its own verified AUTH policy; the helper does not apply a universal AUTH override. The selected snapshot match is advisory. The planner still reads fresh state and checks its action-specific requirements and funding; inspect and simulate its Plan before explicit execution. Protected Profile-update thresholds do not become gameplay-wide multisig rules.
Inspecting SAGE gameplay permission names
Section titled “Inspecting SAGE gameplay permission names”Use SAGE_PERMISSION_SCOPE for named C4 gameplay flags such as moveFleet
(bit 10) and doMining (bit 20). Profile snapshots translate those known scoped
bits into the same ProfilePermission names used by composition and inspection.
This illustrative example inspects an explicit requirement on a loaded Profile;
it displays every candidate and selects none.
import { composeProfilePermissions, inspectProfileKeys, inspectProfilePermissions, SAGE_PERMISSION_SCOPE, type ProfileSnapshot,} from '@aephia/atlas-kit/identity';import type { Address } from '@aephia/atlas-kit/client';
declare const profile: ProfileSnapshot;declare const authority: Address;declare const at: bigint;
const permissionsRaw = composeProfilePermissions(SAGE_PERMISSION_SCOPE, [ 'moveFleet', 'doMining',]);console.log(inspectProfilePermissions(SAGE_PERMISSION_SCOPE, permissionsRaw));const candidates = inspectProfileKeys(profile, { signer: authority, scope: SAGE_PERMISSION_SCOPE, permissionsRaw, at,});console.log(candidates);A direct key can now match these recognized SAGE requirements when its
address, scope, expiry, and bits fit. This does not establish the complete
permission requirements for moving or mining, or prove that either action will
succeed. Permission names describe the pinned binding’s bit meanings, not a
recipe mapping actions to masks. The scoped vocabulary audit
records each name, bit, and pinned source. The administrative names likewise do not
introduce SDK administration APIs or new account types.
A dated unsigned PTR experiment
found that moveFleet alone for C4 startSubwarp, and doMining alone for
startMiningAsteroid, passed the initial permission check but failed a later
Profile vault call. Adding drainSolVault allowed those measured calls, which
transferred native funds from the Profile vault into the Fleet account. This is evidence for
those fixtures, not a universal minimum mask. Vault authority is a separate
power: do not add it automatically or treat a snapshot match as proof that an
action will succeed.
A bot key with drainSolVault is a vault-withdrawal key, not merely a movement
or mining key. The pinned Profile instruction takes a caller-selected recipient;
the permission is not limited to funding that bot’s Fleet. Treat the Profile’s
available vault balance as exposed if that key is compromised, and avoid
keeping unrelated funds there. Never add bit 63 automatically to make a failed
gameplay simulation pass.
A prefunding follow-up
confirmed that these two action-only keys succeed when an explicit simulated
transfer covers the Fleet’s allocation shortfall first: 612,480 lamports for
subwarp and 292,320 for mining in that fixture. Neither target then called the
Profile vault. Those amounts are measurements, not SDK defaults or reusable
rent quotes; another Fleet or later allocation can need different funding.
The same experiment confirmed a direct one-lamport vault withdrawal with a
SAGE-scoped drainSolVault key to a recipient other than the Fleet. Prefer
explicit funding with narrow action authority where simulation verifies it,
without assuming that prefunding removes every future funding requirement.
Bits 43 through 62 remain unrecognized in SAGE scope. A requirement using those
bits is still indeterminate when otherwise consistent, while raw inspection
retains their values. No Profile AUTH override, role-chain resolution,
certificate validation, or automatic key selection is added. An existing
planner retains its own fresh checks; inspect and simulate its Plan before
explicit execution.
What a character gives you
Section titled “What a character gives you”The character is the hub. Almost every gameplay read hangs off it:
const fleets = await character.fleets.all();const stakes = await character.claimStakes.all();const bases = await character.starbases.all();Each of those is a separate network read, which is why each is a method rather than a property. See how Atlas Kit reads the game for why that distinction is consistent across the whole API.
Gotchas
Section titled “Gotchas”A profile with no character is possible. Someone can hold a Star Atlas
profile without having entered SAGE. forProfile will tell you rather than
inventing an empty character.
A wallet can hold more than one profile. wallet.profiles.all() returns an
array for that reason. Do not assume the first is the one you want.
Progression is already on the Character. Its XP, stored levels, spent research points, Renown and acquired nodes need no Game read. Use the optional progression read to join sequence-matched XP definitions. Council Rank shows the point arithmetic and read paths.