@three-ws/skill-license

@three-ws/skill-license

On-chain skill licenses for agents — each purchased skill is a 1/1 SPL NFT plus a deterministic SkillLicense PDA. Mint, verify, and read trustless skill access in one import, no database required.

npm install @three-ws/skill-license
View on GitHubStar the repo

On-chain skill licenses — every purchased agent skill is a 1/1 SPL NFT and a deterministic SkillLicense PDA. Mint, verify, and read trustless access in one import.

npm downloads license node

Install · Quick start · API · How it works · three.ws


@three-ws/skill-license is the official client for the three.ws skill_license Solana program — a trustless way to prove that a wallet owns a purchased agent skill. Each license is a real 1-of-1 SPL NFT held in the buyer's wallet, paired with a deterministic SkillLicense PDA that anyone can re-derive and read in a single RPC call. It wraps the public, auth-free verify endpoint (GET /api/skills/license-onchain) — which derives the SkillLicense PDA and reads it back for you — so checking access is one function call instead of a database round-trip. It pairs with @three-ws/x402-server — gate a paid endpoint on a license the holder actually owns on-chain.

#Why

The usual way to answer "can this wallet use skill X on agent Y?" is a database lookup you have to trust, run, and keep online. That's a single point of failure and a closed door to anyone who isn't you.

The skill_license program makes the answer public chain state:

This SDK is the one-call front door to that chain state: verifyLicense(...) → true | false, resolved through the platform's public read endpoint, which does the PDA derivation and RPC read for you.

#Install

npm install @three-ws/skill-license

Zero runtime dependencies. Works in Node 18+ and the browser (uses fetch). Verification only reads public chain state through the platform endpoint, so no wallet, signer, or RPC of your own is needed to check a license.

#Quick start

The one call that sells it — does this wallet own this skill, right now, on chain?

import { verifyLicense } from '@three-ws/skill-license';

const owns = await verifyLicense({
  holder: 'HoLDeRwa11et1111111111111111111111111111111',
  agent: 'THREEsynthetic11111111111111111111111111111', // agent skill-collection mint
  skill: 'web-search',
});

console.log(owns); // → true | false

A runnable version of this lives in example/verify-license.mjs. It hits the live public read endpoint, so it needs no key, no wallet, and no RPC:

node example/verify-license.mjs                        # the synthetic placeholders above
node example/verify-license.mjs <holder> <agentMint> <skill>

The placeholders are clearly synthetic but decode to real 32-byte base58 keys, so the snippet runs as written and answers false (nothing licensed at that triple). An address that is not a valid 32-byte key is rejected with a validation_error (400) before anything touches the chain.

Need the full record, not just a boolean — purchase date, NFT mint, explorer link, revoked state:

import { getLicense } from '@three-ws/skill-license';

const license = await getLicense({
  holder: 'HoLDeRwa11et1111111111111111111111111111111',
  agent: 'THREEsynthetic11111111111111111111111111111',
  skill: 'web-search',
});
if (license?.owned) {
  console.log(license.nftMint);      // the 1/1 SPL NFT in the holder's wallet
  console.log(license.purchaseDate); // unix seconds
  console.log(license.explorer);     // solscan/explorer link
}

Mint a license after a confirmed purchase (server-side, minter-signed):

import { mintLicense } from '@three-ws/skill-license';

// Runs on your backend, authenticated as the buyer. Verifies the payment was
// confirmed on-chain, then mints the 1/1 NFT + SkillLicense PDA to the wallet.
const { nftMint, signature } = await mintLicense({
  agentId: '3b1f…-uuid',
  skill: 'web-search',
  buyer: 'HoLDeRwa11et1111111111111111111111111111111',
  txSignature: '4xKp…purchaseSig',
  apiKey: process.env.THREE_WS_API_KEY, // your three.ws bearer token
});

#API

The SDK is a thin wrapper over the live three.ws endpoints: the platform does the Solana work (PDA derivation, RPC reads, minting) server-side, so every call is one fetch and the client stays zero-dependency.

#verifyLicense({ holder, agent, skill, network? }) → Promise<boolean>

