@certen.io/sdk
v0.12.1
Published
TypeScript client for the CERTEN Gateway API — proof-gated cross-chain execution on Accumulate
Maintainers
Readme
@certen.io/sdk
TypeScript client for the CERTEN Gateway — proof-gated cross-chain execution on Accumulate.
Docs: https://docs.kompendium.co · Get an API key: https://gateway.kompendium.co/portal
npm install @certen.io/sdkRequires Node 22 or 24 (engines.node >=22). To sign through the SDK, also install the optional peer @certen.io/proof-verify (see Every signature is checked first).
import { CertenClient } from '@certen.io/sdk';
const certen = new CertenClient({ apiKey: process.env.CERTEN_API_KEY! });
// One call: opens the intent, signs what comes back, submits the signature.
const { intentId } = await certen.execute.transfer({
identityId,
adiUrl: 'acc://your-org.acme',
fromChain: 'accumulate',
toChain: 'base-sepolia',
fromAddress: abstractAccount, // the identity's account on the destination chain
toAddress: '0xBe00…9251',
amount: '0.001', // WHOLE units, as a string
sign: (hashHex) => ed25519Sign(hashHex), // YOU hold the key
});
await certen.execute.wait(intentId); // ~60-110s of real validator work
const proof = await certen.execute.proof(intentId); // hand this to your counterpartyexecute.send is the same transfer with everything the identity already knows filled in: it derives the ADI and the abstract account from identityId, and refuses before the network is
touched or anything is signed a recipient that is not an address (or whose EIP-55 checksum fails), an asset that is not the chain's native token, an amount that is zero or looks like wei,
and a chain the catalogue does not know.
const { intentId, fromAddress } = await certen.execute.send({
identityId, chain: 'base-sepolia', toAddress: '0xBe00…9251', amount: '0.001', sign: (hashHex) => ed25519Sign(hashHex), publicKey,
});Prefer execute.* — it is the same flow every integration writes, with the details that are easy to
get wrong already handled. The raw resources are there when you need to drive the steps yourself:
const opened = await certen.transaction.create({ identityId, intent });
// `signing_data` is absent in provider mode, where the gateway holds the key and there is
// nothing for you to sign.
const hash = opened.signing_data?.hash_to_sign;
if (!hash) throw new Error('provider mode: no signature required from you');
await certen.transaction.submitSignature(opened.intent_id, {
signature: ed25519Sign(hash),
publicKey,
});Your key never leaves your process. The gateway computes what must be signed and you decide whether to sign it. That is the supported posture for anything carrying value — see the external-signing guide.
Configuration
new CertenClient({
apiKey: 'ck_live_…', // required
baseUrl: 'https://…', // optional — see precedence below
maxRetries: 3, // default 3
baseBackoffMs: 250,
maxBackoffMs: 8_000,
autoIdempotencyKey: true, // default: every POST gets one
});Base URL precedence: options.baseUrl → $CERTEN_API_URL → https://gateway.kompendium.co. The env var
is there so a deployment can be retargeted at staging or a self-hosted gateway without a code change.
The client only talks to its own gateway. Every request URL, every submit_url a response names, and every redirect must stay
on the base URL's origin; anything else is refused with FOREIGN_ORIGIN_URL before a credential is attached or anything is signed.
Every id, hash or token you pass is encoded as exactly one path segment; an empty one, or one made only of dots, is refused
with INVALID_PATH_PARAMETER instead of reaching a different route.
Browsers
@certen.io/sdk runs in a browser through the browser export condition (webpack, Vite, esbuild and rollup select it
automatically), or explicitly as @certen.io/sdk/browser. That entry is the client, its resources, the typed errors, the status
table, the chain catalogue and the pure helpers; it uses Web Crypto and no Node module. Two things need Node and are only in the default entry:
CertenAgent / ed25519Signer (a local signing key) and fetchSharedProof / decodeSharedBundle (gunzip). A browser sends no
User-Agent (it forbids scripts setting one). verifyReceipt uses Web Crypto's ed25519; a runtime without it reports the signature
check as skipped, never as ok. CI bundles the browser entry and runs it with process, Buffer and require removed.
Every signature is checked first
execute.contractCall, execute.transfer, execute.cosign and CertenAgent's governance operations never sign a bare hash_to_sign. They rebuild the transaction the gateway returned
(signing_data.transaction, signing_data.signature_metadata), recompute every hash and match what it would authorise to your request, then sign; any disagreement throws
SIGNING_DATA_MISMATCH naming the field, and nothing is signed. beforeSign(summary) shows what the signature authorises and may decline. There is no option to sign without the check. It needs the
optional peer @certen.io/proof-verify, and a gateway that returns the unsigned transaction; in a browser, signing through the SDK is refused until the verifier runs there.
Verify a proof: @certen.io/sdk/verify
A separate entry point, so the API client stays light: it needs the optional peer @certen.io/proof-verify (and, under it, the Accumulate encoder).
import { CertenClient } from '@certen.io/sdk';
import { loadProofEvidence, verifyBundle, bundleInputOf } from '@certen.io/sdk/verify';
const client = new CertenClient({ apiKey });
const evidence = await loadProofEvidence(client, intentId); // bundle + proof v2 document (or why there is none)
const v = verifyBundle(bundleInputOf(evidence), { header }); // header: the execution chain's block header, from your own node
v.overall; // 'verified' | 'partial' | 'failed' | 'no_evidence'
v.layers; // one verdict per layer, with the evidence it was checked againstThe Accumulate side is checked from the proof v2 portable document, the execution outcome from the bundle's own receipt. A verified field in a
bundle is never read as a verdict (it comes back as bundleStatements), and neither is the gateway's own receipt. When the gateway serves no
proof v2 document for a proof, proof.portable() throws PROOF_V2_EVIDENCE_NOT_SERVED and loadProofEvidence reports it as notServed: the result is
no_evidence, never a pass.
The receipt
buildReceipt is the whole answer to "what happened": it reads the intent, verifies its proof here, optionally reads the recipient's balance from a node of yours, and assembles one record
(assembleReceipt is the pure half, in the main entry, and needs no verifier).
import { buildReceipt } from '@certen.io/sdk/verify';
const receipt = await buildReceipt(client, intentId, { rpc: process.env.MY_NODE, request });
receipt.verdict; // 'verified' | 'partial' | 'failed' | 'pending' | 'unproven'
receipt.gaps; // what is not established, one entry each: empty exactly when verdict is 'verified'
receipt.ids; // intent, Accumulate tx, destination tx (from the verified proof), proof idverified needs the proof to verify and the value to have arrived. The proof's execution layer stays unchecked until the receipts root is compared with a header from a node you named, so without
rpc a receipt is partial and says which layer. When the process must not call a node itself (a server an agent drives), fetch the block header from your own node and pass it as header:
{ receiptsRoot, hash?, number? } instead: it closes the same layer, cannot read the recipient (so a transfer stays partial), is refused with INVALID_HEADER if it has no receiptsRoot (a header
with nothing to compare is not a comparison), and cannot be combined with rpc. @certen.io/mcp's certen_receipt is this call. A proof that could not be fetched is unavailable (not "no evidence"), a recipient that could not be read is unreadable, and neither is ever turned
into a pass. rpc is yours: no CERTEN credential is sent to it, no redirect is followed, and its URL (which can carry a key) is never put in a message or the receipt, only its host.
What it does for you
Automatic idempotency on POSTs. Every POST gets a generated Idempotency-Key so a retried network error
is safe to replay. This matters more than it sounds: without one, a timeout is indistinguishable from success,
and a blind retry can open a second intent or burn identity quota. Opt out per-client with
autoIdempotencyKey: false, or per-route with noAutoIdempotencyRoutes.
Retries with backoff, and rate-limit awareness. Transient failures retry with exponential backoff. On a
429 the client remembers the reset window and pre-emptively waits rather than hammering.
Typed errors. Catch the class you care about instead of matching on status codes:
import { CertenAuthError, CertenRateLimitError, CertenBadRequestError, CertenServerError } from '@certen.io/sdk';
try {
await certen.identity.create({ … });
} catch (e) {
if (e instanceof CertenBadRequestError) { /* your request — fix and retry */ }
if (e instanceof CertenRateLimitError) { /* back off; e carries the reset */ }
if (e instanceof CertenAuthError) { /* key is wrong, revoked, or lacks the scope */ }
if (e instanceof CertenServerError) { /* gateway or downstream — safe to retry */ }
}Pagination helpers. paginate and paginateWithTotal iterate a paged endpoint as an async generator.
An agent, as one object
CertenAgent is the composition an autonomous agent needs: one identity, accounts on any chain,
and every proof-gated verb, with the key held by a signer that never leaves the process.
import { CertenClient, CertenAgent, ed25519Signer } from '@certen.io/sdk';
const signer = ed25519Signer(process.env.AGENT_SEED); // 64 hex; or ed25519Signer() and persist .seedHex
const agent = new CertenAgent(new CertenClient({ apiKey }), signer);
await agent.provision({ name: 'seller-bot', chains: ['base-sepolia'] });
await agent.linkChain('arbitrum-sepolia'); // same ADI, no new identity, no new key
const intent = await agent.call({
chain: 'base-sepolia',
call: { target: escrobot, functionSignature: 'ship(bytes32,string)', args: [orderId, 'TRACK-1'],
expectedEvents: [{ contract: escrobot, topic0: shippedTopic }] },
});
await agent.wait(intent.intentId);
const link = await agent.share(intent.intentId); // verifiable by a stranger, no account needed
await agent.token({ chain: 'base-sepolia', token: usdc, to, amount: '1000000' }); // 1 USDC, proof-gated
await agent.governance.requireSigner('acc://owner-policy.acme/book'); // the owner's rules gate every spend
await agent.governance.requireSigner('acc://owner-policy.acme/book', { account: 'book' }); // ...and who may act for the agentResume an existing agent by passing its saved state (identity id, ADI, accounts) as the third
constructor argument. An escrow or any other contract ABI belongs on top of call(), not inside.
Resources
| | |
|---|---|
| certen.identity | create · get · update · retire · readiness · topUpCredits · drip |
| certen.transaction | create · quote · submitSignature · get · list |
| certen.chains | list · get · enabled · balance |
| certen.sign | create · submitSignature |
| certen.governance | create · submitSignature · get |
| certen.pending | list |
| certen.portfolio | get |
| certen.admin | createOrg · createApiKey · listApiKeys · revokeApiKey · rotateApiKey · getAuditLog · getUsage |
The gateway serves ~80 endpoints; this covers the integration surface. For anything else, the
live spec is authoritative and the client's request path will
carry it.
Chains
One typed catalogue, CHAIN_CATALOGUE, holds every chain fact the SDK, the CLI and the MCP server use:
slug, numeric id, gas token and decimals, block time, faucet, aliases, and whether it is on by default.
| Chain | Id | Gas | Default |
|---|---|---|---|
| ethereum-sepolia | 11155111 | ETH | on |
| base-sepolia | 84532 | ETH | on |
| arbitrum-sepolia | 421614 | ETH | on |
| telcoin-adiri — Telcoin's Adiri testnet | 2017 | TEL (18 decimals) | off |
The catalogue says what a chain IS; the gateway says whether it may be used. Offer what
client.chains.enabled(configured?) returns: the configured chains (default: the three above) that
GET /v1/chains serves right now. parseEnabledChains('…,telcoin-adiri') turns a setting into that list
and refuses an unknown or retired chain by name.
The funding guard reads each chain's own gas token (readNativeBalance). On Adiri an empty TEL account
throws CertenUnfundedAccountError; a gas balance it cannot identify (an unknown chain, a balance row that
is not the chain's gas token, or an unreadable one) throws CertenFundingUnverifiableError rather than
letting the intent through.
Reads, estimates, and two calls that spend the platform's funds
// Reads (nothing is spent or created)
await certen.chains.balance('base-sepolia', '0x…'); // any address's native balance, as a decimal string: '0.25'; '0' is an empty account
await certen.identity.readiness(id, { chain: 'base-sepolia' }); // identity, key, credits, chain account + balance, org balance: one read, facts only
await certen.transaction.quote({ intent }); // what it would cost and whether it would be admitted: an ESTIMATE, nothing created
// Calls that spend the PLATFORM's funds. Each needs an Idempotency-Key and is OFF unless the deployment's operator switched it on.
await certen.identity.topUpCredits(id, { credits: 500, idempotencyKey: 'order-4411' }); // sponsor-paid ACME credits (1 credit = $0.01)
await certen.identity.drip(id, 'base-sepolia', { idempotencyKey: 'drip-77' }); // testnet gas to the identity's own accountbalance needs an API key (it reads through the gateway's node), and a node that did not answer is BALANCE_UNREADABLE, never a zero.
readiness reports facts and says nothing like "ready"; each section has its own state, and an unreadable one does not fail the others.
client.readiness() is the longer way to the same question: it checks the key page against Accumulate itself, which the gateway's aggregate does not.
topUpCredits and drip return 202 with status: 'unconfirmed' when nobody can say whether the money moved. That is data, not an error:
read the balance before asking again, because a new key is a second draw. Pass your own idempotencyKey so a retry after a crash reuses the
same one; left out, the SDK generates one for that call (and the same one on its own internal retries). Both are refused with a code you can
read when switched off (CREDIT_TOPUP_DISABLED, DRIP_DISABLED; not retried), limited (CREDIT_TOPUP_LIMIT, DRIP_LIMIT), or, for the drip,
aimed at something that is not a testnet (DRIP_TESTNET_ONLY). The drip sends nothing anywhere you name: the destination is the account the
gateway recorded for the identity and the amount is the operator's.
Three things to know
Identity creation is asynchronous. identity.create resolves with a 202-shaped result and provisioning
continues. Poll identity.get until the status is terminal, and check can_sign before building on it.
A proof cycle takes ~60–110 seconds. Real validator work, not a tunable delay. Do not wrap it in a 30s timeout.
vote is approve | reject | abstain — a lowercase string, not a number, and not accept.
Deadlines, header authorities and failure reasons
execute.contractCall, execute.transfer, transaction.create, agent.call and agent.transfer accept
expiresAt (a Date or RFC 3339 string, or expiresIn('30m'); 90 s to 7 days ahead — the gateway allows 60 s, the SDK keeps a margin) and additionalAuthorities (acc:// key
books, at most 8). Both are validated before anything is sent.
expiresAt— unsigned past the deadline, the intent endsfailedwithreason_code: 'expired': nothing executes, no fee or gas.additionalAuthoritiesis refused by the gateway by default — HTTP 422, thrown asCertenHeaderAuthorityNotExecutableError(see itsguidance). Validators do not yet count a header authority's signature, so such an intent would never execute. Make a required co-signer an authority on the account instead (agent.governance.requireSigner).execute.wait()throwsCertenIntentFailedErroron a failed intent, withreasonCode('expired','expectation_unmet','target_reverted', …).describeReasonCode()renders it.expectation_unmetmeans the call ran but itsexpectedEventsare missing: it is not a success.
Guides
- Onboard an identity
- Sign while holding your own key
- Proof-gate a contract call
- M-of-N panels
- Verify a proof
License
MIT
