@usearete/automations 0.0.0-stage → 0.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arete
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,300 @@
1
- # Temporary Holding Version
1
+ # @usearete/automations
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Server-side automation of Arete Protocol steps on a user's behalf: when a
4
+ step needs the user's signature, a backend prepares it, checks it against a
5
+ signing policy and the user's consent, signs it with a delegated signer, and
6
+ submits it. Arete never holds a key; the signer runs in your backend.
7
+
8
+ ```bash
9
+ npm install @usearete/automations @usearete/sdk
10
+ ```
11
+
12
+ Start with [Automating steps for users](https://github.com/AreteA4/arete/blob/main/typescript/automations/docs/automating-steps.md), the
13
+ [consent UX guide](https://github.com/AreteA4/arete/blob/main/typescript/automations/docs/consent-ux.md) and the
14
+ [threat model](https://github.com/AreteA4/arete/blob/main/typescript/automations/docs/threat-model.md); [`examples/token-raise`](https://github.com/AreteA4/arete/tree/main/typescript/automations/examples/token-raise)
15
+ is a complete backend.
16
+
17
+ ## Use the least powerful mechanism
18
+
19
+ Reach for delegated signing only when nothing simpler works:
20
+
21
+ | Situation | Mechanism |
22
+ |---|---|
23
+ | The program lets anyone advance it | A keeper step |
24
+ | The money is committed now | Deposit straight into the pot or escrow |
25
+ | A fixed recurring payment from a wallet the user keeps using | An on-chain allowance |
26
+ | The user's signature is needed each time, and the wallet is where their funds already live | Delegated signing under consent (this package) |
27
+
28
+ ## The runtime
29
+
30
+ ```ts
31
+ import { createAutomations, definePolicy, memoryStore, rpcSimulator, scheduleSlot } from '@usearete/automations';
32
+
33
+ const automations = createAutomations({
34
+ client, // a secret-key protocol client
35
+ signer, // a ProtocolSigner
36
+ policy: definePolicy({ ... }),
37
+ simulator: rpcSimulator(RPC_URL),
38
+ store: memoryStore(), // tests and development only
39
+ }); // targets dev for a client `a4 dev` generated, mainnet for a published one
40
+
41
+ // A protocol event: read state, return the steps to run. Handlers never prepare.
42
+ automations.on('round.settled', async (event, ctx) => {
43
+ const player = await lookUpPlayer(event);
44
+ return [ctx.step('claimAndEnter', { wallet: player.wallet, instance: event.instanceId })];
45
+ });
46
+
47
+ // Webhooks: verified, accepted once, answered straight away.
48
+ export const POST = automations.webhookHandler({ secret: process.env.ARETE_WEBHOOK_SECRET! });
49
+
50
+ // Cron: your scheduler dispatches; the slot id makes a double fire a no-op.
51
+ await automations.dispatch('weeklyBuy', {}, { id: scheduleSlot('weekly-buy', 'week') });
52
+
53
+ automations.start(); // a long-running worker, or `await automations.drain()` per request
54
+ ```
55
+
56
+ Each trigger becomes a **delivery job** that runs its handler. Each step
57
+ request the handler returns becomes a **step job** (`${deliveryId}:${step}:${wallet}`, plus
58
+ `:${instance}` for a step on an instance),
59
+ so rerunning a handler never doubles a step. A step job:
60
+
61
+ 1. is skipped without an active consent grant (`no_consent`) or inside its
62
+ `cooldown`;
63
+ 2. is rejected when `allowedSteps` or prepare says no (a normal outcome);
64
+ 3. is prepared with idempotency key `${jobId}:${generation}` in the
65
+ negotiated format, checked against the intent, simulated and checked
66
+ against the policy (`refused(<rule>)`);
67
+ 4. re-checks consent, is signed, and the signed bytes are checked and saved
68
+ before submit;
69
+ 5. is tracked to `completed`, `chain_failed`, or `expired` (prepared once
70
+ more with a new key). An intent in `submitted_unknown` is read again
71
+ later and never resubmitted blind.
72
+
73
+ Only one step job per wallet runs at a time, from claim until it settles.
74
+ After a crash, a job that had signed reads its intent before doing anything
75
+ else. `onOutcome`, `onRefused` and `onRejected` report final states.
76
+
77
+ A store must pass `runStoreConformance` from `@usearete/automations/testing`.
78
+
79
+ ## Definitions and `a4 dev`
80
+
81
+ Put the handlers and view sources in a definition, apart from the client,
82
+ signer, store and secrets:
83
+
84
+ ```ts
85
+ // automations.ts
86
+ export default defineAutomations(TOKEN_RAISE_PROTOCOL, (automations) => {
87
+ automations.on('raise.failed', async (event, ctx) => { /* ... */ });
88
+ }, { policy: { programs: [RAISE_PROGRAM_ID], maxSolPerTransaction: '0.001' } });
89
+ ```
90
+
91
+ `[dev.automations] entry = "automations.ts"` in the protocol project's
92
+ arete.toml makes `a4 dev up` run it (`arete-automations dev`) against the
93
+ project's dev environment: dev wallets sign, a saved memory store keeps jobs
94
+ and consent across restarts, and the environment's events arrive as signed
95
+ webhook deliveries. It restarts when the handlers or the generated client
96
+ change. In production, `await automations.use(definition)` registers the same
97
+ handlers on a runtime you configure, with
98
+ `definePolicy({ ...definition.policy, deployments, clusters })`.
99
+
100
+ ## Consent
101
+
102
+ Nothing runs for a wallet without a grant the wallet signed:
103
+
104
+ ```ts
105
+ const automations = createAutomations({ ...options, consent: { domain: 'app.example' } });
106
+
107
+ // 1. Your backend builds the message; it issues a single-use nonce.
108
+ const proof = await automations.consent.proofMessage({
109
+ wallet,
110
+ deploymentId: DCA_DEPLOYMENT,
111
+ steps: ['buy'],
112
+ limits: {
113
+ perExecution: { SOL: '10000', [USDC]: '50000000' }, // lamports / base units, per run
114
+ perWindow: { [USDC]: { amount: '200000000', window: '30d' } },
115
+ },
116
+ params: { amount: '50000000', weekday: 'fri' }, // the user's settings, signed too
117
+ expiresAt: new Date('2027-01-01'),
118
+ });
119
+
120
+ // 2. The user's wallet signs `proof.bytes` (signMessage).
121
+ // 3. Your backend rebuilds the message from the same fields and checks the signature.
122
+ const { grant, warnings } = await automations.consent.grant({ ...fields, nonce: proof.nonce, issuedAt: proof.issuedAt, signature });
123
+
124
+ await automations.consent.revoke({ wallet }); // no signature needed
125
+ ```
126
+
127
+ - The message is human-readable and bound to your domain. Changing any
128
+ field, raising a limit or changing a setting needs a new signature.
129
+ - An asset the step moves without a per-run limit is refused
130
+ (`consent_limit`); going past a window is refused (`consent_window`). The
131
+ window counts a step from the moment it is signed and stops counting it
132
+ if its intent never lands. Only one step per wallet runs at a time, so
133
+ concurrent workers cannot overspend a window.
134
+ - A grant scoped to an instance ends when the instance is terminal.
135
+ - `grant` warns when the signer reports no provider-side policy. The
136
+ signature records what the user agreed to; it does not stop a compromised
137
+ backend. The wallet provider's policy and the protocol's on-chain rules do.
138
+
139
+ Handlers fan out over the wallets that consented:
140
+
141
+ ```ts
142
+ automations.on('weeklyBuy', async (_trigger, ctx) => {
143
+ const grants = await ctx.consents.list({ step: 'buy' });
144
+ return grants
145
+ .filter((grant) => grant.params.weekday === today)
146
+ .map((grant) => ctx.step('buy', { wallet: grant.wallet, args: { amount: grant.params.amount } }));
147
+ });
148
+ ```
149
+
150
+ ## View sources
151
+
152
+ A view source watches a live view and dispatches when a condition becomes
153
+ true, for example when the best lending rate moves to another market:
154
+
155
+ ```ts
156
+ import { fromView, fromViewRows } from '@usearete/automations';
157
+
158
+ const bestRate = fromView(client.views['jupiter-lend-rates'].markets.list, {
159
+ name: 'bestRateChanged',
160
+ key: (market) => market.id, // a row's identity in the view
161
+ derive: (markets) => pickBest(markets, { minEdgeBps: 25 }), // hysteresis lives in derive
162
+ changed: (previous, next) => previous !== undefined && previous !== next,
163
+ stableFor: '2m', // a new value must hold 2 minutes
164
+ });
165
+
166
+ automations.on('bestRateChanged', async (trigger, ctx) => { /* fan out over consenting wallets */ });
167
+
168
+ automations.source(bestRate); // long-running: subscribe
169
+ export async function GET() { await automations.poll(bestRate); } // serverless cron: one snapshot per call
170
+ ```
171
+
172
+ `fromViewRows(view, { name, key, derive, filter })` keeps one signal per
173
+ row, for example "this wallet's position crossed below a health of 1.15".
174
+ `fromAppendView(view, { name })` dispatches one trigger per appended row and
175
+ resumes from its stored cursor, which only ever moves forward.
176
+
177
+ Exactly once comes from the store: each key's last stable value is
178
+ advanced by compare-and-set and the delivery is accepted in the same step.
179
+ Replicas watching one view produce one delivery per transition; after a
180
+ restart, a change made while every replica was down fires once; A → B → A
181
+ → B fires three times. A value that changed and changed back while nothing
182
+ was watching is not seen. A trigger means "go and look": handlers read live
183
+ state before returning steps.
184
+
185
+ ## The Postgres store
186
+
187
+ ```ts
188
+ import { postgresStore } from '@usearete/automations/postgres';
189
+
190
+ const store = postgresStore(pool); // your pg Pool; opens no connections of its own
191
+ await store.migrate(); // once, from a deploy step, with a role that may create tables
192
+ ```
193
+
194
+ - Everything lives in one schema, `arete_automation` by default
195
+ (`postgresStore(pool, { schema })`). Nothing outside it is created,
196
+ altered or read.
197
+ - Migrations never run at startup. Run `store.migrate()` (transactional,
198
+ behind an advisory lock, so concurrent callers run one at a time), or
199
+ export the SQL for your own tool:
200
+ `npx arete-automations migrations --out db/migrations`. A store whose
201
+ database is behind refuses to run (`SchemaVersionError`).
202
+ - Use two roles: one that migrates, and a runtime role limited to rows.
203
+ `npx arete-automations grants --role app_runtime` prints its grants.
204
+ - A `pg.Pool` is detected; for another pool adapter pass
205
+ `postgresStore(pool, { executor: 'pool' })`.
206
+ - Pass a client instead of a pool to write inside your own transaction, for
207
+ example a consent grant with the user record:
208
+ `store.using(client).consent.put(grant)`.
209
+ - Jobs are claimed with `FOR UPDATE SKIP LOCKED`; a partial unique index
210
+ keeps one active step per wallet. Tested on Postgres 15, 16 and 17.
211
+
212
+ ## Privy
213
+
214
+ ```ts
215
+ import { PrivyClient } from '@privy-io/node';
216
+ import { privyPolicyFor, privySigner } from '@usearete/automations/privy';
217
+
218
+ const signer = privySigner({
219
+ privy: new PrivyClient({ appId, appSecret }),
220
+ walletIdFor: async (address) => lookUpPrivyWalletId(address), // null when you have no server access
221
+ authorizationPrivateKey: process.env.PRIVY_AUTHORIZATION_KEY!,
222
+ });
223
+
224
+ // The provider-side bound for one user's consent; attach it to their wallet in Privy.
225
+ const { policy, unenforced } = privyPolicyFor(signingPolicy, grant);
226
+ ```
227
+
228
+ The signer asks Privy to sign (not to send) each prepared transaction, as
229
+ v0 until v1 message signing is verified. A Privy policy refusal ends the job
230
+ `refused(provider_policy)`. Privy's policy sees top-level instructions only,
231
+ so `unenforced` lists what it cannot bound, notably amounts a protocol
232
+ program moves by CPI; see
233
+ [docs/privy-policy-expressiveness.md](https://github.com/AreteA4/arete/blob/main/typescript/automations/docs/privy-policy-expressiveness.md).
234
+
235
+ ## The signing policy
236
+
237
+ Every prepared transaction is checked before it is signed. A refusal names
238
+ the rule:
239
+
240
+ ```ts
241
+ import { checkPrepared, checkTarget, definePolicy, rpcSimulator, summarizePrepared } from '@usearete/automations';
242
+
243
+ const policy = definePolicy({
244
+ deployments: [DEPLOYMENT_ID],
245
+ clusters: ['dev', 'mainnet'], // defaults to ['dev']; mainnet only when listed
246
+ programs: [VAULT_PROGRAM_ID], // optional; runtime programs are always allowed
247
+ maxSolPerTransaction: '0.01', // SOL, wrapped SOL and closed token accounts included
248
+ maxTokenAmounts: { [USDC]: '5000000000' }, // base units; a mint without a cap may not leave
249
+ });
250
+
251
+ const target = checkTarget(policy, DEPLOYMENT_ID, 'mainnet'); // before preparing; null when allowed
252
+ if (target) throw new Error(`${target.rule}: ${target.reason}`);
253
+
254
+ const summary = await summarizePrepared(prepared, wallet, rpcSimulator(RPC_URL));
255
+ const refusal = checkPrepared(policy, { deploymentId: DEPLOYMENT_ID, prepared, summary, simulates: true });
256
+ if (refusal) throw new Error(`${refusal.rule}: ${refusal.reason}`);
257
+ ```
258
+
259
+ - Outflows are measured by simulating the exact bytes, per transaction. A
260
+ transaction whose effect on the signer cannot be simulated is refused.
261
+ - Instructions that hand control of the signer's funds to someone else
262
+ (token approvals, authority changes, System assignments, nonce, stake and
263
+ loader authority changes) are always refused, top-level and inner.
264
+ - The rules match the Arete MCP server's local signer. Both run the shared
265
+ vectors in [`test-vectors/signer-policy/`](../../test-vectors/signer-policy/).
266
+
267
+ What a wallet provider can enforce on its side is narrower; see
268
+ [docs/privy-policy-expressiveness.md](https://github.com/AreteA4/arete/blob/main/typescript/automations/docs/privy-policy-expressiveness.md).
269
+
270
+ ## Signers
271
+
272
+ A `ProtocolSigner` fills the wallet's signature slot of each prepared
273
+ transaction and nothing else. The package does not trust it:
274
+
275
+ - Before signing, `checkPreparedAgainstIntent` confirms the prepared bytes
276
+ are the ones the intent committed to (`messageSha256`), in the negotiated
277
+ format, with the wallet as a required signer.
278
+ - After signing, `checkSignedTransactions` re-hashes every message against
279
+ the intent, confirms no other signature slot changed, and verifies the
280
+ wallet's signature. A signer that adds a compute-budget instruction or
281
+ re-encodes a transaction is refused as `signer_modified`.
282
+
283
+ `negotiateTransactionFormat` picks the first format, in the deployment's
284
+ order (`v1`, then `v0`), that the signer supports.
285
+
286
+ ```ts
287
+ import { keypairSigner } from '@usearete/automations/keypair';
288
+
289
+ const signer = keypairSigner(JSON.parse(readFileSync(DEV_WALLET, 'utf8'))); // dev and tests only
290
+ ```
291
+
292
+ ## Development
293
+
294
+ ```bash
295
+ npm install
296
+ npm test # unit tests and the shared signer-policy vectors
297
+ npm run typecheck
298
+ npm run lint
299
+ npm run build
300
+ ```