The headline check. Resolves to true when holder owns an active (non-revoked) license for skill on agent. Wraps GET /api/skills/license-onchain?wallet=…&agent_mint=…&skill=… and returns its data.owned field.

Param Type Default Notes
holder string — Base58 Solana pubkey of the wallet to check.
agent string — The agent's on-chain grouping mint (its skill-collection mint).
agentId string — Alternatively, a three.ws agent UUID — resolved to skill_collection_mint server-side. Pass agent or agentId.
skill string — Skill name/slug (≤100 chars). Hashed with SHA-256 to form the PDA seed.
network 'mainnet' | 'devnet' 'mainnet' Which cluster to read.

#getLicense(input, { network? }) → Promise<LicenseRecord \| null>

Read the full license record. input is { holder, agent, skill } (or { holder, agentId, skill }), the same shape verifyLicense takes. Wraps GET /api/skills/license-onchain and returns null when no license exists on-chain.

LicenseRecord

Field Type Notes
owned boolean Exists and not revoked and authority matches the holder.
exists boolean The PDA is present on-chain.
revoked boolean true once revokedAt !== 0 (refunded / frozen).
authority string The wallet that owns the license.
agentMint string The agent grouping mint recorded on the license.
nftMint string The 1/1 SPL NFT mint backing the license.
ownerTokenAccount string The holder's ATA for the NFT.
skillName string Human-readable skill identifier (≤64 bytes).
skillHash string Hex SHA-256 of skillName (the third PDA seed).
purchaseDate number Unix seconds the license was minted.
revokedAt number Unix seconds when revoked, or 0 while active.
license string The SkillLicense PDA address.
explorer string Explorer link to the license account.

#skillSeed(skillName) → Promise<string>

