Skip to content

Commit c7ebf5f

Browse files
committed
add AutoApprovalManager
1 parent 6e05287 commit c7ebf5f

9 files changed

Lines changed: 500 additions & 1 deletion

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright (c) Mysten Labs, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
export {
5+
operationType,
6+
extractOperationType,
7+
OPERATION_TYPE_INTENT,
8+
operationTypeAnalyzer,
9+
} from './intent.js';
10+
11+
// Auto-approval system exports
12+
export { AutoApprovalManager } from './manager.js';
13+
export type { AutoApprovalAnalysis } from './manager.js';
14+
15+
export * from './schemas/index.js';
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) Mysten Labs, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import type { Transaction, TransactionResult } from '@mysten/sui/transactions';
5+
import type { TransactionDataBuilder } from '@mysten/sui/transactions';
6+
import { Commands } from '@mysten/sui/transactions';
7+
import type { Analyzer } from '../transaction-analyzer/index.js';
8+
9+
export const OPERATION_TYPE_INTENT = 'OperationType';
10+
11+
export function operationType(operationType: string) {
12+
return (tx: Transaction): TransactionResult => {
13+
tx.addIntentResolver(OPERATION_TYPE_INTENT, (transactionData, _options, next) => {
14+
replaceOperationTypeIntent(transactionData);
15+
return next();
16+
});
17+
18+
// Add the intent command to the transaction using Commands API
19+
const result = tx.add(
20+
Commands.Intent({
21+
name: OPERATION_TYPE_INTENT,
22+
inputs: {},
23+
data: { operationType },
24+
}),
25+
);
26+
27+
return result;
28+
};
29+
}
30+
31+
export function extractOperationType(cb: (operationType: string) => void) {
32+
return (
33+
transactionData: TransactionDataBuilder,
34+
_options: unknown,
35+
next: () => Promise<void>,
36+
) => {
37+
replaceOperationTypeIntent(transactionData, cb);
38+
return next();
39+
};
40+
}
41+
42+
function replaceOperationTypeIntent(
43+
transactionData: TransactionDataBuilder,
44+
cb?: (operationType: string) => void,
45+
) {
46+
for (let index = transactionData.commands.length - 1; index >= 0; index--) {
47+
const command = transactionData.commands[index];
48+
if (command.$kind === '$Intent' && command.$Intent.name === OPERATION_TYPE_INTENT) {
49+
const operationType = command.$Intent.data.operationType as string;
50+
transactionData.replaceCommand(index, []);
51+
cb?.(operationType);
52+
}
53+
}
54+
}
55+
56+
export const operationTypeAnalyzer: Analyzer<string | null> = (tx) => {
57+
let operationType: string | null = null;
58+
tx.addIntentResolver(
59+
OPERATION_TYPE_INTENT,
60+
extractOperationType((type) => {
61+
operationType = type;
62+
}),
63+
);
64+
65+
return async ({ get }) => {
66+
// wait for intent to be resolved
67+
await get('data');
68+
return operationType;
69+
};
70+
};
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright (c) Mysten Labs, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import type { Experimental_SuiClientTypes } from '@mysten/sui/experimental';
5+
import { parse, safeParse } from 'valibot';
6+
import type { BaseAnalysis } from '../transaction-analyzer/base.js';
7+
import type { CoinValueAnalysis, TransactionAnalysisIssue } from '../transaction-analyzer/index.js';
8+
import type { AutoApprovalState } from './schemas/state.js';
9+
import { AutoApprovalStateSchema } from './schemas/state.js';
10+
import type { AutoApprovalSettings } from './schemas/policy.js';
11+
import { AutoApprovalPolicySchema, AutoApprovalSettingsSchema } from './schemas/policy.js';
12+
13+
export interface AutoApprovalManagerOptions {
14+
policy: string;
15+
state: string | null;
16+
network: string;
17+
origin: string;
18+
}
19+
20+
export interface AutoApprovalAnalysis {
21+
results: BaseAnalysis & {
22+
usdValue: CoinValueAnalysis;
23+
operationType: string | null;
24+
};
25+
issues: TransactionAnalysisIssue[];
26+
}
27+
28+
export class AutoApprovalManager {
29+
#state: AutoApprovalState;
30+
31+
constructor(options: AutoApprovalManagerOptions) {
32+
let state: AutoApprovalState | null = null;
33+
34+
if (options.state) {
35+
const parseResult = safeParse(AutoApprovalStateSchema, JSON.parse(options.state));
36+
if (parseResult.success) {
37+
const providedPolicy = parse(AutoApprovalPolicySchema, JSON.parse(options.policy));
38+
const currentPolicy = parseResult.output.policy;
39+
40+
if (JSON.stringify(currentPolicy) === JSON.stringify(providedPolicy)) {
41+
state = parseResult.output;
42+
}
43+
}
44+
}
45+
46+
this.#state =
47+
state ??
48+
parse(AutoApprovalStateSchema, {
49+
schemaVersion: '1.0.0',
50+
origin: options.origin,
51+
network: options.network,
52+
policy: parse(AutoApprovalPolicySchema, JSON.parse(options.policy)),
53+
settings: null,
54+
createdObjects: {},
55+
pendingDigests: [],
56+
} satisfies AutoApprovalState);
57+
58+
if (this.#state.network !== options.network) {
59+
throw new Error(`Network mismatch: expected ${options.network}, got ${this.#state.network}`);
60+
}
61+
62+
if (this.#state.origin !== options.origin) {
63+
throw new Error(`Origin mismatch: expected ${options.origin}, got ${this.#state.origin}`);
64+
}
65+
}
66+
67+
canAutoApprove(analysis: AutoApprovalAnalysis): boolean {
68+
if (!this.#state.policy || !this.#state.settings) {
69+
return false;
70+
}
71+
72+
if (analysis.issues.length > 0) {
73+
return false;
74+
}
75+
76+
if (new Date() > new Date(this.#state.settings.expiration)) {
77+
return false;
78+
}
79+
80+
if (
81+
this.#state.settings.remainingTransactions !== null &&
82+
this.#state.settings.remainingTransactions <= 0
83+
) {
84+
return false;
85+
}
86+
87+
if (
88+
!analysis.results.operationType ||
89+
!this.#state.settings.approvedOperations.includes(analysis.results.operationType)
90+
) {
91+
return false;
92+
}
93+
94+
if (!analysis.results.operationType) {
95+
return false;
96+
}
97+
98+
// TODO: analyze balances and budgets
99+
100+
return true;
101+
}
102+
103+
commitTransaction(analysis: AutoApprovalAnalysis): void {
104+
if (this.#state.settings?.remainingTransactions !== null && this.#state.settings) {
105+
this.#state.settings.remainingTransactions = Math.max(
106+
0,
107+
this.#state.settings.remainingTransactions - 1,
108+
);
109+
}
110+
111+
for (const outflow of analysis.results.coinFlows) {
112+
const currentBudget = BigInt(this.#state.settings?.coinBudgets[outflow.coinType] ?? '0');
113+
const newBalance = currentBudget - outflow.amount;
114+
115+
if (this.#state.settings) {
116+
this.#state.settings.coinBudgets[outflow.coinType] = newBalance.toString();
117+
}
118+
}
119+
120+
// TODO: track USD budget
121+
122+
this.#state.pendingDigests.push(analysis.results.digest);
123+
}
124+
125+
revertTransaction(analysis: AutoApprovalAnalysis): void {
126+
this.#removePendingDigest(analysis.results.digest);
127+
128+
if (this.#state.settings?.remainingTransactions !== null && this.#state.settings) {
129+
this.#state.settings.remainingTransactions += 1;
130+
}
131+
132+
this.#revertCoinFlows(analysis.results);
133+
}
134+
135+
#revertCoinFlows(analysis: BaseAnalysis): void {
136+
for (const outflow of analysis.coinFlows) {
137+
const currentBudget = BigInt(this.#state.settings?.coinBudgets[outflow.coinType] ?? '0');
138+
const newBalance = currentBudget + outflow.amount;
139+
140+
if (this.#state.settings) {
141+
this.#state.settings.coinBudgets[outflow.coinType] = newBalance.toString();
142+
}
143+
}
144+
145+
// TODO: revert USD budget
146+
}
147+
148+
#removePendingDigest(digest: string): void {
149+
const pendingIndex = this.#state.pendingDigests.indexOf(digest);
150+
if (pendingIndex >= 0) {
151+
this.#state.pendingDigests.splice(pendingIndex, 1);
152+
} else {
153+
throw new Error(`Transaction with digest ${digest} not found in pending digests`);
154+
}
155+
}
156+
157+
applyTransactionEffects(
158+
analysis: AutoApprovalAnalysis,
159+
result: Experimental_SuiClientTypes.TransactionResponse,
160+
): void {
161+
this.#removePendingDigest(result.digest);
162+
163+
for (const changed of result.effects.changedObjects) {
164+
if (changed.idOperation === 'Created' && changed.outputState === 'ObjectWrite') {
165+
this.#state.createdObjects[changed.id] = {
166+
objectId: changed.id,
167+
version: changed.outputVersion!,
168+
digest: changed.outputDigest!,
169+
objectType: analysis.results.objectsById.get(changed.id)?.type || 'unknown',
170+
};
171+
}
172+
}
173+
174+
// Revert coin flows and use real balance changes instead
175+
this.#revertCoinFlows(analysis.results);
176+
for (const change of result.balanceChanges) {
177+
const currentBudget = BigInt(this.#state.settings?.coinBudgets[change.coinType] ?? '0');
178+
const newBalance = currentBudget + BigInt(change.amount);
179+
if (this.#state.settings) {
180+
this.#state.settings.coinBudgets[change.coinType] = newBalance.toString();
181+
}
182+
}
183+
184+
// TODO: track USD budget
185+
}
186+
187+
reset() {
188+
this.#state.settings = null;
189+
this.#state.createdObjects = {};
190+
this.#state.pendingDigests = [];
191+
}
192+
193+
export(): string {
194+
return JSON.stringify(parse(AutoApprovalStateSchema, this.#state));
195+
}
196+
197+
getSettings(): AutoApprovalSettings | null {
198+
return this.#state.settings;
199+
}
200+
201+
updateSettings(settings: AutoApprovalSettings): void {
202+
const validatedSettings = parse(AutoApprovalSettingsSchema, settings);
203+
this.#state.settings = validatedSettings;
204+
}
205+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Copyright (c) Mysten Labs, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
export { AutoApprovalPolicySchema, AutoApprovalSettingsSchema } from './policy.js';
4+
export type { AutoApprovalPolicy, AutoApprovalSettings } from './policy.js';
5+
6+
export { AutoApprovalStateSchema } from './state.js';
7+
export type { AutoApprovalState } from './state.js';
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) Mysten Labs, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import * as v from 'valibot';
5+
6+
const AccessLevelSchema = v.union([v.literal('read'), v.literal('mutate'), v.literal('transfer')]);
7+
8+
const BasePermissionSchema = v.object({
9+
description: v.string(),
10+
});
11+
12+
const ObjectTypePermissionSchema = v.object({
13+
...BasePermissionSchema.entries,
14+
$kind: v.literal('ObjectType'),
15+
objectType: v.string(),
16+
accessLevel: AccessLevelSchema,
17+
});
18+
19+
const CoinBalancePermissionSchema = v.object({
20+
...BasePermissionSchema.entries,
21+
$kind: v.literal('CoinBalance'),
22+
coinType: v.string(),
23+
});
24+
25+
const AnyBalancesPermissionSchema = v.object({
26+
...BasePermissionSchema.entries,
27+
$kind: v.literal('AnyBalance'),
28+
});
29+
30+
const AutoApprovalOperationSchema = v.object({
31+
id: v.string(),
32+
description: v.string(),
33+
permissions: v.object({
34+
ownedObjects: v.optional(v.array(ObjectTypePermissionSchema)),
35+
sessionCreatedObjects: v.optional(v.array(ObjectTypePermissionSchema)),
36+
balances: v.optional(v.array(CoinBalancePermissionSchema)),
37+
anyBalance: v.optional(AnyBalancesPermissionSchema),
38+
}),
39+
});
40+
41+
export const AutoApprovalSettingsSchema = v.object({
42+
approvedOperations: v.array(v.string()),
43+
expiration: v.number(),
44+
remainingTransactions: v.nullable(v.number()),
45+
usdBudget: v.nullable(v.number()),
46+
// TODO: normalize coin types
47+
coinBudgets: v.record(v.string(), v.string()),
48+
});
49+
50+
export const AutoApprovalPolicySchema = v.object({
51+
schemaVersion: v.literal('1.0.0'),
52+
origin: v.string(),
53+
network: v.string(),
54+
operations: v.array(AutoApprovalOperationSchema),
55+
suggestedSettings: v.partial(AutoApprovalSettingsSchema),
56+
});
57+
58+
export type AutoApprovalSettings = v.InferOutput<typeof AutoApprovalSettingsSchema>;
59+
export type AutoApprovalPolicy = v.InferOutput<typeof AutoApprovalPolicySchema>;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) Mysten Labs, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import * as v from 'valibot';
5+
import { AutoApprovalPolicySchema, AutoApprovalSettingsSchema } from './policy.js';
6+
7+
export const CreatedObjectSchema = v.object({
8+
objectId: v.string(),
9+
version: v.string(),
10+
digest: v.string(),
11+
objectType: v.string(),
12+
});
13+
14+
export const AutoApprovalStateSchema = v.object({
15+
schemaVersion: v.literal('1.0.0'),
16+
network: v.string(),
17+
origin: v.string(),
18+
policy: AutoApprovalPolicySchema,
19+
settings: v.nullable(AutoApprovalSettingsSchema),
20+
pendingDigests: v.array(v.string()),
21+
createdObjects: v.record(v.string(), CreatedObjectSchema),
22+
});
23+
24+
export type AutoApprovalState = v.InferOutput<typeof AutoApprovalStateSchema>;

packages/wallet-sdk/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Copyright (c) Mysten Labs, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
export * from './transaction-analyzer/index.js';
5+
export * from './auto-approvals/index.js';

0 commit comments

Comments
 (0)