@kolny/sdk
v0.1.2
Published
TypeScript SDK for the KOLNY colony program on Solana: PDA derivation, instruction builders and account decoding for registering and operating a forager agent.
Maintainers
Readme
@kolny/sdk
TypeScript access to the KOLNY colony program on Solana: address derivation, instruction construction and account decoding, for a developer registering their own agent as a forager.
What KOLNY is
KOLNY is an autonomous agent fund on Solana. Capital is deposited once into a central vault, then spread across many independent agents called foragers, each trading from its own isolated sub-account. What a forager actually realized -- closed and settled trades, not a backtest and not a mark on an open position -- becomes its pheromone score, and pheromone decides how much capital flows down that trail in the next epoch. Pheromone evaporates every epoch, so a trail that stops earning fades and the capital behind it returns to the vault. No human picks the winning strategy.
That is a description of the mechanism, not a claim about returns. Autonomous agent trading loses money, and the protocol is built around containing that rather than denying it.
- Site: kolny.fi
- Protocol specification, program source and IDL: github.com/kolny-labs/kolny
- Read API reference: api.kolny.fi/docs
Install
npm i @kolny/sdk @solana/web3.jsNode 20 or newer. ESM and CommonJS are both published, with TypeScript
declarations for each; see Package format. @solana/web3.js
is already a direct dependency of the SDK, so it resolves either way under npm's
flat layout, but every example below imports Connection from it directly, so
name it explicitly.
That is the whole install. The program interface ships inside the package as
@kolny/sdk/idl, so there is nothing to download and no way to end up running
one release of the SDK against a different release of the interface. Where that
copy comes from, and the two other ways to supply an IDL, are in
Where the IDL comes from.
Before anything else
Nothing here sends a transaction. Builders return TransactionInstruction.
Signing, simulating and broadcasting stay with the caller, because the wallet
does.
The program is live on devnet only.
7whkmFfDcTyoJgf7jFGFmKNFMQn8NoreHnh2wZ9nWbsk is deployed and executable there
and all seven colony accounts exist, so the reads below return real state. It is
an unaudited test deployment, not production. There is no mainnet deployment and
no audit. On mainnet-beta the same address holds nothing, and a read there
raises ProgramNotFoundOnChainError instead of reporting an empty colony.
Quickstart
After the one command above, this file runs as written. The examples are ESM
and use top-level await, so the file needs to be .mjs, or the project needs
"type": "module"; from CommonJS, require("@kolny/sdk") gives the same
exports and the awaits go inside an async function.
import { createKolnyClient } from "@kolny/sdk";
import { colonyIdl } from "@kolny/sdk/idl";
import { Connection } from "@solana/web3.js";
const client = createKolnyClient({
idl: colonyIdl,
connection: new Connection("https://api.devnet.solana.com", "confirmed"),
});
client.programIdCheck;
// offline: only says the address is not the placeholder
// { usable: true,
// programId: "7whkmFfDcTyoJgf7jFGFmKNFMQn8NoreHnh2wZ9nWbsk",
// reason: "program id is a real address, which has not been checked on
// chain" }
await client.fetchProgramStatus();
// on chain: getAccountInfo plus the executable flag
// { deployed: true, exists: true, executable: true,
// programId: "7whkmFfDcTyoJgf7jFGFmKNFMQn8NoreHnh2wZ9nWbsk",
// cluster: "https://api.devnet.solana.com",
// reason: "the program account exists and is executable" }colonyIdl is the interface this release was compiled against: the Anchor build
output as it stood when the tarball was made, embedded as a module rather than a
file, so it loads identically under ESM, CommonJS and a browser bundler and
needs neither a network call nor a filesystem. Reading an IDL off disk is still
available through @kolny/sdk/idl-node, and is what a clone of the protocol
repository wants, but from an installed package its no-argument form finds no
Anchor build output above it and throws IdlNotBuiltError. The three ways in
are laid out in Where the IDL comes from.
The same code with a mainnet-beta Connection, where the program is not
deployed. Only the second result moves:
client.programIdCheck;
// { usable: true,
// programId: "7whkmFfDcTyoJgf7jFGFmKNFMQn8NoreHnh2wZ9nWbsk",
// reason: "program id is a real address, which has not been checked on
// chain" }
// identical to devnet, because it never looked at a chain
await client.fetchProgramStatus();
// { deployed: false, exists: false, executable: false,
// programId: "7whkmFfDcTyoJgf7jFGFmKNFMQn8NoreHnh2wZ9nWbsk",
// cluster: "https://api.mainnet-beta.solana.com",
// reason: "no account exists at the program id on this cluster, so the
// program is not deployed here" }Deployment is a fact about the chain
Two checks, and only one of them may use the word "deployed".
programIdCheck is offline. It says the address is not the known placeholder,
which is a property of a string, and its own reason says it has not been
checked on chain. fetchProgramStatus() is the on-chain lookup, and it requires
both that the account exists and that it is executable, because an address can
hold an ordinary account.
The two blocks above are the whole argument, and they are real output rather
than an illustration. One program id, one build of this SDK, two endpoints:
programIdCheck is byte-for-byte the same on both, and deployed is true on
one and false on the other. A check that cannot tell those two clusters apart
has no business using the word.
The distinction is not academic. An earlier version called the offline check
deployment.deployed, and on a cluster the program had never reached, a caller
was told the program existed and the missing colony accounts were an
initialization problem. That sent the reader to run an initialization that could
not work, and ruled out the real cause, which was a cluster mix-up. Every read
helper now verifies the program on chain first and throws
ProgramNotFoundOnChainError if it is not there.
ProgramNotDeployedError is the offline half of the same guard: if an IDL still
carrying Anchor's example key is loaded, or that key is passed as programId,
every operation implying a live program refuses before a round trip rather than
computing addresses that match nothing.
Mainnet is not deployed, and deploying it changes every address
There is no mainnet program. Devnet is a test deployment: unaudited, holding test tokens, and reset-able without notice. Nothing on it should be treated as production, and no figure read from it describes a real fund.
A mainnet deployment mints a new program keypair, so the mainnet program id will not be this one. Every PDA in the table below derives from the program id, so every colony address changes with it. That failure is the quiet kind: a devnet address pasted into mainnet code is still a valid address, it just names an account that will never exist.
So derive, never hard-code. createKolnyClient takes the id from the IDL and
colonyAddresses(client.programId) derives the rest, which makes a rebuilt IDL
the only thing that has to change. An address literal copied out of a devnet
session is the one thing in this SDK that a mainnet deployment silently breaks.
Addresses
The seed table is transcribed from packages/anchor-program/README.md. The
program, the indexer, the front end and this SDK must derive identically or
every address silently diverges: accounts read back as missing, and an
initialization would create a second, unreachable set.
import { colonyAddresses, foragerStatePda, foragerVaultPda } from "@kolny/sdk";
import { PublicKey } from "@solana/web3.js";
const { config, brood, cache, trailBoard, vaultBase } = colonyAddresses(client.programId);
config.address.toBase58(); // "64abUz1zEsF8USkGQcpHxJRjWAxJH4jUaKmM5uKun173"
config.bump; // 247
const operator = new PublicKey("11111111111111111111111111111112");
const record = foragerStatePda(client.programId, operator, 42n);
const subAccount = foragerVaultPda(client.programId, record.address); // from the recordEvery derivation returns { address, bump }, never a bare PublicKey.
Calling .toBase58() on the returned object is a TypeError; the field is
.address. The bump comes back with it because an instruction that re-derives
the same PDA on chain needs it, and recomputing it at the call site is how the
two sides drift apart.
| PDA | Seeds |
|---|---|
| colonyConfigPda | [b"colony"] |
| broodVaultStatePda | [b"brood"] |
| riskCacheStatePda | [b"cache"] |
| trailBoardPda | [b"trail_board"] |
| vaultBasePda | [b"brood_vault"] |
| cacheVaultPda | [b"cache_vault"] |
| incineratorVaultPda | [b"incinerator"] |
| foragerStatePda | [b"forager", operator, forager_id_le] |
| foragerVaultPda | [b"forager_vault", forager_state] |
| depositorPositionPda | [b"position", depositor] |
| redemptionRequestPda | [b"redeem", depositor, request_id_le] |
colonyAddresses returns the seven singletons at once. Against the devnet
program id, that is:
config 64abUz1zEsF8USkGQcpHxJRjWAxJH4jUaKmM5uKun173 bump 247
brood HV2A82xp6SyuFqaCSVyC4zFhW8qQTAr5pSz468WrTkoV bump 253
cache 44Ci19oaaJtfJ3UyoBjpGhyjTykN6iiUyWUqHeo1PEQB bump 254
trailBoard AAyhHjRrRc47vKNRpRt3voUwwGyQK3mT153qvjamYTXr bump 250
vaultBase 4BWECNHbvu6Rr8w9xFdjttqomzWxinhGAGWzZYaSSwdi bump 255
cacheVault 5M1chg5bn7EAta2RMakEiTmyXoCvVrgDzotrjLTsLBQb bump 255
incineratorVault 4yGZkXUHTRw8BH3yKAL1o4B7G6fyVrHvsTCg5PUT5Zw4 bump 255Those are the devnet addresses and they are printed here as output, not as constants to copy. A mainnet deployment changes every line of that block, which is the whole point of the section above.
Numeric seeds are eight bytes, little-endian. u64Seed is the one place
that is decided; it throws on a non-integer or out-of-range value rather than
truncating, because a truncated seed produces a perfectly valid address that
matches nothing. test/pda.test.ts transcribes the table a second time by hand
and derives with findProgramAddressSync directly, so the evidence is two
independent transcriptions agreeing rather than a helper checked against itself.
It also asserts that a big-endian id, and the one-byte id the project guide
records as a past mistake, both produce different addresses.
Where the IDL comes from
Three ways in. Which one is right depends on where the code is running, and only
the first needs nothing but npm i.
Installed from npm:
@kolny/sdk/idlexports the interface the release was built against. This is the form used everywhere above.import { colonyIdl } from "@kolny/sdk/idl";const { colonyIdl } = require("@kolny/sdk/idl");A named export in both formats, and no default export, so the CommonJS line above cannot silently hand you a module namespace where an IDL was meant.
Inside a clone of the protocol repository:
loadIdlFromDisk()with no argument readspackages/anchor-program/target/idl/kolny_colony.json, found by walking up from the working directory, so your ownanchor buildis picked up with no second step -- which is what you want while changing the program, and what the bundled copy cannot give you. There is no such build output above an installed package, so fromnode_modulesthe walk finds nothing and the call throwsIdlNotBuiltError.defaultIdlPath()returns the path it found, orundefined, if you want to branch on which situation you are in.Some other IDL: pass it.
createKolnyClient({ idl })takes any object andloadIdlFromDisk(path)reads one from a path you name, which covers a fork, a pinned older interface, or the copy trackingmainin the repository:curl -sLO https://raw.githubusercontent.com/kolny-labs/kolny/main/idl/kolny_colony.jsonconst idl = loadIdlFromDisk("./kolny_colony.json");Note what that download is: the head of
main, which is not necessarily the release you installed. Reach for it when the difference is the point.Browser: import the main entry and give it either
@kolny/sdk/idlor an IDL you fetched yourself. Neither touches the filesystem.@kolny/sdk/idl-nodeis the only entry that usesfs, and it is a separate one for exactly that reason.
assertColonyIdl checks the program name, address and shape on every path
including the bundled one, so the wrong interface is an error naming the
mismatch rather than garbage downstream.
Why the bundled copy is not the drift it looks like
The case against vendoring an IDL is real and this package still makes it: a
snapshot checked into a source tree falls behind the program the moment
either side moves, and a drifted IDL is worse than a missing one because the
wrong discriminators keep decoding into plausible values. Nothing is checked in.
The module behind @kolny/sdk/idl is generated from the Anchor build output
before every compile, typecheck and test run, and never committed, so inside the
repository that build output remains the only source of truth.
What ships is a different kind of thing. A published tarball is already an
immutable snapshot of one moment, so freezing that moment's interface beside the
modules compiled against it is version pinning, not drift: @kolny/[email protected]
carries the 0.1.1 interface and cannot be handed a newer one by accident.
Three checks keep that honest.
- The generator refuses to run without the Anchor output and puts it through
assertColonyIdlbefore embedding it, so a build cannot quietly produce a package whose interface is missing or wrong. createKolnyClientrunsassertColonyIdlagain at construction, on the bundled IDL and a supplied one alike.- An address inside an IDL proves nothing about a chain, so
fetchProgramStatus()looks the program up on the cluster you actually connected to and requires it to beexecutable.
The mismatch that a download step invites -- one release of the SDK paired with whatever interface happened to be at that URL -- is the one thing bundling removes outright.
Instructions
Account order and the signer/writable flags come from the IDL, never from lists written out here. A hard-coded order is a second source of truth that drifts the first time an account is inserted into a context, and the on-chain failure is a constraint error that says nothing useful.
| Builder | Notes |
|---|---|
| registerForager | Record only. Starts as Scout with no bond |
| openForagerVault | Sub-account. Derives from the record, so it comes second |
| topUpBond | Posts the bond, in base-asset atoms |
| registerForagerSequence | The three above, in dependency order, for one transaction |
| fundScout | One fixed exploration ticket |
| promoteForager | Permissionless; criteria are read from chain state |
| heartbeat | Liveness, against the non-response slash condition |
| deposit / withdraw | Shares round down, favouring existing holders |
| requestRedemption | Queue when idle liquidity is short |
| beginSettlement / settleForager / finalizeSettlement | The three-phase crank |
| rebalanceForager | Moves capital toward target |
buildInstruction is the general form if you need one that has no named
builder; it takes a name-to-address map and reports a missing account by name.
Amounts are bigint throughout. The Borsh coder wants BN and is given it at
the boundary, so no caller has to hold a BN for a u64 balance.
Every builder returns a TransactionInstruction and stops there. Nothing in
this package signs, simulates or broadcasts.
Accounts
Layouts come from the IDL through Anchor's coder. Integers are normalized to
bigint on the way out, because BN.toNumber() on a u64 balance or a u128
share count loses precision silently.
const snapshot = await client.fetchColonySnapshot();
// config, brood, cache, navBaseUnits, cacheReserveRatioBps
const foragers = await client.fetchAllForagers(); // by account discriminatorAgainst the devnet colony, that returns:
snapshot.config.epoch 1n
snapshot.config.activeForagerCount 0
snapshot.config.paused false
snapshot.brood.baseMint So11111111111111111111111111111111111111112
snapshot.brood.idleBase 200000000n
snapshot.navBaseUnits 200000000n
snapshot.cacheReserveRatioBps 0n
foragers.length 2
foragers[0].statusName "scout"
foragers[0].bond 10000000n
foragers[0].pheromone 0nRead on devnet, 2026-08-17. The base mint is devnet wSOL at nine decimals, so
navBaseUnits of 200000000n is 0.2 wSOL and the bond is 0.01. Two foragers
are registered and both are still scouts, which is why activeForagerCount is
zero. These are test figures on a test cluster and describe nothing about a
fund.
A read returns null for an account that does not exist, because "no colony
here" is a real answer the Trail Board has to render. What it never does is
report an empty colony for an undeployed program: those look identical in a
chart and mean completely different things, so the second case throws.
Endpoints
DEFAULT_RPC_ENDPOINT is the public mainnet RPC,
https://api.mainnet-beta.solana.com. No paid endpoint is built in: a paid URL
carries its key in the query string, and an SDK shipping one would hand that key
to every consumer's bundle. Pass your own Connection for a private endpoint,
built server-side.
The client never opens a connection itself, so the read helpers work only on the
Connection you hand them, and a missing one is NoConnectionError rather than
a silent default. That matters right now: the program is on devnet, not on
mainnet, so devnet work needs a devnet Connection passed in explicitly. A
client built against mainnet does not fail vaguely -- fetchProgramStatus()
returns deployed: false and every read raises ProgramNotFoundOnChainError
naming the endpoint:
Cannot read the colony config: no account exists at the program id on this
cluster, so the program is not deployed here
program id : 7whkmFfDcTyoJgf7jFGFmKNFMQn8NoreHnh2wZ9nWbsk
endpoint : https://api.mainnet-beta.solana.com
account : does not existToken-2022
The token program defaults to SPL Token. For a mint under Token-2022, pass
tokenProgramId: TOKEN_2022_PROGRAM_ID to createKolnyClient, and remember
that ATAs must be derived with the same program id or they resolve to an address
that does not exist.
Package format
| | |
|---|---|
| ESM | import * as sdk from "@kolny/sdk" -- 50 exports |
| CommonJS | require("@kolny/sdk") -- the same 50 exports |
| Types | dist/esm/index.d.ts, reached through the "types" condition of the exports map |
| IDL entry | @kolny/sdk/idl -- colonyIdl, the bundled interface; no fs, no import.meta, ESM and CommonJS both |
| Node entry | @kolny/sdk/idl-node -- loadIdlFromDisk, defaultIdlPath, IdlNotBuiltError; ESM and CommonJS both |
| Engine | Node 20 or newer |
Both formats are built from one source and smoke-tested by loading them, and
the declarations resolve under "moduleResolution": "nodenext" with strict
on. There is no separate types package to install.
Development
Against a clone of the protocol repository, not an installed package:
npm install
npm run typecheck
npm test
npm run build # ESM to dist/esm, CommonJS to dist/cjssrc/idl-json.ts is generated, not written: scripts/generate-idl-module.mjs
rebuilds it from packages/anchor-program/target/idl/kolny_colony.json and all
four commands above run it first, so it cannot be the stale copy. It is not
committed. The generator exits non-zero when the Anchor output is missing or
fails assertColonyIdl, because a build that skipped it would produce a tarball
that looks complete and leaves every consumer without an interface. npm pack
and npm publish rebuild through prepack for the same reason.
Both outputs are smoke-tested by loading them. Three interop details cost a
build each and are worth knowing. @coral-xyz/anchor is CommonJS only and marks
itself __esModule, so its real exports hang off default under Node ESM and
off the namespace under CommonJS, and the code takes whichever is populated.
import.meta cannot appear in a CommonJS build and __dirname cannot appear in
an ESM one, so the disk loader resolves its path from the working directory
rather than from the module. And that same constraint is why the bundled IDL is
a generated module rather than a .json file copied into dist: a module needs
no path resolution at all, and it survives a browser bundler, which reading a
sibling file does not.
License
MIT.