sha256(skillName) as a 32-byte hex string — the fixed-length third PDA seed. Async (Web Crypto's crypto.subtle.digest, so it runs zero-dep in Node 18+ and the browser). Matches the program's skill_seed() (Solana hash::hash is SHA-256), so it reproduces the exact hash the on-chain seed uses.

#mintLicense({ agentId, skill, buyer, txSignature?, apiKey }) → Promise<MintResult>

Server-side. Mints the on-chain license to buyer after their purchase is confirmed. Wraps POST /api/skills/mint, which verifies the payment reached the agent's payout wallet before minting — a caller can never mint a free license. Idempotent: a second call returns the existing mint.

Field Type Notes
agentId string three.ws agent UUID.
skill string Skill name/slug (≤100 chars).
buyer string Recipient wallet — must be a Solana wallet linked to the caller's account.
txSignature string The purchase transaction signature (optional; the most recent attempt is used otherwise).
apiKey string Bearer token for the authenticated three.ws account (overrides the client's apiKey).

To point the client at a different origin, construct one with createSkillLicense({ baseUrl, fetch, apiKey, headers }) instead of passing baseUrl per call; it returns { verifyLicense, getLicense, mintLicense } bound to that configuration. DEFAULT_BASE_URL (https://three.ws) is exported too, and THREE_WS_BASE_URL in the environment overrides it without any code change.

Returns MintResult: { nftMint, signature, collection, network, explorer, skill, agentId, purchaseId, alreadyMinted }.

PROGRAM_ID is exported as a constant (EdngSwxmDktyrr4phwGEZnCXEoQ27vgnBtowjhKa7Wr8) — the same id on every cluster.

#How it works

The license is two linked artifacts derived from one triple (owner, agent_mint, sha256(skill_name)):

buy skill ──▶ payment confirmed on-chain ──▶ mint_skill_license (minter-signed)
                                                │
                                                ├─ SkillLicense PDA   ← the queryable record
                                                │     seeds = ["skill_license", owner, agent, sha256(skill)]
                                                │     { authority, agent_mint, nft_mint, skill_hash,
                                                │       purchase_date, revoked_at, skill_name }
                                                │
                                                └─ 1/1 SPL NFT ──▶ owner's ATA
                                                      mint PDA = ["skill_mint", owner, agent, sha256(skill)]
                                                      decimals 0, supply locked at 1 (mint authority removed)

verify ─▶ derive SkillLicense PDA ─▶ getAccountInfo ─▶ exists && revoked_at == 0  ⇒  owned

The program is Anchor, id EdngSwxmDktyrr4phwGEZnCXEoQ27vgnBtowjhKa7Wr8 (identical on localnet/devnet/mainnet). Its instructions:

Instruction Signer Effect
initialize_marketplace(minter) admin One-time singleton config; sets the authorized minter wallet.
set_minter(new_minter) admin Rotate the minter (key rotation).
mint_skill_license(skill_name) minter Create the SkillLicense PDA, mint the 1/1 NFT to the owner's ATA, lock supply at 1. Owner doesn't sign.
burn_skill_license() owner Burn the NFT, close the token account + PDA, reclaim rent.
revoke_skill_license() minter Refund path: freeze the holder's token account and stamp revoked_at; the PDA stays readable.

#Errors & edge cases

verifyLicense and getLicense only read public state, so they never need credentials. The shapes they surface, straight from the endpoint:

Every rejection is a typed ThreeWsError carrying a stable code, the HTTP status, and the parsed body; a 402 arrives as PaymentRequiredError with the x402 challenge on .accepts. Both classes are exported.

State Meaning What you get
validation_error (400) holder / agent isn't a real 32-byte Solana address, or skill is empty/over 100 chars. Rejects before any chain read.
Program not deployed The skill_license program isn't live on this cluster. getLicense → { exists: false, owned: false, deployed: false }; verifyLicense → false.
No license The PDA doesn't exist — never purchased. getLicense → null; verifyLicense → false.
Revoked Refunded — frozen with revoked_at set. { exists: true, revoked: true, owned: false }.
rpc_error (502) The Solana RPC read failed. Rejects; retry or fall back.
no_collection (409) Resolved by agentId but the agent has no on-chain skill collection yet. Rejects — nothing can be licensed on-chain for that agent.

mintLicense surfaces the mint endpoint's authenticated states:

code HTTP Meaning Recovery
unauthorized 401 Missing/invalid bearer. Pass a valid apiKey.
wallet_not_linked 403 buyer isn't a Solana wallet linked to your account. Link the wallet first.
no_purchase 404 No purchase row for this skill. Buy the skill before minting.
payment_pending 402 The purchase payment isn't confirmed yet. Wait for confirmation, then retry.
signature_mismatch 400 txSignature contradicts the recorded purchase. Send the correct signature, or omit it.
already minted 200 A license already exists. Returned with alreadyMinted: true — treat as success.

Every state is designed: a missing license reads as false, never a crash; an unconfirmed payment returns 402, never a free mint.

#Examples

Gate a paid x402 endpoint on real ownership — pair with @three-ws/x402-server:

import { verifyLicense } from '@three-ws/skill-license';

async function handler(req, res) {
  const owns = await verifyLicense({
    holder: req.wallet,
    agent: AGENT_SKILL_COLLECTION_MINT,
    skill: 'web-search',
  });
  if (!owns) return res.status(402).json({ error: 'license required' });
  // …serve the skill
}

Render a "You own this" badge in the browser — verification is read-only, so it runs client-side with no signer:

import { verifyLicense } from '@three-ws/skill-license';

const owned = await verifyLicense({ holder: wallet, agentId: AGENT_UUID, skill });
badge.textContent = owned ? 'Licensed on-chain ✓' : 'Buy this skill';

Under the hood — the raw HTTP the verify path wraps, so the docs hold even before the wrapper ships:

const u = new URL('https://three.ws/api/skills/license-onchain');
u.searchParams.set('wallet', holder);
u.searchParams.set('agent_mint', agent); // or agent_id=<uuid>
u.searchParams.set('skill', skill);

const { data } = await fetch(u).then((r) => r.json());
// data → { owned, exists, revoked, deployed, license, nft_mint,
//          owner_token_account, program_id, explorer, record }
console.log(data.owned);

Built by three.ws · The only coin is $THREE