Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,9 @@ jobs:
- name: Install dependencies
run: cd docs && npm ci

- name: Test docs
run: cd docs && npm test

- name: Build docs
run: cd docs && npm run build

Expand Down Expand Up @@ -536,6 +539,21 @@ jobs:
npm publish --access public
fi

- name: Publish octobot-client if version not already on npm
working-directory: packages/client/octobot_client_ts
run: |
npm ci
npm run build
npm test
NAME=$(node -p "require('./package.json').name")
VERSION=$(node -p "require('./package.json').version")
if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then
echo "$NAME@$VERSION is already published — nothing to do."
else
echo "Publishing $NAME@$VERSION..."
npm publish --access public
fi

version:
needs: [ build, tests ]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ docs/_build/
docs/node_modules/
docs/build/
docs/.docusaurus/
docs/tsconfig.tsbuildinfo
# Auto-generated tentacle docs (from collect-tentacles.mjs)
docs/content/creators/
docs/content/guides/exchanges.md
Expand Down
1 change: 1 addition & 0 deletions docs/content/client-sdk/_category_.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"label": "Client SDK"}
70 changes: 70 additions & 0 deletions docs/content/client-sdk/accounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
title: "Accounts"
description: "The protocol account graph (Account, AccountAuthentication, ExchangeConfig), create/update emit ordering, account kinds, and holdings."
sidebar_position: 5
---

# Accounts

Every write below returns an `ActionHandle`, not an immediate result — see [User actions](user-actions.md)
if you haven't read it yet.

## The protocol-0.4.0 account graph

An account is not one object on the wire — it's three, linked by derived ids:

```
Account { authentication_id → AccountAuthentication.id
specifics.exchange_config_ids[0] → ExchangeConfig.id }
```

- `AccountAuthentication` carries credentials (`api_key`/`api_secret` for an exchange, `public_key`
for a wallet address). Id: `auth_{accountId}`.
- `ExchangeConfig` carries the venue (`exchange`, `sandboxed`). Id: `cfg_{accountId}`. Exchange
accounts only.
- `Account` itself carries display fields and asset quantities, and references the other two.

The ids are **derived** (`auth_` / `cfg_` + the account id), not stored separately, so a client can
always reconstruct them from the account id alone — see `protocol/actions.ts::accountAuthIdFor` /
`exchangeConfigIdFor` and their inverses.

**The node does not cascade deletes.** `client.accounts.delete(id)` emits `account_delete` plus
the two companion deletes itself — a raw `account_delete` alone leaves orphaned auth/config items.

## `client.accounts.create()`

```ts
const action = await octobot.accounts.create({
name: 'Binance', type: 'exchange', exchange: 'binance',
credentials: { apiKey, apiSecret },
})
const account = await action.settled()
```

Emits, in order: `account_auth_create`, `exchange_config_create`, `account_create`. Three actions,
one `ActionHandle` — `settled()` resolves once the node confirms all three (in practice, the last one
appended, `account_create`, since it references the other two and the node applies them in order).

## `client.accounts.update()`

Emits credential/exchange-config edits **before** the account edit: `account_auth_edit`,
`exchange_config_edit` (exchange accounts only), then `account_edit`. This ordering matters — the
node's account re-validation on `account_edit` reads whatever credentials/exchange config are
current at that point, so rotating them first means the account edit is validated against the *new*
keys, not the ones about to be replaced. `update()` also preserves the account's original
`created_at` by pulling the existing record before building the edit — it does not re-stamp it to
the current time.

## Kinds

| `AccountInput.type` | Node `specifics.account_type` | Notes |
|---|---|---|
| `'exchange'` | `'exchange'` | Real credentials, a real venue. |
| `'wallet'` | `'generic'` | The node's `'blockchain'` account type isn't supported yet — wallets ride as generic with the address in `AccountAuthentication.public_key`. |
| `'generic'` | `'generic'` | No credentials — a manually-tracked balance. |

## Holdings

`AccountView.holdings` carries quantities only (`symbol`/`total`/`free`/`used`) — no fiat valuation.
The node's `DetailedAsset` schema has no per-asset value field; pricing holdings against a quote
currency is presentation logic that belongs one layer up, not in this package.
69 changes: 69 additions & 0 deletions docs/content/client-sdk/advanced-primitives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
title: "Advanced: Building an Offline Layer on Top"
description: "The tier-1 subpath exports (identity, transport, crypto, collections, protocol, node-api), the layering rule, and the DI seam for the two-phase automation race, for callers building their own persistence/offline/CRDT layer."
sidebar_position: 13
---

# Advanced: building an offline layer on top

This page is for a caller building persistence, an offline queue, or CRDT merge across devices on
top of this package — `@drakkar.software/octobot-sdk` (the OctoBot Cloud app's own sync engine) is
the worked example.

## Use the subpath exports, not `client/`

```
@drakkar.software/octobot-client → the facade (connectOctoBot, strategy, errors)
@drakkar.software/octobot-client/identity → WalletCapProvider, key derivation, mnemonic tools
@drakkar.software/octobot-client/transport → node REST client, sync client factory, node detection
@drakkar.software/octobot-client/crypto → the secret encryptor, wire constants
@drakkar.software/octobot-client/collections → the node collection registry, path helpers
@drakkar.software/octobot-client/protocol → pure builders/parsers, the strategy module, the DI'd
two-phase automation orchestration
@drakkar.software/octobot-client/node-api → the raw node REST fetchers
```

**The layering rule this package enforces on itself** (see `tests/layering.test.ts`): nothing under
these subpaths ever imports `client/`. Building your own offline layer on `client/` instead of these
would give you a second key-derivation cache, a second cap-provider lifecycle, and a second request
path to the same node running alongside whatever caching/retry logic you build — two systems talking
to one node with different retry semantics. Consume the primitives directly and own your own
caching/lifecycle, the way `octobot-sdk` does.

## The DI seam for the two-phase automation race

`protocol/orchestration/createAutomation.ts::runCreateAutomation(io, input, opts)` takes an
`ActionEmitter — { emit, poll }` you implement:

```ts
const io: ActionEmitter = {
emit: (configuration) => myOutbox.append(configuration), // returns the action's id
poll: async () => {
await myOutbox.drain()
await myUserDataStore.pull()
return parseNodeUserActions(myUserDataStore.data)
},
}
const { automationId } = await runCreateAutomation(io, input)
```

This is the SAME state machine `connectOctoBot()`'s facade uses (wired to a direct append + pull) —
one implementation of the strategy-then-automation race fix, reused instead of re-derived.

## Reference stability matters for `useSyncExternalStore`

`protocol/state.ts`'s parsers (`parseNodeAutomationStates`, `parseNodeAccounts`, etc) are memoized
per input document reference (`cachedByDoc`, an internal `WeakMap`). If you read through a
`useSyncExternalStore`-style selector, a fresh `.filter()`/`.map()` on every call trips React's
"getSnapshot should be cached" infinite-loop guard — these parsers return the SAME array reference
for the same document object, and a new reference only appears when your store actually replaces the
document. Preserve this property in anything you build on top: don't rebuild an array from these
parsers' output on every render.

## Local-domain adapters are your job, not this package's

Functions that merge node state into a caller's own local, CRDT-tombstoned domain objects (an
`Account` type with `deletedAt`/`editedAt`, UI-only display fields, kinds the protocol doesn't model)
are deliberately NOT in this package — see `protocol/state.ts`'s exports for the pure half and write
your own `accountFromNodeState(protocolAccount, priorLocalAccount)`-shaped adapter for the merge half.
`octobot-sdk`'s `src/adapters/` is the reference implementation.
72 changes: 72 additions & 0 deletions docs/content/client-sdk/automations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
title: "Automations"
description: "Create, read, stop, and update running bots (automations) with the OctoBot client SDK — progress reporting, status mapping, and strategy versioning on edit."
sidebar_position: 6
---

# Automations

An automation is a running bot: a strategy configuration bound to one or more accounts. See
[User actions](user-actions.md) for the two-phase create race this package sequences
around, and [Strategies](strategies.md) for building the `strategy` argument itself.

## Create

```ts
import { strategy } from '@drakkar.software/octobot-client'

const dca = strategy.dca({ pairs: ['BTC/USDT'], buyOrderAmount: '25' })
const action = await octobot.automations.create({
name: 'My DCA',
strategy: dca,
accountIds: [account.id],
})
const automation = await action.settled()
```

Progress reporting, since this is a two-phase operation under the hood:

```ts
await octobot.automations.create(input, {
onProgress: (p) => console.log(p.phase, p.done ? 'done' : 'waiting'),
// p.phase: 'strategy' | 'automation'
})
```

## Reading state

```ts
const automations = await octobot.automations.list()
for (const a of automations) {
console.log(a.id, a.status, a.accountIds, a.error)
}
```

`AutomationView.status` is a coarse `'live' | 'draft' | 'stopped'` — collapsed from the node's own
`WorkflowStatus` enum (`scheduled`/`periodic`/`running` → `live`; `pending` → `draft`; everything
else, including `canceled`/`failed`/`completed`, → `stopped`, so a non-running workflow never renders
as live).

`AutomationView.strategy` is recovered from the action history, not the node's own state — the
node's `AutomationState` carries no strategy reference at all. If the automation was created by a
different client (or a client that's since lost its action history), this can come back `null`.

## Stop

```ts
const action = await octobot.automations.stop(automation.id)
await action.settled()
```

## Update

```ts
const edited = strategy.dca({ pairs: ['BTC/USDT'], buyOrderAmount: '50' }, { id: dca.id, version: strategy.bumpVersion(dca.version!) })
const action = await octobot.automations.update(automation.id, {
name: automation.name, strategy: edited, accountIds: automation.accountIds,
})
await action.settled()
```

The node treats strategies as replace-by-id — an edit is a `strategy_edit` action carrying the same
`id` with a bumped `version`, followed by `automation_edit`. `update()` emits both.
49 changes: 49 additions & 0 deletions docs/content/client-sdk/collections-and-encryption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
title: "Collections and Encryption"
description: "The node's collections and their paths, the AES-256-GCM document envelope, and the raw documents escape hatch for unmodeled collections."
sidebar_position: 10
---

# Collections and encryption

## The node's collections

| Key | Path | Encryption | Pull/push |
|---|---|---|---|
| `userData` | `users/{identity}/data` | identity | pull; automations/user_actions are node-computed, other fields are whatever else you store there |
| `accounts` | `users/{identity}/accounts` | identity | pull-only from the node's side (`accounts`/`exchange_configs`) |
| `settings` | `users/{identity}/settings` | identity | pull + push, opaque |
| `strategies` | `users/{identity}/strategies` | identity | legacy/unused by the node directly — strategies live in the action history instead |
| `actions` | `users/{identity}/actions` | identity | push/append-only |
| `accountTrading` | `users/{identity}/accounts/{accountId}/trading` | identity | pull-only, one document per account |

Full path→HKDF-info table: [Wire contract](wire-contract.md).

## The envelope

Every document body is `{ iv: base64, data: base64 }`:

1. `deriveKey(encryptionSecret, salt, info)` → HKDF-SHA256 → a 256-bit AES key. `salt` is the fixed
`STARFISH_ENCRYPTION_SALT`; `info` is the per-collection string (`'octobot-sync-user-accounts'`,
etc) — this is what makes each collection's key independent even though they all derive from the
same wallet secret.
2. A random 12-byte IV, AES-256-GCM encrypt the JSON-serialized document.
3. Base64-encode both, wrap as `{ iv, data }`.

Decryption is the inverse. `crypto/secretEncryptor.ts::createSecretEncryptor(secret, salt, info)`
returns an `Encryptor` with `.encrypt()`/`.decrypt()`, memoizing the derived key.

## Using the escape hatch for a collection this package doesn't model

```ts
const { data, hash } = await octobot.documents.pull('settings')
await octobot.documents.push('settings', { ...data, myField: 1 }, { baseHash: hash })
```

For a collection outside the `NodeCollectionKey` union entirely, use `documents.raw`:

```ts
const encryptor = await octobot.documents.raw.encryptorFor('settings') // or build your own info string
const path = octobot.documents.raw.pullPath('settings')
const result = await octobot.documents.raw.sync.pull(path)
```
80 changes: 80 additions & 0 deletions docs/content/client-sdk/errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
title: "Errors"
description: "The full OctoBotError taxonomy — every class, its .code, its extra fields, and when it throws — plus handling patterns: switching on .code across package boundaries and AbortError passthrough."
sidebar_position: 4
mdx:
format: mdx
---

import DemoEmbed from '@site/src/components/demo/Embed';

# Errors

Nine ways a call into this SDK can end badly — eight typed `OctoBotError` subclasses, plus one you
have to catch yourself. Every method throws one of the eight (or lets an `AbortError` `DOMException`
through unwrapped, per the platform convention).

<DemoEmbed section="errors" />

## The taxonomy

| Class | `code` | Extra fields | When |
|---|---|---|---|
| `OctoBotConfigError` | `'config'` | — | Bad `ConnectOptions` — an unparseable `url`, or a `client.node.*` call made without `basicAuth`. |
| `OctoBotConnectionError` | `'unreachable'` \| `'timeout'` \| `'aborted'` | — | The node could not be reached at all — offline, wrong port, the connect-time budget expired, or the caller's own `AbortSignal` fired during connect. |
| `OctoBotAuthError` | `'unauthorized'` | `.address` `.userId` `.derivation` | The node answered but didn't authorize this wallet — the fields name exactly what was tried. |
| `OctoBotHttpError` | `'http'` | `.status` | A `client.node.*` REST call answered non-2xx. |
| `OctoBotConflictError` | `'conflict'` | `.serverHash` | A document push raced another writer — the `baseHash` you pushed against is no longer current. `.serverHash` carries the node's current hash so you can pull-and-retry without a round trip. |
| `OctoBotActionError` | `'action_failed'` | `.detail` `.phase` | The node executed a queued action and rejected it. Not retriable by resubmitting unchanged. |
| `OctoBotTimeoutError` | `'action_timeout'` | `.phase` | `ActionHandle.settled()` gave up waiting — the action may still complete. |
| `OctoBotScopeError` | `'forbidden_collection'` | `.collection` | A read-only session reached a collection its pairing grant doesn't cover — thrown client-side, before any network request. See [Read-only devices](read-only-pairing.md). |
| `AbortError` (`DOMException`) | — not an `OctoBotError` | — | A caller's own `AbortSignal` fired. Passed through unwrapped, matching how `fetch` itself behaves — `isOctoBotError()` on it is `false`. |

## Switch on `.code`, not `instanceof`, across package boundaries

```ts
import { isOctoBotError } from '@drakkar.software/octobot-client'

try {
await octobot.accounts.create(input)
} catch (err) {
if (isOctoBotError(err)) {
switch (err.code) {
case 'unauthorized':
// re-derive with a different seedDerivation
break
case 'conflict':
// pull again, retry the write
break
default:
console.error(err.code, err.message)
}
} else {
throw err // an AbortError, or something outside this package entirely
}
}
```

`instanceof OctoBotError` works fine within a single install of this package. It can silently fail
across a duplicated package instance (a monorepo hoisting quirk, a bundler that doesn't dedupe) —
`.code` is a plain string and survives that.

## `AbortError` is not wrapped

Every method that accepts `{ signal }` lets an aborted call's `DOMException` (`name === 'AbortError'`)
through unwrapped, matching how `fetch` itself behaves. Check for it separately if you care:

```ts
catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return // cancelled, not a failure
throw err
}
```

## `OctoBotActionError` vs `OctoBotTimeoutError`

These only come from `ActionHandle.settled()` (or the underlying two-phase automation orchestration
— see [User actions](user-actions.md)). `OctoBotActionError` means the node executed the action and
rejected it — resubmitting the same configuration will fail the same way. `OctoBotTimeoutError`
means the node never confirmed within the timeout budget; the action may still be pending or
running, so a fresh `settled()`-driving call (not a resubmit) is the right retry.
Loading
Loading